import { test, expect, Page } from '@playwright/test'; import crypto from 'crypto'; function fetchProductTrials(): string[] { const raw = process.env.PRODUCT_TRIALS || ''; const products = raw.split(',').map(s => s.trim()).filter(Boolean); if (!products.length) { throw new Error('Set PRODUCT_TRIALS (comma separated) to run signup E2E tests.'); } return products; } // Per-product timeout (default 5m) and optional inactivity watchdog (env overrides) const PER_PRODUCT_TIMEOUT_MS = parseInt(process.env.SIGNUP_PER_PRODUCT_TIMEOUT_MS || '300000', 10); // 5 minutes default const INACTIVITY_LIMIT_MS = parseInt(process.env.SIGNUP_INACTIVITY_MS || '0', 10); // disabled by default function testEmail(product: string) { const rand = crypto.randomBytes(3).toString('hex'); return `fc-signup-test+${product}_${rand}@jingrowmail.com`; // return `playwright_${product}_${rand}@signup.test`; } async function runSignupFlow(page: Page, product: string) { const flowStart = Date.now(); let lastActivity = Date.now(); function remaining() { return PER_PRODUCT_TIMEOUT_MS - (Date.now() - flowStart); } function touch(label: string) { lastActivity = Date.now(); /* lightweight marker */ } function ensureTime(label: string) { if (remaining() <= 0) { throw new Error(`Per-product timeout (${PER_PRODUCT_TIMEOUT_MS}ms) exceeded at phase: ${label}`); } if (INACTIVITY_LIMIT_MS > 0 && Date.now() - lastActivity > INACTIVITY_LIMIT_MS) { throw new Error(`Inactivity > ${INACTIVITY_LIMIT_MS}ms detected at phase: ${label}`); } } async function runStep(label: string, fn: () => Promise, stepCap?: number): Promise { ensureTime(label + ':before'); const budget = remaining(); const timeoutCap = Math.min(stepCap || budget, budget); touch(label + ':start'); const result = await Promise.race([ fn(), page.waitForTimeout(timeoutCap).then(() => { throw new Error(`Step timeout (${label}) after ${timeoutCap}ms (remaining budget exhausted or step slow)`); }) ]); touch(label + ':done'); ensureTime(label + ':after'); return result; } // optional simple inactivity watcher (non-fatal; enforce via ensureTime on steps) if (INACTIVITY_LIMIT_MS > 0) { (async () => { while (remaining() > 0) { await page.waitForTimeout(Math.min(5000, Math.max(1000, INACTIVITY_LIMIT_MS / 4))); if (Date.now() - lastActivity > INACTIVITY_LIMIT_MS) { // Force a failure by triggering ensureTime on next step; or throw here if desired throw new Error(`Inactivity watcher: no activity for ${INACTIVITY_LIMIT_MS}ms in product ${product}`); } } })().catch(err => console.warn('[signup.spec] inactivity watcher error', err?.message)); } const email = testEmail(product.toLowerCase().replace(/\s+/g, '-')); await runStep('goto', () => page.goto(`/dashboard/signup?product=${encodeURIComponent(product)}`)); await runStep('wait-form', () => page.waitForSelector('form', { timeout: Math.min(30000, remaining()) })); await runStep('wait-email-selectors', () => page.waitForSelector([ 'button:has-text("Sign up with email")', 'input[type="email"]', 'input[name*="email" i]', 'input[placeholder*="email" i]' ].join(', '), { timeout: Math.min(45_000, remaining()) })); // Prefer explicitly labeled input if present; else fall back to broader selector. let emailInput = page.getByLabel(/email/i).first(); if (!(await emailInput.count())) { emailInput = page.locator('input[type="email"], input[name*="email" i], input[placeholder*="email" i]').first(); } await runStep('email-visible', () => emailInput.waitFor({ state: 'visible', timeout: Math.min(15_000, remaining()) })); await runStep('email-fill', () => emailInput.fill(email)); let accountRequestId: string | undefined; await runStep('signup-submit-and-response', () => Promise.all([ (async () => { const resp = await page.waitForResponse( (r) => r.url().includes('jcloude.api.account.signup') && r.status() === 200, ); try { const data = await resp.json(); accountRequestId = data.message as string; } catch (e) { // ignore } })(), (async () => { // slight delay to allow any debounce / validation to finish before submitting signup await page.waitForTimeout(400); await page.getByRole('button', { name: /sign up with email/i }).click(); })(), ])); const otpHelper = process.env.OTP_HELPER_ENDPOINT; let code: string | undefined; if (!accountRequestId) { throw new Error('Signup response did not return account_request id. Cannot fetch OTP.'); } if (otpHelper) { try { const baseHost = new URL(process.env.BASE_URL || 'http://localhost:8010').hostname; const otpRes = await fetch(`${otpHelper}?account_request=${encodeURIComponent(accountRequestId)}`, { headers: { 'X-Jingrow-Site-Name': baseHost } }); const txt = await otpRes.text(); if (otpRes.ok) { try { const json = JSON.parse(txt); code = json.message || json.code || json.otp; } catch { /* parse error ignored */ } } if (!code) { console.warn(`[signup.spec] OTP helper did not return code (status ${otpRes.status}). Falling back to 111111.`); } } catch (e) { console.warn(`[signup.spec] OTP fetch error ${(e as Error).message}. Falling back to 111111.`); } } else { console.warn('[signup.spec] OTP_HELPER_ENDPOINT not set; using fallback OTP 111111.'); } if (!code) { code = '111111'; } await runStep('fill-otp', () => page.getByLabel(/verification code/i).fill(code!)); await runStep('verify-otp', () => Promise.all([ page.waitForResponse((r) => r.url().includes('jcloude.api.account.verify_otp') && r.status() === 200), page.getByRole('button', { name: /verify/i }).click(), ])); await runStep('post-verify-redirect', () => page.waitForURL(/.*\/dashboard\/(setup-account|saas|create-site)\//, { timeout: Math.min(60_000, remaining()) })); if (page.url().includes('/dashboard/setup-account/')) { await Promise.race([ page.waitForSelector('form button:has-text("Create account")', { timeout: 30000 }).catch(() => null), page.waitForSelector('input[name="fname"], label:has-text("First name"), label:has-text("First Name")', { timeout: 30000 }).catch(() => null), ]); const firstName = page.locator('input[name="fname"]'); const lastName = page.locator('input[name="lname"]'); if (await firstName.count()) { await firstName.first().waitFor({ state: 'visible', timeout: 10000 }).catch(() => null); const val = await firstName.first().inputValue().catch(() => ''); if (!val) { await firstName.first().fill('Playwright').catch(() => null); } } if (await lastName.count()) { await lastName.first().waitFor({ state: 'visible', timeout: 10000 }).catch(() => null); const val = await lastName.first().inputValue().catch(() => ''); if (!val) { await lastName.first().fill('Tester').catch(() => null); } } const countryLabel = page.getByLabel(/country/i).first(); if (await countryLabel.count()) { try { const tag = await countryLabel.evaluate(el => el.tagName.toLowerCase()).catch(() => ''); if (tag === 'select') { const current = await countryLabel.inputValue().catch(() => ''); if (!current) { await countryLabel.selectOption({ label: 'India' }).catch(() => null); } } else { const existing = await countryLabel.inputValue().catch(() => ''); if (!existing) { await countryLabel.click({ timeout: 5000 }).catch(() => null); const indiaOption = page.locator('text=/^India$/'); if (await indiaOption.count()) { await indiaOption.first().click({ timeout: 5000 }).catch(() => null); } } } } catch { /* ignore */ } } await runStep('create-account', () => Promise.all([ page.waitForURL(/.*\/dashboard\/(saas|create-site)\//, { timeout: Math.min(60_000, remaining()) }), (async () => { await page.waitForTimeout(400); await page.getByRole('button', { name: /create account/i }).click(); })(), ])); } expect(page.url()).toMatch(/dashboard\/(saas|create-site)\//); if (/\/dashboard\/create-site\/[^/]+\/setup/.test(page.url())) { const siteInput = page.locator('form input'); await runStep('site-input-visible', () => siteInput.first().waitFor({ state: 'visible', timeout: Math.min(15000, remaining()) })); let currentVal = (await siteInput.first().inputValue().catch(() => ''))?.trim(); if (!currentVal) { const base = product.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 15) || 'site'; currentVal = `${base}-pw${crypto.randomBytes(2).toString('hex')}`.slice(0, 32); await siteInput.first().fill(currentVal); } let siteSubdomain = currentVal; // will re-read just before submission for any frontend normalization let domainSuffix = ''; try { const domainEl = page.locator('form .flex.cursor-default').filter({ hasText: /^\.[a-z0-9.-]+$/i }).first(); if (await domainEl.count()) { const raw = (await domainEl.innerText()).trim(); domainSuffix = raw.replace(/^\./, ''); } } catch { /* ignore */ } const createSiteDelay = 500; if (createSiteDelay > 0) await page.waitForTimeout(createSiteDelay); try { const latestVal = (await siteInput.first().inputValue()).trim(); if (latestVal) siteSubdomain = latestVal; } catch { /* ignore */ } const originHost = new URL(page.url()).host; const context = page.context(); const popupPromise = context.waitForEvent('page').catch(() => null); await runStep('create-site-submit', () => Promise.all([ page.waitForResponse(r => r.url().includes('jcloude.api.client.run_pg_method') && r.request().method() === 'POST' && (r.request().postData() || '').includes('create_site')), (async () => { await page.waitForTimeout(400); await page.getByRole('button', { name: /create site/i }).click(); })(), ])); let activePage: Page | null = await Promise.race([ popupPromise, (async () => { await page.waitForTimeout(4000); return null; })(), ]); if (!activePage) activePage = page; // Provisioning deadline capped by remaining product budget (max 4m internal cap) const provisionCap = Math.min(240_000, Math.max(10_000, remaining())); const deadline = Date.now() + provisionCap; let success = false; while (Date.now() < deadline && !success) { ensureTime('provision-loop'); const url = activePage.url(); let host = ''; try { host = new URL(url).host; } catch { /* ignore */ } if (host && host !== originHost && /\/app\//.test(url)) { if (domainSuffix && siteSubdomain) { const hostNoPort = host.split(':')[0].toLowerCase(); const expectedFull = `${siteSubdomain}.${domainSuffix}`.toLowerCase(); if (hostNoPort !== expectedFull) { const relaxedOk = hostNoPort.includes(siteSubdomain.toLowerCase()) && hostNoPort.endsWith(`.${domainSuffix.toLowerCase()}`); if (!relaxedOk) { throw new Error(`Provisioned site host mismatch. Expected ${expectedFull} (allowing variations), got ${hostNoPort}. Details: subdomain=${siteSubdomain} domainSuffix=${domainSuffix} finalUrl=${url}`); } } } success = true; break; } try { await Promise.race([ activePage.waitForEvent('framenavigated', { timeout: 3000 }), activePage.waitForTimeout(800), ]); } catch { /* ignore */ } } if (!success) { throw new Error(`Did not observe redirect to provisioned site app. Last URL: ${activePage.url()}`); } expect(activePage.url()).toMatch(/\/app\//); } } test.describe.configure({ mode: 'parallel' }); const products = fetchProductTrials(); for (const product of products) { test(`signup flow for product: ${product}`, async ({ page }) => { test.setTimeout(PER_PRODUCT_TIMEOUT_MS + 30_000); await runSignupFlow(page, product); }); }