asyncfunctionlogin(page,email,password='Tenant test password 123!'){awaitpage.goto(base+'/main.php');awaitpage.locator('#user_email').fill(email);awaitpage.locator('#user_password').fill(password);awaitPromise.all([page.waitForNavigation(),page.locator('#login button[type=submit]').click()]);awaitrequire('./browser_mfa')(page,password);}
constfirst=awaitcards.nth(0).boundingBox(),second=awaitcards.nth(1).boundingBox();assert(Math.abs(first.y-second.y)<2&&second.x>first.x);pass('Multiple accessible clubs appear side by side after login');
awaitowner.setViewportSize({width:390,height:844});constmobileFirst=awaitcards.nth(0).boundingBox(),mobileSecond=awaitcards.nth(1).boundingBox();assert(mobileSecond.y>=mobileFirst.y+mobileFirst.height&&mobileSecond.x===mobileFirst.x);assert(awaitowner.evaluate(()=>document.documentElement.scrollWidth<=window.innerWidth));pass('Club cards stack without horizontal overflow on phones');awaitowner.setViewportSize({width:1280,height:720});
constsingleContext=awaitbrowser.newContext(),single=awaitsingleContext.newPage();awaitlogin(single,'membera@example.test');assert(newURL(single.url()).searchParams.get('club_serial')===String(ids.clubA));assert.equal(awaitsingle.getByRole('heading',{name:'Tenant Alpha',exact:true}).count(),1);assert.equal(awaitsingle.locator('.club-action-modal').count(),0);awaitsingleContext.close();pass('A member with one club lands directly in its read-only workspace');
assert.equal(awaitowner.locator('.clubSummary h1').textContent(),'Tenant Alpha');assert.equal(awaitowner.locator('.clubSummary').getByRole('button',{name:'Create club',exact:true}).count(),0);assert(awaitowner.locator('.clubSummary header').getByRole('link',{name:'Switch club',exact:true}).count());pass('Club summary starts with club identity and actions, without a creation form');
awaitowner.getByRole('link',{name:'Create club',exact:true}).click();awaitowner.getByRole('heading',{name:'Create club',exact:true}).waitFor();pass('Super Administrator creates clubs from a separate page');awaitowner.goto(base+'/main.php?Xaction=Roster.workspace&club_serial='+ids.clubA);
awaitowner.locator('tr').filter({hasText:'shared@example.test'}).locator('[data-club-step=member]').waitFor();assert((awaitowner.locator('tr').filter({hasText:'shared@example.test'}).textContent()).includes('administrator'));pass('Super System Administrator can appoint an administrator through the club membership form');
awaitownerContext.close();
awaitlogin(admin,'admina2@example.test');assert(newURL(admin.url()).searchParams.get('club_serial')===String(ids.clubA));pass('An administrator with one club lands directly in the club after MFA');awaitadmin.goto(base+'/main.php?Xaction=Roster.workspace&club_serial='+ids.clubA);
assert.equal(awaitadmin.locator('.setup-users,.view-journal').count(),0);assert(!awaitadmin.getByRole('link',{name:'Tenant Beta',exact:true}).count());pass('Club administrator browser navigation hides global controls and unrelated clubs');
awaitadmin.setViewportSize({width:390,height:844});if(process.env.HCR_UI_SCREENSHOT)awaitadmin.screenshot({path:process.env.HCR_UI_SCREENSHOT.replace('.png','-mobile.png')});assert(awaitadmin.evaluate(()=>document.documentElement.scrollWidth<=window.innerWidth));pass('Club summary actions and tables fit a phone-width screen');
awaitadmin.locator('header [data-club-step=invite]').click();awaitadmin.waitForFunction(()=>document.activeElement?.name==='invite_email');constmobileDialog=awaitadmin.locator('.club-action-modal.show .modal-dialog').boundingBox();assert(mobileDialog.x>=0&&mobileDialog.x+mobileDialog.width<=390);awaitadmin.keyboard.press('Escape');awaitadmin.locator('.club-action-modal.show').waitFor({state:'hidden'});awaitadmin.waitForFunction(()=>document.activeElement?.dataset.clubStep==='invite');pass('Phone dialog fits the screen, focuses its first field and restores focus after Escape');awaitadmin.setViewportSize({width:1280,height:720});
awaitadmin.getByRole('link',{name:'Tenant Membera',exact:true}).click();awaitadmin.getByRole('heading',{name:'Tenant Membera',exact:true}).waitFor();assert((awaitadmin.locator('.memberSummary').textContent()).includes('$3,119.00'));assert.equal(awaitadmin.locator('.memberSummary input:visible,.memberSummary select:visible').count(),0);pass('Roster name opens the selected member summary with corrected totals and hidden action forms');
awaitadmin.locator('nav[aria-label="dues pages"]').getByRole('link',{name:'Next',exact:true}).click();assert((awaitadmin.locator('.memberSummary').textContent()).includes('$3,119.00'));assert(newURL(admin.url()).searchParams.get('membership_serial')===String(ids.clubA_membera));pass('Member summary paging retains the selected member and complete totals');
if(process.env.HCR_UI_SCREENSHOT)awaitadmin.screenshot({path:process.env.HCR_UI_SCREENSHOT.replace('.png','-member.png')});awaitadmin.setViewportSize({width:390,height:844});assert(awaitadmin.evaluate(()=>document.documentElement.scrollWidth<=window.innerWidth));pass('Member summary totals and history fit phone-width screens');awaitadmin.setViewportSize({width:1280,height:720});
awaitadmin.locator('nav[aria-label="Member actions"] [data-club-step=member]').click();awaitadmin.locator('.club-action-modal.show').getByLabel('Joined on',{exact:true}).fill('2026-02-01');awaitPromise.all([admin.waitForNavigation(),admin.locator('.club-action-modal.show').getByRole('button',{name:'Save membership',exact:true}).click()]);assert((awaitadmin.locator('.memberSummary header').textContent()).includes('2026-02-01'));assert(newURL(admin.url()).searchParams.get('membership_serial')===String(ids.clubA_membera));pass('Administrator edits membership in a modal and remains on the same member summary');
awaitadmin.locator('[data-club-step=charge]').click();awaitadmin.locator('.club-action-modal.show').getByLabel('Season',{exact:true}).selectOption({label:'Available new season'});awaitadmin.locator('.club-action-modal.show').getByLabel('Dues (USD)',{exact:true}).fill('20.00');awaitPromise.all([admin.waitForNavigation(),admin.locator('.club-action-modal.show').getByRole('button',{name:'Save dues',exact:true}).click()]);assert((awaitadmin.locator('.memberSummary').textContent()).includes('$3,170.00'));pass('Administrator adds dues for the preselected member without returning to the club');
awaitadmin.getByRole('row').filter({hasText:'Available new season'}).locator('[data-club-step=payment]').click();awaitadmin.locator('.club-action-modal.show').getByLabel('Payment (USD)',{exact:true}).fill('10.00');awaitadmin.locator('.club-action-modal.show').getByLabel('Reference (optional)',{exact:true}).fill('Summary modal payment');awaitPromise.all([admin.waitForNavigation(),admin.locator('.club-action-modal.show').getByRole('button',{name:'Record payment',exact:true}).click()]);assert((awaitadmin.locator('.memberSummary').textContent()).includes('$3,129.00'));awaitadmin.getByRole('cell',{name:'unspecified / Summary modal payment',exact:true}).waitFor();pass('Administrator records a selected season payment and sees updated totals on the member summary');
awaitadmin.getByRole('row').filter({hasText:'Available new season'}).locator('[data-club-step=adjust]').click();assert.equal(awaitadmin.locator('.club-action-modal.show').getByLabel('Corrected total (USD)',{exact:true}).inputValue(),'20.00');awaitadmin.locator('.club-action-modal.show').getByLabel('Corrected total (USD)',{exact:true}).fill('15.00');awaitadmin.locator('.club-action-modal.show').getByLabel('Reason',{exact:true}).fill('Summary dues correction');awaitPromise.all([admin.waitForNavigation(),admin.locator('.club-action-modal.show').getByRole('button',{name:'Save correction',exact:true}).click()]);assert((awaitadmin.locator('.memberSummary').textContent()).includes('$3,124.00'));pass('Member-summary correction modal prefills dues and refreshes totals without leaving the member');
constreceipt=admin.getByRole('row').filter({hasText:'Summary modal payment'});awaitreceipt.locator('[data-club-step=reverse]').click();awaitadmin.locator('.club-action-modal.show').getByRole('button',{name:'Cancel',exact:true}).click();awaitadmin.locator('.club-action-modal.show').waitFor({state:'hidden'});assert((awaitadmin.locator('.memberSummary').textContent()).includes('$3,124.00'));awaitreceipt.locator('[data-club-step=reverse]').click();awaitadmin.locator('.club-action-modal.show').getByLabel('Reversal reason',{exact:true}).fill('Summary wrong receipt');awaitPromise.all([admin.waitForNavigation(),admin.locator('.club-action-modal.show').getByRole('button',{name:'Confirm reversal',exact:true}).click()]);assert((awaitadmin.locator('.memberSummary').textContent()).includes('$3,134.00'));constreversed=admin.getByRole('row').filter({hasText:'Summary modal payment'});assert((awaitreversed.textContent()).includes('Summary wrong receipt'));assert.equal(awaitreversed.locator('[data-club-step=reverse]').count(),0);pass('Member-summary reversal supports Cancel, preserves the receipt and removes its reversal button');
constprivateText='Private browser member note\nSecond line';awaitadmin.locator('.member-admin-notes [data-club-step=note_add]').click();awaitadmin.locator('.club-action-modal.show').getByLabel('Note',{exact:true}).fill('Unsaved private draft');awaitadmin.locator('.club-action-modal.show').getByRole('button',{name:'Cancel',exact:true}).click();awaitadmin.locator('.club-action-modal.show').waitFor({state:'hidden'});assert.equal(awaitadmin.locator('.member-admin-notes').getByRole('cell',{name:'Unsaved private draft',exact:true}).count(),0);awaitadmin.locator('.member-admin-notes [data-club-step=note_add]').click();awaitadmin.locator('.club-action-modal.show').getByLabel('Note',{exact:true}).fill(privateText);awaitPromise.all([admin.waitForNavigation(),admin.locator('.club-action-modal.show').getByRole('button',{name:'Save note',exact:true}).click()]);awaitadmin.locator('.member-admin-notes').getByRole('cell',{name:'Private browser member note Second line',exact:true}).waitFor();assert(newURL(admin.url()).hash==='#admin-notes');pass('Administrative note add modal supports Cancel and saves back to the member notes section');
awaitadmin.locator('.member-admin-notes [data-club-step=note_edit]').click();assert.equal(awaitadmin.locator('.club-action-modal.show').getByLabel('Note',{exact:true}).inputValue(),privateText);awaitadmin.locator('.club-action-modal.show').getByLabel('Note',{exact:true}).fill('Updated private browser note');awaitPromise.all([admin.waitForNavigation(),admin.locator('.club-action-modal.show').getByRole('button',{name:'Save note',exact:true}).click()]);awaitadmin.locator('.member-admin-notes').getByRole('cell',{name:'Updated private browser note',exact:true}).waitFor();pass('Administrative note edit modal prefills and saves the selected note');
constprivacyContext=awaitbrowser.newContext(),privacyPage=awaitprivacyContext.newPage();awaitlogin(privacyPage,'membera@example.test');awaitprivacyPage.getByRole('link',{name:'Tenant Membera',exact:true}).click();assert.equal(awaitprivacyPage.locator('.member-admin-notes').count(),0);assert(!(awaitprivacyPage.content()).includes('Updated private browser note'));assert.equal(awaitprivacyPage.locator('.memberSummary [name=note_text]').count(),0);awaitprivacyContext.close();pass('Member browser receives no administrative note section, text or hidden note fields');
awaitadmin.getByRole('link',{name:'Back to club roster',exact:true}).click();awaitadmin.getByRole('heading',{name:'Tenant Alpha',exact:true}).waitFor();
awaitadmin.locator('header [data-club-step=invite]').click();constinvite=admin.locator('form').filter({has:admin.getByRole('button',{name:'Send club invitation',exact:true})});awaitinvite.getByLabel('Invitation email').fill('invitee@example.test');awaitinvite.getByRole('button',{name:'Send club invitation',exact:true}).click();awaitadmin.getByText('The invitation email could not be sent.',{exact:false}).waitFor();pass('Administrator sends a club invitation and sees delivery failure clearly');
awaitlogin(member,'invitee@example.test');assert(awaitmember.getByText('You are not assigned to an active club yet.',{exact:false}).count());pass('Accounts without a club retain the empty workspace and invitation access');awaitmember.goto(base+'/main.php?Xaction=Roster.invitations');awaitmember.getByRole('heading',{name:'Tenant Alpha',exact:true}).waitFor();awaitmember.getByRole('button',{name:'Accept invitation',exact:true}).click();awaitmember.getByRole('heading',{name:'Tenant Alpha',exact:true}).waitFor();assert.equal(awaitmember.getByRole('button',{name:'Send club invitation',exact:true}).count(),0);pass('Invited member accepts the club invitation and receives a read-only workspace');
awaitmember.getByRole('link',{name:'Tenant Invitee',exact:true}).click();awaitmember.getByRole('heading',{name:'Tenant Invitee',exact:true}).waitFor();awaitmember.getByText('No dues recorded for this membership.',{exact:true}).waitFor();awaitmember.getByText('No payments recorded for this membership.',{exact:true}).waitFor();pass('Member opens their own summary and sees clear empty financial histories');awaitmember.getByRole('link',{name:'Back to club roster',exact:true}).click();
awaitmember.getByRole('link',{name:'My tickets',exact:true}).click();constform=member.locator('form').filter({has:member.getByRole('button',{name:'Submit ticket',exact:true})});awaitform.getByLabel('Subject',{exact:true}).fill('Browser membership question');awaitform.getByLabel('Message',{exact:true}).fill('Please review my membership.');awaitform.getByRole('button',{name:'Submit ticket',exact:true}).click();awaitmember.getByRole('heading',{name:'Browser membership question',exact:true}).waitFor();pass('Member submits a private club ticket through the browser');
awaitadmin.goto(base+'/main.php?Xaction=Roster.tickets&club_serial='+ids.clubA);awaitadmin.getByRole('link',{name:/Browser membership question/}).click();constreply=admin.locator('form').filter({has:admin.getByRole('button',{name:'Send reply',exact:true})});awaitreply.getByLabel('Reply',{exact:true}).fill('Your membership is active.');awaitreply.getByLabel('Status',{exact:true}).selectOption('closed');awaitreply.getByRole('button',{name:'Send reply',exact:true}).click();awaitadmin.getByText('Status: closed',{exact:true}).waitFor();pass('Assigned club administrator replies to and closes the member ticket');
awaitmember.reload();awaitmember.getByText('Your membership is active.',{exact:true}).waitFor();assert.equal(awaitmember.getByRole('button',{name:'Send reply',exact:true}).count(),0);pass('Member sees the response and cannot change a closed ticket');
constsignupContext=awaitbrowser.newContext(),signup=awaitsignupContext.newPage();signup.on('pageerror',e=>errors.push(e.message));awaitsignup.goto(base+'/main.php?Xaction=signup');awaitsignup.getByLabel('New club name',{exact:true}).fill('Browser Signup Club');awaitsignup.getByLabel('First name',{exact:true}).fill('Browser');awaitsignup.getByLabel('Last name',{exact:true}).fill('Owner');awaitsignup.getByLabel('Email address',{exact:true}).fill('browserowner@example.test');awaitsignup.getByLabel('Username',{exact:true}).fill('browserowner');awaitsignup.getByLabel('Password',{exact:true}).fill('Browser owner password 123!');awaitsignup.getByLabel('Confirm password',{exact:true}).fill('Browser owner password 123!');awaitsignup.getByRole('button',{name:'Create account',exact:true}).click();awaitsignup.getByText('Your account has been created.',{exact:false}).waitFor();pass('Browser signup registers a new club and its administrator');awaitsignup.goto(base+'/main.php?Xaction=signup&membership=invited');assert.equal(awaitsignup.getByLabel('New club name',{exact:true}).count(),0);awaitsignup.getByText('Use the email address your club administrator invited.',{exact:false}).waitFor();pass('Invited members have a separate account signup without a club creation field');awaitsignupContext.close();
assert.deepEqual(errors,[]);pass('Tenant invitation and ticket flows produce no browser script errors');