Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Remove DOMParser from Blueprints: installPlugin and installTheme #427

Merged
Merged
Show file tree
Hide file tree
Changes from 43 commits
Commits
Show all changes
45 commits
Select commit Hold shift + click to select a range
815acec
Rough draft for installPlugin
eliot-akira May 24, 2023
61e33f3
Format
eliot-akira May 24, 2023
7eb0e26
Merge remote-tracking branch 'upstream/trunk'
eliot-akira May 24, 2023
304e256
Test zip package name different from extracted plugin folder name
eliot-akira May 24, 2023
9c34cf4
Remove test plugin after install
eliot-akira May 24, 2023
89a627d
Use unzip step and find extracted plugin folder name
eliot-akira May 24, 2023
d53a55a
Format
eliot-akira May 24, 2023
98cde65
playground.documentRoot getter returns a promise
eliot-akira May 24, 2023
bfaef98
Remove temporary zip file after extraction
eliot-akira May 24, 2023
3f8eb82
Format
eliot-akira May 24, 2023
f2ed29b
activatePlugin step: await playground.documentRoot because it can ret…
eliot-akira May 24, 2023
7840907
BasePHP: Use FS.rename for method mv
eliot-akira May 24, 2023
daf74b3
Improve error message when extracted plugin folder not found
eliot-akira May 24, 2023
82ad36e
Make server-side test pass: Create plugins folder; Patch VFSResource …
eliot-akira May 24, 2023
3520de3
Format
eliot-akira May 24, 2023
579c568
Add comment about Gutenberg patch becoming unnecessary as the issue h…
eliot-akira May 24, 2023
534d10f
Rewrite installTheme step
eliot-akira May 25, 2023
0615596
Format
eliot-akira May 25, 2023
11bee55
Find plugin entry file to pass to activatePlugin step
eliot-akira May 25, 2023
6080b0d
activateTheme step: await playground.documentRoot because it can retu…
eliot-akira May 25, 2023
fa4f559
VFSResource: Clean up workaround
eliot-akira May 25, 2023
3fce74e
Tests: Clarify comment about zip file names being different from extr…
eliot-akira May 25, 2023
2b8d31b
Remove asDOM helper function, which used DOMParser available only on …
eliot-akira May 25, 2023
e5ae9b5
Remove unnecessary timeout value for tests
eliot-akira May 26, 2023
3dbd17a
Create findPluginEntryFile function for reusable logic
eliot-akira May 26, 2023
ca7b8c1
Remove comment about using plugin/theme upload form, since it's no lo…
eliot-akira May 26, 2023
9eed478
Merge branch 'trunk' into remove-dom-parser-from-blueprints
eliot-akira May 26, 2023
56cb9d3
Extract common logic between install plugin/theme into installAsset f…
eliot-akira May 27, 2023
28f7163
Format
eliot-akira May 27, 2023
dc05730
Install asset: Clean up temporary folder before throwing an error
eliot-akira May 27, 2023
81bb68f
Clarify comment about JSDOM's `File` class missing `arrayBuffer` meth…
eliot-akira May 30, 2023
a368526
installAsset: Make generic by replacing assetType with targetPath; Us…
eliot-akira May 30, 2023
7d4f233
Format
eliot-akira May 30, 2023
30d5c9a
activatePlugin step: Accept path to plugin directory and find plugin …
eliot-akira May 30, 2023
9ed3d75
installAsset: Clean up temporary folder and zip file on success or an…
eliot-akira May 30, 2023
3b67796
Polyfill the File class in JSDOM which lacks arrayBuffer() method
eliot-akira May 31, 2023
c121866
Format
eliot-akira May 31, 2023
661eda9
activatePlugin: Simplify logic to find plugin entry file
eliot-akira May 31, 2023
3cbea6a
activatePlugin: Simplify logic to find plugin entry file
eliot-akira May 31, 2023
4487c3a
activatePlugin: Clarify accepted value for pluginPath option (absolut…
eliot-akira May 31, 2023
6b0f60f
Merge branch 'trunk' into remove-dom-parser-from-blueprints
eliot-akira May 31, 2023
1671b88
Clean up after merge from trunk
eliot-akira May 31, 2023
3526f57
Format
eliot-akira May 31, 2023
6f81a34
activatePlugin: Use error code for entry file not found; Rethrow any …
eliot-akira May 31, 2023
977230f
Format
eliot-akira May 31, 2023
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
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
32 changes: 23 additions & 9 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 @@ -32,8 +33,21 @@ export const activatePlugin: StepHandler<ActivatePluginStep> = async (
}
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;
}
eliot-akira marked this conversation as resolved.
Show resolved Hide resolved
}
throw new Exception('Could not find plugin entry file.');
adamziel marked this conversation as resolved.
Show resolved Hide resolved
`,
});
};
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[];
Copy link
Collaborator

@adamziel adamziel May 31, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ideally this would live in tests, but that makes importing Blueprints in other packages tricky as every package would have to polyfill it. I think this is a neat solution, thank you for taking the time to explore this 👍

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(
Copy link
Collaborator

@adamziel adamziel May 31, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I initially had this in mind as an internal utility to aid other steps, I wonder whether this makes sense as its own step 🤔

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The term "asset" seems ambiguous as part of a public API. Internally it means:

  • a zip package
  • with a single folder of files in it
  • to be extracted to a target path

Technically it's generic, not limited to plugins and themes - but I can't imagine what else would be considered "assets" that has the above characteristics.

There could be an installMuPlugin blueprint step, same as for plugins but with target mu-plugins.

Media files could be another kind of asset. Maybe an installMedia step that uses installAssets. Unlike plugins/themes, it would not require a single container folder. And the step will need to run additional PHP to upload the files into the media library.

So I think installAsset by itself may not be useful to expose as a blueprint step, and can probably stay an internal helper function.

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Oh sorry I got confused. It is in the steps directory and kind of looks like a step declaration, but it actually is an internal helper. My bad!

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It would be nice to put each step in a separate file and move helpers somewhere distinct, maybe a subdirectory? Let's figure this out separately

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);
});
});