diff --git a/.github/actions/run-e2e-tests/script.sh b/.github/actions/run-e2e-tests/script.sh index a2d0743b40..a959ac3506 100755 --- a/.github/actions/run-e2e-tests/script.sh +++ b/.github/actions/run-e2e-tests/script.sh @@ -1,8 +1,10 @@ -## disable EE if options not set -if [[ -z "$RUN_EE" ]]; then - export STRAPI_DISABLE_EE=true -else +#!/usr/bin/env bash +## Align with `tests/utils/e2e-edition.ts`: explicit CE vs EE for the whole e2e run. +if [[ "${RUN_EE:-}" == "true" ]]; then + export STRAPI_E2E_EDITION=ee export STRAPI_DISABLE_LICENSE_PING=true +else + export STRAPI_E2E_EDITION=ce fi jestOptions=($JEST_OPTIONS) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 7bda723158..76589031c7 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -66,6 +66,7 @@ The Strapi core team will review your pull request and either merge it, request - `yarn test:front` - `yarn test:e2e --setup --concurrency=1` - you **_may_** need to install Playwright browsers first: `yarn playwright install` + - Enterprise (EE) e2e: set `STRAPI_LICENSE` in **`tests/e2e/.env`** (see [`docs/docs/guides/e2e/00-setup.md`](docs/docs/guides/e2e/00-setup.md)) - Make sure your code lints by running `yarn lint`. - If your contribution fixes an existing issue, please make sure to link it in your pull request. diff --git a/docs/docs/guides/e2e/00-setup.md b/docs/docs/guides/e2e/00-setup.md index 83f12599dd..9b63bbe742 100644 --- a/docs/docs/guides/e2e/00-setup.md +++ b/docs/docs/guides/e2e/00-setup.md @@ -19,7 +19,28 @@ To run the e2e tests, you must first install the playwright browsers. npx playwright install ``` -Because we require a "fresh" instance to assert our e2e tests against this is included in the testing script so all you need to run is: +### Running Enterprise (EE) tests locally + +To run the suite in **Enterprise** mode (or to let `yarn test:e2e` **auto-detect** EE when possible), put your license in the **e2e-specific env file**: + +1. Copy `tests/e2e/.env.example` to **`tests/e2e/.env`** (path relative to the **monorepo root**). Do **not** put e2e-only variables in a `.env` at the monorepo root; the runner will not load it. +2. Set **`STRAPI_LICENSE`** to your license string (same role as the GitHub Actions secret `strapiLicense` on CI). + +The unified runner (`tests/scripts/run-tests.js`) loads **`tests/e2e/.env`** with `dotenv` before starting Playwright; it does **not** automatically load `.env` from the repository root. Yarn/npm **`cross-env` does not read `.env` files** — only that loader does. + +Then use: + +| Goal | Command | +| ----------------------------------------------------------------------- | ------------------ | +| Auto (EE if `STRAPI_LICENSE` is set, else CE) | `yarn test:e2e` | +| Always CE (license stripped from the runner process so Strapi stays CE) | `yarn test:e2e:ce` | +| Always EE runner mode (exits if `STRAPI_LICENSE` is missing) | `yarn test:e2e:ee` | + +Full precedence and CI behavior are documented in [CE vs EE and environment variables](#e2e-ce-ee-env) below. + +### Default run + +Because we require a "fresh" instance to assert our e2e tests against, that is included in the testing script. After `npx playwright install` (and optional EE `.env` above), run: ```shell yarn test:e2e @@ -27,7 +48,7 @@ yarn test:e2e This will spawn by default a Strapi instance per testing domain (e.g. content-manager) in `test-apps` where the an individual `playwright.config` will start the instance and run tests against. It will automatically link the dependencies from the instance to the monorepo because `test-apps` are not considered part of the monorepo but we want to be using the most recent version of strapi (published or development) therefore meaning our most recent code changes can be tested against. -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, run `yarn test:e2e:clean` (that target invokes `run-e2e-tests.js clean`). ### Running specific tests @@ -35,18 +56,18 @@ To run only one domain, meaning a top-level directory in e2e/tests such as "admi ```shell 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: +To pass file filters or Playwright-only flags (`--project`, `--grep`, `--reporter`, `--debug`, …), put them **after** a `--` that follows the runner options. (If you use **npm** instead of yarn, add one more `--` before those args: `npm run test:e2e -- --domains=admin -- login.spec.ts`.) ```shell -# to run just the login.spec.ts file in the admin domain +# run only login.spec.ts in the admin domain 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" +You should still scope with `--domains` when filtering by file; otherwise every domain may be invoked and domains without that file can fail with "no tests found". + +For **CI, scripts, or automation**, prefer `--reporter=line` after the inner `--` so the run exits without waiting on the HTML reporter. ### Running specific browsers @@ -54,7 +75,6 @@ To run only a specific browser (to speed up test development, for example) you c ```shell yarn test:e2e --domains=admin -- login.spec.ts --project=chromium -npm run test:e2e --domains=admin -- login.spec.ts --project=chromium ``` To debug your tests with a browser instance and the playwright debugger, you can pass the @@ -67,37 +87,74 @@ yarn test:e2e --domains admin -- login.spec.ts --debug ### Concurrency / parallelization -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 +The runner uses `min(number of selected domains, concurrency)` **test apps** (`test-apps/e2e/test-app-*`), each bound to a port `8000 + index` within a batch. **`concurrency` defaults** to the number of domain folders under `tests/e2e/tests/` (not the count after `--domains`). Domains are chunked into batches of that size: domains in a batch run **in parallel**, batches run **one after another**. Spec files **inside** a domain are **serial** (`workers: 1`, `fullyParallel: false` in `playwright.base.config.js`). ```shell -# only run one domain at a time +# run at most one domain at a time (simplest logs; fully serial domains) yarn test:e2e -c 1 + +# example: at most three domains at once, then the next three, etc. +yarn test:e2e -c 3 ``` ### 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 | +| env var | Description | Default | +| -------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -------------------------------------------------------------- | +| PLAYWRIGHT_WEBSERVER_TIMEOUT | Timeout (ms) for starting the Strapi server | 160000 (160s) | +| PLAYWRIGHT_ACTION_TIMEOUT | Playwright action timeout (e.g. `click()`) | 10000 (10s) | +| PLAYWRIGHT_EXPECT_TIMEOUT | `expect()` assertion timeout | 10000 (10s) | +| PLAYWRIGHT_TIMEOUT | Per-test timeout | 90000 (90s) | +| PLAYWRIGHT_OUTPUT_DIR | Base for traces/screenshots/videos; each domain uses a subfolder `-` under that base. Also used as the JUnit output directory when set; when unset, JUnit defaults to `test-apps/junit-reports`. | `../test-results` (relative to each generated test app config) | +| PLAYWRIGHT_VIDEO | Set `true` to save videos on failed tests | false | +| PLAYWRIGHT_REUSE_EXISTING_SERVER | If `true` (local only; **ignored when `CI` is set**), Playwright may skip starting Strapi when the test URL already responds — faster if you keep a matching server up, **risky** if edition or license differs from this run. | `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. +The test-app you create uses a [template](https://docs.strapi.io/developer-docs/latest/setup-deployment-guides/installation/templates.html) under `tests/app-template`. There we store premade content schemas and customisations such as other plugins, custom fields, or endpoints. If you add anything to the template, be sure to add this information to [the docs](/guides/e2e/app-template). -## Running tests with environment variables (needed to run EE tests) +## Running tests with environment variables (needed to run EE tests) {#e2e-ce-ee-env} -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. +Create **`tests/e2e/.env`** next to the e2e tests (see **`tests/e2e/.env.example`**). **`tests/scripts/run-tests.js`** loads that file with `dotenv` when it exists, then **`tests/utils/e2e-edition.ts`** applies CE vs EE so the runner, Playwright, and Strapi agree. Do not rely on the **repository root** `.env` for e2e — it is not loaded unless you change the runner. + +Optional: **`STRAPI_DISABLE_LICENSE_PING=true`** in the same file can match CI EE jobs when your license is offline-only (see `.github/actions/run-e2e-tests/script.sh`). + +### `STRAPI_E2E_EDITION` (recommended mental model) + +| Value | Meaning | +| ----- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| `ce` | Community Edition: `STRAPI_DISABLE_EE=true` for Strapi; `STRAPI_LICENSE` is stripped from the runner env so children cannot boot as EE; EE-only specs are skipped. | +| `ee` | Enterprise: `STRAPI_DISABLE_EE` cleared; `STRAPI_LICENSE` must be present (and valid for Strapi to boot as EE). | + +**Resolution order** (see `tests/utils/e2e-edition.ts`): + +1. **`yarn test:e2e:ce`** → always CE (license removed from env for this process). +2. **`yarn test:e2e:ee`** → EE; **exits with an error** if `STRAPI_LICENSE` is missing. +3. **`STRAPI_E2E_EDITION`** set to **`ce`** or **`ee`** (after `tests/e2e/.env` is loaded, plus CI `script.sh` or your shell) — use that edition (`ee` without a license falls back to CE with a warning unless you used `test:e2e:ee`, which fails instead). +4. Else **auto**: EE if `STRAPI_LICENSE` is non-empty, else CE. + +**CI:** `.github/workflows/tests.yml` defines two jobs: + +- **`e2e_ce`** — no `STRAPI_LICENSE` secret; composite action runs with `runEE: false` → `script.sh` sets `STRAPI_E2E_EDITION=ce`. +- **`e2e_ee`** — `env STRAPI_LICENSE: ${{ secrets.strapiLicense }}` and `runEE: true` → `STRAPI_E2E_EDITION=ee` and `STRAPI_DISABLE_LICENSE_PING=true`. + +CI runs **`script.sh`**, which exports **`STRAPI_E2E_EDITION`** (`ce` vs `ee`) before `yarn test:e2e`. **`e2e-edition.ts`** reads that (and **`STRAPI_LICENSE`**, **`STRAPI_DISABLE_LICENSE_PING`**, etc.) so the runner matches the job. Locally, the same applies: if you **`export STRAPI_E2E_EDITION=ce`** (or `ee`), that explicit value is respected when you run `yarn test:e2e`. If you want **pure auto** (license only: EE when `STRAPI_LICENSE` is set, else CE), **`unset STRAPI_E2E_EDITION`** so it is not set in your shell. + +**Local — Yarn scripts** (`package.json`): + +| Script | Behavior | +| ------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `yarn test:e2e` | Uses **`STRAPI_E2E_EDITION`** from the environment when set (`ce` / `ee`, per the table above); otherwise **auto**: EE if `STRAPI_LICENSE` is set in `tests/e2e/.env` (or exported), otherwise CE. | +| `yarn test:e2e:ce` | Always **CE**; license is stripped from env so Strapi cannot start as EE. | +| `yarn test:e2e:ee` | **Fails fast** without `STRAPI_LICENSE`; otherwise EE in the runner (Strapi still needs a valid license to boot as Enterprise). | + +For convenience you can still use **`yarn test:e2e:ce`** / **`yarn test:e2e:ee`** instead of setting **`STRAPI_E2E_EDITION`** yourself. + +Playwright’s `reuseExistingServer` is **off by default** (see **`PLAYWRIGHT_REUSE_EXISTING_SERVER`** in the **Env Variables to Control Test Config** table) so a process already listening on the test port is not mistaken for this run’s Strapi — edition and env match what `e2e-edition.ts` applied. ## Running tests with future flags diff --git a/docs/docs/guides/e2e/01-app-template.md b/docs/docs/guides/e2e/01-app-template.md index 2e7a6a5e94..a06d3eeec0 100644 --- a/docs/docs/guides/e2e/01-app-template.md +++ b/docs/docs/guides/e2e/01-app-template.md @@ -9,7 +9,7 @@ tags: ## Overview -An app template has been created in `e2e/app-template` which provide some customizations and utilities to allow the tests to run. Note that if any changes are made to the app template, you will need to run `yarn test:e2e:clean` to update the test apps with the new template. +An app template lives under **`tests/app-template`** (shared with other test types). It provides customizations and utilities to allow the tests to run. If you change the template, run **`yarn test:e2e:clean`** and regenerate test apps so they pick up the new template. Here you can read about what content schemas the test instance has & the API customisations we've built (incl. why we built them). @@ -21,7 +21,7 @@ The app template should be realistic and structured in a way an actual user migh To update the app template: -- Run `yarn test:e2e clean` to remove existing test apps +- Run `yarn test:e2e:clean` to remove existing test apps - Run `yarn test:e2e -c=1 -- --ui` to generate a test app (don't run any tests) - Follow the instructions to [import the existing data set](./02-data-transfer.md#importing-an-existing-data-packet) - With the test app server running on 1337 you can now login to the app @@ -30,7 +30,7 @@ To update the app template: Once the app template is updated: -- Run `yarn test:e2e clean` to remove existing test apps +- Run `yarn test:e2e:clean` to remove existing test apps - Run `yarn test:e2e -c=1 -- --ui` to generate a new test app using the updated template (don't run any tests) - Follow the instructions to [import the existing data set](./02-data-transfer.md#importing-an-existing-data-packet) - Follow the instructions to [export the updated data set](./02-data-transfer.md#exporting-an-updated-data-packet) diff --git a/docs/docs/guides/e2e/02-data-transfer.md b/docs/docs/guides/e2e/02-data-transfer.md index 580ed90446..6629b3f999 100644 --- a/docs/docs/guides/e2e/02-data-transfer.md +++ b/docs/docs/guides/e2e/02-data-transfer.md @@ -37,6 +37,8 @@ When you need to update the data packet for a new test, you will first need a St When running the `yarn test:e2e` command, test app instances are created in `test-apps/e2e/test-app-{n}`. You can use one of the these apps to update the data. +For **EE** features, use the same **`STRAPI_LICENSE`** as for e2e: set it in **`tests/e2e/.env`** (see [E2E setup → environment variables](./00-setup.md#e2e-ce-ee-env)) or **export** it in your shell for the manual `dts-import` / `dts-export` commands below (those scripts do not load `tests/e2e/.env` by themselves). + Navigate to one of the test-apps and run `yarn install && yarn develop` Leave the development server running, and then run the following command to reset and seed the database with the current e2e data packet. The script expects the name of the data packet you want to import found in `tests/e2e/data`. diff --git a/package.json b/package.json index a51d7d13f9..381780159a 100644 --- a/package.json +++ b/package.json @@ -66,6 +66,8 @@ "test:cli:debug": "node tests/scripts/run-cli-tests.js --debug", "test:cli:update": "node tests/scripts/run-cli-tests.js -u", "test:e2e": "node tests/scripts/run-e2e-tests.js", + "test:e2e:ce": "cross-env STRAPI_E2E_EDITION=ce node tests/scripts/run-e2e-tests.js", + "test:e2e:ee": "cross-env STRAPI_E2E_EDITION=ee node tests/scripts/run-e2e-tests.js", "test:e2e:clean": "node tests/scripts/run-e2e-tests.js clean", "test:front": "cross-env IS_EE=true jest --config jest.config.front.js --runInBand", "test:front:all": "cross-env IS_EE=true nx run-many --target=test:front --nx-ignore-cycles", diff --git a/playwright.base.config.js b/playwright.base.config.js index 6e4de00a1d..5c30af63df 100644 --- a/playwright.base.config.js +++ b/playwright.base.config.js @@ -28,119 +28,152 @@ const getEnvBool = (envVar, defaultValue) => { /** * @typedef ConfigOptions - * @type {{ port: number; testDir: string; appDir: string; reportFileName: string }} + * @type {{ port: number; testDir: string; appDir: string; reportFileName: string; domain: string }} */ /** + * Port comes from `tests/scripts/run-tests.js` (`8000 + testAppIndex`). Do not default to 8000 here: + * the second app uses 8001, etc. The generated `playwright.config.js` also assigns `process.env.PORT` + * so `globalSetup` and test helpers match `baseURL` / `webServer`. + * * @see https://playwright.dev/docs/test-configuration * @type {(options: ConfigOptions) => import('@playwright/test').PlaywrightTestConfig} */ -const createConfig = ({ port, testDir, appDir, reportFileName }) => ({ - testDir, - testMatch: '*.spec.ts', +const createConfig = ({ port, testDir, appDir, reportFileName, domain }) => { + // Isolate artifacts per domain+port: every test app resolves `../test-results/` to the same + // `test-apps/e2e/test-results/` folder, and the HTML reporter defaults to cwd `playwright-report/`, + // so parallel `yarn test:e2e` runs overwrite each other without subfolders. + const artifactKey = `${domain}-${port}`; + const outputDirBase = getEnvString( + process.env.PLAYWRIGHT_OUTPUT_DIR, + path.join('..', 'test-results') + ); + const outputDir = path.join(outputDirBase, artifactKey); + const htmlReportFolder = path.join(__dirname, 'test-apps', 'e2e', 'html-report', artifactKey); - /* default timeout for a jest test */ - timeout: getEnvNum(process.env.PLAYWRIGHT_TIMEOUT, 90 * 1000), + return { + testDir, + testMatch: '*.spec.ts', - /* Global setup to set localStorage for all tests */ - globalSetup: require.resolve('./tests/utils/global-setup.ts'), + /* default timeout for a jest test */ + timeout: getEnvNum(process.env.PLAYWRIGHT_TIMEOUT, 90 * 1000), - expect: { - /** - * 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, 10 * 1000), - }, - /* Run tests in files in parallel */ - fullyParallel: false, - /* Fail the build on CI if you accidentally left test.only in the source code. */ - forbidOnly: !!process.env.CI, - /* Retry on CI only */ - retries: process.env.CI ? 3 : 1, - /* Opt out of parallel tests on CI. */ - workers: 1, - /* Reporter to use. See https://playwright.dev/docs/test-reporters */ - reporter: [ - ['html'], - // Junit reporter for Trunk flaky test CI upload - [ - 'junit', + /* Global setup to set localStorage for all tests */ + globalSetup: require.resolve('./tests/utils/global-setup.ts'), + + expect: { + /** + * 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, 10 * 1000), + }, + /* Run tests in files in parallel */ + fullyParallel: false, + /* Fail the build on CI if you accidentally left test.only in the source code. */ + forbidOnly: !!process.env.CI, + /* Retry on CI only */ + retries: process.env.CI ? 3 : 1, + /* Opt out of parallel tests on CI. */ + workers: 1, + /* Reporter to use. See https://playwright.dev/docs/test-reporters */ + reporter: [ + ['html', { outputFolder: htmlReportFolder }], + // Junit reporter for Trunk flaky test CI upload + [ + 'junit', + { + outputFile: path.join( + getEnvString( + process.env.PLAYWRIGHT_OUTPUT_DIR, + path.join(__dirname, 'test-apps', 'junit-reports') + ), + reportFileName + ), + }, + ], + ], + /* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */ + use: { + /* Base URL to use in actions like `await page.goto('/')`. */ + baseURL: `http://127.0.0.1:${port}`, + + /** Set timezone for consistency across any machine*/ + timezoneId: 'Europe/Paris', + + /* Default time each action such as `click()` can take */ + actionTimeout: getEnvNum(process.env.PLAYWRIGHT_ACTION_TIMEOUT, 10 * 1000), + // Only record trace when retrying a test to optimize test performance + trace: process.env.CI ? 'on-first-retry' : 'retain-on-failure', + video: getEnvBool(process.env.PLAYWRIGHT_VIDEO, false) + ? { + mode: 'on-first-retry', // Only save videos when retrying a test + size: { + width: 1280, + height: 720, + }, + } + : 'off', + + /* Use the storage state with localStorage set globally */ + storageState: './tests/e2e/playwright-storage-state.json', + }, + + /* Configure projects for major browsers */ + projects: [ { - outputFile: path.join( - getEnvString(process.env.PLAYWRIGHT_OUTPUT_DIR, '../../junit-reports/'), - reportFileName - ), + name: 'chromium', + use: { + ...devices['Desktop Chrome'], + permissions: ['clipboard-read', 'clipboard-write'], + }, + }, + + { + name: 'firefox', + use: { + ...devices['Desktop Firefox'], + // Firefox doesn't need clipboard permissions for secure sites + }, + }, + + { + name: 'webkit', + use: { + ...devices['Desktop Safari'], + permissions: ['clipboard-read'], + }, }, ], - ], - /* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */ - use: { - /* Base URL to use in actions like `await page.goto('/')`. */ - baseURL: `http://127.0.0.1:${port}`, - /** Set timezone for consistency across any machine*/ - timezoneId: 'Europe/Paris', + /* 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, - /* Default time each action such as `click()` can take */ - actionTimeout: getEnvNum(process.env.PLAYWRIGHT_ACTION_TIMEOUT, 10 * 1000), - // Only record trace when retrying a test to optimize test performance - trace: process.env.CI ? 'on-first-retry' : 'retain-on-failure', - video: getEnvBool(process.env.PLAYWRIGHT_VIDEO, false) - ? { - mode: 'on-first-retry', // Only save videos when retrying a test - size: { - width: 1280, - height: 720, - }, - } - : 'off', - - /* Use the storage state with localStorage set globally */ - storageState: './tests/e2e/playwright-storage-state.json', - }, - - /* Configure projects for major browsers */ - projects: [ - { - name: 'chromium', - use: { - ...devices['Desktop Chrome'], - permissions: ['clipboard-read', 'clipboard-write'], + /* Run your local dev server before starting the tests */ + webServer: { + command: `cd ${appDir} && npm run develop -- --no-watch-admin`, + url: `http://127.0.0.1:${port}`, + // Strapi reads PORT/HOST from env (see tests/app-template/config/server.js). Without this, + // `yarn playwright test --config test-apps/e2e/test-app-0/playwright.config.js` leaves PORT + // unset → default 1337 while baseURL/webServer.url expect 8000+ (browser-runner sets PORT). + env: { + PORT: String(port), + HOST: '127.0.0.1', }, + /* default Strapi server startup timeout to 160s */ + timeout: getEnvNum(process.env.PLAYWRIGHT_WEBSERVER_TIMEOUT, 160 * 1000), + // If true, Playwright skips `command` when `url` already responds — you may get the wrong + // edition or stale env (license / STRAPI_DISABLE_EE) vs this run. Default: never reuse; + // set PLAYWRIGHT_REUSE_EXISTING_SERVER=true locally when you intentionally keep a matching + // server up. CI always starts fresh. + reuseExistingServer: process.env.CI + ? false + : getEnvBool(process.env.PLAYWRIGHT_REUSE_EXISTING_SERVER, false), + stdout: 'pipe', }, - - { - name: 'firefox', - use: { - ...devices['Desktop Firefox'], - // Firefox doesn't need clipboard permissions for secure sites - }, - }, - - { - name: 'webkit', - use: { - ...devices['Desktop Safari'], - permissions: ['clipboard-read'], - }, - }, - ], - - /* 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: { - command: `cd ${appDir} && npm run develop -- --no-watch-admin`, - url: `http://127.0.0.1:${port}`, - /* default Strapi server startup timeout to 160s */ - timeout: getEnvNum(process.env.PLAYWRIGHT_WEBSERVER_TIMEOUT, 160 * 1000), - reuseExistingServer: true, - stdout: 'pipe', - }, -}); + }; +}; module.exports = { createConfig }; diff --git a/tests/app-template/.gitignore b/tests/app-template/.gitignore index 3407311492..f1aeb519c5 100644 --- a/tests/app-template/.gitignore +++ b/tests/app-template/.gitignore @@ -117,6 +117,8 @@ yarn-error.log coverage +playwright.config.js + ############################ # Strapi ############################ diff --git a/tests/e2e/README.md b/tests/e2e/README.md index 864038075e..0b26df58d9 100644 --- a/tests/e2e/README.md +++ b/tests/e2e/README.md @@ -1,13 +1,16 @@ # End-to-End Playwright Tests -## Overview +Playwright specs live under **`tests/e2e/tests/`** (each top-level folder is a **domain**). E2E shares app generation and the unified runner (`tests/scripts/run-tests.js`) with CLI tests. -E2E tests use Playwright to test Strapi's browser-based functionality. They share the same infrastructure as CLI tests: +**Do not duplicate setup here.** Use the contributor documentation: -- **Shared app template**: Both test types use `tests/app-template` to generate test applications -- **Shared utilities**: Common test utilities are in `tests/utils/` -- **Shared runners**: Test app setup and management is handled by `tests/utils/runners/shared-setup.js` -- **Unified test runner**: Both use `tests/scripts/run-tests.js` with different execution strategies +| Guide | Contents | +| --------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| [Setup](../../docs/docs/guides/e2e/00-setup.md) | Playwright install, `tests/e2e/.env` / `STRAPI_LICENSE`, `yarn test:e2e` / `:ce` / `:ee`, domains, concurrency, runner vs Playwright args, env vars, cleaning test apps | +| [App template](../../docs/docs/guides/e2e/01-app-template.md) | How the shared `tests/app-template` feeds generated `test-apps/e2e/` | +| [Data transfer](../../docs/docs/guides/e2e/02-data-transfer.md) | DTS-related e2e workflows | + +For scripted or agent-driven runs from the repo root, see **[AGENTS.md](../../AGENTS.md)**. The main difference is that e2e tests use Playwright for browser automation, while CLI tests use Jest for command-line testing. diff --git a/tests/e2e/tests/admin/admin-auth-sessions.spec.ts b/tests/e2e/tests/admin/admin-auth-sessions.spec.ts index e80c70962d..f35d8d4fda 100644 --- a/tests/e2e/tests/admin/admin-auth-sessions.spec.ts +++ b/tests/e2e/tests/admin/admin-auth-sessions.spec.ts @@ -1,5 +1,6 @@ import { test, expect } from '@playwright/test'; import { resetDatabaseAndImportDataFromPath } from '../../../utils/dts-import'; +import { gotoAdminPath } from '../../../utils/shared'; import { login } from '../../../utils/login'; import { TITLE_LOGIN, TITLE_HOME } from '../../constants'; @@ -7,7 +8,7 @@ test.describe('Legacy Admin Token Migration', () => { test.beforeEach(async ({ page, context }) => { await resetDatabaseAndImportDataFromPath('with-admin'); await context.clearCookies(); - await page.goto('/admin'); + await gotoAdminPath(page); }); test('should force logout on first interaction with legacy token', async ({ page }) => { @@ -18,7 +19,7 @@ test.describe('Legacy Admin Token Migration', () => { }); // Try to access admin - should be redirected to login - await page.goto('/admin'); + await gotoAdminPath(page); // Should be redirected to login page await expect(page).toHaveTitle(TITLE_LOGIN); @@ -33,7 +34,7 @@ test.describe('Legacy Admin Token Migration', () => { }); // Navigate to login page - await page.goto('/admin'); + await gotoAdminPath(page); await expect(page).toHaveTitle(TITLE_LOGIN); // Perform fresh login @@ -65,7 +66,7 @@ test.describe('Legacy Admin Token Migration', () => { }); // Navigate to trigger authentication check - should redirect to login - await page.goto('/admin/settings'); + await gotoAdminPath(page, 'settings'); await expect(page).toHaveTitle(TITLE_LOGIN); await expect(page.getByText('Log in to your Strapi account')).toBeVisible(); }); diff --git a/tests/e2e/tests/admin/guided-tour.spec.ts b/tests/e2e/tests/admin/guided-tour.spec.ts index fe045ed9a2..3f60baaac2 100644 --- a/tests/e2e/tests/admin/guided-tour.spec.ts +++ b/tests/e2e/tests/admin/guided-tour.spec.ts @@ -1,20 +1,34 @@ import { test, expect } from '@playwright/test'; import { sharedSetup } from '../../../utils/setup'; import { STRAPI_GUIDED_TOUR_CONFIG, setGuidedTourLocalStorage } from '../../../utils/global-setup'; -import { clickAndWait, describeOnCondition } from '../../../utils/shared'; +import { + clickAndWait, + clickGuidedTourDialogNext, + describeOnCondition, + waitForGuidedTourOverviewReady, + waitForGuidedTourCompletedInStorage, +} from '../../../utils/shared'; import { waitForRestart } from '../../../utils/restart'; const edition = process.env.STRAPI_DISABLE_EE === 'true' ? 'CE' : 'EE'; +const GUIDED_TOUR_COLLECTION_DISPLAY_NAME = 'guided tour'; + +/** Expected Content Manager URL for the collection type created in this spec (keep aligned with display name / CTB UID). */ +const GUIDED_TOUR_COLLECTION_CONTENT_MANAGER_URL = + /.*\/admin\/content-manager\/collection-types\/api::guided-tour\.guided-tour.*/; + describeOnCondition(edition !== 'EE')('Guided tour', () => { - test.use({ viewport: { width: 1920, height: 1080 } }); + // Explicit size + scale so local headed runs match CI; tour popovers sit near the bottom edge. + test.use({ deviceScaleFactor: 1, viewport: { width: 1920, height: 1200 } }); test.beforeEach(async ({ page }) => { await setGuidedTourLocalStorage(page, { ...STRAPI_GUIDED_TOUR_CONFIG, enabled: true }); - await sharedSetup('guided-tour', page, { + await sharedSetup('guided-tour-admin', page, { login: true, resetFiles: true, importData: 'with-admin', + resetAlways: true, }); }); @@ -32,41 +46,39 @@ describeOnCondition(edition !== 'EE')('Guided tour', () => { /** * Content Type Builder */ - await clickAndWait( - page, - page.getByRole('listitem', { name: 'Create your schema' }).getByRole('link', { - name: 'Start', - }) - ); + await waitForGuidedTourOverviewReady(page); + const startCtbTourLink = page + .getByRole('listitem', { name: 'Create your schema' }) + .getByRole('link', { name: 'Start' }); + await expect(startCtbTourLink).toBeEnabled(); + // Avoid clickAndWait's networkidle here: the admin SPA often never reaches "idle" (polling), + // and the overview can mount after guided-tour-meta — wait for route + CTB dialog instead. + await Promise.all([ + page.waitForURL(/\/plugins\/content-type-builder/, { timeout: 30_000 }), + startCtbTourLink.click(), + ]); + await expect( + page.getByRole('dialog', { name: 'Welcome to the Content-Type Builder!' }) + ).toBeVisible(); const nextButton = page.getByRole('button', { name: 'Next' }); const gotItButton = page.getByRole('button', { name: 'Got it' }); - await page - .getByRole('dialog', { name: 'Welcome to the Content-Type Builder!' }) - .getByRole('button', { name: 'Next' }) - .click(); - await page - .getByRole('dialog', { name: 'Collection Types' }) - .getByRole('button', { name: 'Next' }) - .click(); - await page - .getByRole('dialog', { name: 'Single Types' }) - .getByRole('button', { name: 'Next' }) - .click(); - await page - .getByRole('dialog', { name: 'Components' }) - .getByRole('button', { name: 'Next' }) - .click(); - await page - .getByRole('dialog', { name: 'Your turn — Build something!' }) - .getByRole('button', { name: 'Next' }) - .click(); + await clickGuidedTourDialogNext(page, 'Welcome to the Content-Type Builder!'); + await clickGuidedTourDialogNext(page, 'Collection Types'); + await clickGuidedTourDialogNext(page, 'Single Types'); + await clickGuidedTourDialogNext(page, 'Components'); + await clickGuidedTourDialogNext(page, 'Your turn — Build something!'); - // Create collection type + // Create collection type — Continue persists the type and moves to the schema editor; wait for network + // so the guided tour can attach the next step before we assert on the dialog (fast runners can race otherwise). await page.getByRole('button', { name: 'Create new collection type' }).click(); - await page.getByRole('textbox', { name: 'Display name' }).fill('Test'); - await page.getByRole('button', { name: 'Continue' }).click(); + await page + .getByRole('textbox', { name: 'Display name' }) + .fill(GUIDED_TOUR_COLLECTION_DISPLAY_NAME); + await clickAndWait(page, page.getByRole('button', { name: 'Continue' })); + // Empty schema list + tour anchor must mount before the popover dialog; meta/localStorage races otherwise. + await expect(page.getByRole('button', { name: 'Add new field' }).last()).toBeVisible(); await expect( page.getByRole('dialog', { name: 'Add a field to bring it to life' }) ).toBeVisible(); @@ -88,7 +100,7 @@ describeOnCondition(edition !== 'EE')('Guided tour', () => { await expect(page.getByRole('dialog', { name: 'First Step: Done! 🎉' })).toBeVisible(); await clickAndWait(page, page.getByRole('link', { name: 'Next' })); - await expect(page).toHaveURL(/.*\/admin\/content-manager\/collection-types\/api::test.test.*/); + await expect(page).toHaveURL(GUIDED_TOUR_COLLECTION_CONTENT_MANAGER_URL); await page.goto('/admin'); await expect( @@ -189,7 +201,10 @@ describeOnCondition(edition !== 'EE')('Guided tour', () => { }) ); - await page.goto('/admin'); + // Do not `page.goto('/admin')` here: we are already on the homepage after the API tokens step. + // A full reload rehydrates from localStorage; React can show "Done" before STRAPI_GUIDED_TOUR + // is flushed, so reload would wipe the Deploy row back to incomplete. + await waitForGuidedTourCompletedInStorage(page, 'strapiCloud'); await expect( page diff --git a/tests/e2e/tests/content-manager/blocks.spec.ts b/tests/e2e/tests/content-manager/blocks.spec.ts index 4595f0745e..7b44073dd5 100644 --- a/tests/e2e/tests/content-manager/blocks.spec.ts +++ b/tests/e2e/tests/content-manager/blocks.spec.ts @@ -1,7 +1,7 @@ import { test, expect } from '@playwright/test'; import { login } from '../../../utils/login'; import { resetDatabaseAndImportDataFromPath } from '../../../utils/dts-import'; -import { navToHeader } from '../../../utils/shared'; +import { findAndClose, navToHeader } from '../../../utils/shared'; test.describe('Blocks editor', () => { test.beforeEach(async ({ page }) => { @@ -45,11 +45,35 @@ test.describe('Blocks editor', () => { await page.keyboard.press('Enter'); await expect(page.getByText('Fortran')).not.toBeVisible(); - // Save and reload to make sure the change is persisted + // Save and reload to make sure the change is persisted. Await the PUT — the toast can resolve before + // the request finishes on fast machines. + const savePut = page.waitForResponse( + (response) => + response.request().method() === 'PUT' && + response.url().includes('/content-manager/single-types/api::homepage.homepage') && + response.ok() + ); await page.getByRole('button', { name: 'Save' }).click(); + await savePut; + await findAndClose(page, 'Saved document'); + await page.reload(); - await expect(page.getByText(code)).toBeVisible(); - await page.getByText(code).click(); - await expect(page.getByText('Fortran')).toBeVisible(); + + await expect(page.getByRole('link', { name: 'Back' })).toBeVisible(); + const textboxAfterReload = page.getByRole('textbox').filter({ hasText: 'Drag' }); + await expect(textboxAfterReload).toBeVisible(); + await expect(textboxAfterReload.getByText(code)).toBeVisible(); + + // Before save we moved the caret into a new block; after reload the toolbar reflects whichever + // block has focus (often not the code block). Click the saved code to focus that block again. + // The language combobox lives on the blocks toolbar (a sibling of the editable surface in the + // a11y tree), not inside the `textbox` node — scope to the draft tabpanel's toolbar. + // Focus the code block (click the `` surface, not just the text node) so the blocks + // toolbar shows the language combobox. + await textboxAfterReload.locator('code').filter({ hasText: code }).click(); + const draftPanel = page.getByRole('tabpanel', { name: 'draft' }); + await expect(async () => { + await expect(draftPanel.getByRole('combobox').filter({ hasText: 'Fortran' })).toBeVisible(); + }).toPass({ timeout: 15_000 }); }); }); diff --git a/tests/e2e/tests/content-manager/conditional-fields/enum-conditional-text-field-visibility.spec.ts b/tests/e2e/tests/content-manager/conditional-fields/enum-conditional-text-field-visibility.spec.ts index 72ecdb0c54..dd805fe799 100644 --- a/tests/e2e/tests/content-manager/conditional-fields/enum-conditional-text-field-visibility.spec.ts +++ b/tests/e2e/tests/content-manager/conditional-fields/enum-conditional-text-field-visibility.spec.ts @@ -149,9 +149,13 @@ test.describe('Conditional Fields - Enum-controlled conditional text fields and await page.getByRole('combobox', { name: 'target' }).click(); await page.getByRole('option', { name: 'Same window' }).click(); - await expect(page.getByLabel('linkInternal*')).toBeVisible(); + const linkInternalInput = page.getByLabel('linkInternal*'); + await expect(linkInternalInput).toBeVisible(); await expect(page.getByLabel('linkExternal')).toBeHidden(); - await page.getByLabel('linkInternal*').fill('/internal-link'); + await linkInternalInput.fill('/internal-link'); + // Ensure the controlled field has committed before Publish (avoids Yup seeing an empty value when the DOM already shows text) + await expect(linkInternalInput).toHaveValue('/internal-link'); + await linkInternalInput.blur(); await page.getByRole('button', { name: 'Publish' }).click(); diff --git a/tests/e2e/tests/content-type-builder/guided-tour.spec.ts b/tests/e2e/tests/content-type-builder/guided-tour.spec.ts index b45773e923..7a084848e7 100644 --- a/tests/e2e/tests/content-type-builder/guided-tour.spec.ts +++ b/tests/e2e/tests/content-type-builder/guided-tour.spec.ts @@ -9,10 +9,11 @@ describeOnCondition(edition === 'EE')('Guided tour - Content Type Builder (AI Ch test.beforeEach(async ({ page }) => { await setGuidedTourLocalStorage(page, { ...STRAPI_GUIDED_TOUR_CONFIG, enabled: true }); - await sharedSetup('guided-tour', page, { + await sharedSetup('guided-tour-ctb-ai', page, { login: true, resetFiles: true, importData: 'with-admin', + resetAlways: true, }); }); test('should see the ai content-type-builder tour if ai isenabled', async ({ page }) => { diff --git a/tests/scripts/run-tests.js b/tests/scripts/run-tests.js index 9dc8c53cbd..bca3a89db8 100644 --- a/tests/scripts/run-tests.js +++ b/tests/scripts/run-tests.js @@ -5,7 +5,7 @@ const fs = require('node:fs/promises'); const yargs = require('yargs'); const chalk = require('chalk'); const dotenv = require('dotenv'); -const execa = require('execa'); + const { publishYalc, setupTestApps, @@ -65,6 +65,11 @@ yargs dotenv.config({ path: path.join(testRoot, '.env') }); } + if (type === 'e2e') { + const { applyE2eEditionEnv } = require('../utils/e2e-edition.ts'); + applyE2eEditionEnv(); + } + // Read domains const domains = await fs.readdir(testDomainRoot); @@ -153,6 +158,7 @@ yargs setup, currentTestApps: currentTestApps.map((appPath) => path.basename(appPath)), setupTestEnvironment: type === 'e2e' ? setupTestEnvironment : null, + commitE2eBaseline: type === 'e2e', }); if (wasSetup) { @@ -171,167 +177,9 @@ yargs * Run the appropriate test runner */ if (type === 'e2e') { - // Playwright orchestration: init git and start Strapi once per test app, then run tests for each domain + // Git baseline: single commit during `setupTestApps` when apps are generated (`commitE2eBaseline`). + // Between tests, `resetFiles` → `git reset --hard` + `git clean -fd` only. No commits here. const testAppsToSpawn = testAppPaths.length; - const gitUser = ['-c', 'user.name=Strapi CLI', '-c', 'user.email=test@strapi.io']; - - // Initialize git and start Strapi once per test app (not per domain) - // Start sequentially to avoid port conflicts with internal services (e.g., Vite on 5173) - // eslint-disable-next-line no-plusplus - for (let j = 0; j < testAppPaths.length; j++) { - const testAppPath = testAppPaths[j]; - await (async () => { - const port = 8000 + j; - - // Store the filesystem state with git so it can be reset between tests - console.log(`Initializing git for test app ${j} at ${testAppPath}`); - - // Check if git repo already exists - const gitDir = path.join(testAppPath, '.git'); - const gitExists = await pathExists(gitDir); - - if (!gitExists) { - await execa('git', [...gitUser, 'init'], { - stdio: 'inherit', - cwd: testAppPath, - }); - } - - await execa('git', [...gitUser, 'add', '-A', '.'], { - stdio: 'inherit', - cwd: testAppPath, - }); - - // Check if there are changes to commit - try { - await execa('git', [...gitUser, 'diff', '--cached', '--quiet'], { - cwd: testAppPath, - }); - // If exit code is 0, there are no changes, skip commit - } catch (err) { - // Exit code 1 means there are changes, proceed with commit - await execa( - 'git', - [...gitUser, '-c', 'commit.gpgsign=false', 'commit', '-m', 'initial commit'], - { - stdio: 'inherit', - cwd: testAppPath, - } - ); - } - - // Start Strapi and wait for it to be ready to generate files - console.log(`Starting Strapi for test app ${j} to generate files...`); - const strapiProcess = execa('npm', ['run', 'develop'], { - cwd: testAppPath, - env: { - PORT: port, - STRAPI_DISABLE_EE: !process.env.STRAPI_LICENSE, - }, - detached: true, - }); - - await new Promise((resolve, reject) => { - const startTime = Date.now(); - const timeout = 160 * 1000; - const checkInterval = 1000; - - const checkServer = async () => { - try { - const response = await fetch(`http://127.0.0.1:${port}/_health`); - if (response.ok) { - console.log(`Strapi is ready for test app ${j}, shutting down...`); - if (process.env.CI) { - process.kill(-strapiProcess.pid, 'SIGINT'); - } else { - strapiProcess.kill('SIGINT'); - } - resolve(); - return; - } - } catch (err) { - // Server not ready yet, continue checking - } - - if (Date.now() - startTime > timeout) { - console.log('Timeout reached, forcing shutdown...'); - if (process.env.CI) { - process.kill(-strapiProcess.pid, 'SIGKILL'); - } else { - strapiProcess.kill('SIGKILL'); - } - reject(new Error('Strapi failed to start within timeout period')); - return; - } - - setTimeout(checkServer, checkInterval); - }; - - checkServer(); - - strapiProcess.stdout.on('data', (data) => { - console.log(`[stdout] ${data.toString().trim()}`); - }); - - strapiProcess.stderr.on('data', (data) => { - console.error(`[stderr] ${data.toString().trim()}`); - }); - - strapiProcess.on('error', (err) => { - console.error(`[Strapi ERROR] Process error:`, err); - reject(err); - }); - - strapiProcess.on('exit', (code) => { - console.log(`Strapi process exited with code ${code}`); - }); - }); - - // Wait for Strapi to fully shut down - await new Promise((resolve) => { - const checkPort = async () => { - try { - await fetch(`http://127.0.0.1:${port}/_health`); - setTimeout(checkPort, 1000); - } catch (err) { - resolve(); - } - }; - checkPort(); - }); - - // Commit the generated files - await execa('git', [...gitUser, 'add', '-A', '.'], { - stdio: 'inherit', - cwd: testAppPath, - }); - - // Check if there are changes to commit - try { - await execa('git', [...gitUser, 'diff', '--cached', '--quiet'], { - cwd: testAppPath, - }); - // If exit code is 0, there are no changes, skip commit - } catch (err) { - // Exit code 1 means there are changes, proceed with commit - await execa( - 'git', - [ - ...gitUser, - '-c', - 'commit.gpgsign=false', - 'commit', - '-m', - 'commit generated files', - ], - { - stdio: 'inherit', - cwd: testAppPath, - } - ); - } - })(); - } // Now chunk domains and run tests const chunkedDomains = selectedDomains.reduce((acc, _, i) => { @@ -358,9 +206,15 @@ yargs port, appDir: testAppPath, reportFileName: `playwright-${domain}-${port}.xml`, + domain, }); + // Sync `process.env.PORT` when Playwright loads this file (before globalSetup). The + // canonical port is `8000 + j` above — same as baseURL / webServer; helpers read PORT. + // `port` is always a safe integer here (never user-controlled). `JSON.stringify(port)` + // embeds a numeric literal; Node coerces env values to strings on read. const configFileTemplate = ` +process.env.PORT = ${JSON.stringify(port)}; const config = ${JSON.stringify(config)} module.exports = config @@ -368,29 +222,6 @@ module.exports = config await fs.writeFile(pathToPlaywrightConfig, configFileTemplate); - // Add the config file to git so it persists through git clean - await execa('git', [...gitUser, 'add', pathToPlaywrightConfig], { - stdio: 'inherit', - cwd: testAppPath, - }); - await execa( - 'git', - [ - ...gitUser, - '-c', - 'commit.gpgsign=false', - 'commit', - '-m', - 'Add playwright config', - ], - { - stdio: 'inherit', - cwd: testAppPath, - } - ).catch(() => { - // Ignore error if there's nothing to commit (file already tracked) - }); - console.log(`Running ${domain} e2e tests`); // Run Playwright - this is the only line that differs! diff --git a/tests/utils/content-types.ts b/tests/utils/content-types.ts index 042bad169a..fe41b87e9d 100644 --- a/tests/utils/content-types.ts +++ b/tests/utils/content-types.ts @@ -392,7 +392,7 @@ export const addRelationAttribute = async ( await page.locator('input[name="name"]').fill(name); } - await page.getByRole('button', { name: 'Finish' }).click(); + await clickAndWait(page, page.getByRole('button', { name: 'Finish' })); }; export const addComponentAttribute = async ( @@ -445,6 +445,8 @@ export const addComponentAttribute = async ( } await addAttributes(page, attrCompOptions.attributes, { clickFinish: false, ...options }); + // Inner addAttributes skips Finish on the last nested field when clickFinish is false; submit the component form here. + await clickAndWait(page, page.getByRole('button', { name: 'Finish' })); } }; @@ -458,16 +460,12 @@ export const addDynamicZoneAttribute = async (page: Page, attribute: AddDynamicZ page.getByRole('button', { name: new RegExp('Add components to the zone', 'i') }) ); - // Add the components to the dynamic zone + // Creating a DZ does not show a Finish control on the DZ modal (see FormModalEndActions); the + // add-component-to-DZ flow closes the modal when the component is done. Skip the outer Finish. await addAttributes(page, attribute.dz.components, { - fromDz: attribute.name, // Pass the DZ name to ensure subsequent components are added to the DZ + fromDz: attribute.name, + clickFinish: false, }); - - // Finish the dynamic zone creation - const finishButton = page.getByRole('button', { name: 'Finish' }); - if (await finishButton.isVisible({ timeout: 0 })) { - await finishButton.click(); - } }; // Add contentTypeName to options interface @@ -631,11 +629,12 @@ export const addAttributes = async ( page.getByRole('button', { name: new RegExp('^Add Another Field$', 'i'), exact: true }) ); } - } else { - // Last attribute, click 'Finish' only if it's visible - if (await page.getByRole('button', { name: 'Finish' }).isVisible({ timeout: 0 })) { - await page.getByRole('button', { name: 'Finish' }).click({ force: true }); - } + } else if ( + options?.clickFinish !== false && + // Creating a DZ closes the modal when components are added; there is no Finish on the CT field list. + !isDynamicZoneAttribute(attribute) + ) { + await clickAndWait(page, page.getByRole('button', { name: 'Finish' })); } } }; @@ -705,13 +704,13 @@ const createContentType = async ( const singularIdField = page.getByLabel('API ID (Singular)'); await expect(singularIdField).toHaveValue(singularId || kebabCase(name)); if (singularId) { - singularIdField.fill(singularId); + await singularIdField.fill(singularId); } const pluralIdField = page.getByLabel('API ID (Plural)'); await expect(pluralIdField).toHaveValue(pluralId || pluralize(kebabCase(name))); if (pluralId) { - pluralIdField.fill(pluralId); + await pluralIdField.fill(pluralId); } await page.getByRole('button', { name: 'Continue' }).click(); diff --git a/tests/utils/dts-import.ts b/tests/utils/dts-import.ts index e0a528f71c..1192146e2a 100644 --- a/tests/utils/dts-import.ts +++ b/tests/utils/dts-import.ts @@ -19,6 +19,28 @@ interface RestoreConfiguration { coreStore: boolean; } +/** + * Base URL for the Strapi instance under test (HTTP DTS). **Requires a non-empty `process.env.PORT`.** + * + * **Normal e2e (`yarn test:e2e`):** `tests/utils/runners/browser-runner.js` passes `PORT` (and + * `HOST`, `TEST_APP_PATH`) into the Playwright subprocess **before** config loads; the generated + * per-app `playwright.config.js` assigns `process.env.PORT` again (`tests/scripts/run-tests.js`). + * `tests/utils/global-setup.ts` also fails fast if `PORT` is missing — same contract. + * + * **Manual CLI:** only `importData()` at the bottom of this file defaults `PORT` to `8000` when unset + * (first test-app slot). Other programmatic callers must set `PORT` themselves — we deliberately + * removed `?? 1337` so we never silently hit Strapi’s dev default while the runner uses `8000 + index`. + */ +const getStrapiTestBaseUrl = () => { + const port = process.env.PORT?.trim(); + if (!port) { + throw new Error( + 'getStrapiTestBaseUrl: PORT is not set. Use `yarn test:e2e` (runner sets PORT), or set PORT yourself (e.g. `PORT=8000` for the first e2e test app).' + ); + } + return `http://127.0.0.1:${port}`; +}; + /** * Reset the DB and import data from a DTS backup * This function ensures we keep all admin user's and roles in the DB @@ -70,7 +92,7 @@ export const resetDatabaseAndImportDataFromPath = async ( try { // reset the transfer token to allow the transfer if it's been wiped (that is, not included in previous import data) - await fetch(`http://127.0.0.1:${process.env.PORT ?? 1337}/api/config/resettransfertoken`, { + await fetch(new URL('/api/config/resettransfertoken', getStrapiTestBaseUrl()), { method: 'POST', }); } catch (err) { @@ -176,6 +198,11 @@ const importData = async () => { process.exit(1); } + // Manual CLI only: default to the first e2e test app port so `getStrapiTestBaseUrl` does not guess. + if (!process.env.PORT?.trim()) { + process.env.PORT = '8000'; + } + await resetDatabaseAndImportDataFromPath(filePath); console.log('Data transfer succeeded'); process.exit(0); @@ -216,7 +243,7 @@ const createRemoteDestinationProvider = ( configuration: RestoreConfiguration ) => { return createRemoteStrapiDestinationProvider({ - url: new URL(`http://127.0.0.1:${process.env.PORT ?? 1337}/admin`), + url: new URL('/admin', getStrapiTestBaseUrl()), auth: { type: 'token', token: CUSTOM_TRANSFER_TOKEN_ACCESS_KEY }, strategy: 'restore', restore: { diff --git a/tests/utils/e2e-edition.ts b/tests/utils/e2e-edition.ts new file mode 100644 index 0000000000..986d03b254 --- /dev/null +++ b/tests/utils/e2e-edition.ts @@ -0,0 +1,100 @@ +'use strict'; + +const chalk = require('chalk'); + +function hasStrapiLicense() { + return Boolean(process.env.STRAPI_LICENSE?.trim()); +} + +/** @typedef {'ce' | 'ee'} E2eEdition */ + +/** + * CE vs EE for e2e: one resolution path for local runs, CI, and Playwright config. + * + * **Scripts** (`npm_lifecycle_event`): + * - `yarn test:e2e:ce` → always CE (`STRAPI_DISABLE_EE=true`, `STRAPI_LICENSE` removed from env). + * - `yarn test:e2e:ee` → EE; **process exits** if `STRAPI_LICENSE` is missing. + * - `yarn test:e2e` → auto: EE if a license string is present, else CE (safe). + * + * **Explicit `STRAPI_E2E_EDITION`** (CI `run-e2e-tests` / `script.sh`, your shell, or `.env`): + * `ce` forces CE; `ee` requires a license (falls back to CE with a warning when + * running under `test:e2e` without one — only `test:e2e:ee` hard-fails). Unset the variable for + * pure **auto** (license-based) on `yarn test:e2e`. + * + * Mutates `process.env`: sets `STRAPI_E2E_EDITION`; sets or clears `STRAPI_DISABLE_EE` for Strapi + * (`packages/core/core/src/ee/index.ts` treats only `'true'` as force-CE); clears `STRAPI_LICENSE` + * when edition is CE so child processes cannot boot as EE. + * + * This file lives next to Playwright `.ts` helpers but is loaded via `require('../utils/e2e-edition.ts')` + * from `tests/scripts/run-tests.js` (plain Node, not Playwright). Node resolves `.ts` when the extension + * is explicit; **body stays JS-parseable** so Node 20 (supported by `package.json` engines) does not + * need type stripping or SWC. Node 22+ would accept TypeScript syntax here too. + * + * @returns {E2eEdition} + */ +function resolveE2eEdition() { + const lifecycle = process.env.npm_lifecycle_event; + + if (lifecycle === 'test:e2e:ce') { + return 'ce'; + } + + if (lifecycle === 'test:e2e:ee') { + return 'ee'; + } + + const explicit = process.env.STRAPI_E2E_EDITION?.trim().toLowerCase(); + if (explicit === 'ce') { + return 'ce'; + } + if (explicit === 'ee') { + if (!hasStrapiLicense()) { + return 'ce'; + } + return 'ee'; + } + + return hasStrapiLicense() ? 'ee' : 'ce'; +} + +/** + * @returns {E2eEdition} + */ +function applyE2eEditionEnv() { + const lifecycle = process.env.npm_lifecycle_event; + const explicitBefore = process.env.STRAPI_E2E_EDITION?.trim().toLowerCase(); + + if (lifecycle === 'test:e2e:ee' && !hasStrapiLicense()) { + console.error( + chalk.red.bold('[e2e]'), + chalk.red( + 'yarn test:e2e:ee requires STRAPI_LICENSE (e.g. in tests/e2e/.env or export STRAPI_LICENSE=…).' + ) + ); + process.exit(1); + } + + const edition = resolveE2eEdition(); + + if (edition === 'ce' && explicitBefore === 'ee' && !hasStrapiLicense()) { + console.warn( + chalk.yellow('[e2e]'), + chalk.yellow( + 'STRAPI_E2E_EDITION=ee was set but STRAPI_LICENSE is missing — running Community Edition.' + ) + ); + } + + process.env.STRAPI_E2E_EDITION = edition; + + if (edition === 'ce') { + process.env.STRAPI_DISABLE_EE = 'true'; + delete process.env.STRAPI_LICENSE; + } else { + delete process.env.STRAPI_DISABLE_EE; + } + + return edition; +} + +module.exports = { resolveE2eEdition, applyE2eEditionEnv, hasStrapiLicense }; diff --git a/tests/utils/file-reset.ts b/tests/utils/file-reset.ts index 3418733496..5147f5ce7a 100644 --- a/tests/utils/file-reset.ts +++ b/tests/utils/file-reset.ts @@ -5,27 +5,39 @@ import { delay, pollHealthCheck } from './restart'; const gitUser = ['-c', 'user.name=Strapi CLI', '-c', 'user.email=test@strapi.io']; export const resetFiles = async () => { - if (!process.env.TEST_APP_PATH) { - console.warn('Could not reset the files because TEST_APP_PATH not set'); + const testAppPath = process.env.TEST_APP_PATH; + if (!testAppPath) { + const msg = + 'TEST_APP_PATH is not set; cannot reset the test app. Use `yarn test:e2e` so the runner passes the generated app path.'; + if (process.env.CI) { + throw new Error(msg); + } + console.warn(msg); return; } console.log('Restoring filesystem'); + // HEAD is the single baseline commit created during e2e app setup (`commitE2eBaseline` in shared-setup). + // Tracked files → match that commit; untracked (non-ignored) files/dirs → removed. await execa('git', [...gitUser, 'reset', '--hard'], { stdio: 'inherit', - cwd: process.env.TEST_APP_PATH, + cwd: testAppPath, }); await execa('git', [...gitUser, 'clean', '-fd'], { stdio: 'inherit', - cwd: process.env.TEST_APP_PATH, + cwd: testAppPath, }); + // `.tmp/` (SQLite) is gitignored — it is not removed by `clean -fd` and still holds rows from past CTs. + // Deleting it while Strapi is running can crash the server. DTS import re-seeds allowed UIDs but + // does not remove arbitrary API types from the DB. + // wait for the 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 + // We need to find a way to access the playwright server output and watch for the "up" log to appear + await delay(5); // give Strapi time to detect file changes and begin restart await pollHealthCheck(); // give it time to come back up }; diff --git a/tests/utils/global-setup.ts b/tests/utils/global-setup.ts index ab4368e01c..e7d9e8f40c 100644 --- a/tests/utils/global-setup.ts +++ b/tests/utils/global-setup.ts @@ -32,20 +32,26 @@ export const setGuidedTourLocalStorage = async ( page: Page, guidedTourState: typeof STRAPI_GUIDED_TOUR_CONFIG ) => { - // Navigate to the admin page to set localStorage - const port = process.env.PORT || 1337; - await page.goto(`http://127.0.0.1:${port}/admin`); + // Use a path so Playwright resolves `baseURL` from project config (e2e apps use 8000+, not 1337). + + await page.goto('/admin'); - // Set a default local storage so the guided tour is disabled by default await page.evaluate((config) => { localStorage.setItem('STRAPI_GUIDED_TOUR', JSON.stringify(config)); }, guidedTourState); }; async function globalSetup() { - // Create a browser context and set up localStorage + const port = process.env.PORT?.trim(); + if (!port) { + throw new Error( + 'globalSetup: PORT is not set. Run e2e via `yarn test:e2e` so the browser runner sets PORT (same port as the Playwright webServer).' + ); + } const browser = await chromium.launch(); - const context = await browser.newContext(); + const context = await browser.newContext({ + baseURL: `http://127.0.0.1:${port}`, + }); const page = await context.newPage(); await setGuidedTourLocalStorage(page, STRAPI_GUIDED_TOUR_CONFIG); diff --git a/tests/utils/restart.ts b/tests/utils/restart.ts index 46b4cde437..2a1b057379 100644 --- a/tests/utils/restart.ts +++ b/tests/utils/restart.ts @@ -84,7 +84,13 @@ export const delay = (seconds: number) => { }; export const pollHealthCheck = async (interval = 1000, timeout = 30000) => { - const url = `http://127.0.0.1:${process.env.PORT ?? 1337}/_health`; + const port = process.env.PORT?.trim(); + if (!port) { + throw new Error( + 'pollHealthCheck: PORT is not set. Run e2e via `yarn test:e2e` so the browser runner sets PORT (and TEST_APP_PATH) for this process.' + ); + } + const url = `http://127.0.0.1:${port}/_health`; console.log(`Starting to poll: ${url}`); let elapsed = 0; diff --git a/tests/utils/runners/browser-runner.js b/tests/utils/runners/browser-runner.js index 328e4a8cd7..0a4487a8a3 100644 --- a/tests/utils/runners/browser-runner.js +++ b/tests/utils/runners/browser-runner.js @@ -7,6 +7,7 @@ const execa = require('execa'); * Only pass runner-owned env keys; execa merges with process.env by default (extendEnv: true). */ const runPlaywright = async ({ configPath, cwd, port, testAppPath, testArgs }) => { + // `STRAPI_DISABLE_EE` / `STRAPI_E2E_EDITION` come from `applyE2eEditionEnv()` in run-tests.js (after dotenv). await execa('yarn', ['playwright', 'test', '--config', configPath, ...testArgs], { stdio: 'inherit', cwd, @@ -14,7 +15,6 @@ const runPlaywright = async ({ configPath, cwd, port, testAppPath, testArgs }) = PORT: String(port), HOST: '127.0.0.1', TEST_APP_PATH: testAppPath, - STRAPI_DISABLE_EE: !process.env.STRAPI_LICENSE, }, }); }; diff --git a/tests/utils/runners/shared-setup.js b/tests/utils/runners/shared-setup.js index 80e9e33d71..c60a20b142 100644 --- a/tests/utils/runners/shared-setup.js +++ b/tests/utils/runners/shared-setup.js @@ -45,6 +45,8 @@ const publishYalc = async (cwd) => { /** * Setup test apps - clean and generate */ +const gitUser = ['-c', 'user.name=Strapi CLI', '-c', 'user.email=test@strapi.io']; + const setupTestApps = async ({ testAppDirectory, testAppPaths, @@ -52,6 +54,8 @@ const setupTestApps = async ({ setup, currentTestApps, setupTestEnvironment, + // When true (e2e), create one git commit per generated app as the `resetFiles` save point. + commitE2eBaseline = false, }) => { /** * If we don't have enough test apps, we make enough. @@ -104,6 +108,27 @@ const setupTestApps = async ({ }) ); + if (commitE2eBaseline) { + for (const appPath of testAppPaths) { + await execa('git', [...gitUser, 'init'], { + stdio: 'inherit', + cwd: appPath, + }); + await execa('git', [...gitUser, 'add', '-A', '.'], { + stdio: 'inherit', + cwd: appPath, + }); + await execa( + 'git', + [...gitUser, '-c', 'commit.gpgsign=false', 'commit', '-m', 'e2e test app baseline'], + { + stdio: 'inherit', + cwd: appPath, + } + ); + } + } + return true; } diff --git a/tests/utils/setup.ts b/tests/utils/setup.ts index 5300b33675..676e9da77e 100644 --- a/tests/utils/setup.ts +++ b/tests/utils/setup.ts @@ -8,7 +8,8 @@ import { navToHeader } from './shared'; export type SharedSetupOptions = { login?: boolean; // Whether to log in to the application resetFiles?: boolean; // Whether to reset files before tests - importData?: string; // Path to the data to be imported into the database (null if no import is needed) + /** DTS packet name under `tests/e2e/data` (e.g. `with-admin`). When set, runs `resetDatabaseAndImportDataFromPath` — the e2e DB reset/seed (see docs/guides/e2e/02-data-transfer.md). Runs after `resetFiles` when `firstRun || resetAlways`. */ + importData?: string; afterSetup?: ({ page }: { page: Page }) => Promise; // An async function for custom setup logic that runs after the main setup resetAlways?: boolean; // Whether to reset the setup always, even if the setup has already been run }; @@ -28,6 +29,8 @@ const setupRegistry: Record = {}; * - resetFiles: Whether to reset files before tests. * - importData: Path to the data to be imported into the database. * - afterSetup: A custom function that runs once after the setup is complete. + * - resetAlways: When true, run resetFiles/importData on every call (not only the first for this id). + * Use when tests mutate app state (e.g. create content types) so retries and later tests start clean. * * WARNING: @@ -49,7 +52,7 @@ export const sharedSetup = async ( if (firstRun || extraRun) { setupRegistry[id] = true; - // Run one-time setup steps + // Run one-time setup steps (order: git restore test app → DTS import resets/seeds DB for allowed types) if (resetFiles) { await resetFilesFunc(); } diff --git a/tests/utils/shared.ts b/tests/utils/shared.ts index 716b38bbc3..fd95d9d294 100644 --- a/tests/utils/shared.ts +++ b/tests/utils/shared.ts @@ -1,4 +1,4 @@ -import { test, expect, type Page, type Locator } from '@playwright/test'; +import { test, expect, type Page, type Locator, type Response } from '@playwright/test'; type NavItem = string | [string, string] | Locator | { text: string; exact?: boolean }; @@ -107,6 +107,169 @@ export const clickAndWait = async (page: Page, locator: Locator) => { await page.waitForLoadState('networkidle'); }; +// --------------------------------------------------------------------------- +// E2E timing / sync (toast vs API, SPA navigations, guided tour) +// +// Playwright already waits on locators; these helpers cover **ordering** (toast before API, etc.). +// See `tests/e2e/LOCAL_E2E.md` (“race synchronization”). Prefer `withContentManagerSave` / +// `withContentManagerPublish` when you click Save/Publish so the listener is always registered first. +// --------------------------------------------------------------------------- + +/** What to wait for after a Content Manager write (see `waitForContentManagerMutation`). */ +export type ContentManagerWritePhase = 'save' | 'publish'; + +/** + * Wait until the matching Content Manager HTTP response succeeds. + * + * - **`save`**: draft document PUT to `/content-manager/…/collection-types|single-types/…` + * - **`publish`**: POST to `…/actions/publish` + * + * On fast machines the success toast can appear before the API finishes; list/home queries may stay + * stale until this resolves. + */ +export const waitForContentManagerMutation = ( + page: Page, + phase: ContentManagerWritePhase +): Promise => { + if (phase === 'save') { + return page.waitForResponse( + (response) => + response.request().method() === 'PUT' && + response.url().includes('/content-manager/') && + (response.url().includes('/collection-types/') || + response.url().includes('/single-types/')) && + response.ok() + ); + } + return page.waitForResponse( + (response) => + response.request().method() === 'POST' && + response.url().includes('/actions/publish') && + response.ok() + ); +}; + +/** Same as `waitForContentManagerMutation(page, 'save')`. */ +export const waitForContentManagerDocumentPut = (page: Page) => + waitForContentManagerMutation(page, 'save'); + +/** Same as `waitForContentManagerMutation(page, 'publish')`. */ +export const waitForContentManagerPublish = (page: Page) => + waitForContentManagerMutation(page, 'publish'); + +/** + * Registers the save-PUT listener, runs `act` (e.g. click Save), then awaits the PUT. Use this so + * you never forget to `await` the listener after the click. + */ +export const withContentManagerSave = async ( + page: Page, + act: () => Promise +): Promise => { + const done = waitForContentManagerMutation(page, 'save'); + await act(); + await done; +}; + +/** + * Same as `withContentManagerSave` for the publish POST (pair with `findAndClose(…, 'Published document')`). + */ +export const withContentManagerPublish = async ( + page: Page, + act: () => Promise +): Promise => { + const done = waitForContentManagerMutation(page, 'publish'); + await act(); + await done; +}; + +/** Map a segment under `/admin` (or legacy `/admin/…`) to a pathname. */ +function resolveAdminUrl(adminPath: string): string { + let s = adminPath.trim(); + if (s === '' || s === '/') { + return '/admin'; + } + s = s.replace(/^\/+/, ''); + if (s === 'admin' || s.startsWith('admin/')) { + s = s.replace(/^admin\/?/, ''); + } + return s === '' ? '/admin' : `/admin/${s}`; +} + +/** + * Navigate within the admin SPA when auth/session may have changed (cookies, localStorage, tokens). + * + * **`adminPath`** — path after `/admin`: omit or `''` for `/admin`; `'settings'` → `/admin/settings`. + * You do not repeat the `/admin` prefix (legacy strings starting with `/admin` are still accepted). + * + * **`options`** — forwarded to `page.goto` (default `waitUntil: 'domcontentloaded'` unless overridden). + * We default to `domcontentloaded` instead of Playwright’s `load`: a client-side redirect to login can + * overlap a full navigation; waiting for `load` then races (Firefox: `NS_BINDING_ABORTED`). + */ +export const gotoAdminPath = async ( + page: Page, + adminPath: string = '', + options?: Parameters[1] +): Promise => { + const href = resolveAdminUrl(adminPath); + await page.goto(href, { + ...options, + waitUntil: options?.waitUntil ?? 'domcontentloaded', + }); +}; + +/** + * Waits until the homepage guided tour card is rendered (depends on guided-tour-meta and dev mode). + * Call before interacting with tour links to avoid racing login / RTK hydration. + */ +export const waitForGuidedTourOverviewReady = async (page: Page): Promise => { + await expect(page.getByRole('heading', { name: 'Discover your application!' })).toBeVisible(); +}; + +/** + * Clicks "Next" in a guided-tour step (`role="dialog"`). + * Tour popovers are often fixed to the viewport edge; Playwright may report the button as visible but + * still refuse to click with "outside of the viewport" after scroll (differs from headless CI vs local + * window chrome, DPI, or panel height). `force` skips the viewport intersection check while still hitting + * the real element. + */ +export const clickGuidedTourDialogNext = async (page: Page, dialogAccessibleName: string) => { + await page + .getByRole('dialog', { name: dialogAccessibleName }) + .getByRole('button', { name: 'Next' }) + .click({ force: true }); +}; + +const STRAPI_GUIDED_TOUR_KEY = 'STRAPI_GUIDED_TOUR'; + +/** + * Wait until the guided tour state in localStorage marks a tour completed. + * Use before `page.goto('/admin')` (or any full reload): the UI can show "Done" from React + * while `usePersistentState` is still flushing; reloading rehydrates from storage and can + * briefly (or persistently) show stale progress if this wait is skipped. + */ +export const waitForGuidedTourCompletedInStorage = async ( + page: Page, + tourName: 'strapiCloud' | 'contentTypeBuilder' | 'contentManager' | 'apiTokens' +): Promise => { + await page.waitForFunction( + ({ key, name }) => { + const raw = localStorage.getItem(key); + if (!raw) return false; + try { + const parsed = JSON.parse(raw) as { tours?: Record }; + return parsed.tours?.[name]?.isCompleted === true; + } catch { + return false; + } + }, + { key: STRAPI_GUIDED_TOUR_KEY, name: tourName }, + { timeout: 15_000 } + ); +}; + +/** @deprecated Renamed to `waitForGuidedTourCompletedInStorage`. */ +export const waitForGuidedTourTourCompletedInStorage = waitForGuidedTourCompletedInStorage; + /** * Look for an element containing text, and then click a sibling close button */