Skip to content

Commit

Permalink
Remove DOMParser from Blueprints: installPlugin and installTheme (#427)
Browse files Browse the repository at this point in the history
## 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`
  • Loading branch information
eliot-akira committed May 31, 2023
1 parent 424f24b commit 069930c
Show file tree
Hide file tree
Showing 10 changed files with 402 additions and 173 deletions.
2 changes: 1 addition & 1 deletion packages/playground/blueprints/src/lib/resources.ts
Expand Up @@ -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',
Expand Down
39 changes: 29 additions & 10 deletions packages/playground/blueprints/src/lib/steps/activate-plugin.ts
Expand Up @@ -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<ActivatePluginStep> = 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)
Expand All @@ -30,10 +31,28 @@ export const activatePlugin: StepHandler<ActivatePluginStep> = async (
`Required WordPress files do not exist: ${requiredFiles.join(', ')}`
);
}
await playground.run({

const result = await playground.run({
code: `<?php
${requiredFiles.map((file) => `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.');
}
};
Expand Up @@ -17,7 +17,7 @@ export const activateTheme: StepHandler<ActivateThemeStep> = 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}`
Expand Down
36 changes: 30 additions & 6 deletions 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()
Expand All @@ -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<ArrayBuffer> {
return this.buffers[0] as ArrayBuffer;
}
}

const FileWithArrayBuffer =
File.prototype.arrayBuffer instanceof Function ? File : FilePolyfill;

export { FileWithArrayBuffer as File };
16 changes: 9 additions & 7 deletions packages/playground/blueprints/src/lib/steps/index.ts
Expand Up @@ -96,17 +96,19 @@ export type {
WriteFileStep,
};

/**
* Progress reporting details.
*/
export type StepProgress = {
tracker: ProgressTracker;
initialCaption?: string;
};

export type StepHandler<S extends GenericStep<File>> = (
/**
* A PHP instance or Playground client.
*/
php: UniversalPHP,
args: Omit<S, 'step'>,
/**
* Progress reporting details.
*/
progressArgs?: {
tracker: ProgressTracker;
initialCaption?: string;
}
progressArgs?: StepProgress
) => any;
96 changes: 96 additions & 0 deletions 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
*
* <code>
* const targetPath = `${await playground.documentRoot}/wp-content/plugins`;
* </code>
*/
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;
}
}
@@ -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: `<?php $zip = new ZipArchive(); $zip->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);
});
});

0 comments on commit 069930c

Please sign in to comment.