const {chromium}=require('playwright'); const assert=require('node:assert/strict'); (async()=>{ const browser=await chromium.launch({executablePath:process.env.HCR_TEST_CHROME||'C:/Program Files/Google/Chrome/Application/chrome.exe',headless:true}); const adminContext=await browser.newContext(),memberContext=await browser.newContext(); const admin=await adminContext.newPage(),member=await memberContext.newPage(),errors=[];let checks=0; for(const page of [admin,member])page.on('pageerror',e=>errors.push(e.message)); const pass=message=>{checks++;console.log('PASS: '+message);}; const base=process.env.HCR_TEST_URL,ids=JSON.parse(process.env.HCR_TENANT_IDS); async function login(page,email,password='Tenant test password 123!'){await page.goto(base+'/main.php');await page.locator('#user_email').fill(email);await page.locator('#user_password').fill(password);await Promise.all([page.waitForNavigation(),page.locator('#login button[type=submit]').click()]);await require('./browser_mfa')(page,password);} try{ const ownerContext=await browser.newContext(),owner=await ownerContext.newPage();owner.on('pageerror',e=>errors.push(e.message)); await login(owner,'admin@example.test','Admin test password 123!'); const cards=owner.locator('.card').filter({has:owner.getByRole('link',{name:'Open club',exact:true})});assert.equal(await cards.count(),2); const first=await cards.nth(0).boundingBox(),second=await cards.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'); await owner.setViewportSize({width:390,height:844});const mobileFirst=await cards.nth(0).boundingBox(),mobileSecond=await cards.nth(1).boundingBox();assert(mobileSecond.y>=mobileFirst.y+mobileFirst.height && mobileSecond.x===mobileFirst.x);assert(await owner.evaluate(()=>document.documentElement.scrollWidth<=window.innerWidth));pass('Club cards stack without horizontal overflow on phones');await owner.setViewportSize({width:1280,height:720}); const singleContext=await browser.newContext(),single=await singleContext.newPage();await login(single,'membera@example.test');assert(new URL(single.url()).searchParams.get('club_serial')===String(ids.clubA));assert.equal(await single.getByRole('heading',{name:'Tenant Alpha',exact:true}).count(),1);assert.equal(await single.locator('.club-action-modal').count(),0);await singleContext.close();pass('A member with one club lands directly in its read-only workspace'); await owner.goto(base+'/main.php?Xaction=Roster.workspace&club_serial='+ids.clubA); assert.equal(await owner.locator('.clubSummary h1').textContent(),'Tenant Alpha');assert.equal(await owner.locator('.clubSummary').getByRole('button',{name:'Create club',exact:true}).count(),0);assert(await owner.locator('.clubSummary header').getByRole('link',{name:'Switch club',exact:true}).count());pass('Club summary starts with club identity and actions, without a creation form'); await owner.getByRole('link',{name:'Create club',exact:true}).click();await owner.getByRole('heading',{name:'Create club',exact:true}).waitFor();pass('Super Administrator creates clubs from a separate page');await owner.goto(base+'/main.php?Xaction=Roster.workspace&club_serial='+ids.clubA); await owner.locator('tr').filter({hasText:'shared@example.test'}).locator('[data-club-step=member]').click();const assignment=owner.locator('.club-action-modal.show form');await assignment.locator('select[name=membership_role]').selectOption('administrator');await Promise.all([owner.waitForNavigation(),assignment.getByRole('button',{name:'Save membership',exact:true}).click()]); await owner.locator('tr').filter({hasText:'shared@example.test'}).locator('[data-club-step=member]').waitFor();assert((await owner.locator('tr').filter({hasText:'shared@example.test'}).textContent()).includes('administrator'));pass('Super System Administrator can appoint an administrator through the club membership form'); await ownerContext.close(); await login(admin,'admina2@example.test');assert(new URL(admin.url()).searchParams.get('club_serial')===String(ids.clubA));pass('An administrator with one club lands directly in the club after MFA');await admin.goto(base+'/main.php?Xaction=Roster.workspace&club_serial='+ids.clubA); assert.equal(await admin.locator('.setup-users,.view-journal').count(),0);assert(!await admin.getByRole('link',{name:'Tenant Beta',exact:true}).count());pass('Club administrator browser navigation hides global controls and unrelated clubs'); if(process.env.HCR_UI_SCREENSHOT)await admin.screenshot({path:process.env.HCR_UI_SCREENSHOT}); await admin.setViewportSize({width:390,height:844});if(process.env.HCR_UI_SCREENSHOT)await admin.screenshot({path:process.env.HCR_UI_SCREENSHOT.replace('.png','-mobile.png')});assert(await admin.evaluate(()=>document.documentElement.scrollWidth<=window.innerWidth));pass('Club summary actions and tables fit a phone-width screen'); await admin.locator('header [data-club-step=invite]').click();await admin.waitForFunction(()=>document.activeElement?.name==='invite_email');const mobileDialog=await admin.locator('.club-action-modal.show .modal-dialog').boundingBox();assert(mobileDialog.x>=0 && mobileDialog.x+mobileDialog.width<=390);await admin.keyboard.press('Escape');await admin.locator('.club-action-modal.show').waitFor({state:'hidden'});await admin.waitForFunction(()=>document.activeElement?.dataset.clubStep==='invite');pass('Phone dialog fits the screen, focuses its first field and restores focus after Escape');await admin.setViewportSize({width:1280,height:720}); await admin.getByRole('link',{name:'Tenant Membera',exact:true}).click();await admin.getByRole('heading',{name:'Tenant Membera',exact:true}).waitFor();assert((await admin.locator('.memberSummary').textContent()).includes('$3,119.00'));assert.equal(await admin.locator('.memberSummary input:visible,.memberSummary select:visible').count(),0);pass('Roster name opens the selected member summary with corrected totals and hidden action forms'); await admin.locator('nav[aria-label="dues pages"]').getByRole('link',{name:'Next',exact:true}).click();assert((await admin.locator('.memberSummary').textContent()).includes('$3,119.00'));assert(new URL(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)await admin.screenshot({path:process.env.HCR_UI_SCREENSHOT.replace('.png','-member.png')});await admin.setViewportSize({width:390,height:844});assert(await admin.evaluate(()=>document.documentElement.scrollWidth<=window.innerWidth));pass('Member summary totals and history fit phone-width screens');await admin.setViewportSize({width:1280,height:720}); await admin.locator('nav[aria-label="Member actions"] [data-club-step=member]').click();await admin.locator('.club-action-modal.show').getByLabel('Joined on',{exact:true}).fill('2026-02-01');await Promise.all([admin.waitForNavigation(),admin.locator('.club-action-modal.show').getByRole('button',{name:'Save membership',exact:true}).click()]);assert((await admin.locator('.memberSummary header').textContent()).includes('2026-02-01'));assert(new URL(admin.url()).searchParams.get('membership_serial')===String(ids.clubA_membera));pass('Administrator edits membership in a modal and remains on the same member summary'); await admin.locator('[data-club-step=charge]').click();await admin.locator('.club-action-modal.show').getByLabel('Season',{exact:true}).selectOption({label:'Available new season'});await admin.locator('.club-action-modal.show').getByLabel('Dues (USD)',{exact:true}).fill('20.00');await Promise.all([admin.waitForNavigation(),admin.locator('.club-action-modal.show').getByRole('button',{name:'Save dues',exact:true}).click()]);assert((await admin.locator('.memberSummary').textContent()).includes('$3,170.00'));pass('Administrator adds dues for the preselected member without returning to the club'); await admin.getByRole('row').filter({hasText:'Available new season'}).locator('[data-club-step=payment]').click();await admin.locator('.club-action-modal.show').getByLabel('Payment (USD)',{exact:true}).fill('10.00');await admin.locator('.club-action-modal.show').getByLabel('Reference (optional)',{exact:true}).fill('Summary modal payment');await Promise.all([admin.waitForNavigation(),admin.locator('.club-action-modal.show').getByRole('button',{name:'Record payment',exact:true}).click()]);assert((await admin.locator('.memberSummary').textContent()).includes('$3,129.00'));await admin.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'); await admin.getByRole('row').filter({hasText:'Available new season'}).locator('[data-club-step=adjust]').click();assert.equal(await admin.locator('.club-action-modal.show').getByLabel('Corrected total (USD)',{exact:true}).inputValue(),'20.00');await admin.locator('.club-action-modal.show').getByLabel('Corrected total (USD)',{exact:true}).fill('15.00');await admin.locator('.club-action-modal.show').getByLabel('Reason',{exact:true}).fill('Summary dues correction');await Promise.all([admin.waitForNavigation(),admin.locator('.club-action-modal.show').getByRole('button',{name:'Save correction',exact:true}).click()]);assert((await admin.locator('.memberSummary').textContent()).includes('$3,124.00'));pass('Member-summary correction modal prefills dues and refreshes totals without leaving the member'); const receipt=admin.getByRole('row').filter({hasText:'Summary modal payment'});await receipt.locator('[data-club-step=reverse]').click();await admin.locator('.club-action-modal.show').getByRole('button',{name:'Cancel',exact:true}).click();await admin.locator('.club-action-modal.show').waitFor({state:'hidden'});assert((await admin.locator('.memberSummary').textContent()).includes('$3,124.00'));await receipt.locator('[data-club-step=reverse]').click();await admin.locator('.club-action-modal.show').getByLabel('Reversal reason',{exact:true}).fill('Summary wrong receipt');await Promise.all([admin.waitForNavigation(),admin.locator('.club-action-modal.show').getByRole('button',{name:'Confirm reversal',exact:true}).click()]);assert((await admin.locator('.memberSummary').textContent()).includes('$3,134.00'));const reversed=admin.getByRole('row').filter({hasText:'Summary modal payment'});assert((await reversed.textContent()).includes('Summary wrong receipt'));assert.equal(await reversed.locator('[data-club-step=reverse]').count(),0);pass('Member-summary reversal supports Cancel, preserves the receipt and removes its reversal button'); const privateText='Private browser member note\nSecond line';await admin.locator('.member-admin-notes [data-club-step=note_add]').click();await admin.locator('.club-action-modal.show').getByLabel('Note',{exact:true}).fill('Unsaved private draft');await admin.locator('.club-action-modal.show').getByRole('button',{name:'Cancel',exact:true}).click();await admin.locator('.club-action-modal.show').waitFor({state:'hidden'});assert.equal(await admin.locator('.member-admin-notes').getByRole('cell',{name:'Unsaved private draft',exact:true}).count(),0);await admin.locator('.member-admin-notes [data-club-step=note_add]').click();await admin.locator('.club-action-modal.show').getByLabel('Note',{exact:true}).fill(privateText);await Promise.all([admin.waitForNavigation(),admin.locator('.club-action-modal.show').getByRole('button',{name:'Save note',exact:true}).click()]);await admin.locator('.member-admin-notes').getByRole('cell',{name:'Private browser member note Second line',exact:true}).waitFor();assert(new URL(admin.url()).hash==='#admin-notes');pass('Administrative note add modal supports Cancel and saves back to the member notes section'); await admin.locator('.member-admin-notes [data-club-step=note_edit]').click();assert.equal(await admin.locator('.club-action-modal.show').getByLabel('Note',{exact:true}).inputValue(),privateText);await admin.locator('.club-action-modal.show').getByLabel('Note',{exact:true}).fill('Updated private browser note');await Promise.all([admin.waitForNavigation(),admin.locator('.club-action-modal.show').getByRole('button',{name:'Save note',exact:true}).click()]);await admin.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'); const privacyContext=await browser.newContext(),privacyPage=await privacyContext.newPage();await login(privacyPage,'membera@example.test');await privacyPage.getByRole('link',{name:'Tenant Membera',exact:true}).click();assert.equal(await privacyPage.locator('.member-admin-notes').count(),0);assert(!(await privacyPage.content()).includes('Updated private browser note'));assert.equal(await privacyPage.locator('.memberSummary [name=note_text]').count(),0);await privacyContext.close();pass('Member browser receives no administrative note section, text or hidden note fields'); await admin.locator('.member-admin-notes [data-club-step=note_delete]').click();await admin.locator('.club-action-modal.show').getByRole('button',{name:'Confirm deletion',exact:true}).waitFor();assert((await admin.locator('.club-action-modal.show').innerText()).includes('Updated private browser note'));await admin.locator('.club-action-modal.show').getByRole('button',{name:'Cancel',exact:true}).click();await admin.locator('.club-action-modal.show').waitFor({state:'hidden'});assert.equal(await admin.locator('.member-admin-notes').getByRole('cell',{name:'Updated private browser note',exact:true}).count(),1);pass('Note deletion confirmation shows selected note and Cancel preserves it'); await admin.locator('.member-admin-notes [data-club-step=note_delete]').click();await Promise.all([admin.waitForNavigation(),admin.locator('.club-action-modal.show').getByRole('button',{name:'Confirm deletion',exact:true}).click()]);assert.equal(await admin.locator('.member-admin-notes [data-club-step=note_delete]').count(),0);assert(!(await admin.content()).includes('Updated private browser note'));assert(new URL(admin.url()).hash==='#admin-notes');pass('Confirmed note deletion refreshes summary and removes note and hidden dialogs'); await admin.getByRole('link',{name:'Back to club roster',exact:true}).click();await admin.getByRole('heading',{name:'Tenant Alpha',exact:true}).waitFor(); await admin.locator('header [data-club-step=invite]').click();const invite=admin.locator('form').filter({has:admin.getByRole('button',{name:'Send club invitation',exact:true})});await invite.getByLabel('Invitation email').fill('invitee@example.test');await invite.getByRole('button',{name:'Send club invitation',exact:true}).click();await admin.getByText('The invitation email could not be sent.',{exact:false}).waitFor();pass('Administrator sends a club invitation and sees delivery failure clearly'); await login(member,'invitee@example.test');assert(await member.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');await member.goto(base+'/main.php?Xaction=Roster.invitations');await member.getByRole('heading',{name:'Tenant Alpha',exact:true}).waitFor();await member.getByRole('button',{name:'Accept invitation',exact:true}).click();await member.getByRole('heading',{name:'Tenant Alpha',exact:true}).waitFor();assert.equal(await member.getByRole('button',{name:'Send club invitation',exact:true}).count(),0);pass('Invited member accepts the club invitation and receives a read-only workspace'); await member.getByRole('link',{name:'Tenant Invitee',exact:true}).click();await member.getByRole('heading',{name:'Tenant Invitee',exact:true}).waitFor();await member.getByText('No dues recorded for this membership.',{exact:true}).waitFor();await member.getByText('No payments recorded for this membership.',{exact:true}).waitFor();pass('Member opens their own summary and sees clear empty financial histories');await member.getByRole('link',{name:'Back to club roster',exact:true}).click(); await member.getByRole('link',{name:'My tickets',exact:true}).click();const form=member.locator('form').filter({has:member.getByRole('button',{name:'Submit ticket',exact:true})});await form.getByLabel('Subject',{exact:true}).fill('Browser membership question');await form.getByLabel('Message',{exact:true}).fill('Please review my membership.');await form.getByRole('button',{name:'Submit ticket',exact:true}).click();await member.getByRole('heading',{name:'Browser membership question',exact:true}).waitFor();pass('Member submits a private club ticket through the browser'); await admin.goto(base+'/main.php?Xaction=Roster.tickets&club_serial='+ids.clubA);await admin.getByRole('link',{name:/Browser membership question/}).click();const reply=admin.locator('form').filter({has:admin.getByRole('button',{name:'Send reply',exact:true})});await reply.getByLabel('Reply',{exact:true}).fill('Your membership is active.');await reply.getByLabel('Status',{exact:true}).selectOption('closed');await reply.getByRole('button',{name:'Send reply',exact:true}).click();await admin.getByText('Status: closed',{exact:true}).waitFor();pass('Assigned club administrator replies to and closes the member ticket'); await member.reload();await member.getByText('Your membership is active.',{exact:true}).waitFor();assert.equal(await member.getByRole('button',{name:'Send reply',exact:true}).count(),0);pass('Member sees the response and cannot change a closed ticket'); const signupContext=await browser.newContext(),signup=await signupContext.newPage();signup.on('pageerror',e=>errors.push(e.message));await signup.goto(base+'/main.php?Xaction=signup');await signup.getByLabel('New club name',{exact:true}).fill('Browser Signup Club');await signup.getByLabel('First name',{exact:true}).fill('Browser');await signup.getByLabel('Last name',{exact:true}).fill('Owner');await signup.getByLabel('Email address',{exact:true}).fill('browserowner@example.test');await signup.getByLabel('Username',{exact:true}).fill('browserowner');await signup.getByLabel('Password',{exact:true}).fill('Browser owner password 123!');await signup.getByLabel('Confirm password',{exact:true}).fill('Browser owner password 123!');await signup.getByRole('button',{name:'Create account',exact:true}).click();await signup.getByText('Your account has been created.',{exact:false}).waitFor();pass('Browser signup registers a new club and its administrator');await signup.goto(base+'/main.php?Xaction=signup&membership=invited');assert.equal(await signup.getByLabel('New club name',{exact:true}).count(),0);await signup.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');await signupContext.close(); assert.deepEqual(errors,[]);pass('Tenant invitation and ticket flows produce no browser script errors'); }catch(error){console.error('OPEN DIALOG:',await admin.locator('.club-action-modal.show').textContent().catch(()=>''));if(process.env.HCR_UI_SCREENSHOT)await admin.screenshot({path:process.env.HCR_UI_SCREENSHOT.replace('.png','-failure.png')});throw error;}finally{await browser.close();} console.log(checks+' tenant browser checks passed.'); })().catch(e=>{console.error(e);process.exitCode=1;});