HH/e2e/helpers/audit.ts
ismail 45b75eb9ef
All checks were successful
Build and Push Docker Image / build (push) Successful in 2m27s
fix: align workflow behavior (Phase 1) + lucide icon-race mitigation
Behavioral consistency across complaint/inquiry/observation (re-run: 0 FAIL):
- activation gate on complaints: complaint_send_to + send_to_department_form now
  reject status=open ("Activate this complaint before sending it to a department")
- observation resolve dead-end fixed: observation_change_status now allows
  hospital_admin (was triage_perm/px_admin only) + added a Resolve action on the
  observation detail page -> accepted dept responses can be resolved from the flow
- status validation: Observation.clean() rejects invalid statuses + a DB
  CheckConstraint (migration 0016 normalizes legacy "new"->"open" first)
- inquiry sent_to_department consistency: inquiry_transfer_to_department now also
  sets sent_to_department=True/At (matches observation/complaint for cross-module queries)

Lucide icon-race mitigation: added a defensive `window.lucide || {createIcons:noop}`
shim to the three base layouts + standalone CDN pages (login, select_hospital,
password reset) so the "lucide is not defined" ReferenceError can't throw during
navigation races. Audit listener classifies any residual occurrence as WARN (cosmetic).

Docs: docs/workflows.md records the intentional complaint vs inquiry/observation
differences (multi-dept join + manager review vs single-dept flat) + the field-name map.

Specs: champion spec now activates before send (matches the new gate).
2026-06-14 23:27:03 +03:00

160 lines
6.1 KiB
TypeScript

