From 2214ebe54c602236b46d8ec13e1d069a33b51069 Mon Sep 17 00:00:00 2001 From: Gary Dawson Date: Mon, 20 Apr 2026 09:33:13 +0100 Subject: [PATCH 1/7] Removed Confirmation page test from Check your details spec file --- e2e/tests/pages/check-your-details-page.spec.js | 5 ----- 1 file changed, 5 deletions(-) diff --git a/e2e/tests/pages/check-your-details-page.spec.js b/e2e/tests/pages/check-your-details-page.spec.js index e1c99be2..ebad851a 100644 --- a/e2e/tests/pages/check-your-details-page.spec.js +++ b/e2e/tests/pages/check-your-details-page.spec.js @@ -26,9 +26,4 @@ test.describe('Check your details page', () => { test('confirms the change location link is present', async ({ steps }) => { await steps.expectLinkExists(pages.checkYourDetails.changeLocationLink) }) - - test('navigates to confirmation page after clicking order button', async ({ steps }) => { - await steps.clickButton(pages.checkYourDetails.orderButton) - await steps.expectOn(pages.confirmation.page) - }) }) From d5456d221998524732742d257910449e1e5ebaf1 Mon Sep 17 00:00:00 2001 From: RyonLeightonDEFRA Date: Mon, 20 Apr 2026 10:01:11 +0100 Subject: [PATCH 2/7] added projects and tags to separate e2e test --- e2e/package.json | 5 ++-- e2e/playwright.config.js | 28 ++++++++++++++++++----- e2e/tests/e2e.spec.js | 2 +- e2e/tests/pages/confirmation-page.spec.js | 1 - server/views/results.html | 5 +--- 5 files changed, 27 insertions(+), 14 deletions(-) diff --git a/e2e/package.json b/e2e/package.json index 9d7e2bfa..41f10239 100644 --- a/e2e/package.json +++ b/e2e/package.json @@ -6,12 +6,13 @@ "scripts": { "test": "npx playwright test --project=public-chromium --project=internal-chromium", "test:ui": "npx playwright test --ui --project=public-chromium --project=internal-chromium", - "test:urlCheck": "npx playwright test --project=urlCheck-chrome", - "test:local": "TEST_ENV=local npx playwright test --project=noDeps-local-chrome", + "test:urlCheck": "npx playwright test --project=urlCheck-chromium", + "test:local": "TEST_ENV=local npx playwright test --project=noDeps-local-chromium", "test:tst": "TEST_ENV=tst npm run test", "test:dev": "TEST_ENV=dev npm run test", "test:edge": "npx playwright test --project=public-edge --project=internal-edge", "test:firefox": "npx playwright test --project=public-firefox --project=internal-firefox", + "test:e2e": "npx playwright test --project=e2e-public-chromium --project=e2e-internal-chromium", "test:all-browsers": "npx playwright test", "report:open": "npx playwright show-report", "prepare": "husky" diff --git a/e2e/playwright.config.js b/e2e/playwright.config.js index a7993868..bde1e99d 100644 --- a/e2e/playwright.config.js +++ b/e2e/playwright.config.js @@ -62,7 +62,7 @@ export default defineConfig({ ...browserProjects.flatMap((browserProject) => [ { name: `public-${browserProject.suffix}`, - grepInvert: /@internal|@urlCheck/, + grepInvert: /@internal|@urlCheck|@e2e/, use: { ...browserProject.use, baseURL: publicBaseURL, @@ -70,8 +70,8 @@ export default defineConfig({ }, { name: `internal-${browserProject.suffix}`, - grep: /@internal|@both/, - grepInvert: /@urlCheck/, + grep: /@internal/, + grepInvert: /@urlCheck|@e2e/, use: { ...browserProject.use, baseURL: internalBaseURL, @@ -79,21 +79,37 @@ export default defineConfig({ }, ]), { - name: 'noDeps-local-chrome', + name: 'noDeps-local-chromium', grep: /@noDeps/, - grepInvert: /@urlCheck|@internal|@both/, + grepInvert: /@urlCheck|@internal|@e2e/, use: { ...chromeConfig, baseURL: publicBaseURL, }, }, { - name: 'urlCheck-chrome', + name: 'urlCheck-chromium', grep: /@urlCheck/, use: { ...chromeConfig, baseURL: publicBaseURL, }, }, + { + name: 'e2e-public-chromium', + grep: /@e2e/, + use: { + ...chromeConfig, + baseURL: publicBaseURL, + }, + }, + { + name: 'e2e-internal-chromium', + grep: /@e2e/, + use: { + ...chromeConfig, + baseURL: internalBaseURL, + }, + }, ], }) diff --git a/e2e/tests/e2e.spec.js b/e2e/tests/e2e.spec.js index d991ff79..bc1420cf 100644 --- a/e2e/tests/e2e.spec.js +++ b/e2e/tests/e2e.spec.js @@ -4,7 +4,7 @@ import { locationData } from '../data/location-data.js' import { userData } from '../data/user-data.js' test.describe('End-to-end planning journey', () => { - test('completes the journey from home to confirmation', { tag: '@both' }, async ({ steps, mapSteps }) => { + test('completes the journey from home to confirmation', { tag: '@e2e' }, async ({ steps, mapSteps }) => { await test.step('Home → Triage', async () => { await steps.open(pages.home.page) await steps.clickButton(pages.home.startButton) diff --git a/e2e/tests/pages/confirmation-page.spec.js b/e2e/tests/pages/confirmation-page.spec.js index c1baeb41..cc8e7668 100644 --- a/e2e/tests/pages/confirmation-page.spec.js +++ b/e2e/tests/pages/confirmation-page.spec.js @@ -26,7 +26,6 @@ test.describe('Confirmation page', () => { }) // The following tests validate that external links can be reached. - test('navigates to to get more information to help you complete a flood risk assessmment page when clicking the link', { tag: '@urlCheck' }, async ({ steps }) => { await steps.clickLink(pages.confirmation.toGetMoreInformationLink) await steps.expectUrlContains('get-information-about-flood-risk') diff --git a/server/views/results.html b/server/views/results.html index 91910307..e0f48151 100644 --- a/server/views/results.html +++ b/server/views/results.html @@ -55,10 +55,7 @@

From 70deae9342e6efa1cea02d62d43906b76b8362b0 Mon Sep 17 00:00:00 2001 From: Gary Dawson Date: Mon, 20 Apr 2026 12:45:54 +0100 Subject: [PATCH 3/7] Updated framework to handle P1 PDF validation and updates Results page tests --- .gitignore | 3 +- e2e/README.md | 36 ++++- e2e/fixtures.js | 4 + e2e/package-lock.json | 232 ++++++++++++++++++++++++++- e2e/package.json | 3 +- e2e/test-runner-api/pdf-driver.js | 138 ++++++++++++++++ e2e/tests/pages/results-page.spec.js | 78 +++++++++ 7 files changed, 485 insertions(+), 9 deletions(-) create mode 100644 e2e/test-runner-api/pdf-driver.js diff --git a/.gitignore b/.gitignore index 526cd77f..6c2af0f1 100644 --- a/.gitignore +++ b/.gitignore @@ -20,4 +20,5 @@ dist/ coverage/ test/results.json playwright-report/ -test-results/ \ No newline at end of file +test-results/ +e2e/_results_/downloads/*.pdf \ No newline at end of file diff --git a/e2e/README.md b/e2e/README.md index 2d6d3a28..9479cce0 100644 --- a/e2e/README.md +++ b/e2e/README.md @@ -10,25 +10,26 @@ The suite uses a three-layer architecture. Tests read like plain English, driver ``` tests/*.spec.js ← Specifications (what to test) - ↓ fixtures (steps / mapSteps) -test-runner-api/ ← FormDriver & MapDriver (how to interact) + ↓ fixtures (steps / mapSteps / pdfDriver) +test-runner-api/ ← FormDriver, MapDriver & PdfDriver (how to interact) ↓ pages/ ← Page definitions & control factories ``` ### Fixtures (`fixtures.js`) -[Playwright fixtures](https://playwright.dev/docs/test-fixtures) provide two drivers to every test: +[Playwright fixtures](https://playwright.dev/docs/test-fixtures) make three drivers available to tests (most tests use `steps` and/or `mapSteps`; PDF tests use `pdfDriver`): | Fixture | Class | Purpose | |------------|-------------|---------| | `steps` | `FormDriver` | Standard GOV.UK form page interactions | | `mapSteps` | `MapDriver` | Map-specific interactions (extends `FormDriver`) | +| `pdfDriver` | `PdfDriver` | PDF download, parsing, and PDF content assertions | ```js import { test } from '../../fixtures.js' -test('example', async ({ steps, mapSteps }) => { +test('example', async ({ steps, mapSteps, pdfDriver }) => { await steps.open(pages.home.page) // ... }) @@ -72,6 +73,19 @@ All Playwright locator logic lives here. Tests never call `page.getByRole(...)` | `addSquare()` | Open the location menu and click "Add square" | | `confirmBoundaryAndContinue()` | Click "Finish" then "Get summary report" | +**PdfDriver** — handles PDF download capture, parsing, and PDF-specific assertions: + +| Method | Description | +|--------|-------------| +| `clearPdfFiles()` | Remove previously generated PDFs from `_results_/downloads` | +| `waitForDownload(triggerDownload, timeout)` | Wait for a PDF via browser download event or PDF response capture | +| `parsePdf(filePath)` | Parse PDF text and links using `pdf-parse` | +| `expectCoreContent(pdf, { reference, scale })` | Validate core report text, reference handling, and scale formatting | +| `expectFloodZone(pdf, floodZone)` | Validate zone text in PDF matches expected flood zone | +| `expectLocation(pdf, polygonString)` | Validate centroid easting/northing rendered in PDF | +| `expectRequiredLinks(pdf, expectedLinks)` | Validate required static links are embedded in PDF | +| `expectAllLinksAreValid(pdf)` | Validate extracted links are syntactically valid URLs | + ### Page Objects (`pages/`) Each page is defined with `definePage({ slug, title })` and exports typed control handles built from factory functions. @@ -142,7 +156,7 @@ await test.step('Home → Triage', async () => { ## Prerequisites -- Node.js 18+ (LTS recommended) +- Node.js 20.16+ (required by `pdf-parse`) - Browsers — install whichever you need to run: ```bash @@ -166,6 +180,15 @@ npm install npx playwright install # all browsers, or pick one as above ``` +## PDF Test Artifacts + +PDF tests generate files in `_results_/downloads/`. + +- The folder is cleared once at the start of the PDF suite. +- Files generated during that run are retained (for example, 6 PDF tests produce 6 PDFs). +- On the next run, the folder is cleared again before new PDF files are created. +- Generated PDFs are ignored by git (`e2e/_results_/downloads/*.pdf`), so they are not committed. + ## Docker (WIP) Tests can be run in Docker using the Playwright base image. This is a work in progress. @@ -289,7 +312,8 @@ e2e/ │ ├── test-runner-api/ │ ├── form-driver.js # FormDriver — GOV.UK form interactions -│ └── map-driver.js # MapDriver — Esri map interactions +│ ├── map-driver.js # MapDriver — Esri map interactions +│ └── pdf-driver.js # PdfDriver — PDF download/parse/assertions │ ├── pages/ │ ├── .utils/ diff --git a/e2e/fixtures.js b/e2e/fixtures.js index 32323b6c..f8070627 100644 --- a/e2e/fixtures.js +++ b/e2e/fixtures.js @@ -1,6 +1,7 @@ import { test as base } from '@playwright/test' import { FormDriver } from './test-runner-api/form-driver.js' import { MapDriver } from './test-runner-api/map-driver.js' +import { PdfDriver } from './test-runner-api/pdf-driver.js' export const test = base.extend({ steps: async ({ page }, run) => { @@ -9,6 +10,9 @@ export const test = base.extend({ mapSteps: async ({ page }, run) => { await run(new MapDriver(page)) }, + pdfDriver: async ({ page }, run) => { + await run(new PdfDriver(page)) + }, }) export { expect } from '@playwright/test' diff --git a/e2e/package-lock.json b/e2e/package-lock.json index 131fc9ff..5e5963a7 100644 --- a/e2e/package-lock.json +++ b/e2e/package-lock.json @@ -10,7 +10,8 @@ "devDependencies": { "@mapbox/polyline": "^1.2.1", "@playwright/test": "^1.50.0", - "husky": "^9.1.7" + "husky": "^9.1.7", + "pdf-parse": "^2.4.5" } }, "node_modules/@babel/code-frame": { @@ -50,6 +51,201 @@ "polyline": "bin/polyline.bin.js" } }, + "node_modules/@napi-rs/canvas": { + "version": "0.1.80", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas/-/canvas-0.1.80.tgz", + "integrity": "sha512-DxuT1ClnIPts1kQx8FBmkk4BQDTfI5kIzywAaMjQSXfNnra5UFU9PwurXrl+Je3bJ6BGsp/zmshVVFbCmyI+ww==", + "dev": true, + "license": "MIT", + "workspaces": [ + "e2e/*" + ], + "engines": { + "node": ">= 10" + }, + "optionalDependencies": { + "@napi-rs/canvas-android-arm64": "0.1.80", + "@napi-rs/canvas-darwin-arm64": "0.1.80", + "@napi-rs/canvas-darwin-x64": "0.1.80", + "@napi-rs/canvas-linux-arm-gnueabihf": "0.1.80", + "@napi-rs/canvas-linux-arm64-gnu": "0.1.80", + "@napi-rs/canvas-linux-arm64-musl": "0.1.80", + "@napi-rs/canvas-linux-riscv64-gnu": "0.1.80", + "@napi-rs/canvas-linux-x64-gnu": "0.1.80", + "@napi-rs/canvas-linux-x64-musl": "0.1.80", + "@napi-rs/canvas-win32-x64-msvc": "0.1.80" + } + }, + "node_modules/@napi-rs/canvas-android-arm64": { + "version": "0.1.80", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-android-arm64/-/canvas-android-arm64-0.1.80.tgz", + "integrity": "sha512-sk7xhN/MoXeuExlggf91pNziBxLPVUqF2CAVnB57KLG/pz7+U5TKG8eXdc3pm0d7Od0WreB6ZKLj37sX9muGOQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/canvas-darwin-arm64": { + "version": "0.1.80", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-darwin-arm64/-/canvas-darwin-arm64-0.1.80.tgz", + "integrity": "sha512-O64APRTXRUiAz0P8gErkfEr3lipLJgM6pjATwavZ22ebhjYl/SUbpgM0xcWPQBNMP1n29afAC/Us5PX1vg+JNQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/canvas-darwin-x64": { + "version": "0.1.80", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-darwin-x64/-/canvas-darwin-x64-0.1.80.tgz", + "integrity": "sha512-FqqSU7qFce0Cp3pwnTjVkKjjOtxMqRe6lmINxpIZYaZNnVI0H5FtsaraZJ36SiTHNjZlUB69/HhxNDT1Aaa9vA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/canvas-linux-arm-gnueabihf": { + "version": "0.1.80", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm-gnueabihf/-/canvas-linux-arm-gnueabihf-0.1.80.tgz", + "integrity": "sha512-eyWz0ddBDQc7/JbAtY4OtZ5SpK8tR4JsCYEZjCE3dI8pqoWUC8oMwYSBGCYfsx2w47cQgQCgMVRVTFiiO38hHQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/canvas-linux-arm64-gnu": { + "version": "0.1.80", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm64-gnu/-/canvas-linux-arm64-gnu-0.1.80.tgz", + "integrity": "sha512-qwA63t8A86bnxhuA/GwOkK3jvb+XTQaTiVML0vAWoHyoZYTjNs7BzoOONDgTnNtr8/yHrq64XXzUoLqDzU+Uuw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/canvas-linux-arm64-musl": { + "version": "0.1.80", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm64-musl/-/canvas-linux-arm64-musl-0.1.80.tgz", + "integrity": "sha512-1XbCOz/ymhj24lFaIXtWnwv/6eFHXDrjP0jYkc6iHQ9q8oXKzUX1Lc6bu+wuGiLhGh2GS/2JlfORC5ZcXimRcg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/canvas-linux-riscv64-gnu": { + "version": "0.1.80", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-riscv64-gnu/-/canvas-linux-riscv64-gnu-0.1.80.tgz", + "integrity": "sha512-XTzR125w5ZMs0lJcxRlS1K3P5RaZ9RmUsPtd1uGt+EfDyYMu4c6SEROYsxyatbbu/2+lPe7MPHOO/0a0x7L/gw==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/canvas-linux-x64-gnu": { + "version": "0.1.80", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-x64-gnu/-/canvas-linux-x64-gnu-0.1.80.tgz", + "integrity": "sha512-BeXAmhKg1kX3UCrJsYbdQd3hIMDH/K6HnP/pG2LuITaXhXBiNdh//TVVVVCBbJzVQaV5gK/4ZOCMrQW9mvuTqA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/canvas-linux-x64-musl": { + "version": "0.1.80", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-x64-musl/-/canvas-linux-x64-musl-0.1.80.tgz", + "integrity": "sha512-x0XvZWdHbkgdgucJsRxprX/4o4sEed7qo9rCQA9ugiS9qE2QvP0RIiEugtZhfLH3cyI+jIRFJHV4Fuz+1BHHMg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/canvas-win32-x64-msvc": { + "version": "0.1.80", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-win32-x64-msvc/-/canvas-win32-x64-msvc-0.1.80.tgz", + "integrity": "sha512-Z8jPsM6df5V8B1HrCHB05+bDiCxjE9QA//3YrkKIdVDEwn5RKaqOxCJDRJkl48cJbylcrJbW4HxZbTte8juuPg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, "node_modules/@playwright/test": { "version": "1.58.2", "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.58.2.tgz", @@ -609,6 +805,40 @@ "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", "dev": true }, + "node_modules/pdf-parse": { + "version": "2.4.5", + "resolved": "https://registry.npmjs.org/pdf-parse/-/pdf-parse-2.4.5.tgz", + "integrity": "sha512-mHU89HGh7v+4u2ubfnevJ03lmPgQ5WU4CxAVmTSh/sxVTEDYd1er/dKS/A6vg77NX47KTEoihq8jZBLr8Cxuwg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@napi-rs/canvas": "0.1.80", + "pdfjs-dist": "5.4.296" + }, + "bin": { + "pdf-parse": "bin/cli.mjs" + }, + "engines": { + "node": ">=20.16.0 <21 || >=22.3.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/mehmet-kozan" + } + }, + "node_modules/pdfjs-dist": { + "version": "5.4.296", + "resolved": "https://registry.npmjs.org/pdfjs-dist/-/pdfjs-dist-5.4.296.tgz", + "integrity": "sha512-DlOzet0HO7OEnmUmB6wWGJrrdvbyJKftI1bhMitK7O2N8W2gc757yyYBbINy9IDafXAV9wmKr9t7xsTaNKRG5Q==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=20.16.0 || >=22.3.0" + }, + "optionalDependencies": { + "@napi-rs/canvas": "^0.1.80" + } + }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", diff --git a/e2e/package.json b/e2e/package.json index 41f10239..f12eb41e 100644 --- a/e2e/package.json +++ b/e2e/package.json @@ -21,6 +21,7 @@ "devDependencies": { "@mapbox/polyline": "^1.2.1", "@playwright/test": "^1.50.0", - "husky": "^9.1.7" + "husky": "^9.1.7", + "pdf-parse": "^2.4.5" } } diff --git a/e2e/test-runner-api/pdf-driver.js b/e2e/test-runner-api/pdf-driver.js new file mode 100644 index 00000000..3799e55b --- /dev/null +++ b/e2e/test-runner-api/pdf-driver.js @@ -0,0 +1,138 @@ +import fs from 'node:fs/promises' +import path from 'node:path' +import { PDFParse } from 'pdf-parse' +import { expect } from '@playwright/test' + +const downloadsDir = path.resolve(process.cwd(), '_results_', 'downloads') + +// eslint-disable-next-line no-control-regex +const sanitizeFileName = (name = '') => name.replaceAll(/[<>:"/\\|?*\x00-\x1F]/g, '_') + +const fileNameFromContentDisposition = (header = '') => { + const value = header + const utf8Match = /filename\*=UTF-8''([^;]+)/i.exec(value) + if (utf8Match?.[1]) { + return decodeURIComponent(utf8Match[1]) + } + const plainMatch = /filename="?([^";]+)"?/i.exec(value) + return plainMatch?.[1] +} + +export class PdfDriver { + constructor (page) { + this.page = page + } + + // ----FILE HELPERS---- // + + async clearPdfFiles () { + await fs.mkdir(downloadsDir, { recursive: true }) + const files = await this.listPdfFiles() + await Promise.all(files.map(async (filePath) => fs.unlink(filePath).catch(() => {}))) + } + + async listPdfFiles () { + try { + const files = await fs.readdir(downloadsDir) + return files + .filter((fileName) => fileName.toLowerCase().endsWith('.pdf')) + .map((fileName) => path.join(downloadsDir, fileName)) + } catch { + return [] + } + } + + // ----DOWNLOAD HELPERS---- // + + async waitForDownload (triggerDownload, timeout = 20000) { + // Both promises race to collect their data into a normalised shape. + // Only the winner's saveAs is called, so exactly one file is written. + const downloadEventPromise = this.page.waitForEvent('download', { timeout }) + .then(async (download) => ({ + fileName: sanitizeFileName(download.suggestedFilename()), + saveAs: async (dest) => download.saveAs(dest) + })) + + const responsePdfPromise = this.page.waitForResponse((response) => { + const contentType = response.headers()['content-type'] || '' + return contentType.toLowerCase().includes('application/pdf') + }, { timeout }).then(async (response) => { + const body = await response.body() + const headerFileName = fileNameFromContentDisposition(response.headers()['content-disposition']) + const generatedName = headerFileName || `download-${Date.now()}.pdf` + return { + fileName: sanitizeFileName(generatedName), + saveAs: async (dest) => fs.writeFile(dest, body) + } + }) + + await triggerDownload() + const winner = await Promise.any([downloadEventPromise, responsePdfPromise]) + await fs.mkdir(downloadsDir, { recursive: true }) + const downloadPath = path.join(downloadsDir, winner.fileName) + await winner.saveAs(downloadPath) + return downloadPath + } + + // ----PARSE HELPERS---- // + + async parsePdf (filePath) { + const buffer = await fs.readFile(filePath) + const parser = new PDFParse({ data: buffer }) + const parsedPdf = await parser.getText() + const pdfInfo = await parser.getInfo({ parsePageInfo: true }) + await parser.destroy() + + const text = parsedPdf.text.replaceAll(/\s+/g, ' ').toLowerCase() + const links = (pdfInfo.pages || []) + .flatMap((page) => page.links || []) + .map((link) => link?.url || link?.unsafeUrl || link?.href || '') + .filter(Boolean) + const normalizedLinks = links.map((url) => url.trim().replace(/\/$/, '')) + + return { text, links, normalizedLinks } + } + + // ----ASSERTIONS---- // + + async expectCoreContent (pdf, { reference, scale }) { + const scaleFormatted = scale.replaceAll(/(\d)(?=(\d{3})+$)/g, '$1,?') + + expect(pdf.text.length).toBeGreaterThan(100) + expect(pdf.text).toContain('flood') + expect(pdf.text).toContain('your reference') + if (reference) { + expect(pdf.text).toContain(reference.toLowerCase()) + } else { + expect(pdf.text).toContain('unspecified') + } + expect(pdf.text).toContain('scale') + expect(pdf.text).toMatch(new RegExp(String.raw`1\s*:\s*${scaleFormatted}`)) + } + + async expectFloodZone (pdf, floodZone) { + expect(pdf.text).toContain(`your selected location is in flood zone ${floodZone}`) + } + + async expectLocation (pdf, polygonString) { + const coordinates = JSON.parse(polygonString) + const isClosed = JSON.stringify(coordinates[0]) === JSON.stringify(coordinates.at(-1)) + const points = isClosed ? coordinates.slice(0, -1) : coordinates + const easting = Math.floor(points.reduce((sum, [x]) => sum + x, 0) / points.length) + const northing = Math.floor(points.reduce((sum, [, y]) => sum + y, 0) / points.length) + expect(pdf.text).toContain(`${easting}/${northing}`) + } + + async expectRequiredLinks (pdf, expectedLinks) { + expect(pdf.links.length).toBeGreaterThan(0) + expectedLinks.forEach((expectedLink) => { + expect(pdf.normalizedLinks).toContain(expectedLink) + }) + } + + async expectAllLinksAreValid (pdf) { + pdf.links.forEach((url) => { + expect(URL.canParse(url)).toBe(true) + }) + } +} diff --git a/e2e/tests/pages/results-page.spec.js b/e2e/tests/pages/results-page.spec.js index 488f203e..18bc0645 100644 --- a/e2e/tests/pages/results-page.spec.js +++ b/e2e/tests/pages/results-page.spec.js @@ -1,9 +1,14 @@ import { test } from '../../fixtures.js' import { pages } from '../../pages/index.js' import { areaData, floodZonedata } from '../../data/location-data.js' +import { PdfDriver } from '../../test-runner-api/pdf-driver.js' test.describe('Results page', () => { const slug = (polygon) => `/results?encodedPolygon=${encodeURIComponent(polygon)}` + const expectedPdfLinks = [ + 'https://www.nationalarchives.gov.uk/doc/open-government-licence/version/3', + 'https://flood-map-for-planning.service.gov.uk/os-terms' + ] // Flood zone rendering for (const [index, { polygon, floodZone }] of Object.values(areaData).entries()) { @@ -131,4 +136,77 @@ test.describe('Results page', () => { await steps.expectLinkExists(pages.results.orderFloodRiskDataButton) }) }) + + test.describe('PDF download checks', () => { + test.beforeAll(async () => { + await new PdfDriver().clearPdfFiles() + }) + + const pdfScenarios = [ + { + label: 'flood zone 1', + floodZone: '1', + polygon: floodZonedata.FZ1_With_RandS + }, + { + label: 'flood zone 2', + floodZone: '2', + polygon: floodZonedata.FZ2_With_RandS + }, + { + label: 'flood zone 3', + floodZone: '3', + polygon: floodZonedata.FZ3_With_SW_and_RandS + } + ] + + for (const { label, floodZone, polygon } of pdfScenarios) { + const pdfSlug = `/results?encodedPolygon=${encodeURIComponent(polygon)}` + const pdfDownloadTimeoutMs = 60000 + + test.describe(`for ${label}`, () => { + test.beforeEach(async ({ page, steps }) => { + await steps.open({ + ...pages.results.pageWithZone(floodZone), + slug: pdfSlug + }) + await page.locator('.govuk-details__summary').first().click() + }) + + test('downloads pdf without reference text', async ({ steps, pdfDriver }) => { + const scale = '2500' + await steps.select(pages.results.scaleSelect, scale) + + const pdfPath = await pdfDriver.waitForDownload(async () => { + await steps.clickButton(pages.results.downloadFloodMapButton) + }, pdfDownloadTimeoutMs) + const pdf = await pdfDriver.parsePdf(pdfPath) + + await pdfDriver.expectCoreContent(pdf, { scale }) + await pdfDriver.expectFloodZone(pdf, floodZone) + await pdfDriver.expectLocation(pdf, polygon) + await pdfDriver.expectRequiredLinks(pdf, expectedPdfLinks) + await pdfDriver.expectAllLinksAreValid(pdf) + }) + + test('downloads pdf with reference text and amended scale', async ({ steps, pdfDriver }) => { + const referenceText = 'Test123456789101112131415' + const scale = '25000' + await steps.type(pages.results.addReferenceInput, referenceText) + await steps.select(pages.results.scaleSelect, scale) + + const pdfPath = await pdfDriver.waitForDownload(async () => { + await steps.clickButton(pages.results.downloadFloodMapButton) + }, pdfDownloadTimeoutMs) + const pdf = await pdfDriver.parsePdf(pdfPath) + + await pdfDriver.expectCoreContent(pdf, { reference: referenceText, scale }) + await pdfDriver.expectFloodZone(pdf, floodZone) + await pdfDriver.expectLocation(pdf, polygon) + await pdfDriver.expectRequiredLinks(pdf, expectedPdfLinks) + await pdfDriver.expectAllLinksAreValid(pdf) + }) + }) + } + }) }) From 7190090621f1b64e94074c038db4515dd7ec50fb Mon Sep 17 00:00:00 2001 From: Gary Dawson Date: Mon, 20 Apr 2026 14:29:00 +0100 Subject: [PATCH 4/7] Added P1 PDF tests to Next Steps spec file, updated pdf driver to handle potentially conflicting pdf jobs between spec files --- e2e/pages/next-steps.page.js | 1 + e2e/test-runner-api/pdf-driver.js | 3 +- e2e/tests/pages/next-steps-page.spec.js | 78 +++++++++++++++++++++++++ e2e/tests/pages/results-page.spec.js | 4 +- 4 files changed, 83 insertions(+), 3 deletions(-) diff --git a/e2e/pages/next-steps.page.js b/e2e/pages/next-steps.page.js index 7d1b7094..de96c388 100644 --- a/e2e/pages/next-steps.page.js +++ b/e2e/pages/next-steps.page.js @@ -6,6 +6,7 @@ export const page = definePage({ title: 'Next steps for your planning application' }) +// P1 Map Controls export const addReferenceToFloodMapDetails = button('Add a reference to the flood map and set the scale') export const addReferenceInput = textInput('Add a reference') export const scaleSelect = selectInput('Scale') diff --git a/e2e/test-runner-api/pdf-driver.js b/e2e/test-runner-api/pdf-driver.js index 3799e55b..6fa564b7 100644 --- a/e2e/test-runner-api/pdf-driver.js +++ b/e2e/test-runner-api/pdf-driver.js @@ -59,7 +59,8 @@ export class PdfDriver { }, { timeout }).then(async (response) => { const body = await response.body() const headerFileName = fileNameFromContentDisposition(response.headers()['content-disposition']) - const generatedName = headerFileName || `download-${Date.now()}.pdf` + const randomSuffix = Math.random().toString(36).substring(2, 9) + const generatedName = headerFileName || `download-${Date.now()}-${randomSuffix}.pdf` return { fileName: sanitizeFileName(generatedName), saveAs: async (dest) => fs.writeFile(dest, body) diff --git a/e2e/tests/pages/next-steps-page.spec.js b/e2e/tests/pages/next-steps-page.spec.js index d08395fc..d946fadc 100644 --- a/e2e/tests/pages/next-steps-page.spec.js +++ b/e2e/tests/pages/next-steps-page.spec.js @@ -1,9 +1,14 @@ import { test } from '../../fixtures.js' import { pages } from '../../pages/index.js' import { areaData, floodZonedata } from '../../data/location-data.js' +import { PdfDriver } from '../../test-runner-api/pdf-driver.js' test.describe('Next steps page', () => { const slug = (polygon) => `/next-steps?encodedPolygon=${encodeURIComponent(polygon)}` + const expectedPdfLinks = [ + 'https://www.nationalarchives.gov.uk/doc/open-government-licence/version/3', + 'https://flood-map-for-planning.service.gov.uk/os-terms' + ] test.describe('Link and content tests', () => { const polygon = areaData.Yorkshire.polygon @@ -115,4 +120,77 @@ test.describe('Next steps page', () => { await steps.expectLinkExists(pages.nextSteps.orderFloodRiskDataButton) }) }) + + test.describe('PDF download checks', () => { + test.beforeAll(async () => { + await new PdfDriver().clearPdfFiles() + }) + + const pdfScenarios = [ + { + label: 'flood zone 1', + floodZone: '1', + polygon: floodZonedata.FZ1_With_RandS + }, + { + label: 'flood zone 2', + floodZone: '2', + polygon: floodZonedata.FZ2_With_RandS + }, + { + label: 'flood zone 3', + floodZone: '3', + polygon: floodZonedata.FZ3_With_SW_and_RandS + } + ] + + for (const { label, floodZone, polygon } of pdfScenarios) { + const pdfSlug = `/next-steps?encodedPolygon=${encodeURIComponent(polygon)}` + const pdfDownloadTimeoutMs = 60000 + + test.describe(`for ${label}`, () => { + test.beforeEach(async ({ page, steps }) => { + await steps.open({ + ...pages.nextSteps.page, + slug: pdfSlug + }) + await page.locator('.govuk-details__summary').first().click() + }) + + test('downloads pdf without reference text', async ({ steps, pdfDriver }) => { + const scale = '2500' + await steps.select(pages.nextSteps.scaleSelect, scale) + + const pdfPath = await pdfDriver.waitForDownload(async () => { + await steps.clickButton(pages.nextSteps.downloadFloodMapButton) + }, pdfDownloadTimeoutMs) + const pdf = await pdfDriver.parsePdf(pdfPath) + + await pdfDriver.expectCoreContent(pdf, { scale }) + await pdfDriver.expectFloodZone(pdf, floodZone) + await pdfDriver.expectLocation(pdf, polygon) + await pdfDriver.expectRequiredLinks(pdf, expectedPdfLinks) + await pdfDriver.expectAllLinksAreValid(pdf) + }) + + test('downloads pdf with reference text and amended scale', async ({ steps, pdfDriver }) => { + const referenceText = 'Test123456789101112131415' + const scale = '25000' + + const pdfPath = await pdfDriver.waitForDownload(async () => { + await steps.type(pages.nextSteps.addReferenceInput, referenceText) + await steps.select(pages.nextSteps.scaleSelect, scale) + await steps.clickButton(pages.nextSteps.downloadFloodMapButton) + }, pdfDownloadTimeoutMs) + const pdf = await pdfDriver.parsePdf(pdfPath) + + await pdfDriver.expectCoreContent(pdf, { reference: referenceText, scale }) + await pdfDriver.expectFloodZone(pdf, floodZone) + await pdfDriver.expectLocation(pdf, polygon) + await pdfDriver.expectRequiredLinks(pdf, expectedPdfLinks) + await pdfDriver.expectAllLinksAreValid(pdf) + }) + }) + } + }) }) diff --git a/e2e/tests/pages/results-page.spec.js b/e2e/tests/pages/results-page.spec.js index 18bc0645..8b464dcd 100644 --- a/e2e/tests/pages/results-page.spec.js +++ b/e2e/tests/pages/results-page.spec.js @@ -192,10 +192,10 @@ test.describe('Results page', () => { test('downloads pdf with reference text and amended scale', async ({ steps, pdfDriver }) => { const referenceText = 'Test123456789101112131415' const scale = '25000' - await steps.type(pages.results.addReferenceInput, referenceText) - await steps.select(pages.results.scaleSelect, scale) const pdfPath = await pdfDriver.waitForDownload(async () => { + await steps.type(pages.results.addReferenceInput, referenceText) + await steps.select(pages.results.scaleSelect, scale) await steps.clickButton(pages.results.downloadFloodMapButton) }, pdfDownloadTimeoutMs) const pdf = await pdfDriver.parsePdf(pdfPath) From 3fb5a7f3585a1375a40179c773dd20dcbe6ce6f0 Mon Sep 17 00:00:00 2001 From: Gary Dawson Date: Mon, 20 Apr 2026 15:00:59 +0100 Subject: [PATCH 5/7] Sonar Qube fixes --- e2e/test-runner-api/pdf-driver.js | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/e2e/test-runner-api/pdf-driver.js b/e2e/test-runner-api/pdf-driver.js index 6fa564b7..d101a87d 100644 --- a/e2e/test-runner-api/pdf-driver.js +++ b/e2e/test-runner-api/pdf-driver.js @@ -4,6 +4,9 @@ import { PDFParse } from 'pdf-parse' import { expect } from '@playwright/test' const downloadsDir = path.resolve(process.cwd(), '_results_', 'downloads') +const RANDOM_SUFFIX_RADIX = 36 +const RANDOM_SUFFIX_START_INDEX = 2 +const RANDOM_SUFFIX_END_INDEX = 9 // eslint-disable-next-line no-control-regex const sanitizeFileName = (name = '') => name.replaceAll(/[<>:"/\\|?*\x00-\x1F]/g, '_') @@ -59,7 +62,9 @@ export class PdfDriver { }, { timeout }).then(async (response) => { const body = await response.body() const headerFileName = fileNameFromContentDisposition(response.headers()['content-disposition']) - const randomSuffix = Math.random().toString(36).substring(2, 9) + const randomSuffix = Math.random() + .toString(RANDOM_SUFFIX_RADIX) + .substring(RANDOM_SUFFIX_START_INDEX, RANDOM_SUFFIX_END_INDEX) const generatedName = headerFileName || `download-${Date.now()}-${randomSuffix}.pdf` return { fileName: sanitizeFileName(generatedName), From 4a2a5b9e082f4bc3565495b388a5764c8f377c46 Mon Sep 17 00:00:00 2001 From: Gary Dawson Date: Tue, 21 Apr 2026 08:33:59 +0100 Subject: [PATCH 6/7] Further Sonar Qube fixes --- e2e/test-runner-api/pdf-driver.js | 15 ++++++--------- 1 file changed, 6 insertions(+), 9 deletions(-) diff --git a/e2e/test-runner-api/pdf-driver.js b/e2e/test-runner-api/pdf-driver.js index d101a87d..177f6857 100644 --- a/e2e/test-runner-api/pdf-driver.js +++ b/e2e/test-runner-api/pdf-driver.js @@ -1,12 +1,10 @@ import fs from 'node:fs/promises' import path from 'node:path' +import { randomUUID } from 'node:crypto' import { PDFParse } from 'pdf-parse' import { expect } from '@playwright/test' const downloadsDir = path.resolve(process.cwd(), '_results_', 'downloads') -const RANDOM_SUFFIX_RADIX = 36 -const RANDOM_SUFFIX_START_INDEX = 2 -const RANDOM_SUFFIX_END_INDEX = 9 // eslint-disable-next-line no-control-regex const sanitizeFileName = (name = '') => name.replaceAll(/[<>:"/\\|?*\x00-\x1F]/g, '_') @@ -62,10 +60,7 @@ export class PdfDriver { }, { timeout }).then(async (response) => { const body = await response.body() const headerFileName = fileNameFromContentDisposition(response.headers()['content-disposition']) - const randomSuffix = Math.random() - .toString(RANDOM_SUFFIX_RADIX) - .substring(RANDOM_SUFFIX_START_INDEX, RANDOM_SUFFIX_END_INDEX) - const generatedName = headerFileName || `download-${Date.now()}-${randomSuffix}.pdf` + const generatedName = headerFileName || `download-${Date.now()}-${randomUUID()}.pdf` return { fileName: sanitizeFileName(generatedName), saveAs: async (dest) => fs.writeFile(dest, body) @@ -102,7 +97,9 @@ export class PdfDriver { // ----ASSERTIONS---- // async expectCoreContent (pdf, { reference, scale }) { - const scaleFormatted = scale.replaceAll(/(\d)(?=(\d{3})+$)/g, '$1,?') + const normalizedPdfText = pdf.text + .replaceAll(/\s+/g, '') + .replaceAll(',', '') expect(pdf.text.length).toBeGreaterThan(100) expect(pdf.text).toContain('flood') @@ -113,7 +110,7 @@ export class PdfDriver { expect(pdf.text).toContain('unspecified') } expect(pdf.text).toContain('scale') - expect(pdf.text).toMatch(new RegExp(String.raw`1\s*:\s*${scaleFormatted}`)) + expect(normalizedPdfText).toContain(`1:${scale}`) } async expectFloodZone (pdf, floodZone) { From fc3b38a5136b3b381effc3e443f82cedc7b8421b Mon Sep 17 00:00:00 2001 From: RyonLeightonDEFRA Date: Tue, 28 Apr 2026 10:39:10 +0100 Subject: [PATCH 7/7] pdf updates (#770) * pdf updates Co-authored-by: Copilot * removed test.describe.only from results spec file --------- Co-authored-by: Copilot Co-authored-by: Gary Dawson --- e2e/pages/.utils/form-controls.js | 1 + e2e/pages/next-steps.page.js | 4 +- e2e/pages/results.page.js | 4 +- e2e/test-runner-api/form-driver.js | 4 + e2e/test-runner-api/pdf-driver.js | 107 ++++++------------------ e2e/tests/pages/next-steps-page.spec.js | 79 ----------------- e2e/tests/pages/results-page.spec.js | 104 ++++++++--------------- 7 files changed, 69 insertions(+), 234 deletions(-) diff --git a/e2e/pages/.utils/form-controls.js b/e2e/pages/.utils/form-controls.js index 341fc671..6e2cff1e 100644 --- a/e2e/pages/.utils/form-controls.js +++ b/e2e/pages/.utils/form-controls.js @@ -8,3 +8,4 @@ export const footerLink = (text, url) => ({ type: 'footerLink', text, url: url = export const errorText = (text) => ({ type: 'errorText', text }) export const button = (text) => ({ type: 'button', text }) export const selectInput = (text) => ({ type: 'selectInput', text }) +export const details = (text) => ({ type: 'details', text }) diff --git a/e2e/pages/next-steps.page.js b/e2e/pages/next-steps.page.js index de96c388..002a77a1 100644 --- a/e2e/pages/next-steps.page.js +++ b/e2e/pages/next-steps.page.js @@ -1,5 +1,5 @@ import { definePage } from './.utils/page.js' -import { button, link, textInput, selectInput } from './.utils/form-controls.js' +import { button, details, link, textInput, selectInput } from './.utils/form-controls.js' export const page = definePage({ slug: '/next-steps', @@ -7,7 +7,7 @@ export const page = definePage({ }) // P1 Map Controls -export const addReferenceToFloodMapDetails = button('Add a reference to the flood map and set the scale') +export const addReferenceToFloodMapDetails = details('Add a reference to the flood map and set the scale') export const addReferenceInput = textInput('Add a reference') export const scaleSelect = selectInput('Scale') export const downloadFloodMapButton = button('Download flood map for this location (PDF)') diff --git a/e2e/pages/results.page.js b/e2e/pages/results.page.js index d9f8f4b7..ed7017d0 100644 --- a/e2e/pages/results.page.js +++ b/e2e/pages/results.page.js @@ -1,5 +1,5 @@ import { definePage } from './.utils/page.js' -import { button, link, textInput, selectInput } from './.utils/form-controls.js' +import { button, details, link, textInput, selectInput } from './.utils/form-controls.js' export const titleForZone = (floodZone) => `This location is in flood zone ${floodZone}` export const pageWithZone = (floodZone) => definePage({ @@ -8,7 +8,7 @@ export const pageWithZone = (floodZone) => definePage({ }) // P1 Map Controls -export const addReferenceToFloodMapDetails = button('Add a reference to the flood map and set the scale') +export const addReferenceToFloodMapDetails = details('Add a reference to the flood map and set the scale') export const addReferenceInput = textInput('Add a reference') export const scaleSelect = selectInput('Scale') export const downloadFloodMapButton = button('Download flood map for this location (PDF)') diff --git a/e2e/test-runner-api/form-driver.js b/e2e/test-runner-api/form-driver.js index afd60c00..629160da 100644 --- a/e2e/test-runner-api/form-driver.js +++ b/e2e/test-runner-api/form-driver.js @@ -55,6 +55,10 @@ export class FormDriver { await this.page.getByRole('button', { name: element.text, exact: true }).click() } + async clickDetails (element) { + await this.page.locator('details').filter({ hasText: element.text }).locator('summary').click() + } + async clickLink (element) { const locator = this.#getLinkLocator(element) // Some pages legitimately contain duplicate link text; click the first visible match. diff --git a/e2e/test-runner-api/pdf-driver.js b/e2e/test-runner-api/pdf-driver.js index 177f6857..e307c629 100644 --- a/e2e/test-runner-api/pdf-driver.js +++ b/e2e/test-runner-api/pdf-driver.js @@ -1,78 +1,25 @@ import fs from 'node:fs/promises' import path from 'node:path' -import { randomUUID } from 'node:crypto' import { PDFParse } from 'pdf-parse' import { expect } from '@playwright/test' const downloadsDir = path.resolve(process.cwd(), '_results_', 'downloads') -// eslint-disable-next-line no-control-regex -const sanitizeFileName = (name = '') => name.replaceAll(/[<>:"/\\|?*\x00-\x1F]/g, '_') - -const fileNameFromContentDisposition = (header = '') => { - const value = header - const utf8Match = /filename\*=UTF-8''([^;]+)/i.exec(value) - if (utf8Match?.[1]) { - return decodeURIComponent(utf8Match[1]) - } - const plainMatch = /filename="?([^";]+)"?/i.exec(value) - return plainMatch?.[1] -} - export class PdfDriver { constructor (page) { this.page = page } - // ----FILE HELPERS---- // - - async clearPdfFiles () { - await fs.mkdir(downloadsDir, { recursive: true }) - const files = await this.listPdfFiles() - await Promise.all(files.map(async (filePath) => fs.unlink(filePath).catch(() => {}))) - } - - async listPdfFiles () { - try { - const files = await fs.readdir(downloadsDir) - return files - .filter((fileName) => fileName.toLowerCase().endsWith('.pdf')) - .map((fileName) => path.join(downloadsDir, fileName)) - } catch { - return [] - } - } - // ----DOWNLOAD HELPERS---- // - async waitForDownload (triggerDownload, timeout = 20000) { - // Both promises race to collect their data into a normalised shape. - // Only the winner's saveAs is called, so exactly one file is written. - const downloadEventPromise = this.page.waitForEvent('download', { timeout }) - .then(async (download) => ({ - fileName: sanitizeFileName(download.suggestedFilename()), - saveAs: async (dest) => download.saveAs(dest) - })) - - const responsePdfPromise = this.page.waitForResponse((response) => { - const contentType = response.headers()['content-type'] || '' - return contentType.toLowerCase().includes('application/pdf') - }, { timeout }).then(async (response) => { - const body = await response.body() - const headerFileName = fileNameFromContentDisposition(response.headers()['content-disposition']) - const generatedName = headerFileName || `download-${Date.now()}-${randomUUID()}.pdf` - return { - fileName: sanitizeFileName(generatedName), - saveAs: async (dest) => fs.writeFile(dest, body) - } - }) - - await triggerDownload() - const winner = await Promise.any([downloadEventPromise, responsePdfPromise]) - await fs.mkdir(downloadsDir, { recursive: true }) - const downloadPath = path.join(downloadsDir, winner.fileName) - await winner.saveAs(downloadPath) - return downloadPath + awaitDownload (timeout = 60000) { + return this.page.waitForEvent('download', { timeout }) + .then(async (download) => { + await fs.mkdir(downloadsDir, { recursive: true }) + const dest = path.join(downloadsDir, download.suggestedFilename()) + await download.saveAs(dest) + return dest + }) } // ----PARSE HELPERS---- // @@ -89,35 +36,28 @@ export class PdfDriver { .flatMap((page) => page.links || []) .map((link) => link?.url || link?.unsafeUrl || link?.href || '') .filter(Boolean) - const normalizedLinks = links.map((url) => url.trim().replace(/\/$/, '')) - - return { text, links, normalizedLinks } + return { text, links } } // ----ASSERTIONS---- // - async expectCoreContent (pdf, { reference, scale }) { - const normalizedPdfText = pdf.text + expectCoreContent (pdf, { reference, scale }) { + const compactText = pdf.text .replaceAll(/\s+/g, '') .replaceAll(',', '') expect(pdf.text.length).toBeGreaterThan(100) expect(pdf.text).toContain('flood') expect(pdf.text).toContain('your reference') - if (reference) { - expect(pdf.text).toContain(reference.toLowerCase()) - } else { - expect(pdf.text).toContain('unspecified') - } - expect(pdf.text).toContain('scale') - expect(normalizedPdfText).toContain(`1:${scale}`) + expect(pdf.text).toContain(reference ? reference.toLowerCase() : 'unspecified') + expect(compactText).toContain(`1:${scale}`) } - async expectFloodZone (pdf, floodZone) { + expectFloodZone (pdf, floodZone) { expect(pdf.text).toContain(`your selected location is in flood zone ${floodZone}`) } - async expectLocation (pdf, polygonString) { + expectLocation (pdf, polygonString) { const coordinates = JSON.parse(polygonString) const isClosed = JSON.stringify(coordinates[0]) === JSON.stringify(coordinates.at(-1)) const points = isClosed ? coordinates.slice(0, -1) : coordinates @@ -126,16 +66,17 @@ export class PdfDriver { expect(pdf.text).toContain(`${easting}/${northing}`) } - async expectRequiredLinks (pdf, expectedLinks) { + expectLinks (pdf, expectedLinks) { expect(pdf.links.length).toBeGreaterThan(0) - expectedLinks.forEach((expectedLink) => { - expect(pdf.normalizedLinks).toContain(expectedLink) - }) + const normalized = pdf.links.map((url) => url.trim().replace(/\/$/, '')) + expectedLinks.forEach((link) => expect(normalized).toContain(link)) + pdf.links.forEach((url) => expect(URL.canParse(url)).toBe(true)) } - async expectAllLinksAreValid (pdf) { - pdf.links.forEach((url) => { - expect(URL.canParse(url)).toBe(true) - }) + expectPdfContent (pdf, { reference, scale, floodZone, polygon, expectedLinks }) { + this.expectCoreContent(pdf, { reference, scale }) + this.expectFloodZone(pdf, floodZone) + this.expectLocation(pdf, polygon) + this.expectLinks(pdf, expectedLinks) } } diff --git a/e2e/tests/pages/next-steps-page.spec.js b/e2e/tests/pages/next-steps-page.spec.js index d946fadc..d217c61a 100644 --- a/e2e/tests/pages/next-steps-page.spec.js +++ b/e2e/tests/pages/next-steps-page.spec.js @@ -1,14 +1,9 @@ import { test } from '../../fixtures.js' import { pages } from '../../pages/index.js' import { areaData, floodZonedata } from '../../data/location-data.js' -import { PdfDriver } from '../../test-runner-api/pdf-driver.js' test.describe('Next steps page', () => { const slug = (polygon) => `/next-steps?encodedPolygon=${encodeURIComponent(polygon)}` - const expectedPdfLinks = [ - 'https://www.nationalarchives.gov.uk/doc/open-government-licence/version/3', - 'https://flood-map-for-planning.service.gov.uk/os-terms' - ] test.describe('Link and content tests', () => { const polygon = areaData.Yorkshire.polygon @@ -27,7 +22,6 @@ test.describe('Next steps page', () => { }) // The following tests validate that external links can be reached. - test('navigates to Flood risk assessments: climate change allowances page when clicking the link', { tag: '@urlCheck' }, async ({ steps }) => { await steps.clickLink(pages.nextSteps.takeIntoAccountClimateChangeAllowancesLink) await steps.expectUrlContains('climate-change-allowances') @@ -120,77 +114,4 @@ test.describe('Next steps page', () => { await steps.expectLinkExists(pages.nextSteps.orderFloodRiskDataButton) }) }) - - test.describe('PDF download checks', () => { - test.beforeAll(async () => { - await new PdfDriver().clearPdfFiles() - }) - - const pdfScenarios = [ - { - label: 'flood zone 1', - floodZone: '1', - polygon: floodZonedata.FZ1_With_RandS - }, - { - label: 'flood zone 2', - floodZone: '2', - polygon: floodZonedata.FZ2_With_RandS - }, - { - label: 'flood zone 3', - floodZone: '3', - polygon: floodZonedata.FZ3_With_SW_and_RandS - } - ] - - for (const { label, floodZone, polygon } of pdfScenarios) { - const pdfSlug = `/next-steps?encodedPolygon=${encodeURIComponent(polygon)}` - const pdfDownloadTimeoutMs = 60000 - - test.describe(`for ${label}`, () => { - test.beforeEach(async ({ page, steps }) => { - await steps.open({ - ...pages.nextSteps.page, - slug: pdfSlug - }) - await page.locator('.govuk-details__summary').first().click() - }) - - test('downloads pdf without reference text', async ({ steps, pdfDriver }) => { - const scale = '2500' - await steps.select(pages.nextSteps.scaleSelect, scale) - - const pdfPath = await pdfDriver.waitForDownload(async () => { - await steps.clickButton(pages.nextSteps.downloadFloodMapButton) - }, pdfDownloadTimeoutMs) - const pdf = await pdfDriver.parsePdf(pdfPath) - - await pdfDriver.expectCoreContent(pdf, { scale }) - await pdfDriver.expectFloodZone(pdf, floodZone) - await pdfDriver.expectLocation(pdf, polygon) - await pdfDriver.expectRequiredLinks(pdf, expectedPdfLinks) - await pdfDriver.expectAllLinksAreValid(pdf) - }) - - test('downloads pdf with reference text and amended scale', async ({ steps, pdfDriver }) => { - const referenceText = 'Test123456789101112131415' - const scale = '25000' - - const pdfPath = await pdfDriver.waitForDownload(async () => { - await steps.type(pages.nextSteps.addReferenceInput, referenceText) - await steps.select(pages.nextSteps.scaleSelect, scale) - await steps.clickButton(pages.nextSteps.downloadFloodMapButton) - }, pdfDownloadTimeoutMs) - const pdf = await pdfDriver.parsePdf(pdfPath) - - await pdfDriver.expectCoreContent(pdf, { reference: referenceText, scale }) - await pdfDriver.expectFloodZone(pdf, floodZone) - await pdfDriver.expectLocation(pdf, polygon) - await pdfDriver.expectRequiredLinks(pdf, expectedPdfLinks) - await pdfDriver.expectAllLinksAreValid(pdf) - }) - }) - } - }) }) diff --git a/e2e/tests/pages/results-page.spec.js b/e2e/tests/pages/results-page.spec.js index 8b464dcd..941d7eda 100644 --- a/e2e/tests/pages/results-page.spec.js +++ b/e2e/tests/pages/results-page.spec.js @@ -1,14 +1,9 @@ import { test } from '../../fixtures.js' import { pages } from '../../pages/index.js' import { areaData, floodZonedata } from '../../data/location-data.js' -import { PdfDriver } from '../../test-runner-api/pdf-driver.js' test.describe('Results page', () => { const slug = (polygon) => `/results?encodedPolygon=${encodeURIComponent(polygon)}` - const expectedPdfLinks = [ - 'https://www.nationalarchives.gov.uk/doc/open-government-licence/version/3', - 'https://flood-map-for-planning.service.gov.uk/os-terms' - ] // Flood zone rendering for (const [index, { polygon, floodZone }] of Object.values(areaData).entries()) { @@ -138,75 +133,48 @@ test.describe('Results page', () => { }) test.describe('PDF download checks', () => { - test.beforeAll(async () => { - await new PdfDriver().clearPdfFiles() - }) + const expectedPdfLinks = [ + 'https://www.nationalarchives.gov.uk/doc/open-government-licence/version/3', + 'https://flood-map-for-planning.service.gov.uk/os-terms' + ] const pdfScenarios = [ - { - label: 'flood zone 1', - floodZone: '1', - polygon: floodZonedata.FZ1_With_RandS - }, - { - label: 'flood zone 2', - floodZone: '2', - polygon: floodZonedata.FZ2_With_RandS - }, - { - label: 'flood zone 3', - floodZone: '3', - polygon: floodZonedata.FZ3_With_SW_and_RandS - } + { label: 'flood zone 1', floodZone: '1', polygon: floodZonedata.FZ1_With_RandS }, + { label: 'flood zone 2', floodZone: '2', polygon: floodZonedata.FZ2_With_RandS }, + { label: 'flood zone 3', floodZone: '3', polygon: floodZonedata.FZ3_With_SW_and_RandS } ] for (const { label, floodZone, polygon } of pdfScenarios) { - const pdfSlug = `/results?encodedPolygon=${encodeURIComponent(polygon)}` - const pdfDownloadTimeoutMs = 60000 - - test.describe(`for ${label}`, () => { - test.beforeEach(async ({ page, steps }) => { - await steps.open({ - ...pages.results.pageWithZone(floodZone), - slug: pdfSlug - }) - await page.locator('.govuk-details__summary').first().click() - }) - - test('downloads pdf without reference text', async ({ steps, pdfDriver }) => { - const scale = '2500' - await steps.select(pages.results.scaleSelect, scale) - - const pdfPath = await pdfDriver.waitForDownload(async () => { - await steps.clickButton(pages.results.downloadFloodMapButton) - }, pdfDownloadTimeoutMs) - const pdf = await pdfDriver.parsePdf(pdfPath) - - await pdfDriver.expectCoreContent(pdf, { scale }) - await pdfDriver.expectFloodZone(pdf, floodZone) - await pdfDriver.expectLocation(pdf, polygon) - await pdfDriver.expectRequiredLinks(pdf, expectedPdfLinks) - await pdfDriver.expectAllLinksAreValid(pdf) - }) - - test('downloads pdf with reference text and amended scale', async ({ steps, pdfDriver }) => { - const referenceText = 'Test123456789101112131415' - const scale = '25000' - - const pdfPath = await pdfDriver.waitForDownload(async () => { - await steps.type(pages.results.addReferenceInput, referenceText) - await steps.select(pages.results.scaleSelect, scale) - await steps.clickButton(pages.results.downloadFloodMapButton) - }, pdfDownloadTimeoutMs) - const pdf = await pdfDriver.parsePdf(pdfPath) - - await pdfDriver.expectCoreContent(pdf, { reference: referenceText, scale }) - await pdfDriver.expectFloodZone(pdf, floodZone) - await pdfDriver.expectLocation(pdf, polygon) - await pdfDriver.expectRequiredLinks(pdf, expectedPdfLinks) - await pdfDriver.expectAllLinksAreValid(pdf) - }) + test(`downloads pdf with reference and scale for ${label}`, async ({ steps, pdfDriver }) => { + const reference = 'Test123456789101112131415' + const scale = '25000' + + await steps.open({ ...pages.results.pageWithZone(floodZone), slug: slug(polygon) }) + await steps.clickDetails(pages.results.addReferenceToFloodMapDetails) + await steps.type(pages.results.addReferenceInput, reference) + await steps.select(pages.results.scaleSelect, scale) + + const downloadPromise = pdfDriver.awaitDownload() + await steps.clickButton(pages.results.downloadFloodMapButton) + const pdf = await pdfDriver.parsePdf(await downloadPromise) + + pdfDriver.expectPdfContent(pdf, { reference, scale, floodZone, polygon, expectedLinks: expectedPdfLinks }) }) } + + test('defaults to unspecified reference when none provided', async ({ steps, pdfDriver }) => { + const polygon = floodZonedata.FZ1_With_RandS + const scale = '2500' + + await steps.open({ ...pages.results.pageWithZone('1'), slug: slug(polygon) }) + await steps.clickDetails(pages.results.addReferenceToFloodMapDetails) + await steps.select(pages.results.scaleSelect, scale) + + const downloadPromise = pdfDriver.awaitDownload() + await steps.clickButton(pages.results.downloadFloodMapButton) + const pdf = await pdfDriver.parsePdf(await downloadPromise) + + pdfDriver.expectPdfContent(pdf, { scale, floodZone: '1', polygon, expectedLinks: expectedPdfLinks }) + }) }) })