const {openSetup,isolatedUser,demoPassword}=require('./support/browser.cjs'); // Run against an explicitly seeded localhost development instance. const assert = require('node:assert/strict'); const fs = require('node:fs'); const base = process.env.MEGURI_BROWSER_URL || 'http://127.0.0.1:3000'; assert(['127.0.0.1','localhost'].includes(new URL(base).hostname), 'Browser tests require a disposable local instance'); fs.mkdirSync('test-results', { recursive: true }); (async () => { const browser = await require('./support/browser.cjs').launchBrowser(); const errors = []; async function signIn(name, secret) { const context = await browser.newContext({ viewport: { width: 1440, height: 1100 } }); const page = await context.newPage(); page.setDefaultTimeout(10000); page.on('pageerror', error => errors.push(error.message)); await page.goto(base); await page.getByLabel('Username', { exact: true }).fill(name); await page.getByLabel('Password', { exact: true }).fill(secret ?? await demoPassword()); await page.getByRole('button', { name: 'Sign in', exact: true }).click(); await page.getByRole('heading', { name: 'Calendar', exact: true }).waitFor(); return page; } async function save(page) { await page.getByRole('dialog').getByRole('button', { name: 'Save changes', exact: true }).click(); await page.getByRole('dialog').waitFor({ state: 'hidden' }); } try { // Workspaces come only from configuration: this suite owns Sandbox 1 (an administrator and one employee). const label = 'Sandbox 1'; const team1 = await isolatedUser({ sandbox: 1, prefix: 'browser-lead', display: 'Alice Morgan', employees: ['Bob Chen'] }); const [bobAccount] = team1.staff; const alice = await signIn(team1.username, team1.password); await alice.locator('.day').first().waitFor(); await alice.screenshot({ path: 'test-results/calendar-desktop.png', fullPage: true }); const workspaceId = await alice.evaluate(label => Alpine.$data(document.body).directoryState.workspaces.find(w=>w.name===label).id, label); await alice.getByLabel('Workspace', { exact: true }).selectOption(workspaceId); await alice.waitForFunction(id => Alpine.$data(document.body).workspace?.id===id, workspaceId); const created = await alice.evaluate(() => { const a=Alpine.$data(document.body); return {record:a.workspace, listing:a.directoryState}; }); assert.deepEqual(Object.keys(created.listing), ['workspaces']); assert.equal(created.listing.workspaces.filter(w=>w.name===label).length,1); assert.equal(created.record.document.name,label); assert.equal('tenant_id' in created.record,false); assert.equal((await alice.request.get(base+'/api/tenants')).status(),404); const session0 = await alice.evaluate(() => fetch('/api/session').then(r=>r.json())); const creation = await alice.request.post(base+'/api/workspaces', { headers: { Origin: base, 'X-CSRF-Token': session0.csrf }, data: { name: 'Nope' } }); assert([404,405].includes(creation.status()), 'Workspaces cannot be created through the API'); await openSetup(alice,'Workspace settings'); await alice.locator('#workspace-members').getByText('Bob Chen',{exact:true}).waitFor(); assert.equal(await alice.getByRole('button', { name: '+ Add teammate' }).count(),0); assert.equal(await alice.locator(`#workspace-members [data-username=${bobAccount.username}] .role-badge`).innerText(),'Employee'); assert.equal(await alice.locator(`#workspace-members [data-username=${team1.username}] .role-badge`).innerText(),'Workspace administrator'); await openSetup(alice,'Schedules'); await alice.locator('.schedule-create > summary').click(); await alice.getByRole('button', { name: '+ Rotation', exact: true }).click(); const start = new Date(Date.now() + 2 * 86400000).toISOString().slice(0,10); const end = new Date(Date.now() + 3 * 86400000).toISOString().slice(0,10); await alice.getByRole('dialog').getByLabel('Schedule name').fill('Weekly rotation'); await alice.getByRole('dialog').getByLabel('Anchor date').fill(start.split('-').reverse().join('-')); await save(alice); await alice.getByRole('link', { name: 'Calendar', exact: true }).click(); await alice.locator('[data-duty-date="'+start+'"]').click(); await alice.getByRole('dialog').getByRole('button', { name: 'Bob Chen', exact: true }).click(); await alice.getByRole('dialog').getByLabel('Comment (optional)', {exact:true}).fill('Browser regression: planned appointment'); await alice.getByRole('dialog').getByRole('button', {name:'Send request',exact:true}).click(); await alice.getByRole('dialog').waitFor({state:'hidden'}); const bob = await signIn(bobAccount.username, bobAccount.password); const selectedTeam = await bob.evaluate(label => Alpine.$data(document.body).directoryState.workspaces.find(t=>t.name===label).id,label); await bob.getByLabel('Workspace', { exact: true }).selectOption(selectedTeam); await bob.waitForFunction(id => { const a=Alpine.$data(document.body); return a.workspace?.id===id && !a.busy; }, selectedTeam); await bob.getByRole('link', { name: /Requests/ }).click(); await bob.getByRole('heading', { name: 'Requests', exact: true }).waitFor(); await bob.waitForFunction(() => { const a=Alpine.$data(document.body); return !a.busy && a.needsMe.length>0; }); // The calendar's reply panel holds a hidden twin of this button; target the inbox row's actions. await bob.locator('.request-card .actions button.primary', { hasText: /^Accept$/ }).click(); await bob.locator('.badge.applied').waitFor(); await bob.screenshot({ path: 'test-results/approved-request.png', fullPage: true }); await bob.getByRole('link', { name: 'Calendar', exact: true }).click(); await bob.waitForFunction(start => [...document.querySelectorAll('.day')].some(el => el.dataset.dutyDate===start && el.textContent.includes('Bob')), start); const session = await alice.evaluate(() => fetch('/api/session').then(r=>r.json())); const workspace = await alice.evaluate(() => fetch('/api/workspaces').then(r=>r.json())); const team = workspace.workspaces.find(t => t.name === label); const endpoint = `/api/workspaces/${team.id}`; let response = await alice.request.post(base + endpoint + '/sync', { headers: { Origin: base }, data: {} }); assert.equal(response.status(), 403, 'Missing CSRF must be rejected'); response = await alice.request.post(base + endpoint + '/sync', { headers: { Origin: 'https://other.example', 'X-CSRF-Token': session.csrf }, data: {} }); assert.equal(response.status(), 403, 'Cross-origin mutation must be rejected'); const carol = await signIn('carol'); response = await carol.request.get(base + endpoint); assert.equal(response.status(),404,'Other team members cannot read this team'); const stranger = await browser.newContext();response = await stranger.request.get(base + endpoint);assert.equal(response.status(),401); await bob.setViewportSize({ width:390,height:844 }); await bob.getByLabel('Choose workspace', {exact:true}).waitFor(); await bob.screenshot({ path:'test-results/calendar-mobile.png',fullPage:true }); assert(await bob.evaluate(() => document.documentElement.scrollWidth<=innerWidth), 'Mobile layout must not overflow'); assert.deepEqual(errors, [], 'No browser runtime errors'); console.log('PASS: calendar, configured workspaces (no creation API), role-labelled membership, rotation, request, other-user approval, workspace isolation, CSRF/origin, mobile layout'); } catch (error) { console.error('Browser errors:', errors); for(const context of browser.contexts()) for(const page of context.pages()) { console.error('Visible alert:', await page.locator('[role=alert]').textContent().catch(()=>'')); await page.screenshot({ path:'test-results/failure.png',fullPage:true }).catch(()=>{}); } throw error; } finally { await browser.close(); } })().catch(error => { console.error(error); process.exitCode=1; });