2026-09-24 16:10:08 +03:00
|
|
|
const { chromium } = require('playwright');
|
|
|
|
|
|
|
|
|
|
function launchBrowser() {
|
|
|
|
|
const channel = process.env.MEGURI_BROWSER_CHANNEL || 'chrome';
|
2026-10-07 13:28:43 +01:00
|
|
|
const executablePath = process.env.MEGURI_BROWSER_EXECUTABLE;
|
2026-09-24 16:10:08 +03:00
|
|
|
return chromium.launch({
|
|
|
|
|
headless: true,
|
2026-10-07 13:28:43 +01:00
|
|
|
...(executablePath ? { executablePath, args: ['--no-sandbox'] } : channel === 'chromium' ? {} : { channel }),
|
2026-09-24 16:10:08 +03:00
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
exports.launchBrowser = launchBrowser;
|
|
|
|
|
|
2026-10-07 13:28:43 +01:00
|
|
|
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.
|
2026-10-07 16:06:10 +01:00
|
|
|
// 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.
|
2026-10-07 13:28:43 +01:00
|
|
|
let password;
|
2026-10-07 16:06:10 +01:00
|
|
|
exports.demoPassword = () => password ||= fetch(baseUrl() + '/api/session').then(r => r.json()).then(async session => {
|
2026-10-07 13:28:43 +01:00
|
|
|
if (!session.demo_lifecycle?.password) throw new Error('Browser tests require a disposable demo instance');
|
2026-10-07 16:06:10 +01:00
|
|
|
await discardDemoDrafts(session.demo_lifecycle.password);
|
2026-10-07 13:28:43 +01:00
|
|
|
return session.demo_lifecycle.password;
|
|
|
|
|
});
|
|
|
|
|
|
2026-10-07 16:06:10 +01:00
|
|
|
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 } });
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-07 13:28:43 +01:00
|
|
|
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.
|
2026-10-07 16:06:10 +01:00
|
|
|
exports.createUser = async (admin, { sandbox, role = 'admin', prefix, display, email }) => {
|
2026-10-07 13:28:43 +01:00
|
|
|
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';
|
2026-10-07 16:06:10 +01:00
|
|
|
await admin.post('/api/admin/users', { username, display_name: display, password: secret, groups, ...(email ? { email } : {}) });
|
|
|
|
|
return { username, password: secret, display, email, workspace: target };
|
2026-10-07 13:28:43 +01:00
|
|
|
};
|
|
|
|
|
|
|
|
|
|
// An administrator of exactly one sandbox, plus any employees (display names) created beside them.
|
2026-10-07 16:06:10 +01:00
|
|
|
exports.isolatedUser = async ({ sandbox, prefix, display, email, employees = [] }) => {
|
2026-10-07 13:28:43 +01:00
|
|
|
const admin = await exports.adminApi();
|
2026-10-07 16:06:10 +01:00
|
|
|
const user = await exports.createUser(admin, { sandbox, role: 'admin', prefix, display, email });
|
2026-10-07 13:28:43 +01:00
|
|
|
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 };
|
|
|
|
|
};
|
|
|
|
|
|
2026-09-24 14:35:05 +03:00
|
|
|
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();
|
|
|
|
|
};
|