diff --git a/.distignore b/.distignore index 49a3291a..97ecdb3e 100755 --- a/.distignore +++ b/.distignore @@ -1,6 +1,7 @@ .git .github cypress +e2e-tests .distignore .gitignore .travis.yml diff --git a/.github/workflows/copilot-setup-steps.yml b/.github/workflows/copilot-setup-steps.yml index ea4fd06f..2e76e48f 100644 --- a/.github/workflows/copilot-setup-steps.yml +++ b/.github/workflows/copilot-setup-steps.yml @@ -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: @@ -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 \ No newline at end of file diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml index 68faa532..a05ec5d6 100644 --- a/.github/workflows/e2e.yml +++ b/.github/workflows/e2e.yml @@ -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 @@ -37,5 +35,5 @@ jobs: uses: actions/upload-artifact@v7 with: name: e2e-artifacts - path: ./artifacts + path: ./e2e-tests/artifacts retention-days: 1 diff --git a/AGENTS.md b/AGENTS.md index c075e5e3..ea56910b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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 diff --git a/composer.lock b/composer.lock index 4b2ec09b..7a2972e2 100644 --- a/composer.lock +++ b/composer.lock @@ -4,20 +4,20 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "2edb140d9732e088a052918719096234", + "content-hash": "db252b6a1359cca8fbc85821f9f91a8d", "packages": [ { "name": "codeinwp/themeisle-sdk", - "version": "3.3.54", + "version": "3.3.58", "source": { "type": "git", "url": "https://github.com/Codeinwp/themeisle-sdk.git", - "reference": "095c2d0f1388af0b0196c492a7f79e2fd092dab1" + "reference": "d6807c0b7308e323bd77cced667dee3f2d5e6a82" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/Codeinwp/themeisle-sdk/zipball/095c2d0f1388af0b0196c492a7f79e2fd092dab1", - "reference": "095c2d0f1388af0b0196c492a7f79e2fd092dab1", + "url": "https://api.github.com/repos/Codeinwp/themeisle-sdk/zipball/d6807c0b7308e323bd77cced667dee3f2d5e6a82", + "reference": "d6807c0b7308e323bd77cced667dee3f2d5e6a82", "shasum": "" }, "require-dev": { @@ -36,16 +36,16 @@ "homepage": "https://themeisle.com" } ], - "description": "Themeisle SDK.", + "description": "Themeisle SDK library.", "homepage": "https://github.com/Codeinwp/themeisle-sdk", "keywords": [ "wordpress" ], "support": { "issues": "https://github.com/Codeinwp/themeisle-sdk/issues", - "source": "https://github.com/Codeinwp/themeisle-sdk/tree/v3.3.54" + "source": "https://github.com/Codeinwp/themeisle-sdk/tree/v3.3.58" }, - "time": "2026-06-23T13:43:47+00:00" + "time": "2026-07-29T08:38:52+00:00" } ], "packages-dev": [ diff --git a/e2e-tests/.wp-env.json b/e2e-tests/.wp-env.json index 7e8095c1..ccec14b2 100644 --- a/e2e-tests/.wp-env.json +++ b/e2e-tests/.wp-env.json @@ -1,4 +1,10 @@ { "plugins": [ ".." ], - "phpVersion": "8.1" -} \ No newline at end of file + "phpVersion": "8.1", + "config": { + "TPC_E2E": true + }, + "mappings": { + "wp-content/mu-plugins": "./mu-plugins" + } +} diff --git a/e2e-tests/README.md b/e2e-tests/README.md index 7b40eba2..e67b0df8 100644 --- a/e2e-tests/README.md +++ b/e2e-tests/README.md @@ -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). diff --git a/e2e-tests/config/flaky-tests-reporter.ts b/e2e-tests/config/flaky-tests-reporter.ts index c371351f..bf821cdb 100644 --- a/e2e-tests/config/flaky-tests-reporter.ts +++ b/e2e-tests/config/flaky-tests-reporter.ts @@ -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; // 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(); - 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; diff --git a/e2e-tests/config/global-setup.ts b/e2e-tests/config/global-setup.ts index f7af485b..c85a0067 100644 --- a/e2e-tests/config/global-setup.ts +++ b/e2e-tests/config/global-setup.ts @@ -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; diff --git a/e2e-tests/config/mocks.js b/e2e-tests/config/mocks.js new file mode 100644 index 00000000..77000f62 --- /dev/null +++ b/e2e-tests/config/mocks.js @@ -0,0 +1,163 @@ +/** + * Shared mock payloads and page.route installers for browser-side fetches. + * + * Server-side PHP fetches (sites feed, license check, starter ranking, + * content XML, attachments) are mocked by the tpc-e2e mu-plugin; page.route + * here covers only what the browser fetches directly: the cross-origin + * ti-demo-data endpoint, the Templates Cloud API and tracking calls. + */ +import baseDemoData from '../../tests/fixtures/data.json'; +import sites from '../mu-plugins/fixtures/sites.json'; + +export const SITES = sites; + +// Must match TPC_E2E_CONTENT_XML_URL in mu-plugins/tpc-e2e.php, which serves +// tests/fixtures/export.xml for this URL. +export const CONTENT_XML_URL = 'https://demo.themeisle.com/neve-charity/export.xml'; + +const fontPair = (heading, body) => ({ + headingFont: { font: heading, fontSource: 'Google', previewSize: '25px' }, + bodyFont: { font: body, fontSource: 'Google', previewSize: '17px' }, +}); + +export const FONT_PAIRS = { + 'roboto-lora-0': fontPair('Roboto', 'Lora'), + 'playfair-display-source-sans-pro-1': fontPair('Playfair Display', 'Source Sans Pro'), + 'montserrat-open-sans-2': fontPair('Montserrat', 'Open Sans'), + 'oswald-merriweather-3': fontPair('Oswald', 'Merriweather'), + 'raleway-pt-serif-4': fontPair('Raleway', 'PT Serif'), + 'prata-hanken-grotesk-5': fontPair('Prata', 'Hanken Grotesk'), +}; + +export const PALETTES = { + base: { + name: 'Base', + allowDeletion: false, + colors: { + 'nv-primary-accent': '#0366d6', + 'nv-secondary-accent': '#0e509a', + 'nv-site-bg': '#ffffff', + 'nv-light-bg': '#ededed', + 'nv-dark-bg': '#14171c', + 'nv-text-color': '#393939', + 'nv-text-dark-bg': '#ffffff', + }, + }, + darkMode: { + name: 'Dark Mode', + allowDeletion: false, + colors: { + 'nv-primary-accent': '#26bcdb', + 'nv-secondary-accent': '#1f90a6', + 'nv-site-bg': '#121212', + 'nv-light-bg': '#1a1a1a', + 'nv-dark-bg': '#25272c', + 'nv-text-color': '#ffffff', + 'nv-text-dark-bg': '#ffffff', + }, + }, + blackWhite: { + name: 'Black & White', + allowDeletion: false, + colors: { + 'nv-primary-accent': '#000000', + 'nv-secondary-accent': '#292929', + 'nv-site-bg': '#ffffff', + 'nv-light-bg': '#ededed', + 'nv-dark-bg': '#14171c', + 'nv-text-color': '#393939', + 'nv-text-dark-bg': '#ffffff', + }, + }, +}; + +// Demo-data payload served to the onboarding app. Based on the PHPUnit +// fixture. One small mandatory plugin (part of the featured collection, so the +// features list stays at the static 6 cards) keeps the locked/required-card UI +// and its install path under test without pulling in a heavy plugin. +export const demoData = { + ...baseDemoData, + slug: 'neve-charity', + content_file: CONTENT_XML_URL, + mandatory_plugins: { 'optimole-wp': 'Image Optimization' }, + recommended_plugins: {}, + font_pairs: FONT_PAIRS, + theme_mods: { + ...baseDemoData.theme_mods, + neve_global_colors: { + activePalette: 'base', + palettes: PALETTES, + }, + }, +}; + +export const MOCK_TEMPLATES = [ + { + template_id: 'tpc-e2e-template-1', + template_name: 'E2E Template One', + template_thumbnail: '', + template_type: 'gutenberg', + link: 'https://demo.themeisle.com/neve-charity/', + }, + { + template_id: 'tpc-e2e-template-2', + template_name: 'E2E Template Two', + template_thumbnail: '', + template_type: 'gutenberg', + link: 'https://demo.themeisle.com/neve-charity/', + }, +]; + +// apiFetch sends credentialed requests, so the mocked cross-origin responses +// must echo the exact origin (a wildcard is rejected by the browser). +const corsHeaders = (route) => ({ + 'access-control-allow-origin': route.request().headers().origin || '*', + 'access-control-allow-credentials': 'true', + 'access-control-allow-headers': '*', + 'access-control-allow-methods': '*', + 'access-control-expose-headers': 'x-wp-totalpages', +}); + +const fulfillJson = (route, body, headers = {}) => { + if (route.request().method() === 'OPTIONS') { + return route.fulfill({ status: 200, headers: corsHeaders(route) }); + } + return route.fulfill({ + status: 200, + contentType: 'application/json', + headers: { ...corsHeaders(route), ...headers }, + body: JSON.stringify(body), + }); +}; + +export async function mockOnboardingRoutes(page) { + await page.route('**/wp-json/ti-demo-data/data*', (route) => fulfillJson(route, demoData)); + await page.route('**/api.themeisle.com/tracking/**', (route) => + fulfillJson(route, { code: 'success' }), + ); +} + +// Block markup returned by the mocked template-import endpoint; specs assert +// this text lands in the editor canvas. +export const TEMPLATE_CONTENT_TEXT = 'TPC E2E imported content'; +export const TEMPLATE_CONTENT = `

${TEMPLATE_CONTENT_TEXT}

`; + +export async function mockTemplatesCloudRoutes(page, templates = MOCK_TEMPLATES) { + await page.route('**/api.themeisle.com/templates-cloud/**', (route) => { + const url = new URL(route.request().url()); + + // GET templates/{id}/import returns the template's block content. + if (url.pathname.endsWith('/import')) { + return fulfillJson(route, { + __file: 'wp_export', + content: TEMPLATE_CONTENT, + }); + } + + // The library infinite-scrolls to the next page; only page 0 has items, + // so rendered counts stay deterministic. + const requestedPage = url.searchParams.get('page'); + const body = requestedPage && requestedPage !== '0' ? [] : templates; + return fulfillJson(route, body, { 'x-wp-totalpages': '1' }); + }); +} diff --git a/e2e-tests/mu-plugins/fixtures/sites.json b/e2e-tests/mu-plugins/fixtures/sites.json new file mode 100644 index 00000000..925efd11 --- /dev/null +++ b/e2e-tests/mu-plugins/fixtures/sites.json @@ -0,0 +1,39 @@ +{ + "gutenberg": { + "neve-charity": { + "slug": "neve-charity", + "title": "Charity", + "url": "https://demo.themeisle.com/neve-charity/", + "remote_url": "https://demo.themeisle.com/neve-charity/", + "screenshot": "https://demo.themeisle.com/neve-charity/screenshot.png", + "keywords": [ "nonprofit", "business" ] + }, + "neve-web-agency": { + "slug": "neve-web-agency", + "title": "Web Agency", + "url": "https://demo.themeisle.com/neve-charity/", + "remote_url": "https://demo.themeisle.com/neve-charity/", + "screenshot": "https://demo.themeisle.com/neve-charity/screenshot.png", + "keywords": [ "business", "portfolio" ] + }, + "neve-restaurant": { + "slug": "neve-restaurant", + "title": "Restaurant", + "url": "https://demo.themeisle.com/neve-charity/", + "remote_url": "https://demo.themeisle.com/neve-charity/", + "screenshot": "https://demo.themeisle.com/neve-charity/screenshot.png", + "keywords": [ "business" ], + "upsell": true + } + }, + "elementor": { + "neve-charity-el": { + "slug": "neve-charity-el", + "title": "Charity Elementor", + "url": "https://demo.themeisle.com/neve-charity/", + "remote_url": "https://demo.themeisle.com/neve-charity/", + "screenshot": "https://demo.themeisle.com/neve-charity/screenshot.png", + "keywords": [ "nonprofit" ] + } + } +} diff --git a/e2e-tests/mu-plugins/tpc-e2e.php b/e2e-tests/mu-plugins/tpc-e2e.php new file mode 100644 index 00000000..dbb055d9 --- /dev/null +++ b/e2e-tests/mu-plugins/tpc-e2e.php @@ -0,0 +1,223 @@ + 'valid', + 'key' => 'tpc-e2e-key', + 'tier' => 3, + 'expiration' => '', + ) + ); +} + +// The Templates Cloud dashboard (admin.php?page=tiob-plugin) and the editor +// integration only load for "legacy TC" installs (tiob_tc_removed === 'no'), +// while the onboarding surface behaves differently in that mode — the two +// states are mutually exclusive, so specs toggle it per suite via this route. +add_action( + 'rest_api_init', + function () { + register_rest_route( + 'tpc-e2e/v1', + '/legacy-tc', + array( + 'methods' => 'POST', + 'permission_callback' => function () { + return current_user_can( 'manage_options' ); + }, + 'callback' => function ( $request ) { + update_option( 'tiob_tc_removed', $request->get_param( 'enabled' ) ? 'no' : 'yes' ); + return rest_ensure_response( array( 'success' => true ) ); + }, + ) + ); + + register_rest_route( + 'tpc-e2e/v1', + '/api-mode', + array( + 'methods' => 'POST', + 'permission_callback' => function () { + return current_user_can( 'manage_options' ); + }, + 'args' => array( + 'mode' => array( + 'type' => 'string', + 'enum' => array( '', 'down', 'invalid' ), + ), + ), + 'callback' => function ( $request ) { + update_option( 'tpc_e2e_api_mode', $request->get_param( 'mode' ) ); + // Drop cached remote data (stored license, starter-ranking + // order) so the next request reflects the new mode. + delete_option( 'templates_patterns_collection_license_data' ); + delete_transient( 'templates_patterns_collection_license_check' ); + foreach ( array( 'v2', 'v3' ) as $version ) { + foreach ( array( 'gutenberg', 'elementor' ) as $builder ) { + delete_transient( 'tpc_starter_order_' . $version . '_' . $builder ); + } + } + return rest_ensure_response( array( 'success' => true ) ); + }, + ) + ); + } +); + +/** + * Build a WP_Http-shaped 200 response. + * + * @param string $body Response body. + * @param array $headers Extra headers. + * @return array + */ +function tpc_e2e_response( $body, $headers = array() ) { + return array( + 'response' => array( + 'code' => 200, + 'message' => 'OK', + ), + 'headers' => array_merge( array( 'content-type' => 'application/json' ), $headers ), + 'body' => $body, + 'cookies' => array(), + ); +} + +add_filter( + 'pre_http_request', + function ( $preempt, $args, $url ) { + if ( false !== $preempt ) { + return $preempt; + } + + $is_themeisle_api = false !== strpos( $url, 'api.themeisle.com' ) || false !== strpos( $url, 'ai.themeisle.com' ); + + // Scenario modes (Otter pattern), set per spec via tpc-e2e/v1/api-mode: + // 'down' => ThemeIsle APIs unreachable. + // 'invalid' => license check rejects the key (a code/message body is + // what License::check_license treats as invalid). + $mode = get_option( 'tpc_e2e_api_mode', '' ); + + if ( 'down' === $mode && $is_themeisle_api ) { + return new WP_Error( 'http_request_failed', 'TPC E2E: API unreachable.' ); + } + + if ( 'invalid' === $mode && false !== strpos( $url, 'api.themeisle.com/templates-cloud/' ) ) { + return tpc_e2e_response( + wp_json_encode( + array( + 'code' => 'invalid_license', + 'message' => 'TPC E2E: invalid license.', + ) + ) + ); + } + + // Starter sites feed (Sites_Listing::API). + if ( false !== strpos( $url, 'api.themeisle.com/sites/wp-json/demosites-api/sites' ) ) { + return tpc_e2e_response( file_get_contents( TPC_E2E_FIXTURES_DIR . '/sites.json' ) ); + } + + // Templates Cloud: license check (License::check_license) and any other + // server-side cloud call. A 200 body without code/message keys is treated + // as a valid license; `license` !== not_active/invalid => active. + if ( false !== strpos( $url, 'api.themeisle.com/templates-cloud/' ) ) { + return tpc_e2e_response( + wp_json_encode( + array( + 'license' => 'valid', + 'key' => 'tpc-e2e-key', + 'tier' => 3, + 'expiration' => '', + ) + ) + ); + } + + // Starter ranking workflow (Starter_Ranking): returning the order directly + // in the /start response makes parse_order() succeed and skips polling. + if ( false !== strpos( $url, 'ai.themeisle.com/api/workflows/neve-starter-ranking' ) ) { + $sites = json_decode( file_get_contents( TPC_E2E_FIXTURES_DIR . '/sites.json' ), true ); + return tpc_e2e_response( + wp_json_encode( + array( + 'status' => 'completed', + 'output' => array( 'order' => array_keys( $sites['gutenberg'] ) ), + ) + ) + ); + } + + if ( false !== strpos( $url, 'demo.themeisle.com' ) ) { + // Demo content XML fetched by Content_Importer via the content_file URL + // that our mocked demo-data payload points here. Reuses the PHPUnit fixture. + if ( false !== strpos( $url, 'export.xml' ) ) { + return tpc_e2e_response( + file_get_contents( WP_PLUGIN_DIR . '/templates-patterns-collection/tests/fixtures/export.xml' ), + array( 'content-type' => 'text/xml' ) + ); + } + + // Demo-data endpoint. Server-side safety net only — the browser fetch is + // mocked in the specs via page.route. + if ( false !== strpos( $url, 'ti-demo-data/data' ) ) { + $data = json_decode( file_get_contents( WP_PLUGIN_DIR . '/templates-patterns-collection/tests/fixtures/data.json' ), true ); + $data['content_file'] = TPC_E2E_CONTENT_XML_URL; + return tpc_e2e_response( wp_json_encode( $data ) ); + } + + // Attachments referenced by export.xml. WP_Import::fetch_remote_file() + // downloads these streamed to a file; a pre_http_request short-circuit + // skips WP's own streaming, so write the file ourselves and report a + // matching content-length (WP_Import errors on any mismatch or zero size). + if ( false !== strpos( $url, '/wp-content/uploads/' ) ) { + $gif = base64_decode( 'R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7' ); + if ( ! empty( $args['stream'] ) && ! empty( $args['filename'] ) ) { + file_put_contents( $args['filename'], $gif ); + } + return tpc_e2e_response( + empty( $args['stream'] ) ? $gif : '', + array( + 'content-type' => 'image/gif', + 'content-length' => (string) strlen( $gif ), + ) + ); + } + } + + return $preempt; + }, + 10, + 3 +); + +// Bust the plugin's remote-data caches so every run re-hits the (mocked) APIs +// instead of serving a stale cache from a previous, possibly unmocked, run. +add_action( + 'init', + function () { + if ( defined( 'TIOB_VERSION' ) ) { + delete_transient( 'tiob_sites_' . TIOB_VERSION ); + } + delete_transient( 'templates_patterns_collection_license_check' ); + } +); diff --git a/e2e-tests/package.json b/e2e-tests/package.json index 7b00a76d..5a6c2a92 100644 --- a/e2e-tests/package.json +++ b/e2e-tests/package.json @@ -1,19 +1,19 @@ { "name": "e2e-tests", "version": "1.0.0", - "description": "Playwright e2e-tests", + "description": "Playwright e2e tests for templates-patterns-collection", "author": "ThemeIsle ", "license": "MIT", "keywords": [ - "wordpress-theme" + "wordpress-plugin" ], - "homepage": "https://github.com/Codeinwp/neve", + "homepage": "https://github.com/Codeinwp/templates-patterns-collection", "repository": { "type": "git", - "url": "git+https://github.com/Codeinwp/neve.git" + "url": "git+https://github.com/Codeinwp/templates-patterns-collection.git" }, "bugs": { - "url": "https://github.com/Codeinwp/neve/issues" + "url": "https://github.com/Codeinwp/templates-patterns-collection/issues" }, "devDependencies": { "@playwright/test": "^1.60.0", diff --git a/e2e-tests/playwright.config.ts b/e2e-tests/playwright.config.ts index 87f4b97f..838303bd 100644 --- a/e2e-tests/playwright.config.ts +++ b/e2e-tests/playwright.config.ts @@ -10,45 +10,41 @@ import { defineConfig, devices } from '@playwright/test'; */ import baseConfig from '@wordpress/scripts/config/playwright.config.js'; -const config = defineConfig( { - ...baseConfig, - reporter: process.env.CI - ? [ [ 'github' ], [ './config/flaky-tests-reporter.ts' ] ] - : 'list', - workers: 1, - globalSetup: fileURLToPath( - new URL( './config/global-setup.ts', 'file:' + __filename ).href - ), - projects: [ - { - name: 'chromium', - use: { ...devices[ 'Desktop Chrome' ] }, - grepInvert: /-chromium/, - }, - // { - // name: 'webkit', - // use: { - // ...devices[ 'Desktop Safari' ], - // /** - // * Headless webkit won't receive dataTransfer with custom types in the - // * drop event on Linux. The solution is to use `xvfb-run` to run the tests. - // * ```sh - // * xvfb-run npm run test:e2e - // * ``` - // * See `.github/workflows/end2end-test-playwright.yml` for advanced usages. - // */ - // headless: os.type() !== 'Linux', - // }, - // grep: /@webkit/, - // grepInvert: /-webkit/, - // }, - // { - // name: 'firefox', - // use: { ...devices[ 'Desktop Firefox' ] }, - // grep: /@firefox/, - // grepInvert: /-firefox/, - // }, - ], -} ); +const config = defineConfig({ + ...baseConfig, + reporter: process.env.CI ? [['github'], ['./config/flaky-tests-reporter.ts']] : 'list', + workers: 1, + globalSetup: fileURLToPath(new URL('./config/global-setup.ts', 'file:' + __filename).href), + projects: [ + { + name: 'chromium', + use: { ...devices['Desktop Chrome'] }, + grepInvert: /-chromium/, + }, + // { + // name: 'webkit', + // use: { + // ...devices[ 'Desktop Safari' ], + // /** + // * Headless webkit won't receive dataTransfer with custom types in the + // * drop event on Linux. The solution is to use `xvfb-run` to run the tests. + // * ```sh + // * xvfb-run npm run test:e2e + // * ``` + // * See `.github/workflows/end2end-test-playwright.yml` for advanced usages. + // */ + // headless: os.type() !== 'Linux', + // }, + // grep: /@webkit/, + // grepInvert: /-webkit/, + // }, + // { + // name: 'firefox', + // use: { ...devices[ 'Desktop Firefox' ] }, + // grep: /@firefox/, + // grepInvert: /-firefox/, + // }, + ], +}); export default config; diff --git a/e2e-tests/specs/dashboard.spec.js b/e2e-tests/specs/dashboard.spec.js new file mode 100644 index 00000000..ece8c7f8 --- /dev/null +++ b/e2e-tests/specs/dashboard.spec.js @@ -0,0 +1,61 @@ +/** + * WordPress dependencies + */ +import { test, expect } from '@wordpress/e2e-test-utils-playwright'; + +/** + * Internal dependencies + */ +import { MOCK_TEMPLATES, mockTemplatesCloudRoutes } from '../config/mocks'; + +// The tiob-plugin dashboard only exists for "legacy Templates Cloud" installs; +// without the Neve theme it exposes the My Library and Settings tabs (the +// starter-sites grid is hidden and is covered by the onboarding spec instead). +test.describe('Templates Cloud dashboard', () => { + const DASHBOARD_URL = 'admin.php?page=tiob-plugin'; + + test.beforeAll(async ({ requestUtils }) => { + await requestUtils.rest({ + path: '/tpc-e2e/v1/legacy-tc', + method: 'POST', + data: { enabled: true }, + }); + }); + + test.afterAll(async ({ requestUtils }) => { + await requestUtils.rest({ + path: '/tpc-e2e/v1/legacy-tc', + method: 'POST', + data: { enabled: false }, + }); + }); + + test.beforeEach(async ({ page }) => { + await mockTemplatesCloudRoutes(page); + }); + + test('My Library lists mocked cloud templates', async ({ page, admin }) => { + await admin.visitAdminPage(DASHBOARD_URL); + + for (const template of MOCK_TEMPLATES) { + await expect(page.getByText(template.template_name)).toBeVisible(); + } + await expect(page.getByRole('button', { name: 'Import' })).toHaveCount( + MOCK_TEMPLATES.length, + ); + }); + + // Tab navigation happens via the admin sidebar links, which use URL hashes. + test('Settings tab renders general and feedback panels', async ({ page, admin }) => { + await admin.visitAdminPage(`${DASHBOARD_URL}#settings`); + + await expect(page.getByRole('heading', { name: 'Useful links' })).toBeVisible(); + + await page.getByRole('button', { name: 'Feedback' }).click(); + await expect( + page.getByRole('heading', { + name: "What's one thing you need in Templates Cloud?", + }), + ).toBeVisible(); + }); +}); diff --git a/e2e-tests/specs/editor-templates-cloud.spec.js b/e2e-tests/specs/editor-templates-cloud.spec.js new file mode 100644 index 00000000..f84ff9b3 --- /dev/null +++ b/e2e-tests/specs/editor-templates-cloud.spec.js @@ -0,0 +1,67 @@ +/** + * WordPress dependencies + */ +import { test, expect } from '@wordpress/e2e-test-utils-playwright'; + +/** + * Internal dependencies + */ +import { MOCK_TEMPLATES, TEMPLATE_CONTENT_TEXT, mockTemplatesCloudRoutes } from '../config/mocks'; + +// The plugin also portals a "Templates Cloud" button into +// `.edit-post-header__center`, but that element no longer exists in current +// WordPress (renamed to `.editor-header__center`), so the button cannot be +// tested until the plugin is updated. The block path below covers the same +// import modal. +test.describe('Templates Cloud in the block editor', () => { + // The editor integration only loads for "legacy Templates Cloud" installs. + test.beforeAll(async ({ requestUtils }) => { + await requestUtils.rest({ + path: '/tpc-e2e/v1/legacy-tc', + method: 'POST', + data: { enabled: true }, + }); + }); + + test.afterAll(async ({ requestUtils }) => { + await requestUtils.rest({ + path: '/tpc-e2e/v1/legacy-tc', + method: 'POST', + data: { enabled: false }, + }); + }); + + test.beforeEach(async ({ page }) => { + await mockTemplatesCloudRoutes(page); + }); + + test('inserting the block opens the library modal with cloud templates', async ({ + page, + admin, + editor, + }) => { + await admin.createNewPost(); + + // The block's edit component auto-opens the import modal. + await editor.insertBlock({ name: 'ti-tpc/templates-cloud' }); + + const modal = page.locator('.tpc-template-cloud-modal'); + await expect(modal).toBeVisible(); + await expect(modal.locator('.table-grid')).toHaveCount(MOCK_TEMPLATES.length); + await expect(modal.getByText(MOCK_TEMPLATES[0].template_name)).toBeVisible(); + }); + + test('importing a template inserts its blocks into the post', async ({ + page, + admin, + editor, + }) => { + await admin.createNewPost(); + await editor.insertBlock({ name: 'ti-tpc/templates-cloud' }); + + const modal = page.locator('.tpc-template-cloud-modal'); + await modal.locator('.table-grid').first().getByRole('button', { name: 'Import' }).click(); + + await expect(editor.canvas.getByText(TEMPLATE_CONTENT_TEXT)).toBeVisible(); + }); +}); diff --git a/e2e-tests/specs/error-states.spec.js b/e2e-tests/specs/error-states.spec.js new file mode 100644 index 00000000..5c9ca11f --- /dev/null +++ b/e2e-tests/specs/error-states.spec.js @@ -0,0 +1,91 @@ +/** + * WordPress dependencies + */ +import { test, expect } from '@wordpress/e2e-test-utils-playwright'; + +/** + * Internal dependencies + */ +import { mockOnboardingRoutes } from '../config/mocks'; + +const setApiMode = (requestUtils, mode) => + requestUtils.rest({ + path: '/tpc-e2e/v1/api-mode', + method: 'POST', + data: { mode }, + }); + +const setLegacyTc = (requestUtils, enabled) => + requestUtils.rest({ + path: '/tpc-e2e/v1/legacy-tc', + method: 'POST', + data: { enabled }, + }); + +// Error-scenario coverage: the mu-plugin serves failure responses for the +// ThemeIsle APIs based on the tpc_e2e_api_mode option (Otter pattern). +test.describe('API error states', () => { + test.afterEach(async ({ requestUtils }) => { + await setApiMode(requestUtils, ''); + await setLegacyTc(requestUtils, false); + }); + + test('onboarding shows an error when the demo data fetch fails', async ({ page, admin }) => { + await mockOnboardingRoutes(page); + // Later-registered routes win: override the demo-data mock with a 500. + await page.route('**/wp-json/ti-demo-data/data*', (route) => + route.fulfill({ + status: 500, + contentType: 'application/json', + body: JSON.stringify({ message: 'Internal Server Error' }), + }), + ); + + await admin.visitAdminPage('themes.php?page=neve-onboarding'); + await page.locator('.ss-card-wrap').first().click(); + + await expect(page.locator('.ob-error-wrap')).toBeVisible(); + }); + + test('onboarding lists no sites when the sites feed is down', async ({ + page, + admin, + requestUtils, + }) => { + await setApiMode(requestUtils, 'down'); + await mockOnboardingRoutes(page); + + await admin.visitAdminPage('themes.php?page=neve-onboarding'); + + await expect(page.locator('.ss-card-wrap')).toHaveCount(0); + }); + + test('an invalid license shows the activate UI in dashboard settings', async ({ + page, + admin, + requestUtils, + }) => { + await setLegacyTc(requestUtils, true); + await setApiMode(requestUtils, 'invalid'); + + await admin.visitAdminPage('admin.php?page=tiob-plugin#settings'); + + await expect(page.getByText('License Key')).toBeVisible(); + await expect(page.getByRole('button', { name: 'Activate' })).toBeVisible(); + }); + + test('starter_order degrades gracefully when the ranking API is down', async ({ + requestUtils, + }) => { + await setApiMode(requestUtils, 'down'); + + const res = await requestUtils.rest({ + path: '/ti-sites-lib/v1/starter_order', + method: 'GET', + params: { builder: 'gutenberg' }, + }); + + expect(res.success).toBe(true); + expect(res.order).toEqual([]); + }); +}); diff --git a/e2e-tests/specs/onboarding.spec.js b/e2e-tests/specs/onboarding.spec.js index 604409cf..79457404 100644 --- a/e2e-tests/specs/onboarding.spec.js +++ b/e2e-tests/specs/onboarding.spec.js @@ -3,19 +3,30 @@ */ import { test, expect } from '@wordpress/e2e-test-utils-playwright'; +/** + * Internal dependencies + */ +import { SITES, FONT_PAIRS, mockOnboardingRoutes } from '../config/mocks'; + test.describe('Onboarding', () => { const ONBOARDING_URL = 'themes.php?page=neve-onboarding'; - const waitForStarterData = ( page ) => + // Default editor is Gutenberg, so only those sites are listed. + const GUTENBERG_SITES = Object.values(SITES.gutenberg); + const PRO_SITES_COUNT = GUTENBERG_SITES.filter((site) => site.upsell).length; + + test.beforeEach(async ({ page }) => { + await mockOnboardingRoutes(page); + }); + + const waitForStarterData = (page) => page.waitForResponse( - ( response ) => - response.url().includes('/wp-json/ti-demo-data/data') && - response.status() === 200, - { timeout: 45000 } + (response) => + response.url().includes('/wp-json/ti-demo-data/data') && response.status() === 200, ); - const openFirstSiteAndWaitForData = async ( page ) => { - const starterDataResponse = waitForStarterData( page ); + const openFirstSiteAndWaitForData = async (page) => { + const starterDataResponse = waitForStarterData(page); await page.locator('.ss-card-wrap').first().click(); await starterDataResponse; await page.waitForSelector('.ob-site-settings.fetching', { @@ -43,9 +54,9 @@ test.describe('Onboarding', () => { await expect(page.getByRole('button', { name: 'Exit to dashboard' })).toBeVisible(); expect(await page.locator('.ob-cat-wrap button').count()).toBeGreaterThan(0); - // Check if we have Starter Sites listed. And some of the are PRO. - expect(await page.locator('.ss-card-wrap').count()).toBeGreaterThan(0); - expect(await page.locator('.ss-card .ss-badge').count()).toBeGreaterThan(0); + // Check if we have Starter Sites listed. And some of them are PRO. + expect(await page.locator('.ss-card-wrap').count()).toBe(GUTENBERG_SITES.length); + expect(await page.locator('.ss-card .ss-badge').count()).toBe(PRO_SITES_COUNT); // 'All' and 'Free' should show after you select a category. // Match exactly: card page-shot buttons (e.g. "Gallery", "Ballet Blog", @@ -55,6 +66,10 @@ test.describe('Onboarding', () => { await expect(page.getByRole('button', { name: 'All', exact: true })).toBeVisible(); await expect(page.getByRole('button', { name: 'Free', exact: true })).toBeVisible(); + // Back to the full list — the clicked category may have no fixture sites. + await page.getByRole('button', { name: 'All', exact: true }).click(); + await expect(page.locator('.ss-card-wrap')).toHaveCount(GUTENBERG_SITES.length); + // Check card structure. const firstListedSiteCard = page.locator('.ss-card-wrap').first(); await expect(firstListedSiteCard.locator('.ss-image')).toBeVisible(); @@ -67,7 +82,7 @@ test.describe('Onboarding', () => { test('Site Import Customization Rendering', async ({ page, admin }) => { await admin.visitAdminPage(ONBOARDING_URL); - await openFirstSiteAndWaitForData( page ); + await openFirstSiteAndWaitForData(page); // Customize design step. await expect(page.getByRole('button', { name: 'Select or upload image' })).toBeVisible(); @@ -76,7 +91,10 @@ test.describe('Onboarding', () => { expect(await page.locator('.ob-palette').count()).toBeGreaterThan(0); // Check if we have some color pallet available. await expect(page.getByRole('heading', { name: 'Typography' })).toBeVisible(); - expect(await page.locator('.ob-ctrl-wrap.font button').count()).toBe(7); // Check if we have some font family options available. + // Font pairs from the mocked demo data, plus the "Default" button. + expect(await page.locator('.ob-ctrl-wrap.font button').count()).toBe( + Object.keys(FONT_PAIRS).length + 1, + ); // Check if the first option is selected, select another option, reset and check again const firstPalletColorOption = page.locator('.ob-palette').first(); @@ -107,7 +125,7 @@ test.describe('Onboarding', () => { test('Site Import Plugins Rendering', async ({ page, admin }) => { await admin.visitAdminPage(ONBOARDING_URL); - await openFirstSiteAndWaitForData( page ); + await openFirstSiteAndWaitForData(page); await page.getByRole('button', { name: 'Continue' }).click(); expect(await page.locator('.ob-feature-card').count()).toBe(6); @@ -131,7 +149,7 @@ test.describe('Onboarding', () => { test('Site Import Process', async ({ page, admin }) => { await admin.visitAdminPage(ONBOARDING_URL); - await openFirstSiteAndWaitForData( page ); + await openFirstSiteAndWaitForData(page); await page.getByRole('button', { name: 'Continue' }).click(); const cachePlugin = page.getByRole('checkbox', { name: 'Caching Supercharge your site' }); await cachePlugin.click(); @@ -158,23 +176,23 @@ test.describe('Onboarding', () => { expect(page.url()).not.toContain(ONBOARDING_URL); }); - test( 'Back Button navigation', async ({ page, admin }) => { + test('Back Button navigation', async ({ page, admin }) => { await admin.visitAdminPage(ONBOARDING_URL); - await openFirstSiteAndWaitForData( page ); + await openFirstSiteAndWaitForData(page); await page.getByRole('button', { name: 'Continue' }).click(); await page.getByRole('button', { name: 'Go back' }).click(); await page.getByRole('button', { name: 'Go back' }).click(); - await expect( page.getByRole('heading', { name: 'Choose a design' }) ).toBeVisible(); + await expect(page.getByRole('heading', { name: 'Choose a design' })).toBeVisible(); }); - test( 'Exit from Site Import Steps', async ({ page, admin }) => { + test('Exit from Site Import Steps', async ({ page, admin }) => { await admin.visitAdminPage(ONBOARDING_URL); - await openFirstSiteAndWaitForData( page ); + await openFirstSiteAndWaitForData(page); await page.getByRole('button', { name: 'Continue' }).click(); - await page.locator('button:has(span.dashicons-no-alt)' ).click(); - await expect( page.getByRole('heading', { name: 'Choose a design' }) ).toBeVisible(); + await page.locator('button:has(span.dashicons-no-alt)').click(); + await expect(page.getByRole('heading', { name: 'Choose a design' })).toBeVisible(); }); }); diff --git a/e2e-tests/specs/rest-endpoints.spec.js b/e2e-tests/specs/rest-endpoints.spec.js new file mode 100644 index 00000000..13c8c010 --- /dev/null +++ b/e2e-tests/specs/rest-endpoints.spec.js @@ -0,0 +1,129 @@ +/** + * WordPress dependencies + */ +import { test, expect } from '@wordpress/e2e-test-utils-playwright'; + +/** + * Internal dependencies + */ +import baseDemoData from '../../tests/fixtures/data.json'; + +const API = '/ti-sites-lib/v1'; +const SOURCE_URL = 'https://demo.themeisle.com/neve-charity/'; + +// Request-level smoke tests. All external calls behind these endpoints are +// served by the tpc-e2e mu-plugin, so responses are deterministic. +// /install_plugins and /import_content are exercised end-to-end by the +// onboarding import spec and skipped here. +test.describe('ti-sites-lib REST endpoints', () => { + test('refresh_sites_data returns the mocked sites feed', async ({ requestUtils }) => { + const res = await requestUtils.rest({ + path: `${API}/refresh_sites_data`, + method: 'GET', + }); + + expect(res.success).toBe(true); + expect(JSON.stringify(res.data)).toContain('neve-charity'); + }); + + test('starter_order returns the personalized order', async ({ requestUtils }) => { + const res = await requestUtils.rest({ + path: `${API}/starter_order`, + method: 'GET', + params: { builder: 'gutenberg' }, + }); + + expect(res.success).toBe(true); + expect(res.builder).toBe('gutenberg'); + // Independent literals (the mu-plugin derives its response from the + // same fixture, so deriving the expectation from it too would be + // tautological). Must match mu-plugins/fixtures/sites.json. + expect(res.order).toEqual(['neve-charity', 'neve-web-agency', 'neve-restaurant']); + }); + + test('starter_search returns an order for a query', async ({ requestUtils }) => { + const res = await requestUtils.rest({ + path: `${API}/starter_search`, + method: 'GET', + params: { q: 'charity', builder: 'gutenberg' }, + }); + + expect(res.success).toBe(true); + expect(Array.isArray(res.order)).toBe(true); + expect(res.order.length).toBeGreaterThan(0); + }); + + test('import_theme_mods imports theme mods', async ({ requestUtils }) => { + const res = await requestUtils.rest({ + path: `${API}/import_theme_mods`, + method: 'POST', + data: { + source_url: SOURCE_URL, + theme_mods: baseDemoData.theme_mods, + }, + }); + + expect(res.success).toBe(true); + }); + + test('import_widgets imports widgets', async ({ requestUtils }) => { + const res = await requestUtils.rest({ + path: `${API}/import_widgets`, + method: 'POST', + data: { + source_url: SOURCE_URL, + widgets: baseDemoData.widgets, + }, + }); + + expect(res.success).toBe(true); + }); + + test('dismiss_migration sets the dismissal theme mod', async ({ requestUtils }) => { + const res = await requestUtils.rest({ + path: `${API}/dismiss_migration`, + method: 'POST', + data: { theme_mod: 'zelle_frontpage_was_imported' }, + }); + + expect(res.success).toBe(true); + }); + + test('import_single_templates creates a published page from a template', async ({ + requestUtils, + }) => { + const res = await requestUtils.rest({ + path: `${API}/import_single_templates`, + method: 'POST', + data: [ + { + template_id: 'tpc-e2e-import', + template_name: 'E2E Imported Page', + template_type: 'gutenberg', + content: '

Imported by e2e

', + }, + ], + }); + + expect(res.success).toBe(true); + expect(res.pages).toHaveLength(1); + expect(res.pages[0].title).toBe('E2E Imported Page'); + + // The page really exists on the site. + const pages = await requestUtils.rest({ + path: '/wp/v2/pages', + method: 'GET', + params: { search: 'E2E Imported Page' }, + }); + expect(pages.length).toBeGreaterThan(0); + }); + + test('cleanup runs successfully', async ({ requestUtils }) => { + const res = await requestUtils.rest({ + path: `${API}/cleanup`, + method: 'POST', + }); + + expect(res.success).toBe(true); + }); +}); diff --git a/includes/Main.php b/includes/Main.php index 49a7f864..ee82f197 100755 --- a/includes/Main.php +++ b/includes/Main.php @@ -99,7 +99,6 @@ private function init() { $this->setup_beaver(); $this->setup_elementor(); } - $this->setup_sites_listing(); add_filter( 'themeisle_sdk_hide_dashboard_widget', '__return_true' ); add_filter( 'templates_patterns_collection_feedback_review_message', @@ -110,8 +109,12 @@ function ( $message ) { } ); if ( ! $this->should_load() ) { + if ( defined( 'WP_CLI' ) && WP_CLI ) { + $this->setup_sites_listing(); + } return; } + $this->setup_sites_listing(); $this->setup_admin(); $this->setup_api(); $this->setup_active_state(); diff --git a/includes/Sites_Listing.php b/includes/Sites_Listing.php index e5858d73..cc68ce52 100755 --- a/includes/Sites_Listing.php +++ b/includes/Sites_Listing.php @@ -118,9 +118,10 @@ public function add_sites_library_support() { * @return array */ private function get_sites() { - $response = $this->get_cached_sites(); + $response = $this->get_cached_sites(); + $is_cached = ( $response !== false ); - if ( $response === false ) { + if ( ! $is_cached ) { $response = wp_remote_get( esc_url( self::get_api_path() ) ); if ( is_wp_error( $response ) || wp_remote_retrieve_response_code( $response ) !== 200 ) { @@ -168,14 +169,16 @@ private function get_sites() { } } - set_transient( - $this->transient_key, - array( - 'fetched_at' => time(), - 'data' => $response, - ), - $this->cache_ttl - ); + if ( ! $is_cached ) { + set_transient( + $this->transient_key, + array( + 'fetched_at' => time(), + 'data' => $response, + ), + $this->cache_ttl + ); + } return $response; } diff --git a/includes/Starter_Ranking.php b/includes/Starter_Ranking.php index 19bc8a6c..0785aac2 100644 --- a/includes/Starter_Ranking.php +++ b/includes/Starter_Ranking.php @@ -14,7 +14,7 @@ class Starter_Ranking { const DEFAULT_BASE = 'https://ai.themeisle.com'; const SLUG = 'neve-starter-ranking'; - const VERSION = 'v2'; + const VERSION = 'v3'; const CACHE_TTL = 7 * DAY_IN_SECONDS; const REQUEST_BUDGET = 6; const SEARCH_BUDGET = 8; diff --git a/onboarding/src/Components/Sites.js b/onboarding/src/Components/Sites.js index efee2918..af355b19 100644 --- a/onboarding/src/Components/Sites.js +++ b/onboarding/src/Components/Sites.js @@ -7,13 +7,13 @@ import { matchesCategory, searchCatalog } from '../utils/search'; /** * @typedef {Object} Site - * @property {string} url - * @property {string} remote_url - * @property {string} screenshot - * @property {string} title - * @property {string[]} keywords - * @property {boolean} isNew - * @property {string} slug + * @property {string} url Local preview URL. + * @property {string} remote_url Remote demo URL. + * @property {string} screenshot Starter-site screenshot URL. + * @property {string} title Display title. + * @property {string[]} keywords Search keywords. + * @property {boolean} isNew Whether the starter site is newly added. + * @property {string} slug Unique starter-site slug. */ const Sites = ( { @@ -147,15 +147,21 @@ const Sites = ( { return items; } + const localMatches = searchCatalog( items, searchQuery ); + if ( Array.isArray( searchOrder ) && searchOrder.length ) { - return pickBySlugs( items, searchOrder ).picked; - } + const { picked, placed } = pickBySlugs( items, searchOrder ); + localMatches.forEach( ( site ) => { + if ( site?.slug && ! placed[ site.slug ] ) { + placed[ site.slug ] = true; + picked.push( site ); + } + } ); - if ( searchFailed ) { - return searchCatalog( items, searchQuery ); + return picked; } - return []; + return localMatches; }; const filterByCategory = ( items, cat ) => { diff --git a/onboarding/src/Components/Steps/SiteList.js b/onboarding/src/Components/Steps/SiteList.js index f59700b6..299313bb 100644 --- a/onboarding/src/Components/Steps/SiteList.js +++ b/onboarding/src/Components/Steps/SiteList.js @@ -18,7 +18,7 @@ const { onboarding } = tiobDash; // Debounce the search so it stays off the network while typing: only fire after the // field is idle for SEARCH_DEBOUNCE_MS and the query is at least SEARCH_MIN_CHARS. -const SEARCH_DEBOUNCE_MS = 700; +const SEARCH_DEBOUNCE_MS = 1500; const SEARCH_MIN_CHARS = 3; // Guards tracking-session init against firing twice while the first request is in flight. @@ -49,6 +49,10 @@ const SiteList = ( { href={ tiobDash.onboardingUpsell.upgradeToast } target="_blank" rel="noopener noreferrer" + aria-label={ __( + 'View Neve Business plans', + 'templates-patterns-collection' + ) } /> ), } diff --git a/tests/sites-listing-test.php b/tests/sites-listing-test.php index 1641d009..1f0f3fc3 100644 --- a/tests/sites-listing-test.php +++ b/tests/sites-listing-test.php @@ -124,6 +124,29 @@ public function test_fresh_cache_is_served_without_remote_call() { $this->assertSame( 0, $this->remote_calls ); } + /** + * A fresh cache hit must not rewrite the transient value or reset its expiration. + */ + public function test_fresh_cache_hit_leaves_transient_and_expiration_unchanged() { + $original = array( + 'fetched_at' => time() - HOUR_IN_SECONDS, + 'data' => array( 'gutenberg' => array( 'cached-site' => array() ) ), + ); + + set_transient( $this->get_transient_key(), $original, 12 * HOUR_IN_SECONDS ); + + $timeout_option = '_transient_timeout_' . $this->get_transient_key(); + $original_expiration = get_option( $timeout_option ); + + $this->mock_api( array( 'gutenberg' => array( 'fresh-site' => array() ) ) ); + + $this->get_sites(); + + $this->assertSame( 0, $this->remote_calls ); + $this->assertSame( $original, get_transient( $this->get_transient_key() ), 'Cache-hit response must not rewrite the wrapped transient payload.' ); + $this->assertSame( $original_expiration, get_option( $timeout_option ), 'Cache-hit response must not reset the transient expiration.' ); + } + /** * An orphaned-timeout transient (value present but our own fetched_at is * past the TTL) must trigger a re-fetch instead of being served forever.