Files
CompanySite/tests/inline-lead-form.spec.ts
WorkRoot AgentandClaude Opus 4.8 5a6db9c20f
Ping Search Engines / Notify Search Engines (push) Successful in 3s
E2E Test Suite / Smoke Tests (P0) (push) Has been cancelled
E2E Test Suite / Critical User Journeys (push) Has been cancelled
E2E Test Suite / API Integration Tests (push) Has been cancelled
E2E Test Suite / Form Interaction Tests (push) Has been cancelled
E2E Test Suite / Destructive & Chaos Tests (push) Has been cancelled
E2E Test Suite / Cross-Browser Regression (chromium) (push) Has been cancelled
E2E Test Suite / Cross-Browser Regression (firefox) (push) Has been cancelled
E2E Test Suite / Cross-Browser Regression (webkit) (push) Has been cancelled
E2E Test Suite / Mobile Device Tests (push) Has been cancelled
E2E Test Suite / Security Header Tests (push) Has been cancelled
E2E Test Suite / Test Report Summary (push) Has been cancelled
Deploy to Production / Pre-Deploy Tests (push) Failing after 10m58s
Deploy to Production / Build & Verify (push) Failing after 16m37s
Deploy to Production / Deploy to Fly.io (push) Failing after 14m3s
Deploy to Production / Deploy to VPS (PM2) (push) Failing after 14m3s
Deploy to Production / Deploy to Render (push) Failing after 14m55s
Deploy to Production / Deploy to Railway (push) Failing after 14m55s
Deploy to Production / Post-Deploy Verification (push) Failing after 13m32s
Deploy to Production / Notify on Failure (push) Failing after 13m51s
feat(homepage): expand inline lead form to name/email/phone/type/message
Rework the homepage InlineLeadForm from a single combined "email or phone"
contact field to an explicit 5-field layout — name, email (type=email),
phone (type=tel), project type and an optional message — so each field gets
the correct mobile keyboard and leads carry a real email + phone separately.

- Required fields kept to name + email + project type for a low-friction
  mobile flow; phone and message are optional.
- Email uses type=email + inputmode=email; phone uses type=tel + inputmode=tel
  for the numeric keypad. Message is an optional textarea; when blank the
  client synthesizes a >=10 char summary so the /api/contact validator passes.
- Still posts to the same /api/contact endpoint tagged source=homepage-inline,
  delivering leads exactly the same way the /contact page does (no page nav).
- Update inline-lead-form.spec.ts to the new field set and keyboards.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-17 20:33:26 +05:30

248 lines
10 KiB
TypeScript

