Merge branch 'develop' into v5/main

This commit is contained in:
Josh 2024-04-08 15:16:53 +01:00
commit e188aac623
33 changed files with 753 additions and 160 deletions

View File

@ -7,6 +7,7 @@ backend:
- 'packages/{utils,generators,cli,providers}/**'
- 'packages/core/*/{lib,bin,ee,src}/**'
- 'tests/api/**'
- 'packages/core/database/**'
frontend:
- '.github/actions/yarn-nm-install/*.yml'
- '.github/workflows/**'

View File

@ -45,7 +45,7 @@ jobs:
actions: 'add-assignees'
token: ${{ secrets.GITHUB_TOKEN }}
issue-number: ${{ github.event.issue.number }}
assignees: 'derrickmehaffy, kasonde, bolg55, Eventyret'
assignees: 'derrickmehaffy, kasonde, bolg55, Eventyret, dennism501, n-alonso'
random-to: 1
# v3 Legacy Issues
@ -183,25 +183,17 @@ jobs:
# SLACK_USERNAME: Strapi-Alerts
# SLACK_FOOTER: Triggered by GitHub Actions
# Auto assign issues to projects based on source
- name: assign issues to Content squad project
uses: actions/add-to-project@v0.4.1
with:
project-url: https://github.com/orgs/strapi/projects/11
github-token: ${{ secrets.PROJECT_TRANSFER_TOKEN }}
labeled: 'source: core:admin, source: core:content-manager, source: core:upload, source: plugin:i18n'
label-operator: OR
- name: assign issues to DevExp squad project
uses: actions/add-to-project@v0.4.1
with:
project-url: https://github.com/orgs/strapi/projects/13
github-token: ${{ secrets.PROJECT_TRANSFER_TOKEN }}
labeled: 'source: core:content-type-builder, source: core:database, source: core:email, source: core:strapi, source: core:utils, source: plugin:graphql, source: plugin:users-permissions, source: typescript, source: core:data-transfer'
labeled: 'source: cli, source: core:content-type-builder, source: core:core, source: core:data-transfer, source: core:database, source: core:strapi, source: core:utils, source: dependencies, source: docs, source: external, source: generators:app, source: generators, source: marketplace, source: plugin:graphql, source: plugin:users-permissions, source: tooling, source: typescript, source: utils:pack-up, source: utils:upgrade'
label-operator: OR
- name: assign issues to Expansions squad project
- name: assign issues to Content squad project
uses: actions/add-to-project@v0.4.1
with:
project-url: https://github.com/orgs/strapi/projects/4
project-url: https://github.com/orgs/strapi/projects/23
github-token: ${{ secrets.PROJECT_TRANSFER_TOKEN }}
labeled: 'source: marketplace, source: plugin:sentry, source: plugin:documentation'
labeled: 'source: core:admin, source: core:content-manager, source: core:content-releases, source: core:email, source: core:helper-plugin, source: core:permissions, source: core:review-workflows, source: core:upload, source: plugin:cloud, source: plugin:color-picker, source: plugin:documentation, source: , plugin:i18n, source: plugin:sentry, source: providers'
label-operator: OR

8
.gitignore vendored
View File

@ -100,18 +100,14 @@ package-lock.json
test-apps
coverage
############################
# Documentation
############################
dist
############################
# Builds
############################
packages/generators/app/files/public/
schema.graphql
dist
.nx
############################
# Example app

View File

