Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .distignore
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
.git
.github
cypress
e2e-tests
.distignore
.gitignore
.travis.yml
Expand Down
14 changes: 6 additions & 8 deletions .github/workflows/copilot-setup-steps.yml
Original file line number Diff line number Diff line change
Expand Up @@ -21,14 +21,10 @@ jobs:
- uses: actions/setup-node@v6
with:
node-version: "22"
cache: "npm"
cache: "yarn"
- name: Install NPM deps
run: |
yarn install --frozen-lockfile
- name: Install Playwright
run: |
npm install -g playwright-cli
npx playwright install --with-deps chromium
- name: Setup PHP with tools
uses: shivammathur/setup-php@v2
with:
Expand All @@ -40,9 +36,11 @@ jobs:
- name: Run the build
run: yarn run build
- name: Install NPM in ./e2e-tests
run: |
cd e2e-tests
npm install --frozen-lockfile
working-directory: e2e-tests
run: npm ci
- name: Install Playwright browsers
working-directory: e2e-tests
run: npx playwright install --with-deps chromium
- name: Start the wp-env environment
working-directory: e2e-tests
run: npm run wp-env start
4 changes: 1 addition & 3 deletions .github/workflows/e2e.yml
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,6 @@ on:
concurrency:
group: ${{ github.workflow }}-${{ github.event_name == 'pull_request' && github.head_ref || github.ref }}
cancel-in-progress: true
env:
CYPRESS_LICENSE_KEY: ${{ secrets.CYPRESS_LICENSE_KEY }}
jobs:
e2e:
name: E2E Test
Expand Down Expand Up @@ -37,5 +35,5 @@ jobs:
uses: actions/upload-artifact@v7
with:
name: e2e-artifacts
path: ./artifacts
path: ./e2e-tests/artifacts
retention-days: 1
37 changes: 37 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,43 @@ yarn run ci:e2e

`yarn run ci:e2e` reinstalls `e2e-tests` dependencies, starts the wp-env test environment, installs Playwright Chromium, and runs the browser suite.

### E2E suite (Playwright)

Local iteration (needs Docker and Node >= 20; the root repo can stay on an older Node):

```bash
cd e2e-tests
npm install
npm run wp-env start
npm run test:playwright # full suite
npm run test:playwright -- specs/onboarding.spec.js # one spec
```

How the suite works — read this before adding specs:

- **No live ThemeIsle APIs.** All external calls are mocked at two layers:
- Server-side PHP fetches (sites feed, license check, starter ranking, demo content XML, import attachments) are short-circuited by the test-only mu-plugin `e2e-tests/mu-plugins/tpc-e2e.php` via `pre_http_request`, mounted through `.wp-env.json` and gated by the `TPC_E2E` constant. Fixtures: `e2e-tests/mu-plugins/fixtures/sites.json` (starter-sites feed) and the shared PHPUnit fixtures in `tests/fixtures/`.
- Browser-side fetches (`ti-demo-data`, Templates Cloud list/import, tracking) are mocked with `page.route` installers from `e2e-tests/config/mocks.js`. Cross-origin mocks must echo the request origin (credentialed CORS) — reuse the helpers there instead of hand-rolling `route.fulfill`.
- **Only wordpress.org traffic is real** (Neve theme + plugin installs during the import test) — that install path is itself under test.
- **Site state is toggled per spec** via the mu-plugin's `tpc-e2e/v1` REST namespace, called with `requestUtils.rest()`:
- `POST /legacy-tc { enabled }` — the Templates Cloud dashboard (`admin.php?page=tiob-plugin`) and the editor integration only exist in "legacy TC" mode; the onboarding surface behaves differently there. Mutually exclusive states: always reset in `afterAll`/`afterEach`.
- `POST /api-mode { mode: '' | 'down' | 'invalid' }` — ThemeIsle API failure scenarios (see `specs/error-states.spec.js`). Switching flushes cached license/ranking data.
- **Assertions derive from fixtures or independent literals** — never recompute an expected value the same way the mock computes its response. Site/font counts come from the fixture inputs; hardcoded counts belong to remote data and will rot.
- `workers: 1` is required, not incidental: the state toggles above are site-global options and parallel workers would race on them.

Known gaps (deferred, see `e2e-tests/README.md`): Elementor/Beaver template libraries, the dashboard starter-sites grid (needs the Neve theme installed), the Zelle migration flow, and the editor header "Templates Cloud" button (its portal target `.edit-post-header__center` no longer exists in current WordPress).

### Testing practices (TDD)

When adding or changing tests in this repo, follow these rules:

