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:
+10
-1
@@ -159,6 +159,15 @@ function positionsFor(email) {
|
||||
const e = String(email || '').trim().toLowerCase();
|
||||
return Object.values(db.byId).filter(p => p.emailVerified && String(p.email || '') === e).map(p => p.id).sort((a, b) => a - b);
|
||||
}
|
||||
// Reach for one leader's org: how many of the positions below them have given a
|
||||
// way to be contacted. This is the number that makes coverage a leader's own
|
||||
// problem rather than a rule imposed on members.
|
||||
function reachFor(ids) {
|
||||
const list = (ids || []).map(Number).filter(Boolean);
|
||||
const reachable = [];
|
||||
for (const id of list) { const p = db.byId[norm(id)]; if (p && p.emailVerified && p.email) reachable.push(id); }
|
||||
return { total: list.length, reachable: reachable.length, ids: reachable.slice(0, 2000) };
|
||||
}
|
||||
function coverage() {
|
||||
const all = Object.values(db.byId);
|
||||
return { profiles: all.length, withUsername: all.filter(p => p.username).length,
|
||||
@@ -171,5 +180,5 @@ function adminList() { return Object.values(db.byId).map(pub).sort((a, b) => a.i
|
||||
// local testing only: the server exposes this outside production, never on the live site
|
||||
function peekCode(id) { const st = codes.get(norm(id)); return st ? st.code : null; }
|
||||
|
||||
module.exports = { init, get, pub, status, complete, isComplete, setUsername, byUsername, suggest, peekCode,
|
||||
module.exports = { init, get, pub, status, complete, isComplete, setUsername, byUsername, suggest, peekCode, reachFor,
|
||||
startEmail, verifyEmail, contactFor, setTelegram, positionsFor, coverage, adminList, ensure, USER_RE, EMAIL_RE };
|
||||
|
||||
+1
-1
@@ -31,5 +31,5 @@
|
||||
</section>
|
||||
</main>
|
||||
<footer class="wrap disclaimer">All figures are read live from the RM Circle smart contract on Polygon and are historical facts, not a promise of future results. Participation involves cryptocurrency and smart-contract risk. Never use funds you cannot afford to lose.<div class="footer-links"><a href="/">Home</a><a href="/contract">Contract Security</a><a href="/disclaimer">Disclaimers</a><a href="/tools">Promo Tools</a><a href="/privacy">Privacy</a><a href="/refunds">Refunds</a></div></footer>
|
||||
<script src="/track.js"></script><script src="/qrlib.js"></script><script src="/rmc-wallet.js"></script><script src="/inapp-browser.js"></script><script src="/profile-gate.js?v=20260916e"></script><script src="/my.js"></script><script src="/payouts.js" defer></script><script src="/chat.js" defer></script><script src="/translate.js" defer></script><script src="/tg-app.js" defer></script><script src="/wallet-notice.js" defer></script> <script src="/announce.js?v=20260916a"></script>
|
||||
<script src="/track.js"></script><script src="/qrlib.js"></script><script src="/rmc-wallet.js"></script><script src="/inapp-browser.js"></script><script src="/profile-gate.js?v=20260917a"></script><script src="/my.js"></script><script src="/payouts.js" defer></script><script src="/chat.js" defer></script><script src="/translate.js" defer></script><script src="/tg-app.js" defer></script><script src="/wallet-notice.js" defer></script> <script src="/announce.js?v=20260916a"></script>
|
||||
</body></html>
|
||||
|
||||
+63
-4
@@ -315,6 +315,45 @@
|
||||
}).catch(()=>{});
|
||||
loadMsgUI(d);
|
||||
}
|
||||
// A quiet, dismissable invitation on the member's own dashboard. Snoozed for a
|
||||
// week when dismissed; never shown on a teammate's page or to a visitor.
|
||||
async function maybeProfileCard(id){
|
||||
try{
|
||||
var snoozed=0; try{ snoozed=Number(localStorage.getItem('rmc.profileSnooze')||0); }catch(e){}
|
||||
if(Date.now()<snoozed) return;
|
||||
var pr=await window.RMCProfile.status();
|
||||
if(!pr||pr.signedIn===false||Number(pr.id)!==Number(id)) return;
|
||||
if(pr.profile&&pr.profile.complete) return;
|
||||
// Above the tab strip, NOT inside the dashboard pane. The page opens on the
|
||||
// pitch tab, so a member who just registered would never see an invitation
|
||||
// parked in the dashboard, and an invitation nobody sees collects nothing.
|
||||
var tabs=document.querySelector('#dash .mp-tabs'); if(!tabs) return;
|
||||
var box=document.createElement('div');
|
||||
box.className='table-card';
|
||||
box.style.cssText='margin:0 0 18px;border-color:rgba(78,214,203,.4)';
|
||||
box.innerHTML='<h2 style="margin:0 0 4px">Get a note when you get paid</h2>'+
|
||||
'<p style="color:var(--muted);font-size:13px;margin:0 0 12px">Add an email and we will tell you the moment POL lands in your wallet, and your sponsor can reach you when something needs you. '+
|
||||
'Completely optional, never shown to other members, never sold, and removable any time. Your position, your payouts and everything on this page work exactly the same without it.</p>'+
|
||||
'<button id="pcGo" class="btn btn-teal btn-sm">Set this up</button> '+
|
||||
'<button id="pcNo" class="btn btn-secondary btn-sm" style="margin-left:6px">Not now</button>';
|
||||
tabs.parentNode.insertBefore(box,tabs);
|
||||
document.getElementById('pcGo').addEventListener('click',async function(){
|
||||
await window.RMCProfile.prompt({onlyForId:id,reason:'alerts'});
|
||||
var p2=await window.RMCProfile.status();
|
||||
if(p2&&p2.profile&&p2.profile.complete){ box.remove(); try{ renderAlerts({id:id}); }catch(e){} }
|
||||
});
|
||||
document.getElementById('pcNo').addEventListener('click',function(){
|
||||
try{ localStorage.setItem('rmc.profileSnooze',String(Date.now()+7*86400000)); }catch(e){}
|
||||
box.remove();
|
||||
});
|
||||
}catch(e){}
|
||||
}
|
||||
// every position below this one, from the matrix subtree the dashboard already has
|
||||
function orgPositionIds(d){
|
||||
const out=[];
|
||||
(function walk(n,depth){ if(!n||depth>16)return; if(depth>=1&&n.id)out.push(Number(n.id)); walk(n.left,depth+1); walk(n.right,depth+1); })(d&&d.subtree,0);
|
||||
return out;
|
||||
}
|
||||
async function loadMsgUI(d){
|
||||
const el=document.getElementById('dMsg');
|
||||
let me=null;
|
||||
@@ -327,7 +366,23 @@
|
||||
let data;
|
||||
try{data=await(await fetch('/api/public/msg-inbox')).json();}catch(e){el.innerHTML='<div class="empty">Could not load messages — refresh to retry.</div>';return;}
|
||||
const mine=Number(me.id)===Number(d.id);
|
||||
const banner=mine?'':`<div class="callout" style="margin-bottom:10px">You're signed in as <strong>#${me.id}</strong> — this inbox is yours. (You're viewing #${d.id}'s page; the "to" box is pre-filled for them.)</div>`;
|
||||
let banner=mine?'':`<div class="callout" style="margin-bottom:10px">You're signed in as <strong>#${me.id}</strong> — this inbox is yours. (You're viewing #${d.id}'s page; the "to" box is pre-filled for them.)</div>`;
|
||||
// The one place contact details genuinely matter: a message cannot be delivered
|
||||
// to someone who has given no way to reach them. Asked here, never forced.
|
||||
try{
|
||||
const pr=await window.RMCProfile.status();
|
||||
if(pr&&pr.signedIn!==false&&!(pr.profile&&pr.profile.complete)){
|
||||
banner+=`<div class="callout" style="margin-bottom:10px;border-color:rgba(78,214,203,.45)"><strong>Messages reach you here only.</strong> Add an email and they reach you off the site too, plus a note whenever a payout lands. Optional, private, removable any time. <button id="msgAddContact" class="btn btn-teal btn-sm" style="margin-left:8px">Add mine</button></div>`;
|
||||
}
|
||||
const idsInOrg=orgPositionIds(d);
|
||||
if(mine&&idsInOrg.length){
|
||||
const rr=await (await fetch('/api/public/reach?ids='+idsInOrg.slice(0,2000).join(','))).json();
|
||||
if(rr&&rr.total){
|
||||
const pct=Math.round((rr.reachable/rr.total)*100);
|
||||
banner+=`<div class="micro" style="margin:0 0 10px;color:var(--muted)"><strong style="color:var(--text)">${rr.reachable} of ${rr.total}</strong> in your org can be reached off the site (${pct}%). The rest only see a message if they open this page. Ask your two to add an email — it is the difference between a team you can talk to and one you cannot.</div>`;
|
||||
}
|
||||
}
|
||||
}catch(e){}
|
||||
const rows=(data.inbox||[]).map(m=>`<div class="pp-row" style="padding:9px 12px${m.read?'':';border-color:rgba(240,197,109,.55)'}"><div class="pp-icon">${m.org?'📣':'✉️'}</div><div class="pp-body"><strong>From #${m.fromId}</strong> <span class="pp-meta" style="display:inline">· ${new Date(m.ts).toLocaleString()}${m.org?' · team broadcast':''}${m.read?'':' · <strong style="color:var(--gold)">NEW</strong>'}</span><div style="white-space:pre-wrap;margin-top:4px">${esc(m.body)}</div></div></div>`).join('')||'<div class="empty">No messages yet.</div>';
|
||||
const sent=(data.sent||[]).slice(0,3).map(m=>`<div class="micro" style="margin:3px 0">→ ${m.org?'whole team':'#'+m.toId} · ${new Date(m.ts).toLocaleString()}: ${esc(m.body.slice(0,90))}${m.body.length>90?'…':''}</div>`).join('');
|
||||
el.innerHTML=banner+rows+
|
||||
@@ -419,7 +474,6 @@
|
||||
const sig=await eth.request({method:'personal_sign',params:[hex,account]});
|
||||
const v=await(await fetch('/api/public/msg-verify',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({address:account,signature:sig})})).json();
|
||||
if(!v.ok)throw new Error(v.error||'Verification failed.');
|
||||
try{ if(window.RMCProfile)await window.RMCProfile.require({onlyForId:d&&d.id}); }catch(ge){}
|
||||
loadMsgUI(d);
|
||||
}catch(e){if(err)err.textContent=e.message||String(e);}
|
||||
}
|
||||
@@ -500,6 +554,9 @@
|
||||
// to k+2, so the cost to REACH level L is index L-2. Scintilla (L1) is the
|
||||
// entry level, not an upgrade.
|
||||
const upc=(d.upgradeCosts&&d.upgradeCosts[d.tier===2?2:1])||[];
|
||||
// Manson, 2026-09-17: showing all eight rungs can read as a required climb.
|
||||
// It is not — you stop wherever you like, and NEXT is only ever a suggestion
|
||||
// based on who is actually below you.
|
||||
const ladder=LEVELS.map((nm,i)=>{
|
||||
const L=i+1;let cls='nlv';
|
||||
if(L<lvl)cls+=' done';else if(L===lvl)cls+=' you';else if(ns.kind==='upgrade'&&L===ns.nextLevel)cls+=' next';
|
||||
@@ -524,7 +581,7 @@
|
||||
head="You're at the top level 🏆";
|
||||
body='Keep helping your team duplicate — every level they climb still pays up to you.';
|
||||
}else{el.innerHTML='';return;}
|
||||
el.innerHTML=`<div class="ns-card"><div class="ns-top"><div class="ns-eyebrow">Your plan</div>${badge}</div><div class="ns-h">${head}</div><p class="ns-p">${body}</p><div class="nladder">${ladder}</div><p class="micro" style="margin:10px 0 0"><a href="/how-pay-works#level-scale" style="color:var(--teal)">See what each level opens up →</a></p></div>`;
|
||||
el.innerHTML=`<div class="ns-card"><div class="ns-top"><div class="ns-eyebrow">Your plan</div>${badge}</div><div class="ns-h">${head}</div><p class="ns-p">${body}</p><div class="nladder">${ladder}</div><p class="micro" style="margin:8px 0 0;color:var(--muted)">You stop wherever you like — there is no level you have to reach. NEXT is only a suggestion based on who is below you today.</p><p class="micro" style="margin:10px 0 0"><a href="/how-pay-works#level-scale" style="color:var(--teal)">See what each level opens up →</a></p></div>`;
|
||||
}
|
||||
function renderPipeline(d){
|
||||
const el=document.getElementById('dPipeline');
|
||||
@@ -781,5 +838,7 @@
|
||||
if(id)load(id);
|
||||
// Required member profile (username + verified email). No-ops for a visitor who
|
||||
// has not proved they own a position: the API 401s and the gate never shows.
|
||||
try{ if(window.RMCProfile&&id)window.RMCProfile.require({onlyForId:id}); }catch(e){}
|
||||
// No automatic modal. Contact details are optional; the dashboard shows a
|
||||
// dismissable card instead, and the inbox asks only when it actually needs one.
|
||||
try{ if(window.RMCProfile&&id)maybeProfileCard(id); }catch(e){}
|
||||
})();
|
||||
|
||||
+36
-29
@@ -1,15 +1,17 @@
|
||||
// RM Circle: required member profile gate (Marty, 2026-09-16).
|
||||
// RM Circle: OPTIONAL member profile (Marty + Manson, 2026-09-17).
|
||||
//
|
||||
// The member area only opens once a position has a username and a VERIFIED email.
|
||||
// It fires the moment ownership is proved (wallet personal_sign, or the Telegram
|
||||
// Mini App bridge) and cannot be dismissed, because the whole point is that a
|
||||
// leader can reach every member. The public /my/<id> page is untouched: anyone
|
||||
// can still read the chain data there, and nobody can write a profile from it.
|
||||
// Contact details are never required to hold a position, get paid, read the org,
|
||||
// use the training or the tools. Nothing on chain depends on them. They are asked
|
||||
// for in exactly one place where their absence is the whole problem: a message
|
||||
// cannot be delivered to someone who has given no way to reach them. Everything
|
||||
// else is an invitation the member can decline, and declining costs them nothing.
|
||||
//
|
||||
// Usage: await window.RMCProfile.require(); // resolves once the profile is complete
|
||||
// Usage: window.RMCProfile.prompt({ reason: 'alerts'|'inbox' }) // dismissable
|
||||
// window.RMCProfile.edit('username'|'email') // change later
|
||||
// window.RMCProfile.status() // read-only
|
||||
(function () {
|
||||
'use strict';
|
||||
var back = null, resolveDone = null, state = null, editable = false;
|
||||
var back = null, resolveDone = null, state = null, oneShot = false, showSteps = true, reason = 'alerts';
|
||||
var GOLD = '#d4af37', TEAL = '#4ed6cb';
|
||||
|
||||
function el(tag, css, html) {
|
||||
@@ -30,12 +32,12 @@
|
||||
back = el('div', 'position:fixed;inset:0;z-index:2147483100;display:flex;align-items:center;justify-content:center;' +
|
||||
'padding:20px;background:rgba(3,7,14,.88);backdrop-filter:blur(6px);-webkit-backdrop-filter:blur(6px);overflow:auto;');
|
||||
back.setAttribute('role', 'dialog');
|
||||
back.setAttribute('aria-label', 'Finish setting up your member profile');
|
||||
back.setAttribute('aria-label', 'Member profile — optional');
|
||||
var card = el('div', 'position:relative;width:100%;max-width:440px;max-height:94vh;overflow:auto;border-radius:20px;' +
|
||||
'background:#0a1119;border:1px solid rgba(212,175,55,.55);box-shadow:0 24px 70px rgba(0,0,0,.7);' +
|
||||
'font-family:system-ui,Segoe UI,Arial,sans-serif;color:#e8eef6;padding:22px 22px 20px;');
|
||||
card.id = 'pgCard';
|
||||
if (editable) { // editing an existing profile can be abandoned; the first-time gate can not
|
||||
{ // every profile dialog can be closed: nothing here is compulsory
|
||||
var x = el('button', 'position:absolute;top:10px;right:12px;z-index:2;width:34px;height:34px;border-radius:50%;border:none;' +
|
||||
'cursor:pointer;background:rgba(255,255,255,.08);color:#fff;font-size:20px;line-height:1;font-weight:700;', '×');
|
||||
x.id = 'pgClose';
|
||||
@@ -53,7 +55,7 @@
|
||||
var keep = card.querySelector('button[aria-label="Close"]');
|
||||
card.innerHTML = '';
|
||||
if (keep) card.appendChild(keep);
|
||||
if (editable) return card;
|
||||
if (!showSteps) return card;
|
||||
card.appendChild(el('div', 'text-align:center;letter-spacing:2px;text-transform:uppercase;font-size:11px;font-weight:700;color:' + GOLD + ';margin-bottom:8px;',
|
||||
'Position #' + esc(state.id) + ' · step ' + step + ' of 2'));
|
||||
return card;
|
||||
@@ -81,7 +83,7 @@
|
||||
function stepUsername() {
|
||||
var card = head(document.getElementById('pgCard'), 1);
|
||||
card.appendChild(el('h2', 'margin:0 0 6px;font-size:21px;line-height:1.25;color:#fff;', 'Pick your username'));
|
||||
card.appendChild(note('This is how your team leader and the people in your line see you, instead of a bare member number. Letters, numbers or underscores, 3 to 20 characters.'));
|
||||
card.appendChild(note('How your team sees you instead of a bare member number. Letters, numbers or underscores, 3 to 20 characters. Optional, and changeable any time.'));
|
||||
var err = errLine(); card.appendChild(err);
|
||||
var i = input('pgUser', 'e.g. ' + (state.suggest || 'member' + state.id), (state.profile && state.profile.username) || '');
|
||||
card.appendChild(i);
|
||||
@@ -92,7 +94,7 @@
|
||||
try {
|
||||
var r = await api('/api/public/profile/username', { username: i.value });
|
||||
state.profile = r.profile;
|
||||
if (editable) { close(); return; }
|
||||
if (oneShot) { close(); return; }
|
||||
next();
|
||||
} catch (e) {
|
||||
err.textContent = e.message; err.style.display = 'block'; b.disabled = false; b.textContent = 'Save and continue';
|
||||
@@ -108,8 +110,10 @@
|
||||
var card = head(document.getElementById('pgCard'), 2);
|
||||
var prefill = (state.profile && state.profile.email) || '';
|
||||
card.appendChild(el('h2', 'margin:0 0 6px;font-size:21px;line-height:1.25;color:#fff;', 'Confirm your email'));
|
||||
card.appendChild(note('Two reasons this is required. Your leader can actually reach you, and you get a note the moment a payout lands in your wallet. ' +
|
||||
'It is never shown to other members, never sold, and you can change it any time.' +
|
||||
card.appendChild(note((reason === 'inbox'
|
||||
? 'Your inbox needs somewhere to reach you. Add an email and your leader\'s messages reach you even when you are not on the site. '
|
||||
: 'Get an email the moment a payout lands in your wallet, and your leader can reach you when it matters. ') +
|
||||
'Optional, never shown to other members, never sold, and you can remove it any time.' +
|
||||
(prefill ? ' We already have this one on file for your payout alerts, so just confirm it.' : '')));
|
||||
var err = errLine(); card.appendChild(err);
|
||||
var i = input('pgEmail', 'you@example.com', prefill, 'email');
|
||||
@@ -144,7 +148,7 @@
|
||||
try {
|
||||
var r = await api('/api/public/profile/email-verify', { code: ci.value });
|
||||
state.profile = r.profile;
|
||||
if (editable) { close(); return; }
|
||||
if (oneShot) { close(); return; }
|
||||
done();
|
||||
} catch (e) {
|
||||
err.textContent = e.message; err.style.display = 'block'; cb.disabled = false; cb.textContent = 'Confirm and finish';
|
||||
@@ -178,35 +182,38 @@
|
||||
return done();
|
||||
}
|
||||
|
||||
// Resolves once the profile is complete. Safe to call repeatedly: it returns
|
||||
// immediately when there is nothing to collect, and never shows for a visitor
|
||||
// who has not proved they own the position.
|
||||
// opts.onlyForId: only gate when the page being viewed IS this member's own
|
||||
// position (Marty, 2026-09-16), so browsing a teammate's dashboard never prompts.
|
||||
async function require_(opts) {
|
||||
// An invitation, not a gate: fully dismissable, and declining costs nothing.
|
||||
// opts.onlyForId - only for the member's own position, never a teammate's page.
|
||||
// opts.reason - 'alerts' (default) or 'inbox', which picks the copy.
|
||||
async function prompt_(opts) {
|
||||
try { state = await api('/api/public/profile'); }
|
||||
catch (e) { return null; }
|
||||
if (!state || state.signedIn === false) return null; // a visitor on a shared link: never gate
|
||||
if (!state || state.signedIn === false) return null;
|
||||
var only = opts && opts.onlyForId;
|
||||
if (only && Number(only) !== Number(state.id)) return null;
|
||||
if (state.profile && state.profile.complete) return state.profile;
|
||||
if (back) return null; // already open
|
||||
if (back) return null;
|
||||
reason = (opts && opts.reason) || 'alerts';
|
||||
oneShot = false; // full two-step opt-in, not a single-field edit
|
||||
showSteps = true;
|
||||
shell();
|
||||
return new Promise(function (res) { resolveDone = res; next(); });
|
||||
var p = new Promise(function (res) { resolveDone = res; });
|
||||
next();
|
||||
var out = await p; oneShot = false; return out;
|
||||
}
|
||||
async function status() { try { return await api('/api/public/profile'); } catch (e) { return null; } }
|
||||
// Change an existing username or email. Dismissable, unlike the first-time gate.
|
||||
// Change one field. Opens straight at that step and closes on save.
|
||||
// Resolves with the profile when saved, or null if the member closed it.
|
||||
async function edit(kind) {
|
||||
if (back) return null;
|
||||
try { state = await api('/api/public/profile'); } catch (e) { return null; }
|
||||
if (!state || state.signedIn === false) return null;
|
||||
editable = true;
|
||||
oneShot = true; showSteps = false;
|
||||
shell();
|
||||
var p = new Promise(function (res) { resolveDone = res; });
|
||||
if (kind === 'email') stepEmail(); else stepUsername();
|
||||
var out = await p; editable = false; return out;
|
||||
var out = await p; oneShot = false; return out;
|
||||
}
|
||||
|
||||
window.RMCProfile = { require: require_, status: status, edit: edit };
|
||||
window.RMCProfile = { prompt: prompt_, status: status, edit: edit };
|
||||
})();
|
||||
|
||||
+48
-18
@@ -1,25 +1,47 @@
|
||||
# RM Circle QA
|
||||
|
||||
## Member profile gate
|
||||
## Optional member profile
|
||||
|
||||
Since 2026-09-17 contact details are **optional** (Marty + Manson). Nothing about holding a
|
||||
position, getting paid, reading the org, the training or the tools may depend on a username or
|
||||
an email. There is no onboarding gate. The dashboard shows a dismissable invitation, and the
|
||||
inbox asks only because a message cannot be delivered to someone who left no way to reach them.
|
||||
|
||||
If a change to this area makes any of those suites fail on "NO modal is forced", stop. That
|
||||
assertion is the product decision, not a test detail.
|
||||
|
||||
```
|
||||
# unit: profiles.js in isolation (seeding, username rules, code flow, persistence)
|
||||
node qa/profiles-unit.mjs
|
||||
|
||||
# end to end: the real UI with real inbox sessions
|
||||
TMP=/d/tmp/rmc-e2e-data && rm -rf $TMP && mkdir -p $TMP
|
||||
ssh root@coolify.saasy.top "docker exec \$(docker ps -q --filter name=kr445fqc) cat /app/data/config.json" > $TMP/config.json
|
||||
echo '{}' > $TMP/sponsors.json
|
||||
echo '{"49":{"email":"seeded49@example.com","ts":"x"}}' > $TMP/member-alerts.json
|
||||
node -e "const m=require('./messages.js'); m.init({dataDir:process.argv[1],chain:require('./chain.js'),isProd:false}); require('fs').writeFileSync(process.argv[1]+'/tokens.txt', m.mintSession(21)+'\n'+m.mintSession(49));" $TMP
|
||||
PORT=3399 DATA_DIR=$TMP ADMIN_PASSWORD=localtest node server.js &
|
||||
LOCAL=http://127.0.0.1:3399 TOKEN=$(sed -n 1p $TMP/tokens.txt) TOKEN2=$(sed -n 2p $TMP/tokens.txt) node qa/gate-e2e.mjs
|
||||
# sign-in fallback: real secp256k1 signatures, including the abuse cases
|
||||
node qa/signin-fallback.mjs
|
||||
|
||||
# end to end: the real UI with real inbox sessions.
|
||||
# reseed.sh builds a throwaway data dir and starts the server on 3399.
|
||||
# NOT idempotent: the suite completes a profile for 21, so reseed before every run.
|
||||
bash qa/reseed.sh
|
||||
D=/d/tmp/rmc-e2e-data
|
||||
LOCAL=http://127.0.0.1:3399 TOKEN=$(sed -n 1p $D/tokens.txt) TOKEN2=$(sed -n 2p $D/tokens.txt) node qa/gate-e2e.mjs
|
||||
```
|
||||
|
||||
Covers: the gate fires on the owner's own page only, cannot be dismissed (Escape, backdrop, no close
|
||||
button), username and email validation, the code flow including a wrong code, completion landing on
|
||||
the dashboard tab, the profile card and in-place editing (dismissable), the seeded email pre-filling
|
||||
at step 2, visitors on every ID-keyed shared link seeing no gate and no 401, and phone-width layout.
|
||||
Covers: no automatic modal on the owner's own page or on a phone, the dismissable invitation and
|
||||
its 7-day snooze, opting in end to end (including the regression that saving a username must
|
||||
ADVANCE to the email step rather than close), abandoning the dialog at any point, the profile card
|
||||
and in-place editing, the seeded email pre-filling at step 2, the inbox's reason-led ask, visitors
|
||||
on every ID-keyed shared link seeing no prompt and no 401, and phone-width layout.
|
||||
|
||||
Two traps that cost real time, both now guarded in `reseed.sh`:
|
||||
|
||||
* **Never call a shell variable `TMP`, `TEMP` or `TMPDIR`.** Windows already sets them to the
|
||||
system temp directory, so `${TMP:-default}` silently inherits it and a following `rm -rf "$TMP"`
|
||||
wipes the machine's temp folder, including the tooling's own scratch files.
|
||||
* **Never add `pkill`/`taskkill`.** A broad pattern kills the tooling running the script. Stop the
|
||||
old server through the pid file.
|
||||
|
||||
The event flyer and the upgrade promo are full-screen overlays that legitimately cover `/my` once
|
||||
per browser/session and swallow clicks. Both E2E suites mark them already-seen via an init script
|
||||
rather than racing their fade-out.
|
||||
|
||||
`devCode` is returned by `/api/public/profile/email-start` only when `NODE_ENV !== 'production'`,
|
||||
which is what lets the test read the code. The live container runs with NODE_ENV=production.
|
||||
@@ -33,13 +55,21 @@ left #787 without a profile: the cached index does not yet know the brand-new po
|
||||
```
|
||||
node -e "const c=require('crypto'),s=require('./vendor/secp256k1.js'),{keccak256}=require('./vendor/sha3.js');s.utils.hmacSha256Sync=(k,...m)=>{const h=c.createHmac('sha256',Buffer.from(k));m.forEach(x=>h.update(Buffer.from(x)));return Uint8Array.from(h.digest())};const p=c.randomBytes(32),pub=s.getPublicKey(p,false);require('fs').writeFileSync('D:/tmp/rmc-qa-wallet.json',JSON.stringify({priv:p.toString('hex'),addr:'0x'+keccak256(Buffer.from(pub.slice(1))).slice(-40)}))"
|
||||
|
||||
TMP=/d/tmp/rmc-jd && rm -rf $TMP && mkdir -p $TMP && echo '[]' > $TMP/sponsors.json
|
||||
ssh root@coolify.saasy.top "docker exec \$(docker ps -q --filter name=kr445fqc) cat /app/data/config.json" > $TMP/config.json
|
||||
TEST_ADDR=<addr> TEST_ID=9001 COLD=1 PORT=3399 DATA_DIR=$TMP ADMIN_PASSWORD=localtest node qa/harness-server.js &
|
||||
LOCAL=http://127.0.0.1:3399 TEST_ADDR=<addr> TEST_PRIV=<priv> TEST_ID=9001 SCENARIO=sign node qa/join-flow-e2e.mjs
|
||||
LOCAL=http://127.0.0.1:3399 TEST_ADDR=<addr> TEST_PRIV=<priv> TEST_ID=9002 SCENARIO=refuse node qa/join-flow-e2e.mjs
|
||||
J=/d/tmp/rmc-jd && rm -rf $J && mkdir -p $J && echo '[]' > $J/sponsors.json # never name it TMP, see the trap above
|
||||
ssh root@coolify.saasy.top "docker exec \$(docker ps -q --filter name=kr445fqc) cat /app/data/config.json" > $J/config.json
|
||||
|
||||
# cold index (the state that broke #787) on 3400, warm index on 3402
|
||||
TEST_ADDR=<addr> TEST_ID=9001 COLD=1 PORT=3400 DATA_DIR=$J ADMIN_PASSWORD=localtest node qa/harness-server.js &
|
||||
TEST_ADDR=<addr> TEST_ID=9003 COLD=0 PORT=3402 DATA_DIR=$J-w ADMIN_PASSWORD=localtest node qa/harness-server.js &
|
||||
|
||||
LOCAL=http://127.0.0.1:3400 TEST_ADDR=<addr> TEST_PRIV=<priv> TEST_ID=9001 SCENARIO=sign node qa/join-flow-e2e.mjs
|
||||
LOCAL=http://127.0.0.1:3400 TEST_ADDR=<addr> TEST_PRIV=<priv> TEST_ID=9002 SCENARIO=refuse node qa/join-flow-e2e.mjs
|
||||
LOCAL=http://127.0.0.1:3402 TEST_ADDR=<addr> TEST_PRIV=<priv> TEST_ID=9003 SCENARIO=sign node qa/join-flow-e2e.mjs
|
||||
```
|
||||
|
||||
`COLD` defaults to ON: only `COLD=0` gives a warm index, so a missing `COLD` var does not silently
|
||||
run the cold case twice.
|
||||
|
||||
`SCENARIO=refuse` is the regression that matters most: a member who declines the signature must still
|
||||
complete the join and reach their dashboard. Never ship a join-flow change without it passing.
|
||||
|
||||
|
||||
+82
-93
@@ -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.
|
||||
// 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; // ctb.msid for position 21
|
||||
const TOKEN2 = process.env.TOKEN2; // ctb.msid for position 49 (already complete)
|
||||
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 gateOpen = p => p.evaluate(() => !!document.querySelector('#pgCard'));
|
||||
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. the gate on the member's OWN page ----------
|
||||
// ---------- 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(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));
|
||||
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')));
|
||||
|
||||
// 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'))));
|
||||
// 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)));
|
||||
|
||||
// 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));
|
||||
// ---------- 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)));
|
||||
|
||||
// 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
|
||||
// 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('#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.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);
|
||||
t('gate closes itself after completion', !(await gateOpen(p)));
|
||||
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')));
|
||||
|
||||
// ---------- 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
|
||||
// ---------- 3. the inbox asks only where it matters ----------
|
||||
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));
|
||||
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. visitors ----------
|
||||
const ctxAnon = await ctxFor(null); const p4 = await ctxAnon.newPage();
|
||||
// ---------- 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 && r.url().includes('/profile')) anon401.push(r.url()); });
|
||||
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(1600);
|
||||
await p4.goto(B + u, { waitUntil: 'domcontentloaded' }); await p4.waitForTimeout(1500);
|
||||
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: ' + 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 sees no 401 from the profile endpoint', anon401.length === 0, anon401.join(','));
|
||||
t('visitor triggers no 401s', anon401.length === 0, anon401.join(','));
|
||||
|
||||
// ---------- 5. phone width ----------
|
||||
// ---------- 5. phone ----------
|
||||
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);
|
||||
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('\nPASS ' + ok.length);
|
||||
console.log('PASS ' + ok.length);
|
||||
for (const b of bad) console.log('FAIL ' + b);
|
||||
await browser.close();
|
||||
process.exit(bad.length ? 1 : 0);
|
||||
|
||||
+25
-5
@@ -33,6 +33,16 @@ const t = (n, c, extra) => { (c ? ok : bad).push(n + (c || !extra ? '' : ' -> '
|
||||
|
||||
const browser = await chromium.launch();
|
||||
const ctx = await browser.newContext({ viewport: { width: 1280, height: 950 } });
|
||||
// The event flyer and upgrade promo are full-screen overlays that legitimately cover
|
||||
// /my once per browser/session. They are not under test here and they swallow clicks.
|
||||
await ctx.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);
|
||||
};
|
||||
});
|
||||
const page = await ctx.newPage();
|
||||
|
||||
// real signing, called from the page (exposeFunction sidesteps the page's strict CSP)
|
||||
@@ -108,16 +118,26 @@ if (SCENARIO === 'sign') {
|
||||
t('a signature was requested right after the join', (qa.signAsked || 0) >= 1, JSON.stringify(qa));
|
||||
t('an inbox session was created', hasSession, cookies.map(c => c.name).join(','));
|
||||
await page.waitForTimeout(2500);
|
||||
const gate = await page.evaluate(() => !!document.querySelector('#pgCard'));
|
||||
t('the profile gate appears on their dashboard', gate);
|
||||
const card = await page.evaluate(() => { const c = document.querySelector('#pgCard'); return c ? c.innerText.replace(/\s+/g, ' ') : ''; });
|
||||
t('the gate is for THEIR position', new RegExp('position #' + NEW_ID, 'i').test(card), card.slice(0, 90));
|
||||
// Since 2026-09-17 contact details are optional, so a brand-new member must NOT be
|
||||
// met by a modal. They get a dismissable invitation on their own dashboard instead.
|
||||
t('NO modal is forced on a brand-new member', !(await page.evaluate(() => !!document.querySelector('#pgCard'))));
|
||||
const invite = await page.evaluate(() => !!document.getElementById('pcGo'));
|
||||
t('the optional invitation is offered instead', invite);
|
||||
t('and it can be declined', await page.evaluate(() => !!document.getElementById('pcNo')));
|
||||
if (invite) {
|
||||
await page.click('#pcGo'); await page.waitForTimeout(1400);
|
||||
const card = await page.evaluate(() => { const c = document.querySelector('#pgCard'); return c ? c.innerText.replace(/\s+/g, ' ') : ''; });
|
||||
t('opening it addresses THEIR position', new RegExp('position #' + NEW_ID, 'i').test(card), card.slice(0, 90));
|
||||
t('and they can close it again', await page.evaluate(() => !!document.getElementById('pgClose')));
|
||||
}
|
||||
} else {
|
||||
t('the signature was refused by the wallet', (qa.signRefused || 0) >= 1, JSON.stringify(qa));
|
||||
t('REGRESSION: the join still completed and redirected', /\/my\//.test(page.url()), page.url());
|
||||
t('no inbox session, as expected', !hasSession);
|
||||
const gate = await page.evaluate(() => !!document.querySelector('#pgCard'));
|
||||
t('no gate without a session, page still usable', !gate);
|
||||
t('no modal without a session, page still usable', !gate);
|
||||
t('and no invitation either, since we cannot know who they are',
|
||||
!(await page.evaluate(() => !!document.getElementById('pcGo'))));
|
||||
const txt = await page.evaluate(() => document.body.innerText);
|
||||
t('the dashboard still renders for them', txt.length > 300, 'len ' + txt.length);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
#!/usr/bin/env bash
|
||||
# Rebuild a throwaway data dir + server for the profile E2E suite.
|
||||
# The suite completes a profile for 21, so it is NOT idempotent: reseed every run.
|
||||
#
|
||||
# Two rules learned the hard way, do not undo them:
|
||||
# * NEVER name the data-dir variable TMP/TEMP/TMPDIR. On Windows those are already
|
||||
# set to the system temp directory, so `${TMP:-default}` silently inherits it and
|
||||
# the rm -rf below wipes the machine's temp folder instead of the throwaway dir.
|
||||
# * NEVER add pkill/taskkill. A broad pattern kills the tooling running this script.
|
||||
# Stop the old server through the pid file instead.
|
||||
#
|
||||
# Usage: bash qa/reseed.sh (then read $RMCQA_DIR/tokens.txt)
|
||||
set -e
|
||||
RMCQA_DIR=${RMCQA_DIR:-/d/tmp/rmc-e2e-data}
|
||||
PORT=${PORT:-3399}
|
||||
case "$RMCQA_DIR" in
|
||||
/d/tmp/*|/D/tmp/*) ;;
|
||||
*) echo "refusing: RMCQA_DIR must live under /d/tmp (got '$RMCQA_DIR')"; exit 2 ;;
|
||||
esac
|
||||
if [ -f "$RMCQA_DIR/server.pid" ]; then kill "$(cat "$RMCQA_DIR/server.pid")" 2>/dev/null || true; sleep 2; fi
|
||||
rm -rf "$RMCQA_DIR"; mkdir -p "$RMCQA_DIR"
|
||||
if [ -f /d/tmp/rmc-config-cache.json ]; then cp /d/tmp/rmc-config-cache.json "$RMCQA_DIR/config.json";
|
||||
else ssh -o StrictHostKeyChecking=no root@coolify.saasy.top \
|
||||
"docker exec \$(docker ps -q --filter name=kr445fqc) cat /app/data/config.json" > "$RMCQA_DIR/config.json"
|
||||
cp "$RMCQA_DIR/config.json" /d/tmp/rmc-config-cache.json; fi
|
||||
echo '[]' > "$RMCQA_DIR/sponsors.json"
|
||||
echo '{"49":{"email":"seeded49@example.com","ts":"x"}}' > "$RMCQA_DIR/member-alerts.json"
|
||||
node -e "const m=require('./messages.js'); m.init({dataDir:process.argv[1],chain:require('./chain.js'),isProd:false}); require('fs').writeFileSync(process.argv[1]+'/tokens.txt', m.mintSession(21)+'\n'+m.mintSession(49));" "$RMCQA_DIR"
|
||||
PORT=$PORT DATA_DIR=$RMCQA_DIR ADMIN_PASSWORD=localtest node server.js > "$RMCQA_DIR/server.log" 2>&1 &
|
||||
echo $! > "$RMCQA_DIR/server.pid"
|
||||
for i in $(seq 1 40); do
|
||||
if [ "$(curl -s -o /dev/null -w %{http_code} "http://127.0.0.1:$PORT/my/21" 2>/dev/null)" = "200" ]; then echo "ready on $PORT"; exit 0; fi
|
||||
sleep 1
|
||||
done
|
||||
echo "server did not come up"; tail -20 "$RMCQA_DIR/server.log"; exit 1
|
||||
@@ -1447,6 +1447,18 @@ async function handleApi(req,res,pathname){
|
||||
// ---- member profile: username + verified email, REQUIRED before the member
|
||||
// area opens. Only a session that PROVED ownership (wallet personal_sign or the
|
||||
// Telegram Mini App bridge) can read or write one; /my/<id> is public and can not.
|
||||
// How many people in MY org can receive a message. Session required, and the
|
||||
// ids are filtered to the caller's own team, so this leaks nothing upward or sideways.
|
||||
if(req.method==='GET'&&pathname==='/api/public/reach'){
|
||||
const s=messages.authFromCookie(req);
|
||||
if(!s)return json(res,200,{ok:true,signedIn:false});
|
||||
let ids=[];
|
||||
try{
|
||||
const raw=String(new URL(req.url,'http://x').searchParams.get('ids')||'');
|
||||
ids=raw.split(',').map(Number).filter(n=>Number.isInteger(n)&&n>0).slice(0,3000).filter(n=>n!==s.id&&chain.isInTeam(n,s.id));
|
||||
}catch(e){}
|
||||
return json(res,200,Object.assign({ok:true,signedIn:true},profiles.reachFor(ids)));
|
||||
}
|
||||
if(req.method==='GET'&&pathname==='/api/public/profile'){
|
||||
const s=messages.authFromCookie(req);
|
||||
// 200 with signedIn:false, not 401: a shared /my/<id> link is opened by people
|
||||
|
||||
Reference in New Issue
Block a user