2f90e1a97f
Findings fixed: the gate's buttons had no stable ids (fragile to test and maintain), and finishing the gate left the member on the pitch tab where the profile card and Messages are not visible, so completion now opens the Position Dashboard tab. /api/public/profile/email-start returns devCode outside production so the flow is testable locally, matching the InstantAdPay pattern. qa/profiles-unit.mjs (28 assertions) and qa/gate-e2e.mjs (47 assertions, real sessions, real UI) with a README. All pass. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
129 lines
8.9 KiB
JavaScript
129 lines
8.9 KiB
JavaScript
// 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);
|