Files
huntingclubroster/tests/browser.js
T

97 lines
17 KiB
JavaScript

const {chromium}=require('playwright');
const assert=require('node:assert/strict');
const path=require('node:path');
const fs=require('node:fs/promises');
(async()=>{
const browser=await chromium.launch({executablePath:process.env.HCR_TEST_CHROME||'C:/Program Files/Google/Chrome/Application/chrome.exe',headless:true});
const page=await browser.newPage({viewport:{width:1440,height:1000}});
const errors=[],checks=[];page.on('pageerror',error=>errors.push(error.message));
await page.exposeFunction('recordCspViolation',message=>errors.push(message));await page.addInitScript(()=>document.addEventListener('securitypolicyviolation',event=>window.recordCspViolation('CSP: '+event.violatedDirective+' '+event.blockedURI)));
const pass=message=>{checks.push(message);console.log('PASS: '+message);};
const openClubModal=async step=>{await page.locator('[data-club-step="'+step+'"]').first().click();await page.locator('.club-action-modal.show').waitFor();};
const submit=async data=>{await Promise.all([page.waitForNavigation({waitUntil:'load'}),page.evaluate(data=>formSubmit(data),data)]);await page.waitForFunction(()=>typeof formSubmit==='function');await page.waitForLoadState('networkidle');};
try{
await page.goto(process.env.HCR_TEST_URL+'/main.php');
await page.locator('#user_email').fill('admin@example.test');await page.locator('#user_password').fill('Admin test password 123!');await Promise.all([page.waitForNavigation({waitUntil:'load'}),page.locator('#login button[type="submit"]').click()]);await require('./browser_mfa')(page,'Admin test password 123!');await page.waitForFunction(()=>typeof formSubmit==='function');
pass('Browser login loads the application shell without script errors');
const safe=await page.evaluate(()=>{
const original=HTMLFormElement.prototype.submit;HTMLFormElement.prototype.submit=function(){};
try{const value='"><img id="injected-hidden-value" src=x>';formSubmit({action:'Users.setupUsers',find_user:value});return !document.querySelector('#injected-hidden-value')&&document.querySelector('#formSubmit input[name="find_user"]').value===value;}finally{HTMLFormElement.prototype.submit=original;}
});assert(safe);pass('Hidden form values remain text rather than injected HTML');
await submit({action:'Users.setupUsers'});await page.locator('.setupUsers').waitFor();
await page.locator('.btnAdd').click();await page.locator('#addUser').waitFor();
await page.locator('#user_name').fill('browser_tester');await page.locator('#user_first_name').fill('Browser');await page.locator('#user_last_name').fill('Tester');await page.locator('#user_email').fill('browser@example.test');await page.locator('#user_role').selectOption('User');
await page.locator('.btnSave').click();await page.locator('.setupUsers').waitFor();
await page.getByText('Browser',{exact:true}).waitFor();pass('Administrative account form saves through asynchronous validation');
assert((await page.locator('.toast-body').innerText()).includes('invitation'));pass('Failed invitation is visible after returning to the user list');
const row=page.locator('tr.user-row').filter({hasText:'browser_tester'});await row.click();await page.locator('.userSummary').waitFor();pass('Clicking a user opens the repaired summary');
await page.locator('.summary-edit').click();await page.locator('#editUser').waitFor();
const failUsage=async route=>{
const data=route.request().postData()||'';
if(data.includes('Users.userActive'))await route.fulfill({status:500,contentType:'application/json',body:JSON.stringify({ok:false,error:'Deliberate usage-check failure'})});else await route.continue();
};
await page.route('**/*',failUsage);await page.locator('.btnDelete').click();await page.getByText('Deliberate usage-check failure',{exact:true}).waitFor();assert(!await page.locator('#confirm-dialog').isVisible());pass('A failed deletion check shows an error and does not open confirmation');await page.unroute('**/*',failUsage);
await page.locator('.btnDelete').click();await page.locator('#confirm-dialog').waitFor({state:'visible'});await page.locator('#confirm-dialog .btnConfirmDialogConfirm').click();await page.locator('.setupUsers').waitFor();assert(await page.locator('tr.user-row').filter({hasText:'browser_tester'}).count()===0);pass('Deleting an unused account succeeds through the browser');
await submit({action:'Dropdowns.setupDropdowntypes'});await page.locator('.setupDropdowntypes').waitFor();await page.locator('.btnAdd').click();await page.locator('#addDropdowntype').waitFor();await page.locator('#dropdowntype_name').fill('browser_type');await page.locator('#dropdowntype_title').fill('Browser type');await page.locator('.btnSave').click();await page.locator('.setupDropdowntypes').waitFor();pass('Dropdown type form works with restricted usage metadata');
const richHtml='<p><strong>Legacy bold</strong> <em>italic</em> <u>underline</u> <a href="https://example.test/">link</a> <span style="color: rgb(255, 0, 0); font-family: Arial; font-size: 18px;">font</span></p><ul><li>Bullet</li></ul><figure class="table"><table><tbody><tr><td>Cell</td></tr></tbody></table></figure>';
for (const kind of ['Alert','Popover']) {
const isAlert=kind==='Alert',field=isAlert?'alert_message':'popover_text',prefix=isAlert?'alert':'popover',controller=kind+'s';
await page.evaluate(({kind,controller})=>{const key=document.querySelector('#APPLICATION_MNEMONIC').value,state=JSON.parse(sessionStorage.getItem(key)||'{}');state['add'+kind]=state['edit'+kind]=controller+'.setup'+kind+'s';sessionStorage.setItem(key,JSON.stringify(state));},{kind,controller});
await submit({action:controller+'.add'+kind});
await page.waitForFunction(field=>!!document.getElementById(field).hcrEditor,field);
assert(await page.locator('.btnSave').isEnabled());pass(kind+' editor initializes locally under the security policy');
await page.evaluate(({field,richHtml,kind,controller})=>{
document.getElementById(field).hcrEditor.$.html.set(richHtml);
const key=document.querySelector('#APPLICATION_MNEMONIC').value,state=JSON.parse(sessionStorage.getItem(key)||'{}');state['add'+kind]=controller+'.setup'+kind+'s';sessionStorage.setItem(key,JSON.stringify(state));
},{field,richHtml,kind,controller});
await page.locator('#'+prefix+'_title').fill('Browser rich '+kind);
if(isAlert){
await page.evaluate(()=>{$('#alert_starts_date').datepicker('update','2026-01-01');$('#alert_expires_date').datepicker('update','2027-12-31');$('#alert_starts_time').inputmask('setvalue','00:00');$('#alert_expires_time').inputmask('setvalue','23:59');});
}else await page.locator('#popover_name').fill('browser_rich_popover');
await page.locator('.btnSave').click();await page.locator('.setup'+kind+'s').waitFor();
const richRow=page.locator('tr.'+prefix+'-row').filter({hasText:isAlert?'Browser rich Alert':'browser_rich_popover'});
const serial=await richRow.getAttribute('data-'+prefix+'-serial');await richRow.click();
await page.waitForFunction(field=>!!document.getElementById(field)?.hcrEditor,field);
const content=await page.evaluate(field=>document.getElementById(field).hcrEditor.$.html.get(),field);
assert.match(content,/<strong>Legacy bold<\/strong>/);assert.match(content,/<em>italic<\/em>/);assert.match(content,/<u>underline<\/u>/);assert.match(content,/<table/);assert.match(content,/>Cell</);assert.match(content,/<ul/);assert.match(content,/https:\/\/example.test\//);assert.match(content,/font-family/);assert.match(content,/font-size/);assert.match(content,/color:/);
pass(kind+' add/save/reopen preserves legacy formatting, lists, links, fonts and tables');
await page.locator('.sun-editor [contenteditable=true]').first().press('Control+End');
await page.locator('.sun-editor [contenteditable=true]').first().press('Enter');
await page.locator('.sun-editor [contenteditable=true]').first().pressSequentially('Browser edit');
await page.locator('.btnSave').click();await page.locator('.setup'+kind+'s').waitFor();
await submit({action:controller+'.edit'+kind,[prefix+'_serial']:serial});
await page.waitForFunction(field=>!!document.getElementById(field)?.hcrEditor,field);
assert((await page.locator('.sun-editor [contenteditable=true]').first().innerText()).includes('Browser edit'));
pass(kind+' edits made in the visible editor are persisted');
}
await submit({action:'Popovers.addPopover'});await page.waitForFunction(()=>!!document.querySelector('#popover_text').hcrEditor);
const cleaned=await page.evaluate(()=>{const editor=document.querySelector('#popover_text').hcrEditor;editor.$.html.set('<p onclick="window.bad=1">Safe<script>window.bad=1</script><a href="javascript:alert(1)">link</a></p>');return editor.$.html.get();});
assert(!/<script|onclick=|javascript:/i.test(cleaned));pass('Editor content filtering removes scripts, event attributes and unsafe links');
await page.locator('.sun-editor [contenteditable=true]').first().focus();
await page.evaluate(()=>{const transfer=new DataTransfer();transfer.setData('text/html','<p><strong>Pasted formatting</strong></p>');transfer.setData('text/plain','Pasted formatting');document.querySelector('.sun-editor [contenteditable=true]').dispatchEvent(new ClipboardEvent('paste',{clipboardData:transfer,bubbles:true,cancelable:true}));});
await page.waitForFunction(()=>document.querySelector('#popover_text').hcrEditor.$.html.get().includes('<strong>Pasted formatting</strong>'));pass('Pasting formatted HTML retains bold text');
await page.route('**/js/suneditor-3.3.3.min.js',route=>route.abort());await submit({action:'Alerts.addAlert'});
await page.getByText('The text editor could not load. Reload this page before saving.',{exact:true}).waitFor();assert(await page.locator('.btnSave').isDisabled());pass('Editor load failure is visible and saving stays disabled');await page.unroute('**/js/suneditor-3.3.3.min.js');
await page.goto(process.env.HCR_TEST_URL+'/main.php?Xaction=Roster.createClub');
let form=page.locator('form').filter({has:page.getByRole('button',{name:'Create club',exact:true})});await form.getByLabel('Club name',{exact:true}).fill('Browser Club');await form.getByRole('button',{name:'Create club',exact:true}).click();await page.getByRole('heading',{name:'Browser Club',exact:true}).waitFor();pass('Club creation works in the browser');
assert.equal(await page.locator('.clubSummary input:visible,.clubSummary select:visible').count(),0);pass('Club summary hides all editing fields until an action is opened');
await openClubModal('club');await page.locator('.club-action-modal.show').getByLabel('Club name',{exact:true}).fill('Unsaved name');await page.locator('.club-action-modal.show').getByRole('button',{name:'Cancel',exact:true}).click();await page.locator('.club-action-modal.show').waitFor({state:'hidden'});assert.equal(await page.locator('.clubSummary h1').textContent(),'Browser Club');pass('Canceling a club edit leaves the club unchanged');
await openClubModal('club');await page.locator('.club-action-modal.show').getByLabel('Club name',{exact:true}).fill('Browser Club');await Promise.all([page.waitForNavigation(),page.locator('.club-action-modal.show').getByRole('button',{name:'Save club',exact:true}).click()]);assert.equal(await page.locator('.clubSummary h1').textContent(),'Browser Club');pass('Club edits save through their modal and return to the summary');
await openClubModal('member');form=page.locator('form').filter({has:page.getByRole('button',{name:'Add member',exact:true})});await form.getByLabel('Username or email').fill('testadmin');await form.getByRole('button',{name:'Add member',exact:true}).click();await page.getByRole('cell',{name:'Test Administrator',exact:true}).waitFor();pass('Membership assignment works in the browser');
await openClubModal('season');form=page.locator('form').filter({has:page.getByRole('button',{name:'Add season',exact:true})});await form.getByLabel('Season name').fill('Browser Season');await form.getByLabel('Starts on').fill('2026-01-01');await form.getByLabel('Ends on').fill('2025-12-31');await form.getByRole('button',{name:'Add season',exact:true}).click();await page.locator('.club-action-modal.show .club-modal-error:not([hidden])').waitFor();assert.equal(await form.getByLabel('Season name').inputValue(),'Browser Season');pass('Server validation stays inside the modal and preserves entered values');await form.getByLabel('Ends on').fill('2026-12-31');await form.getByRole('button',{name:'Add season',exact:true}).click();await page.getByText('Browser Season (2026-01-01 to 2026-12-31)',{exact:true}).waitFor();pass('Season creation works in the browser');
await openClubModal('charge');form=page.locator('form').filter({has:page.getByRole('button',{name:'Save dues',exact:true})});await form.getByLabel('Dues (USD)').fill('10.00');await form.getByRole('button',{name:'Save dues',exact:true}).click();await page.getByRole('button',{name:'Record payment',exact:true}).waitFor();pass('Dues entry works in the browser');
await openClubModal('payment');form=page.locator('form').filter({has:page.getByRole('button',{name:'Record payment',exact:true})});await form.getByLabel('Payment (USD)').fill('5.00');await form.getByRole('button',{name:'Record payment',exact:true}).click();await page.getByRole('cell',{name:'$5.00',exact:true}).first().waitFor();pass('Recording a payment updates the displayed balance');
await openClubModal('adjust');
form=page.locator('form').filter({has:page.getByRole('button',{name:'Save correction',exact:true})});await form.getByLabel('Corrected total (USD)').fill('8.00');await form.getByLabel('Reason',{exact:true}).fill('Correct season rate');await form.getByRole('button',{name:'Save correction',exact:true}).click();await page.getByRole('cell',{name:'$8.00',exact:true}).waitFor();pass('Dues correction updates the balance and preserves history in the browser');
await openClubModal('reverse');
form=page.locator('form').filter({has:page.getByRole('button',{name:'Confirm reversal',exact:true})});await form.getByLabel('Reversal reason').fill('Wrong receipt');await form.getByRole('button',{name:'Confirm reversal',exact:true}).click();await page.getByText('Reversed: Wrong receipt',{exact:false}).waitFor();pass('Payment reversal is recorded visibly without deleting the original payment');
await openClubModal('invite');await page.locator('.club-action-modal.show').getByLabel('Invitation email',{exact:true}).fill('revocation@example.test');await Promise.all([page.waitForNavigation(),page.locator('.club-action-modal.show').getByRole('button',{name:'Send club invitation',exact:true}).click()]);const invitation=page.locator('li').filter({hasText:'revocation@example.test'});await invitation.locator('[data-club-step=revokeInvitation]').click();await page.locator('.club-action-modal.show').getByRole('button',{name:'Cancel',exact:true}).click();await page.locator('.club-action-modal.show').waitFor({state:'hidden'});assert((await invitation.textContent()).includes('pending'));await invitation.locator('[data-club-step=revokeInvitation]').click();await Promise.all([page.waitForNavigation(),page.locator('.club-action-modal.show').getByRole('button',{name:'Revoke invitation',exact:true}).click()]);assert((await page.locator('li').filter({hasText:'revocation@example.test'}).textContent()).includes('revoked'));pass('Invitation revocation requires modal confirmation and Cancel preserves the invitation');
const ledgerDownload=page.waitForEvent('download');await page.getByRole('button',{name:'Export financial history (CSV)',exact:true}).click();assert.equal((await ledgerDownload).suggestedFilename(),'club-financial-history.csv');pass('Financial history can be downloaded from the browser');
await page.screenshot({path:path.join(__dirname,'.local','roster.png'),fullPage:true});
await submit({action:'Reports.selectReport'});await page.getByRole('heading',{name:'Roster reports'}).waitFor();const downloadEvent=page.waitForEvent('download');await page.getByRole('button',{name:'Download CSV'}).click();const download=await downloadEvent;assert.equal(download.suggestedFilename(),'club-roster.csv');pass('Report catalog downloads the roster CSV in the browser');
await page.goto(process.env.HCR_TEST_URL+'/main.php?Xaction=verifyemail');await page.getByRole('heading',{name:'Verify your email'}).waitFor();await page.getByLabel('Email',{exact:true}).fill('unknown@example.test');await page.getByRole('button',{name:'Send verification link'}).click();await page.getByText('If an active account needs verification',{exact:false}).waitFor();pass('Verification request page gives a neutral response in the browser');
assert.deepEqual(errors,[]);pass('No unexpected browser JavaScript errors occurred');console.log(checks.length+' browser checks passed.');
}catch(error){await page.screenshot({path:path.join(__dirname,'.local','browser-failure.png'),fullPage:true});console.error('PAGE ERRORS:',errors);throw error;}
finally{await browser.close();}
})().catch(error=>{console.error(error);process.exitCode=1;});