Files
rm-circle-team-router/qa/gate-e2e.mjs
T
martbost 2f4b70c886 Members can take it back off, and OPTIONAL is now impossible to miss
Three things, one of which we were quietly getting wrong.

REAL OPT-OUT. We told members "removable any time" in three separate places and there
was no way to remove anything. Same class of failure as the dead "Add mine" button:
copy written, mechanism never built. The profile card now offers Remove username,
Remove email and Remove everything, and profiles.remove() clears the value while never
touching the position. Adding it again later works exactly as before, so opting out is
not a one-way door.

It is a two-step inline confirm, not a native confirm() dialog. Browsers with "suppress
dialogs" switched on return false, which would have made Remove look broken in precisely
the way Add mine was broken. First tap arms and explains the consequence, second tap
does it, and it disarms itself after six seconds.

THE PROMISE WE WERE BREAKING. The payout mailer and the upgrade alerts read
member-alerts.json, NOT profiles.json. So a member who completed the new profile got
NOTHING, while the invitation card promised "a note the moment POL lands in your
wallet". Verifying a profile email now mirrors into member-alerts.json so every existing
alert path works, including the unsubscribe link, and removing the email clears both
stores so opting out actually stops the email.

MANSON'S HUGE ASTERISK. He asked for it to be bigger and bolder so nobody can say they
did not see it, and on a decentralized build that burden is ours, not the member's. One
gold badge now appears on the dashboard invitation, inside the dialog on every step, in
the inbox banner and on the profile card itself: "100% OPTIONAL - never required", with
the plain statement that the position, the payouts, the team and everything on the page
work exactly the same without it, nothing on chain depends on it, and it can be removed
again any time.

gate-e2e is 52 assertions, up from 38. The new ones prove one tap does NOT remove
anything, the second tap does, the server agrees the value is gone, an email-only
removal leaves the username alone, and the invitation reappears afterwards so the whole
thing is reversible. profiles-unit 28, signin-fallback 7, captions-e2e 158 all green.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-17 06:52:43 -05:00

163 lines
12 KiB
JavaScript

// 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')));
// ---------- 2b. OPTIONAL is unmissable, and removal actually works ----------
// Manson asked for a HUGE asterisk so nobody can say they did not see it, and Marty
// asked for a real opt-out. "Removable any time" was written in three places with no
// way to do it, which is the same class of failure as the dead "Add mine" button.
const body2b = await p.evaluate(() => document.body.innerText.replace(/\s+/g, ' '));
t('the profile card shouts that it is optional', /100% OPTIONAL/i.test(body2b), body2b.slice(0, 120));
t('and says nothing is affected without it', /work exactly the same without it/i.test(body2b));
t('Remove username is offered', await p.evaluate(() => !!document.getElementById('pgDropUser')));
t('Remove email is offered', await p.evaluate(() => !!document.getElementById('pgDropMail')));
t('Remove everything is offered', await p.evaluate(() => !!document.getElementById('pgDropAll')));
// one tap ARMS, it must not remove anything yet
await p.click('#pgDropMail'); await p.waitForTimeout(500);
t('one tap only arms, nothing removed yet', /Tap again to remove/i.test(await p.evaluate(() => document.getElementById('pgDropMail').textContent)));
t('and it explains the consequence before the second tap',
/changes nothing about your position/i.test(await p.evaluate(() => (document.getElementById('pgDropMsg') || {}).textContent || '')));
const stillThere = await p.evaluate(() => document.body.innerText);
t('the email is still on file after one tap', /optin@example\.com/.test(stillThere));
// second tap removes it, for real, in the store
await p.click('#pgDropMail'); await p.waitForTimeout(2500);
const afterDrop = await p.evaluate(() => document.body.innerText.replace(/\s+/g, ' '));
t('second tap actually removes the email', !/optin@example\.com/.test(afterDrop), afterDrop.slice(0, 140));
const st = await p.evaluate(async () => (await (await fetch('/api/public/profile')).json()));
t('the server agrees the email is gone', st && st.profile && !st.profile.email, JSON.stringify(st && st.profile));
t('the username survived an email-only removal', st && st.profile && st.profile.username === 'optin21', JSON.stringify(st && st.profile));
t('removal drops them back to incomplete', st && st.profile && !st.profile.complete);
// and it is reversible: the invitation comes back
await p.evaluate(() => { try { localStorage.removeItem('rmc.profileSnooze'); } catch (e) {} });
await p.reload({ waitUntil: 'networkidle' }); await p.waitForTimeout(2200); await openDash(p);
t('after opting out the invitation returns, so it is reversible', await p.evaluate(() => !!document.getElementById('pcGo')));
// ---------- 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));
// Manson's ask: unmissable, not a grey aside. The badge is the phrasing now.
t('the inbox ask carries the loud OPTIONAL badge', /100% OPTIONAL/i.test(body3), body3.slice(0, 200));
t('and the inbox spells out that nothing else changes', /work exactly the same without it/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);