diff --git a/.env.example b/.env.example new file mode 100644 index 00000000..aee3f5b5 --- /dev/null +++ b/.env.example @@ -0,0 +1,9 @@ +# Copy to `.env` and fill in real values. `.env` is gitignored. +# +# Used by Playwright (tests/e2e/playwright.config.js) at e2e startup. + +# Cloudinary connection string consumed by the wizard e2e spec +# (tests/e2e/wizard-setup.spec.js). Use a dedicated test account — +# never production credentials. See README "End-to-end testing" for +# why this is named CLOUDINARY_E2E_URL rather than CLOUDINARY_URL. +CLOUDINARY_E2E_URL=cloudinary://API_KEY:API_SECRET@CLOUD_NAME diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f10de950..e4046c76 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -69,6 +69,8 @@ jobs: run: npm run env:start - name: Run E2E tests + env: + CLOUDINARY_E2E_URL: ${{ secrets.CLOUDINARY_E2E_URL }} run: npm run test:e2e - name: Stop wp-env diff --git a/README.md b/README.md index 7e800e94..2ff4f499 100644 --- a/README.md +++ b/README.md @@ -117,6 +117,59 @@ Files included in the release package are defined in the `gruntfile.js` under th 3. Run `npm run deploy-assets` to deploy just the WP.org plugin assets such as screenshots, icons and banners. +## End-to-end testing + +E2E tests run against a wp-env site using Playwright. + +### One-time setup + +```bash +npm install +npx playwright install --with-deps chromium +npm run env:start +``` + +### Running the tests + +```bash +npm run test:e2e +``` + +### Wizard test credentials + +`tests/e2e/wizard-setup.spec.js` exercises the live Cloudinary connection flow, so it needs a real connection string. Provide one of two ways: + +**Option 1 — `.env` file (recommended for sustained local development).** Copy `.env.example` to `.env` and fill in the value. `.env` is gitignored. Playwright loads it automatically at startup. + +```bash +cp .env.example .env +# edit .env, set CLOUDINARY_E2E_URL=cloudinary://... +npm run test:e2e +``` + +**Option 2 — shell export (good for one-off runs and CI).** + +```bash +export CLOUDINARY_E2E_URL='cloudinary://API_KEY:API_SECRET@CLOUD_NAME' +npm run test:e2e +``` + +A real shell env var takes precedence over the `.env` file. + +The variable is intentionally named `CLOUDINARY_E2E_URL` (not `CLOUDINARY_URL`) so it cannot be confused with the Cloudinary SDK convention or with anything you might define in `.wp-env.override.json` for local dev. Use a dedicated test Cloudinary account — never production credentials. + +> **Note:** Do **not** set `CLOUDINARY_URL` or `CLOUDINARY_CONNECTION_STRING` as PHP constants via `.wp-env.override.json` while running this spec. The plugin treats a constant-defined connection string as already-configured and hides the wizard's connection input, which makes the test impossible. + +CI will provide `CLOUDINARY_E2E_URL` via a GitHub Actions secret (wired separately under WPP-1195's CI subtask). + +### Debugging a failing e2e test + +```bash +npm run test:e2e:debug -- wizard-setup +``` + +This opens Playwright's UI runner where you can step through actions, inspect the DOM, and view the network panel. + ## License Released under the GPL license. diff --git a/package-lock.json b/package-lock.json index f7b9acfe..4b27fb65 100644 --- a/package-lock.json +++ b/package-lock.json @@ -45,6 +45,7 @@ "css-minimizer-webpack-plugin": "^7.0.2", "css-unicode-loader": "^1.0.3", "cssnano": "^7.1.2", + "dotenv": "^17.3.1", "eslint": "^8.57.1", "eslint-plugin-jest": "^29.0.1", "eslint-plugin-react-hooks": "^7.0.1", diff --git a/package.json b/package.json index ce782111..af1731a8 100644 --- a/package.json +++ b/package.json @@ -69,6 +69,7 @@ "tippy.js": "^6.3.1" }, "devDependencies": { + "@playwright/test": "^1.59.1", "@release-it/bumper": "^7.0.5", "@typescript-eslint/eslint-plugin": "^8.46.3", "@wordpress/api-fetch": "^7.34.0", @@ -77,11 +78,10 @@ "@wordpress/browserslist-config": "^6.34.0", "@wordpress/components": "^30.7.0", "@wordpress/data": "^10.34.0", - "@wordpress/element": "^6.34.0", "@wordpress/e2e-test-utils-playwright": "^1.44.0", + "@wordpress/element": "^6.34.0", "@wordpress/env": "^10.12.0", "@wordpress/eslint-plugin": "^22.20.0", - "@playwright/test": "^1.59.1", "@wordpress/i18n": "^6.7.0", "@wordpress/scripts": "^31.0.0", "copy-webpack-plugin": "^13.0.1", @@ -89,6 +89,7 @@ "css-minimizer-webpack-plugin": "^7.0.2", "css-unicode-loader": "^1.0.3", "cssnano": "^7.1.2", + "dotenv": "^17.3.1", "eslint": "^8.57.1", "eslint-plugin-jest": "^29.0.1", "eslint-plugin-react-hooks": "^7.0.1", diff --git a/tests/e2e/playwright.config.js b/tests/e2e/playwright.config.js index db56306e..e3cb78dd 100644 --- a/tests/e2e/playwright.config.js +++ b/tests/e2e/playwright.config.js @@ -4,6 +4,16 @@ const { defineConfig, devices } = require( '@playwright/test' ); const path = require( 'path' ); +// Load env vars from a project-root .env file so devs don't have to +// re-export CLOUDINARY_E2E_URL in every shell. The file is gitignored. +// Real shell env vars take precedence (override: false). `quiet: true` +// suppresses dotenv's promotional banner. +require( 'dotenv' ).config( { + path: path.join( process.cwd(), '.env' ), + override: false, + quiet: true, +} ); + const STORAGE_STATE_PATH = process.env.STORAGE_STATE_PATH || path.join( process.cwd(), 'artifacts/storage-states/admin.json' ); diff --git a/tests/e2e/utils/wizard.js b/tests/e2e/utils/wizard.js new file mode 100644 index 00000000..03ad1772 --- /dev/null +++ b/tests/e2e/utils/wizard.js @@ -0,0 +1,136 @@ +/** + * Helpers for the Cloudinary wizard e2e spec. + * + * State changes (deleting the connection options) bypass the WP REST + * API so they don't trigger `pre_update_option_cloudinary_connect`, + * which would make a live Cloudinary API call. Direct DB access via + * docker + wp-cli is the right tool here. + * + * We use `docker exec` rather than `npx wp-env run cli` because the + * latter routes through `got` → `api.wordpress.org` at startup and + * times out on macOS due to an IPv6 resolution issue. `docker exec` + * goes straight to the running container. + */ + +const { execSync } = require( 'child_process' ); + +const CONNECT_OPTION = 'cloudinary_connect'; +const SIGNATURE_OPTION = 'cloudinary_connection_signature'; +const STATUS_OPTION = 'cloudinary_status'; + +let cachedCliContainer = null; + +/** + * Find the wp-env CLI container name dynamically. + * + * Playwright drives the `tests-wordpress` site (port 8889) by default, + * so we target the matching `*-tests-cli-1` container. The container + * name embeds a project hash that varies between machines; we discover + * it by listing running containers and filtering for the suffix. + * + * @return {string} Container name. + * @throws If no matching container is running. + */ +function getCliContainer() { + if ( cachedCliContainer ) { + return cachedCliContainer; + } + + const out = execSync( "docker ps --format '{{.Names}}'", { + encoding: 'utf8', + } ); + const lines = out.split( '\n' ).filter( Boolean ); + + const cli = lines.find( ( name ) => /-tests-cli-1$/.test( name ) ); + + if ( ! cli ) { + throw new Error( + 'Could not find a running wp-env tests-cli container. Run `docker ps` and confirm a `*-tests-cli-1` container is up.' + ); + } + + cachedCliContainer = cli; + return cli; +} + +/** + * Run a WP-CLI command inside the wp-env cli container. + * + * @param {string[]} args wp-cli arguments after the leading `wp`. + * @return {string} stdout, trimmed. + */ +function wpCli( args ) { + const container = getCliContainer(); + const cmd = [ + 'docker', + 'exec', + container, + 'wp', + ...args, + '--allow-root', + ].join( ' ' ); + + return execSync( cmd, { + encoding: 'utf8', + stdio: [ 'ignore', 'pipe', 'pipe' ], + } ).trim(); +} + +/** + * Wipe any existing Cloudinary connection so the wizard reappears. + * + * Each option may or may not exist. We attempt all three and + * silently swallow "Could not get/delete option" errors. + */ +function resetCloudinaryConnection() { + for ( const opt of [ CONNECT_OPTION, SIGNATURE_OPTION, STATUS_OPTION ] ) { + try { + wpCli( [ 'option', 'delete', opt ] ); + } catch ( e ) { + // Option not present; that's fine. + } + } +} + +/** + * Read the Cloudinary connection string from the environment. + * + * We use a dedicated `CLOUDINARY_E2E_URL` env var rather than the + * Cloudinary SDK's conventional `CLOUDINARY_URL` to make it explicit + * that this is test-only credentials and to avoid colliding with any + * SDK auto-bootstrap behaviour developers may rely on locally. + * + * Throws if not set so the test fails loudly rather than silently + * producing a meaningless pass/fail. + * + * @return {string} The cloudinary:// URL. + */ +function getCloudinaryUrlFromEnv() { + const url = process.env.CLOUDINARY_E2E_URL; + if ( ! url || ! url.startsWith( 'cloudinary://' ) ) { + throw new Error( + 'CLOUDINARY_E2E_URL env var must be set to a valid cloudinary:// connection string before running the wizard e2e spec.' + ); + } + return url; +} + +/** + * Navigate the admin browser to the wizard screen. + * + * We hit the wizard URL directly so the test does not depend on the + * "not connected → wizard" redirect. + * + * @param {Object} admin Admin fixture from @wordpress/e2e-test-utils-playwright. + */ +async function visitWizard( admin ) { + await admin.visitAdminPage( 'admin.php', 'page=cloudinary§ion=wizard' ); +} + +module.exports = { + getCliContainer, + getCloudinaryUrlFromEnv, + resetCloudinaryConnection, + visitWizard, + wpCli, +}; diff --git a/tests/e2e/wizard-setup.spec.js b/tests/e2e/wizard-setup.spec.js new file mode 100644 index 00000000..47a96f65 --- /dev/null +++ b/tests/e2e/wizard-setup.spec.js @@ -0,0 +1,152 @@ +/** + * External dependencies + */ +const { test, expect } = require( '@wordpress/e2e-test-utils-playwright' ); + +/** + * Internal dependencies + */ +const { + getCloudinaryUrlFromEnv, + resetCloudinaryConnection, + visitWizard, +} = require( './utils/wizard' ); + +// Selectors that come from ui-definitions/components/wizard.php. +// Centralising them here makes the spec easier to maintain when the +// wizard markup changes. +const SEL = { + connectionInput: 'input#connect\\.cloudinary_url', + connectionSuccess: '#connection-success', + connectionError: '#connection-error', + connectionWorking: '#connection-working', + tab1: '#tab-1', + tab2: '#tab-2', + tab3: '#tab-3', + tab4: '#tab-4', + nextBtn: 'button[data-navigate="next"]', + completeLink: '#complete-wizard', + wizardWrap: '.cld-wizard', +}; + +test.describe( 'Cloudinary wizard setup (WPP-1201)', () => { + test.beforeEach( async ( { context } ) => { + // Clear server-side state via WP-CLI. + resetCloudinaryConnection(); + // Clear browser localStorage; the wizard persists its + // progress under `_cld_wizard` and reads it back on init, + // which would override our fresh server state. + await context.addInitScript( () => { + window.localStorage.removeItem( '_cld_wizard' ); + } ); + } ); + + test( 'rejects an invalid connection string', async ( { admin, page } ) => { + await visitWizard( admin ); + + // Tab 1 is the welcome screen. Click Next to reach the connect tab. + await expect( page.locator( SEL.tab1 ) ).toBeVisible(); + await page.locator( SEL.nextBtn ).click(); + await expect( page.locator( SEL.tab2 ) ).toBeVisible(); + + // Type a clearly-malformed connection string. The wizard + // debounces input and then calls /cloudinary/v1/test_connection. + await page + .locator( SEL.connectionInput ) + .fill( 'cloudinary://wrong:credential@invalidcloud' ); + + // Error indicator gains the `active` class. The live API call + // can take a few seconds; allow up to 15s. + await expect( page.locator( SEL.connectionError ) ).toHaveClass( + /\bactive\b/, + { timeout: 15_000 } + ); + await expect( page.locator( SEL.connectionSuccess ) ).not.toHaveClass( + /\bactive\b/ + ); + + // Next must remain disabled (native disabled attribute). + await expect( page.locator( SEL.nextBtn ) ).toBeDisabled(); + } ); + + test( 'accepts a valid connection string and completes the wizard', async ( { + admin, + page, + } ) => { + const cloudinaryUrl = getCloudinaryUrlFromEnv(); + + await visitWizard( admin ); + + // Tab 1 → Tab 2. + await page.locator( SEL.nextBtn ).click(); + await expect( page.locator( SEL.tab2 ) ).toBeVisible(); + + // Provide real credentials. + await page.locator( SEL.connectionInput ).fill( cloudinaryUrl ); + + // Wait for the success indicator. Live API call + debounce + // can take ~3–10s. + await expect( page.locator( SEL.connectionSuccess ) ).toHaveClass( + /\bactive\b/, + { timeout: 30_000 } + ); + await expect( page.locator( SEL.connectionError ) ).not.toHaveClass( + /\bactive\b/ + ); + await expect( page.locator( SEL.nextBtn ) ).toBeEnabled(); + + // Tab 2 → Tab 3. + await page.locator( SEL.nextBtn ).click(); + await expect( page.locator( SEL.tab3 ) ).toBeVisible(); + + // Tab 3 → Tab 4. This click triggers the /save_wizard REST + // call. While in flight, the Next button text changes to + // "Setting up Cloudinary" and is briefly disabled. We don't + // assert on the transient state; we just wait for tab 4. + await page.locator( SEL.nextBtn ).click(); + await expect( page.locator( SEL.tab4 ) ).toBeVisible( { + timeout: 30_000, + } ); + + // Final affordance: the "Go to plugin dashboard" link. + await expect( page.locator( SEL.completeLink ) ).toBeVisible(); + await expect( page.locator( SEL.completeLink ) ).toHaveAttribute( + 'href', + /page=cloudinary/ + ); + } ); + + test( 'persists connection state so the wizard does not reappear', async ( { + admin, + page, + } ) => { + const cloudinaryUrl = getCloudinaryUrlFromEnv(); + + // Walk the wizard to completion (same flow as the previous + // test, intentionally duplicated so each test stands alone + // and can be debugged in isolation). + await visitWizard( admin ); + await page.locator( SEL.nextBtn ).click(); + await page.locator( SEL.connectionInput ).fill( cloudinaryUrl ); + await expect( page.locator( SEL.connectionSuccess ) ).toHaveClass( + /\bactive\b/, + { timeout: 30_000 } + ); + await page.locator( SEL.nextBtn ).click(); // → tab 3 + await expect( page.locator( SEL.tab3 ) ).toBeVisible(); + await page.locator( SEL.nextBtn ).click(); // → tab 4 + await expect( page.locator( SEL.tab4 ) ).toBeVisible( { + timeout: 30_000, + } ); + + // Visit the plugin's main entry point. When connected, the + // plugin renders the dashboard (NOT the wizard chrome). + await admin.visitAdminPage( 'admin.php', 'page=cloudinary' ); + + // Section param should not have flipped to wizard. + await expect( page ).not.toHaveURL( /section=wizard/ ); + + // Wizard wrapper element should be absent on the dashboard. + await expect( page.locator( SEL.wizardWrap ) ).toHaveCount( 0 ); + } ); +} );