- **Red before green.** Write one failing test first, then only enough code to pass it. One seam, one test, one minimal implementation per cycle — don't write all tests up front and then all implementation (bulk-written tests verify imagined behavior and go stale).
- **Test at seams (public interfaces), never internals.** Here the seams are: admin pages and the editor canvas (via Playwright locators), the `ti-sites-lib/v1` REST endpoints (via `requestUtils.rest()`), and PHP class public methods (PHPUnit). A good test survives an internal refactor; if it breaks when behavior didn't change, it's coupled to implementation.
- **Prefer role/text locators** (`getByRole`, `getByText`) over CSS classes; use classes only where the UI offers no accessible handle (existing `.ss-card-wrap`-style locators are the ceiling, not the target).
- **No tautological assertions.** The expected value must come from an independent source (a literal, the fixture *input*, the spec) — never recomputed the same way the code or mock computes it. Example in this repo: `starter_order` asserts literal slugs, not `Object.keys(fixture)`.
- **Mock only at system boundaries** — external HTTP (ThemeIsle APIs), never the plugin's own classes/modules or internal collaborators. Don't assert on call counts or internal wiring; assert observable behavior (content in the canvas, a page created, an option's effect in the UI).
- **One logical assertion per test**, name tests as WHAT-statements ("importing a template inserts its blocks into the post"), not HOW.

## Architecture

### Bootstrap & Runtime
Expand Down
10 changes: 8 additions & 2 deletions e2e-tests/.wp-env.json
Original file line number Diff line number Diff line change
@@ -1,4 +1,10 @@
{
"plugins": [ ".." ],
"phpVersion": "8.1"
}
"phpVersion": "8.1",
"config": {
"TPC_E2E": true
},
"mappings": {
"wp-content/mu-plugins": "./mu-plugins"
}
}
21 changes: 21 additions & 0 deletions e2e-tests/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,3 +24,24 @@
```bash
npm run test:playwright:help
```

### Mocking

The suite runs offline and deterministic — no live ThemeIsle APIs are hit. Two layers:

- **Server-side (PHP)**: the `mu-plugins/tpc-e2e.php` mu-plugin (mounted via `.wp-env.json`, gated by the `TPC_E2E` constant) short-circuits `pre_http_request` for:
- the starter-sites feed (`api.themeisle.com/sites/...`) → `mu-plugins/fixtures/sites.json`
- the Templates Cloud license check → always a valid license
- the starter-ranking AI workflow → fixed order, no polling
- the demo content XML → `../tests/fixtures/export.xml` (shared with PHPUnit)
- demo attachment downloads → a 1×1 GIF
- **Browser-side (Playwright)**: `config/mocks.js` provides `page.route` installers for the cross-origin `ti-demo-data` fetch, the Templates Cloud templates list, and tracking calls.

The mu-plugin also exposes a `tpc-e2e/v1` REST namespace for per-spec state:

- `POST /legacy-tc { enabled }` — the Templates Cloud dashboard (`admin.php?page=tiob-plugin`) and the block-editor integration only load for "legacy TC" installs (`tiob_tc_removed` option), while the onboarding surface behaves differently in that mode; specs toggle it per suite.
- `POST /api-mode { mode: '' | 'down' | 'invalid' }` — failure scenarios for the ThemeIsle APIs (unreachable / invalid license), used by `specs/error-states.spec.js`. Switching modes flushes the cached license and starter-ranking order.

