init project
This commit is contained in:
@@ -0,0 +1,12 @@
|
||||
import AxeBuilder from '@axe-core/playwright';
|
||||
import { expect, test } from '@playwright/test';
|
||||
|
||||
for (const locale of ['en', 'fr', 'ar'] as const) {
|
||||
test(`${locale} component fixture has no detectable accessibility violations`, async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.goto(`/${locale}/component-lab`);
|
||||
const results = await new AxeBuilder({ page }).analyze();
|
||||
expect(results.violations).toEqual([]);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import AxeBuilder from '@axe-core/playwright';
|
||||
import { expect, test } from '@playwright/test';
|
||||
|
||||
for (const locale of ['en', 'fr', 'ar'] as const) {
|
||||
test(`${locale} homepage has no detectable WCAG A/AA violations`, async ({ page }) => {
|
||||
await page.goto(`/${locale}`);
|
||||
const results = await new AxeBuilder({ page })
|
||||
.withTags(['wcag2a', 'wcag2aa', 'wcag21aa', 'wcag22aa'])
|
||||
.analyze();
|
||||
expect(results.violations).toEqual([]);
|
||||
});
|
||||
}
|
||||
|
||||
test('homepage keeps a coherent heading outline', async ({ page }) => {
|
||||
await page.goto('/en');
|
||||
const levels = await page
|
||||
.locator('main h1, main h2, main h3')
|
||||
.evaluateAll((headings) => headings.map((heading) => Number(heading.tagName.slice(1))));
|
||||
|
||||
expect(levels[0]).toBe(1);
|
||||
for (let index = 1; index < levels.length; index += 1) {
|
||||
expect((levels[index] ?? 1) - (levels[index - 1] ?? 1)).toBeLessThanOrEqual(1);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,27 @@
|
||||
import AxeBuilder from '@axe-core/playwright';
|
||||
import { expect, test } from '@playwright/test';
|
||||
|
||||
for (const locale of ['en', 'fr', 'ar'] as const) {
|
||||
for (const theme of ['light', 'dark'] as const) {
|
||||
test(`${locale} ${theme} shell has no detectable WCAG A/AA violations`, async ({
|
||||
page,
|
||||
context,
|
||||
}) => {
|
||||
await context.addCookies([
|
||||
{
|
||||
name: 'hpc-theme',
|
||||
value: theme,
|
||||
domain: '127.0.0.1',
|
||||
path: '/',
|
||||
secure: false,
|
||||
sameSite: 'Lax',
|
||||
},
|
||||
]);
|
||||
await page.goto(`/${locale}`);
|
||||
const results = await new AxeBuilder({ page })
|
||||
.withTags(['wcag2a', 'wcag2aa', 'wcag21aa', 'wcag22aa'])
|
||||
.analyze();
|
||||
expect(results.violations).toEqual([]);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import { expect, test } from '@playwright/test';
|
||||
|
||||
for (const locale of ['en', 'fr', 'ar'] as const) {
|
||||
test(`${locale} component fixture renders without horizontal overflow`, async ({ page }) => {
|
||||
await page.goto(`/${locale}/component-lab`);
|
||||
await expect(page.locator('h1')).toBeVisible();
|
||||
const overflow = await page.evaluate(
|
||||
() => document.documentElement.scrollWidth > document.documentElement.clientWidth,
|
||||
);
|
||||
expect(overflow).toBe(false);
|
||||
await expect(page.locator('html')).toHaveAttribute('dir', locale === 'ar' ? 'rtl' : 'ltr');
|
||||
});
|
||||
}
|
||||
|
||||
test('component controls support keyboard interaction and dialog focus return', async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.goto('/en/component-lab');
|
||||
const firstTab = page.getByRole('tab', { name: 'Reservations' });
|
||||
await firstTab.focus();
|
||||
await page.keyboard.press('ArrowRight');
|
||||
await expect(page.getByRole('tab', { name: 'Fleet availability' })).toHaveAttribute(
|
||||
'aria-selected',
|
||||
'true',
|
||||
);
|
||||
|
||||
const trigger = page.getByRole('button', { name: 'Open dialog' });
|
||||
await trigger.click();
|
||||
await expect(page.getByRole('dialog')).toBeVisible();
|
||||
await page.keyboard.press('Escape');
|
||||
await expect(page.getByRole('dialog')).toBeHidden();
|
||||
await expect(trigger).toBeFocused();
|
||||
});
|
||||
|
||||
test('component fixture reflows at narrow mobile and 200 percent zoom equivalent', async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.setViewportSize({ width: 320, height: 800 });
|
||||
await page.goto('/fr/component-lab');
|
||||
await page.evaluate(() => (document.body.style.zoom = '2'));
|
||||
const overflow = await page.evaluate(
|
||||
() => document.documentElement.scrollWidth > document.documentElement.clientWidth + 1,
|
||||
);
|
||||
expect(overflow).toBe(false);
|
||||
});
|
||||
@@ -0,0 +1,51 @@
|
||||
import { expect, test } from '@playwright/test';
|
||||
|
||||
for (const scenario of [
|
||||
{ locale: 'en', dir: 'ltr' },
|
||||
{ locale: 'fr', dir: 'ltr' },
|
||||
{ locale: 'ar', dir: 'rtl' },
|
||||
] as const) {
|
||||
test(`${scenario.locale} homepage assembles the approved section sequence`, async ({ page }) => {
|
||||
await page.goto(`/${scenario.locale}`);
|
||||
|
||||
await expect(page.locator('html')).toHaveAttribute('dir', scenario.dir);
|
||||
await expect(page.locator('main h1')).toHaveCount(1);
|
||||
await expect(page.locator('#product')).toBeVisible();
|
||||
await expect(page.locator('#workflow')).toBeVisible();
|
||||
await expect(page.locator('#modules')).toBeVisible();
|
||||
await expect(page.locator('#pricing')).toBeVisible();
|
||||
await expect(page.locator('#faq')).toBeVisible();
|
||||
await expect(page.locator('main > section')).toHaveCount(12);
|
||||
});
|
||||
|
||||
test(`${scenario.locale} unresolved actions are non-navigable`, async ({ page }) => {
|
||||
await page.goto(`/${scenario.locale}`);
|
||||
|
||||
const disabledActions = page.locator('main [aria-disabled="true"]');
|
||||
await expect(disabledActions).toHaveCount(5);
|
||||
await expect(disabledActions.first()).not.toHaveAttribute('href');
|
||||
});
|
||||
}
|
||||
|
||||
test('Arabic preview preserves left-to-right vehicle identifiers', async ({ page }) => {
|
||||
await page.goto('/ar');
|
||||
const identifiers = page.locator('[role="table"] bdi');
|
||||
await expect(identifiers).toHaveCount(2);
|
||||
await expect(identifiers.first()).toHaveAttribute('dir', 'ltr');
|
||||
});
|
||||
|
||||
test('homepage navigation targets resolve to real sections', async ({ page }) => {
|
||||
await page.goto('/en');
|
||||
for (const hash of ['product', 'workflow', 'modules', 'pricing', 'faq']) {
|
||||
await expect(page.locator(`#${hash}`)).toHaveCount(1);
|
||||
await expect(page.locator(`header a[href="/en#${hash}"]`)).toHaveCount(2);
|
||||
}
|
||||
});
|
||||
|
||||
test('FAQ uses native disclosure behavior', async ({ page }) => {
|
||||
await page.goto('/en');
|
||||
const firstItem = page.locator('#faq details').first();
|
||||
await expect(firstItem).toHaveAttribute('open', '');
|
||||
await firstItem.locator('summary').click();
|
||||
await expect(firstItem).not.toHaveAttribute('open', '');
|
||||
});
|
||||
@@ -0,0 +1,28 @@
|
||||
import { expect, test } from '@playwright/test';
|
||||
|
||||
test.use({ viewport: { width: 390, height: 844 } });
|
||||
|
||||
test('mobile navigation opens modally, closes with Escape, and returns focus', async ({ page }) => {
|
||||
await page.goto('/en');
|
||||
const trigger = page.getByRole('button', { name: 'Open navigation menu' });
|
||||
await trigger.focus();
|
||||
await trigger.click();
|
||||
|
||||
const dialog = page.getByRole('dialog', { name: 'Primary navigation' });
|
||||
await expect(dialog).toBeVisible();
|
||||
await expect(page.locator('body')).toHaveAttribute('data-navigation-open', 'true');
|
||||
await expect(page.getByRole('button', { name: 'Close navigation menu' })).toBeFocused();
|
||||
|
||||
await page.keyboard.press('Escape');
|
||||
await expect(dialog).not.toBeVisible();
|
||||
await expect(trigger).toBeFocused();
|
||||
});
|
||||
|
||||
test('skip navigation moves focus to the main landmark', async ({ page }) => {
|
||||
await page.goto('/en');
|
||||
await page.keyboard.press('Tab');
|
||||
const skip = page.getByRole('link', { name: 'Skip to main content' });
|
||||
await expect(skip).toBeFocused();
|
||||
await skip.press('Enter');
|
||||
await expect(page.locator('#main-content')).toBeFocused();
|
||||
});
|
||||
@@ -0,0 +1,12 @@
|
||||
import { expect, test } from '@playwright/test';
|
||||
|
||||
test('server shell remains readable with JavaScript disabled', async ({ browser }) => {
|
||||
const context = await browser.newContext({ javaScriptEnabled: false, colorScheme: 'dark' });
|
||||
const page = await context.newPage();
|
||||
await page.goto('/ar');
|
||||
await expect(page.locator('html')).toHaveAttribute('lang', 'ar');
|
||||
await expect(page.locator('html')).toHaveAttribute('dir', 'rtl');
|
||||
await expect(page.locator('h1')).toBeVisible();
|
||||
await expect(page.locator('footer')).toBeVisible();
|
||||
await context.close();
|
||||
});
|
||||
@@ -0,0 +1,24 @@
|
||||
import { expect, test } from '@playwright/test';
|
||||
|
||||
const widths = [320, 390, 768, 1024, 1440];
|
||||
|
||||
for (const locale of ['en', 'fr', 'ar'] as const) {
|
||||
for (const width of widths) {
|
||||
test(`${locale} has no horizontal overflow at ${width}px`, async ({ page }) => {
|
||||
await page.setViewportSize({ width, height: 900 });
|
||||
await page.goto(`/${locale}`);
|
||||
const overflow = await page.evaluate(
|
||||
() => document.documentElement.scrollWidth - document.documentElement.clientWidth,
|
||||
);
|
||||
expect(overflow).toBeLessThanOrEqual(1);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
test('shell remains operable at a 320 CSS pixel reflow equivalent to 200 percent zoom', async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.setViewportSize({ width: 640, height: 800 });
|
||||
await page.goto('/fr');
|
||||
await expect(page.getByRole('button', { name: 'Ouvrir le menu de navigation' })).toBeVisible();
|
||||
});
|
||||
@@ -0,0 +1,58 @@
|
||||
import { expect, test } from '@playwright/test';
|
||||
|
||||
for (const scenario of [
|
||||
{ locale: 'en', dir: 'ltr', title: /RentalDriveGo/ },
|
||||
{ locale: 'fr', dir: 'ltr', title: /RentalDriveGo/ },
|
||||
{ locale: 'ar', dir: 'rtl', title: /RentalDriveGo/ },
|
||||
] as const) {
|
||||
test(`${scenario.locale} shell is server rendered`, async ({ page, request }) => {
|
||||
const raw = await request.get(`/${scenario.locale}`);
|
||||
const html = await raw.text();
|
||||
expect(raw.status()).toBe(200);
|
||||
expect(html).toMatch(
|
||||
new RegExp(`<html[^>]*lang=\"${scenario.locale}\"[^>]*dir=\"${scenario.dir}\"`),
|
||||
);
|
||||
|
||||
const response = await page.goto(`/${scenario.locale}`);
|
||||
expect(response?.status()).toBe(200);
|
||||
await expect(page.locator('html')).toHaveAttribute('lang', scenario.locale);
|
||||
await expect(page.locator('html')).toHaveAttribute('dir', scenario.dir);
|
||||
await expect(page).toHaveTitle(scenario.title);
|
||||
await expect(page.locator('h1')).toBeVisible();
|
||||
await expect(page.locator('header')).toBeVisible();
|
||||
await expect(page.locator('footer')).toBeVisible();
|
||||
await expect(page.locator('link[rel="canonical"]')).toHaveAttribute(
|
||||
'href',
|
||||
new RegExp(`/${scenario.locale}$`),
|
||||
);
|
||||
for (const language of ['en', 'fr', 'ar', 'x-default']) {
|
||||
await expect(page.locator(`link[rel="alternate"][hreflang="${language}"]`)).toHaveCount(1);
|
||||
}
|
||||
await expect(page.locator('meta[property="og:locale"]')).toHaveAttribute(
|
||||
'content',
|
||||
scenario.locale,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
test('root locale detection honors Accept-Language', async ({ browser }) => {
|
||||
const context = await browser.newContext({
|
||||
extraHTTPHeaders: { 'Accept-Language': 'ar;q=1,en;q=0.8' },
|
||||
});
|
||||
const page = await context.newPage();
|
||||
await page.goto('/');
|
||||
await expect(page).toHaveURL(/\/ar$/);
|
||||
await context.close();
|
||||
});
|
||||
|
||||
test('locale switching keeps the semantic route and approved campaign state', async ({ page }) => {
|
||||
await page.goto('/en/privacy?utm_source=qa&email=drop-me#ignored');
|
||||
await page.getByLabel('Language').first().selectOption('fr');
|
||||
await expect(page).toHaveURL('/fr/confidentialite?utm_source=qa');
|
||||
});
|
||||
|
||||
test('pending route shells are localized and noindex', async ({ page }) => {
|
||||
await page.goto('/fr/confidentialite');
|
||||
await expect(page.locator('h1')).toContainText('Confidentialité');
|
||||
await expect(page.locator('meta[name="robots"]')).toHaveAttribute('content', /noindex/);
|
||||
});
|
||||
@@ -0,0 +1,20 @@
|
||||
import { expect, test } from '@playwright/test';
|
||||
|
||||
for (const locale of ['fr', 'ar'] as const) {
|
||||
test(`${locale} shell tolerates long content expansion`, async ({ page }) => {
|
||||
await page.setViewportSize({ width: 390, height: 900 });
|
||||
await page.goto(`/${locale}`);
|
||||
await page
|
||||
.locator('main article h3')
|
||||
.first()
|
||||
.evaluate((element) => {
|
||||
element.textContent = [element.textContent, element.textContent, element.textContent]
|
||||
.filter(Boolean)
|
||||
.join(' ');
|
||||
});
|
||||
const overflow = await page.evaluate(
|
||||
() => document.documentElement.scrollWidth - document.documentElement.clientWidth,
|
||||
);
|
||||
expect(overflow).toBeLessThanOrEqual(1);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
import { expect, test } from '@playwright/test';
|
||||
|
||||
test('explicit dark theme is present on the server-rendered root', async ({ browser, baseURL }) => {
|
||||
const origin = new URL(baseURL ?? 'http://127.0.0.1:3000');
|
||||
const context = await browser.newContext();
|
||||
await context.addCookies([
|
||||
{
|
||||
name: 'hpc-theme',
|
||||
value: 'dark',
|
||||
domain: origin.hostname,
|
||||
path: '/',
|
||||
secure: false,
|
||||
sameSite: 'Lax',
|
||||
},
|
||||
]);
|
||||
const page = await context.newPage();
|
||||
const response = await page.goto('/en');
|
||||
await expect(page.locator('html')).toHaveAttribute('data-theme-preference', 'dark');
|
||||
await expect(page.locator('html')).toHaveAttribute('data-theme', 'dark');
|
||||
const csp = response?.headers()['content-security-policy'] ?? '';
|
||||
const nonce = await page.locator('#theme-bootstrap').getAttribute('nonce');
|
||||
expect(nonce).toBeTruthy();
|
||||
expect(csp).toContain(`'nonce-${nonce}'`);
|
||||
await context.close();
|
||||
});
|
||||
|
||||
test('system preference resolves before interaction', async ({ browser }) => {
|
||||
const context = await browser.newContext({ colorScheme: 'dark' });
|
||||
const page = await context.newPage();
|
||||
await page.goto('/en');
|
||||
await expect(page.locator('html')).toHaveAttribute('data-theme-preference', 'system');
|
||||
await expect(page.locator('html')).toHaveAttribute('data-theme', 'dark');
|
||||
await context.close();
|
||||
});
|
||||
|
||||
test('system follows operating-system changes while explicit preferences ignore them', async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.goto('/en');
|
||||
const selector = page.getByLabel('Theme').first();
|
||||
|
||||
await selector.selectOption('system');
|
||||
await page.emulateMedia({ colorScheme: 'dark' });
|
||||
await expect(page.locator('html')).toHaveAttribute('data-theme', 'dark');
|
||||
await page.emulateMedia({ colorScheme: 'light' });
|
||||
await expect(page.locator('html')).toHaveAttribute('data-theme', 'light');
|
||||
|
||||
await selector.selectOption('dark');
|
||||
await page.emulateMedia({ colorScheme: 'light' });
|
||||
await expect(page.locator('html')).toHaveAttribute('data-theme-preference', 'dark');
|
||||
await expect(page.locator('html')).toHaveAttribute('data-theme', 'dark');
|
||||
expect(await page.evaluate(() => window.localStorage.getItem('hpc.theme.preference'))).toBe(
|
||||
'dark',
|
||||
);
|
||||
expect(await page.context().cookies()).toEqual(
|
||||
expect.arrayContaining([expect.objectContaining({ name: 'hpc-theme', value: 'dark' })]),
|
||||
);
|
||||
});
|
||||
Vendored
+5
@@ -0,0 +1,5 @@
|
||||
# Phase 11 foundation fixtures
|
||||
|
||||
Phase 10 did not approve a component-workbench dependency. The repository therefore uses the live localized shell plus the matrix in `foundation-matrix.json` as the approved equivalent fixture strategy.
|
||||
|
||||
Playwright functional, accessibility, responsive, and visual suites consume the same production routes and components. This avoids a parallel story-only rendering path that could pass while production quietly fails, a trick software is distressingly good at.
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"fixtureRoute": "/{locale}/component-lab",
|
||||
"productionGuard": "COMPONENT_FIXTURES_ENABLED=true",
|
||||
"locales": ["en", "fr", "ar"],
|
||||
"themes": ["light", "dark", "system"],
|
||||
"widths": [320, 390, 768, 1024, 1440],
|
||||
"states": ["default", "focus-visible", "disabled", "loading", "error", "long-content", "rtl"],
|
||||
"contentPolicy": "Fictional deterministic development data only; no production claims, customers, personal data, or live operations."
|
||||
}
|
||||
+22
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"locales": ["en", "fr", "ar"],
|
||||
"directions": ["ltr", "rtl"],
|
||||
"themes": ["light", "dark", "system"],
|
||||
"viewports": [
|
||||
{ "name": "mobile", "width": 390, "height": 844 },
|
||||
{ "name": "tablet", "width": 1024, "height": 900 },
|
||||
{ "name": "desktop", "width": 1440, "height": 1000 }
|
||||
],
|
||||
"shell_states": [
|
||||
"default",
|
||||
"mobile-navigation-open",
|
||||
"keyboard-focus",
|
||||
"loading",
|
||||
"error",
|
||||
"not-found",
|
||||
"pending-destination",
|
||||
"long-text",
|
||||
"reduced-motion"
|
||||
],
|
||||
"coverage_source": ["tests/browser", "tests/a11y", "tests/visual"]
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
import '@testing-library/jest-dom/vitest';
|
||||
@@ -0,0 +1,16 @@
|
||||
import { Button } from '@/components/actions/Button';
|
||||
import type { MetricContent } from '@/components/marketing/Evidence';
|
||||
|
||||
void Button;
|
||||
|
||||
const approvedMetric: MetricContent = {
|
||||
value: '1',
|
||||
label: 'Fixture',
|
||||
status: 'approved',
|
||||
sourceReference: 'SRC-001',
|
||||
};
|
||||
void approvedMetric;
|
||||
|
||||
// @ts-expect-error icon-only buttons require an accessible label.
|
||||
const invalidIconButton = <Button icon="close" />;
|
||||
void invalidIconButton;
|
||||
@@ -0,0 +1,35 @@
|
||||
import { Button } from '@/components/actions/Button';
|
||||
import { ActionLink } from '@/components/actions/ActionLink';
|
||||
import { fireEvent, render, screen } from '@testing-library/react';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
describe('action components', () => {
|
||||
it('prevents duplicate interaction while loading and preserves the accessible name', () => {
|
||||
const onClick = vi.fn();
|
||||
render(
|
||||
<Button loading onClick={onClick}>
|
||||
Save reservation
|
||||
</Button>,
|
||||
);
|
||||
const button = screen.getByRole('button', { name: 'Save reservation' });
|
||||
expect(button).toBeDisabled();
|
||||
expect(button).toHaveAttribute('aria-busy', 'true');
|
||||
fireEvent.click(button);
|
||||
expect(onClick).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('requires an accessible label for icon-only use through its public contract', () => {
|
||||
render(<Button icon="close" aria-label="Close panel" />);
|
||||
expect(screen.getByRole('button', { name: 'Close panel' })).toBeVisible();
|
||||
});
|
||||
|
||||
it('renders unresolved navigation as a disabled non-link', () => {
|
||||
render(
|
||||
<ActionLink href="/en" disabledReason="Destination pending approval">
|
||||
Book a demo
|
||||
</ActionLink>,
|
||||
);
|
||||
expect(screen.queryByRole('link')).not.toBeInTheDocument();
|
||||
expect(screen.getByText('Book a demo').closest('[aria-disabled="true"]')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,31 @@
|
||||
import { Accordion } from '@/components/controls/Accordion';
|
||||
import { Tabs } from '@/components/controls/Tabs';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
describe('selection and disclosure controls', () => {
|
||||
it('moves tab focus and selection with arrow keys', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(
|
||||
<Tabs
|
||||
label="Views"
|
||||
items={[
|
||||
{ id: 'one', label: 'One', content: 'Panel one' },
|
||||
{ id: 'two', label: 'Two', content: 'Panel two' },
|
||||
]}
|
||||
/>,
|
||||
);
|
||||
const first = screen.getByRole('tab', { name: 'One' });
|
||||
await user.click(first);
|
||||
await user.keyboard('{ArrowRight}');
|
||||
expect(screen.getByRole('tab', { name: 'Two' })).toHaveAttribute('aria-selected', 'true');
|
||||
expect(screen.getByRole('tabpanel', { name: 'Two' })).toHaveTextContent('Panel two');
|
||||
});
|
||||
|
||||
it('uses native disclosure semantics for accordion items', () => {
|
||||
render(<Accordion items={[{ id: 'faq', title: 'Question', content: 'Answer' }]} />);
|
||||
expect(screen.getByText('Question').closest('summary')).toBeInTheDocument();
|
||||
expect(screen.getByText('Answer').closest('details')).not.toHaveAttribute('open');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,39 @@
|
||||
import { canRenderProductionEvidence, Metric } from '@/components/marketing/Evidence';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
describe('evidence governance', () => {
|
||||
it('allows only approved evidence in production mode', () => {
|
||||
expect(canRenderProductionEvidence('approved')).toBe(true);
|
||||
expect(canRenderProductionEvidence('pending-review')).toBe(false);
|
||||
expect(canRenderProductionEvidence('research-only')).toBe(false);
|
||||
expect(canRenderProductionEvidence('prohibited')).toBe(false);
|
||||
});
|
||||
|
||||
it('suppresses research-only metrics in production mode', () => {
|
||||
const { container } = render(
|
||||
<Metric
|
||||
production
|
||||
statusLabel="Research only"
|
||||
content={{ value: '24', label: 'Fixture', status: 'research-only' }}
|
||||
/>,
|
||||
);
|
||||
expect(container).toBeEmptyDOMElement();
|
||||
});
|
||||
|
||||
it('renders approved evidence with its source reference', () => {
|
||||
render(
|
||||
<Metric
|
||||
production
|
||||
statusLabel="Approved"
|
||||
content={{
|
||||
value: '1',
|
||||
label: 'Approved fixture',
|
||||
status: 'approved',
|
||||
sourceReference: 'SRC-001',
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByText('SRC-001')).toBeVisible();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,41 @@
|
||||
import { ErrorSummary } from '@/components/forms/ErrorSummary';
|
||||
import { FormField } from '@/components/forms/FormField';
|
||||
import { TextInput } from '@/components/forms/TextInput';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
describe('form primitives', () => {
|
||||
it('associates labels, help, required state, and errors', () => {
|
||||
render(
|
||||
<FormField
|
||||
id="work-email"
|
||||
label="Work email"
|
||||
required
|
||||
supportingText="Use work email"
|
||||
error="Enter a valid address"
|
||||
>
|
||||
<TextInput
|
||||
id="work-email"
|
||||
type="email"
|
||||
required
|
||||
invalid
|
||||
describedBy="work-email-help work-email-error"
|
||||
/>
|
||||
</FormField>,
|
||||
);
|
||||
const input = screen.getByLabelText(/Work email/);
|
||||
expect(input).toHaveAttribute('required');
|
||||
expect(input).toHaveAttribute('aria-invalid', 'true');
|
||||
expect(input).toHaveAccessibleDescription('Use work email Enter a valid address');
|
||||
});
|
||||
|
||||
it('links summary errors back to fields', () => {
|
||||
render(
|
||||
<ErrorSummary
|
||||
title="Correct the fields"
|
||||
errors={[{ fieldId: 'work-email', message: 'Enter a valid address' }]}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByRole('alert').querySelector('a')).toHaveAttribute('href', '#work-email');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,20 @@
|
||||
import { buildContentSecurityPolicy, createNonce } from '@/lib/security/csp';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
describe('CSP foundation', () => {
|
||||
it('generates unpredictable nonce-shaped values', () => {
|
||||
const first = createNonce();
|
||||
const second = createNonce();
|
||||
expect(first).toMatch(/^[a-f0-9]{32}$/);
|
||||
expect(second).not.toBe(first);
|
||||
});
|
||||
|
||||
it('allows development evaluation only in development', () => {
|
||||
expect(buildContentSecurityPolicy('abc', true)).toContain("'unsafe-eval'");
|
||||
expect(buildContentSecurityPolicy('abc', false)).not.toContain("'unsafe-eval'");
|
||||
expect(buildContentSecurityPolicy('abc', false)).toContain("'nonce-abc'");
|
||||
expect(buildContentSecurityPolicy('abc', false)).toContain("style-src 'self' 'nonce-abc'");
|
||||
expect(buildContentSecurityPolicy('abc', false)).not.toContain("'unsafe-inline'");
|
||||
expect(buildContentSecurityPolicy('abc', false)).toContain("frame-ancestors 'none'");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,40 @@
|
||||
import {
|
||||
formatCurrency,
|
||||
formatDate,
|
||||
formatDateRange,
|
||||
formatNumber,
|
||||
formatPercent,
|
||||
formatPhoneNumber,
|
||||
formatRelativeTime,
|
||||
preserveIdentifier,
|
||||
} from '@/lib/localization/format';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
const instant = new Date('2026-06-25T12:00:00.000Z');
|
||||
|
||||
describe('locale-aware formatting primitives', () => {
|
||||
it('formats Gregorian dates with explicit time zones', () => {
|
||||
expect(formatDate(instant, { locale: 'en', timeZone: 'UTC' })).toContain('2026');
|
||||
expect(formatDate(instant, { locale: 'fr', timeZone: 'UTC' })).toContain('2026');
|
||||
expect(formatDate(instant, { locale: 'ar', timeZone: 'UTC' })).toContain('2026');
|
||||
});
|
||||
|
||||
it('formats ranges, numbers, percentages, and explicit currencies', () => {
|
||||
expect(
|
||||
formatDateRange(instant, new Date('2026-06-27T12:00:00.000Z'), {
|
||||
locale: 'en',
|
||||
timeZone: 'UTC',
|
||||
}),
|
||||
).toContain('2026');
|
||||
expect(formatNumber(1234.5, 'fr')).toMatch(/1.*234/);
|
||||
expect(formatPercent(0.125, 'en')).toContain('12.5');
|
||||
expect(formatCurrency(125, 'en', 'USD')).toContain('$');
|
||||
expect(() => formatCurrency(125, 'en', 'usd')).toThrow(/ISO 4217/);
|
||||
});
|
||||
|
||||
it('formats relative time and preserves atomic mixed-direction values', () => {
|
||||
expect(formatRelativeTime(-1, 'day', 'en')).toBe('yesterday');
|
||||
expect(formatPhoneNumber('+1 (202) 555-0100')).toBe('+12025550100');
|
||||
expect(preserveIdentifier('RES 2026 0042')).toBe('RES 2026 0042');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,42 @@
|
||||
import arHomepage from '@/content/locales/ar/homepage.json';
|
||||
import arShell from '@/content/locales/ar/shell.json';
|
||||
import enHomepage from '@/content/locales/en/homepage.json';
|
||||
import enShell from '@/content/locales/en/shell.json';
|
||||
import frHomepage from '@/content/locales/fr/homepage.json';
|
||||
import frShell from '@/content/locales/fr/shell.json';
|
||||
import { buildHomepageContent } from '@/content/homepage-model';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
describe('homepage content model', () => {
|
||||
it.each([
|
||||
['en', enHomepage, enShell],
|
||||
['fr', frHomepage, frShell],
|
||||
['ar', arHomepage, arShell],
|
||||
] as const)('builds complete %s route content', (_locale, homepage, shell) => {
|
||||
const content = buildHomepageContent(homepage, shell);
|
||||
|
||||
expect(content.hero.title).toBeTruthy();
|
||||
expect(content.trust.items).toHaveLength(4);
|
||||
expect(content.workflow.steps).toHaveLength(4);
|
||||
expect(content.modules.items).toHaveLength(6);
|
||||
expect(content.faq.items).toHaveLength(6);
|
||||
expect(content.hero.preview.rows).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('keeps unresolved conversion destinations disabled', () => {
|
||||
const content = buildHomepageContent(enHomepage, enShell);
|
||||
|
||||
expect(content.hero.primary.disabledReason).toBe(enShell.header.demoUnavailable);
|
||||
expect(content.pricing.action.disabledReason).toBe(enShell.states.pendingBody);
|
||||
expect(content.final.secondary.disabledReason).toBe(enShell.states.pendingBody);
|
||||
});
|
||||
|
||||
it('keeps preview identifiers isolated from translated prose', () => {
|
||||
const content = buildHomepageContent(arHomepage, arShell);
|
||||
|
||||
expect(content.hero.preview.rows.map((row) => row.secondary)).toEqual([
|
||||
arHomepage.preview.plate1,
|
||||
arHomepage.preview.plate2,
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,58 @@
|
||||
import {
|
||||
getDirection,
|
||||
hreflangMap,
|
||||
isLocale,
|
||||
localeFromAcceptLanguage,
|
||||
localeSwitchUrl,
|
||||
localizedPath,
|
||||
routeById,
|
||||
routeIdFromPathname,
|
||||
routeIdFromSlug,
|
||||
} from '@/lib/localization/config';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
describe('localization foundations', () => {
|
||||
it('selects a supported locale by quality', () => {
|
||||
expect(localeFromAcceptLanguage('de-DE;q=0.9, ar;q=0.8, fr;q=0.7')).toBe('ar');
|
||||
expect(localeFromAcceptLanguage('fr-CA, en;q=0.8')).toBe('fr');
|
||||
expect(localeFromAcceptLanguage(null)).toBe('en');
|
||||
});
|
||||
|
||||
it('validates locale direction and route lookup', () => {
|
||||
expect(isLocale('fr')).toBe(true);
|
||||
expect(isLocale('de')).toBe(false);
|
||||
expect(getDirection('ar')).toBe('rtl');
|
||||
expect(routeById('home').id).toBe('home');
|
||||
});
|
||||
|
||||
it('generates and resolves localized stable route paths', () => {
|
||||
expect(localizedPath('home', 'ar')).toBe('/ar');
|
||||
expect(localizedPath('privacy', 'fr')).toBe('/fr/confidentialite');
|
||||
expect(localizedPath('login', 'ar')).toBe('/ar/تسجيل-الدخول');
|
||||
expect(routeIdFromSlug('fr', 'confidentialite')).toBe('privacy');
|
||||
expect(routeIdFromPathname('/ar/%D8%A7%D9%84%D8%AE%D8%B5%D9%88%D8%B5%D9%8A%D8%A9')).toBe(
|
||||
'privacy',
|
||||
);
|
||||
expect(routeIdFromPathname('/de')).toBeNull();
|
||||
});
|
||||
|
||||
it('generates reciprocal hreflang values', () => {
|
||||
const links = hreflangMap(new URL('https://approved.test'), 'home');
|
||||
expect(links.en).toBe('https://approved.test/en');
|
||||
expect(links.ar).toBe('https://approved.test/ar');
|
||||
expect(links['x-default']).toBe('https://approved.test/en');
|
||||
});
|
||||
|
||||
it('preserves only approved campaign query values and valid hashes', () => {
|
||||
const current = new URL(
|
||||
'https://approved.test/en?utm_source=campaign&email=private%40approved.test#gibberish',
|
||||
);
|
||||
const switched = localeSwitchUrl(current, 'fr', 'home');
|
||||
expect(switched.pathname).toBe('/fr');
|
||||
expect(switched.search).toBe('?utm_source=campaign');
|
||||
expect(switched.hash).toBe('');
|
||||
|
||||
current.hash = '#workflow';
|
||||
expect(localeSwitchUrl(current, 'ar', 'home').hash).toBe('#workflow');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,18 @@
|
||||
import { isThemePreference, resolveTheme, serverResolvedTheme } from '@/lib/theme/config';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
describe('theme foundations', () => {
|
||||
it('accepts only approved preferences', () => {
|
||||
expect(isThemePreference('light')).toBe(true);
|
||||
expect(isThemePreference('dark')).toBe(true);
|
||||
expect(isThemePreference('system')).toBe(true);
|
||||
expect(isThemePreference('sepia')).toBe(false);
|
||||
});
|
||||
|
||||
it('keeps preference and resolved theme separate', () => {
|
||||
expect(resolveTheme('system', true)).toBe('dark');
|
||||
expect(resolveTheme('system', false)).toBe('light');
|
||||
expect(resolveTheme('light', true)).toBe('light');
|
||||
expect(serverResolvedTheme('system')).toBe('light');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,10 @@
|
||||
# Visual baselines
|
||||
|
||||
The Playwright scenarios define the Phase 11 locale, theme, viewport, drawer, and focus-state matrix.
|
||||
Canonical PNG snapshots must be generated with the pinned Chromium build in an unblocked environment:
|
||||
|
||||
```bash
|
||||
pnpm test:visual:update --project=chromium
|
||||
```
|
||||
|
||||
No fabricated or manually composed screenshots are stored here. The local execution environment blocked browser installation and loopback browser navigation, so baseline approval remains an explicit release gate in the Phase 11 issue register.
|
||||
@@ -0,0 +1,20 @@
|
||||
import { expect, test } from '@playwright/test';
|
||||
|
||||
for (const scenario of [
|
||||
{ locale: 'en', theme: 'light', width: 1440, height: 1200 },
|
||||
{ locale: 'fr', theme: 'dark', width: 1024, height: 1200 },
|
||||
{ locale: 'ar', theme: 'dark', width: 390, height: 1200 },
|
||||
] as const) {
|
||||
test(`${scenario.locale}-${scenario.theme} component matrix`, async ({ page }) => {
|
||||
await page.addInitScript(
|
||||
(theme) => localStorage.setItem('rdg-theme-preference', theme),
|
||||
scenario.theme,
|
||||
);
|
||||
await page.setViewportSize({ width: scenario.width, height: scenario.height });
|
||||
await page.goto(`/${scenario.locale}/component-lab`);
|
||||
await expect(page).toHaveScreenshot(
|
||||
`component-system-${scenario.locale}-${scenario.theme}-${scenario.width}.png`,
|
||||
{ fullPage: true },
|
||||
);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import { expect, test } from '@playwright/test';
|
||||
|
||||
const scenarios = [
|
||||
{ name: 'homepage-en-light-desktop', locale: 'en', theme: 'light', width: 1440, height: 1000 },
|
||||
{ name: 'homepage-en-dark-mobile', locale: 'en', theme: 'dark', width: 390, height: 844 },
|
||||
{ name: 'homepage-fr-light-tablet', locale: 'fr', theme: 'light', width: 1024, height: 900 },
|
||||
{ name: 'homepage-fr-dark-mobile', locale: 'fr', theme: 'dark', width: 390, height: 844 },
|
||||
{ name: 'homepage-ar-light-desktop', locale: 'ar', theme: 'light', width: 1440, height: 1000 },
|
||||
{ name: 'homepage-ar-dark-mobile', locale: 'ar', theme: 'dark', width: 390, height: 844 },
|
||||
{ name: 'homepage-en-light-320', locale: 'en', theme: 'light', width: 320, height: 800 },
|
||||
] as const;
|
||||
|
||||
for (const scenario of scenarios) {
|
||||
test(`${scenario.name} baseline`, async ({ page, context }) => {
|
||||
test.skip(test.info().project.name !== 'chromium');
|
||||
await page.setViewportSize({ width: scenario.width, height: scenario.height });
|
||||
await context.addCookies([
|
||||
{
|
||||
name: 'hpc-theme',
|
||||
value: scenario.theme,
|
||||
domain: '127.0.0.1',
|
||||
path: '/',
|
||||
secure: false,
|
||||
sameSite: 'Lax',
|
||||
},
|
||||
]);
|
||||
await page.goto(`/${scenario.locale}`);
|
||||
await expect(page).toHaveScreenshot(`${scenario.name}.png`, { fullPage: true });
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
import { expect, test } from '@playwright/test';
|
||||
|
||||
const scenarios = [
|
||||
{
|
||||
name: 'desktop-en-light',
|
||||
locale: 'en',
|
||||
theme: 'light',
|
||||
viewport: { width: 1440, height: 1000 },
|
||||
},
|
||||
{ name: 'desktop-en-dark', locale: 'en', theme: 'dark', viewport: { width: 1440, height: 1000 } },
|
||||
{ name: 'tablet-fr-light', locale: 'fr', theme: 'light', viewport: { width: 1024, height: 900 } },
|
||||
{ name: 'tablet-fr-dark', locale: 'fr', theme: 'dark', viewport: { width: 1024, height: 900 } },
|
||||
{ name: 'mobile-ar-light', locale: 'ar', theme: 'light', viewport: { width: 390, height: 844 } },
|
||||
{ name: 'mobile-ar-dark', locale: 'ar', theme: 'dark', viewport: { width: 390, height: 844 } },
|
||||
] as const;
|
||||
|
||||
for (const scenario of scenarios) {
|
||||
test(`${scenario.name} visual baseline`, async ({ page, context }) => {
|
||||
test.skip(
|
||||
test.info().project.name !== 'chromium',
|
||||
'Canonical visual baselines use Chromium; other projects run functional coverage.',
|
||||
);
|
||||
await page.setViewportSize(scenario.viewport);
|
||||
await context.addCookies([
|
||||
{
|
||||
name: 'hpc-theme',
|
||||
value: scenario.theme,
|
||||
domain: '127.0.0.1',
|
||||
path: '/',
|
||||
secure: false,
|
||||
sameSite: 'Lax',
|
||||
},
|
||||
]);
|
||||
await page.goto(`/${scenario.locale}`);
|
||||
await expect(page).toHaveScreenshot(`${scenario.name}.png`, { fullPage: true });
|
||||
});
|
||||
}
|
||||
|
||||
test('mobile navigation open baseline', async ({ page }) => {
|
||||
test.skip(test.info().project.name !== 'chromium');
|
||||
await page.setViewportSize({ width: 390, height: 844 });
|
||||
await page.context().addCookies([
|
||||
{
|
||||
name: 'hpc-theme',
|
||||
value: 'dark',
|
||||
domain: '127.0.0.1',
|
||||
path: '/',
|
||||
secure: false,
|
||||
sameSite: 'Lax',
|
||||
},
|
||||
]);
|
||||
await page.goto('/ar');
|
||||
await page.getByRole('button', { name: 'فتح قائمة التنقل' }).click();
|
||||
await expect(page).toHaveScreenshot('mobile-ar-dark-navigation-open.png', { fullPage: true });
|
||||
});
|
||||
|
||||
test('keyboard focus baseline', async ({ page }) => {
|
||||
test.skip(test.info().project.name !== 'chromium');
|
||||
await page.setViewportSize({ width: 1440, height: 900 });
|
||||
await page.goto('/en');
|
||||
await page.keyboard.press('Tab');
|
||||
await expect(page).toHaveScreenshot('desktop-en-light-focus.png', { fullPage: true });
|
||||
});
|
||||
Reference in New Issue
Block a user