mirror of
https://github.com/strapi/strapi.git
synced 2025-06-27 00:41:25 +00:00
Merge branch 'develop' into v5/main
This commit is contained in:
commit
3943ccbac6
4
.github/workflows/tests.yml
vendored
4
.github/workflows/tests.yml
vendored
@ -189,7 +189,7 @@ jobs:
|
|||||||
if: failure()
|
if: failure()
|
||||||
with:
|
with:
|
||||||
name: ce-playwright-trace
|
name: ce-playwright-trace
|
||||||
path: test-apps/e2e/**/test-results/**/trace.zip
|
path: test-apps/e2e/test-results/**/trace.zip
|
||||||
retention-days: 1
|
retention-days: 1
|
||||||
|
|
||||||
e2e_ee:
|
e2e_ee:
|
||||||
@ -231,7 +231,7 @@ jobs:
|
|||||||
if: failure()
|
if: failure()
|
||||||
with:
|
with:
|
||||||
name: ee-playwright-trace
|
name: ee-playwright-trace
|
||||||
path: test-apps/e2e/**/test-results/**/trace.zip
|
path: test-apps/e2e/test-results/**/trace.zip
|
||||||
retention-days: 1
|
retention-days: 1
|
||||||
|
|
||||||
cli:
|
cli:
|
||||||
|
@ -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.
|
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
|
## 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.
|
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.
|
||||||
|
3
e2e/README.md
Normal file
3
e2e/README.md
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
## End-to-end Playwright Tests
|
||||||
|
|
||||||
|
See contributor docs in docs/docs/guides/e2e for more info
|
@ -1,3 +1,5 @@
|
|||||||
|
const { createTestTransferToken } = require('../../../create-transfer-token');
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
rateLimitEnable(ctx) {
|
rateLimitEnable(ctx) {
|
||||||
const { value } = ctx.request.body;
|
const { value } = ctx.request.body;
|
||||||
@ -13,6 +15,11 @@ module.exports = {
|
|||||||
|
|
||||||
await permissionService.cleanPermissionsInDatabase();
|
await permissionService.cleanPermissionsInDatabase();
|
||||||
|
|
||||||
|
ctx.send(200);
|
||||||
|
},
|
||||||
|
async resetTransferToken(ctx) {
|
||||||
|
await createTestTransferToken(strapi);
|
||||||
|
|
||||||
ctx.send(200);
|
ctx.send(200);
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
@ -16,5 +16,13 @@ module.exports = {
|
|||||||
auth: false,
|
auth: false,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
method: 'POST',
|
||||||
|
path: '/config/resettransfertoken',
|
||||||
|
handler: 'config.resetTransferToken',
|
||||||
|
config: {
|
||||||
|
auth: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
|
27
e2e/app-template/template/src/create-transfer-token.js
Normal file
27
e2e/app-template/template/src/create-transfer-token.js
Normal file
@ -0,0 +1,27 @@
|
|||||||
|
const { CUSTOM_TRANSFER_TOKEN_ACCESS_KEY } = require('./constants');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Make sure the test transfer token exists in the database
|
||||||
|
* @param {Strapi.Strapi} strapi
|
||||||
|
* @returns {Promise<void>}
|
||||||
|
*/
|
||||||
|
const createTestTransferToken = async (strapi) => {
|
||||||
|
const { token: transferTokenService } = strapi.admin.services.transfer;
|
||||||
|
|
||||||
|
const accessKeyHash = transferTokenService.hash(CUSTOM_TRANSFER_TOKEN_ACCESS_KEY);
|
||||||
|
const exists = await transferTokenService.exists({ accessKey: accessKeyHash });
|
||||||
|
|
||||||
|
if (!exists) {
|
||||||
|
await transferTokenService.create({
|
||||||
|
name: 'TestToken',
|
||||||
|
description: 'Transfer token used to seed the e2e database',
|
||||||
|
lifespan: null,
|
||||||
|
permissions: ['push'],
|
||||||
|
accessKey: CUSTOM_TRANSFER_TOKEN_ACCESS_KEY,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
createTestTransferToken,
|
||||||
|
};
|
@ -1,4 +1,4 @@
|
|||||||
const { CUSTOM_TRANSFER_TOKEN_ACCESS_KEY } = require('./constants');
|
const { createTestTransferToken } = require('./create-transfer-token');
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
/**
|
/**
|
||||||
@ -23,25 +23,3 @@ module.exports = {
|
|||||||
await createTestTransferToken(strapi);
|
await createTestTransferToken(strapi);
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
|
||||||
* Make sure the test transfer token exists in the database
|
|
||||||
* @param {Strapi.Strapi} strapi
|
|
||||||
* @returns {Promise<void>}
|
|
||||||
*/
|
|
||||||
const createTestTransferToken = async (strapi) => {
|
|
||||||
const { token: transferTokenService } = strapi.admin.services.transfer;
|
|
||||||
|
|
||||||
const accessKeyHash = transferTokenService.hash(CUSTOM_TRANSFER_TOKEN_ACCESS_KEY);
|
|
||||||
const exists = await transferTokenService.exists({ accessKey: accessKeyHash });
|
|
||||||
|
|
||||||
if (!exists) {
|
|
||||||
await transferTokenService.create({
|
|
||||||
name: 'TestToken',
|
|
||||||
description: 'Transfer token used to seed the e2e database',
|
|
||||||
lifespan: null,
|
|
||||||
permissions: ['push'],
|
|
||||||
accessKey: CUSTOM_TRANSFER_TOKEN_ACCESS_KEY,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
@ -5,6 +5,8 @@ const ALLOWED_CONTENT_TYPES = [
|
|||||||
'admin::user',
|
'admin::user',
|
||||||
'admin::role',
|
'admin::role',
|
||||||
'admin::permission',
|
'admin::permission',
|
||||||
|
'admin::api-token',
|
||||||
|
'admin::transfer-token',
|
||||||
'api::article.article',
|
'api::article.article',
|
||||||
'api::author.author',
|
'api::author.author',
|
||||||
'api::homepage.homepage',
|
'api::homepage.homepage',
|
||||||
|
@ -53,6 +53,19 @@ export const resetDatabaseAndImportDataFromPath = async (
|
|||||||
|
|
||||||
engine.diagnostics.onDiagnostic(console.log);
|
engine.diagnostics.onDiagnostic(console.log);
|
||||||
|
|
||||||
|
try {
|
||||||
|
// reset the transfer token to allow the transfer if it's been wiped (that is, not included in previous import data)
|
||||||
|
const res = await fetch(
|
||||||
|
`http://127.0.0.1:${process.env.PORT ?? 1337}/api/config/resettransfertoken`,
|
||||||
|
{
|
||||||
|
method: 'POST',
|
||||||
|
}
|
||||||
|
);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Token reset failed.' + JSON.stringify(err, null, 2));
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await engine.transfer();
|
await engine.transfer();
|
||||||
} catch {
|
} catch {
|
||||||
|
65
e2e/tests/admin/transfer/tokens.spec.ts
Normal file
65
e2e/tests/admin/transfer/tokens.spec.ts
Normal file
@ -0,0 +1,65 @@
|
|||||||
|
import { test, expect } from '@playwright/test';
|
||||||
|
import { login } from '../../../utils/login';
|
||||||
|
import { resetDatabaseAndImportDataFromPath } from '../../../scripts/dts-import';
|
||||||
|
import { navToHeader, delay } from '../../../utils/shared';
|
||||||
|
|
||||||
|
const createTransferToken = async (page, tokenName, duration, type) => {
|
||||||
|
await navToHeader(
|
||||||
|
page,
|
||||||
|
['Settings', 'Transfer Tokens', 'Create new Transfer Token'],
|
||||||
|
'Create Transfer Token'
|
||||||
|
);
|
||||||
|
|
||||||
|
await page.getByLabel('Name*').click();
|
||||||
|
await page.getByLabel('Name*').fill(tokenName);
|
||||||
|
|
||||||
|
await page.getByLabel('Token duration').click();
|
||||||
|
await page.getByRole('option', { name: duration }).click();
|
||||||
|
|
||||||
|
await page.getByLabel('Token type').click();
|
||||||
|
await page.getByRole('option', { name: type }).click();
|
||||||
|
|
||||||
|
await page.getByRole('button', { name: 'Save' }).click();
|
||||||
|
|
||||||
|
await expect(page.getByText(/copy this token/)).toBeVisible();
|
||||||
|
await expect(page.getByText('Expiration date:')).toBeVisible();
|
||||||
|
};
|
||||||
|
|
||||||
|
test.describe('Transfer Tokens', () => {
|
||||||
|
test.beforeEach(async ({ page }) => {
|
||||||
|
await resetDatabaseAndImportDataFromPath('./e2e/data/with-admin.tar');
|
||||||
|
await page.goto('/admin');
|
||||||
|
await login({ page });
|
||||||
|
});
|
||||||
|
|
||||||
|
// Test token creation
|
||||||
|
const testCases = [
|
||||||
|
['30-day push token', '30 days', 'Push'],
|
||||||
|
['30-day pull token', '30 days', 'Pull'],
|
||||||
|
['30-day full-access token', '30 days', 'Full access'],
|
||||||
|
// if push+pull work generally that's good enough for e2e
|
||||||
|
['7-day token', '7 days', 'Full access'],
|
||||||
|
['90-day token', '90 days', 'Full access'],
|
||||||
|
['unlimited token', 'Unlimited', 'Full access'],
|
||||||
|
];
|
||||||
|
for (const [name, duration, type] of testCases) {
|
||||||
|
test(`A user should be able to create a ${name}`, async ({ page }) => {
|
||||||
|
await createTransferToken(page, name, duration, type);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
test('Created tokens list page should be correct', async ({ page }) => {
|
||||||
|
await createTransferToken(page, 'my test token', 'unlimited', 'Full access');
|
||||||
|
|
||||||
|
// if we don't wait until createdAt is at least 1s, we see "NaN" for the timestamp
|
||||||
|
// TODO: fix the bug and remove this
|
||||||
|
await page.waitForTimeout(1100);
|
||||||
|
|
||||||
|
await navToHeader(page, ['Settings', 'Transfer Tokens'], 'Transfer Tokens');
|
||||||
|
|
||||||
|
const row = page.getByRole('gridcell', { name: 'my test token', exact: true });
|
||||||
|
await expect(row).toBeVisible();
|
||||||
|
await expect(page.getByText(/\d+ (second|minute)s? ago/)).toBeVisible();
|
||||||
|
// TODO: expand on this test, it could check edit and delete icons
|
||||||
|
});
|
||||||
|
});
|
@ -70,7 +70,17 @@ describeOnCondition(edition === 'EE')('Releases page', () => {
|
|||||||
name: 'Date',
|
name: 'Date',
|
||||||
})
|
})
|
||||||
.click();
|
.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
|
await page
|
||||||
.getByRole('combobox', {
|
.getByRole('combobox', {
|
||||||
|
@ -1,7 +1,25 @@
|
|||||||
import { test } from '@playwright/test';
|
import { test, Page, expect } from '@playwright/test';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Execute a test suite only if the condition is true
|
* Execute a test suite only if the condition is true
|
||||||
*/
|
*/
|
||||||
export const describeOnCondition = (shouldDescribe: boolean) =>
|
export const describeOnCondition = (shouldDescribe: boolean) =>
|
||||||
shouldDescribe ? test.describe : test.describe.skip;
|
shouldDescribe ? test.describe : test.describe.skip;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Navigate to a page and confirm the header, awaiting each step
|
||||||
|
*/
|
||||||
|
export const navToHeader = async (page: Page, navItems: string[], headerText: string) => {
|
||||||
|
for (const navItem of navItems) {
|
||||||
|
// This does not use getByRole because sometimes "Settings" is "Settings 1" if there's a badge notification
|
||||||
|
// BUT if we don't match exact it conflicts with "Advanceed Settings"
|
||||||
|
// As a workaround, we implement our own startsWith with page.locator
|
||||||
|
const item = page.locator(`role=link[name^="${navItem}"]`);
|
||||||
|
await expect(item).toBeVisible();
|
||||||
|
await item.click();
|
||||||
|
}
|
||||||
|
|
||||||
|
const header = page.getByRole('heading', { name: headerText, exact: true });
|
||||||
|
await expect(header).toBeVisible();
|
||||||
|
return header;
|
||||||
|
};
|
||||||
|
@ -200,8 +200,8 @@ export const EditView = () => {
|
|||||||
if (isCreating) {
|
if (isCreating) {
|
||||||
const res = await createToken({
|
const res = await createToken({
|
||||||
...body,
|
...body,
|
||||||
// in case a token has a lifespan of "unlimited" the API only accepts zero as a number
|
// lifespan must be "null" for unlimited (0 would mean instantly expired and isn't accepted)
|
||||||
lifespan: body.lifespan === '0' ? parseInt(body.lifespan) : null,
|
lifespan: body?.lifespan || null,
|
||||||
permissions: body.type === 'custom' ? state.selectedActions : null,
|
permissions: body.type === 'custom' ? state.selectedActions : null,
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -340,7 +340,7 @@ export const EditView = () => {
|
|||||||
name: apiToken?.name || '',
|
name: apiToken?.name || '',
|
||||||
description: apiToken?.description || '',
|
description: apiToken?.description || '',
|
||||||
type: apiToken?.type,
|
type: apiToken?.type,
|
||||||
lifespan: apiToken?.lifespan ? apiToken.lifespan.toString() : apiToken?.lifespan,
|
lifespan: apiToken?.lifespan,
|
||||||
}}
|
}}
|
||||||
enableReinitialize
|
enableReinitialize
|
||||||
onSubmit={(body, actions) => handleSubmit(body, actions)}
|
onSubmit={(body, actions) => handleSubmit(body, actions)}
|
||||||
|
@ -149,6 +149,8 @@ const EditView = () => {
|
|||||||
if (isCreating) {
|
if (isCreating) {
|
||||||
const res = await createToken({
|
const res = await createToken({
|
||||||
...body,
|
...body,
|
||||||
|
// lifespan must be "null" for unlimited (0 would mean instantly expired and isn't accepted)
|
||||||
|
lifespan: body?.lifespan || null,
|
||||||
permissions,
|
permissions,
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -253,7 +255,7 @@ const EditView = () => {
|
|||||||
{
|
{
|
||||||
name: transferToken?.name || '',
|
name: transferToken?.name || '',
|
||||||
description: transferToken?.description || '',
|
description: transferToken?.description || '',
|
||||||
lifespan: transferToken?.lifespan ?? null,
|
lifespan: transferToken?.lifespan || null,
|
||||||
/**
|
/**
|
||||||
* We need to cast the permissions to satisfy the type for `permissions`
|
* We need to cast the permissions to satisfy the type for `permissions`
|
||||||
* in the request body incase we don't have a transferToken and instead
|
* in the request body incase we don't have a transferToken and instead
|
||||||
|
@ -1,5 +1,5 @@
|
|||||||
import crypto from 'crypto';
|
import crypto from 'crypto';
|
||||||
import { omit, difference, isNil, isEmpty, map, isArray, uniq } from 'lodash/fp';
|
import { omit, difference, isNil, isEmpty, map, isArray, uniq, isNumber } from 'lodash/fp';
|
||||||
import { errors } from '@strapi/utils';
|
import { errors } from '@strapi/utils';
|
||||||
import type { Update, ApiToken, ApiTokenBody } from '../../../shared/contracts/api-token';
|
import type { Update, ApiToken, ApiTokenBody } from '../../../shared/contracts/api-token';
|
||||||
import constants from './constants';
|
import constants from './constants';
|
||||||
@ -61,14 +61,25 @@ const assertCustomTokenPermissionsValidity = (
|
|||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Assert that a token's lifespan is valid
|
* Check if a token's lifespan is valid
|
||||||
*/
|
*/
|
||||||
const assertValidLifespan = (lifespan: ApiTokenBody['lifespan']) => {
|
const isValidLifespan = (lifespan: unknown) => {
|
||||||
if (isNil(lifespan)) {
|
if (isNil(lifespan)) {
|
||||||
return;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!Object.values(constants.API_TOKEN_LIFESPANS).includes(lifespan as number)) {
|
if (!isNumber(lifespan) || !Object.values(constants.API_TOKEN_LIFESPANS).includes(lifespan)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Assert that a token's lifespan is valid
|
||||||
|
*/
|
||||||
|
const assertValidLifespan = (lifespan: unknown) => {
|
||||||
|
if (!isValidLifespan(lifespan)) {
|
||||||
throw new ValidationError(
|
throw new ValidationError(
|
||||||
`lifespan must be one of the following values:
|
`lifespan must be one of the following values:
|
||||||
${Object.values(constants.API_TOKEN_LIFESPANS).join(', ')}`
|
${Object.values(constants.API_TOKEN_LIFESPANS).join(', ')}`
|
||||||
@ -138,14 +149,14 @@ const hash = (accessKey: string) => {
|
|||||||
|
|
||||||
const getExpirationFields = (lifespan: ApiTokenBody['lifespan']) => {
|
const getExpirationFields = (lifespan: ApiTokenBody['lifespan']) => {
|
||||||
// it must be nil or a finite number >= 0
|
// it must be nil or a finite number >= 0
|
||||||
const isValidNumber = Number.isFinite(lifespan) && (lifespan as number) > 0;
|
const isValidNumber = isNumber(lifespan) && Number.isFinite(lifespan) && lifespan > 0;
|
||||||
if (!isValidNumber && !isNil(lifespan)) {
|
if (!isValidNumber && !isNil(lifespan)) {
|
||||||
throw new ValidationError('lifespan must be a positive number or null');
|
throw new ValidationError('lifespan must be a positive number or null');
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
lifespan: lifespan || null,
|
lifespan: lifespan || null,
|
||||||
expiresAt: lifespan ? Date.now() + (lifespan as number) : null,
|
expiresAt: lifespan ? Date.now() + lifespan : null,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ -1,6 +1,6 @@
|
|||||||
import crypto from 'crypto';
|
import crypto from 'crypto';
|
||||||
import assert from 'assert';
|
import assert from 'assert';
|
||||||
import { map, isArray, omit, uniq, isNil, difference, isEmpty } from 'lodash/fp';
|
import { map, isArray, omit, uniq, isNil, difference, isEmpty, isNumber } from 'lodash/fp';
|
||||||
import { errors } from '@strapi/utils';
|
import { errors } from '@strapi/utils';
|
||||||
import '@strapi/types';
|
import '@strapi/types';
|
||||||
import constants from '../constants';
|
import constants from '../constants';
|
||||||
@ -79,7 +79,7 @@ const create = async (attributes: TokenCreatePayload): Promise<TransferToken> =>
|
|||||||
delete attributes.accessKey;
|
delete attributes.accessKey;
|
||||||
|
|
||||||
assertTokenPermissionsValidity(attributes);
|
assertTokenPermissionsValidity(attributes);
|
||||||
assertValidLifespan(attributes);
|
assertValidLifespan(attributes.lifespan);
|
||||||
|
|
||||||
const result = (await strapi.db.transaction(async () => {
|
const result = (await strapi.db.transaction(async () => {
|
||||||
const transferToken = await strapi.query(TRANSFER_TOKEN_UID).create({
|
const transferToken = await strapi.query(TRANSFER_TOKEN_UID).create({
|
||||||
@ -131,7 +131,7 @@ const update = async (
|
|||||||
}
|
}
|
||||||
|
|
||||||
assertTokenPermissionsValidity(attributes);
|
assertTokenPermissionsValidity(attributes);
|
||||||
assertValidLifespan(attributes);
|
assertValidLifespan(attributes.lifespan);
|
||||||
|
|
||||||
return strapi.db.transaction(async () => {
|
return strapi.db.transaction(async () => {
|
||||||
const updatedToken = await strapi.query(TRANSFER_TOKEN_UID).update({
|
const updatedToken = await strapi.query(TRANSFER_TOKEN_UID).update({
|
||||||
@ -281,11 +281,9 @@ const regenerate = async (id: string | number): Promise<TransferToken> => {
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
const getExpirationFields = (
|
const getExpirationFields = (lifespan: TransferToken['lifespan']) => {
|
||||||
lifespan: number | null
|
|
||||||
): { lifespan: null | number; expiresAt: null | number } => {
|
|
||||||
// it must be nil or a finite number >= 0
|
// it must be nil or a finite number >= 0
|
||||||
const isValidNumber = Number.isFinite(lifespan) && lifespan !== null && lifespan > 0;
|
const isValidNumber = isNumber(lifespan) && Number.isFinite(lifespan) && lifespan > 0;
|
||||||
if (!isValidNumber && !isNil(lifespan)) {
|
if (!isValidNumber && !isNil(lifespan)) {
|
||||||
throw new ValidationError('lifespan must be a positive number or null');
|
throw new ValidationError('lifespan must be a positive number or null');
|
||||||
}
|
}
|
||||||
@ -359,14 +357,28 @@ const assertTokenPermissionsValidity = (attributes: TokenUpdatePayload) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Assert that a token's lifespan is valid
|
* Check if a token's lifespan is valid
|
||||||
*/
|
*/
|
||||||
const assertValidLifespan = ({ lifespan }: { lifespan?: TransferToken['lifespan'] }) => {
|
const isValidLifespan = (lifespan: unknown) => {
|
||||||
if (isNil(lifespan)) {
|
if (isNil(lifespan)) {
|
||||||
return;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!Object.values(constants.TRANSFER_TOKEN_LIFESPANS).includes(lifespan)) {
|
if (
|
||||||
|
!isNumber(lifespan) ||
|
||||||
|
!Object.values(constants.TRANSFER_TOKEN_LIFESPANS).includes(lifespan)
|
||||||
|
) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Assert that a token's lifespan is valid
|
||||||
|
*/
|
||||||
|
const assertValidLifespan = (lifespan: unknown) => {
|
||||||
|
if (!isValidLifespan(lifespan)) {
|
||||||
throw new ValidationError(
|
throw new ValidationError(
|
||||||
`lifespan must be one of the following values:
|
`lifespan must be one of the following values:
|
||||||
${Object.values(constants.TRANSFER_TOKEN_LIFESPANS).join(', ')}`
|
${Object.values(constants.TRANSFER_TOKEN_LIFESPANS).join(', ')}`
|
||||||
|
@ -8,7 +8,7 @@ export type ApiToken = {
|
|||||||
expiresAt: string;
|
expiresAt: string;
|
||||||
id: Entity.ID;
|
id: Entity.ID;
|
||||||
lastUsedAt: string | null;
|
lastUsedAt: string | null;
|
||||||
lifespan: string | number;
|
lifespan: string | number | null;
|
||||||
name: string;
|
name: string;
|
||||||
permissions: string[];
|
permissions: string[];
|
||||||
type: 'custom' | 'full-access' | 'read-only';
|
type: 'custom' | 'full-access' | 'read-only';
|
||||||
|
@ -1,7 +1,7 @@
|
|||||||
import { errors } from '@strapi/utils';
|
import { errors } from '@strapi/utils';
|
||||||
|
|
||||||
export interface TransferTokenPermission {
|
export interface TransferTokenPermission {
|
||||||
id: number | string;
|
id: number | `${number}`;
|
||||||
action: 'push' | 'pull' | 'push-pull';
|
action: 'push' | 'pull' | 'push-pull';
|
||||||
token: TransferToken | number;
|
token: TransferToken | number;
|
||||||
}
|
}
|
||||||
@ -12,7 +12,7 @@ export interface DatabaseTransferToken {
|
|||||||
description: string;
|
description: string;
|
||||||
accessKey: string;
|
accessKey: string;
|
||||||
lastUsedAt?: number;
|
lastUsedAt?: number;
|
||||||
lifespan: number | null;
|
lifespan: string | number | null;
|
||||||
expiresAt: number;
|
expiresAt: number;
|
||||||
permissions: TransferTokenPermission[];
|
permissions: TransferTokenPermission[];
|
||||||
}
|
}
|
||||||
|
@ -298,6 +298,9 @@ const TimezoneComponent = ({ timezoneOptions }: { timezoneOptions: ITimezoneOpti
|
|||||||
onChange={(timezone) => {
|
onChange={(timezone) => {
|
||||||
setFieldValue('timezone', timezone);
|
setFieldValue('timezone', timezone);
|
||||||
}}
|
}}
|
||||||
|
onTextValueChange={(timezone) => {
|
||||||
|
setFieldValue('timezone', timezone);
|
||||||
|
}}
|
||||||
onClear={() => {
|
onClear={() => {
|
||||||
setFieldValue('timezone', '');
|
setFieldValue('timezone', '');
|
||||||
}}
|
}}
|
||||||
|
@ -60,6 +60,8 @@ import {
|
|||||||
import { useTypedDispatch } from '../store/hooks';
|
import { useTypedDispatch } from '../store/hooks';
|
||||||
import { getTimezoneOffset } from '../utils/time';
|
import { getTimezoneOffset } from '../utils/time';
|
||||||
|
|
||||||
|
import { getBadgeProps } from './ReleasesPage';
|
||||||
|
|
||||||
import type {
|
import type {
|
||||||
ReleaseAction,
|
ReleaseAction,
|
||||||
ReleaseActionGroupBy,
|
ReleaseActionGroupBy,
|
||||||
@ -78,13 +80,20 @@ const ReleaseInfoWrapper = styled(Flex)`
|
|||||||
border-top: 1px solid ${({ theme }) => theme.colors.neutral150};
|
border-top: 1px solid ${({ theme }) => theme.colors.neutral150};
|
||||||
`;
|
`;
|
||||||
|
|
||||||
const StyledMenuItem = styled(Menu.Item)<{ disabled?: boolean }>`
|
const StyledMenuItem = styled(Menu.Item)<{
|
||||||
|
disabled?: boolean;
|
||||||
|
variant?: 'neutral' | 'danger';
|
||||||
|
}>`
|
||||||
svg path {
|
svg path {
|
||||||
fill: ${({ theme, disabled }) => disabled && theme.colors.neutral500};
|
fill: ${({ theme, disabled }) => disabled && theme.colors.neutral500};
|
||||||
}
|
}
|
||||||
span {
|
span {
|
||||||
color: ${({ theme, disabled }) => disabled && theme.colors.neutral500};
|
color: ${({ theme, disabled }) => disabled && theme.colors.neutral500};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: ${({ theme, variant = 'neutral' }) => theme.colors[`${variant}100`]};
|
||||||
|
}
|
||||||
`;
|
`;
|
||||||
|
|
||||||
const PencilIcon = styled(Pencil)`
|
const PencilIcon = styled(Pencil)`
|
||||||
@ -350,7 +359,13 @@ const ReleaseDetailsLayout = ({
|
|||||||
<HeaderLayout
|
<HeaderLayout
|
||||||
title={release.name}
|
title={release.name}
|
||||||
subtitle={
|
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={
|
navigationAction={
|
||||||
<Link startIcon={<ArrowLeft />} to="/plugins/content-releases">
|
<Link startIcon={<ArrowLeft />} to="/plugins/content-releases">
|
||||||
@ -394,14 +409,7 @@ const ReleaseDetailsLayout = ({
|
|||||||
width="100%"
|
width="100%"
|
||||||
>
|
>
|
||||||
<StyledMenuItem disabled={!canUpdate} onSelect={toggleEditReleaseModal}>
|
<StyledMenuItem disabled={!canUpdate} onSelect={toggleEditReleaseModal}>
|
||||||
<Flex
|
<Flex alignItems="center" gap={2} hasRadius width="100%">
|
||||||
paddingTop={2}
|
|
||||||
paddingBottom={2}
|
|
||||||
alignItems="center"
|
|
||||||
gap={2}
|
|
||||||
hasRadius
|
|
||||||
width="100%"
|
|
||||||
>
|
|
||||||
<PencilIcon />
|
<PencilIcon />
|
||||||
<Typography ellipsis>
|
<Typography ellipsis>
|
||||||
{formatMessage({
|
{formatMessage({
|
||||||
@ -411,15 +419,12 @@ const ReleaseDetailsLayout = ({
|
|||||||
</Typography>
|
</Typography>
|
||||||
</Flex>
|
</Flex>
|
||||||
</StyledMenuItem>
|
</StyledMenuItem>
|
||||||
<StyledMenuItem disabled={!canDelete} onSelect={toggleWarningSubmit}>
|
<StyledMenuItem
|
||||||
<Flex
|
disabled={!canDelete}
|
||||||
paddingTop={2}
|
onSelect={toggleWarningSubmit}
|
||||||
paddingBottom={2}
|
variant="danger"
|
||||||
alignItems="center"
|
>
|
||||||
gap={2}
|
<Flex alignItems="center" gap={2} hasRadius width="100%">
|
||||||
hasRadius
|
|
||||||
width="100%"
|
|
||||||
>
|
|
||||||
<TrashIcon />
|
<TrashIcon />
|
||||||
<Typography ellipsis textColor="danger600">
|
<Typography ellipsis textColor="danger600">
|
||||||
{formatMessage({
|
{formatMessage({
|
||||||
|
@ -4,6 +4,7 @@ import * as React from 'react';
|
|||||||
import { useLicenseLimits } from '@strapi/admin/strapi-admin';
|
import { useLicenseLimits } from '@strapi/admin/strapi-admin';
|
||||||
import {
|
import {
|
||||||
Alert,
|
Alert,
|
||||||
|
Badge,
|
||||||
Box,
|
Box,
|
||||||
Button,
|
Button,
|
||||||
ContentLayout,
|
ContentLayout,
|
||||||
@ -39,7 +40,7 @@ import { useIntl } from 'react-intl';
|
|||||||
import { useNavigate, useLocation } from 'react-router-dom';
|
import { useNavigate, useLocation } from 'react-router-dom';
|
||||||
import styled from 'styled-components';
|
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 { ReleaseModal, FormValues } from '../components/ReleaseModal';
|
||||||
import { PERMISSIONS } from '../constants';
|
import { PERMISSIONS } from '../constants';
|
||||||
import { isAxiosError } from '../services/axios';
|
import { isAxiosError } from '../services/axios';
|
||||||
@ -62,6 +63,37 @@ const LinkCard = styled(Link)`
|
|||||||
display: block;
|
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 ReleasesGrid = ({ sectionTitle, releases = [], isError = false }: ReleasesGridProps) => {
|
||||||
const { formatMessage } = useIntl();
|
const { formatMessage } = useIntl();
|
||||||
const IsSchedulingEnabled = window.strapi.future.isEnabled('contentReleasesScheduling');
|
const IsSchedulingEnabled = window.strapi.future.isEnabled('contentReleasesScheduling');
|
||||||
@ -89,7 +121,7 @@ const ReleasesGrid = ({ sectionTitle, releases = [], isError = false }: Releases
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Grid gap={4}>
|
<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}>
|
<GridItem col={3} s={6} xs={12} key={id}>
|
||||||
<LinkCard href={`content-releases/${id}`} isExternal={false}>
|
<LinkCard href={`content-releases/${id}`} isExternal={false}>
|
||||||
<Flex
|
<Flex
|
||||||
@ -102,32 +134,35 @@ const ReleasesGrid = ({ sectionTitle, releases = [], isError = false }: Releases
|
|||||||
height="100%"
|
height="100%"
|
||||||
width="100%"
|
width="100%"
|
||||||
alignItems="start"
|
alignItems="start"
|
||||||
gap={2}
|
gap={4}
|
||||||
>
|
>
|
||||||
<Typography as="h3" variant="delta" fontWeight="bold">
|
<Flex direction="column" alignItems="start" gap={1}>
|
||||||
{name}
|
<Typography as="h3" variant="delta" fontWeight="bold">
|
||||||
</Typography>
|
{name}
|
||||||
<Typography variant="pi" textColor="neutral600">
|
</Typography>
|
||||||
{IsSchedulingEnabled ? (
|
<Typography variant="pi" textColor="neutral600">
|
||||||
scheduledAt ? (
|
{IsSchedulingEnabled ? (
|
||||||
<RelativeTime timestamp={new Date(scheduledAt)} />
|
scheduledAt ? (
|
||||||
|
<CapitalizeRelativeTime timestamp={new Date(scheduledAt)} />
|
||||||
|
) : (
|
||||||
|
formatMessage({
|
||||||
|
id: 'content-releases.pages.Releases.not-scheduled',
|
||||||
|
defaultMessage: 'Not scheduled',
|
||||||
|
})
|
||||||
|
)
|
||||||
) : (
|
) : (
|
||||||
formatMessage({
|
formatMessage(
|
||||||
id: 'content-releases.pages.Releases.not-scheduled',
|
{
|
||||||
defaultMessage: 'Not scheduled',
|
id: 'content-releases.page.Releases.release-item.entries',
|
||||||
})
|
defaultMessage:
|
||||||
)
|
'{number, plural, =0 {No entries} one {# entry} other {# entries}}',
|
||||||
) : (
|
},
|
||||||
formatMessage(
|
{ number: actions.meta.count }
|
||||||
{
|
)
|
||||||
id: 'content-releases.page.Releases.release-item.entries',
|
)}
|
||||||
defaultMessage:
|
</Typography>
|
||||||
'{number, plural, =0 {No entries} one {# entry} other {# entries}}',
|
</Flex>
|
||||||
},
|
<Badge {...getBadgeProps(status)}>{status}</Badge>
|
||||||
{ number: actions.meta.count }
|
|
||||||
)
|
|
||||||
)}
|
|
||||||
</Typography>
|
|
||||||
</Flex>
|
</Flex>
|
||||||
</LinkCard>
|
</LinkCard>
|
||||||
</GridItem>
|
</GridItem>
|
||||||
@ -402,4 +437,4 @@ const ReleasesPage = () => {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export { ReleasesPage };
|
export { ReleasesPage, getBadgeProps };
|
||||||
|
@ -56,6 +56,9 @@ describe('Releases details page', () => {
|
|||||||
const releaseSubtitle = await screen.findAllByText('No entries');
|
const releaseSubtitle = await screen.findAllByText('No entries');
|
||||||
expect(releaseSubtitle[0]).toBeInTheDocument();
|
expect(releaseSubtitle[0]).toBeInTheDocument();
|
||||||
|
|
||||||
|
const releaseStatus = screen.getByText('empty');
|
||||||
|
expect(releaseStatus).toBeInTheDocument();
|
||||||
|
|
||||||
const moreButton = screen.getByRole('button', { name: 'Release edit and delete menu' });
|
const moreButton = screen.getByRole('button', { name: 'Release edit and delete menu' });
|
||||||
expect(moreButton).toBeInTheDocument();
|
expect(moreButton).toBeInTheDocument();
|
||||||
|
|
||||||
@ -160,7 +163,7 @@ describe('Releases details page', () => {
|
|||||||
expect(tables).toHaveLength(2);
|
expect(tables).toHaveLength(2);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('shows the right status', async () => {
|
it('shows the right status for unpublished release', async () => {
|
||||||
server.use(
|
server.use(
|
||||||
rest.get('/content-releases/:releaseId', (req, res, ctx) =>
|
rest.get('/content-releases/:releaseId', (req, res, ctx) =>
|
||||||
res(ctx.json(mockReleaseDetailsPageData.withActionsHeaderData))
|
res(ctx.json(mockReleaseDetailsPageData.withActionsHeaderData))
|
||||||
@ -187,6 +190,10 @@ describe('Releases details page', () => {
|
|||||||
);
|
);
|
||||||
expect(releaseTitle).toBeInTheDocument();
|
expect(releaseTitle).toBeInTheDocument();
|
||||||
|
|
||||||
|
const releaseStatus = screen.getByText('ready');
|
||||||
|
expect(releaseStatus).toBeInTheDocument();
|
||||||
|
expect(releaseStatus).toHaveStyle(`color: #328048`);
|
||||||
|
|
||||||
const cat1Row = screen.getByRole('row', { name: /cat1/i });
|
const cat1Row = screen.getByRole('row', { name: /cat1/i });
|
||||||
expect(within(cat1Row).getByRole('gridcell', { name: 'Ready to publish' })).toBeInTheDocument();
|
expect(within(cat1Row).getByRole('gridcell', { name: 'Ready to publish' })).toBeInTheDocument();
|
||||||
|
|
||||||
@ -200,4 +207,36 @@ describe('Releases details page', () => {
|
|||||||
within(add1Row).getByRole('gridcell', { name: 'Already published' })
|
within(add1Row).getByRole('gridcell', { name: 'Already published' })
|
||||||
).toBeInTheDocument();
|
).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(
|
||||||
|
<Routes>
|
||||||
|
<Route path="/content-releases/:releaseId" element={<ReleaseDetailsPage />} />
|
||||||
|
</Routes>,
|
||||||
|
{
|
||||||
|
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`);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
@ -9,6 +9,7 @@ const RELEASE_NO_ACTIONS_HEADER_MOCK_DATA = {
|
|||||||
createdAt: '2023-11-16T15:18:32.560Z',
|
createdAt: '2023-11-16T15:18:32.560Z',
|
||||||
updatedAt: '2023-11-16T15:18:32.560Z',
|
updatedAt: '2023-11-16T15:18:32.560Z',
|
||||||
releasedAt: null,
|
releasedAt: null,
|
||||||
|
status: 'empty',
|
||||||
createdBy: {
|
createdBy: {
|
||||||
id: 1,
|
id: 1,
|
||||||
firstname: 'Admin',
|
firstname: 'Admin',
|
||||||
@ -50,6 +51,7 @@ const RELEASE_WITH_ACTIONS_HEADER_MOCK_DATA = {
|
|||||||
createdAt: '2023-11-16T15:18:32.560Z',
|
createdAt: '2023-11-16T15:18:32.560Z',
|
||||||
updatedAt: '2023-11-16T15:18:32.560Z',
|
updatedAt: '2023-11-16T15:18:32.560Z',
|
||||||
releasedAt: null,
|
releasedAt: null,
|
||||||
|
status: 'ready',
|
||||||
createdBy: {
|
createdBy: {
|
||||||
id: 1,
|
id: 1,
|
||||||
firstname: 'Admin',
|
firstname: 'Admin',
|
||||||
@ -70,11 +72,12 @@ const RELEASE_WITH_ACTIONS_HEADER_MOCK_DATA = {
|
|||||||
|
|
||||||
const PUBLISHED_RELEASE_WITH_ACTIONS_HEADER_MOCK_DATA = {
|
const PUBLISHED_RELEASE_WITH_ACTIONS_HEADER_MOCK_DATA = {
|
||||||
data: {
|
data: {
|
||||||
id: 2,
|
id: 3,
|
||||||
name: 'release with actions',
|
name: 'release with actions',
|
||||||
createdAt: '2023-11-16T15:18:32.560Z',
|
createdAt: '2023-11-16T15:18:32.560Z',
|
||||||
updatedAt: '2023-11-16T15:18:32.560Z',
|
updatedAt: '2023-11-16T15:18:32.560Z',
|
||||||
releasedAt: '2023-11-16T15:18:32.560Z',
|
releasedAt: '2023-11-16T15:18:32.560Z',
|
||||||
|
status: 'done',
|
||||||
createdBy: {
|
createdBy: {
|
||||||
id: 1,
|
id: 1,
|
||||||
firstname: 'Admin',
|
firstname: 'Admin',
|
||||||
|
@ -12,6 +12,7 @@ interface CustomInterval {
|
|||||||
export interface RelativeTimeProps {
|
export interface RelativeTimeProps {
|
||||||
timestamp: Date;
|
timestamp: Date;
|
||||||
customIntervals?: CustomInterval[];
|
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 { formatRelativeTime, formatDate, formatTime } = useIntl();
|
||||||
|
|
||||||
const interval = intervalToDuration({
|
const interval = intervalToDuration({
|
||||||
@ -54,6 +55,7 @@ const RelativeTime = ({ timestamp, customIntervals = [] }: RelativeTimeProps) =>
|
|||||||
<time
|
<time
|
||||||
dateTime={timestamp.toISOString()}
|
dateTime={timestamp.toISOString()}
|
||||||
title={`${formatDate(timestamp)} ${formatTime(timestamp)}`}
|
title={`${formatDate(timestamp)} ${formatTime(timestamp)}`}
|
||||||
|
className={className}
|
||||||
>
|
>
|
||||||
{displayText}
|
{displayText}
|
||||||
</time>
|
</time>
|
||||||
|
@ -1,5 +1,6 @@
|
|||||||
// @ts-check
|
// @ts-check
|
||||||
const { devices } = require('@playwright/test');
|
const { devices } = require('@playwright/test');
|
||||||
|
const { parseType } = require('@strapi/utils');
|
||||||
|
|
||||||
const getEnvNum = (envVar, defaultValue) => {
|
const getEnvNum = (envVar, defaultValue) => {
|
||||||
if (envVar !== undefined && envVar !== null) {
|
if (envVar !== undefined && envVar !== null) {
|
||||||
@ -8,6 +9,22 @@ const getEnvNum = (envVar, defaultValue) => {
|
|||||||
return 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
|
* @typedef ConfigOptions
|
||||||
* @type {{ port: number; testDir: string; appDir: string }}
|
* @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.
|
* Maximum time expect() should wait for the condition to be met.
|
||||||
* For example in `await expect(locator).toHaveText();`
|
* 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 */
|
/* Run tests in files in parallel */
|
||||||
fullyParallel: false,
|
fullyParallel: false,
|
||||||
@ -46,13 +63,22 @@ const createConfig = ({ port, testDir, appDir }) => ({
|
|||||||
baseURL: `http://127.0.0.1:${port}`,
|
baseURL: `http://127.0.0.1:${port}`,
|
||||||
|
|
||||||
/* Default time each action such as `click()` can take to 20s */
|
/* 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
|
/* 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,
|
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.
|
because the Strapi server restarts every time a new file (trace) is created.
|
||||||
*/
|
*/
|
||||||
trace: '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 */
|
/* Configure projects for major browsers */
|
||||||
@ -80,7 +106,7 @@ const createConfig = ({ port, testDir, appDir }) => ({
|
|||||||
],
|
],
|
||||||
|
|
||||||
/* Folder for test artifacts such as screenshots, videos, traces, etc. */
|
/* 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 */
|
/* Run your local dev server before starting the tests */
|
||||||
webServer: {
|
webServer: {
|
||||||
|
Loading…
x
Reference in New Issue
Block a user