Skip to content

Commit

Permalink
Blueprints: Add ifAlreadyInstalled to installPlugin and installTheme …
Browse files Browse the repository at this point in the history
…steps (#1244)

Adds an `ifAlreadyInstalled?: 'overwrite' | 'skip' | 'error'` option to
installPlugin and installTheme steps. It defaults to `overwrite`.

Consider the following Blueprint:

```json
{
    "preferredVersions": {
        "php": "latest",
        "wp": "6.4"
    },
    "steps": [
        {
            "step": "installTheme",
            "themeZipFile": {
                "resource": "wordpress.org/themes",
                "slug": "twentytwentyfour"
            }
        }
    ]
}
```

Before this PR, it would result in an error. After this PR, the
installation just works. If the Blueprint author explicitly wants the
installation to fail, they can specify the `ifAlreadyInstalled` option:

```json
{
    "steps": [
        {
            "step": "installTheme",
            "themeZipFile": {
                "resource": "wordpress.org/themes",
                "slug": "twentytwentyfour"
            },
            "ifAlreadyInstalled": "skip" // or "error"
        }
    ]
}
```

## Motivation

Installing a plugin or theme over a currently installed one is a common
gotcha. Currently it results in an error and blocks the Blueprint
execution. This behavior is, however, often undesirable as it prevents
having a single Blueprint that installs a twentytwentyfour theme on
different versions of WordPress.

An addition of the `ifAlreadyInstalled` option puts the Blueprint author
in control and provides a sensible default behavior where the
installation will "just work" by replacing the already installed version
of the plugin or theme.

Closes #1157
Related to WordPress/blueprints#19

 ## Testing instructions

Confirm the unit tests pass

cc @bgrgicak @brandonpayton
  • Loading branch information
adamziel authored Apr 16, 2024
1 parent 9c3d258 commit 6e93da9
Show file tree
Hide file tree
Showing 6 changed files with 270 additions and 151 deletions.
37 changes: 10 additions & 27 deletions packages/playground/blueprints/public/blueprint-schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -546,33 +546,6 @@
},
"required": ["file", "step"]
},
{
"type": "object",
"additionalProperties": false,
"properties": {
"progress": {
"type": "object",
"properties": {
"weight": {
"type": "number"
},
"caption": {
"type": "string"
}
},
"additionalProperties": false
},
"step": {
"type": "string",
"const": "importFile"
},
"file": {
"$ref": "#/definitions/FileReference",
"description": "The file to import"
}
},
"required": ["file", "step"]
},
{
"type": "object",
"additionalProperties": false,
Expand Down Expand Up @@ -620,6 +593,11 @@
},
"additionalProperties": false
},
"ifAlreadyInstalled": {
"type": "string",
"enum": ["overwrite", "skip", "error"],
"description": "What to do if the asset already exists."
},
"step": {
"type": "string",
"const": "installPlugin",
Expand Down Expand Up @@ -652,6 +630,11 @@
},
"additionalProperties": false
},
"ifAlreadyInstalled": {
"type": "string",
"enum": ["overwrite", "skip", "error"],
"description": "What to do if the asset already exists."
},
"step": {
"type": "string",
"const": "installTheme",
Expand Down
34 changes: 33 additions & 1 deletion packages/playground/blueprints/src/lib/steps/install-asset.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,14 +16,22 @@ export interface InstallAssetOptions {
* </code>
*/
targetPath: string;
/**
* What to do if the asset already exists.
*/
ifAlreadyInstalled?: 'overwrite' | 'skip' | 'error';
}

/**
* Install asset: Extract folder from zip file and move it to target
*/
export async function installAsset(
playground: UniversalPHP,
{ targetPath, zipFile }: InstallAssetOptions
{
targetPath,
zipFile,
ifAlreadyInstalled = 'overwrite',
}: InstallAssetOptions
): Promise<{
assetFolderPath: string;
assetFolderName: string;
Expand Down Expand Up @@ -75,6 +83,30 @@ export async function installAsset(

// Move asset folder to target path
const assetFolderPath = `${targetPath}/${assetFolderName}`;

// Handle the scenario when the asset is already installed.
if (await playground.fileExists(assetFolderPath)) {
if (!(await playground.isDir(assetFolderPath))) {
throw new Error(
`Cannot install asset ${assetFolderName} to ${assetFolderPath} because a file with the same name already exists. Note it's a file, not a directory! Is this by mistake?`
);
}
if (ifAlreadyInstalled === 'overwrite') {
await playground.rmdir(assetFolderPath, {
recursive: true,
});
} else if (ifAlreadyInstalled === 'skip') {
return {
assetFolderPath,
assetFolderName,
};
} else {
throw new Error(
`Cannot install asset ${assetFolderName} to ${targetPath} because it already exists and ` +
`the ifAlreadyInstalled option was set to ${ifAlreadyInstalled}`
);
}
}
await playground.mv(tmpAssetPath, assetFolderPath);

return {
Expand Down
220 changes: 133 additions & 87 deletions packages/playground/blueprints/src/lib/steps/install-plugin.spec.ts
Original file line number Diff line number Diff line change
@@ -1,113 +1,159 @@
import { NodePHP } from '@php-wasm/node';
import { compileBlueprint, runBlueprintSteps } from '../compile';
import { RecommendedPHPVersion } from '@wp-playground/wordpress';
import { installPlugin } from './install-plugin';
import { phpVar } from '@php-wasm/util';

async function zipFiles(
php: NodePHP,
fileName: string,
files: Record<string, string>
) {
const zipFileName = 'test.zip';
const zipFilePath = `/${zipFileName}`;

await php.run({
code: `<?php $zip = new ZipArchive();
$zip->open("${zipFileName}", ZIPARCHIVE::CREATE);
$files = ${phpVar(files)};
foreach($files as $path => $content) {
$zip->addFromString($path, $content);
}
$zip->close();`,
});

describe('Blueprint step installPlugin', () => {
let php: NodePHP;
beforeEach(async () => {
php = await NodePHP.load(RecommendedPHPVersion, {
const zip = await php.readFileAsBuffer(zipFilePath);
php.unlink(zipFilePath);
return new File([zip], fileName);
}

describe('Blueprint step installPlugin – without a root-level folder', () => {
it('should install a plugin even when it is zipped directly without a root-level folder', async () => {
const php = await NodePHP.load(RecommendedPHPVersion, {
requestHandler: {
documentRoot: '/wordpress',
},
});
});

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 rootPath = 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
);
// Create test plugin
const pluginName = 'test-plugin';

php.unlink(zipFileName);
await installPlugin(php, {
pluginZipFile: await zipFiles(
php,
// Note the ZIP filename is different from plugin folder name
`${pluginName}-0.0.1.zip`,
{
'index.php': `/**\n * Plugin Name: Test Plugin`,
}
),
ifAlreadyInstalled: 'overwrite',
options: {
activate: false,
},
});

expect(php.fileExists(`${pluginsPath}/${pluginName}`)).toBe(true);
expect(php.fileExists(`${pluginsPath}/${pluginName}-0.0.1`)).toBe(true);
});
});

it('should install a plugin even when it is zipped directly without a root-level folder', async () => {
// Create test plugin
const pluginName = 'test-plugin';

php.writeFile('/index.php', `/**\n * Plugin Name: Test Plugin`);
describe('Blueprint step installPlugin', () => {
let php: NodePHP;
// Create plugins folder
let rootPath = '';
let installedPluginPath = '';
const pluginName = 'test-plugin';
const zipFileName = `${pluginName}-0.0.1.zip`;
beforeEach(async () => {
php = await NodePHP.load(RecommendedPHPVersion, {
requestHandler: {
documentRoot: '/wordpress',
},
});
rootPath = php.documentRoot;
php.mkdir(`${rootPath}/wp-content/plugins`);
installedPluginPath = `${rootPath}/wp-content/plugins/${pluginName}`;
});

// Note the package name is different from plugin folder name
const zipFileName = `${pluginName}-0.0.1.zip`;
afterEach(() => {
php.exit();
});

await php.run({
code: `<?php $zip = new ZipArchive();
$zip->open("${zipFileName}", ZIPARCHIVE::CREATE);
$zip->addFile("/index.php");
$zip->close();`,
it('should install a plugin', async () => {
await installPlugin(php, {
pluginZipFile: await zipFiles(php, zipFileName, {
[`${pluginName}/index.php`]: `/**\n * Plugin Name: Test Plugin`,
}),
ifAlreadyInstalled: 'overwrite',
options: {
activate: false,
},
});
expect(php.fileExists(installedPluginPath)).toBe(true);
});

expect(php.fileExists(zipFileName)).toBe(true);
describe('ifAlreadyInstalled option', () => {
beforeEach(async () => {
await installPlugin(php, {
pluginZipFile: await zipFiles(php, zipFileName, {
[`${pluginName}/index.php`]: `/**\n * Plugin Name: Test Plugin`,
}),
ifAlreadyInstalled: 'overwrite',
options: {
activate: false,
},
});
});

// Create plugins folder
const rootPath = await php.documentRoot;
const pluginsPath = `${rootPath}/wp-content/plugins`;
it('ifAlreadyInstalled=overwrite should overwrite the plugin if it already exists', async () => {
// Install the plugin
await installPlugin(php, {
pluginZipFile: await zipFiles(php, zipFileName, {
[`${pluginName}/index.php`]: `/**\n * Plugin Name: A different Plugin`,
}),
ifAlreadyInstalled: 'overwrite',
options: {
activate: false,
},
});
expect(
php.readFileAsText(`${installedPluginPath}/index.php`)
).toContain('Plugin Name: A different Plugin');
});

php.mkdir(pluginsPath);
it('ifAlreadyInstalled=skip should skip the plugin if it already exists', async () => {
// Install the plugin
await installPlugin(php, {
pluginZipFile: await zipFiles(php, zipFileName, {
[`${pluginName}/index.php`]: `/**\n * Plugin Name: A different Plugin`,
}),
ifAlreadyInstalled: 'skip',
options: {
activate: false,
},
});
expect(
php.readFileAsText(`${installedPluginPath}/index.php`)
).toContain('Plugin Name: Test Plugin');
});

await runBlueprintSteps(
compileBlueprint({
steps: [
{
step: 'installPlugin',
pluginZipFile: {
resource: 'vfs',
path: zipFileName,
},
options: {
activate: false,
},
it('ifAlreadyInstalled=error should throw an error if the plugin already exists', async () => {
// Install the plugin
await expect(
installPlugin(php, {
pluginZipFile: await zipFiles(php, zipFileName, {
[`${pluginName}/index.php`]: `/**\n * Plugin Name: A different Plugin`,
}),
ifAlreadyInstalled: 'error',
options: {
activate: false,
},
],
}),
php
);

php.unlink(zipFileName);
expect(php.fileExists(`${pluginsPath}/${pluginName}-0.0.1`)).toBe(true);
})
).rejects.toThrowError();
});
});
});
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { StepHandler } from '.';
import { installAsset } from './install-asset';
import { InstallAssetOptions, installAsset } from './install-asset';
import { activatePlugin } from './activate-plugin';
import { zipNameToHumanName } from '../utils/zip-name-to-human-name';

Expand All @@ -23,7 +23,8 @@ import { zipNameToHumanName } from '../utils/zip-name-to-human-name';
* }
* </code>
*/
export interface InstallPluginStep<ResourceType> {
export interface InstallPluginStep<ResourceType>
extends Pick<InstallAssetOptions, 'ifAlreadyInstalled'> {
/**
* The step identifier.
*/
Expand Down Expand Up @@ -54,14 +55,15 @@ export interface InstallPluginOptions {
*/
export const installPlugin: StepHandler<InstallPluginStep<File>> = async (
playground,
{ pluginZipFile, options = {} },
{ pluginZipFile, ifAlreadyInstalled, options = {} },
progress?
) => {
const zipFileName = pluginZipFile.name.split('/').pop() || 'plugin.zip';
const zipNiceName = zipNameToHumanName(zipFileName);

progress?.tracker.setCaption(`Installing the ${zipNiceName} plugin`);
const { assetFolderPath } = await installAsset(playground, {
ifAlreadyInstalled,
zipFile: pluginZipFile,
targetPath: `${await playground.documentRoot}/wp-content/plugins`,
});
Expand Down
Loading

0 comments on commit 6e93da9

Please sign in to comment.