From 25cb0b2fa389d5effbb846d1538b401f975f1163 Mon Sep 17 00:00:00 2001 From: Ben Surgison Date: Thu, 8 Jun 2023 15:28:11 +0100 Subject: [PATCH 1/4] Specify plugin dependencies --- CHANGELOG.md | 2 + .../govuk-prototype-kit.config.json | 7 + __tests__/spec/force-https-redirect.js | 4 + ...=> install-plugin-via-cli-test.cypress.js} | 2 +- .../install-plugin-via-ui-test.cypress.js | 105 +++++++ cypress/e2e/plugins/plugin-utils.js | 2 +- cypress/events/index.js | 2 +- .../govuk-prototype-kit.config.json | 14 + .../fixtures/plugins/plugin-fee/package.json | 7 + .../fixtures/plugins/plugin-fee/sass/fee.scss | 4 + .../plugins/plugin-fee/templates/fee.njk | 10 + known-plugins.json | 4 + .../manage-prototype/manage-plugins.js | 11 +- .../manage-prototype/manage-plugins.test.js | 8 + lib/manage-prototype-handlers.js | 263 ++++++++--------- lib/manage-prototype-handlers.test.js | 224 +++++++++------ lib/manage-prototype-routes.js | 4 +- .../plugin-install-or-uninstall.njk | 42 ++- lib/plugins/packages.js | 245 ++++++++++++++++ lib/plugins/packages.spec.js | 264 ++++++++++++++++++ lib/plugins/plugin-utils.js | 14 + lib/plugins/plugin-validator.js | 53 +++- lib/plugins/plugin-validator.spec.js | 64 +++++ lib/plugins/plugins.js | 25 +- lib/utils/index.js | 26 -- lib/utils/requestHttps.js | 96 +++++++ 26 files changed, 1222 insertions(+), 280 deletions(-) rename cypress/e2e/plugins/0-mock-plugin-tests/{install-plugin-test.cypress.js => install-plugin-via-cli-test.cypress.js} (96%) create mode 100644 cypress/e2e/plugins/0-mock-plugin-tests/install-plugin-via-ui-test.cypress.js create mode 100644 cypress/fixtures/plugins/plugin-fee/govuk-prototype-kit.config.json create mode 100644 cypress/fixtures/plugins/plugin-fee/package.json create mode 100644 cypress/fixtures/plugins/plugin-fee/sass/fee.scss create mode 100644 cypress/fixtures/plugins/plugin-fee/templates/fee.njk create mode 100644 lib/plugins/packages.js create mode 100644 lib/plugins/packages.spec.js create mode 100644 lib/plugins/plugin-utils.js create mode 100644 lib/plugins/plugin-validator.spec.js create mode 100644 lib/utils/requestHttps.js diff --git a/CHANGELOG.md b/CHANGELOG.md index ef5e6322a1..c127259122 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,8 @@ ## Unreleased +- [#2220: Specify plugin dependencies](https://github.com/alphagov/govuk-prototype-kit/pull/2220) + ## 13.9.1 ### Fixes diff --git a/__tests__/fixtures/mockPlugins/valid-plugin/govuk-prototype-kit.config.json b/__tests__/fixtures/mockPlugins/valid-plugin/govuk-prototype-kit.config.json index 91f784976d..afec4488ff 100644 --- a/__tests__/fixtures/mockPlugins/valid-plugin/govuk-prototype-kit.config.json +++ b/__tests__/fixtures/mockPlugins/valid-plugin/govuk-prototype-kit.config.json @@ -34,5 +34,12 @@ "path": "/templates/start-with-step-by-step.html", "type": "nunjucks" } + ], + "pluginDependencies": [ + { + "packageName": "govuk-frontend", + "minVersion": "4.5.0", + "maxVersion": "5.5.0" + } ] } \ No newline at end of file diff --git a/__tests__/spec/force-https-redirect.js b/__tests__/spec/force-https-redirect.js index 4f002ecd18..e41b9600da 100644 --- a/__tests__/spec/force-https-redirect.js +++ b/__tests__/spec/force-https-redirect.js @@ -18,6 +18,10 @@ 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/0-mock-plugin-tests/install-plugin-test.cypress.js b/cypress/e2e/plugins/0-mock-plugin-tests/install-plugin-via-cli-test.cypress.js similarity index 96% rename from cypress/e2e/plugins/0-mock-plugin-tests/install-plugin-test.cypress.js rename to cypress/e2e/plugins/0-mock-plugin-tests/install-plugin-via-cli-test.cypress.js index b6575dfacb..37901e1314 100644 --- a/cypress/e2e/plugins/0-mock-plugin-tests/install-plugin-test.cypress.js +++ b/cypress/e2e/plugins/0-mock-plugin-tests/install-plugin-via-cli-test.cypress.js @@ -29,7 +29,7 @@ const pluginBazViewMarkup = ` {% endblock %} ` -describe('Single Plugin Test', async () => { +describe('Install Plugin via CLI Test', async () => { before(() => { uninstallPlugin('plugin-baz') createFile(pluginBazView, { data: pluginBazViewMarkup }) diff --git a/cypress/e2e/plugins/0-mock-plugin-tests/install-plugin-via-ui-test.cypress.js b/cypress/e2e/plugins/0-mock-plugin-tests/install-plugin-via-ui-test.cypress.js new file mode 100644 index 0000000000..7534ab40f1 --- /dev/null +++ b/cypress/e2e/plugins/0-mock-plugin-tests/install-plugin-via-ui-test.cypress.js @@ -0,0 +1,105 @@ +const { installPlugin, uninstallPlugin } = require('../../utils') +const path = require('path') +const { loadTemplatesPage, managePluginsPagePath, performPluginAction, loadPluginsPage } = require('../plugin-utils') + +const panelCompleteQuery = '[aria-live="polite"] #panel-complete' +const fixtures = path.join(Cypress.config('fixturesFolder')) +const dependentPlugin = 'plugin-fee' +const dependentPluginName = 'Plugin Fee' +const dependentPluginLocation = path.join(fixtures, 'plugins', dependentPlugin) +const dependencyPlugin = 'govuk-frontend' +const dependencyPluginName = 'GOV.UK Frontend' + +function restore () { + installPlugin(dependencyPlugin) + uninstallPlugin(dependentPlugin) +} + +describe('Install and uninstall Local Plugin via UI Test', async () => { + before(restore) + after(restore) + + it(`The ${dependentPlugin} plugin templates are not available`, () => { + loadTemplatesPage() + cy.get(`[data-plugin-package-name="${dependentPlugin}"]`).should('not.exist') + }) + + it(`Install the ${dependentPlugin} plugin`, () => { + cy.task('waitUntilAppRestarts') + cy.visit(`${managePluginsPagePath}/install?package=${encodeURIComponent(dependentPlugin)}&version=${encodeURIComponent(dependentPluginLocation)}`) + cy.get('#plugin-action-button').click() + + cy.get(panelCompleteQuery, { timeout: 20000 }) + .should('be.visible') + cy.get('a').contains('Back to plugins').click() + + cy.get(`[data-plugin-package-name="${dependentPlugin}"] button`).contains('Uninstall') + }) + + it(`The ${dependentPlugin} plugin templates are available`, () => { + loadTemplatesPage() + cy.get(`[data-plugin-package-name="${dependentPlugin}"]`).should('exist') + }) + + it('Uninstall the local plugin', () => { + loadPluginsPage() + + cy.get(`[data-plugin-package-name="${dependentPlugin}"]`) + .scrollIntoView() + .find('button') + .contains('Uninstall') + .click() + + performPluginAction('uninstall', dependentPlugin, dependentPluginName) + }) + + it(`The ${dependentPlugin} plugin templates are not available`, () => { + loadTemplatesPage() + cy.get(`[data-plugin-package-name="${dependentPlugin}"]`).should('not.exist') + }) +}) + +describe('Install and uninstall Local Dependent Plugin via UI Test', async () => { + before(restore) + after(restore) + + it(`The ${dependentPlugin} plugin templates are not available`, () => { + loadTemplatesPage() + cy.get(`[data-plugin-package-name="${dependentPlugin}"]`).should('not.exist') + }) + + it(`Uninstall the ${dependencyPlugin} to force the UI to ask for it later`, () => { + cy.task('waitUntilAppRestarts') + cy.visit(`${managePluginsPagePath}/uninstall?package=${encodeURIComponent(dependencyPlugin)}`) + cy.get('#plugin-action-button').click() + performPluginAction('uninstall', dependencyPlugin, dependencyPluginName) + }) + + it(`Install the ${dependentPlugin} plugin and the ${dependencyPlugin}`, () => { + cy.task('waitUntilAppRestarts') + cy.visit(`${managePluginsPagePath}/install?package=${encodeURIComponent(dependentPlugin)}&version=${encodeURIComponent(dependentPluginLocation)}`) + // Should list the dependency plugin + cy.get('li').contains(dependencyPluginName) + cy.get('#plugin-action-button').click() + performPluginAction('install', dependentPlugin, dependentPluginName) + }) + + it(`The ${dependentPlugin} plugin templates are available`, () => { + loadTemplatesPage() + cy.get(`[data-plugin-package-name="${dependentPlugin}"]`).should('exist') + }) + + it('Uninstall the dependency plugin', () => { + cy.task('waitUntilAppRestarts') + cy.visit(`${managePluginsPagePath}/uninstall?package=${encodeURIComponent(dependencyPlugin)}`) + // Should list the dependent plugin + cy.get('li').contains(dependentPluginName) + cy.get('#plugin-action-button').click() + performPluginAction('uninstall', dependencyPlugin, dependencyPluginName) + }) + + it(`The ${dependentPlugin} plugin templates are not available`, () => { + loadTemplatesPage() + cy.get(`[data-plugin-package-name="${dependentPlugin}"]`).should('not.exist') + }) +}) diff --git a/cypress/e2e/plugins/plugin-utils.js b/cypress/e2e/plugins/plugin-utils.js index 63c15fa3dd..30129ce235 100644 --- a/cypress/e2e/plugins/plugin-utils.js +++ b/cypress/e2e/plugins/plugin-utils.js @@ -28,7 +28,7 @@ async function loadTemplatesPage () { function performPluginAction (action, plugin, pluginName) { cy.task('log', `The ${plugin} plugin should be displayed`) cy.get('h2') - .contains(`${capitalize(action)} ${pluginName}`) + .contains(pluginName) const processingText = `${action === 'upgrade' ? 'Upgrad' : action}ing ...` diff --git a/cypress/events/index.js b/cypress/events/index.js index 4af13a6197..80f8b0b524 100644 --- a/cypress/events/index.js +++ b/cypress/events/index.js @@ -24,7 +24,7 @@ const https = require('https') // local dependencies const { starterDir } = require('../../lib/utils/paths') const { sleep } = require('../e2e/utils') -const { requestHttpsJson } = require('../../lib/utils') +const { requestHttpsJson } = require('../../lib/utils/requestHttps') const log = (message) => console.log(`${new Date().toLocaleTimeString()} => ${message}`) diff --git a/cypress/fixtures/plugins/plugin-fee/govuk-prototype-kit.config.json b/cypress/fixtures/plugins/plugin-fee/govuk-prototype-kit.config.json new file mode 100644 index 0000000000..d08deab419 --- /dev/null +++ b/cypress/fixtures/plugins/plugin-fee/govuk-prototype-kit.config.json @@ -0,0 +1,14 @@ +{ + "templates": [ + { + "name": "Plugin Fee page", + "path": "/templates/fee.njk", + "type": "nunjucks" + } + ], + "sass": "/sass/fee.scss", + "pluginDependencies": [{ + "packageName": "govuk-frontend", + "minVersion": "4.5.0" + }] +} diff --git a/cypress/fixtures/plugins/plugin-fee/package.json b/cypress/fixtures/plugins/plugin-fee/package.json new file mode 100644 index 0000000000..8cd96be3f3 --- /dev/null +++ b/cypress/fixtures/plugins/plugin-fee/package.json @@ -0,0 +1,7 @@ +{ + "name": "plugin-fee", + "version": "1.0.0", + "dependencies": { + "govuk-prototype-kit": "file:../../../.." + } +} diff --git a/cypress/fixtures/plugins/plugin-fee/sass/fee.scss b/cypress/fixtures/plugins/plugin-fee/sass/fee.scss new file mode 100644 index 0000000000..4b2b144f81 --- /dev/null +++ b/cypress/fixtures/plugins/plugin-fee/sass/fee.scss @@ -0,0 +1,4 @@ +.plugin-fee-paragraph { + background: #d4351c; + color: #ffdd00; +} \ No newline at end of file diff --git a/cypress/fixtures/plugins/plugin-fee/templates/fee.njk b/cypress/fixtures/plugins/plugin-fee/templates/fee.njk new file mode 100644 index 0000000000..376fd00812 --- /dev/null +++ b/cypress/fixtures/plugins/plugin-fee/templates/fee.njk @@ -0,0 +1,10 @@ +{% extends "layouts/main.html" %} + +{% block pageTitle %} + GOV.UK page template – {{ serviceName }} – GOV.UK Prototype Kit +{% endblock %} + +{% block content %} +

