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