diff --git a/dotcom-rendering/playwright.config.ts b/dotcom-rendering/playwright.config.ts index d9e01b4cf88..a7dcaea7d5d 100644 --- a/dotcom-rendering/playwright.config.ts +++ b/dotcom-rendering/playwright.config.ts @@ -1,10 +1,12 @@ import { defineConfig, devices } from '@playwright/test'; const isDev = process.env.NODE_ENV !== 'production'; + /** * The server port for local development or CI */ export const PORT = isDev ? 3030 : 9000; +export const ORIGIN = `http://localhost:${PORT}`; /** * See https://playwright.dev/docs/test-configuration. @@ -19,7 +21,7 @@ export default defineConfig({ // Retry on CI only retries: process.env.CI ? 3 : 1, // Workers run tests files in parallel - workers: process.env.CI ? 4 : undefined, + workers: process.env.CI ? 4 : 2, // Reporter to use. See https://playwright.dev/docs/test-reporters reporter: [['line'], ['html', { open: 'never' }]], // Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions @@ -38,12 +40,13 @@ export default defineConfig({ use: { ...devices['Desktop Chrome'] }, }, ], - webServer: { - // On CI the server is already started so a no-op - command: isDev ? 'make dev' : ':', - url: `http://localhost:${PORT}`, - reuseExistingServer: true, - stdout: 'pipe', - stderr: 'pipe', - }, + webServer: isDev + ? { + command: 'make dev', + url: ORIGIN, + reuseExistingServer: true, + stdout: 'pipe', + stderr: 'pipe', + } + : undefined, }); diff --git a/dotcom-rendering/playwright/lib/load-page.ts b/dotcom-rendering/playwright/lib/load-page.ts index d28f6aa96bf..e8c9620148f 100644 --- a/dotcom-rendering/playwright/lib/load-page.ts +++ b/dotcom-rendering/playwright/lib/load-page.ts @@ -1,9 +1,131 @@ -import type { Page } from '@playwright/test'; -import { PORT } from 'playwright.config'; +import type { Cookie, Page } from '@playwright/test'; +import { ORIGIN } from '../../playwright.config'; import type { FEArticle } from '../../src/frontend/feArticle'; -import { validateAsFEArticle } from '../../src/model/validate'; +import type { FEFront } from '../../src/frontend/feFront'; +import { + validateAsFEArticle, + validateAsFEFront, +} from '../../src/model/validate'; -const BASE_URL = `http://localhost:${PORT}`; +type LoadPageOptions = { + queryParams?: Record; + queryParamsOn?: boolean; + fragment?: `#${string}`; + waitUntil?: 'domcontentloaded' | 'load'; + region?: 'GB' | 'US' | 'AU' | 'INT'; + preventSupportBanner?: boolean; + overrides?: { + configOverrides?: Record; + switchOverrides?: Record; + feFixture?: FEArticle | FEFront; + }; +}; + +type LoadPageParams = { + page: Page; + path: string; +} & LoadPageOptions; + +/** + * @param path The path for a DCR endpoint path + * e.g. `/Article/https://www.theguardian.com/world/2025/aug/19/the-big-church-move-sweden-kiruna-kyrka` + * @returns The Frontend URL to fetch the JSON payload + * e.g. `https://www.theguardian.com/world/2025/aug/19/the-big-church-move-sweden-kiruna-kyrka.json` + */ +const getFrontendJsonUrl = (path: string) => { + const secondSlashIndex = path.indexOf('/', 1); + const contentUrl = path.substring(secondSlashIndex + 1); + return `${contentUrl}.json`; +}; + +/** + * @param path The path for a DCR endpoint path + * e.g. `/Article/https://www.theguardian.com/world/2025/aug/19/the-big-church-move-sweden-kiruna-kyrka` + * @param cookies Cookies to send with the request + * e.g. `GU_EDITION=US` + * @param queryParams Query parameters to append to the request + * e.g. `live=true` for live blogs + * @returns The JSON response from the Frontend URL + */ +const getFrontendJson = async ( + path: string, + cookies: Cookie[], + queryParams: LoadPageParams['queryParams'], +): Promise => { + try { + const paramsString = `${new URLSearchParams({ + dcr: 'true', + ...queryParams, + }).toString()}`; + const frontendUrl = `${getFrontendJsonUrl(path)}?${paramsString}`; + const cookie = cookies.map((c) => `${c.name}=${c.value}`).join('; '); + const response = await fetch(frontendUrl, { headers: { cookie } }); + if (!response.ok) { + throw new Error( + `Failed to fetch from ${path}: ${response.statusText}`, + ); + } + return response.json(); + } catch (error) { + throw new Error( + `Error fetching from ${path}: ${ + error instanceof Error ? error.message : String(error) + }`, + ); + } +}; + +/** + * Validates the JSON response from the Frontend URL based on the path. + + * Add more validation logic here if additional content types are required. + * + * @param path The path for a DCR endpoint, used to determine the content type. + * e.g. `/Article/https://www.theguardian.com/world/2025/aug/19/the-big-church-move-sweden-kiruna-kyrka` + * @param json The JSON response from the Frontend URL + * @returns The validated `FEArticle` or `FEFront` object + */ +const validateJson = (path: string, json: unknown): FEArticle | FEFront => { + if (path.startsWith('/Article')) { + return validateAsFEArticle(json); + } else if (path.startsWith('/Front')) { + return validateAsFEFront(json); + } + throw new Error(`Unsupported URL for validating payload for: ${path}`); +}; + +/** + * Constructs a DCR URL for a given path and query parameters. + * @param params The parameters for constructing the DCR URL + * @param params.path The path for a DCR endpoint + * @param params.queryParamsOn Whether to append query parameters to the URL + * @param params.queryParams Query parameters to append to the request + * @returns The DCR URL + * e.g. `http://localhost:9000/Article/https://theguardian.com/sport/live/2022/mar/27/west-indies-v-england-third-test-day-four-live?adtest=fixed-puppies-ci&live=true&force-liveblog-epic=true` + */ +const getDcrUrl = ({ + path, + queryParamsOn, + queryParams, +}: Pick): string => { + const paramsString = queryParamsOn + ? `?${new URLSearchParams({ + adtest: 'fixed-puppies-ci', + ...queryParams, + }).toString()}` + : ''; + return `${ORIGIN}${path}${paramsString}`; +}; + +/** + * Constructs a DCR POST URL for a given path. + * @param path The path for a DCR endpoint + * e.g. `/Article/https://www.theguardian.com/world/2025/aug/19/the-big-church-move-sweden-kiruna-kyrka` + * @returns The DCR POST URL to send the request to + * e.g. `http://localhost:9000/Article` + * This is used to override the request method to POST in Playwright tests. + */ +const getDcrPostUrl = (path: string) => `${ORIGIN}/${path.split('/')[1]}`; /** * Loads a page in Playwright and centralises setup @@ -17,16 +139,8 @@ const loadPage = async ({ waitUntil = 'domcontentloaded', region = 'GB', preventSupportBanner = true, -}: { - page: Page; - path: string; - queryParams?: Record; - queryParamsOn?: boolean; - fragment?: `#${string}`; - waitUntil?: 'domcontentloaded' | 'load'; - region?: 'GB' | 'US' | 'AU' | 'INT'; - preventSupportBanner?: boolean; -}): Promise => { + overrides = {}, +}: LoadPageParams): Promise => { await page.addInitScript( (args) => { // Set the geo region, defaults to GB @@ -47,82 +161,54 @@ const loadPage = async ({ preventSupportBanner, }, ); - // Add an adtest query param to ensure we get a fixed test ad - const paramsString = queryParamsOn - ? `?${new URLSearchParams({ - adtest: 'fixed-puppies-ci', - ...queryParams, - }).toString()}` - : ''; - // The default Playwright waitUntil: 'load' ensures all requests have completed - // Use 'domcontentloaded' to speed up tests and prevent hanging requests from timing out tests - await page.goto(`${BASE_URL}${path}${paramsString}${fragment ?? ''}`, { - waitUntil, - }); -}; + const cookies = await page.context().cookies(); -/** - * Create a POST request to the /Article endpoint so we can override config - * and switches in the json sent to DCR - */ -const loadPageWithOverrides = async ( - page: Page, - article: FEArticle, - overrides?: { - configOverrides?: Record; - switchOverrides?: Record; - }, -): Promise => { - const path = `/Article`; - await page.route(`${BASE_URL}${path}`, async (route) => { - const postData = { - ...article, - config: { - ...article.config, - ...overrides?.configOverrides, - switches: { - ...article.config.switches, - ...overrides?.switchOverrides, - }, + // If overrides exist, but no fixture is provided we fetch it from Frontend + const frontendModel = await (overrides.feFixture + ? Promise.resolve(overrides.feFixture) + : validateJson( + path, + await getFrontendJson(path, cookies, queryParams), + )); + + // Apply the config and switch overrides + const postData = { + ...frontendModel, + config: { + ...frontendModel.config, + ...overrides.configOverrides, + switches: { + ...frontendModel.config.switches, + ...overrides.switchOverrides, }, - }; + }, + }; + + const dcrUrl = getDcrUrl({ + path, + queryParamsOn, + queryParams, + }); + + // Override any request matching dcrUrl to use a POST method + // with the overridden payload + await page.route(dcrUrl, async (route) => { await route.continue({ method: 'POST', headers: { + ...route.request().headers(), 'Content-Type': 'application/json', }, postData, + url: getDcrPostUrl(path), }); }); - await loadPage({ page, path, queryParamsOn: false }); -}; -/** - * Fetch the page json from PROD then load it as a POST with overrides - */ -const fetchAndloadPageWithOverrides = async ( - page: Page, - url: string, - overrides?: { - configOverrides?: Record; - switchOverrides?: Record; - }, -): Promise => { - const article = validateAsFEArticle( - await fetch(`${url}.json?dcr`).then((res) => res.json()), - ); - await loadPageWithOverrides(page, article, { - configOverrides: overrides?.configOverrides, - switchOverrides: { - ...overrides?.switchOverrides, - }, - }); + // Initiate the page load + // Add the fragment here as Playwright has an issue when matching urls + // with fragments in the page.route handler + await page.goto(`${dcrUrl}${fragment ?? ''}`, { waitUntil }); }; -export { - BASE_URL, - fetchAndloadPageWithOverrides, - loadPage, - loadPageWithOverrides, -}; +export { loadPage }; diff --git a/dotcom-rendering/playwright/tests/atom.video.e2e.spec.ts b/dotcom-rendering/playwright/tests/atom.video.e2e.spec.ts index bd32e128d5d..80cb6ed9a2a 100644 --- a/dotcom-rendering/playwright/tests/atom.video.e2e.spec.ts +++ b/dotcom-rendering/playwright/tests/atom.video.e2e.spec.ts @@ -3,7 +3,7 @@ import type { Page } from '@playwright/test'; import { expect, test } from '@playwright/test'; import { allowRejectAll, cmpAcceptAll, cmpRejectAll } from '../lib/cmp'; import { waitForIsland } from '../lib/islands'; -import { fetchAndloadPageWithOverrides } from '../lib/load-page'; +import { loadPage } from '../lib/load-page'; import { expectToBeVisible, expectToNotExist } from '../lib/locators'; type YouTubeEmbedConfig = { @@ -122,11 +122,11 @@ const muteYouTube = async (page: Page, iframeSelector: string) => { test.describe.skip('YouTube Atom', () => { // Skipping because the video in this article has stopped working. Investigation needed! test.skip('plays main media video: skipped', async ({ page }) => { - await fetchAndloadPageWithOverrides( + await loadPage({ page, - 'https://www.theguardian.com/uk-news/2020/dec/04/edinburgh-hit-by-thundersnow-as-sonic-boom-wakes-residents', - { switchOverrides: { youtubeIma: false } }, - ); + path: '/Article/https://www.theguardian.com/uk-news/2020/dec/04/edinburgh-hit-by-thundersnow-as-sonic-boom-wakes-residents', + overrides: { switchOverrides: { youtubeIma: false } }, + }); await cmpAcceptAll(page); await waitForIsland(page, 'YoutubeBlockComponent'); @@ -173,11 +173,12 @@ test.describe.skip('YouTube Atom', () => { }); test.skip('plays main media video', async ({ page }) => { - await fetchAndloadPageWithOverrides( + await loadPage({ page, - 'https://www.theguardian.com/us-news/article/2024/may/30/trump-trial-hush-money-verdict', - { switchOverrides: { youtubeIma: false } }, - ); + path: '/Article/https://www.theguardian.com/us-news/article/2024/may/30/trump-trial-hush-money-verdict', + overrides: { switchOverrides: { youtubeIma: false } }, + }); + await cmpAcceptAll(page); await waitForIsland(page, 'YoutubeBlockComponent'); @@ -224,11 +225,11 @@ test.describe.skip('YouTube Atom', () => { }); test.skip('plays in body video', async ({ page }) => { - await fetchAndloadPageWithOverrides( + await loadPage({ page, - 'https://www.theguardian.com/environment/2021/oct/05/volcanoes-are-life-how-the-ocean-is-enriched-by-eruptions-devastating-on-land', - { switchOverrides: { youtubeIma: false } }, - ); + path: '/Article/https://www.theguardian.com/environment/2021/oct/05/volcanoes-are-life-how-the-ocean-is-enriched-by-eruptions-devastating-on-land', + overrides: { switchOverrides: { youtubeIma: false } }, + }); await cmpAcceptAll(page); await waitForIsland(page, 'YoutubeBlockComponent'); @@ -277,11 +278,11 @@ test.describe.skip('YouTube Atom', () => { test('each video plays when the same video exists both in body and in main media of a blog', async ({ page, }) => { - await fetchAndloadPageWithOverrides( + await loadPage({ page, - 'https://www.theguardian.com/world/live/2022/mar/28/russia-ukraine-war-latest-news-zelenskiy-putin-live-updates', - { switchOverrides: { youtubeIma: false } }, - ); + path: '/Article/https://www.theguardian.com/world/live/2022/mar/28/russia-ukraine-war-latest-news-zelenskiy-putin-live-updates', + overrides: { switchOverrides: { youtubeIma: false } }, + }); await cmpAcceptAll(page); // Wait for hydration of all videos @@ -387,11 +388,11 @@ test.describe.skip('YouTube Atom', () => { }) => { await allowRejectAll(context); - await fetchAndloadPageWithOverrides( + await loadPage({ page, - 'https://www.theguardian.com/environment/2021/oct/05/volcanoes-are-life-how-the-ocean-is-enriched-by-eruptions-devastating-on-land', - { switchOverrides: { youtubeIma: false } }, - ); + path: '/Article/https://www.theguardian.com/environment/2021/oct/05/volcanoes-are-life-how-the-ocean-is-enriched-by-eruptions-devastating-on-land', + overrides: { switchOverrides: { youtubeIma: false } }, + }); await cmpRejectAll(page); @@ -441,11 +442,11 @@ test.describe.skip('YouTube Atom', () => { test('video is sticky when the user plays a video then scrolls the video out of the viewport', async ({ page, }) => { - await fetchAndloadPageWithOverrides( + await loadPage({ page, - 'https://www.theguardian.com/world/live/2022/mar/28/russia-ukraine-war-latest-news-zelenskiy-putin-live-updates', - { switchOverrides: { youtubeIma: false } }, - ); + path: '/Article/https://www.theguardian.com/world/live/2022/mar/28/russia-ukraine-war-latest-news-zelenskiy-putin-live-updates', + overrides: { switchOverrides: { youtubeIma: false } }, + }); await cmpAcceptAll(page); // Wait for hydration of all videos diff --git a/dotcom-rendering/playwright/tests/lightbox.e2e.spec.ts b/dotcom-rendering/playwright/tests/lightbox.e2e.spec.ts index a5fcdc01923..5b902439b82 100644 --- a/dotcom-rendering/playwright/tests/lightbox.e2e.spec.ts +++ b/dotcom-rendering/playwright/tests/lightbox.e2e.spec.ts @@ -3,7 +3,7 @@ import { expect, test } from '@playwright/test'; import { Live as LiveBlog } from '../../fixtures/generated/fe-articles/Live'; import { PhotoEssay as photoEssayArticle } from '../../fixtures/generated/fe-articles/PhotoEssay'; import { disableCMP } from '../lib/cmp'; -import { loadPageWithOverrides } from '../lib/load-page'; +import { loadPage } from '../lib/load-page'; import { expectToBeVisible, expectToNotBeVisible } from '../lib/locators'; // LIGHTBOX RL notes @@ -74,7 +74,11 @@ test.describe('Lightbox', () => { page, }) => { await disableCMP(context); - await loadPageWithOverrides(page, photoEssayArticle); + await loadPage({ + page, + path: '/Article', + overrides: { feFixture: photoEssayArticle }, + }); await expectToNotBeVisible(page, '#gu-lightbox'); @@ -95,7 +99,11 @@ test.describe('Lightbox', () => { page, }) => { await disableCMP(context); - await loadPageWithOverrides(page, photoEssayArticle); + await loadPage({ + page, + path: '/Article', + overrides: { feFixture: photoEssayArticle }, + }); await expectToNotBeVisible(page, '#gu-lightbox'); @@ -113,7 +121,11 @@ test.describe('Lightbox', () => { test('should trap focus', async ({ context, page }) => { await disableCMP(context); - await loadPageWithOverrides(page, photoEssayArticle); + await loadPage({ + page, + path: '/Article', + overrides: { feFixture: photoEssayArticle }, + }); await page.locator('article img').first().click({ force: true }); await expectToBeVisible(page, '#gu-lightbox'); @@ -158,7 +170,11 @@ test.describe('Lightbox', () => { page, }) => { await disableCMP(context); - await loadPageWithOverrides(page, photoEssayArticle); + await loadPage({ + page, + path: '/Article', + overrides: { feFixture: photoEssayArticle }, + }); await expectToNotBeVisible(page, '#gu-lightbox'); @@ -246,7 +262,11 @@ test.describe('Lightbox', () => { } await disableCMP(context); - await loadPageWithOverrides(page, photoEssayArticle); + await loadPage({ + page, + path: '/Article', + overrides: { feFixture: photoEssayArticle }, + }); // eq(6) here means the 7th button is clicked (base zero) await page.locator('button.open-lightbox').nth(6).click(); @@ -285,7 +305,11 @@ test.describe('Lightbox', () => { page, }) => { await disableCMP(context); - await loadPageWithOverrides(page, photoEssayArticle); + await loadPage({ + page, + path: '/Article', + overrides: { feFixture: photoEssayArticle }, + }); await page.locator('button.open-lightbox').nth(1).click(); await expectToBeVisible(page, '#gu-lightbox'); @@ -332,7 +356,11 @@ test.describe('Lightbox', () => { page, }) => { await disableCMP(context); - await loadPageWithOverrides(page, photoEssayArticle); + await loadPage({ + page, + path: '/Article', + overrides: { feFixture: photoEssayArticle }, + }); await page.locator('button.open-lightbox').nth(1).click(); await expectToBeVisible(page, '#gu-lightbox'); @@ -374,7 +402,11 @@ test.describe('Lightbox', () => { page, }) => { await disableCMP(context); - await loadPageWithOverrides(page, LiveBlog); + await loadPage({ + page, + path: '/Article', + overrides: { feFixture: LiveBlog }, + }); await page.locator('button.open-lightbox').nth(1).click(); await expectToBeVisible(page, '#gu-lightbox'); @@ -401,7 +433,11 @@ test.describe('Lightbox', () => { page, }) => { await disableCMP(context); - await loadPageWithOverrides(page, photoEssayArticle); + await loadPage({ + page, + path: '/Article', + overrides: { feFixture: photoEssayArticle }, + }); await page.locator('button.open-lightbox').nth(1).click(); await expectToBeVisible(page, '#gu-lightbox'); @@ -431,7 +467,11 @@ test.describe('Lightbox', () => { page, }) => { await disableCMP(context); - await loadPageWithOverrides(page, photoEssayArticle); + await loadPage({ + page, + path: '/Article', + overrides: { feFixture: photoEssayArticle }, + }); await expectToNotBeVisible(page, '#gu-lightbox'); // Open lightbox using the second button on the page (the first is main media) diff --git a/dotcom-rendering/playwright/tests/signedin.e2e.spec.ts b/dotcom-rendering/playwright/tests/signed-out.e2e.spec.ts similarity index 78% rename from dotcom-rendering/playwright/tests/signedin.e2e.spec.ts rename to dotcom-rendering/playwright/tests/signed-out.e2e.spec.ts index 11fb1b014ac..30b0b3748f6 100644 --- a/dotcom-rendering/playwright/tests/signedin.e2e.spec.ts +++ b/dotcom-rendering/playwright/tests/signed-out.e2e.spec.ts @@ -2,15 +2,19 @@ import { expect, test } from '@playwright/test'; import { Standard as standardArticle } from '../../fixtures/generated/fe-articles/Standard'; import { disableCMP } from '../lib/cmp'; import { waitForIsland } from '../lib/islands'; -import { loadPageWithOverrides } from '../lib/load-page'; +import { loadPage } from '../lib/load-page'; -test.describe('Signed in readers', () => { +test.describe('Signed out readers', () => { test('should not display signed in texts when users are not signed in', async ({ context, page, }) => { await disableCMP(context); - await loadPageWithOverrides(page, standardArticle); + await loadPage({ + page, + path: '/Article', + overrides: { feFixture: standardArticle }, + }); await waitForIsland(page, 'DiscussionWeb');