Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions e2e/pages/.utils/form-controls.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 })
4 changes: 2 additions & 2 deletions e2e/pages/next-steps.page.js
Original file line number Diff line number Diff line change
@@ -1,13 +1,13 @@
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',
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 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)')
Expand Down
4 changes: 2 additions & 2 deletions e2e/pages/results.page.js
Original file line number Diff line number Diff line change
@@ -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({
Expand All @@ -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)')
Expand Down
4 changes: 4 additions & 0 deletions e2e/test-runner-api/form-driver.js
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
107 changes: 24 additions & 83 deletions e2e/test-runner-api/pdf-driver.js
Original file line number Diff line number Diff line change
@@ -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---- //
Expand All @@ -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
Expand All @@ -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)
}
}
79 changes: 0 additions & 79 deletions e2e/tests/pages/next-steps-page.spec.js
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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')
Expand Down Expand Up @@ -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)
})
})
}
})
})
Loading
Loading