From 069930c5f39dbc8be37b63bbb8ebae278e1029a7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?3=CE=BBi=C8=AF+?= Date: Thu, 1 Jun 2023 00:27:37 +0200 Subject: [PATCH] Remove DOMParser from Blueprints: installPlugin and installTheme (#427) ## What? The goal of this pull request is to remove the use of any browser-specific API such as `DOMParser` from the Blueprints package. This allows all Blueprint steps to run on the browser and server side. ## Why? Currently, the steps `installPlugin` and `installTheme` can only be run with the Playground in the browser, not on server side with `wp-now` and Node.js, because they use the `asDOM` helper function which depends on the `DOMParser` class only available in the browser. Related discussion: - #379 The initial idea was to introduce an "isomorphic DOM" library that exports native DOM API for the browser and [jsdom](https://github.com/jsdom/jsdom) for Node.js. That would have allowed the existing implementation of `installPlugin` and `installTheme` to work as is on server side. However, after weighing the pros and cons, it was decided that it's simpler to maintain if we rewrite these steps to perform their actions without using any DOM operations. ## How? - Rewrite the Blueprint steps `installPlugin` and `installTheme` to use Playground and PHP WASM API. - Remove the `asDOM` helper function. ## Testing Instructions 1. Check out the branch. 2. Run `npx nx test playground-blueprints` --- .../blueprints/src/lib/resources.ts | 2 +- .../src/lib/steps/activate-plugin.ts | 39 +++- .../src/lib/steps/activate-theme.ts | 2 +- .../blueprints/src/lib/steps/common.ts | 36 +++- .../blueprints/src/lib/steps/index.ts | 16 +- .../blueprints/src/lib/steps/install-asset.ts | 96 ++++++++++ .../src/lib/steps/install-plugin.spec.ts | 66 +++++++ .../src/lib/steps/install-plugin.ts | 176 +++++++++--------- .../src/lib/steps/install-theme.spec.ts | 66 +++++++ .../blueprints/src/lib/steps/install-theme.ts | 76 ++------ 10 files changed, 402 insertions(+), 173 deletions(-) create mode 100644 packages/playground/blueprints/src/lib/steps/install-asset.ts create mode 100644 packages/playground/blueprints/src/lib/steps/install-plugin.spec.ts create mode 100644 packages/playground/blueprints/src/lib/steps/install-theme.spec.ts 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