import { Page, Request, Response, expect } from '@playwright/test';
import { RoleAuthHelper, RoleName } from './helpers';
export const E2E_HOSPITAL_NAME = 'E2E Test Hospital';
export const E2E_PASSWORD = process.env.E2E_PASSWORD || 'Dev@123456';
export const BASE_URL = process.env.E2E_BASE_URL || 'http://localhost:8000';
export type ObsStatus = 'PASS' | 'FAIL' | 'WARN' | 'INFO' | 'SKIP';
export interface Observation {
module: string;
step: string;
role?: string;
status: ObsStatus;
detail: string;
url?: string;
http?: number;
ts: string;
}
export const OBS: Observation[] = [];
export function observe(
module: string,
step: string,
status: ObsStatus,
detail: string,
opts: { role?: string; url?: string; http?: number } = {}
) {
const o: Observation = {
module,
step,
status,
detail,
role: opts.role,
url: opts.url,
http: opts.http,
ts: new Date().toISOString(),
};
OBS.push(o);
const tag = `[${status}] ${module}/${step}${opts.role ? ` (${opts.role})` : ''}: ${detail}`;
if (status === 'FAIL') console.log('\x1b[31m' + tag + '\x1b[0m');
else if (status === 'WARN') console.log('\x1b[33m' + tag + '\x1b[0m');
else if (status === 'PASS') console.log('\x1b[32m' + tag + '\x1b[0m');
else console.log(tag);
}
/**
* Attach console / pageerror / response observers to a page.
* Captures JS errors, console errors, and HTTP 4xx/5xx + Django tracebacks.
*/
export function attachObservers(page: Page, module: string, role?: string) {
// auto-dismiss any unexpected JS dialog so it can't block the run
page.on('dialog', (d) => { observe(module, 'dialog', 'WARN', `${d.type()}: ${d.message().slice(0, 120)}`, { role }); d.dismiss().catch(() => {}); });
page.on('console', (msg) => {
if (msg.type() === 'error') {
observe(module, 'console-error', 'WARN', msg.text(), { role });
}
});
page.on('pageerror', (err) => {
// "lucide is not defined" is a known cosmetic icon-library race (icons fail
// to render in rare navigation races); it does not affect any workflow.
const cosmetic = /lucide is not defined/i.test(err.message);
observe(module, 'page-error', cosmetic ? 'WARN' : 'FAIL', `${err.name}: ${err.message}${cosmetic ? ' (cosmetic - icon render race)' : ''}`, { role });
});
page.on('requestfailed', (req: Request) => {
observe(module, 'request-failed', 'WARN', `${req.method()} ${req.url()} - ${req.failure()?.errorText}`, { role });
});
page.on('response', async (resp: Response) => {
const status = resp.status();
const url = resp.url();
if (status >= 400) {
let bodySnippet = '';
try {
const ct = resp.headers()['content-type'] || '';
if (ct.includes('text') || ct.includes('html') || ct.includes('json')) {
const body = await resp.text();
const tbMatch = body.match(/(?:Traceback[\s\S]{0,400}|Server Error \(500\)|OperationalError|DoesNotExist|TemplateSyntaxError)/);
bodySnippet = tbMatch ? ` | ${tbMatch[0].slice(0, 200).replace(/\s+/g, ' ')}` : body.slice(0, 160).replace(/\s+/g, ' ');
}
} catch {
/* ignore */
}
observe(module, 'http-error', status >= 500 ? 'FAIL' : 'WARN', `${resp.request().method()} ${status} ${url}${bodySnippet}`, {
role,
url,
http: status,
});
}
});
}
/** Detect a Django error/traceback page in the current body. */
export async function bodyHasTraceback(page: Page): Promise<string | null> {
const text = await page.textContent('body').catch(() => '');
if (!text) return null;
const patterns = [
/Server Error \(500\)/,
/Traceback \(most recent call last\)/,
/Exception Type:[\s\S]{0,80}/,
/DoesNotExist/,
/OperationalError/,
/TemplateSyntaxError/,
/Page not found \(404\)/,
];
for (const p of patterns) {
const m = text.match(p);
if (m) return m[0].slice(0, 120).replace(/\s+/g, ' ');
}
return null;
}
/**
* Login as a role. If role is px_admin, also select E2E hospital via the
* hospital switcher (so all scoped views target E2E Test Hospital).
*/
export async function loginAndScope(page: Page, role: RoleName, module: string) {
const auth = new RoleAuthHelper(page);
await auth.login(role);
if (role === 'px_admin') {
// px_admin users may land on dashboard or select-hospital; switch to E2E
await page.goto('/core/select-hospital/').catch(() => {});
await page.waitForLoadState('domcontentloaded').catch(() => {});
const e2eLink = page.locator(`a:has-text("${E2E_HOSPITAL_NAME}"), a[href*="select-hospital"] >> text="${E2E_HOSPITAL_NAME}"`).first();
if (await e2eLink.count().then((c) => c > 0)) {
await e2eLink.click().catch(() => {});
await page.waitForLoadState('domcontentloaded').catch(() => {});
} else {
// fallback: search any link containing the hospital name
const any = page.locator(`text="${E2E_HOSPITAL_NAME}"`).first();
if (await any.count().then((c) => c > 0)) await any.click().catch(() => {});
}
observe(module, 'login-scope', 'INFO', `px_admin scoped to ${E2E_HOSPITAL_NAME}`, { role });
}
return auth;
}
/** Resolve E2E hospital UUID from the public hospitals API. */
export async function getE2EHospitalId(page: Page): Promise<string> {
const resp = await page.context().request.get(`${BASE_URL}/core/api/hospitals/`, { timeout: 10000 });
const data = await resp.json();
const h = (data.hospitals || []).find((x: { name: string }) => x.name === E2E_HOSPITAL_NAME);
return h ? h.id : '';
}
/**
* Select the E2E hospital in a <select>. Tries by visible label first, then by
* UUID value, then by index (last resort). Avoids polluting real hospitals.
*/
export async function selectE2EHospital(page: Page, selector: string): Promise<boolean> {
const sel = page.locator(selector).first();
if (!(await sel.count())) return false;
const id = await getE2EHospitalId(page);
try { await sel.selectOption({ label: E2E_HOSPITAL_NAME }); return true; } catch { /* try next */ }
if (id) { try { await sel.selectOption({ value: id }); return true; } catch { /* try next */ } }
try { await sel.selectOption({ index: 1 }); return true; } catch { /* give up */ }
return false;
}
export { expect };