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 %} +
+{{ govukRadios({ + name: "source", + value: playback.source, + fieldset: { + legend: { + text: "Install a package that's not on the list", + isPageHeading: true, + classes: "govuk-fieldset__legend--l" + } + }, + hint: { + text: "Select one option." + }, + items: [ + { + value: "npm", + text: "NPM", + conditional: { + html: npm + } + }, + { + value: "github", + text: "Github", + conditional: { + html: github + } + }, + { + value: "fs", + text: "File system", + conditional: { + html: fs + } + } + ] +}) }} + + {{ govukButton({text: "Lookup"}) }} +
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 @@ +
+
+

{{ plugin.name }}

+

v{{ plugin.version }}

+ {% if plugin.scope %} +

By {{ plugin.scope }}

+ {% endif %} +
+
diff --git a/lib/nunjucks/views/manage-prototype/plugin-install-or-uninstall.njk b/lib/nunjucks/views/manage-prototype/plugin-install-or-uninstall.njk index 866ddbe1cb..b3e61f9d65 100644 --- a/lib/nunjucks/views/manage-prototype/plugin-install-or-uninstall.njk +++ b/lib/nunjucks/views/manage-prototype/plugin-install-or-uninstall.njk @@ -1,20 +1,38 @@ {% extends "views/manage-prototype/layout.njk" %} {% from "govuk/components/button/macro.njk" import govukButton %} +{% from "govuk/components/notification-banner/macro.njk" import govukNotificationBanner %} {% block content %}
-

Plugins

- -
-

{{ pluginHeading }}

+
+ {% include "views/manage-prototype/plugin-header.njk" %}
{% if dependencyHeading %} -
-

{{ dependencyHeading }}

-
+
+ + {% set html %} +

+ {{ dependencyHeading }} +

+ + {% if relatedPlugins|length %} +
+
    + {% for plugin in relatedPlugins %} +
  • {{ plugin.name }}{% if plugin.scope %} from {{ plugin.scope }} {% endif %}
  • + {% endfor %} +
+
+ {% endif %} + {% endset %} + + {{ govukNotificationBanner({ + html: html + }) }} +
{% endif %}
@@ -33,31 +51,17 @@
- - {% if chosenPlugin.dependencyPlugins|length or chosenPlugin.dependentPlugins|length %} + {% if relatedPlugins|length %}
- -
    - {% for plugin in chosenPlugin.dependencyPlugins %} -
  • {{ plugin.name }}{% if plugin.scope %} from {{ plugin.scope }} {% endif %}
  • - {% endfor %} - {% for plugin in chosenPlugin.dependentPlugins %} -
  • {{ plugin.name }}{% if plugin.scope %} from {{ plugin.scope }} {% endif %}
  • - {% endfor %} -
- -

- To {{ verb.para }} {{ chosenPlugin.name }} you also need to {{ verb.dependencyPara }} {% if chosenPlugin.dependentPlugins | length > 1 %}these plugins{% else %}this plugin{% endif %}. -

-
- {{ govukButton({ - text: verb.title + ' these plugins', - attributes: { id: "plugin-action-button" } - }) }} - - Cancel {{ verb.para }} +
+ {{ govukButton({ + text: verb.title + ' these plugins', + attributes: { id: "plugin-action-button" } + }) }} + + Cancel {{ verb.para }} +
-
{% elseif not isSameOrigin %}

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 %} +

+ {% include "views/manage-prototype/plugin-header.njk" %} + +
+
+ {% if newerLink %} +

Latest version: {{ newerVersion }}

+ {% endif %} + {% if installedLinkAsDifferentLink %} +

Installed version: {{ installedLinkAsDifferentVersion }}

+ {% endif %} + + {% if installLink %} + {{ govukButton({ + text: installLinkText | default('Install'), + href: installLink, + classes: 'govuk-prototype-kit-plugin-install-button' + }) }} + {% endif %} + {% if updateLink %} + {{ govukButton({ + text: 'Update', + href: updateLink, + classes: 'govuk-prototype-kit-plugin-update-button' + }) }} + {% endif %} + {% if uninstallLink %} + {{ govukButton({ + text: 'Uninstall', + classes: 'govuk-button--secondary govuk-prototype-kit-plugin-uninstall-button', + href: uninstallLink + }) }} + {% endif %} + + {% if plugin.releaseDateTime or preparedPluginLinks.releaseNotes or preparedPluginLinks.versionHistory %} + + {% endif %} +

Report

+ {% if debugInfo %} +
+                   {{ debugInfo }}
+                  
+ {% endif %} +
+ +
+

About this plugin

+ +

{{ pluginDescription | default('No description has been provided.') }}

+ + {% if preparedPluginLinks.documentation %} +

How to use this plugin

+ {% endif %} + + {% if inThisPlugin.length %} +

Included in this plugin

+ {% for section in inThisPlugin %} +

{{ section.title }}

+
    + {% for item in section.items %} +
  • {{ item }}
  • + {% endfor %} +
+ {% endfor %} + {% endif %} +
+
+
+{% 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 %} -

Plugins

@@ -44,6 +43,7 @@
+ {% if isSearchPage %}
@@ -88,28 +88,37 @@

- {{ plugin.name }} + {{ plugin.name }}

{% if plugin.scope %}
By {{ plugin.scope }}
{% endif %} - {% if plugin.installedVersion %} + {% if plugin.isInstalled %}
v{{ plugin.installedVersion }}
{% endif %}
- {% if isSearchPage and plugin.installedVersion %} + {% if isSearchPage and plugin.isInstalled %}

{{ govukTag({ text: "Installed", - classes: "govuk-tag--grey" + classes: "govuk-tag--grey govuk-prototype-kit-installed" }) }}

{% endif %} + {% if plugin.updateAvailable %} +

+ + {{ govukTag({ + html: 'Update available', + classes: "govuk-tag--grey govuk-prototype-kit-update-available" + }) }} +

