const {openSetup}=require('./support/browser.cjs'); // Run against an explicitly seeded localhost development instance. const { chromium } = require('playwright'); 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 chromium.launch({ ...(process.env.MEGURI_BROWSER_CHANNEL==='chromium'?{}:{channel:process.env.MEGURI_BROWSER_CHANNEL || 'chrome'}), headless: true }); const errors = []; async function signIn(name) { 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('meguri-demo-only'); 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 { const alice = await signIn('alice'); await alice.locator('.day').first().waitFor(); await alice.screenshot({ path: 'test-results/calendar-desktop.png', fullPage: true }); const label = 'Browser ' + Date.now(); await alice.getByRole('button', { name: 'New workspace' }).click(); await alice.getByRole('dialog').getByLabel('Name', { exact: true }).fill(label); await save(alice); 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(created.record.revision,1); assert.equal('tenant_id' in created.record,false); assert.equal((await alice.request.get(base+'/api/tenants')).status(),404); await openSetup(alice,'Workspace settings'); await alice.getByRole('button', { name: '+ Add teammate' }).click(); await alice.getByRole('dialog').getByLabel('Registered username').fill('bob'); await save(alice); 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('bob'); 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.getByRole('link', { name: /Requests/ }).click(); await bob.getByRole('button', { name: 'Accept', exact: true }).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, workspace creation, 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; });