// End-to-end QA of the RM Circle OPTIONAL member profile (Marty + Manson, 2026-09-17). // // The rule this suite defends: contact details are NEVER required. Nothing about // holding a position, getting paid, reading the org, the training or the tools may // depend on them. The only place they are asked for is the inbox, where a message // cannot be delivered without them, and even there it is an invitation. // // Run: LOCAL=... TOKEN= TOKEN2= node qa/gate-e2e.mjs 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; // position 21, no profile yet const TOKEN2 = process.env.TOKEN2; // position 49, seeded email, no username 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 } }); // The event flyer and the upgrade promo are full-screen overlays that legitimately // cover /my once per browser/session. They are not what this suite tests, and they // swallow clicks, so mark them already-seen instead of racing their fade-out. await c.addInitScript(() => { const g = Storage.prototype.getItem; Storage.prototype.getItem = function (k) { if (/^rmc-promo-/.test(k)) return 'seen'; if (/^rmc-announce-/.test(k)) return 'done'; return g.call(this, k); }; }); if (tok) await c.addCookies([{ name: 'ctb.msid', value: tok, url: B }]); return c; }; const modalOpen = p => p.evaluate(() => !!document.querySelector('#pgCard')); const cardText = p => p.evaluate(() => { const c = document.querySelector('#pgCard'); return c ? c.innerText.replace(/\s+/g, ' ') : ''; }); const openDash = async p => { await p.evaluate(() => { const b = document.querySelector('.mp-tab[data-tab="dash"]'); if (b) b.click(); }); await p.waitForTimeout(1800); }; // ---------- 1. nothing is forced ---------- const ctx = await ctxFor(TOKEN); const p = await ctx.newPage(); await p.goto(B + '/my/21', { waitUntil: 'networkidle' }); await p.waitForTimeout(2600); t('NO automatic modal on the member\'s own page', !(await modalOpen(p))); await openDash(p); t('still no modal after opening the dashboard', !(await modalOpen(p))); const body1 = await p.evaluate(() => document.body.innerText.replace(/\s+/g, ' ')); t('a dismissable invitation card is shown instead', /Get a note when you get paid/i.test(body1), body1.slice(0, 120)); t('the card says it is optional', /optional/i.test(body1)); t('the card promises nothing else changes', /work exactly the same without it/i.test(body1)); t('"Not now" is offered', await p.evaluate(() => !!document.getElementById('pcNo'))); // declining costs nothing and snoozes await p.click('#pcNo'); await p.waitForTimeout(600); t('declining removes the card', !(await p.evaluate(() => !!document.getElementById('pcNo')))); t('declining leaves the dashboard fully usable', (await p.evaluate(() => document.body.innerText)).length > 500); await p.reload({ waitUntil: 'networkidle' }); await p.waitForTimeout(2200); await openDash(p); t('the card stays away after declining (snoozed)', !(await p.evaluate(() => !!document.getElementById('pcNo')))); t('and still no modal', !(await modalOpen(p))); // ---------- 2. opting in works, and the dialog can be abandoned ---------- await p.evaluate(() => { try { localStorage.removeItem('rmc.profileSnooze'); } catch (e) {} }); await p.reload({ waitUntil: 'networkidle' }); await p.waitForTimeout(2200); await openDash(p); t('the card returns once the snooze is cleared', await p.evaluate(() => !!document.getElementById('pcGo'))); await p.click('#pcGo'); await p.waitForTimeout(1000); t('the dialog opens on request', await modalOpen(p)); t('the dialog can always be closed', await p.evaluate(() => !!document.getElementById('pgClose'))); t('it opens at the username step, worded as optional', /changeable any time/i.test(await cardText(p)), await cardText(p)); await p.click('#pgClose'); await p.waitForTimeout(600); t('abandoning the dialog is allowed', !(await modalOpen(p))); // complete it for real await p.click('#pcGo'); await p.waitForTimeout(900); 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('#pgUser', 'optin21'); await p.click('#pgUserSave'); await p.waitForTimeout(1100); // the regression that matters: saving a username must ADVANCE to the email step, // not close the dialog. The dismissable flag used to double as "single-field edit". t('saving the username advances to the email step', await p.evaluate(() => !!document.getElementById('pgEmail')), await cardText(p)); t('the email step leads with payout alerts, not messaging', /payout lands in your wallet/i.test(await cardText(p)), await cardText(p)); await p.fill('#pgEmail', 'optin@example.com'); await p.click('#pgMailSend'); await p.waitForTimeout(1400); await p.fill('#pgCode', devCode || '000000'); await p.click('#pgCodeConfirm'); await p.waitForTimeout(1500); t('opting in completes', /all set, @optin21/i.test(await cardText(p)) || !(await modalOpen(p)), await cardText(p)); await p.waitForTimeout(3200); await p.reload({ waitUntil: 'networkidle' }); await p.waitForTimeout(2200); await openDash(p); const body2 = await p.evaluate(() => document.body.innerText.replace(/\s+/g, ' ')); t('the invitation card is gone once done', !/Get a note when you get paid/i.test(body2)); t('the profile card shows their details', /Your member profile/.test(body2) && /@optin21/.test(body2), body2.slice(0, 120)); t('they can still change it later', await p.evaluate(() => !!document.getElementById('pgEditUser'))); // ---------- 3. the inbox asks only where it matters ---------- const ctx2 = await ctxFor(TOKEN2); const p3 = await ctx2.newPage(); await p3.goto(B + '/my/49', { waitUntil: 'networkidle' }); await p3.waitForTimeout(2400); await openDash(p3); t('no modal for the member without contact details', !(await modalOpen(p3))); const body3 = await p3.evaluate(() => document.body.innerText.replace(/\s+/g, ' ')); t('the inbox explains why it needs an address', /Messages reach you here only/i.test(body3), body3.slice(0, 140)); t('the inbox ask is phrased as optional', /Optional, private, removable/i.test(body3)); // Marty, 2026-09-17: this button shipped with NO click handler and did nothing at all. // Rendering it is not the test; it has to actually open the dialog. t('the inbox "Add mine" button exists', await p3.evaluate(() => !!document.getElementById('msgAddContact'))); await p3.click('#msgAddContact').catch(() => {}); await p3.waitForTimeout(1400); t('"Add mine" actually opens the profile dialog', await modalOpen(p3), 'button did nothing'); t('and that dialog uses the inbox wording', /inbox needs somewhere to reach you|reach you off the site/i.test(await cardText(p3)) || /username/i.test(await cardText(p3)), await cardText(p3)); t('and it can be closed', await p3.evaluate(() => !!document.getElementById('pgClose'))); await p3.click('#pgClose').catch(() => {}); await p3.waitForTimeout(500); t('closing it leaves the inbox usable', !(await modalOpen(p3))); // ---------- 4. nothing changed for visitors or shared links ---------- const anon = await ctxFor(null); const p4 = await anon.newPage(); const anon401 = []; p4.on('response', r => { if (r.status() === 401 && /\/profile|\/reach/.test(r.url())) 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(1500); const txt = await p4.evaluate(() => document.body.innerText); t('visitor: ' + u + ' renders, no prompt of any kind', !(await modalOpen(p4)) && !/Get a note when you get paid/i.test(txt) && txt.length > 300, 'len ' + txt.length); } t('visitor triggers no 401s', anon401.length === 0, anon401.join(',')); // ---------- 5. phone ---------- const ctxM = await ctxFor(TOKEN2, true); const p5 = await ctxM.newPage(); await p5.goto(B + '/my/49', { waitUntil: 'networkidle' }); await p5.waitForTimeout(2400); await openDash(p5); t('no forced modal on a phone', !(await modalOpen(p5))); t('no horizontal scroll on a phone', await p5.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth + 2)); console.log('PASS ' + ok.length); for (const b of bad) console.log('FAIL ' + b); await browser.close(); process.exit(bad.length ? 1 : 0);