import { test, expect } from '@playwright/test';
/**
* Inline Lead Form — Homepage
*
* Covers:
* - The form is mounted on the homepage between FAQ/tech-stack and the
* final CTA band.
* - It exposes the compact 5-field layout (name, email, phone, project type,
* message) with the correct mobile keyboard hints per field. Only name,
* email and project type are required; phone and message are optional.
* - Filling the required fields and submitting calls /api/contact and shows
* an in-place thank-you state without a full-page navigation.
* - The submission is tagged `source: 'homepage-inline'` so leads can be
* segmented downstream.
* - The backend itself accepts the new inline payload shape
* (server-side validation check).
*/
test.describe('Inline Lead Form (homepage)', () => {
test('form is visible on the homepage with the compact field set and mobile keyboards', async ({ page }) => {
await page.goto('/');
const form = page.getByTestId('inline-lead-form');
await form.scrollIntoViewIfNeeded();
await expect(form).toBeVisible();
await expect(page.locator('#inline-lead-name')).toBeVisible();
await expect(page.locator('#inline-lead-email')).toBeVisible();
await expect(page.locator('#inline-lead-phone')).toBeVisible();
await expect(page.locator('#inline-lead-project-type')).toBeVisible();
await expect(page.locator('#inline-lead-message')).toBeVisible();
// Touch-target sanity: the submit button must be at least 48px tall.
const submit = page.locator('#inline-lead-submit');
const submitBox = await submit.boundingBox();
expect(submitBox).not.toBeNull();
expect(submitBox!.height).toBeGreaterThanOrEqual(48);
// Correct mobile keyboard per field.
await expect(page.locator('#inline-lead-email')).toHaveAttribute('type', 'email');
await expect(page.locator('#inline-lead-email')).toHaveAttribute('inputmode', 'email');
await expect(page.locator('#inline-lead-email')).toHaveAttribute('autocomplete', 'email');
await expect(page.locator('#inline-lead-phone')).toHaveAttribute('type', 'tel');
await expect(page.locator('#inline-lead-phone')).toHaveAttribute('inputmode', 'tel');
await expect(page.locator('#inline-lead-phone')).toHaveAttribute('autocomplete', 'tel');
// Phone + message are optional (no `required` attribute).
await expect(page.locator('#inline-lead-phone')).not.toHaveAttribute('required', /.*/);
await expect(page.locator('#inline-lead-message')).not.toHaveAttribute('required', /.*/);
});
test('blocks submit when required fields are empty', async ({ page }) => {
await page.goto('/');
const form = page.getByTestId('inline-lead-form');
await form.scrollIntoViewIfNeeded();
// Intercept so a real submit can't slip through if the validator fails.
let apiCalled = false;
await page.route('**/api/contact', async (route) => {
apiCalled = true;
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({ success: true, message: 'ok' }),
});
});
await page.locator('#inline-lead-submit').click();
// The blank-field errors should surface and the network should NOT be hit.
await expect(page.locator('#inline-lead-name-error')).toBeVisible();
await expect(page.locator('#inline-lead-email-error')).toBeVisible();
await expect(page.locator('#inline-lead-project-type-error')).toBeVisible();
expect(apiCalled).toBe(false);
});
test('fills the required fields and submits successfully', async ({ page }) => {
await page.goto('/');
const form = page.getByTestId('inline-lead-form');
await form.scrollIntoViewIfNeeded();
// Capture the outgoing payload so we can assert source/projectType tagging.
let captured: any = null;
await page.route('**/api/contact', async (route) => {
const req = route.request();
try {
captured = req.postDataJSON();
} catch {
captured = null;
}
await route.fulfill({
status: 200,
contentType: 'application/json',
headers: { 'Access-Control-Allow-Origin': '*' },
body: JSON.stringify({ success: true, message: 'Received.' }),
});
});
await page.locator('#inline-lead-name').fill('Inline Lead Tester');
await page.locator('#inline-lead-email').fill('inline-lead@example.com');
await page.locator('#inline-lead-project-type').selectOption('Web App');
await page.locator('#inline-lead-submit').click();
// Success state should replace the form in-place, no navigation.
await expect(page.getByTestId('inline-lead-success')).toBeVisible();
await expect(page.getByTestId('inline-lead-success')).toContainText(
"Thanks! We'll reply within 24 hours."
);
await expect(page).toHaveURL(/\/$|\/#?$/);
// Confirm the payload is tagged correctly.
expect(captured).toBeTruthy();
expect(captured.source).toBe('homepage-inline');
expect(captured.projectType).toBe('Web App');
expect(captured.name).toBe('Inline Lead Tester');
expect(captured.email).toBe('inline-lead@example.com');
expect(captured.subject).toBe('web-development');
// Message is synthesized when left blank so the backend's >=10 char rule passes.
expect(typeof captured.message).toBe('string');
expect(captured.message.length).toBeGreaterThanOrEqual(10);
// Honeypot must be empty for a real user submission.
expect(captured.website ?? '').toBe('');
});
test('sends the phone and a typed message through their own fields', async ({ page }) => {
await page.goto('/');
const form = page.getByTestId('inline-lead-form');
await form.scrollIntoViewIfNeeded();
let captured: any = null;
await page.route('**/api/contact', async (route) => {
try {
captured = route.request().postDataJSON();
} catch {
captured = null;
}
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({ success: true, message: 'Received.' }),
});
});
await page.locator('#inline-lead-name').fill('Phone Lead');
await page.locator('#inline-lead-email').fill('phone-lead@example.com');
await page.locator('#inline-lead-phone').fill('+91 95614 17403');
await page.locator('#inline-lead-project-type').selectOption('Government / GeM');
await page.locator('#inline-lead-message').fill('We need a GeM-ready procurement portal.');
await page.locator('#inline-lead-submit').click();
await expect(page.getByTestId('inline-lead-success')).toBeVisible();
expect(captured).toBeTruthy();
// The real email and phone are sent as-is in their own fields.
expect(captured.email).toBe('phone-lead@example.com');
expect(captured.phone).toBe('+91 95614 17403');
expect(captured.message).toBe('We need a GeM-ready procurement portal.');
expect(captured.subject).toBe('government-systems');
expect(captured.source).toBe('homepage-inline');
});
});
// ============================================================
// Server-side: /api/contact accepts the inline lead payload
// ============================================================
test.describe('API: inline-lead payload (/api/contact)', () => {
test('accepts a payload tagged source=homepage-inline', async ({ request }) => {
const response = await request.post('/api/contact', {
headers: { 'Content-Type': 'application/json' },
data: {
name: 'Inline API Test',
email: 'inline-api@example.com',
subject: 'web-development',
message:
'New inline lead from the homepage.\n\nProject type: Web App\nPreferred contact: inline-api@example.com',
source: 'homepage-inline',
projectType: 'Web App',
website: '',
},
});
// 200 success or 429 rate-limited (both acceptable in CI re-runs).
expect([200, 429]).toContain(response.status());
if (response.status() === 200) {
const body = (await response.json()) as { success: boolean };
expect(body.success).toBe(true);
}
});
test('accepts the new erp-solutions and government-systems subjects', async ({ request }) => {
for (const subject of ['erp-solutions', 'government-systems']) {
const response = await request.post('/api/contact', {
headers: { 'Content-Type': 'application/json' },
data: {
name: 'Inline API Subject Test',
email: `inline-${subject}@example.com`,
subject,
message: `Inline lead for ${subject}. Project type test.`,
source: 'homepage-inline',
projectType: subject === 'erp-solutions' ? 'ERP / Custom Software' : 'Government / GeM',
website: '',
},
});
// Either accepted or rate-limited — both prove the subject is recognized
// (a rejected subject would 422 instead).
expect([200, 429]).toContain(response.status());
}
});
test('still rejects payloads missing required fields', async ({ request }) => {
const response = await request.post('/api/contact', {
headers: { 'Content-Type': 'application/json' },
data: {
name: '',
email: 'inline-missing@example.com',
subject: 'web-development',
message: 'A message long enough to pass validation.',
source: 'homepage-inline',
website: '',
},
});
expect(response.status()).toBe(422);
const body = (await response.json()) as { success: boolean; error: string };
expect(body.success).toBe(false);
expect(body.error).toBeTruthy();
});
test('treats unknown source values as the safe default and still accepts the lead', async ({ request }) => {
const response = await request.post('/api/contact', {
headers: { 'Content-Type': 'application/json' },
data: {
name: 'Unknown Source Test',
email: 'inline-unknown@example.com',
subject: 'other',
message: 'Inline lead from an unrecognised source string.',
source: 'totally-made-up-source',
projectType: 'Other',
website: '',
},
});
expect([200, 429]).toContain(response.status());
});
});