redesign the homepage
Build & Deploy / Build & Push Docker Image (push) Failing after 47s
Build & Deploy / Deploy to VPS (push) Has been skipped
Test / API Unit Tests (push) Failing after 5m4s
Test / Marketplace Unit Tests (push) Failing after 4m55s
Test / Admin Unit Tests (push) Successful in 9m37s
Test / Dashboard Unit Tests (push) Successful in 9m37s
Test / API Integration Tests (push) Successful in 9m54s
Build & Deploy / Build & Push Docker Image (push) Failing after 47s
Build & Deploy / Deploy to VPS (push) Has been skipped
Test / API Unit Tests (push) Failing after 5m4s
Test / Marketplace Unit Tests (push) Failing after 4m55s
Test / Admin Unit Tests (push) Successful in 9m37s
Test / Dashboard Unit Tests (push) Successful in 9m37s
Test / API Integration Tests (push) Successful in 9m54s
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,15 @@
|
||||
import AxeBuilder from '@axe-core/playwright';
|
||||
import { expect, test } from '@playwright/test';
|
||||
|
||||
for (const locale of ['en', 'fr', 'ar'] as const) {
|
||||
test(`${locale} demo dialog has no detectable WCAG A/AA violations`, async ({ page }) => {
|
||||
await page.goto(`/${locale}`);
|
||||
await page.locator('main button[data-demo-source="hero"]').click();
|
||||
await expect(page.getByRole('dialog')).toBeVisible();
|
||||
const results = await new AxeBuilder({ page })
|
||||
.include('dialog')
|
||||
.withTags(['wcag2a', 'wcag2aa', 'wcag21aa', 'wcag22aa'])
|
||||
.analyze();
|
||||
expect(results.violations).toEqual([]);
|
||||
});
|
||||
}
|
||||
@@ -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,67 @@
|
||||
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 remain non-navigable`, async ({ page }) => {
|
||||
await page.goto(`/${scenario.locale}`);
|
||||
|
||||
const disabledActions = page.locator('main [aria-disabled="true"]');
|
||||
await expect(disabledActions).toHaveCount(2);
|
||||
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', '');
|
||||
});
|
||||
|
||||
test('pricing recommends a plan from the selected fleet band', async ({ page }) => {
|
||||
await page.goto('/en');
|
||||
|
||||
await expect(page.locator('#pricing [data-plan="launch"]')).toHaveAttribute(
|
||||
'data-recommended',
|
||||
'true',
|
||||
);
|
||||
await page.getByLabel('26–75 vehicles').check();
|
||||
await expect(page.locator('#pricing [data-plan="growth"]')).toHaveAttribute(
|
||||
'data-recommended',
|
||||
'true',
|
||||
);
|
||||
await page.getByLabel('Annual', { exact: true }).check();
|
||||
await expect(page.getByLabel('Annual', { exact: true })).toBeChecked();
|
||||
});
|
||||
@@ -0,0 +1,74 @@
|
||||
import { expect, test } from '@playwright/test';
|
||||
|
||||
const localeData = {
|
||||
en: {
|
||||
button: /Book a demo|Book a product demo/,
|
||||
title: 'Book a product demo',
|
||||
success: 'Demo request recorded',
|
||||
},
|
||||
fr: {
|
||||
button: /Réserver une démonstration/,
|
||||
title: 'Réserver une démonstration du produit',
|
||||
success: 'Demande de démonstration enregistrée',
|
||||
},
|
||||
ar: {
|
||||
button: /احجز عرضًا توضيحيًا|احجز عرض/,
|
||||
title: 'احجز عرضًا توضيحيًا للمنتج',
|
||||
success: 'تم تسجيل طلب العرض',
|
||||
},
|
||||
} as const;
|
||||
|
||||
for (const locale of ['en', 'fr', 'ar'] as const) {
|
||||
test(`${locale} demo flow validates and succeeds through the local adapter`, async ({ page }) => {
|
||||
const labels = localeData[locale];
|
||||
await page.goto(`/${locale}`);
|
||||
await page.locator('main button[data-demo-source="hero"]').click();
|
||||
|
||||
const dialog = page.getByRole('dialog', { name: labels.title });
|
||||
await expect(dialog).toBeVisible();
|
||||
await dialog
|
||||
.getByRole('button', { name: /Request my demo|Demander ma démonstration|إرسال طلب العرض/ })
|
||||
.click();
|
||||
await expect(dialog.locator('[role="alert"]')).toBeVisible();
|
||||
await expect(dialog.locator('#fullName')).toBeFocused();
|
||||
|
||||
await dialog.locator('#fullName').fill('Avery Example');
|
||||
await dialog.locator('#workEmail').fill('avery@example.test');
|
||||
await dialog.locator('#company').fill('Example Rental Lab');
|
||||
await dialog.locator('#fleetSize').selectOption('10-24');
|
||||
await dialog
|
||||
.getByRole('button', { name: /Request my demo|Demander ma démonstration|إرسال طلب العرض/ })
|
||||
.click();
|
||||
|
||||
await expect(dialog.getByRole('heading', { name: labels.success })).toBeVisible();
|
||||
await expect(page).toHaveURL(new RegExp(`/${locale}$`));
|
||||
});
|
||||
}
|
||||
|
||||
test('duplicate clicks create only one request while submission is pending', async ({ page }) => {
|
||||
await page.goto('/en');
|
||||
await page.locator('main button[data-demo-source="hero"]').click();
|
||||
const dialog = page.getByRole('dialog', { name: 'Book a product demo' });
|
||||
await dialog.locator('#fullName').fill('Avery Example');
|
||||
await dialog.locator('#workEmail').fill('avery@example.test');
|
||||
await dialog.locator('#company').fill('Example Rental Lab');
|
||||
await dialog.locator('#fleetSize').selectOption('10-24');
|
||||
const submit = dialog.getByRole('button', { name: 'Request my demo' });
|
||||
await submit.dblclick();
|
||||
await expect(dialog.getByRole('heading', { name: 'Demo request recorded' })).toBeVisible();
|
||||
});
|
||||
|
||||
test('demo form does not place personal data in the URL or analytics sink', async ({ page }) => {
|
||||
await page.goto('/en');
|
||||
await page.locator('main button[data-demo-source="hero"]').click();
|
||||
const dialog = page.getByRole('dialog', { name: 'Book a product demo' });
|
||||
await dialog.locator('#fullName').fill('Private Example');
|
||||
await dialog.locator('#workEmail').fill('private@example.test');
|
||||
await dialog.locator('#company').fill('Private Rentals');
|
||||
await dialog.locator('#fleetSize').selectOption('1-9');
|
||||
await dialog.getByRole('button', { name: 'Request my demo' }).click();
|
||||
await expect(dialog.getByRole('heading', { name: 'Demo request recorded' })).toBeVisible();
|
||||
expect(page.url()).not.toContain('private');
|
||||
const analytics = await page.evaluate(() => window.__rdgAnalyticsTestSink ?? []);
|
||||
expect(JSON.stringify(analytics)).not.toContain('private');
|
||||
});
|
||||
@@ -0,0 +1,58 @@
|
||||
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();
|
||||
});
|
||||
|
||||
|
||||
test.describe('primary navigation visibility', () => {
|
||||
test.use({ viewport: { width: 1024, height: 768 } });
|
||||
|
||||
test('keeps the complete navbar visible at common laptop widths', async ({ page }) => {
|
||||
await page.goto('/en');
|
||||
|
||||
const navigation = page.getByRole('navigation', { name: 'Primary navigation' }).first();
|
||||
await expect(navigation).toBeVisible();
|
||||
|
||||
for (const label of ['Product', 'Workflow', 'Modules', 'Pricing', 'FAQ']) {
|
||||
await expect(navigation.getByRole('link', { name: label, exact: true })).toBeVisible();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('mobile primary navigation destinations', () => {
|
||||
test.use({ viewport: { width: 390, height: 844 } });
|
||||
|
||||
test('keeps every navbar destination in the mobile drawer', async ({ page }) => {
|
||||
await page.goto('/en');
|
||||
await page.getByRole('button', { name: 'Open navigation menu' }).click();
|
||||
|
||||
const dialog = page.getByRole('dialog', { name: 'Primary navigation' });
|
||||
for (const label of ['Product', 'Workflow', 'Modules', 'Pricing', 'FAQ']) {
|
||||
await expect(dialog.getByRole('link', { name: label, exact: true })).toBeVisible();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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' })]),
|
||||
);
|
||||
});
|
||||
+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."
|
||||
}
|
||||
@@ -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,97 @@
|
||||
import { clearIdempotencyStoreForTests } from '@/lib/demo/idempotency';
|
||||
import type { LeadDestinationAdapter } from '@/lib/demo/adapters';
|
||||
import { submitDemoLead } from '@/lib/demo/service';
|
||||
import type { DemoLead } from '@/lib/demo/schema';
|
||||
import type { IntegrationEnvironment } from '@/lib/integrations/environment';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const environment: IntegrationEnvironment = {
|
||||
NODE_ENV: 'test',
|
||||
NEXT_PUBLIC_DEMO_ENABLED: true,
|
||||
DEMO_SUBMISSION_MODE: 'local',
|
||||
DEMO_REQUEST_TIMEOUT_MS: 250,
|
||||
DEMO_IDEMPOTENCY_TTL_SECONDS: 900,
|
||||
DEMO_TEST_SCENARIOS_ENABLED: true,
|
||||
DEMO_LOCAL_TEST_MODE: false,
|
||||
NEXT_PUBLIC_ANALYTICS_MODE: 'disabled',
|
||||
};
|
||||
|
||||
const lead: DemoLead = {
|
||||
fullName: 'Avery Example',
|
||||
workEmail: 'avery@example.test',
|
||||
company: 'Example Rental Lab',
|
||||
fleetSize: '10-24',
|
||||
preferredLanguage: 'en',
|
||||
idempotencyKey: '6b4992d5-d88e-4e44-9b66-adc18fd8ee37',
|
||||
source: 'hero',
|
||||
};
|
||||
|
||||
beforeEach(clearIdempotencyStoreForTests);
|
||||
|
||||
describe('demo submission orchestration', () => {
|
||||
it('confirms acceptance before returning success', async () => {
|
||||
const adapter: LeadDestinationAdapter = {
|
||||
name: 'test-success',
|
||||
submitLead: vi.fn(async () => ({
|
||||
ok: true as const,
|
||||
externalId: 'local_test',
|
||||
acceptedAt: '2026-06-25T00:00:00.000Z',
|
||||
mode: 'local' as const,
|
||||
})),
|
||||
};
|
||||
const result = await submitDemoLead(lead, { environment, adapter });
|
||||
expect(result.ok).toBe(true);
|
||||
expect(adapter.submitLead).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it('coalesces repeated transport with the same idempotency key', async () => {
|
||||
const adapter: LeadDestinationAdapter = {
|
||||
name: 'test-idempotent',
|
||||
submitLead: vi.fn(async () => ({
|
||||
ok: true as const,
|
||||
externalId: 'local_once',
|
||||
acceptedAt: '2026-06-25T00:00:00.000Z',
|
||||
mode: 'local' as const,
|
||||
})),
|
||||
};
|
||||
const [first, second] = await Promise.all([
|
||||
submitDemoLead(lead, { environment, adapter }),
|
||||
submitDemoLead(lead, { environment, adapter }),
|
||||
]);
|
||||
expect(first).toEqual(second);
|
||||
expect(adapter.submitLead).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it('normalizes adapter failures without exposing vendor bodies', async () => {
|
||||
const adapter: LeadDestinationAdapter = {
|
||||
name: 'test-error',
|
||||
submitLead: async () => ({
|
||||
ok: false,
|
||||
category: 'integration',
|
||||
retryable: true,
|
||||
code: 'normalized_failure',
|
||||
}),
|
||||
};
|
||||
const result = await submitDemoLead(lead, { environment, adapter });
|
||||
expect(result).toMatchObject({
|
||||
ok: false,
|
||||
category: 'integration',
|
||||
formErrorCode: 'normalized_failure',
|
||||
retryable: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('bounds a hung integration with a timeout', async () => {
|
||||
const adapter: LeadDestinationAdapter = {
|
||||
name: 'test-timeout',
|
||||
submitLead: (_lead, context) =>
|
||||
new Promise((_resolve, reject) => {
|
||||
context.signal.addEventListener('abort', () =>
|
||||
reject(new DOMException('Aborted', 'AbortError')),
|
||||
);
|
||||
}),
|
||||
};
|
||||
const result = await submitDemoLead(lead, { environment, adapter });
|
||||
expect(result).toMatchObject({ ok: false, category: 'timeout', retryable: true });
|
||||
});
|
||||
});
|
||||
@@ -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,44 @@
|
||||
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.pricing.fleetBands).toHaveLength(4);
|
||||
expect(content.pricing.plans).toHaveLength(3);
|
||||
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.actionDisabledReason).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,30 @@
|
||||
import { analyticsEventRegistry, validateAnalyticsEvent } from '@/lib/analytics/events';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
const context = {
|
||||
routeId: 'home',
|
||||
locale: 'en',
|
||||
direction: 'ltr',
|
||||
resolvedTheme: 'light',
|
||||
source: 'hero',
|
||||
} as const;
|
||||
|
||||
describe('analytics contract', () => {
|
||||
it('accepts allowlisted non-personal demo events', () => {
|
||||
expect(validateAnalyticsEvent('demo_open', context)).toEqual({ name: 'demo_open', context });
|
||||
});
|
||||
|
||||
it('rejects unknown events and payload properties', () => {
|
||||
expect(validateAnalyticsEvent('lead_created', context)).toBeNull();
|
||||
expect(
|
||||
validateAnalyticsEvent('demo_open', { ...context, email: 'person@example.test' }),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it('marks every registry entry as non-personal and consent-gated', () => {
|
||||
expect(analyticsEventRegistry.every((event) => !event.personalData)).toBe(true);
|
||||
expect(analyticsEventRegistry.every((event) => event.consentCategory === 'analytics')).toBe(
|
||||
true,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,19 @@
|
||||
import { isApplicationJson } from '@/lib/security/content-type';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
describe('demo API content type validation', () => {
|
||||
it.each([
|
||||
'application/json',
|
||||
'application/json; charset=utf-8',
|
||||
' Application/JSON ; charset=UTF-8',
|
||||
])('accepts %s', (value) => {
|
||||
expect(isApplicationJson(value)).toBe(true);
|
||||
});
|
||||
|
||||
it.each([null, '', 'text/plain', 'application/jsonp', 'application/json-patch+json'])(
|
||||
'rejects %s',
|
||||
(value) => {
|
||||
expect(isApplicationJson(value)).toBe(false);
|
||||
},
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,46 @@
|
||||
import { demoLeadSchema, demoSubmissionEnvelopeSchema, fleetSizeValues } from '@/lib/demo/schema';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
const validLead = {
|
||||
fullName: ' Ada Example ',
|
||||
workEmail: ' ADA@EXAMPLE.TEST ',
|
||||
company: ' Rental Test Labs ',
|
||||
fleetSize: '25-49',
|
||||
market: '',
|
||||
preferredLanguage: 'en',
|
||||
message: ' Reservation workflow ',
|
||||
idempotencyKey: '154249a8-0c3c-4ad8-bda6-91b1ffb361a8',
|
||||
source: 'hero',
|
||||
} as const;
|
||||
|
||||
describe('demo lead schema', () => {
|
||||
it('normalizes approved fields without adding sales-enrichment data', () => {
|
||||
const result = demoLeadSchema.parse(validLead);
|
||||
expect(result).toEqual({
|
||||
...validLead,
|
||||
fullName: 'Ada Example',
|
||||
workEmail: 'ada@example.test',
|
||||
company: 'Rental Test Labs',
|
||||
market: undefined,
|
||||
message: 'Reservation workflow',
|
||||
});
|
||||
});
|
||||
|
||||
it('rejects unknown fields and unsupported fleet sizes', () => {
|
||||
expect(() => demoLeadSchema.parse({ ...validLead, phone: '+1 555 0100' })).toThrow();
|
||||
expect(() => demoLeadSchema.parse({ ...validLead, fleetSize: '5000+' })).toThrow();
|
||||
});
|
||||
|
||||
it('keeps the Phase 9 fleet ranges stable', () => {
|
||||
expect(fleetSizeValues).toEqual(['1-9', '10-24', '25-49', '50-99', '100-249', '250+']);
|
||||
});
|
||||
|
||||
it('rejects transport fields outside the approved envelope', () => {
|
||||
expect(() =>
|
||||
demoSubmissionEnvelopeSchema.parse({
|
||||
lead: validLead,
|
||||
guard: { honeypot: '', startedAtMs: 1, fingerprint: 'nope' },
|
||||
}),
|
||||
).toThrow();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,27 @@
|
||||
import { destinationRegistry, resolveDestination } from '@/lib/integrations/destinations';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
describe('destination registry', () => {
|
||||
it('activates only the safe local demo destination', () => {
|
||||
const registry = destinationRegistry({ demoSubmissionEnabled: true, demoMode: 'local' });
|
||||
expect(registry['book-demo'].status).toBe('active');
|
||||
expect(registry['contact-sales'].status).toBe('blocked');
|
||||
expect(registry['sign-in'].status).toBe('active');
|
||||
expect(registry.pricing.status).toBe('placeholder');
|
||||
});
|
||||
|
||||
it('fails the demo destination closed when integration is disabled', () => {
|
||||
const registry = destinationRegistry({ demoSubmissionEnabled: false, demoMode: 'blocked' });
|
||||
expect(registry['book-demo'].status).toBe('blocked');
|
||||
});
|
||||
|
||||
it('resolves approved localized placeholder routes without guessing external URLs', () => {
|
||||
expect(
|
||||
resolveDestination('privacy', 'fr', { demoSubmissionEnabled: false, demoMode: 'blocked' })
|
||||
.href,
|
||||
).toBe('/fr/confidentialite');
|
||||
expect(
|
||||
resolveDestination('sign-in', 'en', { demoSubmissionEnabled: false, demoMode: 'blocked' }).href,
|
||||
).toBe('/en/sign-in');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,21 @@
|
||||
import { containsSensitiveKey, redactSensitive } from '@/lib/demo/redaction';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
describe('privacy-safe diagnostics', () => {
|
||||
it('redacts nested personal fields', () => {
|
||||
expect(
|
||||
redactSensitive({
|
||||
category: 'validation',
|
||||
lead: { workEmail: 'person@example.test', company: 'Example Rentals' },
|
||||
}),
|
||||
).toEqual({
|
||||
category: 'validation',
|
||||
lead: { workEmail: '[REDACTED]', company: '[REDACTED]' },
|
||||
});
|
||||
});
|
||||
|
||||
it('detects sensitive diagnostic structures', () => {
|
||||
expect(containsSensitiveKey({ context: { message: 'private' } })).toBe(true);
|
||||
expect(containsSensitiveKey({ category: 'timeout', correlationId: 'abc' })).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -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('sign-in', '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,42 @@
|
||||
import { PricingSection } from '@/components/homepage/sections/PricingSection';
|
||||
import enHomepage from '@/content/locales/en/homepage.json';
|
||||
import enShell from '@/content/locales/en/shell.json';
|
||||
import { buildHomepageContent } from '@/content/homepage-model';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { beforeEach, describe, expect, it } from 'vitest';
|
||||
|
||||
describe('PricingSection', () => {
|
||||
beforeEach(() => {
|
||||
document.documentElement.dataset.theme = 'light';
|
||||
});
|
||||
|
||||
it('changes the recommended plan when the fleet band changes', async () => {
|
||||
const user = userEvent.setup();
|
||||
const content = buildHomepageContent(enHomepage, enShell).pricing;
|
||||
const { container } = render(
|
||||
<PricingSection content={content} locale="en" homePath="/en" demoSubmissionEnabled />,
|
||||
);
|
||||
|
||||
expect(container.querySelector('[data-plan="launch"]')).toHaveAttribute(
|
||||
'data-recommended',
|
||||
'true',
|
||||
);
|
||||
|
||||
await user.click(screen.getByLabelText('26–75 vehicles'));
|
||||
|
||||
expect(container.querySelector('[data-plan="growth"]')).toHaveAttribute(
|
||||
'data-recommended',
|
||||
'true',
|
||||
);
|
||||
expect(container.querySelector('[data-plan="launch"]')).not.toHaveAttribute('data-recommended');
|
||||
});
|
||||
|
||||
it('keeps unapproved public prices out of the rendered section', () => {
|
||||
const content = buildHomepageContent(enHomepage, enShell).pricing;
|
||||
render(<PricingSection content={content} locale="en" homePath="/en" demoSubmissionEnabled />);
|
||||
|
||||
expect(screen.getAllByText('Starting price pending approval')).toHaveLength(2);
|
||||
expect(screen.getByText('Custom pricing')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,25 @@
|
||||
import { buildHomepageContent } from '@/content/homepage-model';
|
||||
import enHomepage from '@/content/locales/en/homepage.json';
|
||||
import enShell from '@/content/locales/en/shell.json';
|
||||
import { pricingCommercialConfig } from '@/content/pricing-config';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
describe('homepage pricing configuration', () => {
|
||||
it('keeps commercial values in one guarded configuration', () => {
|
||||
expect(pricingCommercialConfig.publicPricesApproved).toBe(false);
|
||||
expect(pricingCommercialConfig.currency).toBe('USD');
|
||||
expect(pricingCommercialConfig.annualDiscountPercent).toBeNull();
|
||||
});
|
||||
|
||||
it('maps every fleet band and plan from localized content', () => {
|
||||
const pricing = buildHomepageContent(enHomepage, enShell).pricing;
|
||||
|
||||
expect(pricing.fleetBands.map((band) => band.id)).toEqual([
|
||||
'small',
|
||||
'growing',
|
||||
'scale',
|
||||
'enterprise',
|
||||
]);
|
||||
expect(pricing.plans.map((plan) => plan.id)).toEqual(['launch', 'growth', 'enterprise']);
|
||||
});
|
||||
});
|
||||
@@ -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,21 @@
|
||||
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 }) => {
|
||||
test.skip(test.info().project.name !== 'chromium', 'Canonical visual baselines use Chromium.');
|
||||
await page.addInitScript(
|
||||
(theme) => localStorage.setItem('hpc.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,24 @@
|
||||
import { expect, test } from '@playwright/test';
|
||||
|
||||
for (const scenario of [
|
||||
{ locale: 'en', theme: 'light' },
|
||||
{ locale: 'en', theme: 'dark' },
|
||||
{ locale: 'fr', theme: 'light' },
|
||||
{ locale: 'ar', theme: 'light' },
|
||||
{ locale: 'ar', theme: 'dark' },
|
||||
] as const) {
|
||||
test(`${scenario.locale} ${scenario.theme} demo initial and validation states`, async ({
|
||||
page,
|
||||
}) => {
|
||||
test.skip(test.info().project.name !== 'chromium', 'Canonical visual baselines use Chromium.');
|
||||
await page.emulateMedia({ colorScheme: scenario.theme });
|
||||
await page.goto(`/${scenario.locale}`);
|
||||
await page.locator('main button[data-demo-source="hero"]').click();
|
||||
const dialog = page.getByRole('dialog');
|
||||
await expect(dialog).toHaveScreenshot(`demo-${scenario.locale}-${scenario.theme}-initial.png`);
|
||||
await dialog.locator('button[type="submit"]').click();
|
||||
await expect(dialog).toHaveScreenshot(
|
||||
`demo-${scenario.locale}-${scenario.theme}-validation.png`,
|
||||
);
|
||||
});
|
||||
}
|
||||
@@ -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