@ -34,15 +34,27 @@ If you need to clean the test-apps folder because they are not working as expect
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
yarn test:e2e --domains=admin
npm run test:e2e --domains=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
yarn test:e2e --domains=admin -- login.spec.ts
npm run test:e2e --domains=admin -- login.spec.ts
```
Note that you must still include a domain, otherwise playwright will attempt to run every domain filtering by that filename, and any domains that do not contain that filename will fail with "no tests found"
### Running specific browsers
To run only a specific browser (to speed up test development, for example) you can pass `--project` to playwright with the value(s) `chromium`, `firefox`, or `webkit`
```shell
yarn test:e2e --domains=admin -- login.spec.ts --project=chromium
npm run test:e2e --domains=admin -- login.spec.ts --project=chromium
```
### Concurrency / parallellization

View File

@ -134,6 +134,12 @@
"type": "relation",
"relation": "morphToMany"
},
"morph_one": {
"type": "relation",
"relation": "morphOne",
"target": "api::tag.tag",
"morphBy": "taggable"
},
"custom_field": {
"type": "customField",
"customField": "plugin::color-picker.color"

View File

@ -38,6 +38,19 @@
"relation": "oneToOne",
"target": "api::kitchensink.kitchensink",
"mappedBy": "one_to_one_tag"
},
"taggable": {
"type": "relation",
"relation": "morphToOne",
"morphColumn": {
"typeColumn": {
"name": "taggable_type"
},
"idColumn": {
"name": "taggable_id",
"referencedColumn": "id"
}
}
}
}
}

View File

@ -1,13 +1,19 @@
import { fireEvent, waitForElementToBeRemoved } from '@testing-library/react';
import { mockData } from '@tests/mockData';
import { render, waitFor, server } from '@tests/utils';
import { render, waitFor, server, screen } from '@tests/utils';
import { rest } from 'msw';
import { useLocation } from 'react-router-dom';
import { useRBAC } from '../../../../../hooks/useRBAC';
import { ListPage } from '../ListPage';
jest.mock('../../../../../hooks/useRBAC');
const LocationDisplay = () => {
const location = useLocation();
return <span data-testId="location">{location.pathname}</span>;
};
describe('Webhooks | ListPage', () => {
beforeEach(() => {
jest.clearAllMocks();
@ -28,12 +34,6 @@ describe('Webhooks | ListPage', () => {
});
it('should show a loader when permissions are loading', async () => {
// @ts-expect-error mocking for test
useRBAC.mockImplementationOnce(() => ({
isLoading: true,
allowedActions: { canUpdate: true, canCreate: true, canDelete: true },
}));
const { queryByText, getByText } = render(<ListPage />);
expect(getByText('Loading content.')).toBeInTheDocument();
@ -139,4 +139,34 @@ describe('Webhooks | ListPage', () => {
expect(enableSwitches[0]).toHaveAttribute('aria-checked', 'false');
});
});
it('should allow to create a new webhook on empty state screen by clicking on the button', async () => {
server.use(
rest.get('/admin/webhooks', (req, res, ctx) => {
return res(
ctx.json({
data: [],
})
);
})
);
const { getAllByRole, findByText, user } = render(<ListPage />, {
renderOptions: {
wrapper({ children }) {
return (
<>
{children}
<LocationDisplay />
</>
);
},
},
});
await findByText('No webhooks found');
expect(screen.getByTestId('location')).not.toHaveTextContent('/create');
await user.click(getAllByRole('link', { name: 'Create new webhook' })[1]);
await waitFor(() => expect(screen.getByTestId('location')).toHaveTextContent('/create'));
});
});

View File

@ -3,6 +3,7 @@ import { createRoot } from 'react-dom/client';
import { StrapiApp, StrapiAppConstructorArgs } from './StrapiApp';
import { getFetchClient } from './utils/getFetchClient';
import { createAbsoluteUrl } from './utils/urls';
import type { Modules } from '@strapi/types';
@ -30,7 +31,7 @@ const renderAdmin = async (
*
* To ensure that the backendURL is always set, we use the window.location.origin as a fallback.
*/
backendURL: process.env.STRAPI_ADMIN_BACKEND_URL || window.location.origin,
backendURL: createAbsoluteUrl(process.env.STRAPI_ADMIN_BACKEND_URL),
isEE: false,
telemetryDisabled: process.env.STRAPI_TELEMETRY_DISABLED === 'true',
future: {

View File

@ -0,0 +1,25 @@
import { createAbsoluteUrl } from '../urls';
describe('urls', () => {
describe('createAbsoluteUrl', () => {
it('should return the url if it is an absolute URL', () => {
expect(createAbsoluteUrl('https://example.com')).toMatchInlineSnapshot(
`"https://example.com"`
);
});
it('should return the window.location.origin if the url is not provided', () => {
expect(createAbsoluteUrl()).toMatchInlineSnapshot(`"http://localhost:1337"`);
});
it('should return the window.location.origin prefixed to the provided url if the url is relative', () => {
expect(createAbsoluteUrl('/example')).toMatchInlineSnapshot(
`"http://localhost:1337/example"`
);
});
it('should handle protocol relative URLs', () => {
expect(createAbsoluteUrl('//example.com')).toMatchInlineSnapshot(`"http://example.com/"`);
});
});
});

View File

@ -2,4 +2,24 @@ const prefixFileUrlWithBackendUrl = (fileURL?: string): string | undefined => {
return !!fileURL && fileURL.startsWith('/') ? `${window.strapi.backendURL}${fileURL}` : fileURL;
};
export { prefixFileUrlWithBackendUrl };
/**
* @description Creates an absolute URL, if there is no URL or it
* is relative, we use the `window.location.origin` as a fallback.
* IF it's an absolute URL, we return it as is.
*/
const createAbsoluteUrl = (url?: string): string => {
if (!url) {
return window.location.origin;
}
if (url.startsWith('/')) {
/**
* This will also manage protocol relative URLs which is fine because
* as we can see from the test, we still get the expected result.
*/
return new URL(url, window.location.origin).toString();
} else {
return url;
}
};
export { createAbsoluteUrl, prefixFileUrlWithBackendUrl };

View File

@ -23,9 +23,14 @@ export const transformUpgradeHeader = (header = '') => {
let timeouts: Record<string, number> | undefined;
const hasHttpServer = () => {
// during server restarts, strapi may not have ever been defined at all, so we have to check it first
return typeof strapi !== 'undefined' && !!strapi?.server?.httpServer;
};
// temporarily disable server timeouts while transfer is running
const disableTimeouts = () => {
if (!strapi?.server?.httpServer) {
if (!hasHttpServer()) {
return;
}
@ -45,7 +50,7 @@ const disableTimeouts = () => {
strapi.log.info('[Data transfer] Disabling http timeouts');
};
const resetTimeouts = () => {
if (!strapi?.server?.httpServer || !timeouts) {
if (!hasHttpServer() || !timeouts) {
return;
}

View File

@ -220,8 +220,7 @@ const createMorphToOne = (attributeName: string, attribute: Relation.MorphToOne)
Object.assign(attribute, {
owner: true,
morphColumn: {
// TODO: add referenced column
morphColumn: attribute.morphColumn ?? {
typeColumn: {
name: typeColumnName,
},
@ -231,8 +230,6 @@ const createMorphToOne = (attributeName: string, attribute: Relation.MorphToOne)
},
},
});
// TODO: implement bidirectional
};
/**

View File

@ -54,7 +54,7 @@ const createPivotJoin = (
};
const createJoin = (ctx: Ctx, { alias, refAlias, attributeName, attribute }: JoinOptions) => {
const { db, qb } = ctx;
const { db, qb, uid } = ctx;
if (attribute.type !== 'relation') {
throw new Error(`Cannot join on non relational field ${attributeName}`);
@ -62,6 +62,61 @@ const createJoin = (ctx: Ctx, { alias, refAlias, attributeName, attribute }: Joi
const targetMeta = db.metadata.get(attribute.target);
if (['morphOne', 'morphMany'].includes(attribute.relation)) {
const targetAttribute = targetMeta.attributes[attribute.morphBy];
// @ts-expect-error - morphBy is not defined on the attribute
const { joinTable, morphColumn } = targetAttribute;
if (morphColumn) {
const subAlias = refAlias || qb.getAlias();
qb.join({
alias: subAlias,
referencedTable: targetMeta.tableName,
referencedColumn: morphColumn.idColumn.name,
rootColumn: morphColumn.idColumn.referencedColumn,
rootTable: alias,
on: {
[morphColumn.typeColumn.name]: uid,
...morphColumn.on,
},
});
return subAlias;
}
if (joinTable) {
const joinAlias = qb.getAlias();
qb.join({
alias: joinAlias,
referencedTable: joinTable.name,
referencedColumn: joinTable.morphColumn.idColumn.name,
rootColumn: joinTable.morphColumn.idColumn.referencedColumn,
rootTable: alias,
on: {
[joinTable.morphColumn.typeColumn.name]: uid,
field: attributeName,
},
});
const subAlias = refAlias || qb.getAlias();
qb.join({
alias: subAlias,
referencedTable: targetMeta.tableName,
referencedColumn: joinTable.joinColumn.referencedColumn,
rootColumn: joinTable.joinColumn.name,
rootTable: joinAlias,
});
return subAlias;
}
return alias;
}
const { joinColumn } = attribute;
if (joinColumn) {

View File

@ -1,8 +1,8 @@
/* eslint-disable @typescript-eslint/ban-ts-comment */
import { isArray, castArray, keys, isPlainObject } from 'lodash/fp';
import { isArray, castArray, isPlainObject } from 'lodash/fp';
import type { Knex } from 'knex';
import { isOperatorOfType } from '@strapi/utils';
import { isOperator, isOperatorOfType } from '@strapi/utils';
import * as types from '../../utils/types';
import { createField } from '../../fields';
import { createJoin } from './join';
@ -12,6 +12,8 @@ import { isKnexQuery } from '../../utils/knex';
import type { Ctx } from '../types';
import type { Attribute } from '../../types';
type WhereCtx = Ctx & { alias?: string; isGroupRoot?: boolean };
const isRecord = (value: unknown): value is Record<string, unknown> => isPlainObject(value);
const castValue = (value: unknown, attribute: Attribute | null) => {
@ -72,7 +74,34 @@ const processNested = (where: unknown, ctx: WhereCtx) => {
return processWhere(where, ctx);
};
type WhereCtx = Ctx & { alias?: string };
const processRelationWhere = (where: unknown, ctx: WhereCtx) => {
const { qb, alias } = ctx;
const idAlias = qb.aliasColumn('id', alias);
if (!isRecord(where)) {
return { [idAlias]: where };
}
const keys = Object.keys(where);
const operatorKeys = keys.filter((key) => isOperator(key));
if (operatorKeys.length > 0 && operatorKeys.length !== keys.length) {
throw new Error(`Operator and non-operator keys cannot be mixed in a relation where clause`);
}
if (operatorKeys.length > 1) {
throw new Error(
`Only one operator key is allowed in a relation where clause, but found: ${operatorKeys}`
);
}
if (operatorKeys.length === 1) {
const operator = operatorKeys[0];
return { [idAlias]: { [operator]: processNested(where[operator], ctx) } };
}
return processWhere(where, ctx);
};
/**
* Process where parameter
@ -100,8 +129,12 @@ function processWhere(
for (const key of Object.keys(where)) {
const value = where[key];
// if operator $and $or then loop over them
if (isOperatorOfType('group', key) && Array.isArray(value)) {
// if operator $and $or -> process recursively
if (isOperatorOfType('group', key)) {
if (!Array.isArray(value)) {
throw new Error(`Operator ${key} must be an array`);
}
filters[key] = value.map((sub) => processNested(sub, ctx));
continue;
}
@ -132,17 +165,13 @@ function processWhere(
attribute,
});
let nestedWhere = processNested(value, {
const nestedWhere = processRelationWhere(value, {
db,
qb,
alias: subAlias,
uid: attribute.target,
});
if (!isRecord(nestedWhere) || isOperatorOfType('where', keys(nestedWhere)[0])) {
nestedWhere = { [qb.aliasColumn('id', subAlias)]: nestedWhere };
}
// TODO: use a better merge logic (push to $and when collisions)
Object.assign(filters, nestedWhere);

View File

@ -64,11 +64,6 @@ const createConfig = ({ port, testDir, appDir }) => ({
/* Default time each action such as `click()` can take to 20s */
actionTimeout: getEnvNum(process.env.PLAYWRIGHT_ACTION_TIMEOUT, 20 * 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: 'retain-on-failure',
video: getEnvBool(process.env.PLAYWRIGHT_VIDEO, false)
? {
@ -105,8 +100,10 @@ const createConfig = ({ port, testDir, appDir }) => ({
},
],
/* Folder for test artifacts such as screenshots, videos, traces, etc. */
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
/* Folder for test artifacts such as screenshots, videos, traces, etc.
* Must be outside the project itself or develop mode will restart when files are written
* */
outputDir: getEnvString(process.env.PLAYWRIGHT_OUTPUT_DIR, '../test-results/'),
/* Run your local dev server before starting the tests */
webServer: {

View File

@ -202,4 +202,201 @@ describe('Upload plugin', () => {
.delete({ where: { id: dogEntity.profilePicture.id } });
});
});
describe('Filtering data based on media attributes', () => {
let uploadRes;
let dogRes;
beforeAll(async () => {
await Promise.all(
data.dogs.map((dog) => {
return strapi.entityService.delete('api::dog.dog', dog.data.id);
})
);
uploadRes = await rq({
method: 'POST',
url: '/upload',
formData: {
files: fs.createReadStream(path.join(__dirname, '../utils/rec.jpg')),
fileInfo: JSON.stringify({
alternativeText: 'rec',
caption: 'my caption',
}),
},
});
dogRes = await rq({
method: 'POST',
url: '/dogs',
body: {
data: {
profilePicture: {
id: uploadRes.body[0].id,
},
},
},
});
});
afterAll(async () => {
await rq({
method: 'DELETE',
url: `/dogs/${dogRes.body.data.id}`,
});
await rq({
method: 'DELETE',
url: `/upload/files/${uploadRes.body[0].id}`,
});
});
test('can filter on notNull', async () => {
let res;
res = await rq({
method: 'GET',
url: '/dogs',
qs: {
filters: {
profilePicture: { $notNull: true },
},
},
});
expect(res.body.data.length).toBe(1);
res = await rq({
method: 'GET',
url: '/dogs',
qs: {
filters: {
profilePicture: { $notNull: false },
},
},
});
expect(res.body.data.length).toBe(0);
});
test('can filter on null', async () => {
let res;
res = await rq({
method: 'GET',
url: '/dogs',
qs: {
filters: {
profilePicture: { $null: true },
},
},
});
expect(res.body.data.length).toBe(0);
res = await rq({
method: 'GET',
url: '/dogs',
qs: {
filters: {
profilePicture: { $null: false },
},
},
});
expect(res.body.data.length).toBe(1);
});
test('can filter on id', async () => {
let res;
res = await rq({
method: 'GET',
url: '/dogs',
qs: {
filters: {
profilePicture: uploadRes.body[0].id,
},
},
});
expect(res.body.data.length).toBe(1);
res = await rq({
method: 'GET',
url: '/dogs',
qs: {
filters: {
profilePicture: 999999999,
},
},
});
expect(res.body.data.length).toBe(0);
});
test('can filter media attribute', async () => {
let res;
res = await rq({
method: 'GET',
url: '/dogs',
qs: {
filters: {
profilePicture: { ext: '.jpg' },
},
},
});
expect(res.body.data.length).toBe(1);
res = await rq({
method: 'GET',
url: '/dogs',
qs: {
filters: {
profilePicture: { ext: '.pdf' },
},
},
});
expect(res.body.data.length).toBe(0);
});
test('can filter media attribute with operators', async () => {
let res;
res = await rq({
method: 'GET',
url: '/dogs',
qs: {
filters: {
profilePicture: {
caption: {
$contains: 'my',
},
},
},
},
});
expect(res.body.data.length).toBe(1);
res = await rq({
method: 'GET',
url: '/dogs',
qs: {
filters: {
profilePicture: {
caption: {
$contains: 'not',
},
},
},
},
});
expect(res.body.data.length).toBe(0);
});
});
});

View File

@ -1,95 +1,4 @@
const {
file: {
providers: { createLocalFileDestinationProvider },
},
strapi: {
providers: { createLocalStrapiSourceProvider },
},
engine: { createTransferEngine },
} = require('@strapi/data-transfer');
const { createStrapi, compileStrapi } = require('@strapi/strapi');
const { ALLOWED_CONTENT_TYPES } = require('../constants');
/**
* Export the data from a strapi project.
* This script should be run as `node <path-to>/dts-export.js [exportFilePath]` from the
* root directory of a strapi project e.g. `/examples/kitchensink`. Remember to import
* the `with-admin` tar file into the project first because the tests rely on the data.
*/
const exportData = async () => {
let args = process.argv.slice(2);
if (args.length !== 1) {
console.error('Please provide the export file name as a parameter.');
process.exit(1);
}
const strapi = await createStrapiInstance();
const source = createSourceProvider(strapi);
const destination = createDestinationProvider(args[0]);
const engine = createTransferEngine(source, destination, {
versionStrategy: 'ignore', // for an export to file, versionStrategy will always be skipped
schemaStrategy: 'ignore', // for an export to file, schemaStrategy will always be skipped
only: ['content', 'files'],
transforms: {
links: [
{
filter(link) {
return (
ALLOWED_CONTENT_TYPES.includes(link.left.type) &&
ALLOWED_CONTENT_TYPES.includes(link.right.type)
);
},
},
],
entities: [
{
filter(entity) {
return ALLOWED_CONTENT_TYPES.includes(entity.type);
},
},
],
},
});
engine.diagnostics.onDiagnostic(console.log);
try {
const results = await engine.transfer();
console.log(JSON.stringify(results.engine, null, 2));
} catch {
console.error('Export process failed.');
process.exit(1);
}
process.exit(0);
};
const createSourceProvider = (strapi) =>
createLocalStrapiSourceProvider({
async getStrapi() {
return strapi;
},
});
const createDestinationProvider = (filePath) =>
createLocalFileDestinationProvider({
file: { path: filePath },
encryption: { enabled: false },
compression: { enabled: false },
});
const createStrapiInstance = async (logLevel = 'error') => {
const appContext = await compileStrapi();
const app = createStrapi(appContext);
app.log.level = logLevel;
const loadedApp = await app.load();
return loadedApp;
};
const { exportData } = require('../utils/dts-export');
// TODO: make an actual yargs command and pass common options to exportData so it's easier to build the test data
exportData();

View File

@ -1,6 +1,6 @@
import { test, expect } from '@playwright/test';
import { resetDatabaseAndImportDataFromPath } from '../../scripts/dts-import';
import { toggleRateLimiting } from '../../scripts/endpoints';
import { resetDatabaseAndImportDataFromPath } from '../../utils/dts-import';
import { toggleRateLimiting } from '../../utils/rate-limit';
import { ADMIN_EMAIL_ADDRESS, ADMIN_PASSWORD } from '../../constants';
import { login } from '../../utils/login';

View File

@ -1,6 +1,6 @@
import { test, expect } from '@playwright/test';
// eslint-disable-next-line import/extensions
import { resetDatabaseAndImportDataFromPath } from '../../scripts/dts-import';
import { resetDatabaseAndImportDataFromPath } from '../../utils/dts-import';
import { login } from '../../utils/login';
test.describe('Log Out', () => {

View File

@ -1,6 +1,6 @@
import { test, expect } from '@playwright/test';
import { resetDatabaseAndImportDataFromPath } from '../../scripts/dts-import';
import { resetDatabaseAndImportDataFromPath } from '../../utils/dts-import';
import { ADMIN_EMAIL_ADDRESS, ADMIN_PASSWORD } from '../../constants';
/**

View File

@ -1,6 +1,6 @@
import { test, expect } from '@playwright/test';
import { login } from '../../utils/login';
import { resetDatabaseAndImportDataFromPath } from '../../scripts/dts-import';
import { resetDatabaseAndImportDataFromPath } from '../../utils/dts-import';
import { findAndClose } from '../../utils/shared';
test.describe('Edit View', () => {

View File

@ -1,6 +1,6 @@
import { test, expect } from '@playwright/test';
import { login } from '../../utils/login';
import { resetDatabaseAndImportDataFromPath } from '../../scripts/dts-import';
import { resetDatabaseAndImportDataFromPath } from '../../utils/dts-import';
test.describe('List View', () => {
test.beforeEach(async ({ page }) => {

View File

@ -1,6 +1,6 @@
import { test, expect, type Page } from '@playwright/test';
import { describeOnCondition } from '../../utils/shared';
import { resetDatabaseAndImportDataFromPath } from '../../scripts/dts-import';
import { resetDatabaseAndImportDataFromPath } from '../../utils/dts-import';
import { login } from '../../utils/login';
const edition = process.env.STRAPI_DISABLE_EE === 'true' ? 'CE' : 'EE';

View File

@ -1,6 +1,6 @@
import { test, expect } from '@playwright/test';
import { describeOnCondition, navToHeader } from '../../utils/shared';
import { resetDatabaseAndImportDataFromPath } from '../../scripts/dts-import';
import { resetDatabaseAndImportDataFromPath } from '../../utils/dts-import';
import { login } from '../../utils/login';
const edition = process.env.STRAPI_DISABLE_EE === 'true' ? 'CE' : 'EE';

View File

@ -0,0 +1,56 @@
import { test, expect } from '@playwright/test';
import { login } from '../../../utils/login';
import { resetDatabaseAndImportDataFromPath } from '../../../utils/dts-import';
import { waitForRestart } from '../../../utils/restart';
import { resetFiles } from '../../../utils/file-reset';
test.describe('Create collection type', () => {
test.beforeEach(async ({ page }) => {
await resetFiles();
await resetDatabaseAndImportDataFromPath('with-admin.tar');
await page.goto('/admin');
await login({ page });
await page.getByRole('link', { name: 'Content-Type Builder' }).click();
// close the tutorial modal if it's visible
const modal = page.getByRole('button', { name: 'Close' });
if (modal.isVisible()) {
await modal.click();
await expect(modal).not.toBeVisible();
}
});
// 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();
});
test('Can create a collection type', async ({ page }) => {
await page.getByRole('button', { name: 'Create new collection type' }).click();
await expect(page.getByRole('heading', { name: 'Create a collection type' })).toBeVisible();
const displayName = page.getByLabel('Display name');
await displayName.fill('Secret Document');
const singularId = page.getByLabel('API ID (Singular)');
await expect(singularId).toHaveValue('secret-document');
const pluralId = page.getByLabel('API ID (Plural)');
await expect(pluralId).toHaveValue('secret-documents');
await page.getByRole('button', { name: 'Continue' }).click();
await expect(page.getByText('Select a field for your collection type')).toBeVisible();
await page.getByText('Small or long text').click();
await page.getByLabel('Name', { exact: true }).fill('myattribute');
await page.getByRole('button', { name: 'Finish' }).click();
await page.getByRole('button', { name: 'Save' }).click();
await waitForRestart(page);
await expect(page.getByRole('heading', { name: 'Secret Document' })).toBeVisible();
});
});

View File

@ -1,7 +1,7 @@
import { test, expect } from '@playwright/test';
import { login } from '../../utils/login';
import { navToHeader } from '../../utils/shared';
import { resetDatabaseAndImportDataFromPath } from '../../scripts/dts-import';
import { resetDatabaseAndImportDataFromPath } from '../../utils/dts-import';
test.describe('Edit View CTB', () => {
test.beforeEach(async ({ page }) => {

View File

@ -0,0 +1,26 @@
import { test, expect } from '@playwright/test';
import { login } from '../../utils/login';
import { resetDatabaseAndImportDataFromPath } from '../../utils/dts-import';
test.describe('Tutorial', () => {
test.beforeEach(async ({ page }) => {
await resetDatabaseAndImportDataFromPath('with-admin.tar');
await page.goto('/admin');
await login({ page });
});
test('Shows tutorial on first content type', async ({ page }) => {
await page.getByRole('link', { name: 'Content-type Builder' }).click();
const modalHeader = page.getByRole('heading', { name: '🧠 Create a first Collection' });
expect(modalHeader).toBeVisible();
await modalHeader.click();
const closeButton = page.getByRole('button', { name: 'Close' });
expect(closeButton).toBeVisible();
await closeButton.click();
await expect(closeButton).not.toBeVisible();
await expect(modalHeader).not.toBeVisible();
});
});

View File

@ -0,0 +1,93 @@
const {
file: {
providers: { createLocalFileDestinationProvider },
},
strapi: {
providers: { createLocalStrapiSourceProvider },
},
engine: { createTransferEngine },
} = require('@strapi/data-transfer');
const strapiFactory = require('@strapi/strapi');
const { ALLOWED_CONTENT_TYPES } = require('../constants');
/**
* Export the data from a strapi project.
* This script should be run as `node <path-to>/dts-export.js [exportFilePath]` from the
* root directory of a strapi project e.g. `/examples/kitchensink`. Remember to import
* the `with-admin` tar file into the project first because the tests rely on the data.
*/
export const exportData = async () => {
let args = process.argv.slice(2);
if (args.length !== 1) {
console.error('Please provide the export file name as a parameter.');
process.exit(1);
}
const strapi = await createStrapiInstance();
const source = createSourceProvider(strapi);
const destination = createDestinationProvider(args[0]);
const engine = createTransferEngine(source, destination, {
versionStrategy: 'ignore', // for an export to file, versionStrategy will always be skipped
schemaStrategy: 'ignore', // for an export to file, schemaStrategy will always be skipped
only: ['content', 'files'],
transforms: {
links: [
{
filter(link) {
return (
ALLOWED_CONTENT_TYPES.includes(link.left.type) &&
ALLOWED_CONTENT_TYPES.includes(link.right.type)
);
},
},
],
entities: [
{
filter(entity) {
return ALLOWED_CONTENT_TYPES.includes(entity.type);
},
},
],
},
});
engine.diagnostics.onDiagnostic(console.log);
try {
const results = await engine.transfer();
console.log(JSON.stringify(results.engine, null, 2));
} catch {
console.error('Export process failed.');
process.exit(1);
}
process.exit(0);
};
const createSourceProvider = (strapi) =>
createLocalStrapiSourceProvider({
async getStrapi() {
return strapi;
},
});
const createDestinationProvider = (filePath) =>
createLocalFileDestinationProvider({
file: { path: filePath },
encryption: { enabled: false },
compression: { enabled: false },
});
const createStrapiInstance = async (logLevel = 'error') => {
const appContext = await strapiFactory.compile();
const app = strapiFactory(appContext);
app.log.level = logLevel;
const loadedApp = await app.load();
return loadedApp;
};

View File

@ -0,0 +1,54 @@
import execa from 'execa';
const gitUser = ['-c', 'user.name=Strapi CLI', '-c', 'user.email=test@strapi.io'];
function delay(seconds) {
return new Promise((resolve) => setTimeout(resolve, seconds * 1000));
}
export const pollHealthCheck = async (interval = 1000, timeout = 30000) => {
const url = `http://127.0.0.1:${process.env.PORT ?? 1337}/_health`;
console.log(`Starting to poll: ${url}`);
let elapsed = 0;
while (elapsed < timeout) {
try {
const response = await fetch(url, { method: 'HEAD' });
if (response.ok) {
console.log('The service is up and running!');
return; // Exit if the service is up
}
// If the response is not okay, throw an error to catch it below
throw new Error('Service not ready');
} catch (error) {
console.log('Waiting for the service to come up...');
// Wait for the specified interval before trying again
await new Promise((resolve) => setTimeout(resolve, interval));
elapsed += interval; // Update the elapsed time
}
}
// If we've exited the loop because of the timeout
console.error('Timeout reached, service did not become available in time.');
};
export const resetFiles = async () => {
if (process.env.TEST_APP_PATH) {
console.log('Restoring filesystem');
await execa('git', [...gitUser, 'reset', '--hard'], {
stdio: 'inherit',
cwd: process.env.TEST_APP_PATH,
});
const dryRun = await execa('git', [...gitUser, 'clean', '-fd'], {
stdio: 'inherit',
cwd: process.env.TEST_APP_PATH,
});
}
// wait for server to restart after modifying files
console.log('Waiting for Strapi to restart...');
// TODO: this is both a waste of time and flaky. We need to find a way to access playwright server output and watch for the "up" log to appear
await delay(3); // give it time to detect file changes and begin its restart
await pollHealthCheck(); // give it time to come back up
};

