diff --git a/.env.example b/.env.example deleted file mode 100644 index b0b50fdfcc..0000000000 --- a/.env.example +++ /dev/null @@ -1,25 +0,0 @@ -# Copy to `.env` in the free plugin root and fill in values: -# cp .env.example .env -# -# Used by Playwright e2e (free + premium configs load this file). - -# Local by Flywheel / local WP URL (use https if the site redirects to SSL) -WP_BASE_URL=https://local.local - -# Relative path where Playwright stores auth cookies after global setup -WP_AUTH_STORAGE=wp-auth.json - -WP_USERNAME=admin -WP_PASSWORD=password - -# Plugin folder + main PHP file under wp-content/plugins/ -# Free/local example: Stackable/plugin -# Premium CI often uses: Stackable-Premium/plugin -STACKABLE_SLUG=Stackable/plugin - -# Optional. Only needed for existing-blocks.spec.ts (CI creates this post). -# Leave unset locally to skip that test. -# WP_TEST_POSTID= - -# Premium e2e only — Freemius license for activate-license setup -# WP_STK_LICENSE= diff --git a/.github/workflows/compressed-diff.yml b/.github/workflows/compressed-diff.yml index e2dcef9d3f..2c1bda7686 100644 --- a/.github/workflows/compressed-diff.yml +++ b/.github/workflows/compressed-diff.yml @@ -19,13 +19,14 @@ jobs: - name: Install Composer Dependencies run: | composer install --prefer-dist --no-progress --ignore-platform-reqs - - name: Setup Node # Fix because we can't do npm ci --legacy-peer-deps - uses: actions/setup-node@v1 + - name: Setup Node + uses: actions/setup-node@v4 with: - node-version: 14.x + node-version: lts/* - uses: preactjs/compressed-size-action@v2 with: repo-token: "${{ secrets.GITHUB_TOKEN }}" + install-script: "npm ci --legacy-peer-deps" build-script: "build:no-translate" clean-script: "clean" pattern: "./build/stackable/**" diff --git a/.github/workflows/e2e-tests.yml b/.github/workflows/e2e-tests.yml new file mode 100644 index 0000000000..7f03d0bb16 --- /dev/null +++ b/.github/workflows/e2e-tests.yml @@ -0,0 +1,107 @@ +name: E2E Tests + +on: + push: + branches: [ master, develop ] + pull_request: + branches: [ master, develop ] + +# One run per PR or branch. A new push cancels the previous run. +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + +jobs: + build: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: lts/* + cache: npm + cache-dependency-path: package-lock.json + - name: Build Stackable Free Plugin + run: | + npm ci --legacy-peer-deps + npm run build:e2e + - name: Archive plugin build + run: tar --exclude=./node_modules --exclude=./.git -czf /tmp/stackable-e2e-build.tar.gz . + - uses: actions/upload-artifact@v4 + with: + name: stackable-e2e-build + path: /tmp/stackable-e2e-build.tar.gz + retention-days: 1 + + test: + needs: build + timeout-minutes: 60 + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + include: + # stackable-playwright-matrix-start + - php_version: '8.5' + wp_version: '7.0' + - php_version: '7.4' + wp_version: '6.8' + # stackable-playwright-matrix-end + name: Free e2e PHP ${{ matrix.php_version }} WP ${{ matrix.wp_version }} + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: lts/* + cache: npm + cache-dependency-path: package-lock.json + - name: Restore plugin build + uses: actions/download-artifact@v4 + with: + name: stackable-e2e-build + - name: Extract plugin build + run: tar -xzf stackable-e2e-build.tar.gz + - name: Install dependencies + run: npm ci --legacy-peer-deps + - name: Set the version suffix for the output + run: echo VERSION_SUFFIX=${GITHUB_REF_NAME//\//-} >> $GITHUB_ENV + - name: Cache Playwright Browsers + uses: actions/cache@v4 + with: + path: ~/.cache/ms-playwright + key: ${{ runner.os }}-playwright-${{ hashFiles('package-lock.json') }} + restore-keys: | + ${{ runner.os }}-playwright- + - name: Install Playwright Browsers + timeout-minutes: 15 + run: | + for attempt in 1 2 3; do + npx playwright install chromium --with-deps && exit 0 + echo "Playwright install failed on attempt $attempt" + sleep $((attempt * 30)) + done + exit 1 + - name: Run free Playwright e2e suite + id: run-playwright-tests + env: + WP_PHP_VERSION: ${{ matrix.php_version }} + WP_VERSION: ${{ matrix.wp_version }} + run: npm run test:e2e + - uses: actions/upload-artifact@v4 + if: ${{ !cancelled() && steps.run-playwright-tests.outcome == 'failure' }} + id: artifact-upload-step + with: + name: playwright-report-php_${{ matrix.php_version }}-wp_${{ matrix.wp_version }}-${{ env.VERSION_SUFFIX }} + path: playwright-report/ + overwrite: true + retention-days: 30 + - uses: markpatterson27/markdown-to-output@v1 + id: mto + if: ${{ !cancelled() && steps.run-playwright-tests.outcome == 'failure' }} + with: + filepath: ./playwright-stk/errors.md + - name: Add test results to summary + if: ${{ !cancelled() && steps.run-playwright-tests.outcome == 'failure' }} + run: | + echo "${{ steps.mto.outputs.body }}" >> $GITHUB_STEP_SUMMARY + echo "Report: [playwright-report-php_${{ matrix.php_version }}-wp_${{ matrix.wp_version }}-${{ env.VERSION_SUFFIX }}.zip](https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }}/artifacts/${{ steps.artifact-upload-step.outputs.artifact-id }})" >> $GITHUB_STEP_SUMMARY diff --git a/.github/workflows/playwright.yml b/.github/workflows/playwright.yml deleted file mode 100644 index aa0e55baf8..0000000000 --- a/.github/workflows/playwright.yml +++ /dev/null @@ -1,143 +0,0 @@ -name: Playwright Tests -on: - push: - branches: [ master, develop ] - pull_request: - branches: [ master, develop ] -jobs: - build: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - uses: actions/setup-node@v4 - with: - node-version: lts/* - cache: npm - cache-dependency-path: package-lock.json - - name: Build Stackable Free Plugin - run: | - npm ci --legacy-peer-deps - npm run build:no-translate - - name: Archive plugin build - run: tar --exclude=./node_modules --exclude=./.git -czf /tmp/stackable-playwright-build.tar.gz . - - uses: actions/upload-artifact@v4 - with: - name: stackable-playwright-build - path: /tmp/stackable-playwright-build.tar.gz - retention-days: 1 - - test: - needs: build - timeout-minutes: 60 - runs-on: ubuntu-latest - strategy: - fail-fast: false # Ensures the matrix doesn't stop if one job fails - matrix: - include: - # stackable-playwright-matrix-start - - php_version: '8.5' - wp_version: '7.0.2' - - php_version: '7.4' - wp_version: '7.0.2' - - php_version: '7.4' - wp_version: '6.8.6' - - php_version: '7.4' - wp_version: '6.9.5' - # stackable-playwright-matrix-end - name: PHP ${{ matrix.php_version }} and WP ${{ matrix.wp_version || 'latest' }} - steps: - - uses: actions/checkout@v4 - - uses: actions/setup-node@v4 - with: - node-version: lts/* - cache: npm - cache-dependency-path: package-lock.json - - name: Restore plugin build - uses: actions/download-artifact@v4 - with: - name: stackable-playwright-build - - name: Extract plugin build - run: tar -xzf stackable-playwright-build.tar.gz - - name: Install dependencies - run: npm ci --legacy-peer-deps - - name: Set the version suffix for the output - run: echo VERSION_SUFFIX=${GITHUB_REF_NAME//\//-} >> $GITHUB_ENV - - name: Set WP Version and create .wp-env.json - run: | - WP_VERSION="${{ matrix.wp_version }}" - core='"Wordpress/Wordpress#'${{ matrix.wp_version }}'"' - - if [[ -z "$WP_VERSION" || "$WP_VERSION" == "null" ]]; then - WP_VERSION="latest" - core=null - fi - - echo "WP_VERSION=$WP_VERSION" >> $GITHUB_ENV - - echo '{ - "core": '$core', - "phpVersion": "${{ matrix.php_version }}", - "plugins": [ "." ], - "config": { - "SCRIPT_DEBUG": false - } - }' > .wp-env.json - - cat .wp-env.json - - name: Cache Playwright Browsers - uses: actions/cache@v4 - with: - path: ~/.cache/ms-playwright - key: ${{ runner.os }}-playwright-${{ hashFiles('package-lock.json') }} - restore-keys: | - ${{ runner.os }}-playwright- - - name: Install Playwright Browsers - timeout-minutes: 15 - run: | - for attempt in 1 2 3; do - npx playwright install chromium --with-deps && exit 0 - echo "Playwright install failed on attempt $attempt" - sleep $((attempt * 30)) - done - exit 1 - - name: Start wp-env - run: npx @wordpress/env start - - name: Print current WP version - run: npx @wordpress/env run tests-cli wp core version - - name: Create post with existing blocks - run: | - POST_ID=$(npx @wordpress/env run tests-cli wp post create wp-content/plugins/Stackable/e2e/config/post-content.txt --post_title="Existing Blocks" --post_status=publish --porcelain) - echo "WP_TEST_POSTID=$POST_ID" >> $GITHUB_ENV - continue-on-error: true - - name: Disable guided tours - run: | - TOUR_STATES='["design-system", "editor", "design-library", "blocks"]' - npx @wordpress/env run tests-cli wp option update stackable_guided_tour_states "$TOUR_STATES" --format=json - - name: Run playwright tests - id: run-playwright-tests - env: - WP_BASE_URL: http://localhost:8889 - WP_AUTH_STORAGE: wp-auth.json - WP_USERNAME: admin - WP_PASSWORD: password - STACKABLE_SLUG: Stackable/plugin - WP_TEST_POSTID: ${{ env.WP_TEST_POSTID }} - run: npm run test - - uses: actions/upload-artifact@v4 - if: ${{ !cancelled() && steps.run-playwright-tests.outcome == 'failure' }} - id: artifact-upload-step - with: - name: playwright-report-php_${{ matrix.php_version }}-wp_${{ env.WP_VERSION }}-${{ env.VERSION_SUFFIX }} - path: playwright-report/ - overwrite: true - retention-days: 30 - - uses: markpatterson27/markdown-to-output@v1 - id: mto - if: ${{ !cancelled() && steps.run-playwright-tests.outcome == 'failure' }} - with: - filepath: ./playwright-stk/errors.md - - name: Add test results to summary - if: ${{ !cancelled() && steps.run-playwright-tests.outcome == 'failure' }} - run: | - echo "${{ steps.mto.outputs.body }}" >> $GITHUB_STEP_SUMMARY - echo "Report: [playwright-report-php_${{ matrix.php_version }}-wp_${{ env.WP_VERSION }}-${{ env.VERSION_SUFFIX }}.zip](https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }}/artifacts/${{ steps.artifact-upload-step.outputs.artifact-id }})" >> $GITHUB_STEP_SUMMARY diff --git a/.gitignore b/.gitignore index e18c9711bb..bded210c4b 100644 --- a/.gitignore +++ b/.gitignore @@ -21,5 +21,9 @@ composer.lock /wordpress /test-results/ /playwright-report/ +/playwright-report-premium/ +/playwright-stk/ +/playwright-stk-premium/ /blob-report/ /playwright/.cache/ +e2e/.auth/ diff --git a/AGENTS.md b/AGENTS.md index 94c02752c0..dceb45f988 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -59,8 +59,11 @@ Do not apply GitHub changes (close, comment, relabel) during issue triage withou ### Testing -- Unit / Jest: `npm test` (and related `test:*` scripts in `package.json`). -- Playwright e2e: see [`e2e/readme.md`](./e2e/readme.md) (`npm test` / `npm run test:debug` with `.env` configured). +- Unit / Jest: `npm run test:scripts` / `npm run test-old` (and related coverage scripts). +- Playwright e2e (WordPress Playground, no Docker): see [`e2e/readme.md`](./e2e/readme.md) + (`npm run build:e2e` then `npm run test:e2e` / `npm run test:debug`). + Premium: `npm run build:e2e:premium` then `npm run test:e2e:premium` with + `pro__premium_only/` present. - Lint: `npm run lint` / `lint-js` / `lint-css` as appropriate for touched files. ### Quality gate diff --git a/e2e/config/blocks-catalog.d.ts b/e2e/config/blocks-catalog.d.ts new file mode 100644 index 0000000000..4b56629739 --- /dev/null +++ b/e2e/config/blocks-catalog.d.ts @@ -0,0 +1 @@ +export const TOP_LEVEL_BLOCKS: string[] diff --git a/e2e/config/blocks-catalog.js b/e2e/config/blocks-catalog.js new file mode 100644 index 0000000000..74075205f8 --- /dev/null +++ b/e2e/config/blocks-catalog.js @@ -0,0 +1,54 @@ +/** + * Inserter-facing Stackable blocks for catalog smokes. + * Child-only types are omitted; parent templates already include them. + * stackable/design-library is a picker, not saved content. + * + * Keep in sync with inserter-facing stackable blocks in src/block//block.json + * (parent unset, not stk-type child-only). New top-level blocks must be added here. + */ +const TOP_LEVEL_BLOCKS = [ + 'stackable/accordion', + 'stackable/blockquote', + 'stackable/button-group', + 'stackable/call-to-action', + 'stackable/card', + 'stackable/carousel', + 'stackable/columns', + 'stackable/count-up', + 'stackable/countdown', + 'stackable/divider', + 'stackable/expand', + 'stackable/feature', + 'stackable/feature-grid', + 'stackable/heading', + 'stackable/hero', + 'stackable/horizontal-scroller', + 'stackable/icon', + 'stackable/icon-box', + 'stackable/icon-label', + 'stackable/icon-list', + 'stackable/image', + 'stackable/image-box', + 'stackable/map', + 'stackable/notification', + 'stackable/number-box', + 'stackable/posts', + 'stackable/price', + 'stackable/pricing-box', + 'stackable/progress-bar', + 'stackable/progress-circle', + 'stackable/separator', + 'stackable/spacer', + 'stackable/subtitle', + 'stackable/table-of-contents', + 'stackable/tabs', + 'stackable/team-member', + 'stackable/testimonial', + 'stackable/text', + 'stackable/timeline', + 'stackable/video-popup', +] + +module.exports = { + TOP_LEVEL_BLOCKS, +} diff --git a/e2e/config/fixtures/design-library-pages.json b/e2e/config/fixtures/design-library-pages.json new file mode 100644 index 0000000000..bb99079925 --- /dev/null +++ b/e2e/config/fixtures/design-library-pages.json @@ -0,0 +1,23 @@ +{ + "home-page-1": { + "id": "home-page-1", + "label": "Home Page 1", + "description": "E2E free home page", + "plan": "free", + "category": "Home Page", + "template": [ + { "id": "section-call-to-action-1", "bg": true }, + { "id": "section-card-1" } + ] + }, + "about-page-1": { + "id": "about-page-1", + "label": "About Page 1", + "description": "E2E free about page", + "plan": "free", + "category": "About Page", + "template": [ + { "id": "section-card-1" } + ] + } +} diff --git a/e2e/config/fixtures/design-library-patterns.json b/e2e/config/fixtures/design-library-patterns.json new file mode 100644 index 0000000000..ad5e46c281 --- /dev/null +++ b/e2e/config/fixtures/design-library-patterns.json @@ -0,0 +1,26 @@ +{ + "section-call-to-action-1": { + "id": "section-call-to-action-1", + "label": "Call to Action 1", + "description": "E2E free CTA pattern", + "plan": "free", + "category": "Call to Action", + "template": "