Only the wordpress.org plugin/theme installs triggered by the import flow (Neve theme, caching plugin, the fixture's mandatory plugin) still use the network, as the install path is itself under test.

Not covered yet (deferred): the Elementor and Beaver Builder template libraries (need those plugins in wp-env), the starter-sites grid on the dashboard page (hidden unless the Neve theme is installed; the same grid is covered on the onboarding page), the Zelle migration flow, and the editor's header "Templates Cloud" button (its portal target `.edit-post-header__center` no longer exists in current WordPress).
109 changes: 52 additions & 57 deletions e2e-tests/config/flaky-tests-reporter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,73 +13,68 @@ import fs from 'fs';
import type { Reporter, TestCase, TestResult } from '@playwright/test/reporter';
import filenamify from 'filenamify';

type FormattedTestResult = Omit< TestResult, 'steps' >;
type FormattedTestResult = Omit<TestResult, 'steps'>;

// Remove "steps" to prevent stringify circular structure.
function formatTestResult( testResult: TestResult ): FormattedTestResult {
const result = { ...testResult, steps: undefined };
delete result.steps;
return result;
function formatTestResult(testResult: TestResult): FormattedTestResult {
const result = { ...testResult, steps: undefined };
delete result.steps;
return result;
}

class FlakyTestsReporter implements Reporter {
failingTestCaseResults = new Map< string, FormattedTestResult[] >();
failingTestCaseResults = new Map<string, FormattedTestResult[]>();

onBegin() {
try {
fs.mkdirSync( 'flaky-tests' );
} catch ( err ) {
if (
err instanceof Error &&
( err as NodeJS.ErrnoException ).code === 'EEXIST'
) {
// Ignore the error if the directory already exists.
} else {
throw err;
}
}
}
onBegin() {
try {
fs.mkdirSync('flaky-tests');
} catch (err) {
if (err instanceof Error && (err as NodeJS.ErrnoException).code === 'EEXIST') {
// Ignore the error if the directory already exists.
} else {
throw err;
}
}
}

onTestEnd( test: TestCase, testCaseResult: TestResult ) {
const testPath = test.location.file;
const testTitle = test.title;
onTestEnd(test: TestCase, testCaseResult: TestResult) {
const testPath = test.location.file;
const testTitle = test.title;

switch ( test.outcome() ) {
case 'unexpected': {
if ( ! this.failingTestCaseResults.has( testTitle ) ) {
this.failingTestCaseResults.set( testTitle, [] );
}
this.failingTestCaseResults
.get( testTitle )!
.push( formatTestResult( testCaseResult ) );
break;
}
case 'flaky': {
fs.writeFileSync(
`flaky-tests/${ filenamify( testTitle ) }.json`,
JSON.stringify( {
version: 1,
runner: '@playwright/test',
title: testTitle,
path: testPath,
results: this.failingTestCaseResults.get( testTitle ),
} ),
'utf-8'
);
break;
}
default:
break;
}
}
switch (test.outcome()) {
case 'unexpected': {
if (!this.failingTestCaseResults.has(testTitle)) {
this.failingTestCaseResults.set(testTitle, []);
}
this.failingTestCaseResults.get(testTitle)!.push(formatTestResult(testCaseResult));
break;
}
case 'flaky': {
fs.writeFileSync(
`flaky-tests/${filenamify(testTitle)}.json`,
JSON.stringify({
version: 1,
runner: '@playwright/test',
title: testTitle,
path: testPath,
results: this.failingTestCaseResults.get(testTitle),
}),
'utf-8',
);
break;
}
default:
break;
}
}

onEnd() {
this.failingTestCaseResults.clear();
}
onEnd() {
this.failingTestCaseResults.clear();
}

printsToStdio() {
return false;
}
printsToStdio() {
return false;
}
}

module.exports = FlakyTestsReporter;
53 changes: 23 additions & 30 deletions e2e-tests/config/global-setup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,36 +9,29 @@ import type { FullConfig } from '@playwright/test';
*/
import { RequestUtils } from '@wordpress/e2e-test-utils-playwright';

async function globalSetup( config: FullConfig ) {
const { storageState, baseURL } = config.projects[ 0 ].use;
const storageStatePath =
typeof storageState === 'string' ? storageState : undefined;

const requestContext = await request.newContext( {
baseURL,
} );

const requestUtils = new RequestUtils( requestContext, {
storageStatePath,
} );

// Authenticate and save the storageState to disk.
await requestUtils.setupRest();

// Reset the test environment before running the tests.
await Promise.all( [
// requestUtils.activateTheme( 'twentytwentyone' ),
// // Disable this test plugin as it's conflicting with some of the tests.
// // We already have reduced motion enabled and Playwright will wait for most of the animations anyway.
// requestUtils.deactivatePlugin(
// 'gutenberg-test-plugin-disables-the-css-animations'
// ),
requestUtils.deleteAllPosts(),
requestUtils.deleteAllBlocks(),
requestUtils.resetPreferences(),
] );

await requestContext.dispose();
async function globalSetup(config: FullConfig) {
const { storageState, baseURL } = config.projects[0].use;
const storageStatePath = typeof storageState === 'string' ? storageState : undefined;

const requestContext = await request.newContext({
baseURL,
});

const requestUtils = new RequestUtils(requestContext, {
storageStatePath,
});

// Authenticate and save the storageState to disk.
await requestUtils.setupRest();

// Reset the test environment before running the tests.
await Promise.all([
requestUtils.deleteAllPosts(),
requestUtils.deleteAllBlocks(),
requestUtils.resetPreferences(),
]);

await requestContext.dispose();
}

export default globalSetup;
Loading
Loading