Plugin fee styled paragraph

+ +{% endblock %} diff --git a/known-plugins.json b/known-plugins.json index 9ff43b2c43..77eb9cdfe1 100644 --- a/known-plugins.json +++ b/known-plugins.json @@ -8,6 +8,10 @@ "hmrc-frontend", "jquery", "notifications-node-client" + ], + "required": [ + "govuk-prototype-kit", + "govuk-frontend" ] } } diff --git a/lib/assets/javascripts/manage-prototype/manage-plugins.js b/lib/assets/javascripts/manage-prototype/manage-plugins.js index f12b81a3c5..1dceaaa484 100644 --- a/lib/assets/javascripts/manage-prototype/manage-plugins.js +++ b/lib/assets/javascripts/manage-prototype/manage-plugins.js @@ -13,12 +13,16 @@ const show = (id) => { const element = document.getElementById(id) - element.hidden = false + if (element) { + element.hidden = false + } } const hide = (id) => { const element = document.getElementById(id) - element.hidden = true + if (element) { + element.hidden = true + } } const showCompleteStatus = () => { @@ -108,6 +112,9 @@ } const performAction = (event) => { + hide('dependency-heading') + show('plugin-heading') + if (!actionTimeoutId) { actionTimeoutId = setTimeout(() => { timedOut = true diff --git a/lib/assets/javascripts/manage-prototype/manage-plugins.test.js b/lib/assets/javascripts/manage-prototype/manage-plugins.test.js index 3a1a61185f..aa25941c13 100644 --- a/lib/assets/javascripts/manage-prototype/manage-plugins.test.js +++ b/lib/assets/javascripts/manage-prototype/manage-plugins.test.js @@ -10,6 +10,8 @@ async function fetchResponse (data) { const loadedHTML = `
+
+
@@ -20,6 +22,8 @@ const loadedHTML = ` const processingHTML = `
+ +
@@ -30,6 +34,8 @@ const processingHTML = ` const completedHTML = `
+ +
@@ -40,6 +46,8 @@ const completedHTML = ` const errorHTML = `
+ +
diff --git a/lib/manage-prototype-handlers.js b/lib/manage-prototype-handlers.js index cf36dd19c2..538f5f2e0f 100644 --- a/lib/manage-prototype-handlers.js +++ b/lib/manage-prototype-handlers.js @@ -1,4 +1,3 @@ - // core dependencies const path = require('path') @@ -11,10 +10,18 @@ const { doubleCsrf } = require('csrf-csrf') const config = require('./config') const plugins = require('./plugins/plugins') const { exec } = require('./exec') -const { requestHttpsJson, prototypeAppScripts, sortByObjectKey } = require('./utils') +const { prototypeAppScripts } = require('./utils') const { projectDir, packageDir, appViewsDir } = require('./utils/paths') const nunjucksConfiguration = require('./nunjucks/nunjucksConfiguration') const syncChanges = require('./sync-changes') +const { + lookupPackageInfo, + getInstalledPackages, + getAvailablePackages, + getDependentPackages, + getDependencyPackages, + waitForPackagesCache +} = require('./plugins/packages') const contextPath = '/manage-prototype' @@ -24,75 +31,22 @@ const appViews = plugins.getAppViews([ path.join(packageDir, 'lib/final-backup-nunjucks') ]) -const pkgPath = path.join(projectDir, 'package.json') - let kitRestarted = false const { name: currentKitName, version: currentKitVersion } = require(path.join(packageDir, 'package.json')) -const { startPerformanceTimer, endPerformanceTimer } = require('./utils/performance') -const { verboseLog } = require('./utils/verboseLogger') - -const latestReleaseVersions = plugins.getKnownPlugins().available - .reduce((releaseVersions, nextPlugin) => { - return { ...releaseVersions, [nextPlugin]: 0 } - }, { 'govuk-prototype-kit': currentKitVersion }) - -async function lookupPackageInfo (packageName) { - const timer = startPerformanceTimer() - try { - const packageInfo = await requestHttpsJson(`https://registry.npmjs.org/${encodeURIComponent(packageName)}`) - endPerformanceTimer('lookupPackageInfo (success)', timer) - return packageInfo - } catch (e) { - endPerformanceTimer('lookupPackageInfo (failure)', timer) - console.log('ignoring error', e.message) - } -} async function isValidVersion (packageName, version) { - const versions = Object.keys(((await lookupPackageInfo(packageName) || {}).versions || {})) - const isVersionValid = versions.some(distVersion => distVersion === version) + const { versions = [], localVersion } = await lookupPackageInfo(packageName, version) + const isVersionValid = [...versions, localVersion].includes(version) if (!isVersionValid) { console.log('version', version, ' is not valid, valid options are:\n\n', versions) } return isVersionValid } -async function lookupLatestPackageVersion (packageName) { - try { - const packageInfo = (await lookupPackageInfo(packageName) || {}) - if (config.getConfig().showPrereleases) { - latestReleaseVersions[packageName] = Object.values(packageInfo['dist-tags']) - .map(version => ({ version, date: packageInfo.time[version] })) - .sort(sortByObjectKey('date')) - .at(-1) - .version - } else { - latestReleaseVersions[packageName] = packageInfo['dist-tags'].latest - } - return latestReleaseVersions[packageName] - } catch (e) { - verboseLog('Error when looking up latest package') - verboseLog(e) - return null - } -} - -async function updateLatestReleaseVersions () { - const timer = startPerformanceTimer() - await Promise.all(Object.keys(latestReleaseVersions).map(lookupLatestPackageVersion)) - endPerformanceTimer('updateLatestReleaseVersions', timer) - return latestReleaseVersions -} - -if (!config.getConfig().isTest) { - updateLatestReleaseVersions() - setInterval(lookupLatestPackageVersion, 1000 * 60 * 20) -} - function getManagementView (filename) { return ['views', 'manage-prototype', filename].join('/') } @@ -174,6 +128,12 @@ function developmentOnlyMiddleware (req, res, next) { } } +// Middleware to ensure pages load when plugin cache has been initially loaded +async function pluginCacheMiddleware (req, res, next) { + await waitForPackagesCache() + next() +} + const managementLinks = [ { text: 'Home', @@ -206,12 +166,14 @@ 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 viewData = { currentUrl: req.originalUrl, currentSection: pageName, links: managementLinks, - kitUpgradeAvailable: latestReleaseVersions['govuk-prototype-kit'] !== currentKitVersion, - latestAvailableKit: latestReleaseVersions['govuk-prototype-kit'], + kitUpgradeAvailable: kitPackage.latestVersion !== currentKitVersion, + latestAvailableKit: kitPackage.latestVersion, tasks: [ { done: serviceName !== 'Service name goes here' && serviceName !== 'GOV.UK Prototype Kit', @@ -261,14 +223,14 @@ function getPluginTemplates () { return output } -function getTemplatesHandler (req, res) { +async function getTemplatesHandler (req, res) { const pageName = 'Templates' const availableTemplates = getPluginTemplates() const commonTemplatesPackageName = '@govuk-prototype-kit/common-templates' const govUkFrontendPackageName = 'govuk-frontend' let commonTemplatesDetails - const installedPlugins = plugins.listInstalledPlugins() + const installedPlugins = (await getInstalledPackages()).map((pkg) => pkg.packageName) if (installedPlugins.includes(govUkFrontendPackageName) && !installedPlugins.includes(commonTemplatesPackageName)) { commonTemplatesDetails = { pluginDisplayName: plugins.preparePackageNameForDisplay(commonTemplatesPackageName), @@ -390,7 +352,7 @@ async function postTemplatesInstallHandler (req, res) { } // Don't allow URI reserved characters (per RFC 3986) in paths - if ("!$&'()*+,;=:?#[]@.% ".split('').some((char) => chosenUrl.includes(char))) { + if ('!$&\'()*+,;=:?#[]@.% '.split('').some((char) => chosenUrl.includes(char))) { renderError('invalid') return } @@ -424,83 +386,78 @@ function getTemplatesPostInstallHandler (req, res) { }) } -function removeDuplicates (val, index, arr) { - return !arr.includes(val, index + 1) -} - -async function getPluginDetails () { - const pkg = fse.readJsonSync(pkgPath) - const installed = plugins.listInstalledPlugins() - return Promise.all(installed.concat(plugins.getKnownPlugins().available) - .filter(removeDuplicates) - .map(async (packageName) => { - // Only those plugins not referenced locally can be looked up - const reference = pkg.dependencies[packageName] || '' - const installedLocally = reference.startsWith('file:') - const latestVersion = !installedLocally && await lookupLatestPackageVersion(packageName) - return { - packageName, - latestVersion, - installedLocally - } - }) - .map(async (packageDetails) => { - const pack = await packageDetails - Object.assign(pack, plugins.preparePackageNameForDisplay(pack.packageName), { - installLink: `${contextPath}/plugins/install?package=${encodeURIComponent(pack.packageName)}`, - installCommand: `npm install ${pack.packageName}`, - upgradeCommand: pack.latestVersion && `npm install ${pack.packageName}@${pack.latestVersion}`, - uninstallCommand: `npm uninstall ${pack.packageName}` - }) - if (installed.includes(pack.packageName)) { - const pluginPkgPath = path.join(projectDir, 'node_modules', pack.packageName, 'package.json') - const pluginPkg = await fse.pathExists(pluginPkgPath) ? await fse.readJson(pluginPkgPath) : {} - pack.installedVersion = pluginPkg.version - const mandatoryPlugins = ['govuk-prototype-kit'] - if (!config.getConfig().allowGovukFrontendUninstall) { - mandatoryPlugins.push('govuk-frontend') - } - if (!mandatoryPlugins.includes(pack.packageName)) { - pack.uninstallLink = `${contextPath}/plugins/uninstall?package=${encodeURIComponent(pack.packageName)}` - } - } - if (pack.latestVersion && pack.installedVersion && pack.installedVersion !== pack.latestVersion) { - pack.upgradeLink = `${contextPath}/plugins/upgrade?package=${encodeURIComponent(pack.packageName)}` - } - return pack - })) +function buildPluginData (pluginData) { + if (pluginData === undefined) { + return + } + const { + packageName, + installed, + installedLocally, + latestVersion, + installedVersion, + required, + localVersion + } = pluginData + const preparedPackageNameForDisplay = plugins.preparePackageNameForDisplay(packageName) + return { + ...preparedPackageNameForDisplay, + packageName, + latestVersion, + installedLocally, + installLink: `${contextPath}/plugins/install?package=${encodeURIComponent(packageName)}`, + installCommand: `npm install ${packageName}`, + upgradeLink: installed && !installedLocally && latestVersion !== installedVersion ? `${contextPath}/plugins/upgrade?package=${encodeURIComponent(packageName)}` : undefined, + upgradeCommand: latestVersion && `npm install ${packageName}@${latestVersion}`, + uninstallLink: installed && !required ? `${contextPath}/plugins/uninstall?package=${encodeURIComponent(packageName)}${installedLocally ? `&version=${encodeURIComponent(localVersion)}` : ''}` : undefined, + uninstallCommand: `npm uninstall ${packageName}`, + installedVersion + } } async function prepareForPluginPage () { - const all = await getPluginDetails() - const installedOut = {} const availableOut = {} const output = [installedOut, availableOut] + const installedPlugins = await getInstalledPackages() + const availablePlugins = await getAvailablePackages() + installedOut.name = 'Installed' - installedOut.plugins = [] + installedOut.plugins = installedPlugins.map(buildPluginData) installedOut.status = 'installed' availableOut.name = 'Available' - availableOut.plugins = [] + availableOut.plugins = availablePlugins.map(buildPluginData) availableOut.status = 'available' - all.forEach(packageInfo => { - if (packageInfo.installedVersion) { - installedOut.plugins.push(packageInfo) - } else if (packageInfo.latestVersion) { - availableOut.plugins.push(packageInfo) - } - }) - return output } function getCommand (mode, chosenPlugin) { - let { upgradeCommand, installCommand, uninstallCommand, version } = chosenPlugin + let { + upgradeCommand, + installCommand, + uninstallCommand, + version, + dependencyPlugins, + dependentPlugins + } = chosenPlugin + const dependents = dependentPlugins?.map(({ packageName }) => packageName).join(' ') + const dependencies = dependencyPlugins?.map(({ packageName }) => packageName).join(' ') + if (version && installCommand) { - installCommand = installCommand + `@${version}` + installCommand += `@${version}` + } + + if (dependents) { + uninstallCommand += ' ' + dependents + } + + if (dependencies) { + installCommand += ' ' + dependencies + upgradeCommand += ' ' + dependencies } + switch (mode) { case 'upgrade': return upgradeCommand + ' --save-exact' @@ -554,28 +511,58 @@ async function getPluginsHandler (req, res) { } async function getPluginForRequest (req) { - const searchPackage = req.query.package || req.body.package + const packageName = req.query.package || req.body.package const version = req.query.version || req.body.version + const { mode } = req.params let chosenPlugin - if (searchPackage) { - const pluginDetails = await getPluginDetails() - chosenPlugin = pluginDetails.find(({ packageName }) => packageName === searchPackage) - if (chosenPlugin && version) { - if (await isValidVersion(searchPackage, version)) { + + if (packageName) { + chosenPlugin = buildPluginData(await lookupPackageInfo(packageName, version)) + if (!chosenPlugin) { + return // chosen plugin will be invalid + } + 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 } } } + + const dependentPlugins = mode === 'uninstall' + ? (await getDependentPackages(chosenPlugin.packageName)) + .filter(({ installed }) => installed) + .map(buildPluginData) + : [] + + if (dependentPlugins.length) { + chosenPlugin.dependentPlugins = dependentPlugins + } + + const dependencyPlugins = mode !== 'uninstall' + ? (await getDependencyPackages(chosenPlugin.packageName, version)) + .filter(({ installed }) => !installed) + .map(buildPluginData) + : [] + + if (dependencyPlugins.length) { + chosenPlugin.dependencyPlugins = dependencyPlugins + } + return chosenPlugin } -function modeIsComplete (mode, { installedVersion, latestVersion, version }) { +function modeIsComplete (mode, { installedVersion, latestVersion, version, installedLocally }) { switch (mode) { - case 'upgrade': return installedVersion === latestVersion - case 'install': return version ? installedVersion === version : !!installedVersion - case 'uninstall': return !installedVersion + case 'upgrade': + return installedVersion === latestVersion + case 'install': + return installedLocally || (version ? installedVersion === version : !!installedVersion) + case 'uninstall': + return !installedVersion } } @@ -605,6 +592,17 @@ async function getPluginsModeHandler (req, res) { const returnLink = req.query.returnTo === 'templates' ? templatesReturnLink : pluginsReturnLink + const fullPluginName = `${chosenPlugin.name}${chosenPlugin.version ? ` version ${chosenPlugin.version} ` : ''}${chosenPlugin.scope ? ` from ${chosenPlugin.scope}` : ''}` + + 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` + } + res.render(getManagementView('plugin-install-or-uninstall.njk'), { currentSection: 'Plugins', pageName, @@ -612,6 +610,8 @@ async function getPluginsModeHandler (req, res) { links: managementLinks, chosenPlugin, command: getCommand(mode, chosenPlugin), + pluginHeading, + dependencyHeading, verb, isSameOrigin, returnLink @@ -717,6 +717,7 @@ module.exports = { getPasswordHandler, postPasswordHandler, developmentOnlyMiddleware, + pluginCacheMiddleware, getHomeHandler, getTemplatesHandler, getTemplatesViewHandler, diff --git a/lib/manage-prototype-handlers.test.js b/lib/manage-prototype-handlers.test.js index 2e6948a69f..81ce29a022 100644 --- a/lib/manage-prototype-handlers.test.js +++ b/lib/manage-prototype-handlers.test.js @@ -9,9 +9,12 @@ const nunjucksConfiguration = require('./nunjucks/nunjucksConfiguration') // local dependencies const config = require('./config') -const utils = require('./utils') +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 { setKitRestarted, @@ -35,6 +38,17 @@ const { const { projectDir } = require('./utils/paths') // mocked dependencies +jest.mock('../package.json', () => { + return { + dependencies: {} + } +}) +// mocked dependencies +jest.mock('../known-plugins.json', () => { + return { + plugins: {} + } +}) jest.mock('fs-extra', () => { return { readFile: jest.fn().mockResolvedValue(''), @@ -42,9 +56,7 @@ jest.mock('fs-extra', () => { ensureDir: jest.fn().mockResolvedValue(true), exists: jest.fn().mockResolvedValue(true), existsSync: jest.fn().mockReturnValue(true), - pathExists: jest.fn().mockResolvedValue(true), pathExistsSync: jest.fn().mockReturnValue(true), - readJson: jest.fn().mockResolvedValue({}), readJsonSync: jest.fn().mockReturnValue({}) } }) @@ -57,20 +69,51 @@ jest.mock('./nunjucks/nunjucksConfiguration', () => { }) jest.mock('./utils', () => { return { - encryptPassword: jest.fn().mockReturnValue('encrypted password'), + encryptPassword: jest.fn().mockReturnValue('encrypted password') + } +}) +jest.mock('./utils/requestHttps', () => { + return { requestHttpsJson: jest.fn() } }) jest.mock('./plugins/plugins', () => { return { + ...jest.requireActual('./plugins/plugins'), getAppViews: jest.fn(), getAppConfig: jest.fn(), - getByType: jest.fn(), - listInstalledPlugins: jest.fn(), - getKnownPlugins: jest.fn().mockReturnValue({ available: [] }), - preparePackageNameForDisplay: jest.fn() + getByType: jest.fn() } }) + +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([]), + getAvailablePackages: jest.fn().mockResolvedValue([availablePackage]), + getDependentPackages: jest.fn().mockResolvedValue([]), + getDependencyPackages: jest.fn().mockResolvedValue([]) + } +}) + jest.mock('./exec', () => { return { exec: jest.fn().mockReturnValue({ finally: jest.fn() }) @@ -153,10 +196,11 @@ describe('manage-prototype-handlers', () => { }) it('getHomeHandler', async () => { + packages.lookupPackageInfo.mockResolvedValue({ packageName: 'govuk-prototype-kit', latestVersion: '1.0.0' }) await getHomeHandler(req, res) expect(res.render).toHaveBeenCalledWith( 'views/manage-prototype/index.njk', - expect.objectContaining({ currentSection: 'Home' }) + expect.objectContaining({ currentSection: 'Home', latestAvailableKit: '1.0.0' }) ) }) @@ -199,8 +243,6 @@ describe('manage-prototype-handlers', () => { path: templatePath } }]) - plugins.preparePackageNameForDisplay.mockReturnValue(pluginDisplayName) - plugins.listInstalledPlugins.mockReturnValue([]) nunjucksConfiguration.getNunjucksAppEnv.mockImplementation(() => ({ render: () => view })) @@ -367,31 +409,31 @@ describe('manage-prototype-handlers', () => { name: pluginDisplayName.name, packageName, uninstallCommand: `npm uninstall ${packageName}`, - upgradeCommand: `npm install ${packageName}@${latestVersion}`, - installedLocally: false + upgradeCommand: `npm install ${packageName}@${latestVersion}` } beforeEach(() => { - plugins.listInstalledPlugins.mockReturnValue([]) - plugins.preparePackageNameForDisplay.mockReturnValue(pluginDisplayName) - plugins.getKnownPlugins.mockReturnValue({ available: [packageName] }) + knownPlugins.plugins = { available: [packageName] } + projectPackage.dependencies = {} const versions = {} versions[latestVersion] = {} versions[previousVersion] = {} - utils.requestHttpsJson.mockResolvedValue({ + requestHttpsJson.mockResolvedValue({ + name: packageName, 'dist-tags': { latest: latestVersion, 'latest-1': previousVersion }, versions }) - fse.readJsonSync.mockReturnValue({ - dependencies: {} - }) + // mocking the reading of the local package.json + fse.readJsonSync.mockReturnValue(undefined) + packages.lookupPackageInfo.mockResolvedValue(Promise.resolve(availablePlugin)) res.json = jest.fn().mockReturnValue({}) }) it('getPluginsHandler', async () => { + fse.readJsonSync.mockReturnValue(undefined) req.query.mode = 'install' await getPluginsHandler(req, res) expect(res.render).toHaveBeenCalledWith( @@ -428,71 +470,6 @@ describe('manage-prototype-handlers', () => { ) }) - describe('postPluginsStatusHandler', () => { - let pkg - - beforeEach(() => { - req.params.mode = 'install' - req.query.package = packageName - pkg = { - name: packageName, - version: latestVersion, - dependencies: { [packageName]: latestVersion } - } - fse.readJson.mockResolvedValue(pkg) - fse.readJsonSync.mockReturnValue(pkg) - }) - - it('is processing', async () => { - await postPluginsStatusHandler(req, res) - expect(res.json).toHaveBeenCalledWith( - expect.objectContaining({ - status: 'processing' - }) - ) - }) - - it('is completed', async () => { - plugins.listInstalledPlugins.mockReturnValue([packageName]) - 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' - plugins.listInstalledPlugins.mockReturnValue([localPlugin]) - 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) - }) - }) - describe('postPluginsModeHandler', () => { beforeEach(() => { req.params.mode = 'install' @@ -513,6 +490,11 @@ describe('manage-prototype-handlers', () => { }) 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) @@ -528,6 +510,7 @@ describe('manage-prototype-handlers', () => { }) it('error invalid package', async () => { + packages.lookupPackageInfo.mockResolvedValue(undefined) req.body.package = 'invalid-package' await postPluginsModeHandler(req, res) expect(exec.exec).not.toHaveBeenCalled() @@ -550,7 +533,6 @@ describe('manage-prototype-handlers', () => { }) it('is passed on to the postPluginsStatusHandler when status matches mode during upgrade from 13.1 to 13.2.4 and upwards', async () => { - plugins.listInstalledPlugins.mockReturnValue([packageName]) req.params.mode = 'status' setKitRestarted(true) await postPluginsModeHandler(req, res) @@ -558,6 +540,62 @@ describe('manage-prototype-handlers', () => { // req.params.mode should change to upgrade expect(req.params.mode).toEqual('upgrade') + 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' @@ -565,5 +603,19 @@ describe('manage-prototype-handlers', () => { ) }) }) + + 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 1d4f601170..6f0a787d74 100644 --- a/lib/manage-prototype-routes.js +++ b/lib/manage-prototype-routes.js @@ -22,7 +22,7 @@ const { getPluginsModeHandler, postPluginsModeMiddleware, postPluginsModeHandler, - postPluginsStatusHandler + postPluginsStatusHandler, pluginCacheMiddleware } = require('./manage-prototype-handlers') const path = require('path') const { getInternalGovukFrontendDir } = require('./utils') @@ -54,6 +54,8 @@ 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) 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 b7523eaf5a..ccc29d9a7a 100644 --- a/lib/nunjucks/views/manage-prototype/plugin-install-or-uninstall.njk +++ b/lib/nunjucks/views/manage-prototype/plugin-install-or-uninstall.njk @@ -7,9 +7,15 @@

Plugins

-

- {{ verb.title }} {{ chosenPlugin.name }} {% if chosenPlugin.version %} version {{ chosenPlugin.version }} {% endif %} {% if chosenPlugin.scope %} from {{ chosenPlugin.scope }} {% endif %} -

+
+

{{ pluginHeading }}

+
+ + {% if dependencyHeading %} +
+

{{ dependencyHeading }}

+
+ {% endif %}

@@ -28,7 +34,35 @@

- {% if not isSameOrigin %} + {% if chosenPlugin.dependencyPlugins|length or chosenPlugin.dependentPlugins|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 %} +
+ +

+ {% if verb.para == 'uninstall' %} + If you uninstall {{ chosenPlugin.name }}, these plugins will no longer work. + {% else %} + To {{ verb.para }} {{ chosenPlugin.name }} you also need to {{ verb.para }} these plugins. + {% endif %} +

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

Are you sure you want to {{ verb.para }} this plugin? diff --git a/lib/plugins/packages.js b/lib/plugins/packages.js new file mode 100644 index 0000000000..d1c4422a83 --- /dev/null +++ b/lib/plugins/packages.js @@ -0,0 +1,245 @@ +// 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') + +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 latestVersion = registryInfo ? registryInfo['dist-tags']?.latest : undefined + const versions = registryInfo ? Object.keys(registryInfo.versions) : [] + + const installedPackageVersion = projectPackage.dependencies[packageName] + const installed = !!installedPackageVersion + const installedLocally = installedPackageVersion?.startsWith('file:') + + 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) + } + + const { version: installedVersion } = installed ? packageJson : {} + if (installedLocally) { + localVersion = path.resolve(installedPackageVersion.replace('file:', '')) + } + + const pluginDependencies = pluginConfig?.pluginDependencies + + 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 getAvailablePackages () { + if (!Object.keys(packagesCache).length) { + await startPackageTracker() + } + await waitForPackagesCache() + return Object.values(packagesCache) + .filter(({ available, installed }) => available && !installed) + .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) + } + } +} + +async function getDependentPackages (packageName) { + 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) { + if (!Object.keys(packagesCache).length) { + await startPackageTracker() + } + await waitForPackagesCache() + const pkg = await lookupPackageInfo(packageName, version) + return !pkg?.pluginDependencies + ? [] + : await Promise.all(pkg.pluginDependencies.map((pluginDependency) => { + return typeof pluginDependency === 'string' ? lookupPackageInfo(pluginDependency) : lookupPackageInfo(pluginDependency.packageName) + })) +} + +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, + getAvailablePackages, + getDependentPackages, + getDependencyPackages +} diff --git a/lib/plugins/packages.spec.js b/lib/plugins/packages.spec.js new file mode 100644 index 0000000000..468db75538 --- /dev/null +++ b/lib/plugins/packages.spec.js @@ -0,0 +1,264 @@ +/* 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, + getAvailablePackages, + 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 + } + const availableUninstalledPackage = { + packageName: 'available-uninstalled-package', + installed: false, + available: true + } + const unavailableInstalledPackage = { + packageName: 'unavailable-installed-package', + installed: true, + available: false, + pluginDependencies: [availableInstalledPackage] + } + const unavailableUninstalledPackage = { + packageName: 'unavailable-uninstalled-package', + installed: false, + available: false, + pluginDependencies: [availableUninstalledPackage] + } + + beforeEach(() => { + setPackagesCache([ + availableInstalledPackage, + availableUninstalledPackage, + unavailableInstalledPackage, + unavailableUninstalledPackage]) + }) + + describe('getInstalledPackages', () => { + it('', async () => { + const installedPackages = await getInstalledPackages() + expect(installedPackages).toEqual([availableInstalledPackage, unavailableInstalledPackage]) + }) + }) + + describe('getAvailablePackages', () => { + it('', async () => { + const availablePackages = await getAvailablePackages() + expect(availablePackages).toEqual([availableUninstalledPackage]) + }) + }) + + describe('getDependentPackages', () => { + it('', async () => { + const dependentPackages = await getDependentPackages(availableInstalledPackage.packageName) + expect(dependentPackages).toEqual([unavailableInstalledPackage]) + }) + }) + + 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' + ] + }, + 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 new file mode 100644 index 0000000000..2e579c387b --- /dev/null +++ b/lib/plugins/plugin-utils.js @@ -0,0 +1,14 @@ +// 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'] + } + } + return proxyPluginConfig[packageName] ? { ...proxyPluginConfig[packageName] } : undefined +} + +module.exports = { + getProxyPluginConfig +} diff --git a/lib/plugins/plugin-validator.js b/lib/plugins/plugin-validator.js index 7ada59841b..f521f0ba9c 100644 --- a/lib/plugins/plugin-validator.js +++ b/lib/plugins/plugin-validator.js @@ -14,7 +14,8 @@ function getKnownKeys () { 'sass', 'scripts', 'stylesheets', - 'templates' + 'templates', + 'pluginDependencies' ] return knownKeys } @@ -50,7 +51,7 @@ function validateConfigKeys (pluginConfig) { const knownKeys = getKnownKeys() const invalidKeys = [] - const validKeysPluginConfig = Object.fromEntries(Object.entries(pluginConfig).filter(([key, value]) => { + const validKeysPluginConfig = Object.fromEntries(Object.entries(pluginConfig).filter(([key]) => { if (knownKeys.includes(key)) { return true } @@ -66,6 +67,29 @@ function validateConfigKeys (pluginConfig) { return validKeysPluginConfig } +function validatePluginDependency (key, configEntry) { + if (typeof configEntry === 'string') { + return + } + // Can be a string, but if an object, the packageName must be a string + if (!Object.keys(configEntry).includes('packageName')) { + errors.push(`In section ${key}, the packageName property should exist`) + return + } + const { packageName, minVersion, maxVersion } = configEntry + if (typeof packageName !== 'string') { + errors.push(`In section ${key}, the packageName '${packageName}' should be a valid package name`) + } + // The minVersion is optional but must be a string if entered + if (Object.keys(configEntry).includes('minVersion') && typeof minVersion !== 'string') { + errors.push(`In section ${key}, the minVersion '${minVersion}' should be a valid version`) + } + // The maxVersion is optional but must be a string if entered + if (Object.keys(configEntry).includes('maxVersion') && typeof maxVersion !== 'string' && typeof maxVersion !== 'undefined') { + errors.push(`In section ${key}, the maxVersion '${maxVersion}' should be a valid version if entered`) + } +} + function validateConfigPaths (pluginConfig, executionPath) { console.log('Validating whether config paths meet criteria.') const keysToValidate = Object.keys(pluginConfig) @@ -77,15 +101,17 @@ function validateConfigPaths (pluginConfig, executionPath) { criteriaConfig = [criteriaConfig] } - criteriaConfig.forEach((configEntry, index) => { + criteriaConfig.forEach((configEntry) => { try { - if (typeof configEntry === 'string' && configEntry[0] === '/') { - checkPathExists(executionPath, configEntry, key) + if (key === 'pluginDependencies') { + validatePluginDependency(key, configEntry) } else if ((key === 'templates' && configEntry.path[0] === '/') || (key === 'scripts' && configEntry.path !== undefined && configEntry.path[0] === '/')) { checkPathExists(executionPath, configEntry.path, key) - } else if ((key === 'nunjucksMacros')) { + } else if (key === 'nunjucksMacros') { checkNunjucksMacroExists(executionPath, configEntry.importFrom, pluginConfig.nunjucksPaths) + } else if (typeof configEntry === 'string' && configEntry[0] === '/') { + checkPathExists(executionPath, configEntry, key) } else { // Find the path for the ones that can be objects const invalidPath = (key === 'templates' || (key === 'scripts' && configEntry.path !== undefined)) @@ -145,6 +171,19 @@ async function validatePlugin (executionPath) { } } +function clearErrors () { + while (errors.length) { + errors.pop() + } +} + +function getErrors () { + return [...errors] +} + module.exports = { - validatePlugin + clearErrors, + getErrors, + validatePlugin, + validatePluginDependency } diff --git a/lib/plugins/plugin-validator.spec.js b/lib/plugins/plugin-validator.spec.js new file mode 100644 index 0000000000..9d3247d15a --- /dev/null +++ b/lib/plugins/plugin-validator.spec.js @@ -0,0 +1,64 @@ +const { validatePluginDependency, clearErrors, getErrors } = require('./plugin-validator') + +describe('plugin-validator', () => { + beforeEach(() => { + clearErrors() + }) + + describe('validatePluginDependency', () => { + it('should be valid with a string', () => { + validatePluginDependency('pluginDependencies', 'test-package') + expect(getErrors()).toEqual([]) + }) + + it('should be valid when an object with only the pluginName as a property', () => { + validatePluginDependency('pluginDependencies', { packageName: 'test-package' }) + expect(getErrors()).toEqual([]) + }) + + it('should be invalid when an object without the pluginName as a property', () => { + validatePluginDependency('pluginDependencies', {}) + expect(getErrors()).toEqual([ + 'In section pluginDependencies, the packageName property should exist' + ]) + }) + + it('should be invalid when an object when the pluginName is not a string', () => { + validatePluginDependency('pluginDependencies', { packageName: null }) + expect(getErrors()).toEqual([ + 'In section pluginDependencies, the packageName \'null\' should be a valid package name' + ]) + }) + + it('should be valid when an object with both the pluginName and minVersion as properties', () => { + validatePluginDependency('pluginDependencies', { packageName: 'test-package', minVersion: '1.0.0' }) + expect(getErrors()).toEqual([]) + }) + + it('should be valid when an object with both the pluginName and maxVersion as properties', () => { + validatePluginDependency('pluginDependencies', { packageName: 'test-package', maxVersion: '2.0.0' }) + expect(getErrors()).toEqual([]) + }) + + it('should be valid when an object with the pluginName, minVersion and maxVersion as properties', () => { + validatePluginDependency('pluginDependencies', { + packageName: 'test-package', + minVersion: '1.0.0', + maxVersion: '2.0.0' + }) + expect(getErrors()).toEqual([]) + }) + + it('should be invalid the minVersion or maxVersion are not strings', () => { + validatePluginDependency('pluginDependencies', { + packageName: 'test-package', + minVersion: null, + maxVersion: 100 + }) + expect(getErrors()).toEqual([ + 'In section pluginDependencies, the minVersion \'null\' should be a valid version', + 'In section pluginDependencies, the maxVersion \'100\' should be a valid version if entered' + ]) + }) + }) +}) diff --git a/lib/plugins/plugins.js b/lib/plugins/plugins.js index 2c1dd0461c..c2d8b32101 100644 --- a/lib/plugins/plugins.js +++ b/lib/plugins/plugins.js @@ -43,9 +43,9 @@ const fse = require('fs-extra') // local dependencies const appConfig = require('../config') -const { projectDir, packageDir, shadowNunjucksDir } = require('../utils/paths') +const { projectDir, shadowNunjucksDir } = require('../utils/paths') const { startPerformanceTimer, endPerformanceTimer } = require('../utils/performance') -const knownPlugins = require(path.join(packageDir, 'known-plugins.json')) +const { getProxyPluginConfig } = require('./plugin-utils') const pkgPath = path.join(projectDir, 'package.json') @@ -61,13 +61,6 @@ const objectMap = (object, mapFn) => Object.keys(object).reduce((result, key) => const getPathFromProjectRoot = (...all) => path.join(...[projectDir].concat(all)) const pathToPluginConfigFile = packageName => getPathFromProjectRoot('node_modules', packageName, 'govuk-prototype-kit.config.json') -const moduleToPluginConversion = { - jquery: { - scripts: ['/dist/jquery.js'], - assets: ['/dist'] - } -} - const readJsonFile = (filePath) => JSON.parse(fs.readFileSync(filePath, 'utf8')) function getPluginConfig (packageName) { @@ -77,9 +70,10 @@ function getPluginConfig (packageName) { endPerformanceTimer('getPluginConfig (fileSystem)', timer) return readJsonFile(pluginConfigFile) } - if (moduleToPluginConversion[packageName]) { + const proxyPluginConfig = getProxyPluginConfig(packageName) + if (proxyPluginConfig) { endPerformanceTimer('getPluginConfig (backup)', timer) - return moduleToPluginConversion[packageName] + return proxyPluginConfig } endPerformanceTimer('getPluginConfig (empty)', timer) return {} @@ -102,10 +96,6 @@ function throwIfBadFilepath (subject) { // Check for `basePlugins` in config.js. If it's not there, default to `govuk-frontend` const getBasePlugins = () => appConfig.getConfig().basePlugins -function getKnownPlugins () { - return knownPlugins.plugins -} - /** * Get all npm dependencies * @@ -252,9 +242,6 @@ function preparePackageNameForDisplay (packageName, version) { return packageNameDetails } -const listInstalledPlugins = () => getPackageNamesInOrder() - .filter((packageName) => Object.keys(getPluginConfig(packageName)).length || getKnownPlugins().available.includes(packageName)) - function expandToIncludeShadowNunjucks (arr) { const out = [] arr.forEach(orig => { @@ -374,8 +361,6 @@ function legacyGovukFrontendFixesNeeded () { // Exports const self = module.exports = { preparePackageNameForDisplay, - listInstalledPlugins, - getKnownPlugins, getByType, getPublicUrls, getFileSystemPaths, diff --git a/lib/utils/index.js b/lib/utils/index.js index 311e8a1e11..594172d3ec 100644 --- a/lib/utils/index.js +++ b/lib/utils/index.js @@ -3,7 +3,6 @@ const crypto = require('crypto') const fs = require('fs') const fsp = fs.promises -const https = require('https') const path = require('path') const { existsSync } = require('fs') @@ -197,30 +196,6 @@ function encryptPassword (password) { return hash.digest('hex') } -function requestHttpsJson (url) { - return new Promise((resolve, reject) => { - https.get(url, (res) => { - const dataParts = [] - - const statusCode = res.statusCode - if (statusCode < 200 || statusCode >= 300) { - const error = new Error(`Bad response from [${url}]`) - error.statusCode = statusCode - error.code = 'EBADRESPONSE' - reject(error) - } - res.on('end', () => { - resolve(JSON.parse(dataParts.join(''))) - }) - res.on('data', (d) => { - dataParts.push(d) - }) - }).on('error', (e) => { - reject(e) - }) - }) -} - function sessionFileStoreQuietLogFn (message) { if (message.endsWith('Deleting expired sessions')) { // session-file-store logs every time it prunes files for expired sessions, @@ -312,7 +287,6 @@ module.exports = { sleep, waitUntilFileExists, encryptPassword, - requestHttpsJson, sessionFileStoreQuietLogFn, searchAndReplaceFiles, recursiveDirectoryContentsSync, diff --git a/lib/utils/requestHttps.js b/lib/utils/requestHttps.js new file mode 100644 index 0000000000..80b36e52d3 --- /dev/null +++ b/lib/utils/requestHttps.js @@ -0,0 +1,96 @@ +const https = require('https') +const zlib = require("zlib") +const tar = require('tar-stream') +const {startPerformanceTimer, endPerformanceTimer} = require("./performance") +const path = require("path") +const {tmpDir} = require("./paths") +const {exists, readJson, ensureDir, writeJson} = require("fs-extra") + +async function getConfigForPackage(packageName) { + const timer = startPerformanceTimer() + + const cacheFileDirectory = path.join(tmpDir, 'caches') + const cacheFileReference = path.join(cacheFileDirectory, 'getConfigForPackage.json') + let cache = {} + + await ensureDir(cacheFileDirectory) + if (await exists(cacheFileReference)) { + cache = await readJson(cacheFileReference) + } + + let registry + + try { + registry = await requestHttpsJson(`https://registry.npmjs.org/${encodeURIComponent(packageName)}`) + } catch (e) { + endPerformanceTimer('getConfigForPackage (bad status)', timer) + + return undefined + } + const latestTag = registry['dist-tags']?.latest + +function requestHttpsJson (url) { + return new Promise((resolve, reject) => { + https.get(url, (response) => { + + const statusCode = response.statusCode + + if (statusCode < 200 || statusCode >= 300) { + const error = new Error(`Bad response from [${url}]`) + error.statusCode = statusCode + error.code = 'EBADRESPONSE' + reject(error) + } + + prepareFn(options, response, resolve, reject) + }).on('error', (e) => { + reject(e) + }) + }) +} + +const requestHttpsJson = setupRequestFunction((options, response, resolve) => { + const dataParts = [] + response.on('end', () => { + const data = dataParts.join('') + if (data.startsWith('{')) { + resolve(JSON.parse(data)) + } else { + resolve() + } + }) + response.on('data', (d) => { + dataParts.push(d) + }) +}) + +const findFileInHttpsTgz = setupRequestFunction((options, response, resolve) => { + const extract = tar.extract(); + const data = []; + + extract.on('entry', function (header, stream, cb) { + stream.on('data', function (chunk) { + if (header.name === options.fileToFind) { + data.push(chunk.toString()) + } + }) + }) + + extract.on('finish', function () { + const result = data.join(''); + if (options.prepare) { + resolve(options.prepare(result)) + } else { + resolve(result) + } + }) + + response + .pipe(zlib.createGunzip()) + .pipe(extract) +}) + +module.exports = { + requestHttpsJson, + getConfigForPackage +} From 0552d91e74becd319e8a7f6d16a93c3504d386bc Mon Sep 17 00:00:00 2001 From: Natalie Carey Date: Mon, 19 Jun 2023 22:21:39 +0100 Subject: [PATCH 2/4] Handling malformed JSON in cache file. Added cache for config lookup. --- .../plugin-install-or-uninstall.njk | 6 +- lib/utils/requestHttps.js | 48 +++++++++-- npm-shrinkwrap.json | 84 ++++++++++++++++++- package.json | 4 +- proof-of-concept.js | 19 +++++ 5 files changed, 149 insertions(+), 12 deletions(-) create mode 100644 proof-of-concept.js 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 ccc29d9a7a..a01a082f3b 100644 --- a/lib/nunjucks/views/manage-prototype/plugin-install-or-uninstall.njk +++ b/lib/nunjucks/views/manage-prototype/plugin-install-or-uninstall.njk @@ -47,11 +47,7 @@

- {% if verb.para == 'uninstall' %} - If you uninstall {{ chosenPlugin.name }}, these plugins will no longer work. - {% else %} - To {{ verb.para }} {{ chosenPlugin.name }} you also need to {{ verb.para }} these plugins. - {% endif %} + To {{ verb.para }} {{ chosenPlugin.name }} you also need to {{ verb.para }} {% if chosenPlugin.dependentPlugins | length > 1 %}these plugins{% else %}this plugin{% endif %}.

{{ govukButton({ diff --git a/lib/utils/requestHttps.js b/lib/utils/requestHttps.js index 80b36e52d3..3af2126d23 100644 --- a/lib/utils/requestHttps.js +++ b/lib/utils/requestHttps.js @@ -15,7 +15,11 @@ async function getConfigForPackage(packageName) { await ensureDir(cacheFileDirectory) if (await exists(cacheFileReference)) { - cache = await readJson(cacheFileReference) + try { + cache = await readJson(cacheFileReference) + } catch (e) { + writeJson(cacheFileReference, {}) + } } let registry @@ -29,10 +33,38 @@ async function getConfigForPackage(packageName) { } const latestTag = registry['dist-tags']?.latest -function requestHttpsJson (url) { - return new Promise((resolve, reject) => { - https.get(url, (response) => { + if (cache[packageName] && cache[packageName][latestTag]) { + endPerformanceTimer('getConfigForPackage (from cache)', timer) + return cache[packageName][latestTag] + } + + if (!latestTag) { + endPerformanceTimer('getConfigForPackage (no latest tag)', timer) + return + } + + const url = registry.versions[latestTag].dist.tarball + const result = await findFileInHttpsTgz(url, { + fileToFind: 'package/govuk-prototype-kit.config.json', + prepare: str => { + if (str && str.startsWith('{')) { + const result = JSON.parse(str) + cache[packageName] = cache[packageName] || {} + cache[packageName][latestTag] = result + writeJson(cacheFileReference, cache) + return result + } + } + }) + + endPerformanceTimer('getConfigForPackage', timer) + + return result +} +function setupRequestFunction(prepareFn, badStatusFn) { + return (url, options) => new Promise((resolve, reject) => { + https.get(url, (response) => { const statusCode = response.statusCode if (statusCode < 200 || statusCode >= 300) { @@ -70,10 +102,16 @@ const findFileInHttpsTgz = setupRequestFunction((options, response, resolve) => extract.on('entry', function (header, stream, cb) { stream.on('data', function (chunk) { - if (header.name === options.fileToFind) { + if (header.name == options.fileToFind) { data.push(chunk.toString()) } }) + + stream.on('end', function () { + cb() + }) + + stream.resume() }) extract.on('finish', function () { diff --git a/npm-shrinkwrap.json b/npm-shrinkwrap.json index 6babb7abae..f6ae1a25b2 100644 --- a/npm-shrinkwrap.json +++ b/npm-shrinkwrap.json @@ -30,8 +30,10 @@ "require-dir": "^1.2.0", "sass": "^1.57.1", "sync-request": "^6.1.0", + "tar-stream": "^3.1.2", "universal-analytics": "^0.5.3", - "uuid": "^9.0.0" + "uuid": "^9.0.0", + "zlib": "^1.0.5" }, "bin": { "govuk-prototype-kit": "bin/cli" @@ -2514,6 +2516,11 @@ "follow-redirects": "^1.14.0" } }, + "node_modules/b4a": { + "version": "1.6.4", + "resolved": "https://registry.npmjs.org/b4a/-/b4a-1.6.4.tgz", + "integrity": "sha512-fpWrvyVHEKyeEvbKZTVOeZF3VSKKWtJxFIxX/jaVPf+cLbGUSitjb49pHLqPV2BUNNZ0LcoeEGfE/YCpyDYHIw==" + }, "node_modules/babel-jest": { "version": "29.3.1", "resolved": "https://registry.npmjs.org/babel-jest/-/babel-jest-29.3.1.tgz", @@ -5788,6 +5795,11 @@ "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", "dev": true }, + "node_modules/fast-fifo": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/fast-fifo/-/fast-fifo-1.2.0.tgz", + "integrity": "sha512-NcvQXt7Cky1cNau15FWy64IjuO8X0JijhTBBrJj1YlxlDfRkJXNaK9RFUjwpfDPzMdv7wB38jr53l9tkNLxnWg==" + }, "node_modules/fast-glob": { "version": "3.2.12", "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.2.12.tgz", @@ -10498,6 +10510,11 @@ } ] }, + "node_modules/queue-tick": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/queue-tick/-/queue-tick-1.0.1.tgz", + "integrity": "sha512-kJt5qhMxoszgU/62PLP1CJytzd2NKetjSRnyuj31fDd3Rlcz3fzlFdFLD1SItunPwyqEOkca6GbV612BWfaBag==" + }, "node_modules/random-bytes": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/random-bytes/-/random-bytes-1.0.0.tgz", @@ -11415,6 +11432,15 @@ "node": ">= 0.10.0" } }, + "node_modules/streamx": { + "version": "2.15.0", + "resolved": "https://registry.npmjs.org/streamx/-/streamx-2.15.0.tgz", + "integrity": "sha512-HcxY6ncGjjklGs1xsP1aR71INYcsXFJet5CU1CHqihQ2J5nOsbd4OjgjHO42w/4QNv9gZb3BueV+Vxok5pLEXg==", + "dependencies": { + "fast-fifo": "^1.1.0", + "queue-tick": "^1.0.1" + } + }, "node_modules/string_decoder": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", @@ -11730,6 +11756,15 @@ "get-port": "^3.1.0" } }, + "node_modules/tar-stream": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-3.1.2.tgz", + "integrity": "sha512-rEZHMKQop/sTykFtONCcOxyyLA+9hriHJlECxfh4z+Nfew87XGprDLp9RYFCd7yU+z3ePXfHlPbZrzgSLvc16A==", + "dependencies": { + "b4a": "^1.6.4", + "streamx": "^2.15.0" + } + }, "node_modules/test-exclude": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/test-exclude/-/test-exclude-6.0.0.tgz", @@ -12611,6 +12646,15 @@ "funding": { "url": "https://github.com/sponsors/sindresorhus" } + }, + "node_modules/zlib": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/zlib/-/zlib-1.0.5.tgz", + "integrity": "sha512-40fpE2II+Cd3k8HWTWONfeKE2jL+P42iWJ1zzps5W51qcTsOUKM5Q5m2PFb0CLxlmFAaUuUdJGc3OfZy947v0w==", + "hasInstallScript": true, + "engines": { + "node": ">=0.2.0" + } } }, "dependencies": { @@ -14507,6 +14551,11 @@ "follow-redirects": "^1.14.0" } }, + "b4a": { + "version": "1.6.4", + "resolved": "https://registry.npmjs.org/b4a/-/b4a-1.6.4.tgz", + "integrity": "sha512-fpWrvyVHEKyeEvbKZTVOeZF3VSKKWtJxFIxX/jaVPf+cLbGUSitjb49pHLqPV2BUNNZ0LcoeEGfE/YCpyDYHIw==" + }, "babel-jest": { "version": "29.3.1", "resolved": "https://registry.npmjs.org/babel-jest/-/babel-jest-29.3.1.tgz", @@ -16888,6 +16937,11 @@ "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", "dev": true }, + "fast-fifo": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/fast-fifo/-/fast-fifo-1.2.0.tgz", + "integrity": "sha512-NcvQXt7Cky1cNau15FWy64IjuO8X0JijhTBBrJj1YlxlDfRkJXNaK9RFUjwpfDPzMdv7wB38jr53l9tkNLxnWg==" + }, "fast-glob": { "version": "3.2.12", "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.2.12.tgz", @@ -20317,6 +20371,11 @@ "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==" }, + "queue-tick": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/queue-tick/-/queue-tick-1.0.1.tgz", + "integrity": "sha512-kJt5qhMxoszgU/62PLP1CJytzd2NKetjSRnyuj31fDd3Rlcz3fzlFdFLD1SItunPwyqEOkca6GbV612BWfaBag==" + }, "random-bytes": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/random-bytes/-/random-bytes-1.0.0.tgz", @@ -20992,6 +21051,15 @@ "limiter": "^1.0.5" } }, + "streamx": { + "version": "2.15.0", + "resolved": "https://registry.npmjs.org/streamx/-/streamx-2.15.0.tgz", + "integrity": "sha512-HcxY6ncGjjklGs1xsP1aR71INYcsXFJet5CU1CHqihQ2J5nOsbd4OjgjHO42w/4QNv9gZb3BueV+Vxok5pLEXg==", + "requires": { + "fast-fifo": "^1.1.0", + "queue-tick": "^1.0.1" + } + }, "string_decoder": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", @@ -21227,6 +21295,15 @@ "get-port": "^3.1.0" } }, + "tar-stream": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-3.1.2.tgz", + "integrity": "sha512-rEZHMKQop/sTykFtONCcOxyyLA+9hriHJlECxfh4z+Nfew87XGprDLp9RYFCd7yU+z3ePXfHlPbZrzgSLvc16A==", + "requires": { + "b4a": "^1.6.4", + "streamx": "^2.15.0" + } + }, "test-exclude": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/test-exclude/-/test-exclude-6.0.0.tgz", @@ -21892,6 +21969,11 @@ "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", "dev": true + }, + "zlib": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/zlib/-/zlib-1.0.5.tgz", + "integrity": "sha512-40fpE2II+Cd3k8HWTWONfeKE2jL+P42iWJ1zzps5W51qcTsOUKM5Q5m2PFb0CLxlmFAaUuUdJGc3OfZy947v0w==" } } } diff --git a/package.json b/package.json index af8825e30e..4b798da425 100644 --- a/package.json +++ b/package.json @@ -78,8 +78,10 @@ "require-dir": "^1.2.0", "sass": "^1.57.1", "sync-request": "^6.1.0", + "tar-stream": "^3.1.2", "universal-analytics": "^0.5.3", - "uuid": "^9.0.0" + "uuid": "^9.0.0", + "zlib": "^1.0.5" }, "devDependencies": { "cheerio": "^1.0.0-rc.12", diff --git a/proof-of-concept.js b/proof-of-concept.js new file mode 100644 index 0000000000..c28342f15a --- /dev/null +++ b/proof-of-concept.js @@ -0,0 +1,19 @@ +const {startPerformanceTimer, endPerformanceTimer} = require("./lib/utils/performance") +const {getConfigForPackage} = require("./lib/utils/requestHttps"); + +async function demo(packageName) { + await getConfigForPackage(packageName); + + console.log('got result for ', packageName) +} + +(async () => { + const timer = startPerformanceTimer() + await demo('govuk-prototype-kit') + await demo('hmrc-frontend') + await demo('govuk-frontend') + await demo('@x-govuk/edit-prototype-in-browser') + await demo('nhsuk-frontend') + await demo('lkasdjflkajsdflkfjsad') + endPerformanceTimer('Demo', timer) +})() From 43375064716ce156313d0f266a8ee2f3cb532d35 Mon Sep 17 00:00:00 2001 From: Ben Surgison Date: Tue, 4 Jul 2023 15:24:09 +0100 Subject: [PATCH 3/4] Remove duplicate and prevent circular dependency --- lib/utils/requestHttps.js | 65 +++++++++++++++++++++++---------------- proof-of-concept.js | 19 ------------ 2 files changed, 39 insertions(+), 45 deletions(-) delete mode 100644 proof-of-concept.js diff --git a/lib/utils/requestHttps.js b/lib/utils/requestHttps.js index 3af2126d23..9b60d729d6 100644 --- a/lib/utils/requestHttps.js +++ b/lib/utils/requestHttps.js @@ -1,12 +1,13 @@ const https = require('https') -const zlib = require("zlib") +const zlib = require('zlib') const tar = require('tar-stream') -const {startPerformanceTimer, endPerformanceTimer} = require("./performance") -const path = require("path") -const {tmpDir} = require("./paths") -const {exists, readJson, ensureDir, writeJson} = require("fs-extra") +const { startPerformanceTimer, endPerformanceTimer } = require('./performance') +const path = require('path') +const { tmpDir } = require('./paths') +const { exists, readJson, ensureDir, writeJson } = require('fs-extra') +const { verboseLog } = require('./verboseLogger') -async function getConfigForPackage(packageName) { +async function getConfigForPackage (packageName) { const timer = startPerformanceTimer() const cacheFileDirectory = path.join(tmpDir, 'caches') @@ -44,25 +45,29 @@ async function getConfigForPackage(packageName) { } const url = registry.versions[latestTag].dist.tarball - const result = await findFileInHttpsTgz(url, { - fileToFind: 'package/govuk-prototype-kit.config.json', - prepare: str => { - if (str && str.startsWith('{')) { - const result = JSON.parse(str) - cache[packageName] = cache[packageName] || {} - cache[packageName][latestTag] = result - writeJson(cacheFileReference, cache) - return result + try { + const result = await findFileInHttpsTgz(url, { + fileToFind: 'package/govuk-prototype-kit.config.json', + prepare: str => { + if (str && str.startsWith('{')) { + const result = JSON.parse(str) + cache[packageName] = cache[packageName] || {} + cache[packageName][latestTag] = result + writeJson(cacheFileReference, cache) + return result + } } - } - }) + }) - endPerformanceTimer('getConfigForPackage', timer) + endPerformanceTimer('getConfigForPackage', timer) - return result + return result + } catch (e) { + endPerformanceTimer('getConfigForPackage (error-in-findFileInHttpsTgz)', timer) + } } -function setupRequestFunction(prepareFn, badStatusFn) { +function setupRequestFunction (prepareFn, badStatusFn) { return (url, options) => new Promise((resolve, reject) => { https.get(url, (response) => { const statusCode = response.statusCode @@ -97,12 +102,12 @@ const requestHttpsJson = setupRequestFunction((options, response, resolve) => { }) const findFileInHttpsTgz = setupRequestFunction((options, response, resolve) => { - const extract = tar.extract(); - const data = []; + const extract = tar.extract() + const data = [] extract.on('entry', function (header, stream, cb) { stream.on('data', function (chunk) { - if (header.name == options.fileToFind) { + if (header.name === options.fileToFind) { data.push(chunk.toString()) } }) @@ -111,11 +116,15 @@ const findFileInHttpsTgz = setupRequestFunction((options, response, resolve) => cb() }) + stream.on('error', function (e) { + verboseLog('Error from tar.extract stream', e) + }) + stream.resume() }) extract.on('finish', function () { - const result = data.join(''); + const result = data.join('') if (options.prepare) { resolve(options.prepare(result)) } else { @@ -124,8 +133,12 @@ const findFileInHttpsTgz = setupRequestFunction((options, response, resolve) => }) response - .pipe(zlib.createGunzip()) - .pipe(extract) + .on('error', (e) => verboseLog('Error from response', e)) + .pipe(zlib.createGunzip().on('error', (e) => verboseLog('Error from gunzip', e))) + .pipe(extract.on('error', (e) => verboseLog('Error from extract', e))) + .on('error', function (e) { + verboseLog('Error from within .tgz pipe', e) + }) }) module.exports = { diff --git a/proof-of-concept.js b/proof-of-concept.js deleted file mode 100644 index c28342f15a..0000000000 --- a/proof-of-concept.js +++ /dev/null @@ -1,19 +0,0 @@ -const {startPerformanceTimer, endPerformanceTimer} = require("./lib/utils/performance") -const {getConfigForPackage} = require("./lib/utils/requestHttps"); - -async function demo(packageName) { - await getConfigForPackage(packageName); - - console.log('got result for ', packageName) -} - -(async () => { - const timer = startPerformanceTimer() - await demo('govuk-prototype-kit') - await demo('hmrc-frontend') - await demo('govuk-frontend') - await demo('@x-govuk/edit-prototype-in-browser') - await demo('nhsuk-frontend') - await demo('lkasdjflkajsdflkfjsad') - endPerformanceTimer('Demo', timer) -})() From 015d0eacfebed481841760cc1676d848ad73113a Mon Sep 17 00:00:00 2001 From: Ben Surgison Date: Tue, 4 Jul 2023 15:26:36 +0100 Subject: [PATCH 4/4] Lint --- lib/utils/requestHttps.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/utils/requestHttps.js b/lib/utils/requestHttps.js index 9b60d729d6..16720fd2f9 100644 --- a/lib/utils/requestHttps.js +++ b/lib/utils/requestHttps.js @@ -67,7 +67,7 @@ async function getConfigForPackage (packageName) { } } -function setupRequestFunction (prepareFn, badStatusFn) { +function setupRequestFunction (prepareFn) { return (url, options) => new Promise((resolve, reject) => { https.get(url, (response) => { const statusCode = response.statusCode