Merge branch 'develop' into fix-scaleway-docs

This commit is contained in:
Christian 2024-03-05 11:12:46 +01:00 committed by GitHub
commit b7ac2016ea
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
14 changed files with 269 additions and 57 deletions

View File

@ -5,6 +5,8 @@ inputs:
description: 'Should run EE or CE e2e tests'
jestOptions:
description: 'Jest options'
enableFutureFeatures:
description: 'Enable future unstable features'
runs:
using: 'composite'
steps:
@ -12,4 +14,5 @@ runs:
env:
RUN_EE: ${{ inputs.runEE }}
JEST_OPTIONS: ${{ inputs.jestOptions }}
STRAPI_FEATURES_FUTURE_RELEASES_SCHEDULING: ${{ inputs.enableFutureFeatures }}
shell: bash

View File

@ -189,7 +189,7 @@ jobs:
if: failure()
with:
name: ce-playwright-trace
path: test-apps/e2e/**/test-results/**/trace.zip
path: test-apps/e2e/test-results/**/trace.zip
retention-days: 1
e2e_ee:
@ -225,13 +225,14 @@ jobs:
uses: ./.github/actions/run-e2e-tests
with:
runEE: true
enableFutureFeatures: true
jestOptions: --project=${{ matrix.project }}
- uses: actions/upload-artifact@v4
if: failure()
with:
name: ee-playwright-trace
path: test-apps/e2e/**/test-results/**/trace.zip
path: test-apps/e2e/test-results/**/trace.zip
retention-days: 1
api_ce_pg:

1
.gitignore vendored
View File