E2E Free CTA

" + }, + "section-call-to-action-3": { + "id": "section-call-to-action-3", + "label": "Call to Action 3", + "description": "E2E premium CTA pattern", + "plan": "premium", + "category": "Call to Action", + "template": "

E2E Premium CTA

" + }, + "section-card-1": { + "id": "section-card-1", + "label": "Card 1", + "description": "E2E free card pattern", + "plan": "free", + "category": "Card", + "template": "

E2E Free Card

" + } +} diff --git a/e2e/config/global-setup.js b/e2e/config/global-setup.js new file mode 100644 index 0000000000..5bfcb7d2f6 --- /dev/null +++ b/e2e/config/global-setup.js @@ -0,0 +1,33 @@ +/** + * Playwright global setup: cookie-authenticates as the Playground blueprint + * admin (admin / password) and persists storage state for specs + requestUtils. + * + * Writes e2e/.auth/test-env.json with STACKABLE_SLUG. + */ +const fs = require( 'fs/promises' ) +const path = require( 'path' ) +const { RequestUtils } = require( '@wordpress/e2e-test-utils-playwright' ) + +module.exports = async function globalSetup() { + const storageStatePath = process.env.STORAGE_STATE_PATH + const requestUtils = await RequestUtils.setup( { + user: { + username: process.env.WP_USERNAME || 'admin', + password: process.env.WP_PASSWORD || 'password', + }, + storageStatePath, + baseURL: process.env.WP_BASE_URL, + } ) + + await requestUtils.setupRest() + + const authDir = path.dirname( storageStatePath || path.join( __dirname, '../.auth/admin.json' ) ) + await fs.mkdir( authDir, { recursive: true } ) + + await fs.writeFile( + path.join( authDir, 'test-env.json' ), + JSON.stringify( { + STACKABLE_SLUG: process.env.STACKABLE_SLUG || 'stackable/plugin', + }, null, 2 ) + ) +} diff --git a/e2e/config/global-setup.ts b/e2e/config/global-setup.ts deleted file mode 100644 index f465ba6aab..0000000000 --- a/e2e/config/global-setup.ts +++ /dev/null @@ -1,43 +0,0 @@ -// Gutenberg global setup: https://github.com/WordPress/gutenberg/blob/b4304f8bf6bd9b890b4108adcc326cd586a3ab4e/test/e2e/config/global-setup.ts - -import { request } from '@playwright/test' - -import { ExtendedRequestUtils } from 'e2e/test-utils' - -// To interact with the WP Guest Bar plugin's settings page, we must be authenticated. -// Before any tests are run, we sign in, save the cookies set by WordPress, and then discard the session. -// Later, when we need to act as a logged-in user, we make those cookies available. -// https://playwright.dev/docs/test-global-setup-teardown#configure-globalsetup-and-globalteardown -async function globalSetup() { - const requestContext = await request.newContext( { - baseURL: process.env.WP_BASE_URL, - // Local by Flywheel (and similar) often use a self-signed HTTPS cert. - ignoreHTTPSErrors: true, - } ) - const requestUtils = new ExtendedRequestUtils( requestContext, { - storageStatePath: process.env.WP_AUTH_STORAGE, - user: { - username: process.env.WP_USERNAME, - password: process.env.WP_PASSWORD, - }, - } ) - - // Alternatively, we could take a more traditional route, - // filling in the input fields for the username and password and submitting the form. - // https://playwright.dev/docs/test-global-setup-teardown#example - await requestUtils.setupRest() - - // Deactivate all plugins except Stackable - const plugins = await requestUtils.getActivePlugins() - - // Use for loop because forEach cannot handle async operations - for ( const slug of Object.keys( plugins ) ) { - await requestUtils.deactivatePlugin( slug ) - } - - await requestUtils.activatePlugin( process.env.STACKABLE_SLUG ) - - await requestContext.dispose() -} - -export default globalSetup diff --git a/e2e/config/stackable-e2e-mu-plugin.php b/e2e/config/stackable-e2e-mu-plugin.php index 603902e018..b3fca80ac6 100644 --- a/e2e/config/stackable-e2e-mu-plugin.php +++ b/e2e/config/stackable-e2e-mu-plugin.php @@ -2,10 +2,15 @@ /** * Must-use plugin for Stackable Playwright e2e tests. * - * Registers post meta used by Dynamic Content tests so it can be set via the REST API. + * - Registers post meta used by Dynamic Content tests so it can be set via REST. + * - Mocks Design Library CDN responses so free/premium suites do not depend on + * outbound network to stackable-files.pages.dev. * - * Load via wp-env mappings, e.g.: - * "wp-content/mu-plugins/stackable-e2e.php": "./e2e/config/stackable-e2e-mu-plugin.php" + * Mounted by playwright.config.js and playwright.premium.config.js into + * Playground as `wp-content/mu-plugins/stackable-e2e.php`. + * + * Fixtures live under the mounted plugin tree: + * `wp-content/plugins/stackable/e2e/config/fixtures/`. */ add_action( 'init', function() { @@ -18,3 +23,54 @@ }, ) ); } ); + +/** + * Serve local Design Library fixtures instead of hitting the CDN. + * + * Matches `library-v4/library.json` and `library-v4/pages.json` requests from + * `Stackable_Design_Library::get_design_library_from_cloud()`. + * + * @param false|array|WP_Error $preempt + * @param array $args + * @param string $url + * @return false|array + */ +add_filter( 'pre_http_request', function( $preempt, $args, $url ) { + if ( false !== $preempt ) { + return $preempt; + } + + $fixture_file = null; + if ( false !== strpos( $url, 'library-v4/library.json' ) ) { + $fixture_file = 'design-library-patterns.json'; + } elseif ( false !== strpos( $url, 'library-v4/pages.json' ) ) { + $fixture_file = 'design-library-pages.json'; + } + + if ( ! $fixture_file ) { + return $preempt; + } + + $fixture_path = WP_PLUGIN_DIR . '/stackable/e2e/config/fixtures/' . $fixture_file; + if ( ! is_readable( $fixture_path ) ) { + return $preempt; + } + + $body = file_get_contents( $fixture_path ); + if ( false === $body || '' === $body ) { + return $preempt; + } + + return array( + 'headers' => array( + 'content-type' => 'application/json', + ), + 'body' => $body, + 'response' => array( + 'code' => 200, + 'message' => 'OK', + ), + 'cookies' => array(), + 'filename' => null, + ); +}, 10, 3 ); diff --git a/e2e/playground-blueprint.json b/e2e/playground-blueprint.json new file mode 100644 index 0000000000..905364a355 --- /dev/null +++ b/e2e/playground-blueprint.json @@ -0,0 +1,22 @@ +{ + "$schema": "https://playground.wordpress.net/blueprint-schema.json", + "features": { + "networking": true + }, + "steps": [ + { + "step": "login", + "username": "admin", + "password": "password" + }, + { + "step": "activatePlugin", + "pluginName": "Stackable - Gutenberg Blocks", + "pluginPath": "/wordpress/wp-content/plugins/stackable/plugin.php" + }, + { + "step": "runPHP", + "code": " { - // Setup the handler. - // await page.addLocatorHandler( page.getByText( 'Administration email verification' ), async () => { - // await page.getByRole( 'button', { name: 'The email is correct' } ).click() - // } ) - - await page.goto( '/wp-login.php' ) - await page.getByLabel( 'Username or Email Address' ).fill( process.env.WP_USERNAME ) - await page.getByLabel( 'Password', { exact: true } ).fill( process.env.WP_PASSWORD ) - await page.getByRole( 'button', { name: 'Log In' } ).click() - - // Handle if the page suddenly asks for email verification. - if ( await page.locator( 'Administration email verification' ).count() ) { - await page.getByRole( 'button', { name: 'The email is correct' } ).click() - } - - await page.waitForURL( '**/wp-admin/' ) - await page.context().storageState( { path: authFile } ) -} ) diff --git a/e2e/test-utils/block-recovery.ts b/e2e/test-utils/block-recovery.ts new file mode 100644 index 0000000000..91accaa546 --- /dev/null +++ b/e2e/test-utils/block-recovery.ts @@ -0,0 +1,22 @@ +import { expect } from '@wordpress/e2e-test-utils-playwright' +import type { ExtendedEditor } from './editor' + +/** + * Fail when Gutenberg shows invalid-block recovery UI. + * Do not listen for console "Block validation" messages. + * + * @param editor + */ +export const assertNoBlockRecovery = async ( editor: ExtendedEditor ) => { + const canvas = editor.canvas + + await expect( canvas.locator( 'body' ) ).toBeVisible() + + const warning = canvas.locator( '.block-editor-warning' ) + const recoveryButton = canvas.getByRole( 'button', { name: /Attempt (Block )?Recovery/i } ) + const invalidCopy = canvas.getByText( /unexpected or invalid content/i ) + + await expect( warning ).toHaveCount( 0 ) + await expect( recoveryButton ).toHaveCount( 0 ) + await expect( invalidCopy ).toHaveCount( 0 ) +} diff --git a/e2e/test-utils/catalog.ts b/e2e/test-utils/catalog.ts new file mode 100644 index 0000000000..06247ea5ae --- /dev/null +++ b/e2e/test-utils/catalog.ts @@ -0,0 +1,37 @@ +import { expect } from '@wordpress/e2e-test-utils-playwright' +import { TOP_LEVEL_BLOCKS } from '../config/blocks-catalog.js' +import type { ExtendedEditor } from './editor' + +export { TOP_LEVEL_BLOCKS } + +export const waitForBlockEditor = async ( editor: ExtendedEditor ) => { + await editor.page.waitForFunction( + () => window?.wp?.blocks?.createBlock && window?.wp?.data?.dispatch + ) + await expect( editor.canvas.locator( 'body' ) ).toBeVisible() +} + +/** + * Insert default instances via the active plugin's createBlock (old or current save()). + * + * @param editor + * @param names + */ +export const insertStackableCatalog = async ( + editor: ExtendedEditor, + names: string[] = TOP_LEVEL_BLOCKS +) => { + await waitForBlockEditor( editor ) + + await editor.page.evaluate( blockNames => { + const { createBlock } = window.wp.blocks + const { insertBlocks } = window.wp.data.dispatch( 'core/block-editor' ) + insertBlocks( blockNames.map( name => createBlock( name ) ) ) + }, names ) + + for ( const name of names ) { + await expect( + editor.canvas.locator( `[data-type="${ name }"]` ).first() + ).toBeVisible( { timeout: 60_000 } ) + } +} diff --git a/e2e/test-utils/editor.ts b/e2e/test-utils/editor.ts index a378df73bc..c0d6fcb4f5 100644 --- a/e2e/test-utils/editor.ts +++ b/e2e/test-utils/editor.ts @@ -1,8 +1,6 @@ import { Editor as BaseEditor } from '@wordpress/e2e-test-utils-playwright' class ExtendedEditor extends BaseEditor { - blockErrors: Array = [] - getBlockAttributes = async function( clientId : String ) { await this.page.waitForFunction( () => window?.wp?.blocks && window?.wp?.data @@ -15,15 +13,47 @@ class ExtendedEditor extends BaseEditor { return attributes } - getBlockErrors() :Array { - this.blockErrors = [] - this.page.on( 'console', msg => { - if ( msg.type() === 'error' && msg.text().startsWith( 'Block validation' ) ) { - this.blockErrors.push( msg.text() ) - } + /** + * Persist the current editor document as a draft. + * + * Gutenberg's helper clicks the "Save draft" top-bar button. After autosave + * or a reload of an already-saved draft that button is named "Saved" and + * the click waits until the test timeout closes the page. + */ + saveDraft = async function() { + await this.page.waitForFunction( + () => window?.wp?.data?.select?.( 'core/editor' ) && window?.wp?.data?.dispatch?.( 'core/editor' ) + ) + + await this.page.waitForFunction( () => { + return ! window.wp.data.select( 'core/editor' ).isSavingPost() + }, null, { timeout: 60_000 } ) + + const needsSave = await this.page.evaluate( () => { + const editor = window.wp.data.select( 'core/editor' ) + return editor.isEditedPostNew() || editor.isEditedPostDirty() } ) - return this.blockErrors + if ( needsSave ) { + await this.page.evaluate( async () => { + await window.wp.data.dispatch( 'core/editor' ).savePost() + } ) + + await this.page.waitForFunction( () => { + return ! window.wp.data.select( 'core/editor' ).isSavingPost() + }, null, { timeout: 60_000 } ) + + const saveFailed = await this.page.evaluate( () => { + return window.wp.data.select( 'core/editor' ).didPostSaveRequestFail() + } ) + if ( saveFailed ) { + throw new Error( 'Editor saveDraft failed to persist the post' ) + } + } + + await this.page.waitForFunction( () => { + return !! new URLSearchParams( window.location.search ).get( 'post' ) + } ) } } diff --git a/e2e/test-utils/frontend.ts b/e2e/test-utils/frontend.ts new file mode 100644 index 0000000000..1c703a62c7 --- /dev/null +++ b/e2e/test-utils/frontend.ts @@ -0,0 +1,50 @@ +import { expect } from '@wordpress/e2e-test-utils-playwright' +import type { Page } from '@playwright/test' +import type { ExtendedRequestUtils } from './requestUtils' + +type AdminFixture = { + getPageError: () => Promise; +} + +type EditorFixture = { + page: Page; + saveDraft: () => Promise; +} + +const readPostIdFromEditor = ( page: Page ) => { + const postQuery = new URL( page.url() ).search + return new URLSearchParams( postQuery ).get( 'post' ) +} + +/** + * Publish the current editor draft via REST and open the frontend permalink. + * + * @param page + * @param editor + * @param requestUtils + * @param admin + */ +export const publishAndVisitFrontend = async ( + page: Page, + editor: EditorFixture, + requestUtils: ExtendedRequestUtils, + admin: AdminFixture +) => { + await editor.saveDraft() + const postId = readPostIdFromEditor( editor.page ) + expect( postId ).toBeTruthy() + + await requestUtils.updatePost( postId as string, { status: 'publish' } ) + const post = await requestUtils.rest( { + path: `/wp/v2/posts/${ postId }`, + } ) + + await page.goto( post.link ) + + const pageError = await admin.getPageError() + expect( pageError ).toBeNull() + + return { postId, link: post.link as string } +} + +export const getEditorPostId = readPostIdFromEditor diff --git a/e2e/test-utils/index.ts b/e2e/test-utils/index.ts index 5595bda796..ef7240888f 100644 --- a/e2e/test-utils/index.ts +++ b/e2e/test-utils/index.ts @@ -1,4 +1,21 @@ export { test, expect } from './test' export { ExtendedRequestUtils } from './requestUtils' export { StackableFixture } from './stackable' +export { ExtendedEditor } from './editor' export * from './format' +export { assertNoBlockRecovery } from './block-recovery' +export { insertStackableCatalog, waitForBlockEditor, TOP_LEVEL_BLOCKS } from './catalog' +export { publishAndVisitFrontend, getEditorPostId } from './frontend' +export { + assertNoUpsell, + openInspectorPanel, + openBlockStylesPopover, + closeBlockStylesPopover, + waitForWpSettingsSave, + saveNewBlockStyle, + applyBlockStyleByName, + confirmUpdateBlockStyle, + blockStyleRow, + fillInspectorRange, + copyOrPasteStyles, +} from './inspector' diff --git a/e2e/test-utils/inspector.ts b/e2e/test-utils/inspector.ts new file mode 100644 index 0000000000..5ab0420823 --- /dev/null +++ b/e2e/test-utils/inspector.ts @@ -0,0 +1,135 @@ +import { expect } from '@wordpress/e2e-test-utils-playwright' +import type { Page } from '@playwright/test' + +export const assertNoUpsell = async ( page: Page ) => { + await expect( page.locator( '.ugb-design-control-pro-note' ) ).toHaveCount( 0 ) + await expect( page.getByRole( 'link', { name: 'Get Premium' } ) ).toHaveCount( 0 ) +} + +export const openInspectorPanel = async ( page: Page, title: string ) => { + const button = page.getByRole( 'button', { name: title, exact: true } ).first() + await expect( button ).toBeVisible() + const expanded = await button.getAttribute( 'aria-expanded' ) + if ( expanded !== 'true' ) { + await button.click() + } +} + +export const openBlockStylesPopover = async ( page: Page ) => { + const blockTab = page.getByRole( 'tab', { name: 'Block' } ) + if ( await blockTab.isVisible().catch( () => false ) ) { + await blockTab.click() + } + + const button = page.locator( '.ugb-block-styles-controls__block-style-button' ) + await expect( button ).toBeVisible( { timeout: 15_000 } ) + if ( ! await button.evaluate( el => el.classList.contains( 'is-opened' ) ) ) { + await button.click() + } + const popover = page.locator( '.ugb-block-styles-controls__popover' ) + await expect( popover ).toBeVisible( { timeout: 15_000 } ) + return popover +} + +export const closeBlockStylesPopover = async ( page: Page ) => { + const button = page.locator( '.ugb-block-styles-controls__block-style-button' ) + if ( await button.evaluate( el => el.classList.contains( 'is-opened' ) ) ) { + await button.click() + } + await expect( page.locator( '.ugb-block-styles-controls__popover' ) ).toHaveCount( 0 ) +} + +export const waitForWpSettingsSave = ( page: Page, timeout = 15_000 ) => + page.waitForResponse( response => { + const url = decodeURIComponent( response.url() ) + return url.includes( '/wp/v2/settings' ) && + [ 'POST', 'PUT', 'PATCH' ].includes( response.request().method() ) + }, { timeout } ) + +export const saveNewBlockStyle = async ( page: Page, styleName: string, styleSlug: string ) => { + const popover = await openBlockStylesPopover( page ) + await expect( popover.getByRole( 'button', { name: 'Save New Block Style' } ) ) + .toBeVisible( { timeout: 15_000 } ) + await popover.getByRole( 'button', { name: 'Save New Block Style' } ).click() + + const modal = page.locator( '.ugb-block-styles__new-style-modal' ) + await expect( modal ).toBeVisible() + await expect( modal.getByRole( 'heading', { name: 'Create New Block Style' } ) ).toBeVisible() + await modal.locator( 'input[name="name"]' ).click() + await modal.locator( 'input[name="name"]' ).fill( styleName ) + await modal.locator( 'input[name="slug"]' ).click() + await modal.locator( 'input[name="slug"]' ).fill( styleSlug ) + await modal.locator( 'input[name="slug"]' ).blur() + + const saved = waitForWpSettingsSave( page ) + await modal.getByRole( 'button', { name: 'Create Block Style' } ).click() + await saved.catch( () => undefined ) + await expect( modal ).toHaveCount( 0, { timeout: 15_000 } ) + await expect( page.locator( '.ugb-block-styles-controls__block-style-button' ) ) + .toContainText( styleName, { timeout: 15_000 } ) +} + +export const applyBlockStyleByName = async ( page: Page, styleName: string ) => { + const popover = await openBlockStylesPopover( page ) + await expect( popover.locator( '.ugb-block-styles-controls__list' ) ) + .toContainText( styleName ) + await popover.locator( '.ugb-block-styles-controls__list' ) + .getByRole( 'button', { name: styleName } ) + .click() + await closeBlockStylesPopover( page ) +} + +export const blockStyleRow = ( page: Page, blockStyleAttr: string ) => + page.locator( '.ugb-global-block-styles__panel .stk-global-settings-color-picker__color-indicator-wrapper' ) + .filter( { has: page.locator( `[data-item-key="${ blockStyleAttr }"]` ) } ) + +export const fillInspectorRange = async ( page: Page, label: string | RegExp, value: string ) => { + const control = page.locator( '.stk-control' ).filter( { + has: page.locator( '.stk-control-label', { hasText: label } ), + } ).first() + await expect( control ).toBeVisible() + + const numberInput = control.locator( 'input[type="text"], input[type="number"]' ) + if ( await numberInput.count() === 0 ) { + await control.locator( '.stk-range-control__custom-button' ).click() + } + + await expect( numberInput.first() ).toBeVisible() + await numberInput.first().fill( value ) +} + +export const copyOrPasteStyles = async ( + editor: { page: Page, showBlockToolbar?: () => Promise }, + action: 'copy' | 'paste' +) => { + if ( editor.showBlockToolbar ) { + await editor.showBlockToolbar() + } + + const toolbarButton = editor.page.getByLabel( 'Copy & paste styles' ) + await expect( toolbarButton ).toBeVisible() + await toolbarButton.click() + + const menuItem = action === 'copy' ? 'Adv Copy Styles' : 'Adv Paste Styles' + await editor.page.locator( '.stk-copy-paste-styles__menu' ) + .getByRole( 'button', { name: menuItem } ) + .click() + await editor.page.keyboard.press( 'Escape' ) +} + +export const confirmUpdateBlockStyle = async ( page: Page ) => { + const popover = await openBlockStylesPopover( page ) + const update = popover.getByRole( 'button', { name: 'Update Style' } ) + await expect( update ).toBeVisible( { timeout: 15_000 } ) + await update.click() + + const modal = page.locator( '.ugb-block-styles__new-style-modal' ) + await expect( modal ).toBeVisible() + await expect( modal.getByRole( 'heading', { name: /Update Block Style/ } ) ).toBeVisible() + + const saved = waitForWpSettingsSave( page ) + await modal.getByRole( 'button', { name: 'Update Block Style' } ).click() + await saved.catch( () => undefined ) + await expect( modal ).toHaveCount( 0, { timeout: 15_000 } ) + await closeBlockStylesPopover( page ) +} diff --git a/e2e/test-utils/mock-design-library.ts b/e2e/test-utils/mock-design-library.ts new file mode 100644 index 0000000000..6b93ded728 --- /dev/null +++ b/e2e/test-utils/mock-design-library.ts @@ -0,0 +1,40 @@ +/** + * Browser-side Design Library REST mocks for Playwright. + * + * Complements the PHP `pre_http_request` CDN mock in + * `e2e/config/stackable-e2e-mu-plugin.php`. Playwright routes still apply when + * a reused local Playground was started without that MU-plugin mounted. + */ +import fs from 'fs' +import path from 'path' +import type { Page } from '@playwright/test' + +const FIXTURES_DIR = path.join( __dirname, '../config/fixtures' ) + +const readFixture = ( name: string ) => + JSON.parse( fs.readFileSync( path.join( FIXTURES_DIR, name ), 'utf8' ) ) + +const patternsFixture = readFixture( 'design-library-patterns.json' ) +const pagesFixture = readFixture( 'design-library-pages.json' ) + +/** + * Intercept Design Library REST calls and fulfill with local fixtures. + * Response shape matches PHP: `{ v4: { [id]: design } }`. + * + * @param page + */ +export const mockDesignLibraryRest = async ( page: Page ) => { + await page.route( '**/stackable/v2/design_library/**', async route => { + const url = decodeURIComponent( route.request().url() ) + const isPages = /\/design_library\/pages(?:\/|$|\?)/.test( url ) + const body = { + v4: isPages ? pagesFixture : patternsFixture, + } + + await route.fulfill( { + status: 200, + contentType: 'application/json', + body: JSON.stringify( body ), + } ) + } ) +} diff --git a/e2e/test-utils/requestUtils.ts b/e2e/test-utils/requestUtils.ts index d53c48af58..2983acab50 100644 --- a/e2e/test-utils/requestUtils.ts +++ b/e2e/test-utils/requestUtils.ts @@ -1,10 +1,20 @@ import { request as playwrightRequest } from '@playwright/test' import { RequestUtils as BaseRequestUtils } from '@wordpress/e2e-test-utils-playwright' +const REST_NONCE_PATTERN = /^[a-zA-Z0-9_-]{2,64}$/ + +/** + * WP plugin routes identify a plugin as `dir/file`. The slash must be encoded + * so it is one path segment (`dir%2Ffile`), not two. + * + * @param pluginFile Plugin file relative to wp-content/plugins, e.g. `stackable/plugin`. + */ +const pluginFileRestPath = ( pluginFile: string ) => + `/wp/v2/plugins/${ encodeURIComponent( pluginFile ) }` + class ExtendedRequestUtils extends BaseRequestUtils { /** - * Same as the upstream helper, but allows Local by Flywheel self-signed HTTPS certs. - * (Matches Interactions e2e/test-utils/requestUtils.ts.) + * Same as the upstream helper, with ignoreHTTPSErrors for local HTTPS overrides. * * @param options */ @@ -33,6 +43,16 @@ class ExtendedRequestUtils extends BaseRequestUtils { } } + // Drop a poisoned nonce (e.g. HTML from a Freemius activation redirect) + // so setupRest refreshes it instead of sending it as X-WP-Nonce. + if ( storageState?.nonce && ! REST_NONCE_PATTERN.test( String( storageState.nonce ).trim() ) ) { + storageState = { + ...storageState, + nonce: undefined, + rootURL: undefined, + } + } + const requestContext = await playwrightRequest.newContext( { baseURL, ignoreHTTPSErrors: true, @@ -50,6 +70,52 @@ class ExtendedRequestUtils extends BaseRequestUtils { } ) } + /** + * Upstream login() accepts any response body as the nonce. On Playground + + * Freemius, the first admin request after activation can 302 to Getting + * Started; following that redirect stores HTML as X-WP-Nonce and breaks REST. + * + * @param user + */ + login = async function( user = this.user ) { + let response = await this.request.post( 'wp-login.php', { + failOnStatusCode: true, + form: { + log: user.username, + pwd: user.password, + }, + } ) + await response.dispose() + + response = await this.request.get( 'wp-admin/admin-ajax.php?action=rest-nonce', { + failOnStatusCode: false, + maxRedirects: 0, + } ) + + if ( response.status() >= 300 && response.status() < 400 ) { + await response.dispose() + // Consume the one-shot activation redirect, then fetch the real nonce. + const bounced = await this.request.get( 'wp-admin/' ) + await bounced.dispose() + response = await this.request.get( 'wp-admin/admin-ajax.php?action=rest-nonce', { + failOnStatusCode: true, + } ) + } else if ( ! response.ok() ) { + const body = await response.text() + throw new Error( `rest-nonce failed (${ response.status() }): ${ body.slice( 0, 120 ) }` ) + } + + const nonce = ( await response.text() ).trim() + if ( ! REST_NONCE_PATTERN.test( nonce ) ) { + throw new Error( + `Invalid REST nonce (got ${ JSON.stringify( nonce.slice( 0, 80 ) ) }). ` + + 'Likely followed an admin redirect instead of admin-ajax rest-nonce.' + ) + } + + return nonce + } + getActivePlugins = async function() { const plugins : { [key: string]: any }[] = await this.rest( { path: '/wp/v2/plugins', @@ -68,7 +134,7 @@ class ExtendedRequestUtils extends BaseRequestUtils { try { await this.rest( { method: 'PUT', - path: `/wp/v2/plugins/${ slug }`, + path: pluginFileRestPath( slug ), data: { status: 'inactive' }, } ) } catch ( error ) { @@ -80,7 +146,7 @@ class ExtendedRequestUtils extends BaseRequestUtils { try { await this.rest( { method: 'PUT', - path: `/wp/v2/plugins/${ slug }`, + path: pluginFileRestPath( slug ), data: { status: 'active' }, } ) } catch ( error ) { diff --git a/e2e/test-utils/stackable.ts b/e2e/test-utils/stackable.ts index 4fe0123cbf..605c581e9d 100644 --- a/e2e/test-utils/stackable.ts +++ b/e2e/test-utils/stackable.ts @@ -1,4 +1,5 @@ import { Page, Request } from '@playwright/test' +import { expect } from '@wordpress/e2e-test-utils-playwright' import { test } from './test' export class StackableFixture { @@ -60,4 +61,70 @@ export class StackableFixture { response.ok() } ).then( () => undefined ) } + + async openBlockSettings() { + // Scope to the editor chrome. Column Arrangement (and similar inspector + // controls) also expose a "Settings" button that would otherwise match. + const settings = this.page + .getByRole( 'region', { name: 'Editor top bar' } ) + .getByRole( 'button', { name: 'Settings', exact: true } ) + if ( await settings.isVisible() && await settings.getAttribute( 'aria-pressed' ) === 'false' ) { + await settings.click() + } + } + + async openInspectorTab( tab: 'Layout' | 'Style' | 'Advanced' ) { + await this.openBlockSettings() + await this.page.getByLabel( `${ tab } Tab` ).click() + } + + async openDesignSystem() { + const button = this.page.getByLabel( 'Stackable Design System' ) + await button.click() + } + + async dismissToursAndNotices() { + const tourClose = this.page.locator( '.ugb-tour-modal .components-modal__header button' ).first() + if ( await tourClose.isVisible().catch( () => false ) ) { + await tourClose.click() + } + + const skip = this.page.getByRole( 'link', { name: 'Skip', exact: true } ) + if ( await skip.isVisible().catch( () => false ) ) { + await skip.click() + } + } + + async pickLayout( editor, index = 0 ) { + const variations = editor.canvas + .locator( '.stk-variation-picker .block-editor-block-variation-picker__variation' ) + try { + await variations.first().waitFor( { state: 'visible', timeout: 10_000 } ) + } catch { + return + } + await variations.nth( index ).click() + await expect( editor.canvas.locator( '.stk-variation-picker' ) ).toBeHidden() + } + + async pickDefaultLayout( editor ) { + return this.pickLayout( editor, 0 ) + } + + async selectBlockByName( editor, name: string ) { + const block = editor.canvas.locator( `[data-type="${ name }"]` ).first() + await expect( block ).toBeVisible() + await this.selectBlockByClientId( await block.getAttribute( 'data-block' ) ) + } + + async selectBlockByClientId( clientId: string ) { + await this.page.evaluate( id => { + window.wp.data.dispatch( 'core/block-editor' ).selectBlock( id ) + }, clientId ) + await expect.poll( async () => { + return this.page.evaluate( () => + window.wp.data.select( 'core/block-editor' ).getSelectedBlockClientId() + ) + } ).toBe( clientId ) + } } diff --git a/e2e/test-utils/test.ts b/e2e/test-utils/test.ts index 0c1b9c0cb2..271a6bdc9e 100644 --- a/e2e/test-utils/test.ts +++ b/e2e/test-utils/test.ts @@ -28,9 +28,10 @@ const test = base.extend<{ // We want to make all REST API calls as authenticated users. requestUtils = await ExtendedRequestUtils.setup( { baseURL: process.env.WP_BASE_URL, + storageStatePath: process.env.STORAGE_STATE_PATH, user: { - username: process.env.WP_USERNAME, - password: process.env.WP_PASSWORD, + username: process.env.WP_USERNAME || 'admin', + password: process.env.WP_PASSWORD || 'password', }, } ) diff --git a/e2e/tests/admin.spec.ts b/e2e/tests/admin.spec.ts index 293c6dcc89..b8e4d0af94 100644 --- a/e2e/tests/admin.spec.ts +++ b/e2e/tests/admin.spec.ts @@ -74,3 +74,29 @@ test( 'Stackable settings should be saved', async ( { await saveSettings await expect( _option ).toHaveAttribute( 'aria-checked', val ) } ) + +test( 'Settings inner tabs render', async ( { + page, + admin, + stackable, +} ) => { + const settings = stackable.waitForSettings() + await admin.visitAdminPage( 'admin.php?page=stackable-settings' ) + await settings + + const pageError = await admin.getPageError() + expect( pageError ).toBeNull() + + await page.getByRole( 'tab', { name: 'Editor Settings' } ).click() + await expect( page.getByRole( 'heading', { name: 'Block Widths' } ) ).toBeVisible() + + await page.getByRole( 'tab', { name: 'Global Settings' } ).click() + await expect( page.getByRole( 'heading', { name: 'Global Settings' } ) ).toBeVisible() + await expect( page.getByText( 'Force Typography Styles' ) ).toBeVisible() + + await page.getByRole( 'tab', { name: /Miscellaneous/ } ).click() + await expect( page.getByRole( 'heading', { name: 'Miscellaneous' } ) ).toBeVisible() + + await page.getByRole( 'tab', { name: 'Import/Export' } ).click() + await expect( page.getByRole( 'heading', { name: 'Import' } ) ).toBeVisible() +} ) diff --git a/e2e/tests/block-catalog.spec.ts b/e2e/tests/block-catalog.spec.ts new file mode 100644 index 0000000000..ac44a36c6e --- /dev/null +++ b/e2e/tests/block-catalog.spec.ts @@ -0,0 +1,51 @@ +import { test, expect } from 'e2e/test-utils' +import { + assertNoBlockRecovery, + insertStackableCatalog, + publishAndVisitFrontend, + TOP_LEVEL_BLOCKS, + getEditorPostId, +} from 'e2e/test-utils' + +test.describe( 'Block catalog', () => { + let pid = null + + test.afterEach( async ( { requestUtils } ) => { + if ( ! pid ) { + return + } + try { + await requestUtils.deletePost( pid ) + } catch { + // Best-effort cleanup. + } + } ) + + test( 'every top-level Stackable block inserts, reloads, and renders without recovery UI', async ( { + page, + admin, + editor, + requestUtils, + } ) => { + test.setTimeout( 300_000 ) + await admin.createNewPost( { title: 'Block Catalog' } ) + await insertStackableCatalog( editor, TOP_LEVEL_BLOCKS ) + await editor.saveDraft() + pid = getEditorPostId( editor.page ) + + await page.reload() + await expect( + editor.canvas.locator( '[data-type^="stackable/"]' ).first() + ).toBeVisible( { timeout: 60_000 } ) + + for ( const name of TOP_LEVEL_BLOCKS ) { + await expect( + editor.canvas.locator( `[data-type="${ name }"]` ).first() + ).toBeVisible() + } + + await assertNoBlockRecovery( editor ) + await publishAndVisitFrontend( page, editor, requestUtils, admin ) + await expect( page.locator( '.stk-block' ).first() ).toBeVisible() + } ) +} ) diff --git a/e2e/tests/design-library.spec.ts b/e2e/tests/design-library.spec.ts index 505bbaa2a0..7dcd767506 100644 --- a/e2e/tests/design-library.spec.ts +++ b/e2e/tests/design-library.spec.ts @@ -1,5 +1,6 @@ import { Page } from '@playwright/test' import { test, expect } from 'e2e/test-utils' +import { mockDesignLibraryRest } from 'e2e/test-utils/mock-design-library' const dismissTourIfPresent = async ( page: Page ) => { const tourClose = page.locator( '.ugb-tour-modal .components-modal__header button' ).first() @@ -35,7 +36,8 @@ const waitForDesignsToLoad = async ( page: Page ) => { test.describe( 'Design Library', () => { let pid = null - test.beforeEach( async ( { editor, admin } ) => { + test.beforeEach( async ( { editor, admin, page } ) => { + await mockDesignLibraryRest( page ) await admin.createNewPost( { title: 'Design Library Test' } ) await editor.saveDraft() const postQuery = new URL( editor.page.url() ).search @@ -43,7 +45,15 @@ test.describe( 'Design Library', () => { } ) test.afterEach( async ( { requestUtils } ) => { - await requestUtils.deletePost( pid ) + if ( ! pid ) { + return + } + try { + await requestUtils.deletePost( pid ) + } catch { + // Best-effort cleanup. Playground REST nonces can be unusable after + // long editor sessions; leftover drafts should not fail the suite. + } } ) test( 'opens and loads pattern designs without showing a blank library', async ( { diff --git a/e2e/tests/existing-blocks.spec.ts b/e2e/tests/existing-blocks.spec.ts index 2fb67bd07d..bf8ea04289 100644 --- a/e2e/tests/existing-blocks.spec.ts +++ b/e2e/tests/existing-blocks.spec.ts @@ -1,16 +1,51 @@ -/* eslint-disable jest/no-disabled-tests */ -import { test, expect } from 'e2e/test-utils' +import fs from 'fs' +import path from 'path' +import { + test, + expect, + assertNoBlockRecovery, + waitForBlockEditor, +} from 'e2e/test-utils' -test.skip( process.env.WP_TEST_POSTID === undefined, 'For existing test page only' ) +const POST_CONTENT = fs.readFileSync( + path.join( __dirname, '../config/post-content.txt' ), + 'utf8' +) test( 'Existing Stackable blocks should have no errors', async ( { admin, editor, + requestUtils, + stackable, } ) => { - // Start listening on console errors for block validation - const blockErrors = editor.getBlockErrors() + test.setTimeout( 180_000 ) - await admin.editPost( process.env.WP_TEST_POSTID ) + // Persist fixture HTML via REST as admin (unfiltered_html). Opening the + // editor on that saved post is the recovery path we care about, not + // inserting blocks or setContent in an already-open editor. + const post = await requestUtils.createPost( { + title: 'Existing Blocks Fixture', + status: 'publish', + content: POST_CONTENT, + } ) + expect( post.id ).toBeTruthy() - expect( blockErrors ).toHaveLength( 0 ) + try { + const stored = await requestUtils.rest( { + path: `/wp/v2/posts/${ post.id }`, + params: { context: 'edit' }, + } ) + expect( stored.content?.raw ).toContain( '