// End-to-end QA of the RM Circle required profile gate, driven through the real UI // with a real inbox session. Boot: LOCAL (default :3399), session cookie in COOKIE. import { pathToFileURL } from 'node:url'; const PW = 'D:/Projects/MarketingAgent/qa-tester/node_modules/playwright'; const { chromium } = (await import(pathToFileURL(PW + '/index.js').href)).default; const B = process.env.LOCAL || 'http://127.0.0.1:3399'; const TOKEN = process.env.TOKEN; // ctb.msid for position 21 const TOKEN2 = process.env.TOKEN2; // ctb.msid for position 49 (already complete) const ok = [], bad = []; const t = (n, c, extra) => { (c ? ok : bad).push(n + (c || !extra ? '' : ' -> ' + extra)); }; const browser = await chromium.launch(); const ctxFor = async (tok, mobile) => { const c = await browser.newContext(mobile ? { viewport: { width: 390, height: 844 }, isMobile: true, hasTouch: true } : { viewport: { width: 1280, height: 950 } }); if (tok) await c.addCookies([{ name: 'ctb.msid', value: tok, url: B }]); return c; }; const gateOpen = p => p.evaluate(() => !!document.querySelector('#pgCard')); const cardText = p => p.evaluate(() => { const c = document.querySelector('#pgCard'); return c ? c.innerText.replace(/\s+/g, ' ') : ''; }); // ---------- 1. the gate on the member's OWN page ---------- const ctx = await ctxFor(TOKEN); const p = await ctx.newPage(); await p.goto(B + '/my/21', { waitUntil: 'networkidle' }); await p.waitForTimeout(2500); t('gate appears on the owner\'s own page', await gateOpen(p)); t('gate names the position and step', /position #21 . step 1 of 2/i.test(await cardText(p)), await cardText(p)); // cannot be dismissed await p.keyboard.press('Escape'); await p.waitForTimeout(400); t('Escape does not dismiss the required gate', await gateOpen(p)); await p.mouse.click(8, 8); await p.waitForTimeout(400); t('backdrop click does not dismiss the required gate', await gateOpen(p)); t('no close button on the required gate', !(await p.evaluate(() => !!document.querySelector('#pgClose')))); // username validation const typeUser = async v => { await p.fill('#pgUser', v); await p.click('#pgUserSave'); await p.waitForTimeout(600); }; await typeUser('ab'); t('rejects a short username', /3 to 20/.test(await cardText(p)), await cardText(p)); await typeUser('12345'); t('rejects digits only', /at least one letter/.test(await cardText(p))); await typeUser('admin'); t('rejects a reserved name', /reserved/.test(await cardText(p))); await typeUser('takenname'); t('accepts a good username and moves to step 2', /step 2 of 2/i.test(await cardText(p)), await cardText(p)); // email step t('step 2 explains why and promises privacy', /never shown to other members/i.test(await cardText(p))); await p.fill('#pgEmail', 'not-an-email'); await p.click('#pgMailSend'); await p.waitForTimeout(700); t('rejects a malformed email', /does not look right/.test(await cardText(p)), await cardText(p)); // grab the dev code straight off the API response let devCode = null; p.on('response', async r => { if (r.url().includes('/profile/email-start')) { try { const j = await r.json(); if (j.devCode) devCode = j.devCode; } catch (e) {} } }); await p.fill('#pgEmail', 'qa-member@example.com'); await p.click('#pgMailSend'); await p.waitForTimeout(1400); t('code is sent and the code box appears', /Code sent to qa\*\*\*@example\.com/.test(await cardText(p)), await cardText(p)); t('the reply masks the address', /qa\*\*\*@example\.com/.test(await cardText(p))); await p.fill('#pgCode', '000000'); await p.click('#pgCodeConfirm'); await p.waitForTimeout(800); t('wrong code refused', /does not match/.test(await cardText(p)), await cardText(p)); t('dev code exposed locally for the test', !!devCode, String(devCode)); await p.fill('#pgCode', devCode || '000000'); await p.click('#pgCodeConfirm'); await p.waitForTimeout(1200); t('right code completes the profile', /all set, @takenname/i.test(await cardText(p)), await cardText(p)); await p.waitForTimeout(3200); t('gate closes itself after completion', !(await gateOpen(p))); // ---------- 2. the profile card, and editing ---------- t('completion lands on the dashboard tab', await p.evaluate(() => { const d = document.getElementById('tabDash'); return !!d && getComputedStyle(d).display !== 'none'; })); await p.reload({ waitUntil: 'networkidle' }); await p.waitForTimeout(2000); await p.evaluate(() => { const b = document.querySelector('.mp-tab[data-tab="dash"]'); if (b) b.click(); }); await p.waitForTimeout(1800); t('no gate on a completed profile', !(await gateOpen(p))); const body = await p.evaluate(() => document.body.innerText.replace(/\s+/g, ' ')); t('dashboard shows the profile card', /Your member profile/.test(body), body.slice(0, 120)); t('card shows the username', /@takenname/.test(body)); t('card shows the confirmed email', /qa-member@example\.com/.test(body) && /confirmed/i.test(body)); t('Change username button present', await p.evaluate(() => !!document.querySelector('#pgEditUser'))); t('Change email button present', await p.evaluate(() => !!document.querySelector('#pgEditMail'))); await p.click('#pgEditUser'); await p.waitForTimeout(900); t('edit modal opens', await gateOpen(p)); t('edit modal HAS a close button', await p.evaluate(() => !!document.querySelector('#pgClose'))); await p.click('#pgClose'); await p.waitForTimeout(600); t('edit modal can be dismissed', !(await gateOpen(p))); await p.click('#pgEditUser'); await p.waitForTimeout(800); await p.fill('#pgUser', 'renamedqa'); await p.click('#pgUserSave'); await p.waitForTimeout(1400); t('rename saves and closes', !(await gateOpen(p))); await p.waitForTimeout(1200); t('card shows the new username', /@renamedqa/.test(await p.evaluate(() => document.body.innerText))); // ---------- 3. scoping: never on someone else's page ---------- const p2 = await ctx.newPage(); await p2.goto(B + '/my/49', { waitUntil: 'networkidle' }); await p2.waitForTimeout(2000); await p2.evaluate(() => { const b = document.querySelector('.mp-tab[data-tab="dash"]'); if (b) b.click(); }); await p2.waitForTimeout(1600); t('no gate while browsing another position', !(await gateOpen(p2))); t('no profile card on another position\'s page', !/Your member profile/.test(await p2.evaluate(() => document.body.innerText))); t('the other page still renders', (await p2.evaluate(() => document.body.innerText)).length > 400); // an INCOMPLETE member browsing someone else's page must also not be gated const ctx2 = await ctxFor(TOKEN2); const p3 = await ctx2.newPage(); await p3.goto(B + '/my/21', { waitUntil: 'networkidle' }); await p3.waitForTimeout(2600); t('incomplete member is not gated on a teammate page', !(await gateOpen(p3))); await p3.goto(B + '/my/49', { waitUntil: 'networkidle' }); await p3.waitForTimeout(2600); t('incomplete member IS gated on their own page', await gateOpen(p3)); t('their gate starts at the username step', /step 1 of 2/i.test(await cardText(p3)), await cardText(p3)); await p3.fill('#pgUser', 'seeded49'); await p3.click('#pgUserSave'); await p3.waitForTimeout(900); t('seeded email is pre-filled at step 2', await p3.evaluate(() => { const i = document.querySelector('#pgEmail'); return i ? i.value : ''; }) === 'seeded49@example.com', await p3.evaluate(() => { const i = document.querySelector('#pgEmail'); return i ? i.value : 'no input'; })); t('copy tells them it is already on file', /already have this one on file/i.test(await cardText(p3)), await cardText(p3)); // ---------- 4. visitors ---------- const ctxAnon = await ctxFor(null); const p4 = await ctxAnon.newPage(); const anon401 = []; p4.on('response', r => { if (r.status() === 401 && r.url().includes('/profile')) anon401.push(r.url()); }); for (const u of ['/my/21', '/my/49', '/join/21', '/fast-start?id=21', '/generation-pay?id=21', '/flyers?id=21']) { await p4.goto(B + u, { waitUntil: 'domcontentloaded' }); await p4.waitForTimeout(1600); const txt = await p4.evaluate(() => document.body.innerText); t('visitor: ' + u + ' renders with no gate', !(await gateOpen(p4)) && txt.length > 300, 'len ' + txt.length); } t('visitor sees no 401 from the profile endpoint', anon401.length === 0, anon401.join(',')); // ---------- 5. phone width ---------- const ctxM = await ctxFor(TOKEN2, true); const p5 = await ctxM.newPage(); await p5.goto(B + '/my/49', { waitUntil: 'networkidle' }); await p5.waitForTimeout(2600); t('gate renders on a phone', await gateOpen(p5)); const fits = await p5.evaluate(() => { const c = document.querySelector('#pgCard'); if (!c) return false; const r = c.getBoundingClientRect(); return r.width <= window.innerWidth && r.left >= 0; }); t('gate fits the phone viewport', fits); const noHScroll = await p5.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth + 2); t('no horizontal scroll on a phone', noHScroll); console.log('\nPASS ' + ok.length); for (const b of bad) console.log('FAIL ' + b); await browser.close(); process.exit(bad.length ? 1 : 0);