From 6e93da902480589a589f4b47922b39b7e07b74f5 Mon Sep 17 00:00:00 2001 From: Adam Zielinski Date: Tue, 16 Apr 2024 10:53:44 +0200 Subject: [PATCH] Blueprints: Add ifAlreadyInstalled to installPlugin and installTheme 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 https://github.com/WordPress/wordpress-playground/issues/1157 Related to https://github.com/adamziel/blueprints/pull/19 ## Testing instructions Confirm the unit tests pass cc @bgrgicak @brandonpayton --- .../blueprints/public/blueprint-schema.json | 37 +-- .../blueprints/src/lib/steps/install-asset.ts | 34 ++- .../src/lib/steps/install-plugin.spec.ts | 220 +++++++++++------- .../src/lib/steps/install-plugin.ts | 8 +- .../src/lib/steps/install-theme.spec.ts | 114 ++++++--- .../blueprints/src/lib/steps/install-theme.ts | 8 +- 6 files changed, 270 insertions(+), 151 deletions(-) diff --git a/packages/playground/blueprints/public/blueprint-schema.json b/packages/playground/blueprints/public/blueprint-schema.json index 374f0a7a7a..be3b07a47a 100644 --- a/packages/playground/blueprints/public/blueprint-schema.json +++ b/packages/playground/blueprints/public/blueprint-schema.json @@ -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, @@ -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", @@ -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", diff --git a/packages/playground/blueprints/src/lib/steps/install-asset.ts b/packages/playground/blueprints/src/lib/steps/install-asset.ts index ab34418d93..4d21b62b62 100644 --- a/packages/playground/blueprints/src/lib/steps/install-asset.ts +++ b/packages/playground/blueprints/src/lib/steps/install-asset.ts @@ -16,6 +16,10 @@ export interface InstallAssetOptions { * */ targetPath: string; + /** + * What to do if the asset already exists. + */ + ifAlreadyInstalled?: 'overwrite' | 'skip' | 'error'; } /** @@ -23,7 +27,11 @@ export interface InstallAssetOptions { */ export async function installAsset( playground: UniversalPHP, - { targetPath, zipFile }: InstallAssetOptions + { + targetPath, + zipFile, + ifAlreadyInstalled = 'overwrite', + }: InstallAssetOptions ): Promise<{ assetFolderPath: string; assetFolderName: string; @@ -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 { diff --git a/packages/playground/blueprints/src/lib/steps/install-plugin.spec.ts b/packages/playground/blueprints/src/lib/steps/install-plugin.spec.ts index 614c26d78e..834cf9db01 100644 --- a/packages/playground/blueprints/src/lib/steps/install-plugin.spec.ts +++ b/packages/playground/blueprints/src/lib/steps/install-plugin.spec.ts @@ -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 +) { + const zipFileName = 'test.zip'; + const zipFilePath = `/${zipFileName}`; + + await php.run({ + code: `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: `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: `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(); + }); }); }); diff --git a/packages/playground/blueprints/src/lib/steps/install-plugin.ts b/packages/playground/blueprints/src/lib/steps/install-plugin.ts index 70dbee13a5..74fac31f21 100644 --- a/packages/playground/blueprints/src/lib/steps/install-plugin.ts +++ b/packages/playground/blueprints/src/lib/steps/install-plugin.ts @@ -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'; @@ -23,7 +23,8 @@ import { zipNameToHumanName } from '../utils/zip-name-to-human-name'; * } * */ -export interface InstallPluginStep { +export interface InstallPluginStep + extends Pick { /** * The step identifier. */ @@ -54,7 +55,7 @@ export interface InstallPluginOptions { */ export const installPlugin: StepHandler> = async ( playground, - { pluginZipFile, options = {} }, + { pluginZipFile, ifAlreadyInstalled, options = {} }, progress? ) => { const zipFileName = pluginZipFile.name.split('/').pop() || 'plugin.zip'; @@ -62,6 +63,7 @@ export const installPlugin: StepHandler> = async ( progress?.tracker.setCaption(`Installing the ${zipNiceName} plugin`); const { assetFolderPath } = await installAsset(playground, { + ifAlreadyInstalled, zipFile: pluginZipFile, targetPath: `${await playground.documentRoot}/wp-content/plugins`, }); diff --git a/packages/playground/blueprints/src/lib/steps/install-theme.spec.ts b/packages/playground/blueprints/src/lib/steps/install-theme.spec.ts index 900e549688..0a976614f7 100644 --- a/packages/playground/blueprints/src/lib/steps/install-theme.spec.ts +++ b/packages/playground/blueprints/src/lib/steps/install-theme.spec.ts @@ -1,20 +1,25 @@ import { NodePHP } from '@php-wasm/node'; -import { compileBlueprint, runBlueprintSteps } from '../compile'; import { RecommendedPHPVersion } from '@wp-playground/wordpress'; +import { installTheme } from './install-theme'; describe('Blueprint step installTheme', () => { let php: NodePHP; + let zipFileName = ''; + let zipFilePath = ''; + let rootPath = ''; + let themesPath = ''; beforeEach(async () => { php = await NodePHP.load(RecommendedPHPVersion, { requestHandler: { documentRoot: '/wordpress', }, }); - }); - it('should install a theme', async () => { - // Create test theme + rootPath = php.documentRoot; + themesPath = `${rootPath}/wp-content/themes`; + php.mkdir(themesPath); + // Create test theme const themeName = 'test-theme'; php.mkdir(`/${themeName}`); @@ -24,42 +29,91 @@ describe('Blueprint step installTheme', () => { ); // Note the package name is different from theme folder name - const zipFileName = `${themeName}-0.0.1.zip`; + zipFileName = `${themeName}-0.0.1.zip`; + zipFilePath = `${themesPath}/${zipFileName}`; await php.run({ - code: `open("${zipFileName}", ZIPARCHIVE::CREATE); $zip->addFile("/${themeName}/index.php"); $zip->close();`, + code: `open("${zipFilePath}", ZIPARCHIVE::CREATE); $zip->addFile("/${themeName}/index.php"); $zip->close();`, }); php.rmdir(`/${themeName}`); - expect(php.fileExists(zipFileName)).toBe(true); + expect(php.fileExists(zipFilePath)).toBe(true); + }); - // Create themes folder - const rootPath = await php.documentRoot; - const themesPath = `${rootPath}/wp-content/themes`; + afterEach(() => { + php.exit(); + }); - php.mkdir(themesPath); + it('should install a theme', async () => { + await installTheme(php, { + themeZipFile: new File( + [php.readFileAsBuffer(zipFilePath)], + zipFileName + ), + ifAlreadyInstalled: 'overwrite', + options: { + activate: false, + }, + }); + expect(php.fileExists(zipFilePath)).toBe(true); + }); - await runBlueprintSteps( - compileBlueprint({ - steps: [ - { - step: 'installTheme', - themeZipFile: { - resource: 'vfs', - path: zipFileName, - }, - options: { - activate: false, - }, - }, - ], - }), - php - ); + describe('ifAlreadyInstalled option', () => { + beforeEach(async () => { + await installTheme(php, { + themeZipFile: new File( + [php.readFileAsBuffer(zipFilePath)], + zipFileName + ), + ifAlreadyInstalled: 'error', + options: { + activate: false, + }, + }); + }); - php.unlink(zipFileName); + it('ifAlreadyInstalled=ovewrite should overwrite the theme if the theme already exists', async () => { + await installTheme(php, { + themeZipFile: new File( + [php.readFileAsBuffer(zipFilePath)], + zipFileName + ), + ifAlreadyInstalled: 'overwrite', + options: { + activate: false, + }, + }); + expect(php.fileExists(zipFilePath)).toBe(true); + }); + + it('ifAlreadyInstalled=skip should skip the theme if the theme already exists', async () => { + await installTheme(php, { + themeZipFile: new File( + ['invalid zip bytes, unpacking should not attempted'], + zipFileName + ), + ifAlreadyInstalled: 'skip', + options: { + activate: false, + }, + }); + expect(php.fileExists(zipFilePath)).toBe(true); + }); - expect(php.fileExists(`${themesPath}/${themeName}`)).toBe(true); + it('ifAlreadyInstalled=error should throw an error if the theme already exists', async () => { + await expect( + installTheme(php, { + themeZipFile: new File( + [php.readFileAsBuffer(zipFilePath)], + zipFileName + ), + ifAlreadyInstalled: 'error', + options: { + activate: false, + }, + }) + ).rejects.toThrow(); + }); }); }); diff --git a/packages/playground/blueprints/src/lib/steps/install-theme.ts b/packages/playground/blueprints/src/lib/steps/install-theme.ts index 6e319affa0..b666f04b32 100644 --- a/packages/playground/blueprints/src/lib/steps/install-theme.ts +++ b/packages/playground/blueprints/src/lib/steps/install-theme.ts @@ -1,5 +1,5 @@ import { StepHandler } from '.'; -import { installAsset } from './install-asset'; +import { InstallAssetOptions, installAsset } from './install-asset'; import { activateTheme } from './activate-theme'; import { zipNameToHumanName } from '../utils/zip-name-to-human-name'; @@ -22,7 +22,8 @@ import { zipNameToHumanName } from '../utils/zip-name-to-human-name'; * } * */ -export interface InstallThemeStep { +export interface InstallThemeStep + extends Pick { /** * The step identifier. */ @@ -58,13 +59,14 @@ export interface InstallThemeOptions { */ export const installTheme: StepHandler> = async ( playground, - { themeZipFile, options = {} }, + { themeZipFile, ifAlreadyInstalled, options = {} }, progress ) => { const zipNiceName = zipNameToHumanName(themeZipFile.name); progress?.tracker.setCaption(`Installing the ${zipNiceName} theme`); const { assetFolderName } = await installAsset(playground, { + ifAlreadyInstalled, zipFile: themeZipFile, targetPath: `${await playground.documentRoot}/wp-content/themes`, });