@ -146,6 +146,7 @@ front-workspace.code-workspace
playwright-report
test-results
!e2e/data/*.tar
e2e/.env
############################
# Strapi

View File

@ -29,6 +29,46 @@ This will spawn by default a Strapi instance per testing domain (e.g. content-ma
If you need to clean the test-apps folder because they are not working as expected, you can run `yarn test:e2e clean` which will clean said directory.
### Running specific tests
To run only one domain, meaning a top-level directory in e2e/tests such as "admin" or "content-manager", use the `--domains` option.
```shell
yarn test:e2e --domains admin
yarn test:e2e --domain admin
```
To run a specific file, you can pass arguments and options to playwright using `--` between the test:e2e options and the playwright options, such as:
```shell
# to run just the login.spec.ts file in the admin domain
yarn test:e2e --domains admin -- login.spec.ts
```
### Concurrency / parallellization
By default, every domain is run with its own test app in parallel with the other domains. The tests within a domain are run in series, one at a time.
If you need an easier way to view the output, or have problems running multiple apps at once on your system, you can use the `-c` option
```shell
# only run one domain at a time
yarn test:e2e -c 1
```
### Env Variables to Control Test Config
Some helpers have been added to allow you to modify the playwright configuration on your own system without touching the playwright config file used by the test runner.
| env var | Description | Default |
| ---------------------------- | -------------------------------------------- | ------------------ |
| PLAYWRIGHT_WEBSERVER_TIMEOUT | timeout for starting the Strapi server | 16000 (160s) |
| PLAYWRIGHT_ACTION_TIMEOUT | playwright action timeout (ie, click()) | 15000 (15s) |
| PLAYWRIGHT_EXPECT_TIMEOUT | playwright expect waitFor timeout | 10000 (10s) |
| PLAYWRIGHT_TIMEOUT | playwright timeout, for each individual test | 30000 (30s) |
| PLAYWRIGHT_OUTPUT_DIR | playwright output dir, such as trace files | '../test-results/' |
| PLAYWRIGHT_VIDEO | set 'true' to save videos on failed tests | false |
## Strapi Templates
The test-app you create uses a [template](https://docs.strapi.io/developer-docs/latest/setup-deployment-guides/installation/templates.html) found at `e2e/app-template` in this folder we can store our premade content schemas & any customisations we may need such as other plugins / custom fields / endpoints etc.
@ -41,6 +81,14 @@ Playwright enables reliable end-to-end testing for modern web apps. It's cross b
For more information check out their [docs](https://playwright.dev/docs/intro). If you're struggling with their APIs, then check out their specific [API documentation](https://playwright.dev/docs/api/class-playwright).
## Running tests with environment variables
To set specific environment variables for your tests, a `.env` file can be created in the root of the e2e folder. This is useful if you need to run tests with a Strapi license or set future flags.
## Running tests with future flags
If you are writing tests for an unstable future feature you will need to add `app-template/config/features.js`. Currently the app template generation does not take the config folder into consideration. However, the run-e2e-tests script will apply the features config to the generated app. See the documentation for [features.js](https://docs.strapi.io/dev-docs/configurations/features#enabling-a-future-flag)
## What makes a good end to end test?
This is the million dollar question. E2E tests typically test complete user flows that touch numerous points of the application it's testing, we're not interested in what happens during a process, only the user perspective and end results. Consider writing them with your story hat on. E.g. "As a user I want to create a new entity, publish that entity, and then be able to retrieve its data from the content API".

3
e2e/README.md Normal file
View File

@ -0,0 +1,3 @@
## End-to-end Playwright Tests
See contributor docs in docs/docs/guides/e2e for more info

View File

@ -0,0 +1,5 @@
module.exports = ({ env }) => ({
future: {
contentReleasesScheduling: env.bool('STRAPI_FEATURES_FUTURE_RELEASES_SCHEDULING', false),
},
});

View File

@ -10,13 +10,6 @@ describeOnCondition(edition === 'EE')('Releases page', () => {
await resetDatabaseAndImportDataFromPath('./e2e/data/with-admin.tar');
await page.goto('/admin');
await login({ page });
await page.evaluate(() => {
// Remove after Scheduling Beta release
window.strapi.future = {
isEnabled: () => true,
};
});
});
test('A user should be able to create a release without scheduling it and view their pending and done releases', async ({
@ -70,7 +63,17 @@ describeOnCondition(edition === 'EE')('Releases page', () => {
name: 'Date',
})
.click();
await page.getByRole('gridcell', { name: 'Sunday, March 3, 2024' }).click();
const date = new Date();
date.setDate(date.getDate() + 1);
const formattedDate = date.toLocaleDateString('en-US', {
weekday: 'long',
month: 'long',
day: 'numeric',
year: 'numeric',
});
await page.getByRole('gridcell', { name: formattedDate }).click();
await page
.getByRole('combobox', {

View File

@ -60,6 +60,8 @@ import {
import { useTypedDispatch } from '../store/hooks';
import { getTimezoneOffset } from '../utils/time';
import { getBadgeProps } from './ReleasesPage';
import type {
ReleaseAction,
ReleaseActionGroupBy,
@ -350,7 +352,13 @@ export const ReleaseDetailsLayout = ({
<HeaderLayout
title={release.name}
subtitle={
numberOfEntriesText + (IsSchedulingEnabled && isScheduled ? ` - ${scheduledText}` : '')
<Flex gap={2} lineHeight={6}>
<Typography textColor="neutral600" variant="epsilon">
{numberOfEntriesText +
(IsSchedulingEnabled && isScheduled ? ` - ${scheduledText}` : '')}
</Typography>
<Badge {...getBadgeProps(release.status)}>{release.status}</Badge>
</Flex>
}
navigationAction={
<Link startIcon={<ArrowLeft />} to="/plugins/content-releases">

View File

@ -4,6 +4,7 @@ import * as React from 'react';
import { useLicenseLimits } from '@strapi/admin/strapi-admin';
import {
Alert,
Badge,
Box,
Button,
ContentLayout,
@ -39,7 +40,7 @@ import { useIntl } from 'react-intl';
import { useHistory, useLocation } from 'react-router-dom';
import styled from 'styled-components';
import { GetReleases } from '../../../shared/contracts/releases';
import { GetReleases, type Release } from '../../../shared/contracts/releases';
import { ReleaseModal, FormValues } from '../components/ReleaseModal';
import { PERMISSIONS } from '../constants';
import { isAxiosError } from '../services/axios';
@ -62,6 +63,37 @@ const LinkCard = styled(Link)`
display: block;
`;
const CapitalizeRelativeTime = styled(RelativeTime)`
text-transform: capitalize;
`;
const getBadgeProps = (status: Release['status']) => {
let color;
switch (status) {
case 'ready':
color = 'success';
break;
case 'blocked':
color = 'warning';
break;
case 'failed':
color = 'danger';
break;
case 'done':
color = 'primary';
break;
case 'empty':
default:
color = 'neutral';
}
return {
textColor: `${color}600`,
backgroundColor: `${color}100`,
borderColor: `${color}200`,
};
};
const ReleasesGrid = ({ sectionTitle, releases = [], isError = false }: ReleasesGridProps) => {
const { formatMessage } = useIntl();
const IsSchedulingEnabled = window.strapi.future.isEnabled('contentReleasesScheduling');
@ -89,7 +121,7 @@ const ReleasesGrid = ({ sectionTitle, releases = [], isError = false }: Releases
return (
<Grid gap={4}>
{releases.map(({ id, name, actions, scheduledAt }) => (
{releases.map(({ id, name, actions, scheduledAt, status }) => (
<GridItem col={3} s={6} xs={12} key={id}>
<LinkCard href={`content-releases/${id}`} isExternal={false}>
<Flex
@ -102,15 +134,16 @@ const ReleasesGrid = ({ sectionTitle, releases = [], isError = false }: Releases
height="100%"
width="100%"
alignItems="start"
gap={2}
gap={4}
>
<Flex direction="column" alignItems="start" gap={1}>
<Typography as="h3" variant="delta" fontWeight="bold">
{name}
</Typography>
<Typography variant="pi" textColor="neutral600">
{IsSchedulingEnabled ? (
scheduledAt ? (
<RelativeTime timestamp={new Date(scheduledAt)} />
<CapitalizeRelativeTime timestamp={new Date(scheduledAt)} />
) : (
formatMessage({
id: 'content-releases.pages.Releases.not-scheduled',
@ -129,6 +162,8 @@ const ReleasesGrid = ({ sectionTitle, releases = [], isError = false }: Releases
)}
</Typography>
</Flex>
<Badge {...getBadgeProps(status)}>{status}</Badge>
</Flex>
</LinkCard>
</GridItem>
))}
@ -405,4 +440,4 @@ const ReleasesPage = () => {
);
};
export { ReleasesPage };
export { ReleasesPage, getBadgeProps };

View File

@ -52,6 +52,9 @@ describe('Releases details page', () => {
const releaseSubtitle = await screen.findAllByText('No entries');
expect(releaseSubtitle[0]).toBeInTheDocument();
const releaseStatus = screen.getByText('empty');
expect(releaseStatus).toBeInTheDocument();
const moreButton = screen.getByRole('button', { name: 'Release edit and delete menu' });
expect(moreButton).toBeInTheDocument();
@ -146,7 +149,7 @@ describe('Releases details page', () => {
expect(tables).toHaveLength(2);
});
it('shows the right status', async () => {
it('shows the right status for unpublished release', async () => {
server.use(
rest.get('/content-releases/:releaseId', (req, res, ctx) =>
res(ctx.json(mockReleaseDetailsPageData.withActionsHeaderData))
@ -160,7 +163,7 @@ describe('Releases details page', () => {
);
render(<ReleaseDetailsPage />, {
initialEntries: [{ pathname: `/content-releases/1` }],
initialEntries: [{ pathname: `/content-releases/2` }],
});
const releaseTitle = await screen.findByText(
@ -168,6 +171,10 @@ describe('Releases details page', () => {
);
expect(releaseTitle).toBeInTheDocument();
const releaseStatus = screen.getByText('ready');
expect(releaseStatus).toBeInTheDocument();
expect(releaseStatus).toHaveStyle(`color: #328048`);
const cat1Row = screen.getByRole('row', { name: /cat1/i });
expect(within(cat1Row).getByRole('gridcell', { name: 'Ready to publish' })).toBeInTheDocument();
@ -181,4 +188,31 @@ describe('Releases details page', () => {
within(add1Row).getByRole('gridcell', { name: 'Already published' })
).toBeInTheDocument();
});
it('shows the right release status for published release', async () => {
server.use(
rest.get('/content-releases/:releaseId', (req, res, ctx) =>
res(ctx.json(mockReleaseDetailsPageData.withActionsAndPublishedHeaderData))
)
);
server.use(
rest.get('/content-releases/:releaseId/actions', (req, res, ctx) =>
res(ctx.json(mockReleaseDetailsPageData.withMultipleActionsBodyData))
)
);
render(<ReleaseDetailsPage />, {
initialEntries: [{ pathname: `/content-releases/3` }],
});
const releaseTitle = await screen.findByText(
mockReleaseDetailsPageData.withActionsAndPublishedHeaderData.data.name
);
expect(releaseTitle).toBeInTheDocument();
const releaseStatus = screen.getByText('done');
expect(releaseStatus).toBeInTheDocument();
expect(releaseStatus).toHaveStyle(`color: #4945ff`);
});
});

View File

@ -9,6 +9,7 @@ const RELEASE_NO_ACTIONS_HEADER_MOCK_DATA = {
createdAt: '2023-11-16T15:18:32.560Z',
updatedAt: '2023-11-16T15:18:32.560Z',
releasedAt: null,
status: 'empty',
createdBy: {
id: 1,
firstname: 'Admin',
@ -50,6 +51,7 @@ const RELEASE_WITH_ACTIONS_HEADER_MOCK_DATA = {
createdAt: '2023-11-16T15:18:32.560Z',
updatedAt: '2023-11-16T15:18:32.560Z',
releasedAt: null,
status: 'ready',
createdBy: {
id: 1,
firstname: 'Admin',
@ -70,11 +72,12 @@ const RELEASE_WITH_ACTIONS_HEADER_MOCK_DATA = {
const PUBLISHED_RELEASE_WITH_ACTIONS_HEADER_MOCK_DATA = {
data: {
id: 2,
id: 3,
name: 'release with actions',
createdAt: '2023-11-16T15:18:32.560Z',
updatedAt: '2023-11-16T15:18:32.560Z',
releasedAt: '2023-11-16T15:18:32.560Z',
status: 'done',
createdBy: {
id: 1,
firstname: 'Admin',

View File

@ -12,6 +12,7 @@ interface CustomInterval {
export interface RelativeTimeProps {
timestamp: Date;
customIntervals?: CustomInterval[];
className?: string;
}
/**
@ -28,7 +29,7 @@ export interface RelativeTimeProps {
* ]}
* ```
*/
const RelativeTime = ({ timestamp, customIntervals = [] }: RelativeTimeProps) => {
const RelativeTime = ({ timestamp, customIntervals = [], className }: RelativeTimeProps) => {
const { formatRelativeTime, formatDate, formatTime } = useIntl();
const interval = intervalToDuration({
@ -54,6 +55,7 @@ const RelativeTime = ({ timestamp, customIntervals = [] }: RelativeTimeProps) =>
<time
dateTime={timestamp.toISOString()}
title={`${formatDate(timestamp)} ${formatTime(timestamp)}`}
className={className}
>
{displayText}
</time>

View File

@ -1,5 +1,6 @@
// @ts-check
const { devices } = require('@playwright/test');
const { parseType } = require('@strapi/utils');
const getEnvNum = (envVar, defaultValue) => {
if (envVar !== undefined && envVar !== null) {
@ -8,6 +9,22 @@ const getEnvNum = (envVar, defaultValue) => {
return defaultValue;
};
const getEnvString = (envVar, defaultValue) => {
if (envVar?.trim().length) {
return envVar;
}
return defaultValue;
};
const getEnvBool = (envVar, defaultValue) => {
if (!envVar || envVar === '') {
return defaultValue;
}
return parseType({ type: 'boolean', value: envVar.toLowerCase() });
};
/**
* @typedef ConfigOptions
* @type {{ port: number; testDir: string; appDir: string }}
@ -28,7 +45,7 @@ const createConfig = ({ port, testDir, appDir }) => ({
* Maximum time expect() should wait for the condition to be met.
* For example in `await expect(locator).toHaveText();`
*/
timeout: getEnvNum(process.env.PLAYWRIGHT_EXPECT_TIMEOUT, 30 * 1000),
timeout: getEnvNum(process.env.PLAYWRIGHT_EXPECT_TIMEOUT, 10 * 1000),
},
/* Run tests in files in parallel */
fullyParallel: false,
@ -46,13 +63,22 @@ const createConfig = ({ port, testDir, appDir }) => ({
baseURL: `http://127.0.0.1:${port}`,
/* Default time each action such as `click()` can take to 20s */
actionTimeout: getEnvNum(process.env.PLAYWRIGHT_ACTION_TIMEOUT, 20 * 1000),
actionTimeout: getEnvNum(process.env.PLAYWRIGHT_ACTION_TIMEOUT, 15 * 1000),
/* Collect trace when a test failed on the CI. See https://playwright.dev/docs/trace-viewer
Until https://github.com/strapi/strapi/issues/18196 is fixed we can't enable this locally,
because the Strapi server restarts every time a new file (trace) is created.
*/
trace: process.env.CI ? 'retain-on-failure' : 'off',
trace: 'retain-on-failure',
video: getEnvBool(process.env.PLAYWRIGHT_VIDEO, false)
? {
mode: 'retain-on-failure', // 'retain-on-failure' to save videos only for failed tests
size: {
width: 1280,
height: 720,
},
}
: 'off',
},
/* Configure projects for major browsers */
@ -80,7 +106,7 @@ const createConfig = ({ port, testDir, appDir }) => ({
],
/* Folder for test artifacts such as screenshots, videos, traces, etc. */
outputDir: 'test-results/',
outputDir: getEnvString(process.env.PLAYWRIGHT_OUTPUT_DIR, '../test-results/'), // in the test-apps/e2e dir, to avoid writing files to the running Strapi project dir
/* Run your local dev server before starting the tests */
webServer: {

View File

@ -5,12 +5,55 @@ const execa = require('execa');
const fs = require('node:fs/promises');
const yargs = require('yargs');
const chalk = require('chalk');
const dotenv = require('dotenv');
const { cleanTestApp, generateTestApp } = require('../helpers/test-app');
const { createConfig } = require('../../playwright.base.config');
const chalk = require('chalk');
const cwd = path.resolve(__dirname, '../..');
const testAppDirectory = path.join(cwd, 'test-apps', 'e2e');
const testRoot = path.join(cwd, 'e2e');
const templateDir = path.join(testRoot, 'app-template');
const pathExists = async (path) => {
try {
await fs.access(path);
return true;
} catch (err) {
return false;
}
};
/**
* Updates the env file for a generated test app
* - Removes the PORT key/value from generated app .env
* - Uses e2e/app-template/config/features.js to enable future features in the generated app
*/
const setupTestEnvironment = async (generatedAppPath) => {
/**
* Because we're running multiple test apps at the same time
* and the env file is generated by the generator with no way
* to override it, we manually remove the PORT key/value so when
* we set it further down for each playwright instance it works.
*/
const pathToEnv = path.join(generatedAppPath, '.env');
const envFile = (await fs.readFile(pathToEnv)).toString();
const envWithoutPort = envFile.replace('PORT=1337', '');
await fs.writeFile(pathToEnv, envWithoutPort);
/*
* Enable future features in the generated app manually since a template
* does not allow the config folder.
*/
const testRootFeaturesConfigPath = path.join(templateDir, 'config', 'features.js');
const hasFeaturesConfig = await pathExists(testRootFeaturesConfigPath);
if (!hasFeaturesConfig) return;
const configFeatures = await fs.readFile(testRootFeaturesConfigPath);
const appFeaturesConfigPath = path.join(generatedAppPath, 'config', 'features.js');
await fs.writeFile(appFeaturesConfigPath, configFeatures);
};
yargs
.parserConfiguration({
@ -51,6 +94,11 @@ yargs
},
handler: async (argv) => {
try {
if (await pathExists(path.join(testRoot, '.env'))) {
// Run tests with the env variables specified in the e2e/app-template/.env
dotenv.config({ path: path.join(testRoot, '.env') });
}
const { concurrency, domains, setup } = argv;
/**
@ -109,16 +157,8 @@ yargs
template: path.join(cwd, 'e2e', 'app-template'),
link: true,
});
/**
* Because we're running multiple test apps at the same time
* and the env file is generated by the generator with no way
* to override it, we manually remove the PORT key/value so when
* we set it further down for each playwright instance it works.
*/
const pathToEnv = path.join(appPath, '.env');
const envFile = (await fs.readFile(pathToEnv)).toString();
const envWithoutPort = envFile.replace('PORT=1337', '');
await fs.writeFile(pathToEnv, envWithoutPort);
await setupTestEnvironment(appPath);
})
);