Files
meguri/tests/support/browser.cjs
T
Aleksandr BogomiakovandClaude 38105c65c2 Merge NEXT: planning, generation map and portable configuration on top of the OIDC access model
NEXT predates the access work, so the identity layer follows master: access comes from
configured groups, instance administrators replace the site administration, and accounts are
created by SSO or by an administrator. Kept from NEXT: manual publication with the Review
changes panel and shared generator drafts, portable import/export (meguri/v2), the layered
generation map with binding provenance, JSM provider changes, contact/approval semantics and
the account-owned Person. Dropped: site administration, local sign-up, and the mandatory
unique email with its contact/account migrations. The manual-publication migration is
renumbered m_0010 after m_0009_access.

Accounts without an email still get a Person in every workspace they reach, a changed sign-in
email updates the account's person, and an account that loses access keeps its linked person so
schedules stay valid. Instance administrators may use Sync and shared drafts.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-10-07 16:06:10 +01:00

99 lines
5.8 KiB
JavaScript

const { chromium } = require('playwright');
function launchBrowser() {
const channel = process.env.MEGURI_BROWSER_CHANNEL || 'chrome';
const executablePath = process.env.MEGURI_BROWSER_EXECUTABLE;
return chromium.launch({
headless: true,
...(executablePath ? { executablePath, args: ['--no-sandbox'] } : channel === 'chromium' ? {} : { channel }),
});
}
exports.launchBrowser = launchBrowser;
const baseUrl = () => process.env.MEGURI_BROWSER_URL || 'http://127.0.0.1:3000';
exports.baseUrl = baseUrl;
// The demo generates one password per server start and publishes it to the login screen.
// Generator drafts are shared and persisted per workspace, so a suite that edited a demo
// workspace without applying leaves them behind; discard them when a suite starts.
let password;
exports.demoPassword = () => password ||= fetch(baseUrl() + '/api/session').then(r => r.json()).then(async session => {
if (!session.demo_lifecycle?.password) throw new Error('Browser tests require a disposable demo instance');
await discardDemoDrafts(session.demo_lifecycle.password);
return session.demo_lifecycle.password;
});
async function discardDemoDrafts(secret) {
const lead = await exports.apiLogin('alice', secret);
for (const { id } of (await lead.get('/api/workspaces')).workspaces) {
const detail = await lead.get('/api/workspaces/' + id);
const draft = detail.generation_draft;
if (!draft?.payload) continue;
const saved = detail.workspace.document.generation || {};
await lead.put(`/api/workspaces/${id}/generation-draft`, { revision: draft.revision, payload: { revision: detail.workspace.revision, variables: saved.variables || [], rules: saved.rules || [], route_position: saved.route_position || 0 } });
}
}
exports.signIn = async (page, username, secret) => {
await page.goto(baseUrl());
await page.getByLabel('Username', { exact: true }).fill(username);
await page.getByLabel('Password', { exact: true }).fill(secret ?? await exports.demoPassword());
await page.getByRole('button', { name: 'Sign in', exact: true }).click();
await page.waitForFunction(() => { const a = window.Alpine && Alpine.$data(document.body); return a && a.ready && a.user && !a.busy; });
};
// Minimal cookie-aware JSON client for test setup that must not go through the UI.
exports.apiLogin = async (username, secret) => {
const base = baseUrl();
const login = await fetch(base + '/api/login', { method: 'POST', headers: { Origin: base, 'Content-Type': 'application/json' }, body: JSON.stringify({ username, password: secret ?? await exports.demoPassword() }) });
if (!login.ok) throw new Error(`Login as ${username} failed: ${login.status}`);
const cookie = login.headers.getSetCookie().map(c => c.split(';')[0]).find(c => c.startsWith('meguri_session='));
const { csrf } = await login.json();
const call = async (method, path, body) => {
const response = await fetch(base + path, { method, headers: { Origin: base, 'Content-Type': 'application/json', Cookie: cookie, 'X-CSRF-Token': csrf }, ...(body === undefined ? {} : { body: JSON.stringify(body) }) });
const data = await response.json().catch(() => ({}));
return { status: response.status, data };
};
const ok = async (method, path, body) => {
const result = await call(method, path, body);
if (result.status >= 300) throw new Error(`${method} ${path} -> ${result.status} ${JSON.stringify(result.data)}`);
return result.data;
};
return { call, ok, get: path => ok('GET', path), post: (path, body) => ok('POST', path, body), put: (path, body) => ok('PUT', path, body), csrf, cookie };
};
exports.adminApi = () => exports.apiLogin('admin');
// Mutating suites each own one empty sandbox workspace ("Sandbox N", demo started with
// MEGURI_DEMO_SANDBOXES) whose groups are sandbox-N-leads (administrators) and sandbox-N-staff (employees).
// Nobody is in a sandbox except accounts created here, so suites do not depend on each other or on order.
exports.createUser = async (admin, { sandbox, role = 'admin', prefix, display, email }) => {
const overview = await admin.get('/api/admin/overview');
const target = overview.workspaces.find(w => w.name === `Sandbox ${sandbox}` && !w.disabled_at);
if (!target) throw new Error(`Start the demo with MEGURI_DEMO_SANDBOXES>=${sandbox}: Sandbox ${sandbox} not found`);
const groups = role === 'admin' ? target.admin_groups : target.member_groups;
const username = `${prefix}-${Date.now().toString(36)}${Math.random().toString(36).slice(2, 6)}`.toLowerCase();
const secret = 'local-browser-test-password';
await admin.post('/api/admin/users', { username, display_name: display, password: secret, groups, ...(email ? { email } : {}) });
return { username, password: secret, display, email, workspace: target };
};
// An administrator of exactly one sandbox, plus any employees (display names) created beside them.
exports.isolatedUser = async ({ sandbox, prefix, display, email, employees = [] }) => {
const admin = await exports.adminApi();
const user = await exports.createUser(admin, { sandbox, role: 'admin', prefix, display, email });
const staff = [];
for (const name of employees) staff.push(await exports.createUser(admin, { sandbox, role: 'member', prefix: name.split(' ')[0], display: name }));
return { admin, ...user, staff };
};
exports.openSetup = async (page, name) => {
await page.waitForFunction(()=>window.Alpine && Alpine.$data(document.body).ready && Alpine.$data(document.body).workspace);
const combined=['Schedules','Escalations & routing','Variables & rules'].includes(name);
const target=page.getByRole('link',{name:combined?'Schedule setup':name,exact:true});
if(!await target.isVisible())await page.getByRole('button',{name:'Setup',exact:true}).click();
await target.click();
if(combined)await page.locator(name==='Schedules'?'#schedules':name==='Escalations & routing'?'#schedules':'#generation').scrollIntoViewIfNeeded();
};