diff --git a/packages/playground/blueprints/src/lib/resources.ts b/packages/playground/blueprints/src/lib/resources.ts index ad2d1c38fd..93065089cc 100644 --- a/packages/playground/blueprints/src/lib/resources.ts +++ b/packages/playground/blueprints/src/lib/resources.ts @@ -4,7 +4,7 @@ import { } from '@php-wasm/progress'; import { UniversalPHP } from '@php-wasm/universal'; import { Semaphore } from '@php-wasm/util'; -import { zipNameToHumanName } from './steps/common'; +import { File, zipNameToHumanName } from './steps/common'; export const ResourceTypes = [ 'vfs', diff --git a/packages/playground/blueprints/src/lib/steps/activate-plugin.ts b/packages/playground/blueprints/src/lib/steps/activate-plugin.ts index 6d8c5237c2..72efa36a8a 100644 --- a/packages/playground/blueprints/src/lib/steps/activate-plugin.ts +++ b/packages/playground/blueprints/src/lib/steps/activate-plugin.ts @@ -2,25 +2,26 @@ import { StepHandler } from '.'; export interface ActivatePluginStep { step: 'activatePlugin'; - /* Path to the plugin file relative to the plugins directory. */ + /* Path to the plugin directory as absolute path (/wordpress/wp-content/plugins/plugin-name); or the plugin entry file relative to the plugins directory (plugin-name/plugin-name.php). */ pluginPath: string; + /* Optional plugin name */ + pluginName?: string; } /** * Activates a WordPress plugin in the Playground. * * @param playground The playground client. - * @param plugin The plugin slug. */ export const activatePlugin: StepHandler = async ( playground, - { pluginPath }, + { pluginPath, pluginName }, progress ) => { - progress?.tracker.setCaption(`Activating ${pluginPath}`); + progress?.tracker.setCaption(`Activating ${pluginName || pluginPath}`); const requiredFiles = [ - `${playground.documentRoot}/wp-load.php`, - `${playground.documentRoot}/wp-admin/includes/plugin.php`, + `${await playground.documentRoot}/wp-load.php`, + `${await playground.documentRoot}/wp-admin/includes/plugin.php`, ]; const requiredFilesExist = requiredFiles.every((file) => playground.fileExists(file) @@ -30,10 +31,28 @@ export const activatePlugin: StepHandler = async ( `Required WordPress files do not exist: ${requiredFiles.join(', ')}` ); } - await playground.run({ + + const result = await playground.run({ code: ` `require_once( '${file}' );`).join('\n')} - activate_plugin('${pluginPath}'); - `, +${requiredFiles.map((file) => `require_once( '${file}' );`).join('\n')} +$plugin_path = '${pluginPath}'; +if (!is_dir($plugin_path)) { + activate_plugin($plugin_path); + return; +} +// Find plugin entry file +foreach ( ( glob( $plugin_path . '/*.php' ) ?: array() ) as $file ) { + $info = get_plugin_data( $file, false, false ); + if ( ! empty( $info['Name'] ) ) { + activate_plugin( $file ); + return; + } +} +echo 'NO_ENTRY_FILE'; +`, }); + if (result.errors) throw new Error(result.errors); + if (result.text === 'NO_ENTRY_FILE') { + throw new Error('Could not find plugin entry file.'); + } }; diff --git a/packages/playground/blueprints/src/lib/steps/activate-theme.ts b/packages/playground/blueprints/src/lib/steps/activate-theme.ts index 009194e4d0..0e6bcf49c6 100644 --- a/packages/playground/blueprints/src/lib/steps/activate-theme.ts +++ b/packages/playground/blueprints/src/lib/steps/activate-theme.ts @@ -17,7 +17,7 @@ export const activateTheme: StepHandler = async ( progress ) => { progress?.tracker.setCaption(`Activating ${themeFolderName}`); - const wpLoadPath = `${playground.documentRoot}/wp-load.php`; + const wpLoadPath = `${await playground.documentRoot}/wp-load.php`; if (!playground.fileExists(wpLoadPath)) { throw new Error( `Required WordPress file does not exist: ${wpLoadPath}` diff --git a/packages/playground/blueprints/src/lib/steps/common.ts b/packages/playground/blueprints/src/lib/steps/common.ts index 4af86c4068..0dae687f47 100644 --- a/packages/playground/blueprints/src/lib/steps/common.ts +++ b/packages/playground/blueprints/src/lib/steps/common.ts @@ -1,11 +1,7 @@ -import type { PHPResponse, UniversalPHP } from '@php-wasm/universal'; - -export function asDOM(response: PHPResponse) { - return new DOMParser().parseFromString(response.text, 'text/html')!; -} +import type { UniversalPHP } from '@php-wasm/universal'; export function zipNameToHumanName(zipName: string) { - const mixedCaseName = zipName.split('.').shift()!.replace('-', ' '); + const mixedCaseName = zipName.split('.').shift()!.replace(/-/g, ' '); return ( mixedCaseName.charAt(0).toUpperCase() + mixedCaseName.slice(1).toLowerCase() @@ -28,3 +24,31 @@ export async function updateFile( export async function fileToUint8Array(file: File) { return new Uint8Array(await file.arrayBuffer()); } + +/** + * Polyfill the File class in JSDOM which lacks arrayBuffer() method + * + * - [Implement Blob.stream, Blob.text and Blob.arrayBuffer](https://github.com/jsdom/jsdom/issues/2555) + * + * When a Resource (../resources.ts) resolves to an instance of File, the + * resulting object is missing the arrayBuffer() method in JSDOM environment + * during tests. + * + * Import the polyfilled File class below to ensure its buffer is available to + * functions like writeFile (./client-methods.ts) and fileToUint8Array (above). + */ +class FilePolyfill extends File { + buffers: BlobPart[]; + constructor(buffers: BlobPart[], name: string) { + super(buffers, name); + this.buffers = buffers; + } + override async arrayBuffer(): Promise { + return this.buffers[0] as ArrayBuffer; + } +} + +const FileWithArrayBuffer = + File.prototype.arrayBuffer instanceof Function ? File : FilePolyfill; + +export { FileWithArrayBuffer as File }; diff --git a/packages/playground/blueprints/src/lib/steps/index.ts b/packages/playground/blueprints/src/lib/steps/index.ts index 9c51a6aa68..037cc2962b 100644 --- a/packages/playground/blueprints/src/lib/steps/index.ts +++ b/packages/playground/blueprints/src/lib/steps/index.ts @@ -96,17 +96,19 @@ export type { WriteFileStep, }; +/** + * Progress reporting details. + */ +export type StepProgress = { + tracker: ProgressTracker; + initialCaption?: string; +}; + export type StepHandler> = ( /** * A PHP instance or Playground client. */ php: UniversalPHP, args: Omit, - /** - * Progress reporting details. - */ - progressArgs?: { - tracker: ProgressTracker; - initialCaption?: string; - } + progressArgs?: StepProgress ) => any; diff --git a/packages/playground/blueprints/src/lib/steps/install-asset.ts b/packages/playground/blueprints/src/lib/steps/install-asset.ts new file mode 100644 index 0000000000..ed613d5e8f --- /dev/null +++ b/packages/playground/blueprints/src/lib/steps/install-asset.ts @@ -0,0 +1,96 @@ +import type { UniversalPHP } from '@php-wasm/universal'; +import { writeFile } from './client-methods'; +import { unzip } from './import-export'; + +export interface InstallAssetOptions { + /** + * The zip file to install. + */ + zipFile: File; + /** + * Target path to extract the main folder. + * @example + * + * + * const targetPath = `${await playground.documentRoot}/wp-content/plugins`; + * + */ + targetPath: string; +} + +/** + * Install asset: Extract folder from zip file and move it to target + */ +export async function installAsset( + playground: UniversalPHP, + { targetPath, zipFile }: InstallAssetOptions +): Promise<{ + assetFolderPath: string; + assetFolderName: string; +}> { + // Extract to temporary folder so we can find asset folder name + + const zipFileName = zipFile.name; + const tmpFolder = `/tmp/assets`; + const tmpZipPath = `/tmp/${zipFileName}`; + + const removeTmpFolder = () => + playground.rmdir(tmpFolder, { + recursive: true, + }); + + if (await playground.fileExists(tmpFolder)) { + await removeTmpFolder(); + } + + await writeFile(playground, { + path: tmpZipPath, + data: zipFile, + }); + + const cleanup = () => + Promise.all([removeTmpFolder, () => playground.unlink(tmpZipPath)]); + + try { + await unzip(playground, { + zipPath: tmpZipPath, + extractToPath: tmpFolder, + }); + + // Find extracted asset folder name + + const files = await playground.listFiles(tmpFolder); + + let assetFolderName; + let tmpAssetPath = ''; + + for (const file of files) { + tmpAssetPath = `${tmpFolder}/${file}`; + if (await playground.isDir(tmpAssetPath)) { + assetFolderName = file; + break; + } + } + + if (!assetFolderName) { + throw new Error( + `The zip file should contain a single folder with files inside, but the provided zip file (${zipFileName}) does not contain such a folder.` + ); + } + + // Move asset folder to target path + + const assetFolderPath = `${targetPath}/${assetFolderName}`; + await playground.mv(tmpAssetPath, assetFolderPath); + + await cleanup(); + + return { + assetFolderPath, + assetFolderName, + }; + } catch (error) { + await cleanup(); + throw error; + } +} diff --git a/packages/playground/blueprints/src/lib/steps/install-plugin.spec.ts b/packages/playground/blueprints/src/lib/steps/install-plugin.spec.ts new file mode 100644 index 0000000000..15b48f7fae --- /dev/null +++ b/packages/playground/blueprints/src/lib/steps/install-plugin.spec.ts @@ -0,0 +1,66 @@ +import { NodePHP } from '@php-wasm/node'; +import { compileBlueprint, runBlueprintSteps } from '../compile'; + +const phpVersion = '8.0'; +describe('Blueprint step installPlugin', () => { + let php: NodePHP; + beforeEach(async () => { + php = await NodePHP.load(phpVersion, { + requestHandler: { + documentRoot: '/wordpress', + isStaticFilePath: (path) => !path.endsWith('.php'), + }, + }); + }); + + it('should install a plugin', async () => { + // Create test plugin + + const pluginName = 'test-plugin'; + + php.mkdir(`/${pluginName}`); + php.writeFile( + `/${pluginName}/index.php`, + `/**\n * Plugin Name: Test Plugin` + ); + + // Note the package name is different from plugin folder name + const zipFileName = `${pluginName}-0.0.1.zip`; + + await php.run({ + code: `open("${zipFileName}", ZIPARCHIVE::CREATE); $zip->addFile("/${pluginName}/index.php"); $zip->close();`, + }); + + php.rmdir(`/${pluginName}`); + + expect(php.fileExists(zipFileName)).toBe(true); + + // Create plugins folder + const rootPath = await php.documentRoot; + const pluginsPath = `${rootPath}/wp-content/plugins`; + + php.mkdir(pluginsPath); + + await runBlueprintSteps( + compileBlueprint({ + steps: [ + { + step: 'installPlugin', + pluginZipFile: { + resource: 'vfs', + path: zipFileName, + }, + options: { + activate: false, + }, + }, + ], + }), + php + ); + + php.unlink(zipFileName); + + expect(php.fileExists(`${pluginsPath}/${pluginName}`)).toBe(true); + }); +}); diff --git a/packages/playground/blueprints/src/lib/steps/install-plugin.ts b/packages/playground/blueprints/src/lib/steps/install-plugin.ts index 2da309da57..036feb8ba6 100644 --- a/packages/playground/blueprints/src/lib/steps/install-plugin.ts +++ b/packages/playground/blueprints/src/lib/steps/install-plugin.ts @@ -1,6 +1,8 @@ import { UniversalPHP } from '@php-wasm/universal'; import { StepHandler } from '.'; -import { asDOM, zipNameToHumanName } from './common'; +import { zipNameToHumanName } from './common'; +import { installAsset } from './install-asset'; +import { activatePlugin } from './activate-plugin'; /** * @inheritDoc installPlugin @@ -46,8 +48,6 @@ export interface InstallPluginOptions { /** * Installs a WordPress plugin in the Playground. - * Technically, it uses the same plugin upload form as a WordPress user - * would, and then activates the plugin if needed. * * @param playground The playground client. * @param pluginZipFile The plugin zip file. @@ -58,110 +58,104 @@ export const installPlugin: StepHandler> = async ( { pluginZipFile, options = {} }, progress? ) => { - progress?.tracker.setCaption( - `Installing the ${zipNameToHumanName(pluginZipFile?.name)} plugin` - ); - try { - const activate = 'activate' in options ? options.activate : true; + const zipFileName = pluginZipFile.name.split('/').pop() || 'plugin.zip'; + const zipNiceName = zipNameToHumanName(zipFileName); - // Upload it to WordPress - const pluginForm = await playground.request({ - url: '/wp-admin/plugin-install.php?tab=upload', + progress?.tracker.setCaption(`Installing the ${zipNiceName} plugin`); + try { + const { assetFolderPath } = await installAsset(playground, { + zipFile: pluginZipFile, + targetPath: `${await playground.documentRoot}/wp-content/plugins`, }); - const pluginFormPage = asDOM(pluginForm); - const pluginFormData = new FormData( - pluginFormPage.querySelector('.wp-upload-form')! as HTMLFormElement - ) as any; - // eslint-disable-next-line @typescript-eslint/no-unused-vars - const { pluginzip, ...postData } = Object.fromEntries( - pluginFormData.entries() - ); - const pluginInstalledResponse = await playground.request({ - url: '/wp-admin/update.php?action=upload-plugin', - method: 'POST', - formData: postData, - files: { pluginzip: pluginZipFile }, - }); + // Activate - // Activate if needed - if (activate) { - const pluginInstalledPage = asDOM(pluginInstalledResponse); - const activateButtonHref = pluginInstalledPage - .querySelector('#wpbody-content .button.button-primary')! - .attributes.getNamedItem('href')!.value; - const activatePluginUrl = new URL( - activateButtonHref, - await playground.pathToInternalUrl('/wp-admin/') - ).toString(); - await playground.request({ - url: activatePluginUrl, - }); - } + const activate = 'activate' in options ? options.activate : true; - /** - * Pair the site editor's nested iframe to the Service Worker. - * - * Without the patch below, the site editor initiates network requests that - * aren't routed through the service worker. That's a known browser issue: - * - * * https://bugs.chromium.org/p/chromium/issues/detail?id=880768 - * * https://bugzilla.mozilla.org/show_bug.cgi?id=1293277 - * * https://github.com/w3c/ServiceWorker/issues/765 - * - * The problem with iframes using srcDoc and src="about:blank" as they - * fail to inherit the root site's service worker. - * - * Gutenberg loads the site editor using