test: add deterministic resilience matrix
This commit is contained in:
167
tests/helpers/test-build-auth.ts
Normal file
167
tests/helpers/test-build-auth.ts
Normal file
@@ -0,0 +1,167 @@
|
||||
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 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)
|
||||
);
|
||||
Reference in New Issue
Block a user