PronounsPage/test/snapshot.test.ts

439 lines
16 KiB
TypeScript

import { readdirSync, mkdirSync } from 'fs';
import { readdir, rm, writeFile } from 'fs/promises';
import { join } from 'path';
import { promisify } from 'util';
import { chromium } from '@playwright/test';
import type { Browser, Page } from '@playwright/test';
import { execa } from 'execa';
import type { ResultPromise } from 'execa';
import tkKill from 'tree-kill';
import { describe, it, beforeAll, afterAll, expect } from 'vitest';
import { createRouter, createMemoryHistory } from 'vue-router';
import type { RouteRecordRaw } from 'vue-router';
const kill = promisify(tkKill);
/**
* Function to generate sample routes for a given route path using Vue Router.
* Initializes a new Vue Router instance per route path.
*/
function generateSampleRoutes(routePath: string): string[] {
// Ensure unique route names by appending a random string
const routeName = `TestRoute${Math.random().toString(36)
.substring(2, 15)}`;
const routeRecord: RouteRecordRaw = {
path: routePath,
name: routeName,
component: { template: '<div></div>' }, // Dummy component
};
const router = createRouter({
history: createMemoryHistory(),
routes: [routeRecord],
});
const routeMatcher = router.getRoutes().find((r) => r.name === routeName);
if (!routeMatcher) {
throw new Error(`Route not found for path: ${routePath}`);
}
// Extract route parameters from the path.
const paramNames: RouteParameter[] = extractRouteParams(routePath);
// Generate combinations of parameters including permutations of optional parameters.
const paramCombinations = generateParamCombinations(paramNames);
const sampleRoutes: string[] = [];
for (const paramsObj of paramCombinations) {
const resolvedRoute = router.resolve({ name: routeName, params: paramsObj });
sampleRoutes.push(resolvedRoute.path);
}
return sampleRoutes;
}
interface RouteParameter {
name: string;
pattern: string | null;
modifier: string;
optional: boolean;
}
/**
* Extracts route parameters from the route path using regular expressions.
*/
function extractRouteParams(path: string): RouteParameter[] {
const dynamicSegmentRegex = /:([a-zA-Z0-9_]+)(?:\(([^)]+)\))?([+*?])?/g;
const params: RouteParameter[] = [];
let match;
while ((match = dynamicSegmentRegex.exec(path)) !== null) {
const paramName = match[1]; // Group 1: Parameter name.
const pattern = match[2] || null; // Group 2: Pattern inside parentheses, if any.
const modifier = match[3] || ''; // Group 3: Modifier (e.g., '?', '*', '+'), if any.
const isOptional = modifier === '?' || modifier === '*';
params.push({
name: paramName,
pattern,
modifier,
optional: isOptional,
});
}
return params;
}
/**
* Generates all combinations of parameters, including permutations of optional
* parameters.
*/
function generateParamCombinations(params: RouteParameter[]): Record<string, string | undefined>[] {
const requiredParams = params.filter((p) => !p.optional);
const optionalParams = params.filter((p) => p.optional);
const optionalParamCombos = generatePowerSet(optionalParams);
const paramCombinations: Record<string, string | undefined>[] = [];
for (const optionalSubset of optionalParamCombos) {
const paramsObj: Record<string, string | undefined> = {};
// Add required parameters
for (const param of requiredParams) {
paramsObj[param.name] = generateSampleValueForParamName(param.name);
}
// Add the optional parameters included in this subset
for (const param of optionalSubset) {
paramsObj[param.name] = generateSampleValueForParamName(param.name);
}
paramCombinations.push(paramsObj);
}
return paramCombinations;
}
/**
* Generates the power set of an array of optional parameters.
*/
function generatePowerSet(array: RouteParameter[]): RouteParameter[][] {
const result: RouteParameter[][] = [[]];
for (const item of array) {
const length = result.length;
for (let i = 0; i < length; i++) {
result.push(result[i].concat(item));
}
}
return result;
}
/**
* Generates sample values for route parameters based on their names.
*/
function generateSampleValueForParamName(paramName: string): string {
// Basic heuristic to generate sample values based on parameter name.
if (/id$/i.test(paramName)) {
return '123';
} else if (/year/i.test(paramName)) {
return '2025';
} else if (/month/i.test(paramName)) {
return '01';
} else if (/day/i.test(paramName)) {
return '01';
} else if (/username/i.test(paramName)) {
return 'admin';
} else if (/slug|path|url|name/i.test(paramName)) {
return 'example';
} else {
return 'sample';
}
}
// Fetch locales
function getLocales() {
console.debug('Fetching locales...');
const testLocalesEnv = process.env.TEST_LOCALES;
let localesToTest: string[];
if (testLocalesEnv && testLocalesEnv.trim().length > 0) {
// Use the specified locales from the environment variable.
localesToTest = testLocalesEnv.trim().split(/\s+/);
console.debug(`Locales specified via TEST_LOCALES: ${localesToTest.join(', ')}`);
} else {
// Discover all locales in the locale directory.
const localeDir = join(__dirname, '..', 'locale');
const localeDirs = readdirSync(localeDir, { withFileTypes: true })
.filter((name) => {
return name.isDirectory() && !name.name.startsWith('_');
});
localesToTest = localeDirs.map((dir) => dir.name);
console.debug(`Locales discovered: ${localesToTest.join(', ')}`);
}
// Create snapshots directory for each locale.
for (const locale of localesToTest) {
const snapshotDir = join(__dirname, '__snapshots__', locale);
mkdirSync(snapshotDir, { recursive: true });
}
return localesToTest;
}
// If the test suite is interrupted, kill the servers.
const killHandler = async () => {
for (const server of runningServers) {
const pid = server?.pid;
if (typeof pid === 'number') {
console.debug('Received interrupt signal. Killing server process...');
await kill(pid);
console.debug('Server process killed.');
}
}
};
process
.on('SIGINT', killHandler)
.on('SIGTERM', killHandler)
.on('uncaughtException', killHandler);
// Initialize.
let locales: string[] = [];
const shouldRunSnapshotTests = process.env.RUN_SNAPSHOT_TESTS === 'true';
if (!shouldRunSnapshotTests) {
// Instead of passing shouldRunSnapshotTests to skip, we manually skip the
// test suite here with a fake test. We do this since the describe blocks
// are generated dynamically.
describe(
'Snapshot tests can only be run with the RUN_SNAPSHOT_TESTS environment variable set to true.',
{ skip: true },
() => {
it('should skip snapshot tests', () => {
// Empty test to avoid "no tests found" error.
});
},
);
} else {
locales = getLocales();
// Run tests per locale in parallel.
locales.forEach((locale, index) => {
runTestsForLocale(locale, index);
});
}
const runningServers: Array<ResultPromise<{ stdio: 'inherit' }> | null> = [];
/**
* Runs tests for a specific locale.
*/
function runTestsForLocale(locale: string, index: number) {
describe(`Snapshot tests for locale: ${locale}`, () => {
let browser: Browser;
let page: Page;
let serverProcess: ResultPromise<{
stdio: 'inherit';
}> | null = null;
const port = 3000 + index;
const routesToTest: string[] = [];
beforeAll(async () => {
// Delete all .error.html files in the snapshot directory.
const errorFiles = (await readdir(join(__dirname, '__snapshots__', locale)))
.filter((file) => file.endsWith('.error.html'));
for (const file of errorFiles) {
await rm(join(__dirname, '__snapshots__', locale, file));
}
serverProcess = execa('node', [join(__dirname, '..', `.output`, 'server', 'index.mjs')], {
env: {
...process.env,
PORT: port.toString(),
APP_ENV: 'development',
RUN_SNAPSHOT_TESTS: '1',
},
shell: true,
detached: true,
stdio: 'inherit',
});
runningServers.push(serverProcess);
// Launch the browser and create a page.
console.debug('Launching Playwright Chromium...');
browser = await chromium.launch();
page = await browser.newPage();
// Poll for readiness.
console.debug('Waiting for the server to be ready...');
let ready = false;
for (let i = 0; i < 30; i++) {
try {
// Attempt to navigate to the site.
await page.goto(`http://${locale}.localhost:${port}`, { waitUntil: 'networkidle' });
const content = await page.content();
if (content.includes('Nuxt is loading')) {
console.debug('Nuxt is still loading. Retrying...');
continue;
}
const routes = await page.evaluate(() => {
return window.__NUXT_ROUTES__;
});
if (!routes?.length) {
console.debug('No routes discovered. Retrying...');
continue;
}
console.debug(`Routes discovered: ${routes.join(', ')}`);
// Store the routes for use in tests.
routesToTest.push(...routes.flatMap(generateSampleRoutes));
console.debug(`Routes to test: ${routesToTest.join(', ')}`);
ready = true;
break;
} catch (err: unknown) {
// If error was anything other than a connection error or a
// timeout, throw it.
if (
!err ||
(
!err.toString().includes('ERR_CONNECTION_REFUSED') &&
!err.toString().includes('TimeoutError')
)
) {
throw err;
}
}
// Wait 1 second and try again.
await new Promise((r) => setTimeout(r, 1000));
}
expect(ready).toBe(true);
// Set the `token` cookie using TEST_JWT_TOKEN.
const token = process.env.TEST_JWT_TOKEN;
if (token) {
console.debug('Setting the token cookie...');
await page.context().addCookies([
{
name: 'token',
value: token,
domain: 'localhost',
path: '/',
},
]);
await page.evaluate((token) => {
localStorage.setItem('account-tokens', token);
}, token);
}
}, 120_000);
afterAll(async () => {
console.debug('Closing the page...');
await page.close();
console.debug('Closing the browser...');
await browser.close();
const pid = serverProcess?.pid;
if (typeof pid === 'number') {
console.debug('Killing the server process...');
serverProcess?.catch(() => { /* ignore */ });
await kill(pid);
console.debug('Server process killed.');
serverProcess = null;
}
}, 30_000);
it(
`renders all routes correctly in locale '${locale}'`,
async () => {
const errors: Error[] = [];
for (const route of routesToTest) {
console.debug(`Navigating to route '${route}'...`);
// Navigate to the route.
await page.goto(`http://localhost:${port}${route}`);
await page.waitForLoadState('networkidle');
// Wait for #__nuxt to be present and non-empty (i.e. not
// just empty or whitespace).
await page.waitForFunction(
() => {
const el = document.getElementById('__nuxt');
return Boolean(el?.textContent?.trim().length);
},
);
console.debug(`Capturing HTML content for route '${route}'...`);
// Capture the HTML
const html = (await page.$eval('#__nuxt', (el) => el.innerHTML))
// Remove data-v-hash attributes.
?.replace(/ data-v-\w+(?:="[^"]*")?/g, '')
// Remove nuxt-loading-indicator div.
.replace(/<div class="nuxt-loading-indicator"[^>]*>.*?<\/div>/, '')
// Remove style attributes on columnist elements.
.replace(/<\w+[^>]*class="[^"]*columnist[^"]*"[^>]*>/g, (match) => {
return match.replace(/ style="[^"]*"/, '');
})
// Remove token=... from URLs.
.replace(/(href|src)="([^"]*)token=[^"&#]*/g, '$1="$2token=<token>')
// Remove /init-universal/... from URLs.
.replace(/(href|src)="([^"]*)\/init-universal\/[^"&#]*/g, '$1="$2/init-universal/<token>');
const snapshotBaseName = join(
__dirname,
'__snapshots__',
locale,
// Remove any non-path-safe characters from the route.
route
// URL-encode slashes.
.replaceAll('/', '%2F')
// Replace \:*?"<>| with _.
.replace(/[\\:*?"<>|]/g, '_')
// Remove non-printable characters.
// eslint-disable-next-line no-control-regex
.replace(/[\x00-\x1F\x7F-\x9F]/g, ''),
);
// Snapshot test with custom message.
try {
await expect(html, `Snapshot for route '${route}'`)
.toMatchFileSnapshot(
`${snapshotBaseName}.html`,
`locale-${locale}/${route}`,
);
console.debug(`Snapshot test passed for route '${route}'.`);
} catch (err: unknown) {
errors.push(err as Error);
await writeFile(`${snapshotBaseName}.error.html`, html);
console.error(`Snapshot test failed for route '${route}':`, err);
}
}
if (errors.length > 0) {
let errMessage = `Snapshot test failed for ${errors.length} routes.\n\n`;
for (const err of errors) {
errMessage += `${err}\n\n`;
}
throw new Error(errMessage);
}
console.debug('All snapshot tests passed.');
},
routesToTest.length * 20_000, // 20 seconds per route.
);
});
}