diff --git a/__tests__/spec/build.js b/__tests__/spec/build.js index da74055ae2..b65abee332 100644 --- a/__tests__/spec/build.js +++ b/__tests__/spec/build.js @@ -85,6 +85,11 @@ describe('the build pipeline', () => { }) it('copies javascript to the public folder', () => { + expect(fs.copyFileSync).toHaveBeenCalledWith( + path.join(projectDir, 'lib', 'assets', 'javascripts', 'kit.js'), + path.join('public', '_kit', 'javascripts', 'kit.js') + ) + expect(fs.copyFileSync).toHaveBeenCalledWith( path.join('app', 'assets', 'javascripts', 'application.js'), path.join('public', 'javascripts', 'application.js') diff --git a/__tests__/spec/update-script.js b/__tests__/spec/update-script.js index c173a52940..9a881d28e7 100644 --- a/__tests__/spec/update-script.js +++ b/__tests__/spec/update-script.js @@ -14,7 +14,7 @@ const execPromise = promisify(child_process.exec) const execFilePromise = promisify(child_process.execFile) // This is a long-running test -jest.setTimeout(60000) +jest.setTimeout(120000) function testSkipFailingIf (condition, ...args) { if (condition) { @@ -511,6 +511,37 @@ describe('update.sh', () => { }) }) + describe('post', () => { + let testDir, gitStatus + + beforeAll(async () => { + testDir = await mktestPrototype( + 'update-post', { ref: 'v12.1.1' } + ) + + await runScriptAndExpectSuccess('post', { testDir }) + + gitStatus = await execGitStatus(testDir) + }) + + it('updates app stylesheets', () => { + expect(gitStatus).toEqual(expect.arrayContaining([ + ' M app/assets/sass/application.scss', + ' D app/assets/sass/application-ie8.scss', + ' D app/assets/sass/unbranded-ie8.scss' + ])) + }) + + it('updates app javascripts', () => { + expect(gitStatus).toEqual(expect.arrayContaining([ + ' M app/assets/javascripts/application.js', + ' D app/assets/javascripts/auto-store-data.js', + ' D app/assets/javascripts/jquery-1.11.3.js', + ' D app/assets/javascripts/step-by-step-nav.js' + ])) + }) + }) + it('can be run as a piped script', async () => { const testDir = await mktestPrototype('pipe') diff --git a/app/assets/javascripts/application.js b/app/assets/javascripts/application.js index 5600fb7b84..277b4f5578 100644 --- a/app/assets/javascripts/application.js +++ b/app/assets/javascripts/application.js @@ -1,20 +1,3 @@ -window.GOVUKPrototypeKit = { - documentReady: function (fn) { - if (document.readyState !== 'loading') { - // IE9 support - fn() - } else { - // Everything else - document.addEventListener('DOMContentLoaded', fn) - } - } -} - -// Warn about using the kit in production -if (window.console && window.console.info) { - window.console.info('GOV.UK Prototype Kit - do not use for production') -} - -window.GOVUKPrototypeKit.documentReady(function () { - window.GOVUKFrontend.initAll() +window.GOVUKPrototypeKit.documentReady(() => { + // Add JavaScript here }) diff --git a/app/views/includes/scripts.html b/app/views/includes/scripts.html index 40960cd667..b078c8efb8 100644 --- a/app/views/includes/scripts.html +++ b/app/views/includes/scripts.html @@ -3,8 +3,9 @@ {% endfor %} + {% if useAutoStoreData %} - + {% endif %} diff --git a/cypress/integration/1-watch-files/watch-javascripts.cypress.js b/cypress/integration/1-watch-files/watch-javascripts.cypress.js index b9fa1293da..30b0406da1 100644 --- a/cypress/integration/1-watch-files/watch-javascripts.cypress.js +++ b/cypress/integration/1-watch-files/watch-javascripts.cypress.js @@ -22,7 +22,7 @@ describe('watch application.js', () => { const onAlert = cy.stub() cy.on('window:alert', onAlert) - const markerText = 'window.GOVUKFrontend.initAll()' + const markerText = '// Add JavaScript here' const newText = markerText + '\n ' + "window.alert('Test')" cy.task('replaceTextInFile', { diff --git a/lib/_update/post.js b/lib/_update/post.js new file mode 100644 index 0000000000..521e06803f --- /dev/null +++ b/lib/_update/post.js @@ -0,0 +1,5 @@ +const { updateJavascripts } = require('./update-javascripts') +const { updateScss } = require('./update-scss') + +updateJavascripts() +updateScss() diff --git a/lib/_update/update-javascripts.js b/lib/_update/update-javascripts.js new file mode 100644 index 0000000000..dc222304c9 --- /dev/null +++ b/lib/_update/update-javascripts.js @@ -0,0 +1,29 @@ +const fs = require('fs').promises +const path = require('path') + +const { getProjectVersion, patchUserFile } = require('./util') +const { projectDir } = require('../path-utils') + +async function updateJavascripts () { + // Delete any old shared files + const appJsPath = path.join(projectDir, 'app', 'assets', 'javascripts') + await fs.unlink(path.join(appJsPath, 'auto-store-data.js')).catch(() => {}) + await fs.unlink(path.join(appJsPath, 'jquery-1.11.3.js')).catch(() => {}) + await fs.unlink(path.join(appJsPath, 'step-by-step-nav.js')).catch(() => {}) + await fs.unlink(path.join(appJsPath, 'step-by-step-navigation.js')).catch(() => {}) + + const userVersion = await getProjectVersion() + + // If the user already has version 13 or greater of the kit installed then + // their application.js file is all their code and we don't don't want to + // change it + if (userVersion >= '13.0.0') { + return + } + + await patchUserFile(userVersion, 'app/assets/javascripts/application.js') +} + +module.exports = { + updateJavascripts +} diff --git a/lib/_update/update-javascripts.test.js b/lib/_update/update-javascripts.test.js new file mode 100644 index 0000000000..3658d17699 --- /dev/null +++ b/lib/_update/update-javascripts.test.js @@ -0,0 +1,124 @@ +/* eslint-env jest */ + +const fs = require('fs') +const fsp = require('fs').promises +const path = require('path') + +jest.mock('./util/fetch-original') +jest.mock('./util', () => { + const originalModule = jest.requireActual('./util') + + return { + ...originalModule, + getProjectVersion: jest.fn(async () => '') + } +}) +const { fetchOriginal: mockFetchOriginal } = require('./util/fetch-original') +const { getProjectVersion: mockGetProjectVersion } = require('./util') +const { projectDir } = require('../path-utils') + +const { updateJavascripts } = require('./update-javascripts') + +const originalApplicationJs = `/* global $ */ + +// Warn about using the kit in production +if (window.console && window.console.info) { + window.console.info('GOV.UK Prototype Kit - do not use for production') +} + +$(document).ready(function () { + window.GOVUKFrontend.initAll() +}) +` +const newApplicationJs = fs.readFileSync( + path.join('app', 'assets', 'javascripts', 'application.js'), + 'utf8' +) + +describe('updateJavascripts', () => { + let mockCopyFile, mockReadFile, mockUnlink, mockWriteFile + + beforeEach(async () => { + mockGetProjectVersion.mockResolvedValue( + '12.1.1' + ) + + mockFetchOriginal.mockResolvedValue( + originalApplicationJs + ) + + mockReadFile = jest.spyOn(fsp, 'readFile').mockResolvedValue( + newApplicationJs + ) + + mockCopyFile = jest.spyOn(fsp, 'copyFile').mockImplementation(async () => {}) + mockUnlink = jest.spyOn(fsp, 'unlink').mockImplementation(async () => {}) + mockWriteFile = jest.spyOn(fsp, 'writeFile').mockImplementation(async () => {}) + }) + + afterEach(() => { + jest.restoreAllMocks() + }) + + it('replaces application.js if the user has not updated it', async () => { + // theirs + mockReadFile.mockResolvedValueOnce( + originalApplicationJs + ) + + await updateJavascripts() + + expect(mockCopyFile).toHaveBeenCalledWith( + path.join(projectDir, 'update', 'app', 'assets', 'javascripts', 'application.js'), + path.join(projectDir, 'app', 'assets', 'javascripts', 'application.js') + ) + }) + + it('rewrites application.js if the user has added lines to the bottom of the file', async () => { + // theirs + mockReadFile.mockResolvedValueOnce( + originalApplicationJs + '\ncallMyCode()\n' + ) + // ours + mockReadFile.mockResolvedValue( + newApplicationJs + ) + + await updateJavascripts() + + expect(mockWriteFile).toHaveBeenCalledWith( + path.join(projectDir, 'app', 'assets', 'javascripts', 'application.js'), + newApplicationJs + '\ncallMyCode()\n', + 'utf8' + ) + }) + + it('does not touch application.js if the user prototype is already on v13', async () => { + mockGetProjectVersion.mockResolvedValue( + '13.0.0' + ) + + await updateJavascripts() + + expect(mockReadFile).not.toHaveBeenCalled() + expect(mockWriteFile).not.toHaveBeenCalled() + expect(mockCopyFile).not.toHaveBeenCalled() + }) + + it('removes files that have been moved from app folder', async () => { + await updateJavascripts() + + expect(mockUnlink).toHaveBeenCalledWith( + path.join(projectDir, 'app', 'assets', 'javascripts', 'auto-store-data.js') + ) + expect(mockUnlink).toHaveBeenCalledWith( + path.join(projectDir, 'app', 'assets', 'javascripts', 'jquery-1.11.3.js') + ) + expect(mockUnlink).toHaveBeenCalledWith( + path.join(projectDir, 'app', 'assets', 'javascripts', 'step-by-step-nav.js') + ) + expect(mockUnlink).toHaveBeenCalledWith( + path.join(projectDir, 'app', 'assets', 'javascripts', 'step-by-step-navigation.js') + ) + }) +}) diff --git a/lib/_update/update-scss/index.js b/lib/_update/update-scss/index.js new file mode 100644 index 0000000000..59e7579d66 --- /dev/null +++ b/lib/_update/update-scss/index.js @@ -0,0 +1,16 @@ +const { + appSassPatternsPath, + libSassPatternsPath, + appSassPath, + libSassPath, + removeKitSassFromApplicationSass, + removeKitSassFromAppSassPath, + removeLegacyIE8Sass +} = require('./update_scss') + +module.exports.updateScss = function () { + removeKitSassFromApplicationSass() + removeKitSassFromAppSassPath(appSassPatternsPath, libSassPatternsPath) + removeKitSassFromAppSassPath(appSassPath, libSassPath) + removeLegacyIE8Sass() +} diff --git a/lib/_update_scss/update_scss.js b/lib/_update/update-scss/update_scss.js similarity index 97% rename from lib/_update_scss/update_scss.js rename to lib/_update/update-scss/update_scss.js index 16d1c3fcbe..e179518b3a 100644 --- a/lib/_update_scss/update_scss.js +++ b/lib/_update/update-scss/update_scss.js @@ -1,6 +1,6 @@ const path = require('path') const fs = require('fs') -const { projectDir, packageDir } = require('../path-utils') +const { projectDir, packageDir } = require('../../path-utils') const appSassPath = path.join(projectDir, 'app', 'assets', 'sass') const appSassPatternsPath = path.join(appSassPath, 'patterns') const applicationScssPath = path.join(appSassPath, 'application.scss') diff --git a/lib/_update_scss/update_scss.test.js b/lib/_update/update-scss/update_scss.test.js similarity index 97% rename from lib/_update_scss/update_scss.test.js rename to lib/_update/update-scss/update_scss.test.js index 12f67f4c62..eec65daa42 100644 --- a/lib/_update_scss/update_scss.test.js +++ b/lib/_update/update-scss/update_scss.test.js @@ -4,7 +4,7 @@ const fs = require('fs') const path = require('path') const updateKit = require('./update_scss') const { appSassPath } = require('./update_scss') -const { projectDir } = require('../path-utils') +const { projectDir } = require('../../path-utils') describe('scripts/update-kit', () => { afterEach(() => { diff --git a/lib/_update/util/fetch-original.js b/lib/_update/util/fetch-original.js new file mode 100644 index 0000000000..81a5ea1115 --- /dev/null +++ b/lib/_update/util/fetch-original.js @@ -0,0 +1,33 @@ +const https = require('https') + +async function fetchOriginal (version, filePath) { + const remoteUrl = `https://raw.githubusercontent.com/alphagov/govuk-prototype-kit/v${version}/${filePath}` + + let data = '' + return new Promise((resolve, reject) => { + https.get(remoteUrl, (response) => { + let error + + if (response.statusCode !== 200) { + error = new Error(`could not fetch ${remoteUrl}: status code ${response.statusCode}`) + Object.assign(error, response) + response.resume() + reject(error) + } + + response.setEncoding('utf8') + + response.on('data', (chunk) => { + data += chunk + }) + + response.on('end', () => { + resolve(data) + }) + }) + }) +} + +module.exports = { + fetchOriginal +} diff --git a/lib/_update/util/fetch-original.test.js b/lib/_update/util/fetch-original.test.js new file mode 100644 index 0000000000..1f4fe76011 --- /dev/null +++ b/lib/_update/util/fetch-original.test.js @@ -0,0 +1,29 @@ +/* eslint-env jest */ + +const https = require('https') +const stream = require('stream') + +const { fetchOriginal } = require('./fetch-original') + +describe('fetchOriginal', () => { + it('gets the contents of a file as of version from GitHub', async () => { + const mockHttpsGet = jest.spyOn(https, 'get').mockImplementation((url, callback) => { + const response = new stream.PassThrough() + response.statusCode = 200 + + callback(response) + + response.write('foo\n') + response.write('bar\n') + response.end() + }) + + await expect(fetchOriginal('99.99.99', 'app/views/foo.html')).resolves.toEqual( + 'foo\nbar\n' + ) + expect(mockHttpsGet).toHaveBeenCalledWith( + 'https://raw.githubusercontent.com/alphagov/govuk-prototype-kit/v99.99.99/app/views/foo.html', + expect.anything() + ) + }) +}) diff --git a/lib/_update/util/index.js b/lib/_update/util/index.js new file mode 100644 index 0000000000..b6493bb784 --- /dev/null +++ b/lib/_update/util/index.js @@ -0,0 +1,61 @@ +const fs = require('fs').promises +const path = require('path') + +const { projectDir } = require('../../path-utils') +const { fetchOriginal } = require('./fetch-original') + +const updateDir = path.join(projectDir, 'update') + +async function getProjectVersion () { + return (await fs.readFile(path.join(projectDir, 'VERSION.txt'), 'utf8')).trim() +} + +async function patchUserFile (originalVersion, filePath) { + const theirs = await fs.readFile(path.resolve(projectDir, filePath), 'utf8') + let original = await fetchOriginal(originalVersion, filePath) + let ours = await fs.readFile(path.resolve(updateDir, filePath), 'utf8') + + // Normalise line endings to match their file + var eol = '\n' + if (theirs.includes('\r\n')) { + eol = '\r\n' + if (!original.includes(eol)) { + original = original.replace(/\n/g, eol) + } + if (!ours.includes(eol)) { + ours = ours.replace(/\n/g, eol) + } + } + + // It is possible that the file has already been upgraded, in which case there is nothing to do + if (theirs === ours) { + return + } + + // If the user hasn't changed the file we can just replace it completely + if (original === theirs) { + return fs.copyFile(path.join(updateDir, filePath), path.join(projectDir, filePath)) + } + + // Otherwise, if the original code is contained as-is in their file, we can + // remove the shared lines, and add our hints + if (theirs.includes(original)) { + let merged + merged = theirs.replace(original, '') + merged = ours + merged + return fs.writeFile(path.resolve(projectDir, filePath), merged, 'utf8') + } + + // If the original code is not recognisable, we should give up, but not + // without giving a warning to the user + console.warn( + `WARNING: update.sh was not able to automatically update your ${filePath} file.\n` + + 'If you have a problem when running your prototype contact the GOV.UK Prototype team for support,\n' + + 'using one of the methods listed at https://design-system.service.gov.uk/get-in-touch/' + ) +} + +module.exports = { + getProjectVersion, + patchUserFile +} diff --git a/lib/_update/util/index.test.js b/lib/_update/util/index.test.js new file mode 100644 index 0000000000..a51924642a --- /dev/null +++ b/lib/_update/util/index.test.js @@ -0,0 +1,144 @@ +/* eslint-env jest */ + +const fs = require('fs').promises +const path = require('path') + +jest.mock('./fetch-original') +const { fetchOriginal: mockFetchOriginal } = require('./fetch-original') +const { projectDir } = require('../../path-utils') + +const { getProjectVersion, patchUserFile } = require('./index.js') + +afterEach(() => { + jest.restoreAllMocks() +}) + +describe('getProjectVersion', () => { + it('reads the VERSION.txt file to get the version number', async () => { + const mockReadFile = jest.spyOn(fs, 'readFile').mockImplementation( + async () => '99.99.99\n' + ) + + await expect(getProjectVersion()).resolves.toEqual('99.99.99') + expect(mockReadFile).toHaveBeenCalledWith( + expect.stringContaining(`${path.sep}VERSION.txt`), + 'utf8' + ) + }) +}) + +describe('patchUserFile', () => { + const filePath = 'app/assets/javascripts/application.js' + + const originalContents = `/* global $ */ +$(document).ready(function () { + window.GOVUKFrontend.initAll() +}) +` + + const newContents = `window.GOVUKPrototypeKit.ready(() => { + // Add JavaScript here +}) +` + + let mockCopyFile, mockReadFile, mockWriteFile + + beforeEach(() => { + mockFetchOriginal.mockResolvedValue( + originalContents + ) + + mockReadFile = jest.spyOn(fs, 'readFile').mockResolvedValue( + newContents + ) + + mockCopyFile = jest.spyOn(fs, 'copyFile').mockImplementation(() => {}) + mockWriteFile = jest.spyOn(fs, 'writeFile').mockImplementation(() => {}) + }) + + it('gets the original contents to compare with the user file', async () => { + mockReadFile.mockResolvedValue( + originalContents + ) + + mockFetchOriginal.mockResolvedValue( + originalContents + ) + + await patchUserFile('99.99.99', filePath) + + expect(mockFetchOriginal).toHaveBeenCalledWith( + '99.99.99', filePath + ) + }) + + it('replaces file if the user has not updated it', async () => { + mockReadFile.mockResolvedValueOnce( + originalContents + ) + + await patchUserFile('99.99.99', filePath) + + expect(mockCopyFile).toHaveBeenCalledWith( + path.join(projectDir, 'update', 'app', 'assets', 'javascripts', 'application.js'), + path.join(projectDir, 'app', 'assets', 'javascripts', 'application.js') + ) + }) + + it('rewrites file if the user has added lines to the bottom of the file', async () => { + // theirs + jest.spyOn(fs, 'readFile').mockResolvedValueOnce( + originalContents + '\ncallMyCode()\n' + ) + // ours + jest.spyOn(fs, 'readFile').mockResolvedValueOnce( + newContents + ) + + await patchUserFile('99.99.99', filePath) + + expect(mockWriteFile).toHaveBeenCalledWith( + expect.stringContaining(path.join('app', 'assets', 'javascripts', 'application.js')), + newContents + '\ncallMyCode()\n', + 'utf8' + ) + }) + + it('warns user and does not touch file if the user has rewritten it a lot', async () => { + // theirs + jest.spyOn(fs, 'readFile').mockImplementationOnce( + async () => 'justMyCode()\n' + ) + // ours + jest.spyOn(fs, 'readFile').mockResolvedValueOnce( + newContents + ) + + const mockConsoleWarn = jest.spyOn(global.console, 'warn').mockImplementation(() => {}) + + await patchUserFile('99.99.99', filePath) + + expect(mockWriteFile).not.toHaveBeenCalled() + expect(mockCopyFile).not.toHaveBeenCalled() + expect(mockConsoleWarn).toHaveBeenCalled() + }) + + it('does nothing if file has already been updated somehow', async () => { + // theirs + jest.spyOn(fs, 'readFile').mockResolvedValueOnce( + newContents + ) + // ours + jest.spyOn(fs, 'readFile').mockResolvedValueOnce( + newContents + ) + + const mockConsoleWarn = jest.spyOn(global.console, 'warn') + + await patchUserFile('99.99.99', filePath) + + expect(mockWriteFile).not.toHaveBeenCalled() + expect(mockCopyFile).not.toHaveBeenCalled() + expect(mockConsoleWarn).not.toHaveBeenCalled() + }) +}) diff --git a/lib/_update_scss/index.js b/lib/_update_scss/index.js deleted file mode 100644 index 460f625727..0000000000 --- a/lib/_update_scss/index.js +++ /dev/null @@ -1,14 +0,0 @@ -const { - appSassPatternsPath, - libSassPatternsPath, - appSassPath, - libSassPath, - removeKitSassFromApplicationSass, - removeKitSassFromAppSassPath, - removeLegacyIE8Sass -} = require('./update_scss') - -removeKitSassFromApplicationSass() -removeKitSassFromAppSassPath(appSassPatternsPath, libSassPatternsPath) -removeKitSassFromAppSassPath(appSassPath, libSassPath) -removeLegacyIE8Sass() diff --git a/app/assets/javascripts/auto-store-data.js b/lib/assets/javascripts/auto-store-data.js similarity index 100% rename from app/assets/javascripts/auto-store-data.js rename to lib/assets/javascripts/auto-store-data.js diff --git a/lib/assets/javascripts/kit.js b/lib/assets/javascripts/kit.js new file mode 100644 index 0000000000..5600fb7b84 --- /dev/null +++ b/lib/assets/javascripts/kit.js @@ -0,0 +1,20 @@ +window.GOVUKPrototypeKit = { + documentReady: function (fn) { + if (document.readyState !== 'loading') { + // IE9 support + fn() + } else { + // Everything else + document.addEventListener('DOMContentLoaded', fn) + } + } +} + +// Warn about using the kit in production +if (window.console && window.console.info) { + window.console.info('GOV.UK Prototype Kit - do not use for production') +} + +window.GOVUKPrototypeKit.documentReady(function () { + window.GOVUKFrontend.initAll() +}) diff --git a/lib/build/tasks.js b/lib/build/tasks.js index 410aaff022..b8d60f99ae 100644 --- a/lib/build/tasks.js +++ b/lib/build/tasks.js @@ -16,7 +16,8 @@ const { projectDir, packageDir } = require('../path-utils') const { paths } = buildConfig const appSassPath = path.join(projectDir, paths.assets, 'sass') -const libSassPath = path.join(packageDir, paths.libAssets, 'sass') +const libAssetsPath = path.join(packageDir, paths.libAssets) +const libSassPath = path.join(libAssetsPath, 'sass') const tempPath = path.join(projectDir, '.tmp') const tempSassPath = path.join(tempPath, 'sass') @@ -46,6 +47,7 @@ function generateAssetsSync ({ verbose } = {}) { if (verbose) process.stdout.write('done\n') if (verbose) process.stdout.write('copying assets...') + copyAssets(libAssetsPath, path.join(paths.public, '_kit')) copyAssets(paths.assets, paths.public) if (verbose) process.stdout.write('done\n') } diff --git a/update.sh b/update.sh index 2af30c4725..ee94decbfd 100755 --- a/update.sh +++ b/update.sh @@ -170,6 +170,16 @@ copy () { trap - ERR + update_gitignore +} + +post () { + if [ -f 'update/lib/_update/post.js' ]; then + node 'update/lib/_update/post' + fi +} + +finish () { msg msg "Your prototype kit files have now been updated, from version ${OLD_VERSION} to ${NEW_VERSION}." msg 'If you need to make configuration changes, follow the steps at' @@ -180,13 +190,6 @@ copy () { update_gitignore } -post () { - # execute _update_scss if it exists in the update folder - if [ -d "update/lib/_update_scss" ]; then - node "update/lib/_update_scss" - fi -} - if [ "$0" == "${BASH_SOURCE:-$0}" ] then check @@ -195,4 +198,5 @@ then extract copy post + finish fi