Member profiles are optional: an invitation on the dashboard, never a gate

Manson's objection was that requiring a username and a verified email pulls the
build back toward a centralized database of members. He is right, and the
communication gap is real too, so the answer is to ask well rather than to force.

Nothing about holding a position, getting paid, reading the org, the training or
the tools depends on contact details any more. There is no onboarding gate: a
brand-new member registers, lands on their page and is never stopped by a modal.
The dashboard offers a dismissable card ("Not now" snoozes it for a week) that
leads with the thing members actually want, a note the moment a payout lands in
their wallet, and says outright that everything works the same without it. The
inbox is the one place that asks, because a message cannot be delivered to
someone who left no way to reach them, and even there it is an invitation.

The card sits above the tab strip rather than inside the dashboard pane: the page
opens on the pitch tab, so an invitation parked in the dashboard would never be
seen by the new members it is aimed at.

For leaders, /api/public/reach answers "how many of my org can I reach off the
site", scoped by chain.isInTeam so it leaks nothing upward or sideways. That
makes coverage a leader's own problem to solve by asking, not a rule imposed on
members.

Fixes a real bug found by the rewritten suite: the dismissable flag double-booked
as "single-field edit", so saving a username in the opt-in flow closed the dialog
instead of advancing to the email step. Split into oneShot; the suite now asserts
the advance as a regression.

QA, all green: profiles-unit 28, signin-fallback 7, gate-e2e 33 (rewritten to
assert the opposite of what it used to: no forced modal, dismissable everywhere,
visitors unaffected), join-flow 12 cold / 11 refuse / 12 warm.

qa/reseed.sh carries two hard-won guards: never name a shell variable TMP on
Windows (it inherits the system temp dir and rm -rf wipes it), and never pkill.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
martbost
2026-09-17 04:44:09 -05:00
parent 0189278aa5
commit 91a6893df9
9 changed files with 386 additions and 225 deletions
+117 -128
View File
@@ -1,128 +1,117 @@
// 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);
// 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=<msid for 21> TOKEN2=<msid for 49> 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));
// ---------- 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);