178 lines
7.4 KiB
TypeScript
178 lines
7.4 KiB
TypeScript
import { createHash, randomBytes } from 'node:crypto';
|
|
import { execFile } from 'node:child_process';
|
|
import { copyFile, mkdir, readFile, readdir, rm, writeFile } from 'node:fs/promises';
|
|
import { resolve } from 'node:path';
|
|
import { promisify } from 'node:util';
|
|
import { expect, type FullConfig, type Page } from '@playwright/test';
|
|
import type { PortfolioFaultId } from '@/testing/fault-hooks';
|
|
|
|
const execute = promisify(execFile);
|
|
const repositoryRoot = process.cwd();
|
|
const temporaryClientRoot = resolve(repositoryRoot, '.portfolio-test-client');
|
|
|
|
const run = async (command: string, args: readonly string[], environment = process.env): Promise<void> => {
|
|
await execute(command, [...args], {
|
|
cwd: repositoryRoot,
|
|
env: environment,
|
|
maxBuffer: 16 * 1024 * 1024,
|
|
});
|
|
};
|
|
|
|
const firstMatch = (source: string, expression: RegExp, label: string): string => {
|
|
const matches = source.match(expression);
|
|
if (matches === null || matches.length !== 1) throw new Error(`Test client is missing ${label}.`);
|
|
return matches[0];
|
|
};
|
|
|
|
const buildAuthenticatedClient = async (tokenDigest: string): Promise<void> => {
|
|
await rm(temporaryClientRoot, { recursive: true, force: true });
|
|
await run('npm', ['run', 'build:preview']);
|
|
await run(
|
|
resolve(repositoryRoot, 'node_modules/.bin/vite'),
|
|
['build', '--outDir', temporaryClientRoot, '--manifest'],
|
|
{
|
|
...process.env,
|
|
PORTFOLIO_TEST_BUILD: '1',
|
|
PORTFOLIO_TEST_TOKEN_DIGEST: tokenDigest,
|
|
},
|
|
);
|
|
|
|
const productionIndexPath = resolve(repositoryRoot, 'dist/index.html');
|
|
const testIndexPath = resolve(temporaryClientRoot, 'index.html');
|
|
const [productionIndex, testIndex] = await Promise.all([
|
|
readFile(productionIndexPath, 'utf8'),
|
|
readFile(testIndexPath, 'utf8'),
|
|
]);
|
|
const testModule = firstMatch(testIndex, /<script type="module"[^>]+><\/script>/u, 'entry module');
|
|
const testStyle = firstMatch(testIndex, /<link rel="stylesheet"[^>]+>/u, 'stylesheet');
|
|
const authenticatedIndex = productionIndex
|
|
.replace(/\s*<link rel="modulepreload"[^>]+>/gu, '')
|
|
.replace(/<script type="module"[^>]+><\/script>/u, testModule)
|
|
.replace(/<link rel="stylesheet"[^>]+>/u, testStyle);
|
|
|
|
const destinationAssets = resolve(repositoryRoot, 'dist/assets');
|
|
await mkdir(destinationAssets, { recursive: true });
|
|
for (const entry of await readdir(resolve(temporaryClientRoot, 'assets'), { withFileTypes: true })) {
|
|
if (!entry.isFile()) throw new Error('The authenticated test client emitted an unexpected nested asset.');
|
|
await copyFile(
|
|
resolve(temporaryClientRoot, 'assets', entry.name),
|
|
resolve(destinationAssets, entry.name),
|
|
);
|
|
}
|
|
await writeFile(productionIndexPath, authenticatedIndex);
|
|
await rm(temporaryClientRoot, { recursive: true, force: true });
|
|
};
|
|
|
|
export default async function globalSetup(_config: FullConfig): Promise<() => Promise<void>> {
|
|
const token = randomBytes(32).toString('hex');
|
|
const tokenDigest = createHash('sha256').update(token).digest('hex');
|
|
process.env.PORTFOLIO_TEST_TOKEN = token;
|
|
await buildAuthenticatedClient(tokenDigest);
|
|
return async () => {
|
|
await rm(temporaryClientRoot, { recursive: true, force: true });
|
|
delete process.env.PORTFOLIO_TEST_BUILD;
|
|
delete process.env.PORTFOLIO_TEST_TOKEN_DIGEST;
|
|
await run('npm', ['run', 'build:preview']);
|
|
};
|
|
}
|
|
|
|
const withFault = (origin: string, token: string | null, fault: PortfolioFaultId): string => {
|
|
const url = new URL(origin);
|
|
url.searchParams.set('__portfolio_fault', fault);
|
|
if (token !== null) url.searchParams.set('__portfolio_test_token', token);
|
|
return url.href;
|
|
};
|
|
|
|
export const authorizedFaultUrl = (origin: string, fault: PortfolioFaultId): string => {
|
|
const token = process.env.PORTFOLIO_TEST_TOKEN;
|
|
if (token === undefined) throw new Error('The per-run portfolio test token is unavailable.');
|
|
return withFault(origin, token, fault);
|
|
};
|
|
|
|
export const missingTokenFaultUrl = (origin: string, fault: PortfolioFaultId): string => withFault(origin, null, fault);
|
|
|
|
export const wrongTokenFaultUrl = (origin: string, fault: PortfolioFaultId): string => withFault(
|
|
origin,
|
|
'00'.repeat(32),
|
|
fault,
|
|
);
|
|
|
|
export const authorizedPerformanceUrl = (origin: string, tier: 'high' | 'low'): string => {
|
|
const token = process.env.PORTFOLIO_TEST_TOKEN;
|
|
if (token === undefined) throw new Error('The per-run portfolio test token is unavailable.');
|
|
const url = new URL(origin);
|
|
url.searchParams.set('__portfolio_performance', 'drive-trace.v1');
|
|
url.searchParams.set('__portfolio_test_token', token);
|
|
url.searchParams.set('__portfolio_tier', tier);
|
|
return url.href;
|
|
};
|
|
|
|
export interface BrowserErrorCollector {
|
|
readonly pageErrors: string[];
|
|
}
|
|
|
|
export const collectBrowserErrors = async (page: Page): Promise<BrowserErrorCollector> => {
|
|
const pageErrors: string[] = [];
|
|
page.on('pageerror', (error) => pageErrors.push(error.message));
|
|
await page.addInitScript(() => {
|
|
const target = window as unknown as { __faultUnhandledRejections?: string[] };
|
|
target.__faultUnhandledRejections = [];
|
|
window.addEventListener('unhandledrejection', (event) => {
|
|
target.__faultUnhandledRejections!.push(event.reason instanceof Error ? event.reason.message : String(event.reason));
|
|
});
|
|
});
|
|
return { pageErrors };
|
|
};
|
|
|
|
export const readUnhandledRejections = (page: Page): Promise<string[]> => page.evaluate(() => (
|
|
[...((window as unknown as { __faultUnhandledRejections?: string[] }).__faultUnhandledRejections ?? [])]
|
|
));
|
|
|
|
export const assertSemanticFaultOracle = async (page: Page): Promise<void> => {
|
|
await expect(page.getByRole('heading', { level: 1, name: 'Luca Pîslaru' })).toHaveCount(1);
|
|
await expect(page.locator('main > section')).toHaveCount(8);
|
|
await expect(page.locator('nav[aria-label="Portfolio sections"] a')).toHaveCount(8);
|
|
await expect(page.locator('nav a[href="#contact"]')).toBeVisible();
|
|
const email = page.locator('#contact a[href^="mailto:"]');
|
|
await expect(email).toBeVisible();
|
|
for (const title of [
|
|
'Private self-hosted medication tracker',
|
|
'Private healthcare platform',
|
|
'Private game prototype',
|
|
]) {
|
|
await expect(page.locator('article', { hasText: title }).locator('a.project-link')).toHaveCount(0);
|
|
}
|
|
await page.locator('nav a[href="#contact"]').focus();
|
|
await expect(page.locator('nav a[href="#contact"]')).toBeFocused();
|
|
await expect(page.locator('.journey-world')).toHaveCount(1);
|
|
};
|
|
|
|
export interface TestHookSnapshot {
|
|
fault: PortfolioFaultId;
|
|
events: string[];
|
|
sceneState: string | null;
|
|
canvasCount: number;
|
|
posterCount: number;
|
|
staticFallbackCount: number;
|
|
audioStatus: string | null;
|
|
videoStatus: string | null;
|
|
}
|
|
|
|
export const readFaultSnapshot = (page: Page): Promise<TestHookSnapshot> => page.evaluate(() => {
|
|
const hooks = (window as unknown as {
|
|
__PORTFOLIO_TEST_HOOKS__?: { snapshot(): TestHookSnapshot };
|
|
}).__PORTFOLIO_TEST_HOOKS__;
|
|
if (hooks === undefined) throw new Error('Authenticated portfolio fault hooks were not installed.');
|
|
return hooks.snapshot();
|
|
});
|
|
|
|
export const triggerFaultAction = (page: Page, action: 'context-loss' | 'context-restore' | 'audio-interruption') => (
|
|
page.evaluate((nextAction) => {
|
|
const hooks = (window as unknown as {
|
|
__PORTFOLIO_TEST_HOOKS__?: { trigger(action: typeof nextAction): void };
|
|
}).__PORTFOLIO_TEST_HOOKS__;
|
|
if (hooks === undefined) throw new Error('Authenticated portfolio fault hooks were not installed.');
|
|
hooks.trigger(nextAction);
|
|
}, action)
|
|
);
|