/* eslint-disable */ /** * Onboarding workflow E2E — from invitation activation through wizard to completion. * * Tests: * 1. Token activation (auto-login via invitation link) * 2. Welcome page * 3. Wizard content steps * 4. Checklist step * 5. Activation step (password) * 6. Completion * 7. Invalid token → error page * 8. Admin provisional user list * * Run headed: * E2E_MAXIMIZED=1 E2E_TIMEOUT=120000 E2E_ACTION_TIMEOUT=12000 \ * npx playwright test --headed --project chromium onboarding-workflow --workers=1 */ import { test } from '@playwright/test'; import { execSync } from 'child_process'; import * as path from 'path'; import { attachObservers, observe, bodyHasTraceback, loginAndScope, OBS, BASE_URL } from '../../helpers/audit'; import { RoleName } from '../../helpers/helpers'; const PROJECT_ROOT = path.resolve(__dirname, '..', '..', '..'); const M = 'Onboarding'; const PROVISIONAL_EMAIL = 'e2e-provisional@px360.test'; type Page = import('@playwright/test').Page; function uv(args: string): string { return execSync(args, { cwd: PROJECT_ROOT }).toString(); } function seedProvisional(): string { const out = uv('uv run manage.py seed_e2e_provisional'); const m = out.match(/token=(\S+)/); if (!m) throw new Error('seed parse failed: ' + out); return m[1]; } function onboardingState(): Record { const out = uv(`uv run manage.py get_e2e_onboarding_state ${PROVISIONAL_EMAIL}`); const s: Record = {}; for (const line of out.split('\n')) { const i = line.indexOf('='); if (i > 0) s[line.slice(0, i)] = line.slice(i + 1); } return s; } async function login(page: Page, role: RoleName) { await page.context().clearCookies(); await loginAndScope(page, role, M); } test.describe('Onboarding flow', () => { test.describe.configure({ mode: 'serial' }); test('1. Token activation → welcome → wizard → completion', async ({ page }) => { attachObservers(page, M, 'provisional'); const token = seedProvisional(); observe(M, 'seed', 'INFO', `provisional user created, token=${token.slice(0, 8)}...`, {}); try { // ── 1a. Visit activation URL (auto-login via token) ─────────────────── await page.goto(`${BASE_URL}/accounts/onboarding/activate/${token}/`); await page.waitForLoadState('domcontentloaded'); await page.waitForTimeout(1000); const tb1 = await bodyHasTraceback(page); const onLogin = page.url().includes('/accounts/login'); const atWelcome = page.url().includes('onboarding/welcome') || page.url().includes('onboarding'); observe(M, '1-activate', tb1 ? 'FAIL' : onLogin ? 'FAIL' : 'PASS', tb1 ? `traceback: ${tb1}` : onLogin ? 'redirected to login (token invalid?)' : `activated, url=${page.url().slice(-60)}`, { url: page.url() }); // ── 1b. Welcome page ────────────────────────────────────────────────── if (atWelcome || !onLogin) { const body = (await page.textContent('body')) || ''; const hasWelcome = /welcome|onboarding|get.started|px360/i.test(body); observe(M, '2-welcome', hasWelcome ? 'PASS' : 'WARN', `welcome content: ${hasWelcome} (${body.length} chars)`, { url: page.url() }); } // ── 1c. Wizard content steps (walk steps 1-5 gracefully) ────────────── for (const step of [1, 2, 3, 4, 5]) { await page.goto(`${BASE_URL}/accounts/onboarding/wizard/step/${step}/`).catch(() => {}); await page.waitForLoadState('domcontentloaded').catch(() => {}); await page.waitForTimeout(500); const stepUrl = page.url(); const stepBody = (await page.textContent('body')) || ''; const stepOk = !stepUrl.includes('/accounts/login') && !await bodyHasTraceback(page); const hasContent = stepBody.length > 200; if (stepOk && hasContent) { observe(M, `3-step-${step}`, 'PASS', `step ${step} renders (${stepBody.length} chars)`, { url: stepUrl }); } else if (stepUrl.includes('checklist') || stepUrl.includes('activation') || stepUrl.includes('complete')) { observe(M, `3-step-${step}`, 'INFO', `step ${step} redirected to ${stepUrl.split('/').slice(-2).join('/')}`, { url: stepUrl }); break; // reached the end of content steps } else { observe(M, `3-step-${step}`, 'WARN', `step ${step}: url=${stepUrl.slice(-40)} content=${stepBody.length}`, { url: stepUrl }); break; } } // ── 1d. Checklist step ──────────────────────────────────────────────── await page.goto(`${BASE_URL}/accounts/onboarding/wizard/checklist/`); await page.waitForLoadState('domcontentloaded'); await page.waitForTimeout(500); const checklistBody = (await page.textContent('body')) || ''; const checklistRenders = !page.url().includes('/accounts/login') && !await bodyHasTraceback(page); const checklistItems = await page.locator('input[type="checkbox"], .checklist-item, [class*="acknowledge"]').count(); observe(M, '4-checklist', checklistRenders ? 'PASS' : 'FAIL', `checklist renders: ${checklistRenders}, items: ${checklistItems}`, { url: page.url() }); // ── 1e. Activation step (password set) ──────────────────────────────── await page.goto(`${BASE_URL}/accounts/onboarding/wizard/activation/`); await page.waitForLoadState('domcontentloaded'); await page.waitForTimeout(500); const actUrl = page.url(); const actBody = (await page.textContent('body')) || ''; const actRenders = !actUrl.includes('/accounts/login') && !await bodyHasTraceback(page); const hasPasswordForm = /password|set.*password|create.*password/i.test(actBody); observe(M, '5-activation', actRenders ? 'PASS' : 'WARN', `activation page: renders=${actRenders}, hasPasswordForm=${hasPasswordForm}, url=${actUrl.slice(-40)}`, { url: actUrl }); // Try to set a password if a form exists const pwInput = page.locator('input[name*="password"], input[type="password"]').first(); if (await pwInput.count()) { const testPassword = 'E2E@Test123'; await pwInput.fill(testPassword).catch(() => {}); // if there's a confirm field const pwConfirm = page.locator('input[name*="confirm"], input[type="password"]').nth(1); if (await pwConfirm.count()) await pwConfirm.fill(testPassword).catch(() => {}); // submit await page.locator('button[type="submit"], input[type="submit"]').first().click().catch(() => {}); await page.waitForLoadState('networkidle', { timeout: 8000 }).catch(() => {}); await page.waitForTimeout(1500); observe(M, '5-password-set', 'PASS', `password form submitted, url=${page.url().slice(-40)}`, { url: page.url() }); } else { observe(M, '5-password-set', 'WARN', 'no password form found on activation page', {}); } // ── 1f. Completion page ─────────────────────────────────────────────── await page.goto(`${BASE_URL}/accounts/onboarding/complete/`).catch(() => {}); await page.waitForLoadState('domcontentloaded').catch(() => {}); await page.waitForTimeout(500); const completeBody = (await page.textContent('body')) || ''; observe(M, '6-complete', completeBody.length > 200 ? 'PASS' : 'WARN', `completion page: ${completeBody.length} chars`, { url: page.url() }); // ── 1g. Verify state ────────────────────────────────────────────────── const st = onboardingState(); observe(M, '7-state', st.is_provisional === 'False' ? 'PASS' : 'WARN', `is_provisional=${st.is_provisional} ack=${st.acknowledgement_completed} pw=${st.has_usable_password}`, {}); } catch (e) { observe(M, 'flow', 'FAIL', `exception: ${(e as Error).message}`, {}); } }); test('2. Invalid token → error page', async ({ page }) => { attachObservers(page, M, 'anonymous'); try { await page.goto(`${BASE_URL}/accounts/onboarding/activate/invalid_token_12345/`); await page.waitForLoadState('domcontentloaded'); await page.waitForTimeout(500); const body = (await page.textContent('body')) || ''; const hasError = /invalid|expired|error|contact/i.test(body); const notLoggedIn = !page.url().includes('welcome'); observe(M, 'invalid-token', hasError && notLoggedIn ? 'PASS' : 'FAIL', `invalid token handled: errorShown=${hasError} notLoggedIn=${notLoggedIn}`, { url: page.url() }); } catch (e) { observe(M, 'invalid-token', 'FAIL', `exception: ${(e as Error).message}`, {}); } }); test('3. Admin provisional user list', async ({ page }) => { attachObservers(page, M, 'px_admin'); try { await login(page, 'px_admin'); await page.goto(`${BASE_URL}/accounts/onboarding/provisional/`); await page.waitForLoadState('domcontentloaded'); await page.waitForTimeout(1000); const tb = await bodyHasTraceback(page); const body = (await page.textContent('body')) || ''; const hasList = /provisional|onboarding|pending|invited/i.test(body); observe(M, 'admin-provisional-list', tb ? 'FAIL' : hasList ? 'PASS' : 'WARN', tb ? `traceback: ${tb}` : `provisional list: ${hasList ? 'renders' : 'no data'} (${body.length} chars)`, { url: page.url() }); } catch (e) { observe(M, 'admin-provisional-list', 'FAIL', `exception: ${(e as Error).message}`, {}); } }); }); test.afterAll(async () => { const counts = OBS.reduce>((a, o) => ((a[o.status] = (a[o.status] || 0) + 1), a), {}); console.log('\n=========== ONBOARDING SUMMARY ==========='); console.log('Total observations:', OBS.length, JSON.stringify(counts)); console.log('==========================================\n'); });