jcloude/dashboard/tests-e2e/tests/cron/signup.cron.spec.ts
2025-12-23 23:08:43 +08:00

275 lines
12 KiB
TypeScript

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<T>(label: string, fn: () => Promise<T>, stepCap?: number): Promise<T> {
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);
});
}