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: '
' }, // 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[] { const requiredParams = params.filter((p) => !p.optional); const optionalParams = params.filter((p) => p.optional); const optionalParamCombos = generatePowerSet(optionalParams); const paramCombinations: Record[] = []; for (const optionalSubset of optionalParamCombos) { const paramsObj: Record = {}; // 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 | 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>/, '') // 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=') // Remove /init-universal/... from URLs. .replace(/(href|src)="([^"]*)\/init-universal\/[^"&#]*/g, '$1="$2/init-universal/'); 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. ); }); }