phase 16 implementation
This commit is contained in:
@@ -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([]);
|
||||
});
|
||||
}
|
||||
@@ -18,11 +18,11 @@ for (const scenario of [
|
||||
await expect(page.locator('main > section')).toHaveCount(12);
|
||||
});
|
||||
|
||||
test(`${scenario.locale} unresolved actions are non-navigable`, async ({ page }) => {
|
||||
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(5);
|
||||
await expect(disabledActions).toHaveCount(3);
|
||||
await expect(disabledActions.first()).not.toHaveAttribute('href');
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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,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,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,22 @@
|
||||
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.login.status).toBe('blocked');
|
||||
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('login', 'en', { demoSubmissionEnabled: false, demoMode: 'blocked' }).href,
|
||||
).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -6,8 +6,9 @@ for (const scenario of [
|
||||
{ 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('rdg-theme-preference', theme),
|
||||
(theme) => localStorage.setItem('hpc.theme.preference', theme),
|
||||
scenario.theme,
|
||||
);
|
||||
await page.setViewportSize({ width: scenario.width, height: scenario.height });
|
||||
|
||||
@@ -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`,
|
||||
);
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user