mirror of
https://gitlab.com/PronounsPage/PronounsPage.git
synced 2025-08-03 11:07:00 -04:00
439 lines
16 KiB
TypeScript
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.
|
|
);
|
|
});
|
|
}
|