View File

@ -0,0 +1,6 @@
export async function toggleRateLimiting(page, enabled = true) {
await page.request.fetch('/api/config/ratelimit/enable', {
method: 'POST',
data: { value: enabled },
});
}

View File

@ -0,0 +1,50 @@
import { Page, expect } from '@playwright/test';
// Function to check modal visibility
const isModalVisible = async (page: Page) => {
return page.isVisible('text="Waiting for restart..."');
};
/**
* Wait for a restart modal to appear, but instead of failing if it doesn't, attempt to
* refresh the page and see if it comes back up
*/
export const waitForRestart = async (page, timeout = 60000) => {
const initialWaitForModal = 5000; // Time to wait for the modal to initially appear
let elapsedTime = 0;
const checkInterval = 1000; // Check every 1 second
const reloadTimeout = 15000; // 15 seconds before trying to reload
// Initially wait for the modal to become visible
try {
await page.waitForSelector('text="Waiting for restart..."', {
state: 'visible',
timeout: initialWaitForModal,
});
} catch (error) {
console.log('The modal did not become visible within the initial wait period.');
throw error; // Or handle this scenario as appropriate
}
// Now wait until the modal is not visible or until the reloadTimeout
let modalVisible = await isModalVisible(page);
while (modalVisible && elapsedTime < reloadTimeout) {
await new Promise((r) => setTimeout(r, checkInterval));
elapsedTime += checkInterval;
modalVisible = await isModalVisible(page);
}
// If modal is still visible after reloadTimeout, reload the page and wait again
if (modalVisible) {
console.log("Restart overlay didn't disappear after 15 seconds. Reloading page...");
await page.reload({ waitUntil: 'domcontentloaded' });
// Optionally, wait again for the modal to disappear after reloading
}
// Final check to ensure the modal has disappeared
if (await isModalVisible(page)) {
throw new Error('Restart overlay did not disappear after waiting and reloading.');
}
console.log('Restart overlay has disappeared, proceeding with the test.');
};

