test(e2e): improve flakiness and performance of file reset (#23337)

This commit is contained in:
Ben Irvin 2025-04-10 16:56:16 +02:00 committed by GitHub
parent 76977370da
commit df1dc7b498
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
13 changed files with 505 additions and 581 deletions

View File

@ -212,17 +212,13 @@ jobs:
shard: [1/4, 2/4, 3/4, 4/4]
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
- name: Monorepo install
uses: ./.github/actions/yarn-nm-install
- name: Install Playwright Browsers
run: npx playwright install --with-deps
- name: Monorepo build
uses: ./.github/actions/run-build
@ -277,7 +273,6 @@ jobs:
- name: Monorepo build
uses: ./.github/actions/run-build
- name: Run [EE] E2E tests
uses: ./.github/actions/run-e2e-tests
with:
@ -439,7 +434,7 @@ jobs:
strategy:
matrix:
node: [20, 22]
shard: [1/4, 2/4, 3/4, 4/4]
shard: [1/5, 2/5, 3/5, 4/5, 5/5]
services:
postgres:
image: postgres
@ -482,7 +477,7 @@ jobs:
strategy:
matrix:
node: [20, 22]
shard: [1/4, 2/4, 3/4, 4/4]
shard: [1/5, 2/5, 3/5, 4/5, 5/5]
services:
mysql:
image: bitnami/mysql:latest

View File

@ -56,7 +56,7 @@
"setup": "yarn && yarn clean && yarn build --skip-nx-cache",
"test:api": "node tests/scripts/run-api-tests.js",
"test:api:clean": "rimraf ./coverage",
"test:clean": "yarn test:api:clean ; yarn test:e2e:clean ; yarn test:cli:clean",
"test:clean": "run-s -c test:api:clean test:e2e:clean test:cli:clean",
"test:cli": "node tests/scripts/run-cli-tests.js",
"test:cli:clean": "node tests/scripts/run-cli-tests.js clean",
"test:cli:debug": "node tests/scripts/run-cli-tests.js --debug",

View File

@ -1,5 +1,11 @@
import { test, expect } from '@playwright/test';
import { clickAndWait, dragElementAbove, findAndClose, isElementBefore } from '../../utils/shared';
import {
clickAndWait,
dragElementAbove,
findAndClose,
isElementBefore,
navToHeader,
} from '../../utils/shared';
import { createContent, FieldValue, verifyFields } from '../../utils/content-creation';
import { resetDatabaseAndImportDataFromPath } from '../../utils/dts-import';
import { login } from '../../utils/login';
@ -10,8 +16,7 @@ test.describe('Adding content', () => {
await page.goto('/admin');
await login({ page });
// Navigate to Content Manager
await clickAndWait(page, page.getByRole('link', { name: 'Content Manager' }));
await navToHeader(page, ['Content Manager'], 'Content Manager');
});
test('I want to be able to save and publish content', async ({ page }) => {

View File

@ -6,7 +6,7 @@ import { clickAndWait } from '../../../utils/shared';
test.describe('Create collection type with all field types', () => {
// very long timeout for these tests because they restart the server multiple times
test.describe.configure({ timeout: 300000 });
test.describe.configure({ timeout: 500000 });
test.beforeEach(async ({ page }) => {
await sharedSetup('ctb-edit-ct', page, {
@ -19,8 +19,6 @@ test.describe('Create collection type with all field types', () => {
await clickAndWait(page, page.getByRole('link', { name: 'Content-Type Builder' }));
});
// TODO: each test should have a beforeAll that does this, maybe combine all the setup into one util to simplify it
// to keep other suites that don't modify files from needing to reset files, clean up after ourselves at the end
test.afterAll(async () => {
await resetFiles();
});

View File

@ -6,7 +6,7 @@ import { sharedSetup } from '../../../utils/setup';
test.describe('Edit collection type', () => {
// very long timeout for these tests because they restart the server multiple times
test.describe.configure({ timeout: 300000 });
test.describe.configure({ timeout: 500000 });
// use existing type to avoid extra resets and flakiness
const ctName = 'Article';
@ -23,9 +23,7 @@ test.describe('Edit collection type', () => {
await navToHeader(page, ['Content-Type Builder', ctName], ctName);
});
// TODO: each test should have a beforeAll that does this, maybe combine all the setup into one util to simplify it
// to keep other suites that don't modify files from needing to reset files, clean up after ourselves at the end
test.afterEach(async () => {
test.afterAll(async () => {
await resetFiles();
});

View File

@ -5,7 +5,7 @@ import { createComponent, type AddAttribute } from '../../../utils/content-types
test.describe('Create a new component', () => {
// very long timeout for these tests because they restart the server multiple times
test.describe.configure({ timeout: 300000 });
test.describe.configure({ timeout: 500000 });
test.beforeEach(async ({ page }) => {
await sharedSetup('create-component', page, {

View File

@ -14,7 +14,7 @@ import { navToHeader } from '../../../utils/shared';
test.describe('Update a new component', () => {
// very long timeout for these tests because they restart the server multiple times
test.describe.configure({ timeout: 300000 });
test.describe.configure({ timeout: 500000 });
const originalAttributes = [{ type: 'text', name: 'testtext' }] satisfies AddAttribute[];

View File

@ -10,7 +10,7 @@ import { clickAndWait } from '../../../utils/shared';
test.describe('Create single type with all field types', () => {
// very long timeout for these tests because they restart the server multiple times
test.describe.configure({ timeout: 300000 });
test.describe.configure({ timeout: 500000 });
test.beforeEach(async ({ page }) => {
await sharedSetup('ctb-edit-st', page, {
@ -23,8 +23,6 @@ test.describe('Create single type with all field types', () => {
await clickAndWait(page, page.getByRole('link', { name: 'Content-Type Builder' }));
});
// TODO: each test should have a beforeAll that does this, maybe combine all the setup into one util to simplify it
// to keep other suites that don't modify files from needing to reset files, clean up after ourselves at the end
test.afterAll(async () => {
await resetFiles();
});

View File

@ -6,7 +6,7 @@ import { sharedSetup } from '../../../utils/setup';
test.describe('Edit single type', () => {
// very long timeout for these tests because they restart the server multiple times
test.describe.configure({ timeout: 300000 });
test.describe.configure({ timeout: 500000 });
// Use the existing single-type from our test data
const ctName = 'Homepage';
@ -23,9 +23,7 @@ test.describe('Edit single type', () => {
await navToHeader(page, ['Content-Type Builder', ctName], ctName);
});
// TODO: each test should have a beforeAll that does this, maybe combine all the setup into one util to simplify it
// to keep other suites that don't modify files from needing to reset files, clean up after ourselves at the end
test.afterEach(async () => {
test.afterAll(async () => {
await resetFiles();
});

View File

@ -0,0 +1,244 @@
import { test, expect } from '@playwright/test';
import { login } from '../../utils/login';
import { clickAndWait, findAndClose, navToHeader } from '../../utils/shared';
import { waitForRestart } from '../../utils/restart';
import { resetFiles } from '../../utils/file-reset';
import { resetDatabaseAndImportDataFromPath } from '../../utils/dts-import';
test.describe('Create and Edit Operations', () => {
test.describe.configure({ timeout: 500000 });
test.beforeEach(async ({ page }) => {
await resetDatabaseAndImportDataFromPath('with-admin.tar');
await page.goto('/admin');
await login({ page });
});
test.afterAll(async () => {
await resetFiles();
});
test('As a user I want to create a brand new document in the non-default locale', async ({
page,
}) => {
const LIST_URL = /\/admin\/content-manager\/collection-types\/api::product.product(\?.*)?/;
const EDIT_URL =
/\/admin\/content-manager\/collection-types\/api::product.product\/[^/]+(\?.*)?/;
const CREATE_URL =
/\/admin\/content-manager\/collection-types\/api::product.product\/create(\?.*)?/;
/**
* Navigate to our products list-view
*/
await navToHeader(page, ['Content Manager', 'Products'], 'Products');
await expect(page.getByRole('heading', { name: 'Products' })).toBeVisible();
/**
* Assert we're on the english locale and our document exists
*/
await expect(page.getByRole('combobox', { name: 'Select a locale' })).toHaveText(
'English (en)'
);
await expect(
page.getByRole('row', { name: 'Nike Mens 23/24 Away Stadium Jersey' })
).toBeVisible();
/**
* Swap to es locale to create a new document
*/
await page.getByRole('combobox', { name: 'Select a locale' }).click();
await page.getByRole('option', { name: 'Spanish (es)' }).click();
await expect(page.getByRole('combobox', { name: 'Select a locale' })).toHaveText(
'Spanish (es)'
);
await expect(page.getByRole('row', { name: 'No content found' })).toBeVisible();
/**
* So now we're going to create a document.
*/
await page.getByRole('link', { name: 'Create new entry' }).first().click();
await page.waitForURL(CREATE_URL);
await expect(page.getByRole('heading', { name: 'Create an entry' })).toBeVisible();
await expect(page.getByRole('combobox', { name: 'Locales' })).toHaveText('Spanish (es)');
expect(new URL(page.url()).searchParams.get('plugins[i18n][locale]')).toEqual('es');
await page
.getByRole('textbox', { name: 'name' })
.fill('Camiseta de fuera 23/24 de Nike para hombres');
/**
* Verify the UID works as expected
*/
await expect
.poll(async () => {
const requestPromise = page.waitForRequest('**/content-manager/uid/generate?locale=es');
await page.getByRole('button', { name: 'Regenerate' }).click();
const req = await requestPromise;
return req.postDataJSON();
})
.toMatchObject({
contentTypeUID: 'api::product.product',
data: {
id: '',
isAvailable: true,
name: 'Camiseta de fuera 23/24 de Nike para hombres',
slug: 'product',
},
field: 'slug',
});
await expect(page.getByRole('textbox', { name: 'slug' })).toHaveValue(
'camiseta-de-fuera-23-24-de-nike-para-hombres'
);
/**
* Publish the document
*/
await page.getByRole('button', { name: 'Publish' }).click();
await findAndClose(page, 'Success:Published');
/**
* Now we'll go back to the list view to ensure the content has been updated
*/
await page.getByRole('link', { name: 'Products' }).click();
await page.waitForURL(LIST_URL);
await expect(page.getByRole('heading', { name: 'Products' })).toBeVisible();
await expect(page.getByRole('combobox', { name: 'Select a locale' })).toHaveText(
'Spanish (es)'
);
await expect(
page.getByRole('row', { name: 'Camiseta de fuera 23/24 de Nike para hombres' })
).toBeVisible();
/**
* Now we'll go back to the edit view to swap back to the en locale to ensure
* these updates were made on a different document
*/
await page.getByRole('row', { name: 'Camiseta de fuera 23/24 de Nike para hombres' }).click();
await page.waitForURL(EDIT_URL);
await expect(
page.getByRole('heading', { name: 'Camiseta de fuera 23/24 de Nike para hombres' })
).toBeVisible();
await page.getByRole('combobox', { name: 'Locales' }).click();
await page.getByRole('option', { name: 'English (en)' }).click();
await expect(page.getByRole('heading', { name: 'Untitled' })).toBeVisible();
});
test('As a user I want to add a locale entry to an existing document', async ({
browser,
page,
}) => {
const LIST_URL = /\/admin\/content-manager\/collection-types\/api::product.product(\?.*)?/;
const EDIT_URL =
/\/admin\/content-manager\/collection-types\/api::product.product\/[^/]+(\?.*)?/;
/**
* Navigate to our products list-view where there will be one document already made in the `en` locale
*/
await navToHeader(page, ['Content Manager', 'Products'], 'Products');
await expect(page.getByRole('heading', { name: 'Products' })).toBeVisible();
/**
* Assert we're on the english locale and our document exists
*/
await expect(page.getByRole('combobox', { name: 'Select a locale' })).toHaveText(
'English (en)'
);
await expect(
page.getByRole('row', { name: 'Nike Mens 23/24 Away Stadium Jersey' })
).toBeVisible();
await page.getByRole('row', { name: 'Nike Mens 23/24 Away Stadium Jersey' }).click();
/**
* Assert we're on the edit view for the document
*/
await page.waitForURL(EDIT_URL);
await expect(
page.getByRole('heading', { name: 'Nike Mens 23/24 Away Stadium Jersey' })
).toBeVisible();
await page.getByRole('combobox', { name: 'Locales' }).click();
await page.getByRole('option', { name: 'Spanish (es)' }).click();
/**
* Now we should be on a new document in the `es` locale
*/
expect(new URL(page.url()).searchParams.get('plugins[i18n][locale]')).toEqual('es');
await expect(page.getByRole('heading', { name: 'Untitled' })).toBeVisible();
/**
* This is here because the `fill` method below doesn't immediately update the value
* in webkit.
*/
if (browser.browserType().name() === 'webkit') {
await page.getByRole('textbox', { name: 'name' }).press('s');
await page.getByRole('textbox', { name: 'name' }).press('Delete');
}
await page
.getByRole('textbox', { name: 'name' })
.fill('Camiseta de fuera 23/24 de Nike para hombres');
/**
* Verify the UID works as expected due to issues with webkit above,
* this has been kept.
*/
await expect
.poll(
async () => {
const requestPromise = page.waitForRequest('**/content-manager/uid/generate?locale=es');
await page.getByRole('button', { name: 'Regenerate' }).click();
const body = (await requestPromise).postDataJSON();
return body;
},
{
intervals: [1000, 2000, 4000, 8000],
}
)
.toMatchObject({
contentTypeUID: 'api::product.product',
data: {
id: expect.any(String),
name: 'Camiseta de fuera 23/24 de Nike para hombres',
slug: 'product',
},
field: 'slug',
});
await expect(page.getByRole('textbox', { name: 'slug' })).toHaveValue(
'camiseta-de-fuera-23-24-de-nike-para-hombres'
);
/**
* Publish the document
*/
await page.getByRole('button', { name: 'Publish' }).click();
await findAndClose(page, 'Success:Published');
/**
* Now we'll go back to the list view to ensure the content has been updated
*/
await page.getByRole('link', { name: 'Products' }).click();
await page.waitForURL(LIST_URL);
await expect(page.getByRole('heading', { name: 'Products' })).toBeVisible();
await expect(page.getByRole('combobox', { name: 'Select a locale' })).toHaveText(
'Spanish (es)'
);
await expect(
page.getByRole('row', { name: 'Camiseta de fuera 23/24 de Nike para hombres' })
).toBeVisible();
/**
* Now we'll go back to the edit view to swap back to the en locale to ensure
* these updates were made on the same document
*/
await page.getByRole('row', { name: 'Camiseta de fuera 23/24 de Nike para hombres' }).click();
await page.waitForURL(EDIT_URL);
await expect(
page.getByRole('heading', { name: 'Camiseta de fuera 23/24 de Nike para hombres' })
).toBeVisible();
await page.getByRole('combobox', { name: 'Locales' }).click();
await page.getByRole('option', { name: 'English (en)' }).click();
await expect(
page.getByRole('heading', { name: 'Nike Mens 23/24 Away Stadium Jersey' })
).toBeVisible();
});
});

View File

@ -3,13 +3,11 @@ import { test, expect } from '@playwright/test';
import { EDITOR_EMAIL_ADDRESS, EDITOR_PASSWORD } from '../../constants';
import { resetDatabaseAndImportDataFromPath } from '../../utils/dts-import';
import { login } from '../../utils/login';
import { clickAndWait, findAndClose } from '../../utils/shared';
import { clickAndWait, findAndClose, navToHeader } from '../../utils/shared';
import { waitForRestart } from '../../utils/restart';
test.describe('Edit view', () => {
// TODO: split this into multiple tests
// give additional time because this test file is so large
test.describe.configure({ timeout: 300000 });
test.describe.configure({ timeout: 500000 });
test.beforeEach(async ({ page }) => {
await resetDatabaseAndImportDataFromPath('with-admin.tar');
@ -17,370 +15,11 @@ test.describe('Edit view', () => {
await login({ page });
});
test('As a user I want to create a brand new document in the non-default locale', async ({
page,
}) => {
const LIST_URL = /\/admin\/content-manager\/collection-types\/api::product.product(\?.*)?/;
const EDIT_URL =
/\/admin\/content-manager\/collection-types\/api::product.product\/[^/]+(\?.*)?/;
const CREATE_URL =
/\/admin\/content-manager\/collection-types\/api::product.product\/create(\?.*)?/;
/**
* Navigate to our products list-view
*/
await page.getByRole('link', { name: 'Content Manager' }).click();
await page.getByRole('link', { name: 'Products' }).click();
await page.waitForURL(LIST_URL);
await expect(page.getByRole('heading', { name: 'Products' })).toBeVisible();
/**
* Assert we're on the english locale and our document exists
*/
await expect(page.getByRole('combobox', { name: 'Select a locale' })).toHaveText(
'English (en)'
);
await expect(
page.getByRole('row', { name: 'Nike Mens 23/24 Away Stadium Jersey' })
).toBeVisible();
/**
* Swap to es locale to create a new document
*/
await page.getByRole('combobox', { name: 'Select a locale' }).click();
await page.getByRole('option', { name: 'Spanish (es)' }).click();
await expect(page.getByRole('combobox', { name: 'Select a locale' })).toHaveText(
'Spanish (es)'
);
await expect(page.getByRole('row', { name: 'No content found' })).toBeVisible();
/**
* So now we're going to create a document.
*/
await page.getByRole('link', { name: 'Create new entry' }).first().click();
await page.waitForURL(CREATE_URL);
await expect(page.getByRole('heading', { name: 'Create an entry' })).toBeVisible();
await expect(page.getByRole('combobox', { name: 'Locales' })).toHaveText('Spanish (es)');
expect(new URL(page.url()).searchParams.get('plugins[i18n][locale]')).toEqual('es');
await page
.getByRole('textbox', { name: 'name' })
.fill('Camiseta de fuera 23/24 de Nike para hombres');
/**
* Verify the UID works as expected
*/
await expect
.poll(async () => {
const requestPromise = page.waitForRequest('**/content-manager/uid/generate?locale=es');
await page.getByRole('button', { name: 'Regenerate' }).click();
const req = await requestPromise;
return req.postDataJSON();
})
.toMatchObject({
contentTypeUID: 'api::product.product',
data: {
id: '',
isAvailable: true,
name: 'Camiseta de fuera 23/24 de Nike para hombres',
slug: 'product',
},
field: 'slug',
});
await expect(page.getByRole('textbox', { name: 'slug' })).toHaveValue(
'camiseta-de-fuera-23-24-de-nike-para-hombres'
);
/**
* Publish the document
*/
await page.getByRole('button', { name: 'Publish' }).click();
await findAndClose(page, 'Success:Published');
/**
* Now we'll go back to the list view to ensure the content has been updated
*/
await page.getByRole('link', { name: 'Products' }).click();
await page.waitForURL(LIST_URL);
await expect(page.getByRole('heading', { name: 'Products' })).toBeVisible();
await expect(page.getByRole('combobox', { name: 'Select a locale' })).toHaveText(
'Spanish (es)'
);
await expect(
page.getByRole('row', { name: 'Camiseta de fuera 23/24 de Nike para hombres' })
).toBeVisible();
/**
* Now we'll go back to the edit view to swap back to the en locale to ensure
* these updates were made on a different document
*/
await page.getByRole('row', { name: 'Camiseta de fuera 23/24 de Nike para hombres' }).click();
await page.waitForURL(EDIT_URL);
await expect(
page.getByRole('heading', { name: 'Camiseta de fuera 23/24 de Nike para hombres' })
).toBeVisible();
await page.getByRole('combobox', { name: 'Locales' }).click();
await page.getByRole('option', { name: 'English (en)' }).click();
await expect(page.getByRole('heading', { name: 'Untitled' })).toBeVisible();
});
test('As a user I want to add a locale entry to an existing document', async ({
browser,
page,
}) => {
const LIST_URL = /\/admin\/content-manager\/collection-types\/api::product.product(\?.*)?/;
const EDIT_URL =
/\/admin\/content-manager\/collection-types\/api::product.product\/[^/]+(\?.*)?/;
/**
* Navigate to our products list-view where there will be one document already made in the `en` locale
*/
await page.getByRole('link', { name: 'Content Manager' }).click();
await page.getByRole('link', { name: 'Products' }).click();
await page.waitForURL(LIST_URL);
await expect(page.getByRole('heading', { name: 'Products' })).toBeVisible();
/**
* Assert we're on the english locale and our document exists
*/
await expect(page.getByRole('combobox', { name: 'Select a locale' })).toHaveText(
'English (en)'
);
await expect(
page.getByRole('row', { name: 'Nike Mens 23/24 Away Stadium Jersey' })
).toBeVisible();
await page.getByRole('row', { name: 'Nike Mens 23/24 Away Stadium Jersey' }).click();
/**
* Assert we're on the edit view for the document
*/
await page.waitForURL(EDIT_URL);
await expect(
page.getByRole('heading', { name: 'Nike Mens 23/24 Away Stadium Jersey' })
).toBeVisible();
await page.getByRole('combobox', { name: 'Locales' }).click();
await page.getByRole('option', { name: 'Spanish (es)' }).click();
/**
* Now we should be on a new document in the `es` locale
*/
expect(new URL(page.url()).searchParams.get('plugins[i18n][locale]')).toEqual('es');
await expect(page.getByRole('heading', { name: 'Untitled' })).toBeVisible();
/**
* This is here because the `fill` method below doesn't immediately update the value
* in webkit.
*/
if (browser.browserType().name() === 'webkit') {
await page.getByRole('textbox', { name: 'name' }).press('s');
await page.getByRole('textbox', { name: 'name' }).press('Delete');
}
await page
.getByRole('textbox', { name: 'name' })
.fill('Camiseta de fuera 23/24 de Nike para hombres');
/**
* Verify the UID works as expected due to issues with webkit above,
* this has been kept.
*/
await expect
.poll(
async () => {
const requestPromise = page.waitForRequest('**/content-manager/uid/generate?locale=es');
await page.getByRole('button', { name: 'Regenerate' }).click();
const body = (await requestPromise).postDataJSON();
return body;
},
{
intervals: [1000, 2000, 4000, 8000],
}
)
.toMatchObject({
contentTypeUID: 'api::product.product',
data: {
id: expect.any(String),
name: 'Camiseta de fuera 23/24 de Nike para hombres',
slug: 'product',
},
field: 'slug',
});
await expect(page.getByRole('textbox', { name: 'slug' })).toHaveValue(
'camiseta-de-fuera-23-24-de-nike-para-hombres'
);
/**
* Publish the document
*/
await page.getByRole('button', { name: 'Publish' }).click();
await findAndClose(page, 'Success:Published');
/**
* Now we'll go back to the list view to ensure the content has been updated
*/
await page.getByRole('link', { name: 'Products' }).click();
await page.waitForURL(LIST_URL);
await expect(page.getByRole('heading', { name: 'Products' })).toBeVisible();
await expect(page.getByRole('combobox', { name: 'Select a locale' })).toHaveText(
'Spanish (es)'
);
await expect(
page.getByRole('row', { name: 'Camiseta de fuera 23/24 de Nike para hombres' })
).toBeVisible();
/**
* Now we'll go back to the edit view to swap back to the en locale to ensure
* these updates were made on the same document
*/
await page.getByRole('row', { name: 'Camiseta de fuera 23/24 de Nike para hombres' }).click();
await page.waitForURL(EDIT_URL);
await expect(
page.getByRole('heading', { name: 'Camiseta de fuera 23/24 de Nike para hombres' })
).toBeVisible();
await page.getByRole('combobox', { name: 'Locales' }).click();
await page.getByRole('option', { name: 'English (en)' }).click();
await expect(
page.getByRole('heading', { name: 'Nike Mens 23/24 Away Stadium Jersey' })
).toBeVisible();
});
test("As a user I should not be able to create a document in a locale I don't have permissions for", async ({
page,
}) => {
const LIST_URL = /\/admin\/content-manager\/collection-types\/api::article.article(\?.*)?/;
/**
* Navigate to settings and roles & modify editor permissions
*/
await page.getByRole('link', { name: 'Settings', exact: true }).click();
await page.getByRole('link', { name: 'Roles' }).first().click();
await page.getByRole('gridcell', { name: 'Editor', exact: true }).click();
/**
* Set permissions for English (en) locale
*/
await page.getByRole('button', { name: 'Article' }).click();
await page.getByLabel('Select all English (en)').check();
/**
* Set permissions for French (fr) locale. Editors can now do everything BUT
* create french content
*/
await page.getByLabel('Select all French (fr)').check();
await page.getByLabel('Select fr Create permission').uncheck();
// Scroll to the top of the page before clicking save
// TODO: Fix the need to scroll to the top before saving. z-index of layout
// header is behind the permissions component.
await page.evaluate(() => window.scrollTo(0, 0));
await page.getByRole('button', { name: 'Save' }).click();
await findAndClose(page, 'Success:Saved');
/**
* Logout and login as editor
*/
await page.getByRole('button', { name: 'tt test testing' }).click();
await page.getByRole('menuitem', { name: 'Log out' }).click();
await login({ page, username: EDITOR_EMAIL_ADDRESS, password: EDITOR_PASSWORD });
/**
* Verify permissions
*/
await page.getByRole('link', { name: 'Content Manager' }).click();
await page.waitForURL(LIST_URL);
await expect(page.getByText('English (en)', { exact: true })).toBeVisible();
/**
* Verify we can create a new entry in the english locale as expected
*/
await page.getByRole('link', { name: 'Create new entry' }).click();
await page.getByLabel('title').fill('the richmond way');
await page.getByRole('button', { name: 'Save' }).click();
await findAndClose(page, 'Success:Saved');
/**
* Verify we cannot create a new entry in the french locale as editors do
* not have the right permissions
*/
await page.getByLabel('Locales').click();
await expect(page.getByLabel('Create French (fr) locale')).toBeDisabled();
});
test('As a user I should be able to delete a locale of a single type and collection type', async ({
page,
}) => {
const LIST_URL = /\/admin\/content-manager\/collection-types\/api::article.article(\?.*)?/;
const HOMEPAGE_LIST_URL =
/\/admin\/content-manager\/single-types\/api::homepage.homepage(\?.*)?/;
/**
* Navigate to our articles list-view and create a new entry
*/
await page.getByRole('link', { name: 'Content Manager' }).click();
await page.waitForURL(LIST_URL);
await page.getByRole('link', { name: 'Create new entry' }).click();
await page.getByLabel('title').fill('trent crimm');
await page.getByRole('button', { name: 'Save' }).click();
await findAndClose(page, 'Success:Saved');
/**
* Create a Spanish (es) locale for the entry
*/
await page.getByLabel('Locales').click();
await page.getByRole('option', { name: 'Spanish (es)' }).click();
await page.getByLabel('title').fill('dani rojas');
await page.getByRole('button', { name: 'Save' }).click();
await findAndClose(page, 'Success:Saved');
/**
* Delete the Spanish (es) locale entry
*/
await page.getByRole('button', { name: 'More actions' }).click();
await page.getByRole('menuitem', { name: 'Delete entry (Spanish (es))' }).click();
await page.getByRole('button', { name: 'Confirm' }).click();
await findAndClose(page, 'Success:Deleted');
/**
* Navigate to our homepage single-type and create a new entry
*/
await page.getByRole('link', { name: 'Content Manager' }).click();
await page.getByRole('link', { name: 'Homepage' }).click();
await page.waitForURL(HOMEPAGE_LIST_URL);
await page.getByLabel('title').fill('football is life');
await page.getByRole('button', { name: 'Save' }).click();
await findAndClose(page, 'Success:Saved');
/**
* Create a Spanish (es) locale for the homepage entry
*/
await page.getByLabel('Locales').click();
await page.getByRole('option', { name: 'Spanish (es)' }).click();
await page.getByLabel('title').fill('el fútbol también es muerte.');
await page.getByRole('button', { name: 'Save' }).click();
await findAndClose(page, 'Success:Saved');
/**
* Delete the Spanish (es) locale homepage entry
*/
await page.getByRole('button', { name: 'More actions' }).click();
await page.getByRole('menuitem', { name: 'Delete entry (Spanish (es))' }).click();
await page.getByRole('button', { name: 'Confirm' }).click();
await findAndClose(page, 'Success:Deleted');
});
test('As a user I want to publish multiple locales of my document', async ({ page, browser }) => {
const LIST_URL = /\/admin\/content-manager\/collection-types\/api::article.article(\?.*)?/;
const EDIT_URL =
/\/admin\/content-manager\/collection-types\/api::article.article\/[^/]+(\?.*)?/;
/**
* Navigate to our articles list-view where there will be one document already made in the `en` locale
*/
await page.getByRole('link', { name: 'Content Manager' }).click();
await page.getByRole('link', { name: 'Article' }).click();
await page.waitForURL(LIST_URL);
await expect(page.getByRole('heading', { name: 'Article' })).toBeVisible();
await navToHeader(page, ['Content Manager', 'Article'], 'Article');
/**
* Assert we're on the english locale and our document exists
@ -396,7 +35,6 @@ test.describe('Edit view', () => {
/**
* Create a new spanish draft article
*/
await page.waitForURL(EDIT_URL);
await expect(
page.getByRole('heading', { name: 'Why I prefer football over soccer' })
).toBeVisible();
@ -456,17 +94,10 @@ test.describe('Edit view', () => {
page,
browser,
}) => {
const LIST_URL = /\/admin\/content-manager\/collection-types\/api::article.article(\?.*)?/;
const EDIT_URL =
/\/admin\/content-manager\/collection-types\/api::article.article\/[^/]+(\?.*)?/;
/**
* Navigate to our articles list-view where there will be one document already made in the `en` locale
*/
await page.getByRole('link', { name: 'Content Manager' }).click();
await page.getByRole('link', { name: 'Article' }).click();
await page.waitForURL(LIST_URL);
await expect(page.getByRole('heading', { name: 'Article' })).toBeVisible();
await navToHeader(page, ['Content Manager', 'Article'], 'Article');
/**
* Assert we're on the english locale and our document exists
@ -482,7 +113,6 @@ test.describe('Edit view', () => {
/**
* Create a new spanish draft article
*/
await page.waitForURL(EDIT_URL);
await expect(
page.getByRole('heading', { name: 'Why I prefer football over soccer' })
).toBeVisible();
@ -553,184 +183,4 @@ test.describe('Edit view', () => {
page.getByLabel('Unpublish multiple locales').getByRole('button', { name: 'Unpublish' })
).toBeDisabled();
});
interface ValidationType {
field: string;
initialValue: string;
expectedError: string;
ctbParams: {
key: string;
operation: {
type: 'click' | 'fill';
value?: string;
};
};
}
const typesOfValidation: Record<string, ValidationType> = {
required: {
field: 'title',
initialValue: '',
ctbParams: {
key: 'Required Field',
operation: {
type: 'click',
},
},
expectedError: 'This value is required.',
},
maxLength: {
field: 'title',
initialValue: 'a'.repeat(256),
ctbParams: {
key: 'Maximum Length',
operation: {
type: 'fill',
value: '255',
},
},
expectedError: 'The value is too long',
},
// TODO schema changes from previous runs persist which means each new
// validation must take into account the previous one.
minLength: {
field: 'title',
initialValue: 'a'.repeat(10),
ctbParams: {
key: 'Minimum Length',
operation: {
type: 'fill',
value: '11',
},
},
expectedError: 'The value is too short',
},
};
for (const [type, validationParams] of Object.entries(typesOfValidation)) {
test(`As a user I want to see the relevant error message when trying to publish a draft that fails ${type} validation`, async ({
browser,
page,
}) => {
const LIST_URL = /\/admin\/content-manager\/collection-types\/api::article.article(\?.*)?/;
const EDIT_URL =
/\/admin\/content-manager\/collection-types\/api::article.article\/[^/]+(\?.*)?/;
const {
field,
initialValue,
ctbParams: { key: ctbKey, operation: ctbOperation },
expectedError,
} = validationParams;
/**
* Navigate to our articles list-view where there will be one document already made in the `en` locale
*/
await page.getByRole('link', { name: 'Content Manager' }).click();
await clickAndWait(page, page.getByRole('link', { name: 'Article' }));
await page.waitForURL(LIST_URL);
await expect(page.getByRole('heading', { name: 'Article' })).toBeVisible();
/**
* Assert we're on the english locale and our document exists
*/
await expect(page.getByRole('combobox', { name: 'Select a locale' })).toHaveText(
'English (en)'
);
await expect(
page.getByRole('row', { name: 'Why I prefer football over soccer' })
).toBeVisible();
await page.getByText('why-i-prefer-football-over-').click();
await page.waitForURL(EDIT_URL);
/**
* This is here because the `fill` method below doesn't immediately update the value
* in webkit.
*/
if (browser.browserType().name() === 'webkit') {
await page.getByRole('textbox', { name: 'title' }).press('s');
await page.getByRole('textbox', { name: 'title' }).press('Delete');
}
/**
* Fill the target field with the initial value, there is currently no
* validation on this field
*/
await page.getByRole('textbox', { name: field }).fill(initialValue);
await page.getByRole('button', { name: 'Save' }).click();
await findAndClose(page, 'Success:Saved');
/**
* Navigate to the CTB and modify the schema of the article to apply the
* validation constraints to the field
*/
await page.getByRole('link', { name: 'Content-Type Builder' }).click();
await page.getByRole('button', { name: 'Close' }).click(); // TODO improve this
/**
* Edit the field and apply the validation constraint
*/
await page.getByRole('link', { name: 'Article' }).click();
await page
.getByRole('button', { name: `Edit ${field}` })
.first()
.click();
await page.getByRole('tab', { name: 'Advanced settings' }).click();
const ctbOperatonType = ctbOperation?.type ?? '';
switch (ctbOperatonType) {
case 'click':
await page.getByLabel(ctbKey).first().click();
break;
case 'fill': {
if (!ctbOperation?.value) {
throw new Error('CTB operation value is required');
}
await page.getByLabel(ctbKey).first().click();
await page.getByRole('textbox', { name: ctbKey }).fill(ctbOperation.value);
break;
}
default:
throw new Error(`Unsupported CTB operation type ${ctbOperatonType} provided`);
}
await page.getByRole('button', { name: 'Finish' }).click();
await page.getByRole('button', { name: 'Save' }).click();
/**
* Wait for the server to restart
*/
await waitForRestart(page);
/**
* Navigate back to the article we just modified
*/
await page.getByRole('link', { name: 'Content Manager' }).click();
await page.getByRole('link', { name: 'Article' }).click();
await page.waitForURL(LIST_URL);
await expect(page.getByRole('heading', { name: 'Article' })).toBeVisible();
await expect(page.getByRole('combobox', { name: 'Select a locale' })).toHaveText(
'English (en)'
);
/**
* Attempt to publish through the 'Publish multiple locales' button
*/
await page.getByText('why-i-prefer-football-over-').click();
await page.getByRole('button', { name: 'More document actions' }).click();
await page.getByRole('menuitem', { name: 'Publish multiple locales', exact: true }).click();
/**
* We have modified the content and then modifed the schema in a way that
* is incompatible. Therefore we should expect the relevant error message
* to be displayed.
*/
await expect(page.getByText('1 entry waiting for action')).toBeVisible();
await expect(
page.getByLabel('Publish multiple locales').getByText(`${field}: ${expectedError}`)
).toBeVisible();
});
}
});

View File

@ -0,0 +1,139 @@
import { test, expect } from '@playwright/test';
import { EDITOR_EMAIL_ADDRESS, EDITOR_PASSWORD } from '../../constants';
import { resetDatabaseAndImportDataFromPath } from '../../utils/dts-import';
import { login } from '../../utils/login';
import { clickAndWait, findAndClose, navToHeader } from '../../utils/shared';
import { waitForRestart } from '../../utils/restart';
import { resetFiles } from '../../utils/file-reset';
test.describe('Locale Permissions', () => {
test.describe.configure({ timeout: 500000 });
test.beforeEach(async ({ page }) => {
await resetDatabaseAndImportDataFromPath('with-admin.tar');
await page.goto('/admin');
await login({ page });
});
test.afterAll(async () => {
await resetFiles();
});
test("As a user I should not be able to create a document in a locale I don't have permissions for", async ({
page,
}) => {
/**
* Navigate to settings and roles & modify editor permissions
*/
await navToHeader(page, ['Settings', ['Administration Panel', 'Roles']], 'Roles');
await expect(page.getByRole('gridcell', { name: 'Editor', exact: true })).toBeVisible();
await page.getByRole('gridcell', { name: 'Editor', exact: true }).click();
await expect(page.getByRole('heading', { name: 'Edit a role' })).toBeVisible();
/**
* Set permissions for English (en) locale
*/
await clickAndWait(page, page.getByRole('button', { name: 'Article' }));
await page.getByLabel('Select all English (en)').check();
/**
* Set permissions for French (fr) locale. Editors can now do everything BUT
* create french content
*/
await page.getByLabel('Select all French (fr)').check();
await page.getByLabel('Select fr Create permission').uncheck();
// Scroll to the top of the page before clicking save
// TODO: Fix the need to scroll to the top before saving. z-index of layout
// header is behind the permissions component.
await page.evaluate(() => window.scrollTo(0, 0));
await clickAndWait(page, page.getByRole('button', { name: 'Save' }));
await findAndClose(page, 'Success:Saved');
/**
* Logout and login as editor
*/
await clickAndWait(page, page.getByRole('button', { name: 'tt test testing' }));
await page.getByRole('menuitem', { name: /^Log(?:out| out)$/i }).click();
await page.waitForURL('/admin/auth/login');
await login({ page, username: EDITOR_EMAIL_ADDRESS, password: EDITOR_PASSWORD });
/**
* Verify permissions
*/
await navToHeader(page, ['Content Manager'], 'Content Manager');
await expect(page.getByText('English (en)', { exact: true })).toBeVisible();
/**
* Verify we can create a new entry in the english locale as expected
*/
await clickAndWait(page, page.getByRole('link', { name: 'Create new entry' }));
await page.getByLabel('title').fill('the richmond way');
await clickAndWait(page, page.getByRole('button', { name: 'Save' }));
await findAndClose(page, 'Success:Saved');
/**
* Verify we cannot create a new entry in the french locale as editors do
* not have the right permissions
*/
await page.getByLabel('Locales').click();
await expect(page.getByLabel('Create French (fr) locale')).toBeDisabled();
});
test('As a user I should be able to delete a locale of a single type and collection type', async ({
page,
}) => {
/**
* Navigate to our articles list-view and create a new entry
*/
await navToHeader(page, ['Content Manager'], 'Content Manager');
await clickAndWait(page, page.getByRole('link', { name: 'Create new entry' }));
await page.getByLabel('title').fill('trent crimm');
await clickAndWait(page, page.getByRole('button', { name: 'Save' }));
await findAndClose(page, 'Success:Saved');
/**
* Create a Spanish (es) locale for the entry
*/
await page.getByLabel('Locales').click();
await page.getByRole('option', { name: 'Spanish (es)' }).click();
await page.getByLabel('title').fill('dani rojas');
await clickAndWait(page, page.getByRole('button', { name: 'Save' }));
await findAndClose(page, 'Success:Saved');
/**
* Delete the Spanish (es) locale entry
*/
await clickAndWait(page, page.getByRole('button', { name: 'More actions' }));
await clickAndWait(page, page.getByRole('menuitem', { name: 'Delete entry (Spanish (es))' }));
await clickAndWait(page, page.getByRole('button', { name: 'Confirm' }));
await findAndClose(page, 'Success:Deleted');
/**
* Navigate to our homepage single-type and create a new entry
*/
await navToHeader(page, ['Content Manager', 'Homepage'], 'Homepage');
await page.getByLabel('title').fill('football is life');
await clickAndWait(page, page.getByRole('button', { name: 'Save' }));
await findAndClose(page, 'Success:Saved');
/**
* Create a Spanish (es) locale for the homepage entry
*/
await page.getByLabel('Locales').click();
await page.getByRole('option', { name: 'Spanish (es)' }).click();
await page.getByLabel('title').fill('el fútbol también es muerte.');
await clickAndWait(page, page.getByRole('button', { name: 'Save' }));
await findAndClose(page, 'Success:Saved');
/**
* Delete the Spanish (es) locale homepage entry
*/
await clickAndWait(page, page.getByRole('button', { name: 'More actions' }));
await clickAndWait(page, page.getByRole('menuitem', { name: 'Delete entry (Spanish (es))' }));
await clickAndWait(page, page.getByRole('button', { name: 'Confirm' }));
await findAndClose(page, 'Success:Deleted');
});
});

View File

@ -245,6 +245,105 @@ module.exports = config
cwd: testAppPath,
});
// We need to generate the typescript and documentation files to avoid re-generating after each file reset
// Start Strapi and wait for it to be ready
console.log(`Starting Strapi for domain '${domain}' to generate files...`);
const strapiProcess = execa('npm', ['run', 'develop', '--', '--no-watch-admin'], {
cwd: testAppPath,
env: {
PORT: port,
STRAPI_DISABLE_EE: !process.env.STRAPI_LICENSE,
},
detached: true, // This is important for CI
});
// Wait for Strapi to be ready by checking HTTP endpoint
await new Promise((resolve, reject) => {
const startTime = Date.now();
const timeout = 160 * 1000; // 160 seconds, matching Playwright's timeout
const checkInterval = 1000; // Check every second
const checkServer = async () => {
try {
const response = await fetch(`http://127.0.0.1:${port}/_health`);
if (response.ok) {
console.log('Strapi is ready, shutting down...');
// In CI, we need to kill the entire process group
if (process.env.CI) {
process.kill(-strapiProcess.pid, 'SIGINT');
} else {
strapiProcess.kill('SIGINT');
}
resolve();
return;
}
} catch (err) {
// Server not ready yet, continue checking
}
if (Date.now() - startTime > timeout) {
console.log('Timeout reached, forcing shutdown...');
if (process.env.CI) {
process.kill(-strapiProcess.pid, 'SIGKILL');
} else {
strapiProcess.kill('SIGKILL');
}
reject(new Error('Strapi failed to start within timeout period'));
return;
}
setTimeout(checkServer, checkInterval);
};
// Start checking
checkServer();
// Log stdout and stderr for debugging
strapiProcess.stdout.on('data', (data) => {
console.log(`[stdout] ${data.toString().trim()}`);
});
strapiProcess.stderr.on('data', (data) => {
console.error(`[stderr] ${data.toString().trim()}`);
});
strapiProcess.on('error', (err) => {
console.error(`[Strapi ERROR] Process error:`, err);
reject(err);
});
strapiProcess.on('exit', (code) => {
console.log(`Strapi process exited with code ${code}`);
});
});
// Double check that Strapi has shut down
await new Promise((resolve) => {
const checkPort = async () => {
try {
await fetch(`http://127.0.0.1:${port}/_health`);
// If we can connect, port is still in use
setTimeout(checkPort, 1000);
} catch (err) {
// Port is free
resolve();
}
};
checkPort();
});
// Commit the generated files
await execa('git', [...gitUser, 'add', '-A', '.'], {
stdio: 'inherit',
cwd: testAppPath,
});
await execa('git', [...gitUser, 'commit', '-m', 'commit generated files'], {
stdio: 'inherit',
cwd: testAppPath,
});
console.log(`Running ${chalk.blue(domain)} e2e tests`);
await execa(