diff --git a/CHANGELOG.md b/CHANGELOG.md index 1b551604b0..c13c231ade 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,10 @@ ## Unreleased +### New features + +- [#2328: New Plugin Details page](https://github.com/alphagov/govuk-prototype-kit/pull/2328) + ## 13.13.1 ### Fixes diff --git a/__tests__/spec/force-https-redirect.js b/__tests__/spec/force-https-redirect.js index e41b9600da..4f002ecd18 100644 --- a/__tests__/spec/force-https-redirect.js +++ b/__tests__/spec/force-https-redirect.js @@ -18,10 +18,6 @@ process.env.KIT_PROJECT_DIR = testDir process.env.NODE_ENV = 'production' process.env.USE_HTTPS = 'true' -jest.mock('../../lib/plugins/packages.js', () => { - return {} -}) - const app = require('../../server.js') describe('The Prototype Kit - force HTTPS redirect functionality', () => { diff --git a/cypress/e2e/plugins/2-prototype-kit-plugin-tests/handle-plugin-update-when-a-dependency-is-now-required.cypress.js b/cypress/e2e/plugins/0-complex-dependent-packages/handle-plugin-update-when-a-dependency-is-now-required.cypress.js similarity index 78% rename from cypress/e2e/plugins/2-prototype-kit-plugin-tests/handle-plugin-update-when-a-dependency-is-now-required.cypress.js rename to cypress/e2e/plugins/0-complex-dependent-packages/handle-plugin-update-when-a-dependency-is-now-required.cypress.js index dc62095b6a..9ebbe6f187 100644 --- a/cypress/e2e/plugins/2-prototype-kit-plugin-tests/handle-plugin-update-when-a-dependency-is-now-required.cypress.js +++ b/cypress/e2e/plugins/0-complex-dependent-packages/handle-plugin-update-when-a-dependency-is-now-required.cypress.js @@ -25,6 +25,8 @@ describe('Handle a plugin update', () => { it('when a dependency is now required', () => { cy.task('createFile', { filename: additionalScssPath, data: additionalScssContents }) + + waitForApplication(pluginsPage) installPlugin(plugin, pluginVersion) uninstallPlugin(dependencyPlugin) @@ -32,18 +34,27 @@ describe('Handle a plugin update', () => { cy.get('[data-plugin-group-status="search"]') .find(`[data-plugin-package-name="${dependencyPlugin}"]`) - .find('button') + .find('.plugin-details-link') + .contains(dependencyPluginName) + .click() + + cy.get('.govuk-prototype-kit-plugin-install-button', { timeout: 4000 }) .contains('Install') + waitForApplication(pluginsPage) + cy.get('#installed-plugins-link').click() cy.get('[data-plugin-group-status="installed"]') .find(`[data-plugin-package-name="${plugin}"]`) - .find('button') + .find('.plugin-details-link') + .click() + + cy.get('.govuk-prototype-kit-plugin-update-button', { timeout: 4000 }) .contains('Update') .click() - cy.get('#plugin-action-confirmation') + cy.get('#dependency-heading', { timeout: 4000 }) .find('ul') .contains(dependencyPluginName) @@ -54,6 +65,10 @@ describe('Handle a plugin update', () => { .contains('Update complete') cy.get('#instructions-complete a') + .contains('Back to plugin details') + .click() + + cy.get('.govuk-back-link', { timeout: 5000 }) .contains('Back to plugins') .click() diff --git a/cypress/e2e/plugins/0-mock-plugin-tests/install-plugin-via-cli-test.cypress.js b/cypress/e2e/plugins/1-mock-plugin-tests/install-plugin-via-cli-test.cypress.js similarity index 100% rename from cypress/e2e/plugins/0-mock-plugin-tests/install-plugin-via-cli-test.cypress.js rename to cypress/e2e/plugins/1-mock-plugin-tests/install-plugin-via-cli-test.cypress.js diff --git a/cypress/e2e/plugins/0-mock-plugin-tests/install-plugin-via-ui-test.cypress.js b/cypress/e2e/plugins/1-mock-plugin-tests/install-plugin-via-ui-test.cypress.js similarity index 70% rename from cypress/e2e/plugins/0-mock-plugin-tests/install-plugin-via-ui-test.cypress.js rename to cypress/e2e/plugins/1-mock-plugin-tests/install-plugin-via-ui-test.cypress.js index 8c3e66f539..2964191b32 100644 --- a/cypress/e2e/plugins/0-mock-plugin-tests/install-plugin-via-ui-test.cypress.js +++ b/cypress/e2e/plugins/1-mock-plugin-tests/install-plugin-via-ui-test.cypress.js @@ -1,14 +1,14 @@ -const { restoreStarterFiles, log } = require('../../utils') +const { restoreStarterFiles, log, waitForApplication } = require('../../utils') const path = require('path') const { loadTemplatesPage, - managePluginsPagePath, - performPluginAction + performPluginAction, managePrototypeContextPath } = require('../plugin-utils') const panelCompleteQuery = '[aria-live="polite"] #panel-complete' const fixtures = path.join(Cypress.config('fixturesFolder')) const dependentPlugin = 'plugin-fee' +const dependentPluginPackageName = 'plugin-fee' const dependentPluginName = 'Plugin Fee' const dependentPluginLocation = [fixtures, 'plugins', dependentPlugin].join(Cypress.config('pathSeparator')) const dependencyPlugin = 'govuk-frontend' @@ -16,6 +16,10 @@ const dependencyPluginName = 'GOV.UK Frontend' describe('Install and uninstall Local Plugin via UI Test', async () => { afterEach(restoreStarterFiles) + beforeEach(() => () => { + cy.exec(`cd ${Cypress.env('projectFolder')} && npm uninstall ${dependentPluginPackageName}`) + cy.task('addToConfigJson', { allowGovukFrontendUninstall: true }) + }) it(`The ${dependentPlugin} plugin will be installed`, () => { log(`The ${dependentPlugin} plugin templates are not available`) @@ -26,16 +30,11 @@ describe('Install and uninstall Local Plugin via UI Test', async () => { log(`Install the ${dependentPlugin} plugin`) cy.task('waitUntilAppRestarts') - cy.visit(`${managePluginsPagePath}/install?package=${encodeURIComponent(dependentPlugin)}&version=${encodeURIComponent(dependentPluginLocation)}`) + cy.visit(`${managePrototypeContextPath}/plugin/fs:${encodeURIComponent(dependentPluginLocation)}/install`) cy.get('#plugin-action-button').click() cy.get(panelCompleteQuery, { timeout: 20000 }) .should('be.visible') - cy.get('a').contains('Back to plugins').click() - - cy.get('#installed-plugins-link').click() - - cy.get(`[data-plugin-package-name="${dependentPlugin}"] button`).contains('Uninstall') // ------------------------ @@ -46,25 +45,30 @@ describe('Install and uninstall Local Plugin via UI Test', async () => { // ------------------------ log('Uninstall the local plugin') - cy.get('a').contains('Plugins').click() - cy.get('#installed-plugins-link').click() + cy.visit('') + cy.visit('/manage-prototype/plugins-installed') cy.get(`[data-plugin-package-name="${dependentPlugin}"]`) .scrollIntoView() - .find('button') - .contains('Uninstall') + .find('.plugin-details-link') .click() + cy.visit(`${managePrototypeContextPath}/plugin/fs:${encodeURIComponent(dependentPluginLocation)}/uninstall`) + cy.get('#plugin-action-button').click() + performPluginAction('uninstall', dependentPlugin, dependentPluginName) + waitForApplication() + // ------------------------ log(`The ${dependentPlugin} plugin templates are not available`) - cy.get('a').contains('Templates').click() + cy.visit('http://localhost:3000/manage-prototype/templates', { retryOnNetworkFailure: true, timeout: 4000 }) cy.get(`[data-plugin-package-name="${dependentPlugin}"]`).should('not.exist') }) it(`The ${dependentPlugin} plugin and ${dependencyPlugin} will be installed`, () => { + cy.task('addToConfigJson', { allowGovukFrontendUninstall: true }) log(`The ${dependentPlugin} plugin templates are not available`) loadTemplatesPage() cy.get(`[data-plugin-package-name="${dependentPlugin}"]`).should('not.exist') @@ -72,16 +76,16 @@ describe('Install and uninstall Local Plugin via UI Test', async () => { // ------------------------ log(`Uninstall the ${dependencyPlugin} to force the UI to ask for it later`) - cy.task('waitUntilAppRestarts') - cy.visit(`${managePluginsPagePath}/uninstall?package=${encodeURIComponent(dependencyPlugin)}`) + waitForApplication() + cy.visit(`${managePrototypeContextPath}/plugin/installed:${encodeURIComponent(dependencyPlugin)}/uninstall`) cy.get('#plugin-action-button').click() performPluginAction('uninstall', dependencyPlugin, dependencyPluginName) // ------------------------ log(`Install the ${dependentPlugin} plugin and the ${dependencyPlugin}`) - cy.task('waitUntilAppRestarts') - cy.visit(`${managePluginsPagePath}/install?package=${encodeURIComponent(dependentPlugin)}&version=${encodeURIComponent(dependentPluginLocation)}`) + waitForApplication() + cy.visit(`${managePrototypeContextPath}/plugin/fs:${encodeURIComponent(dependentPluginLocation)}/install`) // Should list the dependency plugin cy.get('li').contains(dependencyPluginName) cy.get('#plugin-action-button').click() @@ -96,8 +100,8 @@ describe('Install and uninstall Local Plugin via UI Test', async () => { // ------------------------ log('Uninstall the dependency plugin') - cy.task('waitUntilAppRestarts') - cy.visit(`${managePluginsPagePath}/uninstall?package=${encodeURIComponent(dependencyPlugin)}`) + waitForApplication() + cy.visit(`${managePrototypeContextPath}/plugin/installed:${encodeURIComponent(dependencyPlugin)}/uninstall`) // Should list the dependent plugin cy.get('li').contains(dependentPluginName) cy.get('#plugin-action-button').click() diff --git a/cypress/e2e/plugins/0-mock-plugin-tests/multi-combined-plugin-test.cypress.js b/cypress/e2e/plugins/1-mock-plugin-tests/multi-combined-plugin-test.cypress.js similarity index 100% rename from cypress/e2e/plugins/0-mock-plugin-tests/multi-combined-plugin-test.cypress.js rename to cypress/e2e/plugins/1-mock-plugin-tests/multi-combined-plugin-test.cypress.js diff --git a/cypress/e2e/plugins/0-mock-plugin-tests/multi-plugin-test.cypress.js b/cypress/e2e/plugins/1-mock-plugin-tests/multi-plugin-test.cypress.js similarity index 100% rename from cypress/e2e/plugins/0-mock-plugin-tests/multi-plugin-test.cypress.js rename to cypress/e2e/plugins/1-mock-plugin-tests/multi-plugin-test.cypress.js diff --git a/cypress/e2e/plugins/0-mock-plugin-tests/single-plugin-test.cypress.js b/cypress/e2e/plugins/1-mock-plugin-tests/single-plugin-test.cypress.js similarity index 100% rename from cypress/e2e/plugins/0-mock-plugin-tests/single-plugin-test.cypress.js rename to cypress/e2e/plugins/1-mock-plugin-tests/single-plugin-test.cypress.js diff --git a/cypress/e2e/plugins/1-available-plugins-tests/available-plugins.cypress.js b/cypress/e2e/plugins/2-available-plugins-tests/available-plugins.cypress.js similarity index 72% rename from cypress/e2e/plugins/1-available-plugins-tests/available-plugins.cypress.js rename to cypress/e2e/plugins/2-available-plugins-tests/available-plugins.cypress.js index 51c1bf632a..cc5a2ada9d 100644 --- a/cypress/e2e/plugins/1-available-plugins-tests/available-plugins.cypress.js +++ b/cypress/e2e/plugins/2-available-plugins-tests/available-plugins.cypress.js @@ -1,11 +1,11 @@ // local dependencies -const { uninstallPlugin, restoreStarterFiles, log } = require('../../utils') +const { uninstallPlugin, restoreStarterFiles, log, waitForApplication } = require('../../utils') const { - managePluginsPagePath, loadTemplatesPage, loadPluginsPage, manageTemplatesPagePath, - manageInstalledPluginsPagePath + manageInstalledPluginsPagePath, + managePrototypeContextPath } = require('../plugin-utils') const panelCompleteQuery = '[aria-live="polite"] #panel-complete' @@ -25,7 +25,7 @@ async function installPluginTests ({ plugin, templates, version }) { log(`Install the ${plugin} plugin`) if (version) { cy.task('waitUntilAppRestarts') - cy.visit(`${managePluginsPagePath}/install?package=${encodeURIComponent(plugin)}&version=${version}`) + cy.visit(`${managePrototypeContextPath}/plugin/npm:${encodeURIComponent(plugin)}:${version}/install`) cy.get('#plugin-action-button').click() } else { @@ -36,10 +36,11 @@ async function installPluginTests ({ plugin, templates, version }) { cy.get(panelCompleteQuery, { timeout: 20000 }) .should('be.visible') - cy.get('a').contains('Back to plugins').click() + cy.get('a').contains('Back to plugin details').should('exist') - cy.get('#installed-plugins-link').click() - cy.get(`[data-plugin-package-name="${plugin}"] button`).contains('Uninstall') + waitForApplication(manageInstalledPluginsPagePath) + + cy.get(`[data-plugin-package-name="${plugin}"]`, { timeout: 3000 }) // ------------------------ @@ -59,15 +60,18 @@ async function installPluginTests ({ plugin, templates, version }) { // ------------------------ log(`Uninstall the ${plugin} plugin`) - cy.visit(manageInstalledPluginsPagePath) + waitForApplication(manageInstalledPluginsPagePath) + + cy.get(`[data-plugin-package-name="${plugin}"] .plugin-details-link`, { timeout: 20000 }).click() - cy.get(`[data-plugin-package-name="${plugin}"] button`).contains('Uninstall').click() + cy.get('.govuk-prototype-kit-plugin-uninstall-button', { timeout: 20000 }).contains('Uninstall').click() cy.get(panelCompleteQuery, { timeout: 20000 }) .should('be.visible') - cy.get('a').contains('Back to plugins').click() - cy.get(`[data-plugin-package-name="${plugin}"] button`).contains('Install') + cy.visit(`${managePrototypeContextPath}/plugin/npm:${encodeURIComponent(plugin)}:${version}`, { retryOnNetworkFailure: true, timeout: 10000 }) + + cy.get('.govuk-prototype-kit-plugin-install-button', { timeout: 20000 }).contains('Install').should('exist') }) }) } diff --git a/cypress/e2e/plugins/1-available-plugins-tests/install-common-templates-plugin-from-templates-page.cypress.js b/cypress/e2e/plugins/2-available-plugins-tests/install-common-templates-plugin-from-templates-page.cypress.js similarity index 100% rename from cypress/e2e/plugins/1-available-plugins-tests/install-common-templates-plugin-from-templates-page.cypress.js rename to cypress/e2e/plugins/2-available-plugins-tests/install-common-templates-plugin-from-templates-page.cypress.js diff --git a/cypress/e2e/plugins/1-available-plugins-tests/preview-template-view.cypress.js b/cypress/e2e/plugins/2-available-plugins-tests/preview-template-view.cypress.js similarity index 100% rename from cypress/e2e/plugins/1-available-plugins-tests/preview-template-view.cypress.js rename to cypress/e2e/plugins/2-available-plugins-tests/preview-template-view.cypress.js diff --git a/cypress/e2e/plugins/1-available-plugins-tests/view-template-with-default-layout.cypress.js b/cypress/e2e/plugins/2-available-plugins-tests/view-template-with-default-layout.cypress.js similarity index 100% rename from cypress/e2e/plugins/1-available-plugins-tests/view-template-with-default-layout.cypress.js rename to cypress/e2e/plugins/2-available-plugins-tests/view-template-with-default-layout.cypress.js diff --git a/cypress/e2e/plugins/2-prototype-kit-plugin-tests/prevent-uninstalling-kit-from-ui.cypress.js b/cypress/e2e/plugins/2-prototype-kit-plugin-tests/prevent-uninstalling-kit-from-ui.cypress.js deleted file mode 100644 index ddae11b29c..0000000000 --- a/cypress/e2e/plugins/2-prototype-kit-plugin-tests/prevent-uninstalling-kit-from-ui.cypress.js +++ /dev/null @@ -1,18 +0,0 @@ -// local dependencies -const { failAction, managePluginsPagePath } = require('../plugin-utils') -const { restoreStarterFiles } = require('../../utils') - -const plugin = 'govuk-prototype-kit' -const pluginName = 'GOV.UK Prototype Kit' - -describe('Prevent uninstalling kit from ui', () => { - after(restoreStarterFiles) - - it('Should fail', () => { - cy.task('waitUntilAppRestarts') - cy.visit(`${managePluginsPagePath}/uninstall?package=${plugin}`) - cy.get('h2') - .contains(`Uninstall ${pluginName}`) - failAction('uninstall') - }) -}) diff --git a/cypress/e2e/plugins/2-prototype-kit-plugin-tests/allow-upgrade-in-url.cypress.js b/cypress/e2e/plugins/3-prototype-kit-plugin-tests/allow-upgrade-in-url.cypress.js similarity index 92% rename from cypress/e2e/plugins/2-prototype-kit-plugin-tests/allow-upgrade-in-url.cypress.js rename to cypress/e2e/plugins/3-prototype-kit-plugin-tests/allow-upgrade-in-url.cypress.js index 22e59cf9dd..e49982a8c9 100644 --- a/cypress/e2e/plugins/2-prototype-kit-plugin-tests/allow-upgrade-in-url.cypress.js +++ b/cypress/e2e/plugins/3-prototype-kit-plugin-tests/allow-upgrade-in-url.cypress.js @@ -18,7 +18,7 @@ describe('Allow upgrade in URLs', () => { cy.exec(`cd ${Cypress.env('projectFolder')} && npm install`) log('Make sure old upgrade URL still works') - cy.visit(`/manage-prototype/plugins/upgrade?package=${encodeURIComponent(plugin)}`) + cy.visit(`/manage-prototype/plugin/npm:${encodeURIComponent(plugin)}/update`) cy.get('button#plugin-action-button') .contains('Update') .click() diff --git a/cypress/e2e/plugins/2-prototype-kit-plugin-tests/handle-plugin-installation-mismatch.cypress.js b/cypress/e2e/plugins/3-prototype-kit-plugin-tests/handle-plugin-installation-mismatch.cypress.js similarity index 79% rename from cypress/e2e/plugins/2-prototype-kit-plugin-tests/handle-plugin-installation-mismatch.cypress.js rename to cypress/e2e/plugins/3-prototype-kit-plugin-tests/handle-plugin-installation-mismatch.cypress.js index cf7bb995a6..1fc9f03585 100644 --- a/cypress/e2e/plugins/2-prototype-kit-plugin-tests/handle-plugin-installation-mismatch.cypress.js +++ b/cypress/e2e/plugins/3-prototype-kit-plugin-tests/handle-plugin-installation-mismatch.cypress.js @@ -1,6 +1,7 @@ const { replaceInFile, waitForApplication, restoreStarterFiles, log } = require('../../utils') const path = require('path') const plugin = '@govuk-prototype-kit/task-list' +const pluginName = 'Task List' const pluginVersion = '1.1.1' const originalText = '"dependencies": {' const replacementText = `"dependencies": { "${plugin}": "${pluginVersion}",` @@ -12,16 +13,23 @@ describe('Handle a plugin installation mismatch', () => { it('where the prototype package.json specifies a dependency that has not been installed', () => { waitForApplication() + cy.exec(`cd ${Cypress.env('projectFolder')} && npm uninstall ${plugin}`) log(`Add ${plugin} to the dependencies within the package.json`) replaceInFile(pkgJsonFile, originalText, '', replacementText) + waitForApplication() + log(`Make sure ${plugin} is displayed as not installed`) cy.visit(pluginsPage) cy.get(`[data-plugin-package-name="${plugin}"]`) .scrollIntoView() - .find('button') + .find('.plugin-details-link') + .contains(pluginName) + .click() + + cy.get('.govuk-prototype-kit-plugin-install-button', { timeout: 4000 }) .contains('Install') log('Force the plugins to be installed with an npm install') @@ -34,7 +42,7 @@ describe('Handle a plugin installation mismatch', () => { cy.get('#installed-plugins-link').click() cy.get(`[data-plugin-package-name="${plugin}"]`) .scrollIntoView() - .find('button') - .contains('Uninstall') + .find('.plugin-details-link') + .contains(pluginName) }) }) diff --git a/cypress/e2e/plugins/3-prototype-kit-plugin-tests/prevent-uninstalling-kit-from-ui.cypress.js b/cypress/e2e/plugins/3-prototype-kit-plugin-tests/prevent-uninstalling-kit-from-ui.cypress.js new file mode 100644 index 0000000000..cd084479d2 --- /dev/null +++ b/cypress/e2e/plugins/3-prototype-kit-plugin-tests/prevent-uninstalling-kit-from-ui.cypress.js @@ -0,0 +1,20 @@ +// local dependencies +const { managePrototypeContextPath } = require('../plugin-utils') +const { restoreStarterFiles } = require('../../utils') + +const plugin = 'govuk-prototype-kit' + +describe('Prevent uninstalling kit from ui', () => { + after(restoreStarterFiles) + + it('Should fail', () => { + cy.task('waitUntilAppRestarts') + cy.request({ + url: `${managePrototypeContextPath}/plugin/installed:${plugin}/uninstall`, + method: 'GET', + failOnStatusCode: false + }).then(response => { + expect(response.status).to.eq(403) + }) + }) +}) diff --git a/cypress/e2e/plugins/2-prototype-kit-plugin-tests/remove-govuk-frontend.cypress.js b/cypress/e2e/plugins/3-prototype-kit-plugin-tests/remove-govuk-frontend.cypress.js similarity index 61% rename from cypress/e2e/plugins/2-prototype-kit-plugin-tests/remove-govuk-frontend.cypress.js rename to cypress/e2e/plugins/3-prototype-kit-plugin-tests/remove-govuk-frontend.cypress.js index fc4645f6b5..c1072efd86 100644 --- a/cypress/e2e/plugins/2-prototype-kit-plugin-tests/remove-govuk-frontend.cypress.js +++ b/cypress/e2e/plugins/3-prototype-kit-plugin-tests/remove-govuk-frontend.cypress.js @@ -1,9 +1,11 @@ -const { managePluginsPagePath, performPluginAction } = require('../plugin-utils') -const { uninstallPlugin, restoreStarterFiles } = require('../../utils') +const { performPluginAction, managePrototypeContextPath } = require('../plugin-utils') +const { uninstallPlugin, restoreStarterFiles, waitForApplication } = require('../../utils') const plugin = 'govuk-frontend' const pluginName = 'GOV.UK Frontend' const dependentPlugin = '@govuk-prototype-kit/common-templates' +const pluginListUrl = `${managePrototypeContextPath}/plugins` +const pluginPageUrl = `${managePrototypeContextPath}/plugin/npm:${plugin}` describe('Manage prototype pages without govuk-frontend', () => { afterEach(restoreStarterFiles) @@ -14,15 +16,23 @@ describe('Manage prototype pages without govuk-frontend', () => { uninstallPlugin(dependentPlugin) cy.task('waitUntilAppRestarts') - cy.visit(`${managePluginsPagePath}/uninstall?package=${plugin}`) + cy.visit(`${pluginPageUrl}/uninstall`) cy.get('#plugin-action-button').contains('Uninstall').click() performPluginAction('uninstall', plugin, pluginName) cy.task('log', 'Make sure govuk-frontend is uninstalled') + + waitForApplication(pluginListUrl) + cy.get(`[data-plugin-package-name="${plugin}"]`) - .find('button') + .scrollIntoView() + .find('.plugin-details-link') + .contains(pluginName) + .click() + + cy.get('.govuk-prototype-kit-plugin-install-button', { timeout: 3000 }) .contains('Install') cy.task('log', 'Test home page') @@ -41,17 +51,22 @@ describe('Manage prototype pages without govuk-frontend', () => { cy.get(`[data-plugin-package-name="${plugin}"]`) .scrollIntoView() - .find('button') - .contains('Install') + .find('.plugin-details-link') + .contains(pluginName) .click() - performPluginAction('install', plugin, pluginName) + cy.get('.govuk-prototype-kit-plugin-install-button', { timeout: 6000 }) + .click() - cy.get('#installed-plugins-link').click() + performPluginAction('install', plugin, pluginName) cy.task('log', 'Make sure govuk-frontend is installed') + + waitForApplication(pluginListUrl) + cy.get(`[data-plugin-package-name="${plugin}"]`) - .find('button') - .contains('Uninstall') + .scrollIntoView() + .find('.govuk-prototype-kit-installed') + .contains('Installed') }) }) diff --git a/cypress/e2e/plugins/1-available-plugins-tests/install-available-plugin.cypress.js b/cypress/e2e/plugins/4-lots-of-actions/install-available-plugin.cypress.js similarity index 74% rename from cypress/e2e/plugins/1-available-plugins-tests/install-available-plugin.cypress.js rename to cypress/e2e/plugins/4-lots-of-actions/install-available-plugin.cypress.js index 526e518610..992c69cb29 100644 --- a/cypress/e2e/plugins/1-available-plugins-tests/install-available-plugin.cypress.js +++ b/cypress/e2e/plugins/4-lots-of-actions/install-available-plugin.cypress.js @@ -2,15 +2,15 @@ const path = require('path') // local dependencies -const { deleteFile, uninstallPlugin, installPlugin, restoreStarterFiles, log } = require('../../utils') +const { deleteFile, uninstallPlugin, installPlugin, restoreStarterFiles, log, waitForApplication } = require('../../utils') const { - failAction, performPluginAction, managePluginsPagePath, getTemplateLink, loadInstalledPluginsPage, loadPluginsPage, - manageInstalledPluginsPagePath + manageInstalledPluginsPagePath, + managePrototypeContextPath } = require('../plugin-utils') const { showHideAllLinkQuery, assertVisible, assertHidden } = require('../../step-by-step-utils') @@ -26,7 +26,7 @@ const pluginPagePath = '/step-by-step-navigation' const provePluginFunctionalityWorks = () => { log(`Prove ${pluginName} functionality works`) - cy.visit(pluginPagePath) + cy.visit(pluginPagePath, { retryOnNetworkFailure: true, timeout: 4000 }) // click toggle button and check that all steps details are visible cy.get(showHideAllLinkQuery).contains('Show all').click() @@ -49,7 +49,7 @@ const provePluginFunctionalityFails = () => { log(`Prove ${pluginName} functionality fails`) - cy.visit(pluginPagePath) + cy.visit(pluginPagePath, { retryOnNetworkFailure: true, timeout: 4000 }) cy.get(showHideAllLinkQuery).should('not.exist') } @@ -61,13 +61,13 @@ describe('Management plugins: ', () => { // Load the plugins page, so we don't get any network errors when running the test loadPluginsPage() // Now run the test - const installUrl = `${managePluginsPagePath}/install` + const installUrl = `${managePrototypeContextPath}/plugin/npm:${encodeURIComponent(plugin)}/install` log(`Posting to ${installUrl} without csrf protection`) cy.request({ - url: `${managePluginsPagePath}/install`, + url: installUrl, method: 'POST', failOnStatusCode: false, - body: { package: plugin } + body: {} }).then(response => { expect(response.status).to.eq(403) expect(response.body).to.have.property('error', 'invalid csrf token') @@ -78,9 +78,11 @@ describe('Management plugins: ', () => { log(`Install ${plugin}@${version1} directly`) uninstallPlugin(plugin) + waitForApplication() + loadPluginsPage() - cy.visit(`${managePluginsPagePath}/install?package=${encodeURIComponent(plugin)}&version=${version1}`) + cy.visit(`${managePrototypeContextPath}/plugin/npm:${encodeURIComponent(plugin)}:${version1}/install`) cy.get('#plugin-action-button').click() @@ -91,13 +93,17 @@ describe('Management plugins: ', () => { log(`Update the ${plugin}@${version1} plugin to ${plugin}@${version2}`) installPlugin(plugin, version1) + waitForApplication() + loadInstalledPluginsPage() log(`Update the ${plugin} plugin`) cy.get(`[data-plugin-package-name="${plugin}"]`) .scrollIntoView() - .find('button') - .contains('Update') + .find('.plugin-details-link') + .click() + + cy.get('.govuk-prototype-kit-plugin-update-button', { timeout: 6000 }) .click() performPluginAction('update', plugin, pluginName) @@ -137,8 +143,10 @@ describe('Management plugins: ', () => { cy.get(`[data-plugin-package-name="${plugin}"]`) .scrollIntoView() - .find('button') - .contains('Uninstall') + .find('.plugin-details-link') + .click() + + cy.get('.govuk-prototype-kit-plugin-uninstall-button', { timeout: 6000 }) .click() performPluginAction('uninstall', plugin, pluginName) @@ -153,8 +161,10 @@ describe('Management plugins: ', () => { cy.get(`[data-plugin-package-name="${plugin}"]`) .scrollIntoView() - .find('button') - .contains('Install') + .find('.plugin-details-link') + .click() + + cy.get('.govuk-prototype-kit-plugin-install-button', { timeout: 6000 }) .click() performPluginAction('install', plugin, pluginName) @@ -164,9 +174,9 @@ describe('Management plugins: ', () => { it('Get plugin page directly', () => { log('Pass when installing a plugin already installed') - cy.task('waitUntilAppRestarts') + waitForApplication() log(`Simulate refreshing the install ${plugin} plugin confirmation page`) - cy.visit(`${managePluginsPagePath}/install?package=${encodeURIComponent(plugin)}`) + cy.visit(`${managePrototypeContextPath}/plugin/npm:${encodeURIComponent(plugin)}/install`) cy.get('#plugin-action-button').click() @@ -176,16 +186,27 @@ describe('Management plugins: ', () => { log('Fail when installing a non existent plugin') const pkg = 'invalid-prototype-kit-plugin' - const invalidPluginName = 'Invalid Prototype Kit Plugin' - cy.visit(`${managePluginsPagePath}/install?package=${encodeURIComponent(pkg)}`) - cy.get('h2').contains(`Install ${invalidPluginName}`) - failAction('install') + cy.request({ + url: `${managePrototypeContextPath}/plugin/npm:${encodeURIComponent(pkg)}/install`, + method: 'GET', + failOnStatusCode: false, + retryOnNetworkFailure: true, + timeout: 4000 + }).then(response => { + expect(response.status).to.eq(404) + }) // ------------------------ log('Fail when installing a plugin with a non existent version') - cy.visit(`${managePluginsPagePath}/install?package=${encodeURIComponent(plugin)}&version=0.0.1`) - cy.get('h2').contains(`Install ${pluginName}`) - failAction('install') + cy.request({ + url: `${managePrototypeContextPath}/plugin/npm:${encodeURIComponent(plugin)}:0.0.1/install`, + method: 'GET', + failOnStatusCode: false, + retryOnNetworkFailure: true, + timeout: 4000 + }).then(response => { + expect(response.status).to.eq(404) + }) }) }) diff --git a/cypress/e2e/plugins/plugin-utils.js b/cypress/e2e/plugins/plugin-utils.js index 56541707bd..46850c18d6 100644 --- a/cypress/e2e/plugins/plugin-utils.js +++ b/cypress/e2e/plugins/plugin-utils.js @@ -3,8 +3,9 @@ const { capitalize } = require('lodash') const { urlencode } = require('nunjucks/src/filters') const { waitForApplication } = require('../utils') -const manageTemplatesPagePath = '/manage-prototype/templates' -const managePluginsPagePath = '/manage-prototype/plugins' +const managePrototypeContextPath = '/manage-prototype' +const manageTemplatesPagePath = `${managePrototypeContextPath}/templates` +const managePluginsPagePath = `${managePrototypeContextPath}/plugins` const manageInstalledPluginsPagePath = '/manage-prototype/plugins-installed' const panelProcessingQuery = '[aria-live="polite"] #panel-processing' @@ -33,7 +34,7 @@ async function loadTemplatesPage () { function performPluginAction (action, plugin, pluginName) { cy.task('log', `The ${plugin} plugin should be displayed`) - cy.get('h2') + cy.get('h1') .contains(pluginName) const processingText = `${action === 'update' ? 'Updat' : action}ing ...` @@ -62,49 +63,20 @@ function performPluginAction (action, plugin, pluginName) { cy.task('log', `The ${plugin} plugin ${action} has completed`) - cy.get('#instructions-complete a') - .contains('Back to plugins') - .click() - - cy.task('log', 'Returning to plugins page') - - cy.get('h1').contains('Plugins') -} - -function failAction (action) { - cy.get('#plugin-action-button').click() + const expectedButtonContents = action === 'uninstall' ? 'Back to plugins' : 'Back to plugin details' - if (Cypress.env('skipPluginActionInterimStep') !== 'true') { - cy.get(panelCompleteQuery) - .should('not.be.visible') - cy.get(panelErrorQuery) - .should('not.be.visible') - cy.get(panelProcessingQuery) - .should('be.visible') - .contains(`${capitalize(action === 'update' ? 'Updat' : action)}ing ...`) - } - - cy.get(panelProcessingQuery, { timeout: 40000 }) - .should('not.be.visible') - cy.get(panelCompleteQuery) - .should('not.be.visible') - cy.get(panelErrorQuery) - .should('be.visible') - - cy.get(`${panelErrorQuery} .govuk-panel__title`) - .contains(`There was a problem ${action === 'update' ? 'Updat' : action}ing`) - cy.get(`${panelErrorQuery} a`) - .contains('Please contact support') + cy.get('#instructions-complete a') + .contains(expectedButtonContents) } module.exports = { managePluginsPagePath, manageInstalledPluginsPagePath, manageTemplatesPagePath, + managePrototypeContextPath, loadPluginsPage, loadInstalledPluginsPage, loadTemplatesPage, getTemplateLink, - performPluginAction, - failAction + performPluginAction } diff --git a/cypress/e2e/utils.js b/cypress/e2e/utils.js index 7f78caf6e2..ce7ba50106 100644 --- a/cypress/e2e/utils.js +++ b/cypress/e2e/utils.js @@ -14,9 +14,9 @@ const authenticate = () => { const waitForApplication = async (path = '/index') => { log(`Waiting for app to restart and load ${path} page`) cy.task('waitUntilAppRestarts') - cy.visit(path) + cy.visit(path, { retryOnNetworkFailure: true, timeout: 10000 }) cy.get('.govuk-header__logotype-text') - .contains('GOV.UK') + .contains('GOV.UK', { timeout: 10000 }) } const copyFile = (source, target) => { diff --git a/known-plugins.json b/known-plugins.json index 77eb9cdfe1..1f8ab5d6a1 100644 --- a/known-plugins.json +++ b/known-plugins.json @@ -12,6 +12,29 @@ "required": [ "govuk-prototype-kit", "govuk-frontend" - ] + ], + "proxyConfig": { + "jquery": { + "scripts": ["/dist/jquery.js"], + "assets": ["/dist"], + "meta": { + "description": "jQuery is a fast, small, and feature-rich JavaScript library. It makes things like HTML document traversal and manipulation, event handling, animation, and Ajax much simpler with an easy-to-use API that works across a multitude of browsers." + } + }, + "notifications-node-client": { + "meta": { + "description": "GOV.UK Notify makes it easy for public sector service teams to send emails, text messages and letters." + } + }, + "home-office-kit": {"pluginDependencies": ["govuk-frontend"]}, + "@govuk-prototype-kit/common-templates": {"pluginDependencies": ["govuk-frontend"]}, + "@govuk-prototype-kit/step-by-step": {"pluginDependencies": ["govuk-frontend"]}, + "@govuk-prototype-kit/task-list": {"pluginDependencies": ["govuk-frontend"]}, + "hmrc-frontend": {"pluginDependencies": ["govuk-frontend"]}, + "@hmcts/frontend": {"pluginDependencies": ["govuk-frontend"]}, + "@ministryofjustice/frontend": {"pluginDependencies": ["govuk-frontend"]}, + "@x-govuk/govuk-prototype-components": {"pluginDependencies": ["govuk-frontend"]} + } + } } diff --git a/lib/assets/javascripts/manage-prototype/manage-plugins.js b/lib/assets/javascripts/manage-prototype/manage-plugins.js index 1253e9b6d7..998a8ca828 100644 --- a/lib/assets/javascripts/manage-prototype/manage-plugins.js +++ b/lib/assets/javascripts/manage-prototype/manage-plugins.js @@ -1,15 +1,10 @@ ;(() => { - const params = new URLSearchParams(window.location.search) - const packageName = params.get('package') - const version = params.get('version') - const mode = params.get('mode') || window.location.pathname.split('/').pop() - const timeout = 30 * 1000 let requestTimeoutId let timedOut = false - let kitIsRestarting = false let actionTimeoutId + let localStorageKey const show = (id) => { const element = document.getElementById(id) @@ -25,6 +20,18 @@ } } + const storeInLocalStorage = (status) => { + window.localStorage.setItem(localStorageKey, JSON.stringify(status)) + } + + const cleanupLocalStorage = () => { + const maxAgeInDays = 30 + const maxAgeEopchTime = new Date().getTime() - 1000 * 60 * 24 * maxAgeInDays + const allKeys = Object.keys(window.localStorage) + const expiredKeys = allKeys.filter(x => Number(x.split('#')[1]) < maxAgeEopchTime) + expiredKeys.forEach(key => window.localStorage.removeItem(key)) + } + const showCompleteStatus = () => { if (actionTimeoutId) { clearTimeout(actionTimeoutId) @@ -32,6 +39,7 @@ hide('panel-processing') show('panel-complete') show('instructions-complete') + cleanupLocalStorage() } const showErrorStatus = () => { @@ -50,10 +58,19 @@ 'Content-Type': 'application/json', 'X-CSRF-TOKEN': token }, - body: JSON.stringify({ - package: packageName, - version + body: JSON.stringify({}) + }) + .then(response => { + return response.json() }) + } + + const getRequest = (url) => { + return fetch(url, { + method: 'GET', + headers: { + Accept: 'application/json' + } }) .then(response => { return response.json() @@ -74,17 +91,13 @@ }) } - const pollStatus = () => { + const pollStatus = (statusUrl) => { // Be aware that changing this path for monitoring the status of a plugin will affect the // kit update process as the browser request and server route would be out of sync. - return postRequest(`/manage-prototype/plugins/${mode}/status`) + return getRequest(statusUrl) .then(data => { - if (kitIsRestarting) { - // kit has restarted as the request has returned with data - kitIsRestarting = false - if (data.status === 'processing') { - return makeRequest(performAction) - } + if (data.status) { + storeInLocalStorage({ status: data.status, statusUrl }) } switch (data.status) { case 'completed': @@ -94,14 +107,12 @@ } default: { // poll status again if prototype hasn't restarted - return makeRequest(pollStatus) + return makeRequest(() => pollStatus(statusUrl)) } } }) .catch(() => { - // kit must be restarting as the request failed - kitIsRestarting = true - return makeRequest(pollStatus) + return makeRequest(() => pollStatus(statusUrl)) }) } @@ -124,21 +135,16 @@ if (event && event.preventDefault) { event.preventDefault() hide('plugin-action-confirmation') + hide('plugin-action-confirmation-multiple') } show('panel-processing') return getToken() - .then((token) => postRequest(`/manage-prototype/plugins/${mode}`, token)) + .then((token) => postRequest(window.location.href, token)) .then(data => { - switch (data.status) { - case 'completed': - return showCompleteStatus() - case 'processing': - return makeRequest(pollStatus) - default: - return showErrorStatus() - } + storeInLocalStorage({ statusUrl: data.statusUrl }) + return makeRequest(() => pollStatus(data.statusUrl)) }) .catch(() => { // kit must be restarting as the request failed so try again @@ -146,16 +152,36 @@ }) } - const init = () => { - kitIsRestarting = false + const loadPreviousStatus = (previousRunObj) => { + console.log('previousState', previousRunObj) + switch (previousRunObj.status) { + case 'completed': + return showCompleteStatus() + case 'error': { + return showErrorStatus() + } + default: { + // poll status again if prototype hasn't restarted + return makeRequest(() => pollStatus(previousRunObj.statusUrl)) + } + } + } + + const init = (config) => { timedOut = false actionTimeoutId = null requestTimeoutId = null + if (window.location.hash) { + localStorageKey = ['manage-plugins', window.location.pathname, window.location.hash].join('__') + } hide('panel-manual-instructions') const actionButton = document.getElementById('plugin-action-button') + const previousRun = localStorageKey && window.localStorage.getItem(localStorageKey) - if (actionButton) { + if (previousRun) { + return loadPreviousStatus(JSON.parse(previousRun)) + } else if (actionButton) { actionButton.addEventListener('click', performAction) } else { return performAction() diff --git a/lib/assets/javascripts/manage-prototype/manage-plugins.test.js b/lib/assets/javascripts/manage-prototype/manage-plugins.test.js index aa25941c13..9f05af1945 100644 --- a/lib/assets/javascripts/manage-prototype/manage-plugins.test.js +++ b/lib/assets/javascripts/manage-prototype/manage-plugins.test.js @@ -62,8 +62,8 @@ describe('manage-plugins', () => { global.fetch = jest.fn().mockResolvedValue(null) const getTokenUrl = '/manage-prototype/csrf-token' - const performActionUrl = '/manage-prototype/plugins/' - const pollStatusUrl = '/manage-prototype/plugins//status' + const statusUrl = '/hello/world' + const selfUrl = 'http://localhost/' const { document, window } = global let managePlugins @@ -76,10 +76,20 @@ describe('manage-plugins', () => { if (status === 'throws') { return Promise.reject(new Error('The server is restarting')) } - return fetchResponse({ status }) + return fetchResponse(status) }) jest.spyOn(global, 'fetch').mockImplementation((url) => { - return responses[responseIndex++](url) + const index = responseIndex++ + const fn = responses[index] + if (!fn) { + throw new Error(`More responses than expected, ${responses.length} configured, ${index + 1} called + + Calls were [${fetchList.join(', ')}] + + Failing call was [${url}] + `) + } + return fn(url) }) } @@ -130,8 +140,9 @@ describe('manage-plugins', () => { it('completed', async () => { mockFetch([ - { token }, - 'completed' + { status: { token } }, + { statusUrl }, + { status: 'completed' } ]) await managePlugins.performAction() @@ -140,14 +151,16 @@ describe('manage-plugins', () => { expect(global.fetch).toHaveBeenCalledTimes(fetchList.length) expect(fetchList).toEqual([ getTokenUrl, - performActionUrl + selfUrl, + statusUrl ]) }) it('error', async () => { mockFetch([ - { token }, - 'error' + { status: token }, + { statusUrl }, + { status: 'error' } ]) await managePlugins.performAction() @@ -156,17 +169,20 @@ describe('manage-plugins', () => { expect(global.fetch).toHaveBeenCalledTimes(fetchList.length) expect(fetchList).toEqual([ getTokenUrl, - performActionUrl + selfUrl, + statusUrl ]) }) it('will restart', async () => { mockFetch([ - { token }, - 'throws', - { token }, - 'processing', - 'completed'] + { status: { token } }, + { statusUrl }, + { status: 'throws' }, + { status: { token } }, + { statusUrl }, + { status: 'processing' }, + { status: 'completed' }] ) await managePlugins.performAction() @@ -175,10 +191,12 @@ describe('manage-plugins', () => { expect(global.fetch).toHaveBeenCalledTimes(fetchList.length) expect(fetchList).toEqual([ getTokenUrl, - performActionUrl, - getTokenUrl, - performActionUrl, - pollStatusUrl + selfUrl, + statusUrl, + statusUrl, + statusUrl, + statusUrl, + statusUrl ]) }) }) @@ -190,37 +208,37 @@ describe('manage-plugins', () => { it('completed', async () => { mockFetch([ - 'processing', - 'throws', - 'completed' + { status: 'processing' }, + { status: 'throws' }, + { status: 'completed' } ]) - await managePlugins.pollStatus() + await managePlugins.pollStatus(statusUrl) expect(document.body.innerHTML).toEqual(completedHTML) expect(global.fetch).toHaveBeenCalledTimes(fetchList.length) expect(fetchList).toEqual([ - pollStatusUrl, - pollStatusUrl, - pollStatusUrl + statusUrl, + statusUrl, + statusUrl ]) }) it('error', async () => { mockFetch([ - 'processing', - 'throws', - 'error' + { status: 'processing' }, + { status: 'throws' }, + { status: 'error' } ]) - await managePlugins.pollStatus() + await managePlugins.pollStatus(statusUrl) expect(document.body.innerHTML).toEqual(errorHTML) expect(global.fetch).toHaveBeenCalledTimes(fetchList.length) expect(fetchList).toEqual([ - pollStatusUrl, - pollStatusUrl, - pollStatusUrl + statusUrl, + statusUrl, + statusUrl ]) }) }) @@ -232,11 +250,12 @@ describe('manage-plugins', () => { it('completed', async () => { mockFetch([ - { token }, - 'processing', - 'processing', - 'throws', - 'completed' + { status: { token } }, + { statusUrl }, + { status: 'processing' }, + { status: 'processing' }, + { status: 'throws' }, + { status: 'completed' } ]) await managePlugins.init() @@ -245,20 +264,22 @@ describe('manage-plugins', () => { expect(global.fetch).toHaveBeenCalledTimes(fetchList.length) expect(fetchList).toEqual([ getTokenUrl, - performActionUrl, - pollStatusUrl, - pollStatusUrl, - pollStatusUrl + selfUrl, + statusUrl, + statusUrl, + statusUrl, + statusUrl ]) }) it('error', async () => { mockFetch([ - { token }, - 'processing', - 'processing', - 'throws', - 'error' + { status: { token } }, + { statusUrl }, + { status: 'processing' }, + { status: 'processing' }, + { status: 'throws' }, + { status: 'error' } ]) await managePlugins.init() @@ -267,10 +288,11 @@ describe('manage-plugins', () => { expect(global.fetch).toHaveBeenCalledTimes(fetchList.length) expect(fetchList).toEqual([ getTokenUrl, - performActionUrl, - pollStatusUrl, - pollStatusUrl, - pollStatusUrl + selfUrl, + statusUrl, + statusUrl, + statusUrl, + statusUrl ]) }) }) diff --git a/lib/assets/sass/manage-prototype.scss b/lib/assets/sass/manage-prototype.scss index 977587e8a3..979d48a9fd 100644 --- a/lib/assets/sass/manage-prototype.scss +++ b/lib/assets/sass/manage-prototype.scss @@ -440,6 +440,15 @@ body .govuk-prototype-kit-manage-prototype-govuk-tag { padding-left: 0 !important; } +.govuk-prototype-kit-manage-prototype-plugin-heading { + margin-bottom: 0; +} + +.govuk-prototype-kit-manage-prototype-plugin-sub-heading { + color: #505a5f; + margin-bottom: 15px; +} + .govuk-prototype-kit-manage-prototype-plugin-list-plugin-list { border-top: 1px solid #b1b4b6; margin-bottom: 2em; @@ -574,3 +583,10 @@ body .govuk-prototype-kit-manage-prototype-govuk-tag { } } } + +.govuk-prototype-kit-manage-prototype-plugin-details-links { + padding-top: 20px; + margin-bottom: 20px; + border-top: 2px solid #b1b4b6; + border-bottom: 2px solid #b1b4b6; +} diff --git a/lib/config.js b/lib/config.js index 5f7a1169f1..341f4f2997 100644 --- a/lib/config.js +++ b/lib/config.js @@ -96,6 +96,10 @@ function getConfig (config, swallowError = true) { overrideOrDefault('verbose', 'VERBOSE', asBoolean, false) overrideOrDefault('showPrereleases', 'SHOW_PRERELEASES', asBoolean, false) overrideOrDefault('allowGovukFrontendUninstall', 'ALLOW_GOVUK_FRONTEND_UNINSTALL', asBoolean, false) + overrideOrDefault('showPluginLookup', 'SHOW_PLUGIN_LOOKUP', asBoolean, false) + overrideOrDefault('showPluginDowngradeButtons', 'SHOW_PLUGIN_DOWNGRADE_BUTTONS', asBoolean, false) + overrideOrDefault('showPluginDebugInfo', 'SHOW_PLUGIN_DEBUG_INFO', asBoolean, false) + overrideOrDefault('turnOffFunctionCaching', 'TURN_OFF_FUNCTION_CACHING', asBoolean, false) if (config.serviceName === undefined) { config.serviceName = 'GOV.UK Prototype Kit' diff --git a/lib/config.test.js b/lib/config.test.js index d7f13e8099..0d620d0d60 100644 --- a/lib/config.test.js +++ b/lib/config.test.js @@ -34,7 +34,12 @@ describe('config', () => { logPerformance: false, showPrereleases: false, allowGovukFrontendUninstall: false, - verbose: false + verbose: false, + showPluginDebugInfo: false, + showPluginDowngradeButtons: false, + showPluginLookup: false, + turnOffFunctionCaching: false + }) const mergeWithDefaults = (config) => Object.assign({}, defaultConfig, config) diff --git a/lib/dev-server.js b/lib/dev-server.js index a5ae807afa..9a63829f14 100644 --- a/lib/dev-server.js +++ b/lib/dev-server.js @@ -1,5 +1,7 @@ // node dependencies const path = require('path') +const fs = require('fs') +const fsp = fs.promises // npm dependencies const chokidar = require('chokidar') @@ -18,6 +20,8 @@ const { const plugins = require('./plugins/plugins') const { collectDataUsage } = require('./usage-data') const utils = require('./utils') +const { logPerformanceSummaryOnce, startPerformanceTimer, endPerformanceTimer } = require('./utils/performance') +const { exec } = require('./exec') const { packageDir, projectDir, @@ -25,10 +29,44 @@ const { appDir, appSassDir, - publicCssDir + publicCssDir, + + commandsDir } = require('./utils/paths') -const fs = require('fs') -const { logPerformanceSummaryOnce, startPerformanceTimer, endPerformanceTimer } = require('./utils/performance') + +const pollRateForCommandsDir = 1000 + +async function pollCommandsDir () { + await fse.ensureDir(commandsDir) + const commandFilesToRun = (await fsp.readdir(commandsDir)).sort() + while (commandFilesToRun.length > 0) { + const commandFilePath = path.join(commandsDir, commandFilesToRun.shift()) + let commandFileContents + try { + commandFileContents = await fse.readJson(commandFilePath) + } catch (err) { + await fse.writeJson(commandFilePath, { status: 'failed', error: 'File was malformed' }) + continue + } + if (commandFileContents.status !== 'pending') { + continue + } + try { + console.log('running command', commandFileContents.command) + await fse.writeJson(commandFilePath, { ...commandFileContents, status: 'processing', updated: new Date() }) + await exec(commandFileContents.command, { cwd: projectDir }) + await fse.writeJson(commandFilePath, { ...commandFileContents, status: 'completed', updated: new Date() }) + if (commandFileContents.restartOnCompletion) { + nodemonInstance.emit('restart') + } + } catch (err) { + console.error('Error running command') + console.error(err) + await fse.writeJson(commandFilePath, { ...commandFileContents, status: 'error', error: 'Command failed', errorMessage: err.message, errorStack: err.stack, updated: new Date() }) + } + } + setTimeout(pollCommandsDir, pollRateForCommandsDir) +} // Build watch and serve async function runDevServer () { @@ -158,6 +196,8 @@ function runNodemon (port) { setNodemonInstance(nodemonInstance) + pollCommandsDir() + endPerformanceTimer('runDevServer', timer) logPerformanceSummaryOnce() diff --git a/lib/manage-prototype-handlers.js b/lib/manage-prototype-handlers.js index b6defcc838..267c5749d3 100644 --- a/lib/manage-prototype-handlers.js +++ b/lib/manage-prototype-handlers.js @@ -9,19 +9,11 @@ const { doubleCsrf } = require('csrf-csrf') // local dependencies const config = require('./config') const plugins = require('./plugins/plugins') -const { exec } = require('./exec') const { prototypeAppScripts } = require('./utils') -const { projectDir, packageDir, appViewsDir } = require('./utils/paths') +const { projectDir, packageDir, appViewsDir, commandsDir } = require('./utils/paths') const nunjucksConfiguration = require('./nunjucks/nunjucksConfiguration') const syncChanges = require('./sync-changes') -const { - lookupPackageInfo, - getInstalledPackages, - getAllPackages, - getDependentPackages, - getDependencyPackages, - waitForPackagesCache -} = require('./plugins/packages') +const { plugins: knownPlugins } = require('../known-plugins.json') const contextPath = '/manage-prototype' @@ -31,22 +23,22 @@ const appViews = plugins.getAppViews([ path.join(packageDir, 'lib/final-backup-nunjucks') ]) -let kitRestarted = false - const { - name: currentKitName, version: currentKitVersion } = require(path.join(packageDir, 'package.json')) - -async function isValidVersion (packageName, version) { - const { versions = [], localVersion } = await lookupPackageInfo(packageName, version) - const validVersions = [...versions, localVersion].filter(version => version) - const isVersionValid = validVersions.includes(version) - if (!isVersionValid) { - console.log('version', version, ' is not valid, valid options are:\n\n', validVersions) - } - return isVersionValid -} +const pluginDetails = require('./utils/packageDetails') +const { + getPluginDetailsFromFileSystem, + getPluginDetailsFromGithub, + getPluginDetailsFromNpm, + getLatestPluginDetailsFromNpm, + getPluginDetailsFromRef, + getInstalledPackages, + getKnownPlugins, + getInstalledPluginDetails, + isInstalled +} = pluginDetails +const { getConfig } = require('./config') function getManagementView (filename) { return ['views', 'manage-prototype', filename].join('/') @@ -129,15 +121,6 @@ function developmentOnlyMiddleware (req, res, next) { } } -// Middleware to ensure pages load when plugin cache has been initially loaded -async function pluginCacheMiddleware (req, res, next) { - await Promise.race([ - waitForPackagesCache(), - new Promise((resolve) => setTimeout(resolve, 1000)) - ]) - next() -} - const managementLinks = [ { text: 'Home', @@ -170,14 +153,15 @@ async function getHomeHandler (req, res) { const originalHomepage = await fse.readFile(path.join(packageDir, 'prototype-starter', 'app', 'views', 'index.html'), 'utf8') const currentHomepage = await readFileIfExists(path.join(appViewsDir, 'index.html')) - const kitPackage = await lookupPackageInfo('govuk-prototype-kit') + const kitPackage = await pluginDetails.getLatestPluginDetailsFromNpm('govuk-prototype-kit') const viewData = { currentUrl: req.originalUrl, currentSection: pageName, links: managementLinks, - kitUpdateAvailable: kitPackage.latestVersion !== currentKitVersion, - latestAvailableKit: kitPackage.latestVersion, + kitUpdateAvailable: kitPackage.version !== currentKitVersion, + latestAvailableKit: kitPackage.version, + latestKitUrl: kitPackage.links.pluginDetails, tasks: [ { done: serviceName !== 'Service name goes here' && serviceName !== 'GOV.UK Prototype Kit', @@ -232,13 +216,14 @@ async function getTemplatesHandler (req, res) { const availableTemplates = getPluginTemplates() const commonTemplatesPackageName = '@govuk-prototype-kit/common-templates' - const govUkFrontendPackageName = 'govuk-frontend' + const govukFrontendPackageName = 'govuk-frontend' let commonTemplatesDetails - const installedPlugins = (await getInstalledPackages()).map((pkg) => pkg.packageName) - if (installedPlugins.includes(govUkFrontendPackageName) && !installedPlugins.includes(commonTemplatesPackageName)) { + const installedPlugins = (await pluginDetails.getInstalledPackages()).map((pkg) => pkg.packageName) + if (installedPlugins.includes(govukFrontendPackageName) && !installedPlugins.includes(commonTemplatesPackageName)) { + const plugin = await getLatestPluginDetailsFromNpm(commonTemplatesPackageName) commonTemplatesDetails = { - pluginDisplayName: plugins.preparePackageNameForDisplay(commonTemplatesPackageName), - installLink: `${contextPath}/plugins/install?package=${encodeURIComponent(commonTemplatesPackageName)}&returnTo=templates` + pluginDisplayName: plugin?.name, + installLink: plugin?.links?.install } } @@ -390,39 +375,39 @@ function getTemplatesPostInstallHandler (req, res) { }) } -function buildPluginData (pluginData) { - if (pluginData === undefined) { - return - } - const { - packageName, - installed, - installedLocally, - latestVersion, - installedVersion, - required, - localVersion, - pluginConfig = {} - } = pluginData - const preparedPackageNameForDisplay = plugins.preparePackageNameForDisplay(packageName) +async function buildPluginData (plugin) { + const latestVersion = (await getLatestPluginDetailsFromNpm(plugin.packageName))?.version + const installedPlugin = await getInstalledPluginDetails(plugin.packageName) + const installedVersion = installedPlugin?.version + return { - ...preparedPackageNameForDisplay, - ...pluginConfig.meta, - packageName, - latestVersion, - installedLocally, - installLink: `${contextPath}/plugins/install?package=${encodeURIComponent(packageName)}`, - installCommand: `npm install ${packageName}`, - updateLink: installed && !installedLocally && latestVersion !== installedVersion ? `${contextPath}/plugins/update?package=${encodeURIComponent(packageName)}` : undefined, - updateCommand: latestVersion && `npm install ${packageName}@${latestVersion}`, - uninstallLink: installed && !required ? `${contextPath}/plugins/uninstall?package=${encodeURIComponent(packageName)}${installedLocally ? `&version=${encodeURIComponent(localVersion)}` : ''}` : undefined, - uninstallCommand: `npm uninstall ${packageName}`, - installedVersion + ...plugin, + installedVersion, + isInstalled: !!installedVersion, + updateAvailable: latestVersion && installedVersion && installedVersion !== latestVersion, + description: plugin.pluginConfig?.meta?.description, + pluginDetailsLink: installedPlugin?.links?.pluginDetails || plugin.links.pluginDetails + } +} + +function getTimeSummary (date) { + const epochDate = date.getTime() + const epochNow = new Date().getTime() + const timeDifferenceInDays = (epochNow - epochDate) / 1000 / 60 / 60 / 24 + if (timeDifferenceInDays < 1) { + return 'today' + } + if (timeDifferenceInDays < 2) { + return 'yesterday' + } + if (timeDifferenceInDays < 14) { + return Math.floor(timeDifferenceInDays) + ' days ago' } + return Math.floor(timeDifferenceInDays / 7) + ' weeks ago' } async function prepareForPluginPage (isInstalledPage, search) { - const allPlugins = await getAllPackages() + const allPlugins = await getKnownPlugins() const installedPlugins = await getInstalledPackages() const plugins = isInstalledPage @@ -434,47 +419,9 @@ async function prepareForPluginPage (isInstalledPage, search) { return { status: isInstalledPage ? 'installed' : 'search', - plugins: plugins.map(buildPluginData), + plugins: await Promise.all(plugins.map(buildPluginData)), found: plugins.length, - updates: installedPlugins.filter(plugin => plugin.installedVersion !== plugin.latestVersion).length - } -} - -function getCommand (mode, chosenPlugin) { - let { - updateCommand, - installCommand, - uninstallCommand, - version, - dependencyPlugins, - dependentPlugins - } = chosenPlugin - const dependents = dependentPlugins?.map(({ packageName }) => packageName).join(' ') - const dependencies = dependencyPlugins?.map(({ - packageName, - latestVersion - }) => packageName + '@' + latestVersion).join(' ') - - if (version && installCommand) { - installCommand += `@${version}` - } - - if (dependents) { - uninstallCommand += ' ' + dependents - } - - if (dependencies) { - installCommand += ' ' + dependencies - updateCommand += ' ' + dependencies - } - - switch (mode) { - case 'update': - return updateCommand + ' --save-exact' - case 'install': - return installCommand + ' --save-exact' - case 'uninstall': - return uninstallCommand + updates: installedPlugins.filter(plugin => plugin.updateAvailable).length } } @@ -504,7 +451,17 @@ const verbs = { async function getPluginsHandler (req, res) { const isInstalledPage = req.route.path.endsWith('installed') - const { search = '' } = req.query || {} + const { + search = '', + error, + fsPath, + githubOrg, + githubProject, + githubBranch, + npmPackage, + npmVersion, + source + } = req.query || {} const pageName = 'Plugins' const { plugins, status, updates = 0, found = 0 } = await prepareForPluginPage(isInstalledPage, search) const foundMessage = found === 1 ? found + ' Plugin found' : found + ' Plugins found' @@ -513,13 +470,25 @@ async function getPluginsHandler (req, res) { currentSection: pageName, links: managementLinks, isInstalledPage, + showPluginLookup: getConfig().showPluginLookup, isSearchPage: !isInstalledPage, search, plugins, updatesMessage, foundMessage, - status + status, + playback: { + error, + fsPath, + githubOrg, + githubProject, + githubBranch, + npmPackage, + npmVersion, + source + } } + res.render(getManagementView('plugins.njk'), model) } @@ -528,91 +497,250 @@ async function postPluginsHandler (req, res) { res.redirect(contextPath + req.route.path + query) } -async function getPluginForRequest (req) { - const packageName = req.query.package || req.body.package - const version = req.query.version || req.body.version - const mode = getModeFromRequest(req) - let chosenPlugin +async function postPluginDetailsHandler (req, res) { + let found + const { + fsPath, + githubOrg, + githubProject, + githubBranch, + npmPackage, + npmVersion, + source, + notFoundErrorUrl + } = req.body + + if (source === 'fs') { + found = await getPluginDetailsFromFileSystem(fsPath) + } else if (source === 'github') { + found = await getPluginDetailsFromGithub(githubOrg, githubProject, githubBranch) + } else if (source === 'npm' && npmVersion) { + found = await getPluginDetailsFromNpm(npmPackage, npmVersion) + } else if (source === 'npm') { + found = await getLatestPluginDetailsFromNpm(npmPackage) + } + + if (found && found.exists && found.pluginConfig) { + res.redirect(found.links.pluginDetails) + } else { + const [url, query] = notFoundErrorUrl.split('?') + const queryParts = [query].concat([ + 'fsPath', + 'githubOrg', + 'githubProject', + 'githubBranch', + 'npmPackage', + 'npmVersion', + 'source' + ].map(x => { + return x && `${encodeURIComponent(x)}=${encodeURIComponent(req.body[x])}` + })).filter(x => x) + res.redirect([url, queryParts.join('&')].join('?')) + } +} + +async function getPluginDetailsHandler (req, res, next) { + const config = getConfig() + const plugin = await getPluginDetailsFromRef(req.params.packageRef).catch(e => undefined) + + if (!plugin?.pluginConfig) { + console.warn('No page found for plugin ref', req.params.packageRef) + const err = new Error('Plugin not found') + err.status = 404 + next(err) + return + } + + if (req.originalUrl !== plugin.links.pluginDetails) { + const redirectUrl = plugin.links.pluginDetails + console.log('redirecting from:', req.originalUrl) + console.log('redirecting to:', redirectUrl) + res.redirect(redirectUrl) + return + } + + const latestVersionPromise = plugin.origin === 'NPM' ? getLatestPluginDetailsFromNpm(plugin.packageName) : Promise.resolve(undefined) + const installedVersionPromise = getInstalledPluginDetails(plugin.packageName) - if (packageName) { - chosenPlugin = buildPluginData(await lookupPackageInfo(packageName, version)) - if (!chosenPlugin) { - return // chosen plugin will be invalid + function replaceUrlVars (url) { + return url && url + .replace('{{version}}', plugin.version || plugin.latestVersion) + .replace('{{kitVersion}}', currentKitVersion) + } + + function getInThisPluginDetails () { + const list = [] + if (plugin.pluginConfig.nunjucksMacros && plugin.pluginConfig.nunjucksMacros.length > 0) { + list.push({ + title: 'Components', + items: plugin.pluginConfig.nunjucksMacros.map(x => x.macroName) + }) } - if (version) { - if (await isValidVersion(packageName, version)) { - chosenPlugin.version = version - } else if (chosenPlugin.installedLocally) { - chosenPlugin.version = chosenPlugin.installedVersion - } else { - return // chosen plugin will be invalid - } + if (plugin.pluginConfig.templates && plugin.pluginConfig.templates.length > 0) { + list.push({ + title: 'Templates', + items: plugin.pluginConfig.templates.map(x => x.name) + }) } + return list } - const dependentPlugins = (await getDependentPackages(chosenPlugin.packageName, version, mode)) - .filter(({ installed }) => installed || mode !== 'uninstall') - .map(buildPluginData) + const model = { + currentSection: 'Plugins', + links: managementLinks, + plugin, + pluginDescription: plugin?.pluginConfig?.meta?.description, + version: plugin.version, + releaseTimeSummary: plugin.releaseDateTime && getTimeSummary(new Date(plugin.releaseDateTime)), + inThisPlugin: getInThisPluginDetails(), + preparedPluginLinks: { + documentation: replaceUrlVars(plugin?.pluginConfig?.meta?.urls?.documentation), + versionHistory: replaceUrlVars(plugin?.pluginConfig?.meta?.urls?.versionHistory), + releaseNotes: replaceUrlVars(plugin?.pluginConfig?.meta?.urls?.releaseNotes) + } + } + + const epochDateBookmark = '#' + new Date().getTime() + const latestVersion = await latestVersionPromise + const installedVersion = await installedVersionPromise - if (dependentPlugins.length) { - chosenPlugin.dependentPlugins = dependentPlugins + if (latestVersion?.version && latestVersion.version !== plugin.version) { + model.newerLink = latestVersion.links.pluginDetails + model.newerVersion = latestVersion.version + } + if (installedVersion?.version && installedVersion.version !== plugin.version) { + model.installedLinkAsDifferentLink = installedVersion.links.pluginDetails + model.installedLinkAsDifferentVersion = installedVersion.version + } + if (installedVersion?.version && latestVersion?.version !== installedVersion?.version) { + model.updateLink = latestVersion?.links?.update + } + if (await isInstalled(plugin.internalRef)) { + if (!getRequiredPlugins().includes(plugin.packageName)) { + model.uninstallLink = plugin.links.uninstall + } + } else { + model.installLink = plugin.links.install + } + if (config.showPluginDowngradeButtons && installedVersion?.version !== plugin.version) { + model.installLink = plugin.links.install + model.installLinkText = 'Install this version' } - const dependencyPlugins = (await getDependencyPackages(chosenPlugin.packageName, version, mode)).map(buildPluginData) + ;['updateLink', 'uninstallLink', 'installLink'].forEach(key => { + model[key] = model[key] && model[key] + epochDateBookmark + }) - if (dependencyPlugins.length) { - chosenPlugin.dependencyPlugins = dependencyPlugins + if (config.showPluginDebugInfo) { + model.debugInfo = [ + '', + 'versions:', + '', + `viewing: ${plugin?.version}`, + `latest: ${latestVersion?.version}`, + `installed: ${installedVersion?.version}`, + '', + 'origin:', + '', + `viewing: ${plugin?.origin}`, + `latest: ${latestVersion?.origin}`, + `installed: ${installedVersion?.origin}` + ].join('\n') } - return chosenPlugin + res.set('Cache-control', 'no-cache, no-store') + + res.render(getManagementView('pluginDetails.njk'), model) +} + +async function getRelatedPluginsForUninstall (chosenPlugin) { + const installed = await getInstalledPackages() + return installed.filter(x => { + return (x.pluginConfig?.pluginDependencies || []).some(y => (y.packageName || y) === chosenPlugin.packageName) + }) } -function modeIsComplete (mode, { installedVersion, latestVersion, version, installedLocally }) { - switch (mode) { - case 'update': - return installedVersion === latestVersion - case 'install': - return installedLocally || (version ? installedVersion === version : !!installedVersion) - case 'uninstall': - return !installedVersion +async function getRelatedPluginsForInstallOrUpdate (chosenPlugin) { + const output = {} + const installed = (await getInstalledPackages()).map(x => x.packageName) + + async function addDependenciesToOutputRecursive (plugin) { + const deps = plugin.pluginConfig?.pluginDependencies || [] + const depsAsObjects = (await Promise.all(deps.map(dep => getLatestPluginDetailsFromNpm(dep.packageName || dep)))) + .filter(depObj => { + return !Object.keys(output).includes(depObj.internalRef) && !installed.includes(depObj.packageName) + }) + + depsAsObjects.forEach(x => { + output[x.internalRef] = x + }) + + await Promise.all(depsAsObjects.map(depObj => addDependenciesToOutputRecursive(depObj))) + + return output } + + await addDependenciesToOutputRecursive(chosenPlugin) + return Object.values(output) } -async function getPluginsModeHandler (req, res) { +async function getPluginsModeHandler (req, res, next) { const isSameOrigin = req.headers['sec-fetch-site'] === 'same-origin' - const mode = getModeFromRequest(req) - const { version } = req.query + const { packageRef, mode } = req.params const verb = verbs[mode] - if (!verb) { - res.status(404).send(`Page not found: ${req.path}`) - return - } + const plugin = await getPluginDetailsFromRef(packageRef) + + const err = getErrorIfModeNotAllowedForPlugin(mode, plugin) - const chosenPlugin = await getPluginForRequest(req) || plugins.preparePackageNameForDisplay(req.query.package, version) + if (err) { + return next(err) + } - const pageName = `${verb.title} ${chosenPlugin.name}` + const command = plugin?.commands && plugin?.commands[mode] - const templatesReturnLink = { - href: '/manage-prototype/templates', - text: 'Back to templates' + if (!plugin) { + const err = new Error('Plugin not found.') + err.status = 404 + return next(err) } - const pluginsReturnLink = { - href: '/manage-prototype/plugins', - text: 'Back to plugins' + + if (!command) { + const err = new Error(`Command not found for mode "${mode}", options are ${Object.keys(plugin?.commands || {}).join(', ')}`) + err.status = 404 + return next(err) } - const returnLink = req.query.returnTo === 'templates' ? templatesReturnLink : pluginsReturnLink + const pageName = `${verb.title} ${plugin.name}` - const fullPluginName = `${chosenPlugin.name}${chosenPlugin.version ? ` version ${chosenPlugin.version} ` : ''}${chosenPlugin.scope ? ` from ${chosenPlugin.scope}` : ''}` + let returnLink + let cancelLink = plugin?.links.pluginDetails + + if (req.query.returnTo === 'templates') { + returnLink = { + href: `${contextPath}/templates`, + text: 'Back to templates' + } + cancelLink = returnLink.href + } else if (mode === 'uninstall') { + returnLink = { + href: `${contextPath}/plugins`, + text: 'Back to plugins' + } + } else { + returnLink = { + href: `${contextPath}/plugin/installed:${encodeURIComponent(plugin.packageName)}`, + text: 'Back to plugin details' + } + } - const pluginHeading = `${verb.title} ${fullPluginName}` let dependencyHeading = '' - if (chosenPlugin?.dependentPlugins?.length) { - dependencyHeading = `Other plugins need ${fullPluginName}` - } else if (chosenPlugin?.dependencyPlugins?.length) { - dependencyHeading = `${fullPluginName} needs other plugins` + const relatedPlugins = mode === 'uninstall' ? await getRelatedPluginsForUninstall(plugin) : await getRelatedPluginsForInstallOrUpdate(plugin) + + if (relatedPlugins.length > 0) { + const plural = relatedPlugins.length > 1 + dependencyHeading = `To ${mode} this plugin, you also need to ${mode === 'update' ? 'install' : mode} ${(plural ? 'other plugins' : 'another plugin')}` } res.render(getManagementView('plugin-install-or-uninstall.njk'), { @@ -620,115 +748,90 @@ async function getPluginsModeHandler (req, res) { pageName, currentUrl: req.originalUrl, links: managementLinks, - chosenPlugin, - command: getCommand(mode, chosenPlugin), - pluginHeading, + plugin, + command, dependencyHeading, verb, isSameOrigin, - returnLink + returnLink, + cancelLink, + relatedPlugins }) } -function setKitRestarted (state) { - kitRestarted = state -} +async function queueCommand (command) { + await fse.ensureDir(commandsDir) -function getModeFromRequest (req) { - const { mode } = req.params - if (mode === 'upgrade') { - return 'update' - } - return mode + const commandId = new Date().getTime() + const filePath = path.join(commandsDir, commandId + '.json') + + await fse.writeJson(filePath, { command, restartOnCompletion: true, status: 'pending' }) + return commandId } -async function postPluginsStatusHandler (req, res) { - const mode = getModeFromRequest(req) - let status = 'processing' - try { - if (kitRestarted) { - const chosenPlugin = await getPluginForRequest(req) - if (chosenPlugin) { - if (modeIsComplete(mode, chosenPlugin)) { - status = 'completed' - } else if (chosenPlugin.installedLocally && mode === 'uninstall') { - status = 'completed' - } - } +function getErrorIfModeNotAllowedForPlugin (mode, plugin) { + if (mode === 'uninstall') { + if (getRequiredPlugins().includes(plugin.packageName)) { + const err = new Error('Uninstall restricted for this plugin') + err.status = 403 + return err } - } catch (e) { - if (mode !== 'uninstall') { - console.log(e) - } - } - if (status === 'completed') { - setKitRestarted(false) } - res.json({ status }) } -async function postPluginsModeMiddleware (req, res, next) { - // Redirect to the GET route of the same url when the post request is not an ajax request - if (req.headers['content-type'].indexOf('json') === -1) { - res.redirect(req.originalUrl) - } else { - next() - } -} +async function runPluginMode (req, res, next) { + const { mode, packageRef } = req.params + const plugin = await getPluginDetailsFromRef(packageRef) -async function postPluginsModeHandler (req, res) { - const mode = getModeFromRequest(req) + const err = getErrorIfModeNotAllowedForPlugin(mode, plugin) - // Allow smooth update from 13.1.0 as the status route is incorrectly matched - if (mode === 'status') { - req.params.mode = 'update' - return postPluginsStatusHandler(req, res) + if (err) { + return next(err) } - // Reset to false so the status route will only return completed when the prototype has restarted - setKitRestarted(false) + let command = plugin.commands && plugin.commands[mode] - const verb = verbs[mode] - - if (!verb) { - res.json({ status: 'error' }) - return + if (mode === 'uninstall') { + const related = await getRelatedPluginsForUninstall(plugin) + command = command.replace('npm uninstall ', `npm uninstall ${related.map(x => x.packageName).join(' ')} `) + } else { + const related = await getRelatedPluginsForInstallOrUpdate(plugin) + command = command.replace('npm install ', `npm install ${related.map(x => x.packageName).join(' ')} `) } - // Prevent uninstalling the kit itself - if (req.body.package === currentKitName && mode === 'uninstall') { - res.json({ status: 'error' }) - return - } + const commandId = await queueCommand(command) - let status = 'processing' + res.send({ + mode, + commandId, + statusUrl: `${contextPath}/command/${commandId}/status` + }) +} + +async function getCommandStatus (req, res) { + const { commandId } = req.params try { - const chosenPlugin = await getPluginForRequest(req) - if (!chosenPlugin) { - status = 'error' - } else if (modeIsComplete(mode, chosenPlugin)) { - status = 'completed' - } else { - const command = getCommand(mode, chosenPlugin) - await exec(command, { cwd: projectDir }) - .finally(() => { - console.log(`Completed ${command}`) - // force the application to stop after a delay as nodemon restart does not always work on Windows when running acceptance tests - setTimeout(() => { - process.exit(1) - }, 6000) - }) - } + const status = await fse.readJson(path.join(commandsDir, commandId + '.json')) + res.send(status) } catch (e) { - console.log(e) - status = 'error' + console.error(e) + res.status(400).send(e) + } +} + +function getRequiredPlugins () { + const output = knownPlugins.required.filter(pluginName => pluginName !== 'govuk-frontend' || !getConfig().allowGovukFrontendUninstall) + return output +} + +function legacyUpdateStatusCompatibilityHandler (req, res) { + if (req.body.package === 'govuk-prototype-kit') { + res.send({ status: 'completed' }) } - res.json({ status }) } module.exports = { contextPath, - setKitRestarted, csrfProtection: [doubleCsrfProtection, csrfErrorHandler], getPageLoadedHandler, getCsrfTokenHandler, @@ -737,7 +840,6 @@ module.exports = { getPasswordHandler, postPasswordHandler, developmentOnlyMiddleware, - pluginCacheMiddleware, getHomeHandler, getTemplatesHandler, getTemplatesViewHandler, @@ -746,8 +848,10 @@ module.exports = { getTemplatesPostInstallHandler, getPluginsHandler, postPluginsHandler, + getPluginDetailsHandler, + postPluginDetailsHandler, getPluginsModeHandler, - postPluginsStatusHandler, - postPluginsModeMiddleware, - postPluginsModeHandler + getCommandStatus, + runPluginMode, + legacyUpdateStatusCompatibilityHandler } diff --git a/lib/manage-prototype-handlers.test.js b/lib/manage-prototype-handlers.test.js index b2b3b010b6..da666dd837 100644 --- a/lib/manage-prototype-handlers.test.js +++ b/lib/manage-prototype-handlers.test.js @@ -9,34 +9,21 @@ const nunjucksConfiguration = require('./nunjucks/nunjucksConfiguration') // local dependencies const config = require('./config') -const { requestHttpsJson } = require('./utils/requestHttps') -const exec = require('./exec') const plugins = require('./plugins/plugins') -const packages = require('./plugins/packages') -const projectPackage = require('../package.json') -const knownPlugins = require('../known-plugins.json') + +const pluginDetails = require('./utils/packageDetails') const { - setKitRestarted, getPasswordHandler, getClearDataHandler, - getHomeHandler, postClearDataHandler, postPasswordHandler, developmentOnlyMiddleware, - getTemplatesHandler, getTemplatesViewHandler, getTemplatesInstallHandler, postTemplatesInstallHandler, - getTemplatesPostInstallHandler, - getPluginsHandler, - postPluginsStatusHandler, - postPluginsModeMiddleware, - getPluginsModeHandler, - postPluginsModeHandler, - postPluginsHandler + getTemplatesPostInstallHandler, getHomeHandler, getTemplatesHandler } = require('./manage-prototype-handlers') -const { projectDir } = require('./utils/paths') // mocked dependencies jest.mock('../package.json', () => { @@ -87,34 +74,6 @@ jest.mock('./plugins/plugins', () => { } }) -jest.mock('./plugins/packages', () => { - const availablePackage = { - packageName: 'test-package', - installed: false, - available: true, - required: false, - latestVersion: '2.0.0', - versions: [ - '2.0.0', - '1.0.0' - ], - packageJson: {} - } - return { - lookupPackageInfo: jest.fn().mockImplementation((packageName) => { - if (packageName === availablePackage.packageName) { - return availablePackage - } else { - return undefined - } - }), - getInstalledPackages: jest.fn().mockResolvedValue([]), - getAllPackages: jest.fn().mockResolvedValue([availablePackage]), - getDependentPackages: jest.fn().mockResolvedValue([]), - getDependencyPackages: jest.fn().mockResolvedValue([]) - } -}) - jest.mock('./exec', () => { return { exec: jest.fn().mockReturnValue({ finally: jest.fn() }) @@ -199,11 +158,26 @@ describe('manage-prototype-handlers', () => { }) it('getHomeHandler', async () => { - packages.lookupPackageInfo.mockResolvedValue({ packageName: 'govuk-prototype-kit', latestVersion: '1.0.0' }) + jest.spyOn(pluginDetails, 'getLatestPluginDetailsFromNpm').mockImplementation((packageName) => { + if (packageName === 'govuk-prototype-kit') { + return { + version: '99.99.1', + links: { + pluginDetails: '/abc' + } + } + } + }) + await getHomeHandler(req, res) expect(res.render).toHaveBeenCalledWith( 'views/manage-prototype/index.njk', - expect.objectContaining({ currentSection: 'Home', latestAvailableKit: '1.0.0' }) + expect.objectContaining({ + currentSection: 'Home', + latestAvailableKit: '99.99.1', + kitUpdateAvailable: true, + latestKitUrl: '/abc' + }) ) }) @@ -225,7 +199,6 @@ describe('manage-prototype-handlers', () => { describe('templates handlers', () => { const packageName = 'test-package' const templateName = 'A page with everything' - const pluginDisplayName = { name: 'Test Package' } const templatePath = '/template' const encodedTemplatePath = encodeURIComponent(templatePath) const view = 'Test View' @@ -252,6 +225,7 @@ describe('manage-prototype-handlers', () => { }) it('getTemplatesHandler', async () => { + jest.spyOn(pluginDetails, 'getInstalledPackages').mockResolvedValue([]) await getTemplatesHandler(req, res) expect(res.render).toHaveBeenCalledWith( 'views/manage-prototype/templates.njk', @@ -259,7 +233,7 @@ describe('manage-prototype-handlers', () => { currentSection: 'Templates', availableTemplates: [{ packageName, - pluginDisplayName, + pluginDisplayName: { name: 'Test Package' }, templates: [{ installLink: `/manage-prototype/templates/install?package=${packageName}&template=${encodedTemplatePath}`, name: templateName, @@ -377,7 +351,7 @@ describe('manage-prototype-handlers', () => { }).forEach(testPostTemplatesInstallHandler) // Test each invalid character - "!$&'()*+,;=:?#[]@.% " + '!$&\'()*+,;=:?#[]@.% ' .split('') .map(invalidCharacter => ['invalid', `/${invalidCharacter}/abc`]) .forEach(testPostTemplatesInstallHandler) @@ -398,254 +372,4 @@ describe('manage-prototype-handlers', () => { ) }) }) - - describe('plugins handlers', () => { - const csrfToken = 'x-csrf-token' - const packageName = 'test-package' - const latestVersion = '2.0.0' - const previousVersion = '1.0.0' - const pluginDisplayName = { name: 'Test Package' } - const availablePlugin = { - installCommand: `npm install ${packageName}`, - installLink: `/manage-prototype/plugins/install?package=${packageName}`, - latestVersion, - name: pluginDisplayName.name, - packageName, - uninstallCommand: `npm uninstall ${packageName}`, - updateCommand: `npm install ${packageName}@${latestVersion}` - } - - beforeEach(() => { - knownPlugins.plugins = { available: [packageName] } - projectPackage.dependencies = {} - const versions = {} - versions[latestVersion] = {} - versions[previousVersion] = {} - requestHttpsJson.mockResolvedValue({ - name: packageName, - 'dist-tags': { - latest: latestVersion, - 'latest-1': previousVersion - }, - versions - }) - // mocking the reading of the local package.json - fse.readJsonSync.mockReturnValue(undefined) - packages.lookupPackageInfo.mockResolvedValue(Promise.resolve(availablePlugin)) - res.json = jest.fn().mockReturnValue({}) - }) - - describe('getPluginsHandler', () => { - it('plugins installed', async () => { - fse.readJsonSync.mockReturnValue(undefined) - req.route.path = 'plugins-installed' - await getPluginsHandler(req, res) - expect(res.render).toHaveBeenCalledWith( - 'views/manage-prototype/plugins.njk', - expect.objectContaining({ - currentSection: 'Plugins', - isSearchPage: false, - isInstalledPage: true, - plugins: [], - status: 'installed' - }) - ) - }) - it('plugins available', async () => { - fse.readJsonSync.mockReturnValue(undefined) - req.route.path = 'plugins' - await getPluginsHandler(req, res) - expect(res.render).toHaveBeenCalledWith( - 'views/manage-prototype/plugins.njk', - expect.objectContaining({ - currentSection: 'Plugins', - isSearchPage: true, - isInstalledPage: false, - plugins: [availablePlugin], - status: 'search' - }) - ) - }) - }) - - it('postPluginsHandler', async () => { - const search = 'task list' - const routePath = '/plugins-installed' - const fullPath = '/manage-prototype' + routePath - req.body.search = search - req.route.path = routePath - await postPluginsHandler(req, res) - expect(res.redirect).toHaveBeenCalledWith(fullPath + '?search=' + search) - }) - - it('getPluginsModeHandler', async () => { - req.params.mode = 'install' - req.query.package = packageName - req.csrfToken = jest.fn().mockReturnValue(csrfToken) - await getPluginsModeHandler(req, res) - expect(res.render).toHaveBeenCalledWith( - 'views/manage-prototype/plugin-install-or-uninstall.njk', - expect.objectContaining({ - chosenPlugin: availablePlugin, - command: `npm install ${packageName} --save-exact`, - currentSection: 'Plugins', - pageName: `Install ${pluginDisplayName.name}`, - currentUrl: req.originalUrl, - isSameOrigin: false, - returnLink: { - href: '/manage-prototype/plugins', - text: 'Back to plugins' - } - }) - ) - }) - - describe('postPluginsModeHandler', () => { - beforeEach(() => { - req.params.mode = 'install' - req.body.package = packageName - }) - - it('processing', async () => { - await postPluginsModeHandler(req, res) - expect(exec.exec).toHaveBeenCalledWith( - availablePlugin.installCommand + ' --save-exact', - { cwd: projectDir } - ) - expect(res.json).toHaveBeenCalledWith( - expect.objectContaining({ - status: 'processing' - }) - ) - }) - - it('processing specific version', async () => { - packages.lookupPackageInfo.mockResolvedValue({ - packageName: 'test-package', - installed: false, - versions: ['1.0.0'] - }) - req.body.version = previousVersion - const installSpecificCommand = availablePlugin.installCommand + `@${previousVersion}` - await postPluginsModeHandler(req, res) - expect(exec.exec).toHaveBeenCalledWith( - installSpecificCommand + ' --save-exact', - { cwd: projectDir } - ) - expect(res.json).toHaveBeenCalledWith( - expect.objectContaining({ - status: 'processing' - }) - ) - }) - - it('error invalid package', async () => { - packages.lookupPackageInfo.mockResolvedValue(undefined) - req.body.package = 'invalid-package' - await postPluginsModeHandler(req, res) - expect(exec.exec).not.toHaveBeenCalled() - expect(res.json).toHaveBeenCalledWith( - expect.objectContaining({ - status: 'error' - }) - ) - }) - - it('error invalid version', async () => { - req.body.version = '1.0.0-invalid' - await postPluginsModeHandler(req, res) - expect(exec.exec).not.toHaveBeenCalled() - expect(res.json).toHaveBeenCalledWith( - expect.objectContaining({ - status: 'error' - }) - ) - }) - - it('is passed on to the postPluginsStatusHandler when status matches mode during update from 13.1 to 13.2.4 and upwards', async () => { - req.params.mode = 'status' - setKitRestarted(true) - await postPluginsModeHandler(req, res) - - // req.params.mode should change to update - expect(req.params.mode).toEqual('update') - - expect(res.json).toHaveBeenCalledWith( - expect.objectContaining({ - status: 'processing' - }) - ) - }) - }) - - describe('postPluginsStatusHandler', () => { - let pkg - - beforeEach(() => { - req.params.mode = 'install' - req.query.package = packageName - pkg = { - name: packageName, - version: latestVersion, - dependencies: { [packageName]: latestVersion } - } - fse.readJsonSync.mockReturnValue(pkg) - }) - - it('is processing', async () => { - await postPluginsStatusHandler(req, res) - expect(res.json).toHaveBeenCalledWith( - expect.objectContaining({ - status: 'processing' - }) - ) - }) - - it('is completed', async () => { - packages.lookupPackageInfo.mockResolvedValue({ - packageName: 'test-package', - installedVersion: '2.0.0' - }) - setKitRestarted(true) - await postPluginsStatusHandler(req, res) - expect(res.json).toHaveBeenCalledWith( - expect.objectContaining({ - status: 'completed' - }) - ) - }) - - it('uninstall local plugin is completed', async () => { - const localPlugin = 'local-plugin' - req.params.mode = 'uninstall' - req.query.package = localPlugin - pkg.dependencies[localPlugin] = 'file:../../local-plugin' - packages.lookupPackageInfo.mockResolvedValue({ - packageName: 'test-package', - installed: false - }) - setKitRestarted(true) - await postPluginsStatusHandler(req, res) - expect(res.json).toHaveBeenCalledWith( - expect.objectContaining({ - status: 'completed' - }) - ) - }) - }) - - describe('postPluginsModeMiddleware', () => { - it('with AJAX', async () => { - req.headers['content-type'] = 'application/json' - await postPluginsModeMiddleware(req, res, next) - expect(next).toHaveBeenCalled() - }) - - it('without AJAX', async () => { - req.headers['content-type'] = 'document/html' - await postPluginsModeMiddleware(req, res, next) - expect(res.redirect).toHaveBeenCalledWith(req.originalUrl) - }) - }) - }) }) diff --git a/lib/manage-prototype-routes.js b/lib/manage-prototype-routes.js index 5ad9460d12..20b093369d 100644 --- a/lib/manage-prototype-routes.js +++ b/lib/manage-prototype-routes.js @@ -3,7 +3,6 @@ const express = require('express') const { contextPath, - setKitRestarted, csrfProtection, getPageLoadedHandler, getCsrfTokenHandler, @@ -19,12 +18,13 @@ const { postTemplatesInstallHandler, getTemplatesPostInstallHandler, getPluginsHandler, + postPluginsHandler, + getPluginDetailsHandler, + postPluginDetailsHandler, + runPluginMode, + getCommandStatus, getPluginsModeHandler, - postPluginsModeMiddleware, - postPluginsModeHandler, - postPluginsStatusHandler, - pluginCacheMiddleware, - postPluginsHandler + legacyUpdateStatusCompatibilityHandler } = require('./manage-prototype-handlers') const path = require('path') const { getInternalGovukFrontendDir } = require('./utils') @@ -51,8 +51,6 @@ router.post('/password', postPasswordHandler) // view when the prototype is not running in development router.use(developmentOnlyMiddleware) -router.use(pluginCacheMiddleware) - router.get('/', getHomeHandler) router.get('/templates', getTemplatesHandler) @@ -69,15 +67,16 @@ router.get('/plugins', getPluginsHandler) router.post('/plugins', postPluginsHandler) router.get('/plugins-installed', getPluginsHandler) -// Be aware that changing this path for monitoring the status of a plugin will affect the -// kit update process as the browser request and server route would be out of sync. -router.post('/plugins/:mode/status', postPluginsStatusHandler) - -router.get('/plugins/:mode', csrfProtection, getPluginsModeHandler) +router.get('/plugin/:packageRef', getPluginDetailsHandler) +router.post('/plugin', postPluginDetailsHandler) +router.get('/plugin/:packageRef/:mode', getPluginsModeHandler) +router.post('/plugin/:packageRef/:mode', csrfProtection, runPluginMode) -router.post('/plugins/:mode', postPluginsModeMiddleware) +router.get('/command/:commandId/status', getCommandStatus) -router.post('/plugins/:mode', csrfProtection, postPluginsModeHandler) +// // Be aware that changing this path for monitoring the status of a plugin will affect the +// // kit update process as the browser request and server route would be out of sync. +router.post('/plugins/:mode', legacyUpdateStatusCompatibilityHandler) const partialGovukFrontendUrls = [ 'govuk/assets', @@ -88,6 +87,17 @@ partialGovukFrontendUrls.forEach(url => { router.use(`/dependencies/govuk-frontend/${url}`, express.static(path.join(getInternalGovukFrontendDir(), url))) }) -setKitRestarted(true) +router.use((err, req, res, next) => { + if (err.status === 404) { + next(err) + } else { + res.status(err.status || 500).render('views/error-handling/server-error.njk', { + error: { + message: err.message, + errorStack: err.stack + } + }) + } +}) module.exports = router diff --git a/lib/nunjucks/views/manage-prototype/index.njk b/lib/nunjucks/views/manage-prototype/index.njk index ece33f1f15..f394c194e7 100644 --- a/lib/nunjucks/views/manage-prototype/index.njk +++ b/lib/nunjucks/views/manage-prototype/index.njk @@ -13,8 +13,8 @@
{% if kitUpdateAvailable %}- - New version available: {{ latestAvailableKit }} + + New version available: v{{ latestAvailableKit }}
{% endif %} diff --git a/lib/nunjucks/views/manage-prototype/lookup-plugin.njk b/lib/nunjucks/views/manage-prototype/lookup-plugin.njk new file mode 100644 index 0000000000..ab2d9d73f9 --- /dev/null +++ b/lib/nunjucks/views/manage-prototype/lookup-plugin.njk @@ -0,0 +1,128 @@ +{% from "govuk/components/radios/macro.njk" import govukRadios %} +{% from "govuk/components/input/macro.njk" import govukInput %} +{% from "govuk/components/error-summary/macro.njk" import govukErrorSummary %} + +{% set npm %} + {{ govukInput({ + id: "npm-package", + name: "npmPackage", + value: playback.npmPackage, + spellcheck: false, + label: { + text: "Package name" + }, + hint: { + text: "The package name is what you'd normally put after 'npm install'." + } + }) }} + {{ govukInput({ + id: "npm-version", + name: "npmVersion", + value: playback.npmVersion, + spellcheck: false, + classes: "govuk-!-width-one-third", + label: { + text: "Version number" + }, + hint: { + text: "If you want to install a specific version you can enter it here e.g. 5.0.1" + } + }) }} +{% endset -%} + +{% set github %} + {{ govukInput({ + id: "github-org", + name: "githubOrg", + value: playback.githubOrg, + spellcheck: false, + label: { + text: "The organisation in Github" + } + }) }} + {{ govukInput({ + id: "github-project", + name: "githubProject", + value: playback.githubProject, + spellcheck: false, + label: { + text: "The project name in Github" + } + }) }} + {{ govukInput({ + id: "github-branch", + name: "githubBranch", + value: playback.githubBranch, + spellcheck: false, + label: { + text: "The branch name in Github (optional)" + } + }) }} +{% endset -%} + +{% set fs %} + {{ govukInput({ + id: "fs-path", + spellcheck: false, + name: "fsPath", + value: playback.fsPath, + label: { + text: "File system path" + }, + hint: { + text: "Use this if you're working on a plugin and want to try it before releasing." + } + }) }} +{% endset -%} + +{% if playback.error == 'plugin-lookup-no-results' %} +{{ govukErrorSummary({ + titleText: "There is a problem", + errorList: [ + { + text: "The package you requested couldn't be found" + } + ] +}) }} +{% endif %} + diff --git a/lib/nunjucks/views/manage-prototype/plugin-header.njk b/lib/nunjucks/views/manage-prototype/plugin-header.njk new file mode 100644 index 0000000000..89466e5701 --- /dev/null +++ b/lib/nunjucks/views/manage-prototype/plugin-header.njk @@ -0,0 +1,9 @@ +v{{ plugin.version }}
+ {% if plugin.scope %} +By {{ plugin.scope }}
+ {% endif %} +- To {{ verb.para }} {{ chosenPlugin.name }} you also need to {{ verb.dependencyPara }} {% if chosenPlugin.dependentPlugins | length > 1 %}these plugins{% else %}this plugin{% endif %}. -
- -diff --git a/lib/nunjucks/views/manage-prototype/pluginDetails.njk b/lib/nunjucks/views/manage-prototype/pluginDetails.njk new file mode 100644 index 0000000000..62a0f90393 --- /dev/null +++ b/lib/nunjucks/views/manage-prototype/pluginDetails.njk @@ -0,0 +1,88 @@ +{% extends "views/manage-prototype/layout.njk" %} +{% from "govuk/components/button/macro.njk" import govukButton %} + +{% block beforeContent %} + {{ super() }} + Back to plugins +{% endblock %} + +{% block content %} +
+{% endblock %} diff --git a/lib/nunjucks/views/manage-prototype/plugins.njk b/lib/nunjucks/views/manage-prototype/plugins.njk index 1cb2129512..d43eca1b7e 100644 --- a/lib/nunjucks/views/manage-prototype/plugins.njk +++ b/lib/nunjucks/views/manage-prototype/plugins.njk @@ -5,7 +5,6 @@ {% from "govuk/components/tag/macro.njk" import govukTag %} {% block content %} -