View File

@ -59,8 +59,8 @@ const setupTestEnvironment = async (generatedAppPath) => {
yargs
.parserConfiguration({
/**
* This lets us pass any other arguments to playwright
* e.g. the name of a specific test or the project we want to run
* When unknown options is false, using -- to separate playwright args from test:e2e args works
* When it is true, the script gets confused about additional arguments, with or without using -- to separate commands
*/
'unknown-options-as-args': false,
})
@ -222,6 +222,28 @@ module.exports = config
await fs.writeFile(pathToPlaywrightConfig, configFileTemplate);
// Store the filesystem state with git so it can be reset between tests
// TODO: if we have a large test test suite, it might be worth it to run a `strapi start` and then shutdown here to generate documentation and types only once and save unneccessary server restarts from those files being cleared every time
console.log('Initializing git');
const gitUser = ['-c', 'user.name=Strapi CLI', '-c', 'user.email=test@strapi.io'];
await execa('git', [...gitUser, 'init'], {
stdio: 'inherit',
cwd: testAppPath,
});
// we need to use -A to track even hidden files like .env; remember we're only using git as a file state manager
await execa('git', [...gitUser, 'add', '-A', '.'], {
stdio: 'inherit',
cwd: testAppPath,
});
await execa('git', [...gitUser, 'commit', '-m', 'initial commit'], {
stdio: 'inherit',
cwd: testAppPath,
});
console.log(`Running ${chalk.blue(domain)} e2e tests`);
await execa(
@ -233,6 +255,7 @@ module.exports = config
env: {
PORT: port,
HOST: '127.0.0.1',
TEST_APP_PATH: testAppPath,
STRAPI_DISABLE_EE: !process.env.STRAPI_LICENSE,
},
}