diff --git a/.gitignore b/.gitignore index 9e8c0ae..6ede1b8 100644 --- a/.gitignore +++ b/.gitignore @@ -34,6 +34,7 @@ Thumbs.db wp-auth.json e2e/.auth/ playwright-report/ +playwright-report-premium/ test-results/ blob-report/ .playwright-browsers/ diff --git a/AGENTS.md b/AGENTS.md index ef7b116..0add2ca 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -51,8 +51,11 @@ Roadmap / version targeting: [org project #12](https://github.com/orgs/gambitph/ ### Testing Playwright for UI / e2e (`e2e/`, `@wp-playground/cli` — no Docker). -Run with `npm run test:e2e` (or `npm run test:debug` for the UI) after `npm run build:e2e`. +Run free with `npm run test:e2e` after `npm run build:e2e`. +With `pro__premium_only/` mounted, run premium with `npm run test:e2e:premium` after `npm run build:e2e:premium`. See [`e2e/readme.md`](./e2e/readme.md). +Free-repo CI runs the free suite only. +Premium-repo CI runs free then premium. There is no PHPUnit suite today - prefer Playwright for regressions that matter to upload and settings flows. ## Free / premium diff --git a/e2e/fixtures/sample-large.jpg b/e2e/fixtures/sample-large.jpg new file mode 100644 index 0000000..5dba782 Binary files /dev/null and b/e2e/fixtures/sample-large.jpg differ diff --git a/e2e/fixtures/sample.png b/e2e/fixtures/sample.png new file mode 100644 index 0000000..98d89b2 Binary files /dev/null and b/e2e/fixtures/sample.png differ diff --git a/e2e/playground-blueprint.premium.json b/e2e/playground-blueprint.premium.json new file mode 100644 index 0000000..c11b134 --- /dev/null +++ b/e2e/playground-blueprint.premium.json @@ -0,0 +1,19 @@ +{ + "$schema": "https://playground.wordpress.net/blueprint-schema.json", + "steps": [ + { + "step": "login", + "username": "admin", + "password": "password" + }, + { + "step": "activatePlugin", + "pluginName": "Cimo - Image Optimizer", + "pluginPath": "/wordpress/wp-content/plugins/cimo/cimo.php" + }, + { + "step": "runPHP", + "code": " 'activated',\n\t'install_id' => 'e2e-install-id',\n\t'activation_params' => [\n\t\t'uid' => $uid,\n\t\t'license_key' => 'e2e-license-key',\n\t],\n\t'install_data' => [\n\t\t'license_plan_name' => 'premium',\n\t\t'install_public_key' => 'e2e-public',\n\t\t'install_secret_key' => 'e2e-secret',\n\t],\n], false );\n" + } + ] +} diff --git a/e2e/readme.md b/e2e/readme.md index f33492e..20a6019 100644 --- a/e2e/readme.md +++ b/e2e/readme.md @@ -1,7 +1,8 @@ # E2E Testing -Cimo's end-to-end tests verify that client-side upload interception actually -converts JPEG uploads to WebP across the main WordPress admin surfaces. +Cimo's end-to-end tests verify client-side upload interception, settings, +post-upload stats, freemium chrome, and (when premium is mounted) bulk +optimization across WordPress admin. WordPress is provided by [`@wp-playground/cli`](https://www.npmjs.com/package/@wp-playground/cli) (WASM PHP + SQLite, **no Docker**). Playwright's `webServer` boots it @@ -11,15 +12,22 @@ automatically before the suite runs. Just Node **20+** — no Docker, Composer, or separate WordPress install. +For **premium** specs you also need `pro__premium_only/` checked out under +the free plugin root (clone `bfintal/cimo-premium` into that directory). +Playground mounts the plugin tree as a real directory, so a symlink that +points outside the mount will not expose premium files inside WASM PHP. + ## Usage +### Free suite + Build the plugin assets first (Playground mounts this repo; enqueue needs `build/`): ```bash npm run build:e2e ``` -Run e2e tests (Playground starts on port `9410` if nothing is already listening): +Run free e2e tests (Playground starts on port `9410` if nothing is already listening): ```bash npm run test:e2e @@ -31,22 +39,50 @@ or with the Playwright UI: npm run test:debug ``` -Locally, `playwright.config.js` reuses an already-running Playground on port -`9410` when present (fast repeat runs). In CI it always boots fresh. If a stale +### Premium suite + +```bash +# Mount premium (once), e.g.: +# ln -sfn /path/to/cimo-premium pro__premium_only + +npm run build:e2e:premium +npm run test:e2e:premium +``` + +Premium uses port `9411`, `playwright.premium.config.js`, and +`e2e/playground-blueprint.premium.json` (seeds a mock Freemius `premium` plan). + +After premium builds, restore the free build type if you need free packaging: + +```bash +node scripts/update-build-type.js free +``` + +Locally, Playwright reuses an already-running Playground on the suite's port +when present (fast repeat runs). In CI it always boots fresh. If a stale instance is misbehaving after editing PHP/JS that Playground mounted at boot, -kill whatever is listening on `9410` and re-run. +kill whatever is listening on that port and re-run. -Optional overrides (defaults are set in `playwright.config.js`): +Optional overrides (defaults differ per config): ``` -WP_PORT=9410 +WP_PORT=9410 # free; premium config defaults to 9411 WP_BASE_URL=http://127.0.0.1:9410 WP_USERNAME=admin WP_PASSWORD=password ``` +## CI + +| Repo | Workflow | Suites | +|------|----------|--------| +| Free (`gambitph/Cimo`) | `.github/workflows/e2e-tests.yml` | Free only (`build:e2e` → `test:e2e`) | +| Premium (`bfintal/cimo-premium`) | `pro__premium_only/.github/workflows/e2e-tests.yml` | Free then premium (checks out free as root + premium as `pro__premium_only/`) | + ## What is covered +### Free + | Surface | Flow | |---------|------| | Image block | Upload button → JPG → WebP | @@ -54,16 +90,33 @@ WP_PASSWORD=password | Block editor | Drop JPG on canvas → WebP image block | | Page sidebar | Drop JPG on Featured Image → WebP | | Media → Add New | File picker → JPG → WebP in library | +| Settings | Load/save, quality + max dimension affect upload | +| Media Library grid | Drop → WebP | +| Media modal | Select Files → WebP | +| PNG / multi-file | Convert to WebP | +| Progress modal | Cancel when visible | +| Post-upload | Sidebar stats + attachment meta box | +| Freemium | Disabled premium controls, bulk upsell, plugins links | + +### Premium (`e2e/tests/premium/`) + +| Surface | Flow | +|---------|------| +| Settings | Gated controls enabled; working bulk UI (not upsell) | +| Bulk optimizer | Progress, complete 4 images under timeout, stop mid-run | ## Files | Path | Role | | --- | --- | -| `../playwright.config.js` | Boots `@wp-playground/cli` as Playwright's `webServer`; auth/`baseURL`/storage state | -| `playground-blueprint.json` | Logs in as `admin`, activates Cimo | +| `../playwright.config.js` | Free suite; Playground on 9410; ignores `tests/premium/` | +| `../playwright.premium.config.js` | Premium suite; Playground on 9411 | +| `playground-blueprint.json` | Login + activate Cimo (free) | +| `playground-blueprint.premium.json` | Activate + seed Freemius premium plan | | `config/global-setup.js` | Cookie-authenticates via `RequestUtils.setup()`, persists `storageState` | -| `test-utils/` | Shared fixtures + upload/drop helpers | -| `tests/*.spec.ts` | Browser-driven upload interception specs | +| `test-utils/` | Shared fixtures + upload/settings/drop helpers | +| `tests/*.spec.ts` | Free browser specs | +| `tests/premium/*.spec.ts` | Premium browser specs | | `.auth/` | Gitignored; written by global setup | ## Troubleshooting @@ -71,9 +124,11 @@ WP_PASSWORD=password - **`browserType.launch: Executable doesn't exist`** — run `npx playwright install chromium` once per machine. - **Stale plugin behaviour after editing PHP** — Playground snapshots the - mount at boot. Stop the process on port `9410` and re-run. + mount at boot. Stop the process on the suite port and re-run. - **Composer/settings never appear / `cimoSettings` missing** — run - `npm run build:e2e` so `build/admin/` exists for enqueue. + `npm run build:e2e` (or `build:e2e:premium`) so `build/admin/` exists for enqueue. +- **Premium suite missing Bulk Optimizer** — ensure `pro__premium_only/` is present, + `CIMO_BUILD` is `premium`, and the Freemius seed in the premium blueprint applied. ## Dev Notes diff --git a/e2e/test-utils/index.ts b/e2e/test-utils/index.ts index 8af27bb..183e892 100644 --- a/e2e/test-utils/index.ts +++ b/e2e/test-utils/index.ts @@ -1,12 +1,28 @@ export { test, expect } from './test' export { SAMPLE_JPG, + SAMPLE_PNG, + SAMPLE_LARGE_JPG, dropFile, + dropFiles, waitForCimoReady, waitForCimoEditorIframeReady, dismissEditorOverlays, getMaxMediaId, getMediaCreatedAfter, + getMediaById, deletePage, expectNewMediaIsWebp, + expectNewMediaCount, + uploadSampleViaMediaNew, + expectCimoSidebarStats, + expectCimoMetaBox, + openAttachmentInLibraryModal, } from './media' +export { + saveCimoOptions, + gotoCimoSettings, + saveSettingsUi, + getCimoSettings, + reloadCimoRuntime, +} from './settings' diff --git a/e2e/test-utils/media.ts b/e2e/test-utils/media.ts index 96035a5..8e6d5cb 100644 --- a/e2e/test-utils/media.ts +++ b/e2e/test-utils/media.ts @@ -8,33 +8,46 @@ import type { RequestUtils } from '@wordpress/e2e-test-utils-playwright' /** JPEG fixture used by upload interception tests. */ export const SAMPLE_JPG = path.resolve( __dirname, '../fixtures/sample.jpg' ) +/** Small PNG fixture for format conversion tests. */ +export const SAMPLE_PNG = path.resolve( __dirname, '../fixtures/sample.png' ) + +/** Large JPEG (2000×1500) for max-dimension / progress tests. */ +export const SAMPLE_LARGE_JPG = path.resolve( + __dirname, + '../fixtures/sample-large.jpg' +) + +type DropFileSpec = { + path: string; + mimeType?: string; +} + /** - * Dispatch dragenter → dragover → drop with a real File payload. + * Dispatch dragenter → dragover → drop with one or more real File payloads. * Needed for Cimo's capture-phase drop interceptor (and Gutenberg DropZone). */ -export async function dropFile( +export async function dropFiles( target: Locator, - filePath: string = SAMPLE_JPG, - mimeType: string = 'image/jpeg' + files: DropFileSpec[] ) { - const buffer = fs.readFileSync( filePath ) - const fileName = path.basename( filePath ) - // A plain array of byte values (rather than a base64 string, which also - // requires `fetch()`/`atob()` that can run afoul of the block editor - // iframe's CSP) serializes reliably as the evaluateHandle arg. - const bytes = Array.from( buffer ) - - // Note: Locator#evaluateHandle calls pageFunction as (element, arg), not - // just (arg) — the element itself is unused here, but the parameter is - // required so `arg` correctly receives our payload. + const payloads = files.map( ( file ) => { + const buffer = fs.readFileSync( file.path ) + return { + data: Array.from( buffer ), + name: path.basename( file.path ), + type: file.mimeType || 'application/octet-stream', + } + } ) + const dataTransfer = await target.evaluateHandle( - ( element, { data, name, type } ) => { - const file = new File( [ new Uint8Array( data ) ], name, { type } ) + ( element, filePayloads ) => { const dt = new DataTransfer() - dt.items.add( file ) + for ( const { data, name, type } of filePayloads ) { + dt.items.add( new File( [ new Uint8Array( data ) ], name, { type } ) ) + } return dt }, - { data: bytes, name: fileName, type: mimeType } + payloads ) await target.dispatchEvent( 'dragenter', { dataTransfer } ) @@ -42,6 +55,18 @@ export async function dropFile( await target.dispatchEvent( 'drop', { dataTransfer } ) } +/** + * Dispatch dragenter → dragover → drop with a real File payload. + * Needed for Cimo's capture-phase drop interceptor (and Gutenberg DropZone). + */ +export async function dropFile( + target: Locator, + filePath: string = SAMPLE_JPG, + mimeType: string = 'image/jpeg' +) { + await dropFiles( target, [ { path: filePath, mimeType } ] ) +} + /** * Wait until Cimo's admin script has localized settings (interceptors are ready). */ @@ -91,10 +116,16 @@ export async function waitForCimoEditorIframeReady( page: Page ) { }, undefined, { timeout: 30_000 } ) } -type MediaItem = { +export type MediaItem = { id: number; mime_type: string; source_url: string; + media_details?: { + width?: number; + height?: number; + filesize?: number; + cimo?: Record; + }; } /** @@ -109,6 +140,7 @@ export async function listMediaNewestFirst( per_page: 100, orderby: 'id', order: 'desc', + context: 'edit', }, } ) as MediaItem[] } @@ -182,3 +214,108 @@ export async function expectNewMediaIsWebp( expect( newest.source_url ).toMatch( /\.webp(\?|$)/i ) return newest } + +/** + * Assert that N new WebP media items were uploaded after `afterId`. + */ +export async function expectNewMediaCount( + requestUtils: RequestUtils, + afterId: number, + count: number, + options: { timeout?: number; mime?: string } = {} +) { + const timeout = options.timeout ?? 90_000 + const mime = options.mime ?? 'image/webp' + + await expect.poll( + async () => { + const created = await getMediaCreatedAfter( requestUtils, afterId ) + return created.filter( ( item ) => item.mime_type === mime ).length + }, + { + timeout, + message: `Expected ${ count } new ${ mime } attachments after upload`, + } + ).toBe( count ) + + return ( await getMediaCreatedAfter( requestUtils, afterId ) ) + .filter( ( item ) => item.mime_type === mime ) +} + +/** + * Upload via Media → Add New file picker and wait for WebP conversion. + */ +export async function uploadSampleViaMediaNew( + page: Page, + requestUtils: RequestUtils, + filePath: string = SAMPLE_JPG, + mimeType: string = 'image/jpeg' +) { + await page.goto( '/wp-admin/media-new.php' ) + await waitForCimoReady( page ) + const afterId = await getMaxMediaId( requestUtils ) + + const fileInput = page.locator( + '.media-upload-form input[type="file"], #async-upload, input[name="async-upload"]' + ).first() + await expect( fileInput ).toBeAttached( { timeout: 15_000 } ) + await fileInput.setInputFiles( filePath ) + + if ( mimeType.startsWith( 'image/' ) && mimeType !== 'image/webp' ) { + return await expectNewMediaIsWebp( requestUtils, afterId ) + } + + await expect.poll( async () => { + return ( await getMediaCreatedAfter( requestUtils, afterId ) ).length + }, { timeout: 60_000 } ).toBeGreaterThan( 0 ) + + return ( await getMediaCreatedAfter( requestUtils, afterId ) )[ 0 ] +} + +/** + * Fetch a single media item (edit context includes media_details). + */ +export async function getMediaById( + requestUtils: RequestUtils, + id: number +): Promise { + return await requestUtils.rest( { + path: `/wp/v2/media/${ id }`, + params: { context: 'edit' }, + } ) as MediaItem +} + +/** + * Assert Cimo sidebar stats are visible in the open media modal. + */ +export async function expectCimoSidebarStats( page: Page ) { + const root = page.locator( '.media-modal .cimo-media-manager-metadata' ).first() + await expect( root ).toBeVisible( { timeout: 30_000 } ) + await expect( root.locator( '.cimo-converted' ) ).toContainText( /WebP/i ) + await expect( root.locator( '.cimo-compression-savings' ) ).toBeVisible() + return root +} + +/** + * Assert the attachment edit screen meta box shows Cimo optimization data. + */ +export async function expectCimoMetaBox( page: Page ) { + const box = page.locator( '#cimo-data-meta-box' ) + await expect( box ).toBeVisible( { timeout: 15_000 } ) + await expect( box ).not.toContainText( /Cimo did not optimize this attachment/i ) + await expect( box.locator( '.cimo-converted, .cimo-compression-savings' ).first() ).toBeVisible( { + timeout: 15_000, + } ) + return box +} + +/** + * Open an attachment in the Media Library grid modal. + */ +export async function openAttachmentInLibraryModal( page: Page, mediaId: number ) { + await page.goto( '/wp-admin/upload.php' ) + const attachment = page.locator( `.attachment[data-id="${ mediaId }"]` ) + await expect( attachment ).toBeVisible( { timeout: 30_000 } ) + await attachment.click() + await expect( page.locator( '.media-modal' ) ).toBeVisible( { timeout: 15_000 } ) +} diff --git a/e2e/test-utils/settings.ts b/e2e/test-utils/settings.ts new file mode 100644 index 0000000..44f5c19 --- /dev/null +++ b/e2e/test-utils/settings.ts @@ -0,0 +1,83 @@ +import type { Page } from '@playwright/test' +import { expect } from '@playwright/test' +import type { Admin, RequestUtils } from '@wordpress/e2e-test-utils-playwright' + +import { waitForCimoReady } from './media' + +export type CimoOptions = { + webp_quality?: number; + max_image_dimension?: number; + disable_wp_scaling?: number; + smart_optimization?: number; + optimize_all_media?: number; + [ key: string ]: unknown; +} + +/** + * Merge partial options into stored `cimo_options` via REST. + */ +export async function saveCimoOptions( + requestUtils: RequestUtils, + partial: CimoOptions +) { + const settings = await requestUtils.rest( { + path: '/wp/v2/settings', + } ) as { cimo_options?: CimoOptions } + + const current = settings.cimo_options || {} + await requestUtils.rest( { + method: 'POST', + path: '/wp/v2/settings', + data: { + cimo_options: { + ...current, + ...partial, + }, + }, + } ) +} + +/** + * Open Settings → Cimo and wait for the React settings app. + */ +export async function gotoCimoSettings( admin: Admin, page: Page ) { + await admin.visitAdminPage( 'options-general.php', 'page=cimo-settings' ) + await expect( page.locator( '#cimo-admin-settings' ) ).toBeVisible( { + timeout: 30_000, + } ) + await expect( page.locator( '.cimo-admin-settings-wrap' ) ).toBeVisible( { + timeout: 30_000, + } ) +} + +/** + * Click Save Changes and wait for the success notice. + */ +export async function saveSettingsUi( page: Page ) { + const saveButton = page.locator( '.cimo-save-button' ) + await expect( saveButton ).toBeVisible() + await saveButton.click() + await expect( page.getByText( 'Settings saved successfully!' ) ).toBeVisible( { + timeout: 15_000, + } ) +} + +/** + * Read localized `window.cimoSettings` after the upload runtime has loaded. + */ +export async function getCimoSettings( page: Page ) { + await waitForCimoReady( page ) + return await page.evaluate( () => { + return ( window as Window & { + cimoSettings?: Record; + } ).cimoSettings || {} + } ) +} + +/** + * Open an admin surface that enqueues `cimo-script` so localized settings refresh. + */ +export async function reloadCimoRuntime( page: Page ) { + await page.goto( '/wp-admin/media-new.php' ) + await waitForCimoReady( page ) +} diff --git a/e2e/tests/freemium.spec.ts b/e2e/tests/freemium.spec.ts new file mode 100644 index 0000000..96c03cd --- /dev/null +++ b/e2e/tests/freemium.spec.ts @@ -0,0 +1,80 @@ +import { + test, + expect, + gotoCimoSettings, +} from '../test-utils' + +test.describe.configure( { timeout: 90_000 } ) + +test.describe( 'Freemium admin chrome', () => { + test( 'premium settings controls stay disabled with Premium labels', async ( { + admin, + page, + } ) => { + await gotoCimoSettings( admin, page ) + + const optimizeAll = page.getByRole( 'checkbox', { + name: /Optimize All Media Uploads/i, + } ) + await expect( optimizeAll ).toBeDisabled() + + const smartOpt = page.getByRole( 'checkbox', { + name: /Smart Optimization/i, + } ) + await expect( smartOpt ).toBeDisabled() + + await expect( page.locator( '.cimo-premium-tag' ).first() ).toBeVisible() + await expect( page.locator( '#bulk-optimization' ) ).toBeVisible() + await expect( page.locator( '.cimo-bulk-optimizer-upsell' ) ).toBeVisible() + await expect( + page.getByRole( 'link', { name: /Bulk Optimize with Premium/i } ) + ).toBeVisible() + await expect( + page.locator( '.cimo-bulk-optimize-button-view-images' ) + ).toBeDisabled() + + await expect( page.locator( '.cimo-premium-feature-label' ).first() ).toBeVisible() + } ) + + test( 'plugins screen exposes Settings and Upgrade links', async ( { + admin, + page, + } ) => { + await admin.visitAdminPage( 'plugins.php' ) + const row = page.locator( 'tr[data-slug="cimo-image-optimizer"], tr[data-plugin*="cimo.php"]' ).first() + await expect( row ).toBeVisible( { timeout: 30_000 } ) + await expect( row.getByRole( 'link', { name: 'Settings', exact: true } ) ).toBeVisible() + await expect( row.getByRole( 'link', { name: 'Upgrade', exact: true } ) ).toBeVisible() + } ) + + test( 'activation notice can be dismissed when present', async ( { + admin, + page, + requestUtils, + } ) => { + // Re-seed the activation transient via a no-op REST round-trip is not available; + // Playground activates Cimo at boot, so the notice may still be present. + await admin.visitAdminPage( 'index.php' ) + + const notice = page.locator( '.cimo-activation-notice' ) + const visible = await notice.isVisible().catch( () => false ) + if ( ! visible ) { + // Force show by re-setting the transient through a temporary plugin option + // is not exposed; soft-pass when already dismissed in this Playground instance. + test.info().annotations.push( { + type: 'note', + description: 'Activation notice not visible (already dismissed or transient expired)', + } ) + return + } + + await notice.locator( '.cimo-activation-dismiss' ).click() + await expect( notice ).toBeHidden( { timeout: 10_000 } ) + + await admin.visitAdminPage( 'index.php' ) + await expect( page.locator( '.cimo-activation-notice' ) ).toHaveCount( 0 ) + + // Keep requestUtils referenced so the fixture stays available if we extend seeding later. + expect( requestUtils ).toBeTruthy() + } ) +} ) diff --git a/e2e/tests/post-upload-stats.spec.ts b/e2e/tests/post-upload-stats.spec.ts new file mode 100644 index 0000000..ee54ff4 --- /dev/null +++ b/e2e/tests/post-upload-stats.spec.ts @@ -0,0 +1,44 @@ +import { + test, + expect, + uploadSampleViaMediaNew, + openAttachmentInLibraryModal, + expectCimoSidebarStats, + expectCimoMetaBox, +} from '../test-utils' + +test.describe.configure( { timeout: 120_000 } ) + +test.describe( 'Post-upload Cimo stats', () => { + test.beforeEach( async ( { requestUtils } ) => { + await requestUtils.deleteAllMedia() + } ) + + test.afterEach( async ( { requestUtils } ) => { + await requestUtils.deleteAllMedia() + } ) + + test( 'media modal sidebar shows optimization stats', async ( { + page, + requestUtils, + } ) => { + const media = await uploadSampleViaMediaNew( page, requestUtils ) + + await openAttachmentInLibraryModal( page, media.id ) + await expectCimoSidebarStats( page ) + } ) + + test( 'attachment edit meta box shows optimization stats after reload', async ( { + page, + requestUtils, + } ) => { + const media = await uploadSampleViaMediaNew( page, requestUtils ) + + // Reload attachment edit so PHP meta box reads persisted `_wp_attachment_metadata.cimo`. + await page.goto( `/wp-admin/post.php?post=${ media.id }&action=edit` ) + await expectCimoMetaBox( page ) + + await page.reload() + await expectCimoMetaBox( page ) + } ) +} ) diff --git a/e2e/tests/premium/bulk-optimizer.spec.ts b/e2e/tests/premium/bulk-optimizer.spec.ts new file mode 100644 index 0000000..71bbec0 --- /dev/null +++ b/e2e/tests/premium/bulk-optimizer.spec.ts @@ -0,0 +1,139 @@ +import { + test, + expect, + gotoCimoSettings, + saveCimoOptions, + SAMPLE_JPG, + SAMPLE_LARGE_JPG, +} from '../../test-utils' + +test.describe.configure( { timeout: 180_000 } ) + +async function seedUnoptimizedImages( requestUtils, count = 4 ) { + const files = [ SAMPLE_JPG, SAMPLE_LARGE_JPG, SAMPLE_JPG, SAMPLE_LARGE_JPG ] + const uploaded = [] + for ( let i = 0; i < count; i++ ) { + uploaded.push( await requestUtils.uploadMedia( files[ i % files.length ] ) ) + } + return uploaded +} + +async function waitForBulkIdle( page ) { + const button = page.locator( '.cimo-bulk-optimize-button' ) + await expect( button ).not.toHaveClass( /is-optimizing/, { timeout: 120_000 } ) + await expect( button ).not.toContainText( /Stop Optimization/i, { + timeout: 30_000, + } ) +} + +async function readUnoptimizedCount( page ) { + const bulkButton = page.locator( '.cimo-bulk-optimize-button' ) + await expect( bulkButton ).toContainText( /Bulk Optimize \(\d+\)/, { + timeout: 30_000, + } ) + const label = await bulkButton.innerText() + return Number( ( label.match( /\((\d+)\)/ ) || [] )[ 1 ] || 0 ) +} + +test.describe( 'Premium bulk optimizer', () => { + test.beforeEach( async ( { requestUtils } ) => { + await saveCimoOptions( requestUtils, { + disable_thumbnail_generation: 1, + disable_wp_scaling: 1, + } ) + await requestUtils.deleteAllMedia() + } ) + + test.afterEach( async ( { requestUtils } ) => { + await requestUtils.deleteAllMedia() + } ) + + test( 'bulk optimize completes with progress updates', async ( { + admin, + page, + requestUtils, + } ) => { + const seeded = await seedUnoptimizedImages( requestUtils, 4 ) + expect( seeded ).toHaveLength( 4 ) + + await gotoCimoSettings( admin, page ) + + const progressText = page.locator( '.cimo-bulk-optimizer-progress-bar-text' ) + await expect( progressText ).toBeVisible( { timeout: 30_000 } ) + + const total = await readUnoptimizedCount( page ) + expect( total ).toBeGreaterThanOrEqual( 4 ) + + const bulkButton = page.locator( '.cimo-bulk-optimize-button' ) + await page.locator( '.cimo-bulk-optimize-button-view-images' ).click() + await expect( page.locator( '.cimo-bulk-optimizer-image-list' ) ).toBeVisible() + await expect( page.getByText( new RegExp( `Unoptimized \\(${ total }\\)` ) ) ).toBeVisible() + + const startedAt = Date.now() + await bulkButton.click() + + await expect( bulkButton ).toHaveClass( /is-optimizing/ ) + await expect( bulkButton ).toContainText( /Stop Optimization/i ) + await expect( + page.locator( '.cimo-bulk-optimizer-optimization-in-progress' ) + ).toBeVisible() + + await expect.poll( + async () => { + const text = await progressText.innerText() + const match = text.match( /(\d+)\s+of\s+(\d+)\s+optimized/i ) + if ( ! match ) { + return 0 + } + return Number( match[ 1 ] ) + }, + { + timeout: 90_000, + message: 'Expected bulk progress to advance', + } + ).toBeGreaterThan( 0 ) + + await waitForBulkIdle( page ) + + const elapsedMs = Date.now() - startedAt + expect( elapsedMs ).toBeLessThan( 120_000 ) + + await expect( progressText ).toContainText( + new RegExp( `${ total } of ${ total } optimized|100%`, 'i' ) + ) + await expect( bulkButton ).toBeDisabled() + await expect( page.getByText( new RegExp( `Optimized \\(${ total }\\)` ) ) ).toBeVisible() + await expect( page.getByText( /Unoptimized \(0\)/ ) ).toBeVisible() + } ) + + test( 'stop optimization leaves remaining unoptimized items', async ( { + admin, + page, + requestUtils, + } ) => { + await seedUnoptimizedImages( requestUtils, 4 ) + await gotoCimoSettings( admin, page ) + + const total = await readUnoptimizedCount( page ) + expect( total ).toBeGreaterThanOrEqual( 4 ) + + const bulkButton = page.locator( '.cimo-bulk-optimize-button' ) + await bulkButton.click() + await expect( bulkButton ).toContainText( /Stop Optimization/i ) + + await bulkButton.click() + await waitForBulkIdle( page ) + + const label = await bulkButton.innerText() + if ( /Bulk Optimize/.test( label ) ) { + const remaining = Number( ( label.match( /\((\d+)\)/ ) || [] )[ 1 ] || 0 ) + expect( remaining ).toBeGreaterThan( 0 ) + } else { + test.info().annotations.push( { + type: 'note', + description: 'All items finished before stop could take effect', + } ) + await expect( bulkButton ).toBeDisabled() + } + } ) +} ) diff --git a/e2e/tests/premium/settings-unlocked.spec.ts b/e2e/tests/premium/settings-unlocked.spec.ts new file mode 100644 index 0000000..4ddd9a1 --- /dev/null +++ b/e2e/tests/premium/settings-unlocked.spec.ts @@ -0,0 +1,44 @@ +import { + test, + expect, + gotoCimoSettings, + SAMPLE_JPG, +} from '../../test-utils' + +test.describe.configure( { timeout: 120_000 } ) + +test.describe( 'Premium settings unlocked', () => { + test( 'premium build enables gated controls and working bulk UI', async ( { + admin, + page, + requestUtils, + } ) => { + await requestUtils.deleteAllMedia() + await requestUtils.uploadMedia( SAMPLE_JPG ) + + await gotoCimoSettings( admin, page ) + + await expect( page.locator( '.cimo-admin-settings-wrap.cimo-is-premium' ) ).toBeVisible() + + const optimizeAll = page.getByRole( 'checkbox', { + name: /Optimize All Media Uploads/i, + } ) + await expect( optimizeAll ).toBeEnabled() + + const smartOpt = page.getByRole( 'checkbox', { + name: /Smart Optimization/i, + } ) + await expect( smartOpt ).toBeEnabled() + + await expect( page.locator( '.cimo-bulk-optimizer-upsell' ) ).toHaveCount( 0 ) + + const bulkButton = page.locator( '.cimo-bulk-optimize-button' ) + await expect( bulkButton ).toBeVisible( { timeout: 30_000 } ) + await expect( bulkButton ).toContainText( /Bulk Optimize \(\d+\)/ ) + await expect( bulkButton ).not.toContainText( /with Premium/i ) + + await expect( + page.locator( '.cimo-bulk-optimize-button-view-images' ) + ).toBeEnabled() + } ) +} ) diff --git a/e2e/tests/settings.spec.ts b/e2e/tests/settings.spec.ts new file mode 100644 index 0000000..018f26f --- /dev/null +++ b/e2e/tests/settings.spec.ts @@ -0,0 +1,100 @@ +import { + test, + expect, + SAMPLE_LARGE_JPG, + gotoCimoSettings, + saveCimoOptions, + saveSettingsUi, + getCimoSettings, + reloadCimoRuntime, + uploadSampleViaMediaNew, + getMediaById, +} from '../test-utils' + +test.describe.configure( { timeout: 120_000 } ) + +test.describe( 'Cimo settings', () => { + test.beforeEach( async ( { requestUtils } ) => { + await requestUtils.deleteAllMedia() + await saveCimoOptions( requestUtils, { + webp_quality: 80, + max_image_dimension: 0, + disable_wp_scaling: 1, + } ) + } ) + + test.afterEach( async ( { requestUtils } ) => { + await requestUtils.deleteAllMedia() + await saveCimoOptions( requestUtils, { + webp_quality: 80, + max_image_dimension: 0, + disable_wp_scaling: 1, + } ) + } ) + + test( 'settings page loads with stats and image controls', async ( { + admin, + page, + } ) => { + await gotoCimoSettings( admin, page ) + + await expect( page.locator( '#cimo-stats' ) ).toBeVisible() + await expect( page.getByText( 'Total Storage Saved' ) ).toBeVisible() + await expect( page.getByRole( 'heading', { name: 'General Settings' } ) ).toBeVisible() + await expect( page.getByRole( 'heading', { name: 'Image Optimization Settings' } ) ).toBeVisible() + await expect( page.locator( '.cimo-webp-quality-range-control' ) ).toBeVisible() + await expect( page.getByLabel( 'Maximum Image Dimension' ) ).toBeVisible() + await expect( page.locator( '.cimo-save-button' ) ).toBeVisible() + } ) + + test( 'saving quality and max dimension updates runtime and upload', async ( { + admin, + page, + requestUtils, + } ) => { + await saveCimoOptions( requestUtils, { + webp_quality: 55, + max_image_dimension: 800, + disable_wp_scaling: 1, + } ) + + await gotoCimoSettings( admin, page ) + await expect( page.getByLabel( 'Maximum Image Dimension' ) ).toHaveValue( '800' ) + + // UI save smoke: tweak max dimension and persist via Save Changes. + await page.getByLabel( 'Maximum Image Dimension' ).fill( '640' ) + await saveSettingsUi( page ) + + await reloadCimoRuntime( page ) + const settings = await getCimoSettings( page ) + expect( Number( settings.webpQuality ) ).toBe( 55 ) + expect( Number( settings.maxImageDimension ) ).toBe( 640 ) + + const media = await uploadSampleViaMediaNew( + page, + requestUtils, + SAMPLE_LARGE_JPG + ) + const details = await getMediaById( requestUtils, media.id ) + const width = details.media_details?.width || 0 + const height = details.media_details?.height || 0 + expect( Math.max( width, height ) ).toBeLessThanOrEqual( 640 ) + expect( details.mime_type ).toBe( 'image/webp' ) + } ) + + test( 'Recommended and Reset presets update image controls', async ( { + admin, + page, + } ) => { + await gotoCimoSettings( admin, page ) + + const imageSection = page.locator( '.cimo-settings-section' ).filter( { + has: page.getByRole( 'heading', { name: 'Image Optimization Settings' } ), + } ) + await imageSection.getByRole( 'button', { name: 'Recommended' } ).click() + await expect( page.getByLabel( 'Maximum Image Dimension' ) ).toHaveValue( '1920' ) + + await imageSection.getByRole( 'button', { name: 'Reset to Default' } ).click() + await expect( page.getByLabel( 'Maximum Image Dimension' ) ).toHaveValue( '' ) + } ) +} ) diff --git a/e2e/tests/upload-surfaces.spec.ts b/e2e/tests/upload-surfaces.spec.ts new file mode 100644 index 0000000..1b7aa65 --- /dev/null +++ b/e2e/tests/upload-surfaces.spec.ts @@ -0,0 +1,184 @@ +import { + test, + expect, + SAMPLE_JPG, + SAMPLE_PNG, + SAMPLE_LARGE_JPG, + dropFile, + dropFiles, + waitForCimoReady, + waitForCimoEditorIframeReady, + dismissEditorOverlays, + getMaxMediaId, + deletePage, + expectNewMediaIsWebp, + expectNewMediaCount, +} from '../test-utils' + +test.describe.configure( { timeout: 120_000 } ) + +test.describe( 'Additional upload surfaces', () => { + let pageId: number | null = null + + test.beforeEach( async ( { requestUtils } ) => { + await requestUtils.deleteAllMedia() + } ) + + test.afterEach( async ( { requestUtils } ) => { + if ( pageId ) { + await deletePage( requestUtils, pageId ) + pageId = null + } + await requestUtils.deleteAllMedia() + } ) + + async function openNewPage( admin, editor, page, requestUtils ) { + const draft = await requestUtils.createPage( { + title: 'Cimo Upload Surfaces E2E', + status: 'draft', + content: '

Cimo e2e

', + } ) + pageId = draft.id + + await admin.visitAdminPage( + 'post.php', + `post=${ pageId }&action=edit` + ) + await editor.setPreferences( 'core/edit-post', { + welcomeGuide: false, + fullscreenMode: false, + } ) + await editor.setPreferences( 'core', { + enableChoosePatternModal: false, + } ) + await dismissEditorOverlays( page ) + await waitForCimoReady( page ) + await waitForCimoEditorIframeReady( page ) + } + + test( 'Media Library grid drop converts JPG to WebP', async ( { + page, + requestUtils, + } ) => { + await page.goto( '/wp-admin/upload.php' ) + await waitForCimoReady( page ) + const afterId = await getMaxMediaId( requestUtils ) + + // Drop directly on `.uploader-window` so Cimo's allowlist match succeeds + // even when the overlay is not display:block yet. + const dropTarget = page.locator( '.uploader-window' ).first() + await expect( dropTarget ).toBeAttached( { timeout: 15_000 } ) + await dropFile( dropTarget ) + + const media = await expectNewMediaIsWebp( requestUtils, afterId ) + await expect( + page.locator( `.attachment[data-id="${ media.id }"]` ) + ).toBeVisible( { timeout: 30_000 } ) + } ) + + test( 'Media Library Select Files converts JPG to WebP', async ( { + page, + requestUtils, + } ) => { + await page.goto( '/wp-admin/upload.php' ) + await waitForCimoReady( page ) + const afterId = await getMaxMediaId( requestUtils ) + + // Grid view exposes the classic plupload "Select Files" input (`.moxie-shim` + // / browser uploader), which is the non-drop select-files interception path. + const fileInput = page.locator( + '.uploader-inline input[type="file"], .moxie-shim input[type="file"], input#async-upload, .media-upload-form input[type="file"]' + ).first() + await expect( fileInput ).toBeAttached( { timeout: 15_000 } ) + await fileInput.setInputFiles( SAMPLE_JPG ) + + const media = await expectNewMediaIsWebp( requestUtils, afterId ) + await expect( + page.locator( `.attachment[data-id="${ media.id }"]` ) + ).toBeVisible( { timeout: 30_000 } ) + } ) + + test( 'PNG upload converts to WebP', async ( { + page, + requestUtils, + } ) => { + await page.goto( '/wp-admin/media-new.php' ) + await waitForCimoReady( page ) + const afterId = await getMaxMediaId( requestUtils ) + + const fileInput = page.locator( + '.media-upload-form input[type="file"], #async-upload, input[name="async-upload"]' + ).first() + await fileInput.setInputFiles( SAMPLE_PNG ) + + const media = await expectNewMediaIsWebp( requestUtils, afterId ) + expect( media.source_url ).toMatch( /\.webp(\?|$)/i ) + } ) + + test( 'multi-file drop converts all JPGs to WebP', async ( { + page, + requestUtils, + } ) => { + await page.goto( '/wp-admin/media-new.php' ) + await waitForCimoReady( page ) + const afterId = await getMaxMediaId( requestUtils ) + + const dropTarget = page.locator( '.media-upload-form, .uploader-inline' ).first() + await expect( dropTarget ).toBeVisible( { timeout: 15_000 } ) + await dropFiles( dropTarget, [ + { path: SAMPLE_JPG, mimeType: 'image/jpeg' }, + { path: SAMPLE_LARGE_JPG, mimeType: 'image/jpeg' }, + ] ) + + const created = await expectNewMediaCount( requestUtils, afterId, 2 ) + expect( created ).toHaveLength( 2 ) + for ( const item of created ) { + expect( item.mime_type ).toBe( 'image/webp' ) + } + } ) + + test( 'progress modal cancel stops optimization', async ( { + page, + requestUtils, + } ) => { + await page.goto( '/wp-admin/media-new.php' ) + await waitForCimoReady( page ) + const afterId = await getMaxMediaId( requestUtils ) + + page.once( 'dialog', async ( dialog ) => { + await dialog.accept() + } ) + + const fileInput = page.locator( + '.media-upload-form input[type="file"], #async-upload, input[name="async-upload"]' + ).first() + // Large file + progressDelay should surface the modal long enough to cancel. + await fileInput.setInputFiles( SAMPLE_LARGE_JPG ) + + const modal = page.locator( '.cimo-progress-modal' ) + const appeared = await modal + .waitFor( { state: 'visible', timeout: 5_000 } ) + .then( () => true ) + .catch( () => false ) + + test.skip( + ! appeared, + 'Progress modal did not appear before conversion finished (too fast on this host)' + ) + + await page.locator( '.cimo-progress-close' ).click() + await expect( modal ).toBeHidden( { timeout: 10_000 } ) + + // Cancel may leave zero new attachments or an unconverted original. + await page.waitForTimeout( 2_000 ) + const created = await requestUtils.rest( { + path: '/wp/v2/media', + params: { per_page: 10, orderby: 'id', order: 'desc' }, + } ) as Array<{ id: number; mime_type: string }> + const newer = created.filter( ( item ) => item.id > afterId ) + for ( const item of newer ) { + // If anything uploaded after cancel, it should not be a mid-flight Cimo failure crash. + expect( item.mime_type ).toMatch( /^image\// ) + } + } ) +} ) diff --git a/package.json b/package.json index abbe211..8a95e84 100644 --- a/package.json +++ b/package.json @@ -14,6 +14,7 @@ "build:premium": "cd pro__premium_only && npm run build", "start:premium": "cd pro__premium_only && npm run start", "build:e2e": "node scripts/update-build-type.js free && wp-scripts build", + "build:e2e:premium": "node scripts/update-build-type.js premium && npx wp-scripts build --config pro__premium_only/webpack.config.js", "lint:js": "wp-scripts lint-js", "lint:js:fix": "wp-scripts lint-js --fix", "lint:css": "wp-scripts lint-style", @@ -24,6 +25,7 @@ "sync-version": "node scripts/sync-version.js", "test": "npm run test:e2e", "test:e2e": "playwright test", + "test:e2e:premium": "playwright test -c playwright.premium.config.js", "test:debug": "playwright test --ui" }, "browserslist": [ diff --git a/playwright.config.js b/playwright.config.js index 06347af..169a14a 100644 --- a/playwright.config.js +++ b/playwright.config.js @@ -29,6 +29,7 @@ const PLAYGROUND_BLUEPRINT = path.join( __dirname, 'e2e/playground-blueprint.jso module.exports = defineConfig( { testDir: './e2e/tests', + testIgnore: [ '**/premium/**' ], globalSetup: require.resolve( './e2e/config/global-setup.js' ), fullyParallel: false, forbidOnly: !! process.env.CI, diff --git a/playwright.premium.config.js b/playwright.premium.config.js new file mode 100644 index 0000000..2056c0d --- /dev/null +++ b/playwright.premium.config.js @@ -0,0 +1,59 @@ +/** + * Playwright config for Cimo's premium e2e suite. + * + * Requires `pro__premium_only/` on disk and a prior `npm run build:e2e:premium`. + * Uses a separate Playground port/blueprint that seeds a mock Freemius premium plan. + */ +const path = require( 'path' ) +const { defineConfig, devices } = require( '@playwright/test' ) + +const PORT = process.env.WP_PORT || '9411' +const baseURL = process.env.WP_BASE_URL || `http://127.0.0.1:${ PORT }` +process.env.WP_BASE_URL = baseURL +process.env.WP_USERNAME = process.env.WP_USERNAME || 'admin' +process.env.WP_PASSWORD = process.env.WP_PASSWORD || 'password' + +const STORAGE_STATE_PATH = path.join( __dirname, 'e2e/.auth/admin.premium.json' ) +process.env.STORAGE_STATE_PATH = STORAGE_STATE_PATH + +const PLAYGROUND_BLUEPRINT = path.join( + __dirname, + 'e2e/playground-blueprint.premium.json' +) + +module.exports = defineConfig( { + testDir: './e2e/tests/premium', + globalSetup: require.resolve( './e2e/config/global-setup.js' ), + fullyParallel: false, + forbidOnly: !! process.env.CI, + retries: process.env.CI ? 1 : 0, + workers: 1, + timeout: 120_000, + reporter: process.env.CI + ? [ [ 'github' ], [ 'html', { open: 'never' } ] ] + : [ [ 'list' ], [ 'html', { outputFolder: 'playwright-report-premium', open: 'never' } ] ], + reportSlowTests: null, + webServer: { + command: [ + 'npx @wp-playground/cli server', + '--mount=.:/wordpress/wp-content/plugins/cimo', + `--blueprint=${ PLAYGROUND_BLUEPRINT }`, + '--php=8.2', + `--port=${ PORT }`, + ].join( ' ' ), + port: Number( PORT ), + reuseExistingServer: ! process.env.CI, + timeout: 180 * 1000, + stdout: 'pipe', + stderr: 'pipe', + }, + use: { + baseURL, + storageState: STORAGE_STATE_PATH, + ignoreHTTPSErrors: true, + screenshot: 'only-on-failure', + trace: 'retain-on-failure', + video: 'retain-on-failure', + ...devices[ 'Desktop Chrome' ], + }, +} ) diff --git a/src/admin/class-admin.php b/src/admin/class-admin.php index 135893f..4a64a64 100644 --- a/src/admin/class-admin.php +++ b/src/admin/class-admin.php @@ -259,10 +259,12 @@ public function enqueue_admin_scripts( $hook ) { // Enqueue JavaScript $script_asset = include $build_dir . 'admin-page.asset.php'; + // Depend on cimo-script so premium filters (BulkOptimizer, buildType peers) + // register before admin-settings.js evaluates applyFilters at module load. wp_enqueue_script( 'cimo-admin-page', $build_url . 'admin-page.js', - array_merge( $script_asset['dependencies'], $dependencies['js'] ), + array_merge( $script_asset['dependencies'], $dependencies['js'], [ 'cimo-script' ] ), $script_asset['version'], true );