+ {% endif %} {% if plugin.description %}
{{ plugin.description }} @@ -117,27 +126,6 @@ {% endif %}
- {% if not plugin.installedVersion %} - {{ govukButton({ - html: 'Install ' + plugin.name + '', - attributes: { id: "install-" + plugin.packageName, formaction: plugin.installLink } - }) }} - {% endif %} - {% if isInstalledPage %} - {% if plugin.updateLink %} - {{ govukButton({ - html: 'Update ' + plugin.name + '', - attributes: { id: "update-" + plugin.packageName, formaction: plugin.updateLink } - }) }} - {% endif %} - {% if plugin.uninstallLink %} - {{ govukButton({ - html: 'Uninstall ' + plugin.name + '', - classes: "govuk-button--secondary", - attributes: { id: "uninstall-" + plugin.packageName, formaction: plugin.uninstallLink } - }) }} - {% endif %} - {% endif %} {% if plugin.helpLink %} Help {% endfor %} + + {% if showPluginLookup %} + {% include "views/manage-prototype/lookup-plugin.njk" %} + {% endif %}
- {% endblock %} diff --git a/lib/nunjucks/views/manage-prototype/scripts.njk b/lib/nunjucks/views/manage-prototype/scripts.njk index 92673dbc78..4b09283f9d 100644 --- a/lib/nunjucks/views/manage-prototype/scripts.njk +++ b/lib/nunjucks/views/manage-prototype/scripts.njk @@ -1,3 +1,3 @@ + - diff --git a/lib/nunjucks/views/manage-prototype/templates.njk b/lib/nunjucks/views/manage-prototype/templates.njk index 492c97be31..3aaa5d0074 100644 --- a/lib/nunjucks/views/manage-prototype/templates.njk +++ b/lib/nunjucks/views/manage-prototype/templates.njk @@ -16,7 +16,7 @@ {{ govukButton({ text: 'Install common templates', classes: "govuk-button--secondary", - href: commonTemplatesDetails.installLink + href: commonTemplatesDetails.installLink + '?returnTo=templates' }) }} {% endif %} diff --git a/lib/plugins/packages.js b/lib/plugins/packages.js deleted file mode 100644 index 17ec890edf..0000000000 --- a/lib/plugins/packages.js +++ /dev/null @@ -1,279 +0,0 @@ -// core dependencies -const path = require('path') - -// npm dependencies -const fse = require('fs-extra') - -// local dependencies -const { startPerformanceTimer, endPerformanceTimer } = require('../utils/performance') -const { packageDir, projectDir } = require('../utils/paths') -const { requestHttpsJson } = require('../utils/requestHttps') -const { verboseLog } = require('../utils/verboseLogger') -const knownPlugins = require(path.join(packageDir, 'known-plugins.json')) -const projectPackage = require(path.join(projectDir, 'package.json')) -const config = require('../config') -const { getConfigForPackage } = require('../utils/requestHttps') -const { getProxyPluginConfig } = require('./plugin-utils') -const { sortByObjectKey } = require('../utils') - -let packageTrackerInterval - -const packagesCache = {} - -async function startPackageTracker () { - await updatePackagesInfo() - packageTrackerInterval = setInterval(updatePackagesInfo, 36000) -} - -async function updatePackagesInfo () { - const availablePlugins = knownPlugins?.plugins?.available || [] - const packagesRequired = [...availablePlugins, ...Object.keys(projectPackage.dependencies)] - return Promise.all(packagesRequired.map(async (packageName) => refreshPackageInfo(packageName))) -} - -async function readJson (filename) { - return await fse.pathExists(filename) ? fse.readJson(filename) : undefined -} - -async function requestRegistryInfo (packageName) { - const timer = startPerformanceTimer() - try { - const registryInfoUrl = `https://registry.npmjs.org/${encodeURIComponent(packageName)}` - verboseLog(`looking up ${registryInfoUrl}`) - const registryInfo = await requestHttpsJson(registryInfoUrl) - verboseLog(`retrieved ${registryInfoUrl}`) - endPerformanceTimer('lookupPackageInfo (success)', timer) - return registryInfo - } catch (e) { - endPerformanceTimer('lookupPackageInfo (failure)', timer) - verboseLog('ignoring error', e.message) - return undefined - } -} - -async function refreshPackageInfo (packageName, version) { - const packageDir = path.join(projectDir, 'node_modules', packageName) - const pluginConfigFile = path.join(packageDir, 'govuk-prototype-kit.config.json') - - const requiredPlugins = knownPlugins?.plugins?.required || [] - - const required = (!(packageName === 'govuk-frontend' && config.getConfig().allowGovukFrontendUninstall)) && requiredPlugins.includes(packageName) - - let [ - packageJson, - pluginConfig, - registryInfo - ] = await Promise.all([ - readJson(path.join(packageDir, 'package.json')), - readJson(pluginConfigFile), - requestRegistryInfo(packageName) - ]) - - if ([packageJson, pluginConfig, registryInfo, version].every(val => !val)) { - return undefined - } - - const distTags = registryInfo ? registryInfo['dist-tags'] : undefined - let latestVersion = distTags?.latest - if (distTags && config.getConfig().showPrereleases) { - latestVersion = Object.values(distTags) - .map(version => ({ version, date: registryInfo.time[version] })) - .sort(sortByObjectKey('date')) - .at(-1) - .version - } - const versions = registryInfo ? Object.keys(registryInfo.versions) : [] - - const installedPackageVersion = packageJson && projectPackage.dependencies[packageName] - const installed = !!installedPackageVersion - const installedLocally = installedPackageVersion?.startsWith('file:') - const installedVersion = installed ? packageJson?.version : undefined - - let localVersion - - if (!installed) { - // Retrieve the packageJson and pluginConfig from the registry if possible - if (registryInfo) { - packageJson = registryInfo?.versions ? registryInfo?.versions[latestVersion] : undefined - pluginConfig = await getConfigForPackage(packageName) - } else if (version) { - packageJson = await readJson(path.join(path.relative(projectDir, version), 'package.json')) - pluginConfig = await readJson(path.join(path.relative(projectDir, version), 'govuk-prototype-kit.config.json')) - if (packageJson) { - localVersion = version - } else { - return undefined - } - } - } - - const available = !!knownPlugins?.plugins?.available.includes(packageName) - - if (!pluginConfig && getProxyPluginConfig(packageName)) { - // Use the proxy pluginConfig if exists when no other plugin config can be found - pluginConfig = getProxyPluginConfig(packageName) - } - - if (installedLocally) { - localVersion = path.resolve(installedPackageVersion.replace('file:', '')) - } - - const pluginDependencies = pluginConfig?.pluginDependencies ? normaliseDependencies(pluginConfig.pluginDependencies) : undefined - - const packageInfo = { - packageName, - installed, - installedVersion, - installedLocally, - available, - required, - latestVersion, - versions, - packageJson, - pluginConfig, - pluginDependencies, - localVersion, - installedPackageVersion - } - - // Remove all undefined properties and save to cache - packagesCache[packageName] = Object.fromEntries(Object.entries(packageInfo).filter(([_, value]) => value !== undefined)) -} - -async function lookupPackageInfo (packageName, version) { - if (!packagesCache[packageName]) { - await refreshPackageInfo(packageName, version) - } - return packagesCache[packageName] -} - -const basePlugins = config.getConfig().basePlugins - -function emphasizeBasePlugins (plugins, nextPlugin) { - if (basePlugins.includes(nextPlugin.packageName)) { - return [nextPlugin, ...plugins] - } else { - return [...plugins, nextPlugin] - } -} - -function packageNameSort (pkgA, pkgB) { - const nameA = pkgA.packageName.toLowerCase() - const nameB = pkgB.packageName.toLowerCase() - if (nameA > nameB) return 1 - if (nameA < nameB) return -1 - return 0 -} - -async function getInstalledPackages () { - if (!Object.keys(packagesCache).length) { - await startPackageTracker() - } - await waitForPackagesCache() - return Object.values(packagesCache) - .filter(({ installed }) => installed) - .sort(packageNameSort) - .reduce(emphasizeBasePlugins, []) -} - -async function getAllPackages () { - if (!Object.keys(packagesCache).length) { - await startPackageTracker() - } - await waitForPackagesCache() - return Object.values(packagesCache) - .sort(packageNameSort) - .reduce(emphasizeBasePlugins, []) -} - -function sleep (ms) { - return new Promise((resolve) => setTimeout(resolve, ms)) -} - -async function waitForPackagesCache () { - let numberOfRetries = 20 - let waiting = !packageTrackerInterval - while (waiting) { - // If the packageTrackerInterval has been set, then packages cache has been populated at least once - waiting = !packageTrackerInterval && numberOfRetries > 0 - numberOfRetries-- - if (numberOfRetries === 0) { - console.log('Failed to load the package cache') - } else { - await sleep(250) - } - } -} - -function normaliseDependencies (dependencies) { - return dependencies.map((dependency) => { - if (typeof dependency === 'string') { - dependency = { - packageName: dependency - } - } - return dependency - }) -} - -async function getDependentPackages (packageName, version, mode) { - if (mode !== 'uninstall') { - return [] - } - if (!Object.keys(packagesCache).length) { - await startPackageTracker() - } - await waitForPackagesCache() - return Object.values(packagesCache) - .filter(({ pluginDependencies }) => pluginDependencies?.some((pluginDependency) => pluginDependency === packageName || pluginDependency.packageName === packageName)) -} - -async function getDependencyPackages (packageName, version, mode) { - if (!Object.keys(packagesCache).length) { - await startPackageTracker() - } - await waitForPackagesCache() - const pkg = await lookupPackageInfo(packageName, version) - let pluginDependencies = pkg?.pluginDependencies - if (version || mode === 'update') { - const targetVersion = version || pkg.latestVersion - if (targetVersion !== pkg.installedVersion) { - const latestPluginConfig = await getConfigForPackage(pkg.packageName, version) - if (latestPluginConfig) { - pluginDependencies = latestPluginConfig.pluginDependencies - } - } - } - const dependencyPlugins = !pluginDependencies - ? [] - : await Promise.all(normaliseDependencies(pluginDependencies).map((pluginDependency) => { - return lookupPackageInfo(pluginDependency.packageName) - })) - - return dependencyPlugins.filter(({ installed }) => !installed) -} - -if (!config.getConfig().isTest) { - startPackageTracker() -} - -function setPackagesCache (packagesInfo) { - // Only used for unit tests - while (packagesCache.length) { - packagesCache.pop() - } - packagesInfo.forEach((packageInfo) => { - packagesCache[packageInfo.packageName] = packageInfo - }) - packageTrackerInterval = true -} - -module.exports = { - setPackagesCache, // Only for unit testing purposes - waitForPackagesCache, - lookupPackageInfo, - getInstalledPackages, - getAllPackages, - getDependentPackages, - getDependencyPackages -} diff --git a/lib/plugins/packages.spec.js b/lib/plugins/packages.spec.js deleted file mode 100644 index 3fbfbdb218..0000000000 --- a/lib/plugins/packages.spec.js +++ /dev/null @@ -1,282 +0,0 @@ -/* eslint-env jest */ - -jest.mock('fs-extra') -jest.mock('../utils/requestHttps') - -// node dependencies -const path = require('path') - -// npm dependencies -const fse = require('fs-extra') - -// local dependencies -const packages = require('./packages') -const { lookupPackageInfo } = packages -const requestHttps = require('../utils/requestHttps') -const { - getInstalledPackages, - setPackagesCache, - getAllPackages, - getDependentPackages, - getDependencyPackages -} = require('./packages') -const registryUrl = 'https://registry.npmjs.org/' - -jest.mock('../../package.json', () => { - return { - dependencies: { - '@govuk-prototype-kit/common-templates': '1.0.0' - } - } -}) - -describe('packages', () => { - beforeEach(() => { - setPackagesCache([]) - }) - - afterEach(() => { - jest.restoreAllMocks() - }) - - describe('get packages', () => { - const availableInstalledPackage = { - packageName: 'available-installed-package', - installed: true, - available: true, - installedVersion: '1.0.0', - latestVersion: '1.0.0' - } - const availableUninstalledPackage = { - packageName: 'available-uninstalled-package', - installed: false, - available: true, - latestVersion: '1.0.0' - } - const unavailableInstalledPackage = { - packageName: 'unavailable-installed-package', - installed: true, - available: false, - installedVersion: '1.0.0', - latestVersion: '1.0.0', - pluginDependencies: [availableInstalledPackage] - } - const unavailableUninstalledPackage = { - packageName: 'unavailable-uninstalled-package', - installed: false, - available: false, - latestVersion: '1.0.0', - pluginDependencies: [availableUninstalledPackage] - } - - beforeEach(() => { - setPackagesCache([ - availableInstalledPackage, - availableUninstalledPackage, - unavailableInstalledPackage, - unavailableUninstalledPackage]) - }) - - describe('getInstalledPackages', () => { - it('', async () => { - const installedPackages = await getInstalledPackages() - expect(installedPackages).toEqual([availableInstalledPackage, unavailableInstalledPackage]) - }) - }) - - describe('getAllPackages', () => { - it('', async () => { - const allPackages = await getAllPackages() - expect(allPackages).toEqual([ - availableInstalledPackage, - availableUninstalledPackage, - unavailableInstalledPackage, - unavailableUninstalledPackage]) - }) - }) - - describe('getDependentPackages', () => { - it('when mode is uninstall', async () => { - const dependentPackages = await getDependentPackages(availableInstalledPackage.packageName, undefined, 'uninstall') - expect(dependentPackages).toEqual([unavailableInstalledPackage]) - }) - - it('when mode is update', async () => { - const dependentPackages = await getDependentPackages(availableInstalledPackage.packageName, '1.1.1', 'update') - expect(dependentPackages).toEqual([]) - }) - }) - - describe('getDependencyPackages', () => { - it('', async () => { - const dependencyPackages = await getDependencyPackages(unavailableUninstalledPackage.packageName) - expect(dependencyPackages).toEqual([availableUninstalledPackage]) - }) - }) - }) - - describe('lookupPackageInfo', () => { - let packageJson, pluginJson - - async function mockReadJson (fullFileName) { - const fileName = fullFileName.substring(fullFileName.lastIndexOf(path.sep) + 1) - if (fileName === 'package.json') { - return packageJson - } else if (fileName === 'govuk-prototype-kit.config.json') { - return pluginJson - } - } - - beforeEach(() => { - packageJson = { - local: true, - version: '1.0.0' - } - pluginJson = { - loaded: true - } - jest.spyOn(requestHttps, 'requestHttpsJson').mockImplementation(async (url) => { - switch (decodeURIComponent(url.replace(registryUrl, ''))) { - case 'jquery': - return { - 'dist-tags': { latest: '2.0.0' }, - versions: { - '1.0.0': { version: '1.0.0' }, - '2.0.0': { version: '2.0.0' } - } - } - case '@govuk-prototype-kit/common-templates': - return { - 'dist-tags': { latest: '1.0.1' }, - versions: { - '1.0.0': { version: '1.0.0' }, - '1.0.1': { version: '1.0.1' } - } - } - case '@govuk-prototype-kit/task-list': - return { - 'dist-tags': { latest: '1.0.0' }, - versions: { - '1.0.0': { version: '1.0.0' } - } - } - default: - return undefined - } - }) - }) - - it('lookup installed approved plugin', async () => { - const packageName = '@govuk-prototype-kit/common-templates' - - jest.spyOn(fse, 'pathExists').mockResolvedValue(true) - jest.spyOn(fse, 'readJson').mockImplementation(mockReadJson) - - const packageInfo = await lookupPackageInfo(packageName) - - expect(requestHttps.requestHttpsJson).toHaveBeenCalledWith(registryUrl + encodeURIComponent(packageName)) - - expect(packageInfo).toEqual({ - packageName, - available: true, - installed: true, - installedLocally: false, - installedPackageVersion: '1.0.0', - installedVersion: '1.0.0', - latestVersion: '1.0.1', - required: false, - packageJson: { - local: true, - version: '1.0.0' - }, - pluginConfig: { - loaded: true - }, - versions: [ - '1.0.0', - '1.0.1' - ] - }) - }) - - it('lookup uninstalled approved plugin', async () => { - const packageName = '@govuk-prototype-kit/task-list' - const packageInfo = await lookupPackageInfo(packageName) - - expect(requestHttps.requestHttpsJson).toHaveBeenCalledWith(registryUrl + encodeURIComponent(packageName)) - - expect(packageInfo).toEqual({ - packageName, - available: true, - installed: false, - latestVersion: '1.0.0', - required: false, - packageJson: { - version: '1.0.0' - }, - versions: [ - '1.0.0' - ] - }) - }) - - it('lookup uninstalled approved proxy plugin', async () => { - const packageName = 'jquery' - const packageInfo = await lookupPackageInfo(packageName) - - expect(requestHttps.requestHttpsJson).toHaveBeenCalledWith(registryUrl + encodeURIComponent(packageName)) - - expect(packageInfo).toEqual({ - packageName, - available: true, - installed: false, - latestVersion: '2.0.0', - required: false, - packageJson: { - version: '2.0.0' - }, - pluginConfig: { - assets: [ - '/dist' - ], - scripts: [ - '/dist/jquery.js' - ], - 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.' - } - }, - versions: [ - '1.0.0', - '2.0.0' - ] - }) - }) - - it('lookup uninstalled local plugin', async () => { - const packageName = 'local-plugin' - const version = '/local/folder/local-plugin' - jest.spyOn(fse, 'pathExists').mockResolvedValue(true) - jest.spyOn(fse, 'readJson').mockImplementation(mockReadJson) - const packageInfo = await lookupPackageInfo(packageName, version) - - expect(requestHttps.requestHttpsJson).toHaveBeenCalledWith(registryUrl + encodeURIComponent(packageName)) - - expect(packageInfo).toEqual({ - available: false, - installed: false, - localVersion: version, - packageJson: { - local: true, - version: '1.0.0' - }, - packageName, - pluginConfig: { - loaded: true - }, - required: false, - versions: [] - }) - }) - }) -}) diff --git a/lib/plugins/plugin-utils.js b/lib/plugins/plugin-utils.js deleted file mode 100644 index 63154073d7..0000000000 --- a/lib/plugins/plugin-utils.js +++ /dev/null @@ -1,22 +0,0 @@ -// This allows npm modules to act as if they are plugins by providing the plugin config for them -function getProxyPluginConfig (packageName) { - const proxyPluginConfig = { - 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.' - } - } - } - return proxyPluginConfig[packageName] ? { ...proxyPluginConfig[packageName] } : undefined -} - -module.exports = { - getProxyPluginConfig -} diff --git a/lib/plugins/plugins.js b/lib/plugins/plugins.js index 900468ed1b..773d90a098 100644 --- a/lib/plugins/plugins.js +++ b/lib/plugins/plugins.js @@ -45,7 +45,7 @@ const fse = require('fs-extra') const appConfig = require('../config') const { projectDir, shadowNunjucksDir } = require('../utils/paths') const { startPerformanceTimer, endPerformanceTimer } = require('../utils/performance') -const { getProxyPluginConfig } = require('./plugin-utils') +const { plugins: { proxyConfig: proxyPluginConfig } } = require('../../known-plugins.json') const pkgPath = path.join(projectDir, 'package.json') const pkgLockPath = path.join(projectDir, 'package-lock.json') @@ -71,10 +71,10 @@ function getPluginConfig (packageName) { endPerformanceTimer('getPluginConfig (fileSystem)', timer) return readJsonFile(pluginConfigFile) } - const proxyPluginConfig = getProxyPluginConfig(packageName) - if (proxyPluginConfig) { + const proxyPluginConfigForPackage = proxyPluginConfig && proxyPluginConfig[packageName] + if (proxyPluginConfigForPackage) { endPerformanceTimer('getPluginConfig (backup)', timer) - return proxyPluginConfig + return proxyPluginConfigForPackage } endPerformanceTimer('getPluginConfig (empty)', timer) return {} @@ -205,7 +205,9 @@ const knownWordsToFormat = { ho: 'HO', ons: 'ONS', jquery: 'jQuery', - dwp: 'DWP' + dwp: 'DWP', + tpr: 'TPR', + ministryofjustice: 'Ministry of Justice' } function prepareWordForPackageNameDisplay (word) { @@ -219,12 +221,15 @@ function prepareWordForPackageNameDisplay (word) { } function prepareName (name) { + if (name === 'x-govuk') { + return name + } return name .split('-') .map(prepareWordForPackageNameDisplay).join(' ') } -function preparePackageNameForDisplay (packageName, version) { +function preparePackageNameForDisplay (packageName) { const safePackageName = (packageName || '') const packageNameDetails = {} @@ -236,10 +241,6 @@ function preparePackageNameForDisplay (packageName, version) { packageNameDetails.name = prepareName(safePackageName) } - if (version) { - packageNameDetails.version = version - } - return packageNameDetails } diff --git a/lib/utils/functionCache.js b/lib/utils/functionCache.js new file mode 100644 index 0000000000..2615a24252 --- /dev/null +++ b/lib/utils/functionCache.js @@ -0,0 +1,67 @@ +const fs = require('fs') +const fsp = fs.promises +const fse = require('fs-extra') +const config = require('../config') + +function cacheFunctionCalls (fn, options = {}) { + const stored = {} + const loadResponsePromise = options.persistance?.load || ((signature) => stored[signature]) + const savePromise = options.persistance?.save || ((signature, promise) => { stored[signature] = promise }) + const remove = options.persistance?.remove || ((signature) => { delete stored[signature] }) + return async function () { + if (config.getConfig().turnOffFunctionCaching) { + return await fn.apply(null, arguments) + } + const signature = Buffer.from(JSON.stringify([...arguments])).toString('base64') + const response = loadResponsePromise(signature) + if (response) { + return await response + } + await savePromise(signature, fn.apply(null, arguments)) + if (options.maxTimeMinutes) { + setTimeout(() => { + remove(signature) + }, options.maxTimeMinutes * 60 * 1000) + } + return await loadResponsePromise(signature) + } +} + +function createFSCache (getCachePath) { + const accessOnce = {} + return { + save: async (signature, promise) => { + const fileLocation = getCachePath(signature) + const value = await promise + if (value) { + await fsp.writeFile(fileLocation, JSON.stringify({ value })).catch(x => { + console.error('error writing to FS cache', signature) + console.error(x) + }) + } else { + accessOnce[signature] = value + } + }, + load: (signature) => { + const fileLocation = getCachePath(signature) + if (accessOnce[signature]) { + delete accessOnce[signature] + return accessOnce[signature] + } + if (fs.existsSync(fileLocation)) { + return fsp.readFile(fileLocation).then(x => JSON.parse(x)?.value) + } + }, + delete: async (signature) => { + const fileLocation = getCachePath(signature) + if (await fse.exists(fileLocation)) { + await fsp.rm(fileLocation) + } + } + } +} + +module.exports = { + cacheFunctionCalls, + createFSCache +} diff --git a/lib/utils/functionCache.spec.js b/lib/utils/functionCache.spec.js new file mode 100644 index 0000000000..23b791ec2b --- /dev/null +++ b/lib/utils/functionCache.spec.js @@ -0,0 +1,18 @@ +const { cacheFunctionCalls } = require('./functionCache') +describe('function cache', () => { + it('should wrap a function without changing the behaviour', async () => { + const fn = jest.fn().mockReturnValueOnce('Hi there') + + const wrapped = cacheFunctionCalls(fn) + + expect(fn).not.toHaveBeenCalled() + + const result = await wrapped('hello', 'world') + + expect(fn).toHaveBeenCalledWith('hello', 'world') + expect(result).toBe('Hi there') + + expect(await wrapped('hello', 'world2')).toBe(undefined) + expect(await wrapped('hello', 'world')).toBe('Hi there') + }) +}) diff --git a/lib/utils/packageDetails.js b/lib/utils/packageDetails.js new file mode 100644 index 0000000000..183f660873 --- /dev/null +++ b/lib/utils/packageDetails.js @@ -0,0 +1,308 @@ +const fsp = require('fs').promises +const path = require('path') +const fse = require('fs-extra') +const requestHttps = require('./requestHttps') +const { preparePackageNameForDisplay } = require('../plugins/plugins') +const { projectDir } = require('./paths') +const encode = encodeURIComponent +const { plugins: { available: availablePlugins, proxyConfig: pluginProxyConfig } } = require('../../known-plugins.json') +const { cacheFunctionCalls } = require('./functionCache') +const { getConfig } = require('../config') +const { sortByObjectKey } = require('./index') + +const packageJsonFilePath = path.join(projectDir, 'package.json') +const contextPath = 'manage-prototype' +const doesntExistResponse = { + exists: false +} + +const encodeRef = ref => encode(ref).replaceAll('%3A', ':') + +const requestJson = cacheFunctionCalls((url) => requestHttps.requestHttpsJson(url), { maxTimeMinutes: 20 }) + +const errorResult = (errorDetails) => ({ + error: true, + errorDetails +}) + +async function getPluginDetailsFromNpm (packageName, version) { + if (!version) { + throw new Error('No version specified, version must be specified') + } + try { + let registerEntry + try { + registerEntry = await requestJson(`https://registry.npmjs.org/${encode(packageName)}`) + } catch (e) { + if (e.statusCode === 404) { + return doesntExistResponse + } + console.error('non-404 error when requesting registry (a)') + throw e + } + const versionConfig = registerEntry.versions[version] + if (versionConfig) { + const npmIdentifier = `${registerEntry.name}@${version}` + return await addStandardDetails({ + exists: true, + packageName: registerEntry.name, + version, + releaseDateTime: registerEntry.time[version], + npmIdentifier, + legacyInstallQueryString: `?package=${encode(packageName)}&version=${encode(version)}`, + internalRef: `npm:${packageName}:${version}`, + origin: 'NPM', + pluginConfig: await requestHttps.getPluginConfigContentsFromNodeModule(versionConfig.dist.tarball) + }) + } else { + return doesntExistResponse + } + } catch (e) { + console.error('error looking up plugin from npm', e) + return errorResult(e) + } +} + +async function getLatestPluginDetailsFromNpm (packageName) { + try { + let result + try { + const registerEntry = await requestJson(`https://registry.npmjs.org/${encode(packageName)}`) + let versionTag = 'latest' + if (getConfig().showPrereleases) { + versionTag = Object.keys(registerEntry['dist-tags']).map(tag => ({ + tag, + date: registerEntry.time[registerEntry['dist-tags'][tag]] + })).sort(sortByObjectKey('date')).at(-1).tag + } + result = await self.getPluginDetailsFromNpm(packageName, registerEntry['dist-tags'][versionTag]) + } catch (e) { + if (e.statusCode === 404) { + return doesntExistResponse + } + console.error('non-404 error when requesting registry (b)') + throw e + } + result.internalRef = `npm:${result.packageName}` + return addStandardDetails(result) + } catch (e) { + console.error('error looking up latest plugin from npm', e) + return errorResult(e) + } +} + +async function getPluginDetailsFromGithub (org, project, branch) { + try { + let githubDetails + try { + githubDetails = await requestJson(`https://api.github.com/repos/${encode(org)}/${encode(project)}`) + } catch (e) { + if (e.statusCode === 404) { + return doesntExistResponse + } + console.log('Unexpected error when looking up in Github', e) + throw e + } + const chosenBranch = branch || githubDetails.default_branch + const updatedTimePromise = requestJson(githubDetails.branches_url.replace('{/branch}', '/' + chosenBranch)) + .then(x => x.commit.commit.author.date) + .catch(_ => undefined) + const getJsonFileContentsFromGithubApi = async path => JSON.parse(await requestJson(githubDetails.contents_url.replace('{+path}', `${encode(path)}`) + `?ref=${encode(chosenBranch)}`).then(result => Buffer.from(result.content, 'base64').toString())) + const packageJsonContents = await getJsonFileContentsFromGithubApi('/package.json').catch(e => undefined) + const pluginConfig = await getJsonFileContentsFromGithubApi('/govuk-prototype-kit.config.json').catch(e => undefined) + if (!packageJsonContents) { + return doesntExistResponse + } + const npmIdentifier = `github:${org}/${project}` + (branch ? `#${chosenBranch}` : '') + const packageName = packageJsonContents.name + const version = packageJsonContents.version + const refParts = ['github', org, project] + if (branch) { + refParts.push(branch) + } + return await addStandardDetails({ + exists: true, + packageName, + version, + releaseDateTime: await updatedTimePromise, + legacyInstallQueryString: `?package=${encode(packageName)}&version=${encode(npmIdentifier)}`, + npmIdentifier, + pluginConfig, + origin: 'Github', + internalRef: refParts.join(':') + }) + } catch (e) { + console.error('error looking up plugin from Github', e) + return errorResult(e) + } +} + +async function getPluginDetailsFromFileSystem (pluginPath) { + try { + const packageJsonText = await fsp.readFile(path.join(pluginPath, 'package.json'), 'utf8').catch(e => undefined) + const pluginConfigText = await fsp.readFile(path.join(pluginPath, 'govuk-prototype-kit.config.json'), 'utf8').catch(e => undefined) + + if (!packageJsonText) { + return doesntExistResponse + } + + const packageJson = JSON.parse(packageJsonText) + const pluginConfig = pluginConfigText && JSON.parse(pluginConfigText) + + const internalRef = `fs:${pluginPath}` + return await addStandardDetails({ + exists: true, + packageName: packageJson.name, + version: packageJson.version, + queryString: `?package=${encode(packageJson.name)}&version=${encode(pluginPath)}`, + npmIdentifier: `file:${pluginPath}`, + origin: 'File System', + internalRef, + pluginConfig + }) + } catch (e) { + console.error('error looking up plugin from file system', e) + return errorResult(e) + } +} + +async function addStandardDetails (config) { + const updatedConfig = { ...config, ...preparePackageNameForDisplay(config.packageName) } + + updatedConfig.links = { + pluginDetails: ['', contextPath, 'plugin', updatedConfig.internalRef].map(encodeRef).join('/') + } + updatedConfig.links.install = [updatedConfig.links.pluginDetails, 'install'].join('/') + updatedConfig.links.uninstall = [updatedConfig.links.pluginDetails, 'uninstall'].join('/') + updatedConfig.links.update = [updatedConfig.links.pluginDetails, 'update'].join('/') + + updatedConfig.commands = updatedConfig.commands || {} + updatedConfig.commands.uninstall = updatedConfig.commands.uninstall || `npm uninstall ${updatedConfig.packageName}` + updatedConfig.commands.update = updatedConfig.commands.update || `npm install ${updatedConfig.packageName}@latest --save-exact` + updatedConfig.commands.install = updatedConfig.commands.install || `npm install ${updatedConfig.npmIdentifier} --save-exact` + + const proxyConfig = pluginProxyConfig[config.packageName] || {} + + Object.keys(proxyConfig).forEach(key => { + if (!updatedConfig?.pluginConfig || !updatedConfig?.pluginConfig[key]) { + updatedConfig.pluginConfig = updatedConfig.pluginConfig || {} + updatedConfig.pluginConfig[key] = proxyConfig[key] + } + }) + + return updatedConfig +} + +async function getInstalledPluginDetails (packageName) { + try { + const packageJson = await fse.readJson(packageJsonFilePath) + const npmId = packageJson.dependencies[packageName] + + if (!npmId) { + return doesntExistResponse + } + + const [prefix, id] = npmId.split(':') + + if (prefix === 'github') { + const [ghid, branch] = id.split('#') + const [org, project] = ghid.split('/') + return await getPluginDetailsFromGithub(org, project, branch) + } else if (prefix === 'file') { + return await getPluginDetailsFromFileSystem(path.resolve(projectDir, id)) + } else { + const packageJson = await fse.readJson(path.join(projectDir, 'node_modules', packageName, 'package.json')) + return await getPluginDetailsFromNpm(packageName, packageJson.version) + } + } catch (e) { + console.error('error looking up plugin details', e) + return errorResult(e) + } +} + +async function getPluginDetailsFromRef (ref) { + try { + const refSafe = ref || '' + const [source, id, ...extra] = refSafe.split(':') + const everythingAfterSource = refSafe.substring(refSafe.indexOf(':') + 1) + + if (source === 'installed') { + return await self.getInstalledPluginDetails(everythingAfterSource) + } + if (source === 'fs') { + return await self.getPluginDetailsFromFileSystem(everythingAfterSource) + } + if (source === 'github') { + if (extra[1]) { + return await self.getPluginDetailsFromGithub(id, extra[0], extra[1]) + } else { + return await self.getPluginDetailsFromGithub(id, extra[0]) + } + } + if (source === 'npm') { + if (extra[0]) { + return await self.getPluginDetailsFromNpm(id, extra[0]) + } else { + return await self.getLatestPluginDetailsFromNpm(everythingAfterSource) + } + } + } catch (e) { + console.error('error looking up plugin from ref', e) + return errorResult(e) + } +} + +async function getInstalledPackages () { + try { + const packageJson = await fse.readJson(packageJsonFilePath) + + return await Promise.all(Object.keys(packageJson.dependencies).map(async packageName => { + const result = await getInstalledPluginDetails(packageName) + return result + })) + } catch (e) { + console.error('error looking up installed packages', e) + return errorResult(e) + } +} + +async function getKnownPlugins () { + try { + return await Promise.all(availablePlugins.map(async packageName => getLatestPluginDetailsFromNpm(packageName))) + } catch (e) { + console.error('error getting known plugins', e) + return errorResult(e) + } +} + +async function isInstalled (ref) { + try { + const plugin = await getPluginDetailsFromRef(ref) + if (!plugin || !plugin.exists) { + return false + } + const isInPackageJson = await fse.readJson(packageJsonFilePath).then(x => Object.keys(x.dependencies).includes(plugin.packageName)) + const isInFileSystem = await fse.exists(path.join(projectDir, 'node_modules', plugin.packageName)) + return isInPackageJson && isInFileSystem + } catch (e) { + console.error('error looking up plugin from npm', e) + return errorResult(e) + } +} + +const self = { + getInstalledPackages, + getKnownPlugins, + getPluginDetailsFromFileSystem, + getInstalledPluginDetails, + getPluginDetailsFromRef, + isInstalled +} + +self.getPluginDetailsFromGithub = cacheFunctionCalls(getPluginDetailsFromGithub, { maxTimeMinutes: 20 }) +self.getPluginDetailsFromNpm = cacheFunctionCalls(getPluginDetailsFromNpm, { maxTimeMinutes: 120 }) +self.getLatestPluginDetailsFromNpm = cacheFunctionCalls(getLatestPluginDetailsFromNpm, { maxTimeMinutes: 20 }) +self.getInstalledPackages = getInstalledPackages +self.getKnownPlugins = getKnownPlugins + +module.exports = self diff --git a/lib/utils/packageDetails.spec.js b/lib/utils/packageDetails.spec.js new file mode 100644 index 0000000000..bb2510f097 --- /dev/null +++ b/lib/utils/packageDetails.spec.js @@ -0,0 +1,388 @@ +const requestHttps = require('./requestHttps') +const packageDetails = require('./packageDetails') +const { + getLatestPluginDetailsFromNpm, + getPluginDetailsFromGithub, + getPluginDetailsFromFileSystem +} = require('./packageDetails') +const { mockFileSystem } = require('../../__tests__/utils/mock-file-system') +const path = require('path') +const { getPluginDetailsFromNpm } = packageDetails +const config = require('../config') + +function notFound (url) { + const error = new Error(`Bad response from ${url}`) + error.statusCode = 404 + error.code = 'EBADRESPONSE' + return Promise.reject(error) +} + +function addExpectedLinks (config) { + const pluginDetails = ['', 'manage-prototype', 'plugin', config.internalRef].map(encodeURIComponent).join('/').replaceAll('%3A', ':') + return { + ...config, + links: { + install: pluginDetails + '/install', + uninstall: pluginDetails + '/uninstall', + update: pluginDetails + '/update', + pluginDetails + } + } +} + +describe.only('Package Details', () => { + let testScope + beforeEach(() => { + testScope = { + jsonResultForUrl: { + 'https://registry.npmjs.org/example-plugin': { + name: 'example-plugin', + description: 'An example NPM package', + 'dist-tags': { + latest: '1.0.1' + }, + versions: { + '1.0.0': { + name: 'example-plugin', + version: '1.0.0', + description: 'An example NPM package', + dist: { + shasum: '8be6695eb2443505bb8fe1e1d3a01b7b8e639677', + tarball: 'https://registry.npmjs.org/example-plugin/-/example-plugin-1.0.0.tgz', + integrity: 'sha512-KZXSIpe+EQIrB4RBmLnL/FOI+g3QjAsuq4b0VVMhYkueW0esfa+rsAwsiCMGG4mWGy131Ejf5Jg61DeFC/PM9A==', + signatures: [ + { + keyid: 'SHA256:jl3bwswu80PjjokCgh0o2w5c2U4LhQAE57gj9cz1kzA', + sig: 'MEQCIGyCHSLow3MXz1cJogT8P2+NMtPleu9k0hkYqaDKMGdtAiAA88G0H+Gs6y88fFenFsnJm93+9AuENeO5XZSGMHHgUQ==' + } + ] + } + }, + '1.0.1': { + name: 'example-plugin', + version: '1.0.1', + description: 'An example NPM package', + dist: { + shasum: '7f2872c10dbb30e2fa9fd95aabcb788ef39a223a', + tarball: 'https://registry.npmjs.org/example-plugin/-/example-plugin-1.0.1.tgz', + integrity: 'sha512-x+tWXVO6JFiwh+nykmGnCQqBzk+S4jh+aKiJ79uHXoouc1hkww+iqUzvjwfEhl7dW1kTie28+YakTbcmbITUzA==', + signatures: [ + { + keyid: 'SHA256:jl3bwswu80PjjokCgh0o2w5c2U4LhQAE57gj9cz1kzA', + sig: 'MEUCIDzMp4vQnulE7D1NSpvU62JenUqkj/ptiE1xH+JTg79iAiEA0Onsp3exZb7WPrE2NB7wd6gQ3NCY5jIhTQeytz5+YH4=' + } + ] + } + } + }, + time: { + modified: '2022-06-19T05:31:34.523Z', + created: '2015-02-01T00:43:35.034Z', + '1.0.0': '2015-02-01T00:43:35.034Z', + '1.0.2': '2015-04-03T16:05:22.839Z' + } + }, + 'https://api.github.com/repos/x-govuk/example-plugin': { + name: 'example-plugin', + full_name: 'x-govuk/example-plugin', + contents_url: 'https://api.github.com/repos/x-govuk/example-plugin/contents/{+path}', + branches_url: 'https://api.github.com/repos/x-govuk/edit-prototype-in-browser/branches{/branch}', + default_branch: 'main' + }, + 'https://api.github.com/repos/x-govuk/example-plugin/contents/%2Fpackage.json?ref=main': { + content: Buffer.from(JSON.stringify({ + name: 'this-is-the-package-name', + version: '100.200.300' + })).toString('base64') + }, + 'https://api.github.com/repos/x-govuk/example-plugin/contents/%2Fgovuk-prototype-kit.config.json?ref=main': { + content: Buffer.from(JSON.stringify({ + hello: 'world' + })).toString('base64') + }, + 'https://api.github.com/repos/x-govuk/example-plugin/contents/%2Fpackage.json?ref=my-spike': { + content: Buffer.from(JSON.stringify({ + name: 'this-is-the-package-name', + version: '100.200.301' + })).toString('base64') + }, + 'https://api.github.com/repos/x-govuk/example-plugin/contents/%2Fgovuk-prototype-kit.config.json?ref=my-spike': { + content: Buffer.from(JSON.stringify({ + meta: { + description: 'This is the plugin description.' + } + })).toString('base64') + }, + 'https://example.com/hello': 'Hi there' + }, + getPluginConfigContentsFromNodeModule: { + 'https://registry.npmjs.org/example-plugin/-/example-plugin-1.0.0.tgz': { + scripts: ['/example.js'] + }, + 'https://registry.npmjs.org/example-plugin/-/example-plugin-1.0.1.tgz': { + assets: ['/all-the-assets'] + } + } + } + jest.spyOn(config, 'getConfig').mockReturnValue({ + turnOffFunctionCaching: true + }) + jest.spyOn(requestHttps, 'requestHttpsJson').mockImplementation((url) => { + const result = testScope.jsonResultForUrl[url] + if (result) { + return Promise.resolve(result) + } + return notFound(url) + }) + jest.spyOn(requestHttps, 'getPluginConfigContentsFromNodeModule').mockImplementation((url) => { + const result = testScope.getPluginConfigContentsFromNodeModule[url] + if (result) { + return Promise.resolve(result) + } + return notFound('some-url') + }) + }) + afterEach(() => { + jest.restoreAllMocks() + }) + + describe('httpMock', () => { + it('should show mock response', async () => { + expect(await requestHttps.requestHttpsJson('https://example.com/hello')).toEqual('Hi there') + }) + }) + + describe('getPluginDetailsFromNpm', () => { + it('should represent an NPM dependency', async () => { + expect(await getPluginDetailsFromNpm('example-plugin', '1.0.0')).toEqual(addExpectedLinks({ + exists: true, + packageName: 'example-plugin', + version: '1.0.0', + name: 'Example Plugin', + origin: 'NPM', + legacyInstallQueryString: '?package=example-plugin&version=1.0.0', + npmIdentifier: 'example-plugin@1.0.0', + internalRef: 'npm:example-plugin:1.0.0', + releaseDateTime: '2015-02-01T00:43:35.034Z', + pluginConfig: { + scripts: [ + '/example.js' + ] + }, + commands: { + install: 'npm install example-plugin@1.0.0 --save-exact', + uninstall: 'npm uninstall example-plugin', + update: 'npm install example-plugin@latest --save-exact' + } + })) + }) + it('should error if the no version provided', async () => { + const expectedError = new Error('No version specified, version must be specified') + + getPluginDetailsFromNpm('example-plugin') + .then(() => { + throw new Error('error expected, but no error thrown') + }) + .catch(err => { + expect(err.message).toEqual(expectedError.message) + }) + }) + it('should fail if the version doesn\'t exist', async () => { + expect(await getPluginDetailsFromNpm('example-plugin', '1.0.3')).toEqual({ + exists: false + }) + }) + it('should fail if package doesn\'t exist', async () => { + delete testScope.jsonResultForUrl['https://registry.npmjs.org/example-plugin'] + expect(await getPluginDetailsFromNpm('example-plugin', '1.0.0')).toEqual({ + exists: false + }) + }) + }) + + describe('getLatestPluginDetailsFromNpm', () => { + beforeEach(() => { + testScope.mockedResult = { packageName: 'example-plugin', npmIdentifier: 'example-plugin' } + jest.spyOn(packageDetails, 'getPluginDetailsFromNpm').mockReturnValue(testScope.mockedResult) + }) + it('should get the latest package number and proxy', async () => { + const result = await getLatestPluginDetailsFromNpm('example-plugin') + + expect(packageDetails.getPluginDetailsFromNpm).toHaveBeenCalledWith('example-plugin', '1.0.1') + + expect(result).toEqual({ + ...addExpectedLinks(testScope.mockedResult), + commands: { + install: 'npm install example-plugin --save-exact', + uninstall: 'npm uninstall example-plugin', + update: 'npm install example-plugin@latest --save-exact' + }, + name: 'Example Plugin' + }) + }) + it('should use the latest from dist-tags', async () => { + testScope.jsonResultForUrl['https://registry.npmjs.org/example-plugin']['dist-tags'].latest = '1.0.0' + const result = await getLatestPluginDetailsFromNpm('example-plugin') + + expect(packageDetails.getPluginDetailsFromNpm).toHaveBeenCalledWith('example-plugin', '1.0.0') + + expect(result).toEqual({ + ...addExpectedLinks(testScope.mockedResult), + commands: { + install: 'npm install example-plugin --save-exact', + uninstall: 'npm uninstall example-plugin', + update: 'npm install example-plugin@latest --save-exact' + }, + name: 'Example Plugin' + }) + }) + it('should fail if package doesn\'t exist', async () => { + delete testScope.jsonResultForUrl['https://registry.npmjs.org/example-plugin'] + expect(await getLatestPluginDetailsFromNpm('example-plugin')).toEqual({ + exists: false + }) + }) + }) + describe('getPluginDetailsFromGithub', () => { + it('should get details from default branch', async () => { + expect(await getPluginDetailsFromGithub('x-govuk', 'example-plugin')).toEqual(addExpectedLinks({ + exists: true, + npmIdentifier: 'github:x-govuk/example-plugin', + packageName: 'this-is-the-package-name', + pluginConfig: { + hello: 'world' + }, + legacyInstallQueryString: '?package=this-is-the-package-name&version=github%3Ax-govuk%2Fexample-plugin', + version: '100.200.300', + internalRef: 'github:x-govuk:example-plugin', + name: 'This Is The Package Name', + origin: 'Github', + commands: { + install: 'npm install github:x-govuk/example-plugin --save-exact', + uninstall: 'npm uninstall this-is-the-package-name', + update: 'npm install this-is-the-package-name@latest --save-exact' + } + })) + }) + it('should specify the branch if default branch is specified', async () => { + expect(await getPluginDetailsFromGithub('x-govuk', 'example-plugin', 'main')).toEqual(addExpectedLinks({ + exists: true, + npmIdentifier: 'github:x-govuk/example-plugin#main', + packageName: 'this-is-the-package-name', + pluginConfig: { + hello: 'world' + }, + legacyInstallQueryString: '?package=this-is-the-package-name&version=github%3Ax-govuk%2Fexample-plugin%23main', + version: '100.200.300', + internalRef: 'github:x-govuk:example-plugin:main', + name: 'This Is The Package Name', + origin: 'Github', + commands: { + install: 'npm install github:x-govuk/example-plugin#main --save-exact', + uninstall: 'npm uninstall this-is-the-package-name', + update: 'npm install this-is-the-package-name@latest --save-exact' + } + })) + }) + it('should lookup specified branch', async () => { + expect(await getPluginDetailsFromGithub('x-govuk', 'example-plugin', 'my-spike')).toEqual(addExpectedLinks({ + exists: true, + npmIdentifier: 'github:x-govuk/example-plugin#my-spike', + packageName: 'this-is-the-package-name', + pluginConfig: { + meta: { + description: 'This is the plugin description.' + } + }, + legacyInstallQueryString: '?package=this-is-the-package-name&version=github%3Ax-govuk%2Fexample-plugin%23my-spike', + version: '100.200.301', + internalRef: 'github:x-govuk:example-plugin:my-spike', + name: 'This Is The Package Name', + origin: 'Github', + commands: { + install: 'npm install github:x-govuk/example-plugin#my-spike --save-exact', + uninstall: 'npm uninstall this-is-the-package-name', + update: 'npm install this-is-the-package-name@latest --save-exact' + } + })) + }) + }) + describe('getPluginDetailsFromFileSystem', () => { + beforeEach(() => { + testScope.fakeDocsDir = path.join(process.cwd(), 'docs') + testScope.fileSystem = mockFileSystem(testScope.fakeDocsDir) + testScope.fileSystem.setupSpies() + + testScope.plugin1Path = testScope.fileSystem.createDirectory(['plugin-1']) + testScope.fileSystem.writeFile(['plugin-1', 'package.json'], JSON.stringify({ + name: 'the-first-plugin', + version: '80.102.3' + })) + testScope.fileSystem.writeFile(['plugin-1', 'govuk-prototype-kit.config.json'], JSON.stringify({ + stylesheets: ['abc.css'] + })) + + testScope.plugin2Path = testScope.fileSystem.createDirectory(['second-plugin']) + testScope.fileSystem.writeFile(['second-plugin', 'package.json'], JSON.stringify({ + name: 'example-plugin-2', + version: '94.1.4' + })) + testScope.fileSystem.writeFile(['second-plugin', 'govuk-prototype-kit.config.json'], JSON.stringify({ + meta: { + urls: { + documentation: 'example.com' + } + } + })) + }) + it('should output correctly for example 1', async () => { + expect(await getPluginDetailsFromFileSystem(testScope.plugin1Path)).toEqual(addExpectedLinks({ + exists: true, + packageName: 'the-first-plugin', + version: '80.102.3', + queryString: `?package=the-first-plugin&version=${encodeURIComponent(testScope.plugin1Path)}`, + npmIdentifier: 'file:' + testScope.plugin1Path, + internalRef: 'fs:' + testScope.plugin1Path, + pluginConfig: { + stylesheets: ['abc.css'] + }, + name: 'The First Plugin', + origin: 'File System', + commands: { + install: `npm install file:${testScope.plugin1Path} --save-exact`, + uninstall: 'npm uninstall the-first-plugin', + update: 'npm install the-first-plugin@latest --save-exact' + } + })) + }) + it('should output correctly for example 2', async () => { + expect(await getPluginDetailsFromFileSystem(testScope.plugin2Path)).toEqual(addExpectedLinks({ + exists: true, + packageName: 'example-plugin-2', + version: '94.1.4', + queryString: `?package=example-plugin-2&version=${encodeURIComponent(testScope.plugin2Path)}`, + npmIdentifier: 'file:' + testScope.plugin2Path, + internalRef: 'fs:' + testScope.plugin2Path, + pluginConfig: { + meta: { + urls: { + documentation: 'example.com' + } + } + }, + name: 'Example Plugin 2', + origin: 'File System', + commands: { + install: `npm install file:${testScope.plugin2Path} --save-exact`, + uninstall: 'npm uninstall example-plugin-2', + update: 'npm install example-plugin-2@latest --save-exact' + } + })) + }) + it('should fail nicely when plugin doesn\'t exist', async () => { + expect(await getPluginDetailsFromFileSystem(path.join(testScope.fakeDocsDir, 'plugin-3'))).toEqual({ + exists: false + }) + }) + }) +}) diff --git a/lib/utils/paths.js b/lib/utils/paths.js index aa3f9430c1..1d2b2b79a3 100644 --- a/lib/utils/paths.js +++ b/lib/utils/paths.js @@ -26,3 +26,4 @@ exports.sessionStoreDir = path.join(exports.tmpDir, 'sessions') exports.shadowNunjucksDir = path.join(exports.tmpDir, 'shadow-nunjucks') exports.backupNunjucksDir = path.join(exports.tmpDir, 'backup-nunjucks') exports.finalBackupNunjucksDir = path.join(exports.packageDir, 'lib', 'final-backup-nunjucks') +exports.commandsDir = path.join(exports.tmpDir, 'commands') diff --git a/lib/utils/requestHttps.js b/lib/utils/requestHttps.js index 3afe27ad17..b991676d55 100644 --- a/lib/utils/requestHttps.js +++ b/lib/utils/requestHttps.js @@ -6,6 +6,8 @@ const path = require('path') const { tmpDir } = require('./paths') const { exists, readJson, ensureDir, writeJson } = require('fs-extra') const { verboseLog } = require('./verboseLogger') +const { cacheFunctionCalls, createFSCache } = require('./functionCache') +const pluginConfigCacheDir = path.join(tmpDir, 'caches', 'pluginConfigCacheDir') async function getConfigForPackage (packageName, version) { const timer = startPerformanceTimer() @@ -69,7 +71,9 @@ async function getConfigForPackage (packageName, version) { function setupRequestFunction (prepareFn) { return (url, options) => new Promise((resolve, reject) => { - https.get(url, (response) => { + https.get(url, { + headers: { 'User-Agent': 'GOV.UK Prototype Kit' } + }, (response) => { const statusCode = response.statusCode if (statusCode < 200 || statusCode >= 300) { @@ -141,7 +145,27 @@ const findFileInHttpsTgz = setupRequestFunction((options, response, resolve) => }) }) +async function getPluginConfigContentsFromNodeModule (url) { + try { + return await findFileInHttpsTgz(url, { + fileToFind: 'package/govuk-prototype-kit.config.json', + prepare: str => { + if (str && str.startsWith('{')) { + return JSON.parse(str) + } + } + }) + } catch (e) {} +} + +ensureDir(pluginConfigCacheDir) + module.exports = { requestHttpsJson, - getConfigForPackage + getConfigForPackage, + getPluginConfigContentsFromNodeModule: cacheFunctionCalls(getPluginConfigContentsFromNodeModule, { + persistance: createFSCache(function (signature) { + return path.join(pluginConfigCacheDir, signature + '.json') + }) + }) } diff --git a/npm-shrinkwrap.json b/npm-shrinkwrap.json index 5105e01a56..d8847b8689 100644 --- a/npm-shrinkwrap.json +++ b/npm-shrinkwrap.json @@ -42,7 +42,7 @@ "cheerio": "^1.0.0-rc.12", "clean-publish": "^4.0.2", "cross-env": "^7.0.3", - "cypress": "^12.3.0", + "cypress": "^13.1.0", "eslint-plugin-cypress": "^2.14.0", "eslint-plugin-jest": "^27.2.1", "extract-zip": "^2.0.1", @@ -716,9 +716,9 @@ } }, "node_modules/@cypress/request": { - "version": "2.88.12", - "resolved": "https://registry.npmjs.org/@cypress/request/-/request-2.88.12.tgz", - "integrity": "sha512-tOn+0mDZxASFM+cuAP9szGUGPI1HwWVSvdzm7V4cCsPdFTx6qMj29CwaQmRAMIEhORIUBFBsYROYJcveK4uOjA==", + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/@cypress/request/-/request-3.0.1.tgz", + "integrity": "sha512-TWivJlJi8ZDx2wGOw1dbLuHJKUYX7bWySw377nlnGOW3hP9/MUKIsEdXT/YngWxVdgNCHRBmFlBipE+5/2ZZlQ==", "dev": true, "dependencies": { "aws-sign2": "~0.7.0", @@ -734,7 +734,7 @@ "json-stringify-safe": "~5.0.1", "mime-types": "~2.1.19", "performance-now": "^2.1.0", - "qs": "~6.10.3", + "qs": "6.10.4", "safe-buffer": "^5.1.2", "tough-cookie": "^4.1.3", "tunnel-agent": "^0.6.0", @@ -3201,13 +3201,13 @@ "dev": true }, "node_modules/cypress": { - "version": "12.17.4", - "resolved": "https://registry.npmjs.org/cypress/-/cypress-12.17.4.tgz", - "integrity": "sha512-gAN8Pmns9MA5eCDFSDJXWKUpaL3IDd89N9TtIupjYnzLSmlpVr+ZR+vb4U/qaMp+lB6tBvAmt7504c3Z4RU5KQ==", + "version": "13.1.0", + "resolved": "https://registry.npmjs.org/cypress/-/cypress-13.1.0.tgz", + "integrity": "sha512-LUKxCYlB973QBFls1Up4FAE9QIYobT+2I8NvvAwMfQS2YwsWbr6yx7y9hmsk97iqbHkKwZW3MRjoK1RToBFVdQ==", "dev": true, "hasInstallScript": true, "dependencies": { - "@cypress/request": "2.88.12", + "@cypress/request": "^3.0.0", "@cypress/xvfb": "^1.2.4", "@types/node": "^16.18.39", "@types/sinonjs__fake-timers": "8.1.1", @@ -3255,7 +3255,7 @@ "cypress": "bin/cypress" }, "engines": { - "node": "^14.0.0 || ^16.0.0 || >=18.0.0" + "node": "^16.0.0 || ^18.0.0 || >=20.0.0" } }, "node_modules/cypress/node_modules/commander": { @@ -11440,9 +11440,9 @@ "optional": true }, "@cypress/request": { - "version": "2.88.12", - "resolved": "https://registry.npmjs.org/@cypress/request/-/request-2.88.12.tgz", - "integrity": "sha512-tOn+0mDZxASFM+cuAP9szGUGPI1HwWVSvdzm7V4cCsPdFTx6qMj29CwaQmRAMIEhORIUBFBsYROYJcveK4uOjA==", + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/@cypress/request/-/request-3.0.1.tgz", + "integrity": "sha512-TWivJlJi8ZDx2wGOw1dbLuHJKUYX7bWySw377nlnGOW3hP9/MUKIsEdXT/YngWxVdgNCHRBmFlBipE+5/2ZZlQ==", "dev": true, "requires": { "aws-sign2": "~0.7.0", @@ -11458,7 +11458,7 @@ "json-stringify-safe": "~5.0.1", "mime-types": "~2.1.19", "performance-now": "^2.1.0", - "qs": "~6.10.3", + "qs": "6.10.4", "safe-buffer": "^5.1.2", "tough-cookie": "^4.1.3", "tunnel-agent": "^0.6.0", @@ -13356,12 +13356,12 @@ } }, "cypress": { - "version": "12.17.4", - "resolved": "https://registry.npmjs.org/cypress/-/cypress-12.17.4.tgz", - "integrity": "sha512-gAN8Pmns9MA5eCDFSDJXWKUpaL3IDd89N9TtIupjYnzLSmlpVr+ZR+vb4U/qaMp+lB6tBvAmt7504c3Z4RU5KQ==", + "version": "13.1.0", + "resolved": "https://registry.npmjs.org/cypress/-/cypress-13.1.0.tgz", + "integrity": "sha512-LUKxCYlB973QBFls1Up4FAE9QIYobT+2I8NvvAwMfQS2YwsWbr6yx7y9hmsk97iqbHkKwZW3MRjoK1RToBFVdQ==", "dev": true, "requires": { - "@cypress/request": "2.88.12", + "@cypress/request": "^3.0.0", "@cypress/xvfb": "^1.2.4", "@types/node": "^16.18.39", "@types/sinonjs__fake-timers": "8.1.1", diff --git a/package.json b/package.json index c47e0bbe9f..2ff81c7209 100644 --- a/package.json +++ b/package.json @@ -91,7 +91,7 @@ "cheerio": "^1.0.0-rc.12", "clean-publish": "^4.0.2", "cross-env": "^7.0.3", - "cypress": "^12.3.0", + "cypress": "^13.1.0", "eslint-plugin-cypress": "^2.14.0", "eslint-plugin-jest": "^27.2.1", "extract-zip": "^2.0.1", @@ -126,6 +126,6 @@ "/node_modules/", "/tmp/" ], - "testTimeout": 30000 + "testTimeout": 5000 } }