2026-09-24 14:35:05 +03:00
|
|
|
const {openSetup}=require('./support/browser.cjs');
|
|
|
|
|
// Run against an explicitly seeded localhost development instance.
|
2026-09-24 16:10:08 +03:00
|
|
|
|
2026-09-24 14:35:05 +03:00
|
|
|
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 () => {
|
2026-09-24 16:10:08 +03:00
|
|
|
const browser = await require('./support/browser.cjs').launchBrowser();
|
2026-09-24 14:35:05 +03:00
|
|
|
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; });
|