diff --git a/blueprint-test.json b/blueprint-test.json new file mode 100644 index 0000000000..a699340937 --- /dev/null +++ b/blueprint-test.json @@ -0,0 +1,19 @@ +{ + "steps": [ + { + "step": "mkdir", + "path": "wordpress/wp-content/mu-plugins" + }, + { + "step": "writeFile", + "path": "wordpress/wp-content/mu-plugins/load.php", + "data": "dbh));" + } + ], + "login": true, + "preferredVersions": { + "php": "8.3", + "wp": "latest" + }, + "features": {} +} diff --git a/blueprint.json b/blueprint.json new file mode 100644 index 0000000000..7c2012d5bf --- /dev/null +++ b/blueprint.json @@ -0,0 +1,4 @@ +{ + "login": true, + "steps": [] +} diff --git a/package.json b/package.json index 55c076788d..5cc35b7afe 100644 --- a/package.json +++ b/package.json @@ -217,5 +217,9 @@ }, "optionalDependencies": { "fs-ext": "2.1.1" + }, + "volta": { + "node": "23.10.0", + "npm": "10.9.0" } } diff --git a/packages/php-wasm/logger/src/lib/logger.ts b/packages/php-wasm/logger/src/lib/logger.ts index ddbe3d0bed..b111c26b56 100644 --- a/packages/php-wasm/logger/src/lib/logger.ts +++ b/packages/php-wasm/logger/src/lib/logger.ts @@ -96,6 +96,15 @@ export class Logger extends EventTarget { } } + /** + * Get the current severity filter level. + * + * @returns LogSeverity + */ + public getSeverityFilterLevel(): LogSeverity { + return this.severity; + } + /** * Filter message based on severity * @param severity LogSeverity diff --git a/packages/playground/cli/src/blueprints-v1/worker-thread-v1.ts b/packages/playground/cli/src/blueprints-v1/worker-thread-v1.ts index 5aecb031fc..e1c7d3cc37 100644 --- a/packages/playground/cli/src/blueprints-v1/worker-thread-v1.ts +++ b/packages/playground/cli/src/blueprints-v1/worker-thread-v1.ts @@ -1,3 +1,4 @@ +import { logger } from '@php-wasm/logger'; import type { FileLockManager } from '@php-wasm/node'; import { loadNodeRuntime } from '@php-wasm/node'; import { EmscriptenDownloadMonitor } from '@php-wasm/progress'; @@ -18,9 +19,14 @@ import { } from '@wp-playground/wordpress'; import { rootCertificates } from 'tls'; import { jspi } from 'wasm-feature-detect'; -import { MessageChannel, type MessagePort, parentPort } from 'worker_threads'; +import { + MessageChannel, + type MessagePort, + parentPort, + workerData, +} from 'worker_threads'; import { mountResources } from '../mounts'; -import { logger } from '@php-wasm/logger'; +import { LogVerbosity, type WorkerData } from '../run-cli'; export interface Mount { hostPath: string; @@ -311,6 +317,17 @@ export class PlaygroundCliBlueprintV1Worker extends PHPWorker { } } +// Configure logger verbosity from workerData +if (typeof workerData === 'object') { + const verbosity = (workerData as WorkerData).verbosity; + const severity = Object.values(LogVerbosity).find( + (v) => v.name === verbosity + )?.severity; + if (severity) { + logger.setSeverityFilterLevel(severity); + } +} + process.on('unhandledRejection', (e: any) => { logger.error('Unhandled rejection:', e); }); diff --git a/packages/playground/cli/src/blueprints-v2/worker-thread-v2.ts b/packages/playground/cli/src/blueprints-v2/worker-thread-v2.ts index 2d3e0e0aab..cb1fd128d8 100644 --- a/packages/playground/cli/src/blueprints-v2/worker-thread-v2.ts +++ b/packages/playground/cli/src/blueprints-v2/worker-thread-v2.ts @@ -31,10 +31,15 @@ import { bootRequestHandler } from '@wp-playground/wordpress'; import { existsSync } from 'fs'; import path from 'path'; import { rootCertificates } from 'tls'; -import { MessageChannel, type MessagePort, parentPort } from 'worker_threads'; +import { + MessageChannel, + type MessagePort, + parentPort, + workerData, +} from 'worker_threads'; import type { Mount } from '../mounts'; +import { type RunCLIArgs, LogVerbosity, type WorkerData } from '../run-cli'; import { jspi } from 'wasm-feature-detect'; -import { type RunCLIArgs } from '../run-cli'; import type { PhpIniOptions, PHPInstanceCreatedHook, @@ -484,6 +489,17 @@ export class PlaygroundCliBlueprintV2Worker extends PHPWorker { } } +// Configure logger verbosity from workerData +if (typeof workerData === 'object') { + const verbosity = (workerData as WorkerData).verbosity; + const severity = Object.values(LogVerbosity).find( + (v) => v.name === verbosity + )?.severity; + if (severity) { + logger.setSeverityFilterLevel(severity); + } +} + process.on('unhandledRejection', (e: any) => { logger.error('Unhandled rejection:', e); }); diff --git a/packages/playground/cli/src/run-cli.ts b/packages/playground/cli/src/run-cli.ts index 67af7faa01..fb13fffdf9 100644 --- a/packages/playground/cli/src/run-cli.ts +++ b/packages/playground/cli/src/run-cli.ts @@ -925,6 +925,13 @@ export async function runCLI(args: RunCLIArgs): Promise { nativeInternalDirPath ); + [...workers, initialWorker].map(async (worker) => { + playgroundsToCleanUp.push({ + playground: initialPlayground, + worker: worker.worker, + }); + }); + await initialPlayground.isReady(); wordPressReady = true; logger.log(`Booted!`); @@ -1072,6 +1079,10 @@ export type SpawnedWorker = { phpPort: NodeMessagePort; }; +export type WorkerData = { + verbosity: LogVerbosity; +}; + async function spawnWorkerThreads( count: number, workerType: WorkerType, @@ -1139,10 +1150,22 @@ async function spawnWorkerThread(workerType: 'v1' | 'v2') { // @ts-expect-error globalThis['__WORKER_V2_URL__'] = './blueprints-v2/worker-thread-v2.ts'; } + + // Pass logger verbosity to the worker thread. + const currentSeverity = logger.getSeverityFilterLevel(); + const verbosity = Object.values(LogVerbosity).find( + (v) => v.severity === currentSeverity + )?.name; + + // Prepare worker options. + const options = { + workerData: { verbosity }, + } as const; + if (workerType === 'v1') { - return new Worker(new URL(__WORKER_V1_URL__, import.meta.url)); + return new Worker(new URL(__WORKER_V1_URL__, import.meta.url), options); } else { - return new Worker(new URL(__WORKER_V2_URL__, import.meta.url)); + return new Worker(new URL(__WORKER_V2_URL__, import.meta.url), options); } } diff --git a/packages/playground/plugin-stats/README.md b/packages/playground/plugin-stats/README.md new file mode 100644 index 0000000000..78954f3782 --- /dev/null +++ b/packages/playground/plugin-stats/README.md @@ -0,0 +1,13 @@ +# WordPress Playground Plugin Compatibility Stats CLI + +This is a simple CLI script that evaluates basic WordPress Playground plugin +compatibility with top N plugins from the WordPress.org plugin repository. + +Usage: + +```bash +npx nx start playground-plugin-stats --top=10 +``` + +At the moment, the script evaluates whether each of the plugins successfully +activates in WordPress Playground without crashing and logging any errors. diff --git a/packages/playground/plugin-stats/package.json b/packages/playground/plugin-stats/package.json new file mode 100644 index 0000000000..c4fb2812b9 --- /dev/null +++ b/packages/playground/plugin-stats/package.json @@ -0,0 +1,4 @@ +{ + "private": true, + "type": "module" +} diff --git a/packages/playground/plugin-stats/plugin-stats.ts b/packages/playground/plugin-stats/plugin-stats.ts new file mode 100644 index 0000000000..9eee0f274a --- /dev/null +++ b/packages/playground/plugin-stats/plugin-stats.ts @@ -0,0 +1,241 @@ +import fs from 'fs'; +import { type BlueprintV1Declaration } from '@wp-playground/blueprints'; +import { runCLI } from '@wp-playground/cli'; + +// Get --top from command line arguments. +const args = process.argv.slice(2); + +// Options. +let plugin_count = 100; +if (args.find((arg) => arg.startsWith('--top='))) { + const top = args.find((arg) => arg.startsWith('--top=')); + plugin_count = parseInt(top.split('=')[1] ?? plugin_count.toString()); +} else if (args.includes('--top')) { + const top_index = args.findIndex((arg) => arg.startsWith('--top')); + plugin_count = parseInt(args[top_index + 1] ?? plugin_count.toString()); +} + +if (!Number.isInteger(plugin_count) || plugin_count <= 0) { + console.error('Invalid plugin count. Please, specify a positive integer.'); + process.exit(1); +} + +const max_attempts = 3; +const debug = false; +const tmp_dir = `${import.meta.dirname}/tmp`; + +// Types. +type Plugin = { + name: string; + slug: string; + tested: string; + requires_php: string; + requires_plugins: string[]; +}; + +console.log(`Testing top ${plugin_count} plugins...\n`); + +// Construct the top plugins URL. +const base_url = 'https://api.wordpress.org/plugins/info/1.2/'; +const url = new URL(base_url); +url.searchParams.set('action', 'query_plugins'); +url.searchParams.set('request[browse]', 'popular'); + +// Fetch the top plugins. +const plugins: Plugin[] = []; +const per_page = Math.min(plugin_count, 250); +const pages = Math.ceil(plugin_count / per_page); + +for (let page = 1; page <= pages; page++) { + url.searchParams.set('request[per_page]', per_page.toString()); + url.searchParams.set('request[page]', page.toString()); + const response = await fetch(url, { + headers: { Accept: 'application/json' }, + }); + const data = await response.json(); + plugins.push(...data.plugins.slice(0, plugin_count - plugins.length)); +} + +// Run plugin tests. +const results: { plugin: Plugin; error?: string }[] = []; +for await (const [i, plugin] of plugins.entries()) { + process.stdout.write(`[${i + 1}] ${plugin.slug}... `); + + let php_version = plugin.requires_php; + if (php_version < '7.4') { + php_version = '7.4'; + } else if (php_version > '8.4') { + php_version = '8.4'; + } + + const blueprint: BlueprintV1Declaration = { + preferredVersions: { + php: php_version as BlueprintV1Declaration['preferredVersions']['php'], + wp: plugin.tested ?? 'latest', + }, + login: true, + steps: [ + { + step: 'defineWpConfigConsts', + consts: { + WP_AUTO_UPDATE_CORE: false, + DISABLE_WP_CRON: true, + }, + }, + ...plugin.requires_plugins.map( + (slug) => + ({ + step: 'installPlugin', + pluginData: { + resource: 'wordpress.org/plugins', + slug, + }, + options: { + activate: true, + }, + } as const) + ), + { + step: 'installPlugin', + pluginData: { + resource: 'wordpress.org/plugins', + slug: plugin.slug, + }, + options: { + activate: true, + }, + }, + ], + }; + + // Run the blueprint. + let errors: string[] = []; + let attempts = 0; + do { + // Ensure tmp directory exists. + fs.rmSync(tmp_dir, { recursive: true, force: true }); + fs.mkdirSync(tmp_dir); + fs.mkdirSync(`${tmp_dir}/home`); + fs.mkdirSync(`${tmp_dir}/tmp`); + fs.mkdirSync(`${tmp_dir}/wordpress`); + + let should_retry = false; + errors = []; + attempts += 1; + + try { + await runCLI({ + command: 'run-blueprint', + blueprint, + debug, + verbosity: 'quiet', + internalCookieStore: true, + 'mount-before-install': [ + { + hostPath: `${tmp_dir}/home`, + vfsPath: '/home', + }, + { + hostPath: `${tmp_dir}/tmp`, + vfsPath: '/tmp', + }, + { + hostPath: `${tmp_dir}/wordpress`, + vfsPath: '/wordpress', + }, + ], + }); + } catch (error) { + errors.push(error.message.trim()); + should_retry = true; + } + + // Read the error log file. + const error_log = fs.existsSync( + `${tmp_dir}/wordpress/wp-content/debug.log` + ) + ? fs.readFileSync( + `${tmp_dir}/wordpress/wp-content/debug.log`, + 'utf8' + ) + : ''; + + for (const error of error_log.split('\n')) { + // Exclude PHP notices. + if (error.includes('] PHP Notice: ')) { + continue; + } + + // Error on the Smash Balloon Social Photo Feed plugin side. + // This error appears also when used without Playground or SQLite. + if (error.includes('no such table: wp_sbi_feeds')) { + continue; + } + errors.push(error.trim()); + } + + if ( + errors + .join('\n') + .includes( + 'Could not download "https://downloads.wordpress.org/plugin/' + ) + ) { + should_retry = true; + } + + if (should_retry) { + await new Promise((resolve) => setTimeout(resolve, 3000)); + continue; + } + + break; + } while (attempts <= max_attempts); + + const error = errors.length > 0 ? errors.join('\n') : undefined; + results.push({ plugin, error }); + console.log(error ? '[ERROR]' : '[OK]'); + + // Sleep for 3 seconds to avoid wordpress.org rate limiting. + await new Promise((resolve) => setTimeout(resolve, 3000)); +} + +// Print the results. +const errors = results.filter((result) => result.error); +const total_count = results.length; +const error_count = errors.length; +const success_count = total_count - error_count; + +const success_rate = Math.round((success_count / total_count) * 100); +const error_rate = Math.round((error_count / total_count) * 100); + +const formatted_success_count = success_count + .toString() + .padStart(total_count.toString().length, ' '); +const formatted_error_count = error_count + .toString() + .padStart(total_count.toString().length, ' '); + +const summary = [ + `\n${'='.repeat(100)}\n`, + `SUCCESS RATE: ${formatted_success_count}/${total_count} (${success_rate}%)`, + `ERROR RATE: ${formatted_error_count}/${total_count} (${error_rate}%)`, + `\n${'='.repeat(100)}\n`, +].join('\n'); + +console.log(summary); + +for (const [i, error] of errors.entries()) { + console.log(`ERROR when activating '${error.plugin.slug}':\n`); + console.log(`${error.error.trim()}\n`); + + if (i < errors.length - 1) { + console.log('-'.repeat(100)); + } +} + +if (error_count > 0) { + console.log(summary); +} + +process.exit(0); diff --git a/packages/playground/plugin-stats/project.json b/packages/playground/plugin-stats/project.json new file mode 100644 index 0000000000..5bd312dbc0 --- /dev/null +++ b/packages/playground/plugin-stats/project.json @@ -0,0 +1,16 @@ +{ + "name": "playground-plugin-stats", + "$schema": "../../../node_modules/nx/schemas/project-schema.json", + "sourceRoot": "packages/playground/plugin-stats", + "projectType": "application", + "targets": { + "start": { + "executor": "nx:run-commands", + "options": { + "commands": [ + "node --experimental-strip-types --experimental-transform-types --import ./packages/meta/src/node-es-module-loader/register.mts --no-warnings=ExperimentalWarning packages/playground/plugin-stats/plugin-stats.ts" + ] + } + } + } +} diff --git a/packages/playground/plugin-stats/tsconfig.json b/packages/playground/plugin-stats/tsconfig.json new file mode 100644 index 0000000000..f5ca586b31 --- /dev/null +++ b/packages/playground/plugin-stats/tsconfig.json @@ -0,0 +1,4 @@ +{ + "include": ["**/*.ts"], + "extends": "../../../tsconfig.base.json" +} diff --git a/tsconfig.base.json b/tsconfig.base.json index 0a4f8d2954..c72004afce 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -47,7 +47,7 @@ "@wp-playground/blueprints": [ "packages/playground/blueprints/src/index.ts" ], - "@wp-playground/cli": ["packages/playground/cli/src/cli.ts"], + "@wp-playground/cli": ["packages/playground/cli/src/index.ts"], "@wp-playground/client": [ "packages/playground/client/src/index.ts" ],