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
21 changes: 12 additions & 9 deletions dotcom-rendering/playwright.config.ts
Original file line number Diff line number Diff line change
@@ -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.
Expand All @@ -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
Expand All @@ -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,
});
244 changes: 165 additions & 79 deletions dotcom-rendering/playwright/lib/load-page.ts
Original file line number Diff line number Diff line change
@@ -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<string, string>;
queryParamsOn?: boolean;
fragment?: `#${string}`;
waitUntil?: 'domcontentloaded' | 'load';
region?: 'GB' | 'US' | 'AU' | 'INT';
preventSupportBanner?: boolean;
overrides?: {
configOverrides?: Record<string, unknown>;
switchOverrides?: Record<string, unknown>;
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<unknown> => {
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<LoadPageParams, 'path' | 'queryParamsOn' | 'queryParams'>): 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
Expand All @@ -17,16 +139,8 @@ const loadPage = async ({
waitUntil = 'domcontentloaded',
region = 'GB',
preventSupportBanner = true,
}: {
page: Page;
path: string;
queryParams?: Record<string, string>;
queryParamsOn?: boolean;
fragment?: `#${string}`;
waitUntil?: 'domcontentloaded' | 'load';
region?: 'GB' | 'US' | 'AU' | 'INT';
preventSupportBanner?: boolean;
}): Promise<void> => {
overrides = {},
}: LoadPageParams): Promise<void> => {
await page.addInitScript(
(args) => {
// Set the geo region, defaults to GB
Expand All @@ -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<string, unknown>;
switchOverrides?: Record<string, unknown>;
},
): Promise<void> => {
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<string, unknown>;
switchOverrides?: Record<string, unknown>;
},
): Promise<void> => {
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 };
Loading