diff --git a/.github/workflows/test-plugins.yaml b/.github/workflows/test-plugins.yaml new file mode 100644 index 0000000000..3d4c1a6fbd --- /dev/null +++ b/.github/workflows/test-plugins.yaml @@ -0,0 +1,56 @@ +name: Tests (Acceptance-Plugins) + +on: + push: + branches: + - main + - support/* + pull_request: + +jobs: + tests: + + strategy: + fail-fast: false # continue other tests if one test in matrix fails + matrix: + node-version: [16.x, 18.x] + os: [macos-latest, windows-latest, ubuntu-latest] + + name: Acceptance test kit on Node v${{ matrix.node-version }} (${{ matrix.os }}) + runs-on: ${{ matrix.os }} + timeout-minutes: 25 + + env: + CYPRESS_CACHE_FOLDER: ~/.cache/Cypress + + steps: + - uses: actions/checkout@v3 + with: + fetch-depth: 0 + + - name: Use Node v${{ matrix.node-version }} + uses: actions/setup-node@v3 + with: + cache: 'npm' + node-version: ${{ matrix.node-version }} + + - name: Cache Cypress binary + uses: actions/cache@v3 + with: + path: ~/.cache/Cypress + key: cypress-${{ runner.os }}-cypress-${{ hashFiles('**/package.json') }} + + - run: npm ci + + - run: npm run test:acceptance:plugins + env: + CYPRESS_REQUEST_TIMEOUT: 20000 + CYPRESS_DEFAULT_COMMAND_TIMEOUT: 40000 + CYPRESS_PAGE_LOAD_TIMEOUT: 120000 + CYPRESS_RETRIES: 3 + + - if: ${{ failure() }} + uses: actions/upload-artifact@v3 + with: + name: cypress-screenshots-dev-${{ runner.os }}-${{ matrix.node-version }} + path: cypress/screenshots/ diff --git a/CHANGELOG.md b/CHANGELOG.md index d5522a3978..c5814418c1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,10 @@ ## Unreleased +### New features + +- [#1824: Add feature to manage plugins without using the command line](https://github.com/alphagov/govuk-prototype-kit/pull/1824) + ### Fixes - [#1849: Update dependencies](https://github.com/alphagov/govuk-prototype-kit/pull/1849) diff --git a/cypress/e2e/dev/6-management-tests/install-available-plugin.cypress.js b/cypress/e2e/dev/6-management-tests/install-available-plugin.cypress.js deleted file mode 100644 index 28eb0a621c..0000000000 --- a/cypress/e2e/dev/6-management-tests/install-available-plugin.cypress.js +++ /dev/null @@ -1,72 +0,0 @@ -const { waitForApplication, uninstallPlugin, installPlugin } = require('../../utils') -const managePluginsPagePath = '/manage-prototype/plugins' -const plugin = '@govuk-prototype-kit/step-by-step' -const version1 = '@1.0.0' -const version2 = '@2.1.0' -const pluginName = 'Step By Step' - -const cleanup = () => { - // Make sure plugin version 1 is installed - installPlugin(plugin, version1) - cy.wait(5000) -} - -describe('Management plugins: ', () => { - before(() => { - cy.task('log', 'Visit the manage prototype plugins page') - cleanup() - waitForApplication(managePluginsPagePath) - }) - - after(() => { - cleanup() - }) - - it(`Make sure ${plugin}${version1} is installed and it can be upgraded to ${plugin}${version2} and then uninstalled`, () => { - cy.task('log', `Make sure ${plugin}${version1} is installed and it can be upgraded to ${plugin}${version2} and then uninstalled`) - cy.get(`a[href*="/uninstall?package=${encodeURIComponent(plugin)}"]`) - .should('contains.text', 'Uninstall') - cy.get(`a[href*="/install?package=${encodeURIComponent(plugin)}&mode=upgrade"]`) - .should('contains.text', 'Upgrade') - }) - - it(`Upgrade the ${plugin} plugin to ${plugin}${version2}`, () => { - cy.task('log', `Upgrade the ${plugin} plugin`) - cy.get(`a[href*="/install?package=${encodeURIComponent(plugin)}&mode=upgrade"]`) - .should('contains.text', 'Upgrade').click() - - cy.task('log', `The ${plugin} plugin should be displayed`) - cy.get('h1') - .should('contains.text', `Upgrade ${pluginName}`) - - cy.get('code').eq(0) - .should('have.text', `npm install ${plugin}${version2}`) - - installPlugin(plugin, version2) - - cy.wait(10000) - - waitForApplication(managePluginsPagePath) - - cy.task('log', `Uninstall the ${plugin}${version2} plugin`) - cy.get(`a[href*="/uninstall?package=${encodeURIComponent(plugin)}"]`) - .should('contains.text', 'Uninstall').click() - - cy.task('log', `The ${plugin} plugin should be displayed`) - cy.get('h1') - .should('contains.text', `Uninstall ${pluginName}`) - - cy.get('code').eq(0) - .should('have.text', `npm uninstall ${plugin}`) - - uninstallPlugin(plugin) - - cy.wait(5000) - - waitForApplication(managePluginsPagePath) - - cy.task('log', `Install the ${plugin} plugin`) - cy.get(`a[href*="/install?package=${encodeURIComponent(plugin)}"]`) - .should('contains.text', 'Install') - }) -}) diff --git a/cypress/e2e/dev/6-management-tests/preview-template-view.cypress.js b/cypress/e2e/dev/6-management-tests/preview-template-view.cypress.js index b99b964fcf..d561296355 100644 --- a/cypress/e2e/dev/6-management-tests/preview-template-view.cypress.js +++ b/cypress/e2e/dev/6-management-tests/preview-template-view.cypress.js @@ -12,13 +12,13 @@ describe('Management plugins: ', () => { before(() => { cy.task('log', 'Visit the manage prototype plugins page') installPlugin(plugin, version2) - cy.wait(5000) + cy.wait(8000) waitForApplication(manageTemplatesPagePath) }) after(() => { installPlugin(plugin, version1) - cy.wait(5000) + cy.wait(8000) }) it(`Preview a ${plugin}${version2} template`, () => { diff --git a/cypress/e2e/plugins/0-plugins-tests/install-available-plugin.cypress.js b/cypress/e2e/plugins/0-plugins-tests/install-available-plugin.cypress.js new file mode 100644 index 0000000000..d65e4f813b --- /dev/null +++ b/cypress/e2e/plugins/0-plugins-tests/install-available-plugin.cypress.js @@ -0,0 +1,212 @@ +const { waitForApplication, installPlugin, getTemplateLink, deleteFile } = require('../../utils') +const { capitalize } = require('lodash') +const path = require('path') +const { showHideAllLinkQuery, assertVisible, assertHidden } = require('../../step-by-step-utils') +const appViews = path.join(Cypress.env('projectFolder'), 'app', 'views') +const managePluginsPagePath = '/manage-prototype/plugins' +const plugin = '@govuk-prototype-kit/step-by-step' +const version1 = '@1.0.0' +const version2 = '@2.1.0' +const pluginName = 'Step By Step' +const pluginPageTemplate = '/templates/step-by-step-navigation.html' +const pluginPageTitle = 'Step by step navigation' +const pluginPagePath = '/step-by-step-navigation' + +const cleanup = () => { + deleteFile(path.join(appViews, 'step-by-step-navigation.html')) + // Make sure plugin version 1 is installed + installPlugin(plugin, version1) + cy.wait(8000) +} + +const panelProcessingQuery = '[aria-live="polite"] #panel-processing' +const panelCompleteQuery = '[aria-live="polite"] #panel-complete' +const panelErrorQuery = '[aria-live="polite"] #panel-error' + +const performPluginAction = (action) => { + cy.task('log', `The ${plugin} plugin should be displayed`) + cy.get('h2') + .should('contains.text', `${capitalize(action)} ${pluginName}`) + + cy.get(panelCompleteQuery) + .should('not.be.visible') + cy.get(panelCompleteQuery) + .should('not.be.visible') + cy.get(panelErrorQuery) + .should('not.be.visible') + cy.get(panelProcessingQuery) + .should('be.visible') + .should('contain.text', `${capitalize(action === 'upgrade' ? 'Upgrad' : action)}ing ...`) + + cy.get(panelProcessingQuery, { timeout: 20000 }) + .should('not.be.visible') + cy.get(panelErrorQuery) + .should('not.be.visible') + cy.get(panelCompleteQuery) + .should('be.visible') + .should('contain.text', `${capitalize(action)} complete`) + + cy.get('#instructions-complete a') + .should('contain.text', 'Back to plugins') + .wait(3000) + .click() + + cy.get('h1').should('have.text', 'Plugins') +} + +const provePluginFunctionalityWorks = () => { + cy.wait(2000) + + cy.task('log', `Prove ${pluginName} functionality works`) + + cy.visit(pluginPagePath) + + // click toggle button and check that all steps details are visible + cy.get(showHideAllLinkQuery).should('contains.text', 'Show all').click() + assertVisible(1) + assertVisible(2) + + // click toggle button and check that all steps details are hidden + cy.get(showHideAllLinkQuery).should('contains.text', 'Hide all').click() + assertHidden(1) + assertHidden(2) +} + +const provePluginFunctionalityFails = () => { + cy.on('uncaught:exception', (err, runnable) => { + console.log(err) + // returning false here prevents Cypress from + // failing a test when javascript in the browser fails + return false + }) + + cy.wait(2000) + + cy.task('log', `Prove ${pluginName} functionality fails`) + + cy.visit(pluginPagePath) + + cy.get(showHideAllLinkQuery).should('not.exist') +} + +describe('Management plugins: ', () => { + before(() => { + cleanup() + cy.task('log', 'Visit the manage prototype plugins page') + waitForApplication(managePluginsPagePath) + }) + + after(() => { + cleanup() + }) + + beforeEach(() => { + cy.wait(4000) + }) + + it('CSRF Protection on POST action', () => { + const installUrl = `${managePluginsPagePath}/install` + cy.task('log', `Posting to ${installUrl} without csrf protection`) + cy.request({ + url: `${managePluginsPagePath}/install`, + method: 'POST', + failOnStatusCode: false, + body: { package: plugin } + }).then(response => { + expect(response.status).to.eq(403) + expect(response.body).to.eq('invalid csrf token') + }) + }) + + it(`Upgrade the ${plugin}${version1} plugin to ${plugin}${version2}`, () => { + cy.task('log', `Upgrade the ${plugin} plugin`) + cy.get(`button[formaction*="/upgrade?package=${encodeURIComponent(plugin)}"]`) + .should('contains.text', 'Upgrade').click() + + performPluginAction('upgrade', version2) + }) + + it(`Create a page using a template from the ${plugin} plugin`, () => { + cy.get('a[href*="/templates"]') + .should('contains.text', 'Templates').click() + + cy.get('h2').eq(2).should('contain.text', pluginName) + + cy.task('log', `Create a new ${pluginPageTitle} page`) + + cy.get(`a[href="${getTemplateLink('install', '@govuk-prototype-kit/step-by-step', pluginPageTemplate)}"]`).click() + + // create step-by-step-navigation page + cy.get('.govuk-heading-l') + .should('contains.text', `Create new ${pluginPageTitle} page`) + cy.get('#chosen-url') + .type(pluginPagePath) + cy.get('.govuk-button') + .should('contains.text', 'Create page').click() + + provePluginFunctionalityWorks() + }) + + it(`Uninstall the ${plugin} plugin`, () => { + cy.visit(managePluginsPagePath) + cy.task('log', `Uninstall the ${plugin} plugin`) + cy.get(`button[formaction*="/uninstall?package=${encodeURIComponent(plugin)}"]`) + .should('contains.text', 'Uninstall').click() + + performPluginAction('uninstall') + + provePluginFunctionalityFails() + }) + + it(`Install the ${plugin} plugin`, () => { + cy.visit(managePluginsPagePath) + cy.task('log', `Install the ${plugin} plugin`) + cy.get(`button[formaction*="/install?package=${encodeURIComponent(plugin)}"]`) + .should('contains.text', 'Install').click() + + performPluginAction('install') + + provePluginFunctionalityWorks() + }) + + describe('Get plugin page directly', () => { + it('Pass when installing a plugin already installed', () => { + cy.task('log', `Simulate refreshing the install ${plugin} plugin confirmation page`) + cy.visit(`${managePluginsPagePath}/install?package=${encodeURIComponent(plugin)}`) + + cy.get('#plugin-action-button').click() + + performPluginAction('install') + }) + + it('Fail when installing a non existent plugin', () => { + const pkg = 'invalid-prototype-kit-plugin' + const pluginName = 'Invalid Prototype Kit Plugin' + cy.visit(`${managePluginsPagePath}/install?package=${encodeURIComponent(pkg)}`) + cy.get('h2') + .should('contains.text', `Install ${pluginName}`) + + cy.get('#plugin-action-button').click() + + cy.get(panelCompleteQuery) + .should('not.be.visible') + cy.get(panelErrorQuery) + .should('not.be.visible') + cy.get(panelProcessingQuery) + .should('be.visible') + .should('contain.text', 'Installing ...') + + 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`) + .should('contain.text', 'There was a problem installing') + cy.get(`${panelErrorQuery} a`) + .should('contain.text', 'Please contact support') + }) + }) +}) diff --git a/govuk-prototype-kit.config.json b/govuk-prototype-kit.config.json index e061512696..e5725b7e8b 100644 --- a/govuk-prototype-kit.config.json +++ b/govuk-prototype-kit.config.json @@ -1,7 +1,8 @@ { "assets": [ "/lib/assets/images", - "/lib/assets/javascripts/optional" + "/lib/assets/javascripts/optional", + "/lib/assets/javascripts/manage-prototype" ], "nunjucksPaths": "/lib/nunjucks", "nunjucksFilters": "/lib/filters/coreFilters", diff --git a/lib/assets/javascripts/manage-prototype/manage-plugins.js b/lib/assets/javascripts/manage-prototype/manage-plugins.js new file mode 100644 index 0000000000..9b377c657a --- /dev/null +++ b/lib/assets/javascripts/manage-prototype/manage-plugins.js @@ -0,0 +1,127 @@ +window.GOVUKPrototypeKit.documentReady(() => { + const params = new URLSearchParams(window.location.search) + const packageName = params.get('package') + const mode = params.get('mode') || window.location.pathname.split('/').pop() + let startTimestamp = '' + let requestTimeoutId + let timedOut = false + const timeout = 30 * 1000 + const token = document.querySelector('meta[name="csrf-token"]').getAttribute('content') + + const show = (id) => { + const element = document.getElementById(id) + element.hidden = false + } + + const hide = (id) => { + const element = document.getElementById(id) + element.hidden = true + } + + const log = (status) => { + window.console.info(`GOV.UK Prototype Kit - ${status} ${mode} of ${packageName}`) + } + + const showCompleteStatus = () => { + clearTimeout(actionTimeoutId) + log('Successful') + hide('panel-processing') + show('panel-complete') + show('instructions-complete') + } + + const showErrorStatus = () => { + log('Failed') + hide('panel-processing') + show('panel-error') + } + + const postRequest = (url) => fetch(url, { + method: 'POST', + headers: { + Accept: 'application/json', + 'Content-Type': 'application/json', + 'CSRF-Token': token + }, + body: JSON.stringify({ + package: packageName + }) + }) + + const actionTimeoutId = setTimeout(() => { + timedOut = true + }, timeout) + + const pollStatus = () => { + log('Processing') + if (requestTimeoutId) { + clearTimeout(requestTimeoutId) + } + if (timedOut) { + showErrorStatus() + } else { + postRequest('/manage-prototype/plugins/status') + .then(response => response.json()) + .then(data => { + requestTimeoutId = setTimeout(() => { + if (data.status === 'error') { + clearTimeout(actionTimeoutId) + showErrorStatus() + } else if (data.startTimestamp === startTimestamp) { + // poll status again if prototype hasn't restarted + pollStatus() + } else { + showCompleteStatus() + } + }, 1000) + }) + .catch(() => { + requestTimeoutId = setTimeout(pollStatus, 1000) + }) + } + } + + const performAction = (event) => { + log('Starting') + + if (event) { + event.preventDefault() + hide('plugin-action-confirmation') + } + + show('panel-processing') + + postRequest(`/manage-prototype/plugins/${mode}`) + .then(response => response.json()) + .then(data => { + switch (data.status) { + case 'completed': { + // Command has already been run + showCompleteStatus() + break + } + case 'processing': { + startTimestamp = data.startTimestamp + pollStatus() + break + } + default: { + // Default to error + showErrorStatus() + } + } + }) + .catch(() => { + showErrorStatus() + }) + } + + hide('panel-manual-instructions') + const actionButton = document.getElementById('plugin-action-button') + + if (actionButton) { + actionButton.addEventListener('click', performAction) + } else { + performAction() + } +}) diff --git a/lib/assets/sass/internal/manage-prototype.scss b/lib/assets/sass/internal/manage-prototype.scss index 64a30e2fa8..a2d69f27c2 100644 --- a/lib/assets/sass/internal/manage-prototype.scss +++ b/lib/assets/sass/internal/manage-prototype.scss @@ -317,12 +317,12 @@ .govuk-prototype-kit-manage-prototype-template-list-template-list { - border-top: 1px solid govuk-colour('mid-grey'); + border-top: 1px solid #b1b4b6; margin-bottom: 2em; } .govuk-prototype-kit-manage-prototype-template-list-template-list__item { - border-bottom: 1px solid govuk-colour('mid-grey'); + border-bottom: 1px solid #b1b4b6; padding: .6em 0 } @@ -339,12 +339,12 @@ margin-right: 1em; } .govuk-prototype-kit-manage-prototype-template-list-template-list { - border-top: 1px solid govuk-colour('mid-grey'); + border-top: 1px solid #b1b4b6; margin-bottom: 2em; } .govuk-prototype-kit-manage-prototype-template-list-template-list__item { - border-bottom: 1px solid govuk-colour('mid-grey'); + border-bottom: 1px solid #b1b4b6; padding: .6em 0 } @@ -371,12 +371,12 @@ body .govuk-prototype-kit-manage-prototype-govuk-tag { .govuk-prototype-kit-manage-prototype-plugin-list-plugin-list { - border-top: 1px solid govuk-colour('mid-grey'); + border-top: 1px solid #b1b4b6; margin-bottom: 2em; } .govuk-prototype-kit-manage-prototype-plugin-list-plugin-list__item { - border-bottom: 1px solid govuk-colour('mid-grey'); + border-bottom: 1px solid #b1b4b6; padding: .6em 0 } @@ -389,33 +389,80 @@ body .govuk-prototype-kit-manage-prototype-govuk-tag { margin-right: 1em; } .govuk-prototype-kit-manage-prototype-plugin-list-plugin-list { - border-top: 1px solid govuk-colour('mid-grey'); + border-top: 1px solid #b1b4b6; margin-bottom: 2em; } .govuk-prototype-kit-manage-prototype-plugin-list-plugin-list__item { - border-bottom: 1px solid govuk-colour('mid-grey'); + border-bottom: 1px solid #b1b4b6; padding: .6em 0 } .govuk-prototype-kit-manage-prototype-plugin-list-plugin-list__item-name { display: inline-block; - width: 45% + width: 35%; + vertical-align: middle; } -.govuk-prototype-kit-manage-prototype-plugin-list-plugin-list__item-links, +.govuk-prototype-kit-manage-prototype-plugin-list-plugin-list__item-buttons, .govuk-prototype-kit-manage-prototype-plugin-list-plugin-list__item-version{ display: inline-block; + vertical-align: middle; } .govuk-prototype-kit-manage-prototype-plugin-list-plugin-list__item-version { - width: 23% + width: 15% } -.govuk-prototype-kit-manage-prototype-plugin-list-plugin-list__item-links { - width: 30% +@media(min-width: 40.0525em) { + .govuk-prototype-kit-manage-prototype-plugin-list-plugin-list__item-buttons { + .govuk-button { + margin: 0 + } + } } -.govuk-prototype-kit-manage-prototype-plugin-list-plugin-list__item-link { - margin-right: 1em; +@media(max-width: 40.0525em) { + .govuk-prototype-kit-manage-prototype-plugin-list-plugin-list__item-name, + .govuk-prototype-kit-manage-prototype-plugin-list-plugin-list__item-buttons, + .govuk-prototype-kit-manage-prototype-plugin-list-plugin-list__item-version { + display: block; + width: auto; + } } + +.govuk-prototype-kit-manage-prototype-plugin-instructions { + &.js-hidden { + display: initial; + } + &.js-visible { + display: none; + } +} + +.js-enabled { + .govuk-prototype-kit-manage-prototype-plugin-instructions { + &.js-hidden { + display: none; + } + &.js-visible { + display: initial; + } + } +} + +.govuk-prototype-kit-manage-prototype-plugin-processing{ + .panel-processing{ + background: #fff; + color: #0b0c0c; + border: 2px solid #0b0c0c; + } + .panel-error{ + background: #fff; + color: #d4351c; + border: 2px solid #d4351c; + a,a:visited{ + color: #d4351c; + } + } +} \ No newline at end of file diff --git a/lib/nunjucks/govuk-prototype-kit/internal/views/manage-prototype/plugin-install-or-uninstall.njk b/lib/nunjucks/govuk-prototype-kit/internal/views/manage-prototype/plugin-install-or-uninstall.njk index dd7405b5e5..2969756dcf 100644 --- a/lib/nunjucks/govuk-prototype-kit/internal/views/manage-prototype/plugin-install-or-uninstall.njk +++ b/lib/nunjucks/govuk-prototype-kit/internal/views/manage-prototype/plugin-install-or-uninstall.njk @@ -1,29 +1,88 @@ {% extends "govuk-prototype-kit/internal/views/manage-prototype/layout.njk" %} -{% from "govuk/components/button/macro.njk" import govukButton %} -{% block beforeContent %} - {{ super() }} - Back +{% block meta %} + {% endblock %} {% block content %} -
- In terminal, press ctrl + c to stop your prototype, then run: -
-
- {{ command }}
-
- When you've {{ verb.status }} the plugin, restart your prototype in the terminal by typing: -
-
- npm run dev
-
+ In terminal, press ctrl + c to stop your prototype, then run: +
+
+ {{ command }}
+
+ When you've {{ verb.status }} the plugin, restart your prototype in the terminal by typing: +
+
+ npm run dev
+
+ Are you sure you want to {{ verb.para }} this plugin? +
+ {{ govukButton({ + text: verb.title, + attributes: { id: "plugin-action-button" } + }) }} ++ If you are using Git you can commit this change. +
+ +You'll be redirected back to the Plugins page soon.
-