diff --git a/.claude/skills/qa-clinicpro/SKILL.md b/.claude/skills/qa-clinicpro/SKILL.md
new file mode 100644
index 00000000..50054ab1
--- /dev/null
+++ b/.claude/skills/qa-clinicpro/SKILL.md
@@ -0,0 +1,300 @@
+---
+name: qa-clinicpro
+description: تست QA اپلیکیشن ClinicPro مثل یک کاربر واقعی — اجرای اپ، ورود با هر نقش، پیمایش صفحات پنل ادمین، اسکرینشات، کشف خطاهای کنسول و شبکه، تست UI/UX و RTL، تست دسترسی نقشها (authz)، تست قرارداد API و اندازهگیری کارایی، و تولید Bug Report. Use when asked to QA, test, smoke-test, find bugs in, screenshot, or verify ClinicPro's admin panel or API — «تست کن»، «باگ پیدا کن»، «QA کن»، «این صفحه را بررسی کن».
+---
+
+# QA ClinicPro
+
+ClinicPro = بکاند Symfony 7.4 + یک **SPA کلاینتساید React 19** که از `/admin/*` سرو میشود.
+یعنی `curl` و فلگ `--screenshot` کروم به درد نمیخورند — هر دو روی فرم لاگین مینشینند،
+چون JWT در `localStorage['clinicpro-auth']` است.
+
+درایور این skill آن کار را انجام میدهد: با API لاگین میکند، `localStorage` را seed
+میکند، بعد ناوبری میکند و **خطاهای کنسول، درخواستهای شکستخورده، مسیری که واقعاً روی آن
+فرود آمده، و اسکرینشات** را گزارش میدهد — با CDP روی `WebSocket` نیتیو Node 22،
+**بدون هیچ وابستگی npm** (نه playwright، نه puppeteer).
+
+مسیرها نسبت به `clinicpro/` هستند.
+
+## پیشنیازها
+
+هیچ نصبی لازم نیست. فقط این دو:
+
+```bash
+ddev describe | head -3 # باید بالا باشد: https://clinic-pro.ddev.site
+ls "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome"
+```
+
+کروم جای دیگری است؟ `CHROME_BIN` را ست کن. بکاند جای دیگری است؟ `CLINICPRO_BASE`.
+
+## کاربران تست
+
+⚠ **`TEST_USERS.md` منسوخ است** — هیچکدام از کاربرانش (`09100000001`, `09100100000`, …)
+در دیتابیس وجود ندارند و همه `ERR_AUTH_005` میگیرند. اسکریپتهای `create_test_users.php`
+و `seed_realistic_data.php` هم که آن فایل ارجاع میدهد در ریپو نیستند.
+
+نقشهای واقعیِ کارکننده در `ROLES` داخل درایور هاردکد شدهاند:
+
+| نقش | موبایل | پسورد |
+|---|---|---|
+| `admin` | `09120671756` | `QaTest@1234` |
+| `clinic` | `09127000000` | `QaTest@1234` |
+| `secretary` | `09123456778` | `QaTest@1234` |
+| `doctor` | `09390039833` | `09390039833` |
+| `representation` | `09124000001` | `09124000001` |
+
+سه تای اول پسوردشان عمداً برای QA ست شده. اگر DB ریست شد، دوباره ست کن:
+
+```bash
+ddev exec php bin/console security:hash-password 'QaTest@1234'
+# هش خروجی را در این کوئری بگذار:
+ddev mysql -e "UPDATE users SET password_hash='<هش>' \
+ WHERE mobile_number IN ('09120671756','09127000000','09123456778');"
+```
+
+اعتبارسنجی همه نقشها:
+
+```bash
+node .claude/skills/qa-clinicpro/driver.mjs roles
+```
+
+خروجی واقعی:
+
+```
+admin 09120671756 ROLE_USER,ROLE_ADMIN token 15min
+clinic 09127000000 ROLE_USER,ROLE_CLINIC token 15min
+secretary 09123456778 ROLE_USER,ROLE_SECRETARY token 15min
+doctor 09390039833 ROLE_USER,ROLE_DOCTOR token 15min
+representation 09124000001 ROLE_USER,ROLE_REPRESENTATION token 15min
+```
+
+میتوانی بهجای نام نقش، `--as "0912xxxxxxx:password"` هم بدهی.
+
+---
+
+## مسیر اجرا (agent path)
+
+### ۱. بازدید از صفحه — اسکرینشات + خطاها
+
+```bash
+node .claude/skills/qa-clinicpro/driver.mjs visit \
+ "https://clinic-pro.ddev.site/admin/dashboard" --as admin --out /tmp/qa-dash.png
+```
+
+```
+✓ screenshot /tmp/qa-dash.png (1440x900, as admin)
+
+LANDING
+ (none)
+
+CONSOLE ERRORS
+ (none)
+
+NETWORK FAILURES
+ (none)
+```
+
+**بعد حتماً تصویر را با ابزار Read باز کن و نگاه کن.** نیمی از باگهای UI فقط دیدنیاند،
+نه لاگشدنی — همان یک اسکرینشات داشبورد دو باگ i18n لو داد (پایین را ببین).
+
+فلگها: `--w 1440 --h 900` (ویوپورت)، `--wait 4000` (ms صبر برای رندر)، `--full` (کل صفحه).
+
+**موبایل را جدا تست کن** — پنل RTL و پرجدول است و بیشتر مشکلات آنجاست:
+
+```bash
+node .claude/skills/qa-clinicpro/driver.mjs visit \
+ "https://clinic-pro.ddev.site/admin/dashboard" --as admin --w 390 --h 844 --out /tmp/qa-m.png
+```
+
+بخش `LANDING` دو حالتی را میگیرد که اسکرینشات پنهان میکند:
+
+```
+⚠ WRONG PAGE: asked /admin/users, landed /admin/dashboard — role likely lacks access (RoleRoute in App.tsx)
+```
+
+### ۲. آدیت UI/UX و RTL
+
+```bash
+node .claude/skills/qa-clinicpro/driver.mjs ux \
+ "https://clinic-pro.ddev.site/admin/dashboard" --as admin --w 390 --h 844
+```
+
+```
+UX FINDINGS (390x844, as admin)
+ 5 tap target(s) under 36px on a mobile viewport
+```
+
+چکها: RTL نبودن ریشه، `lang` غلط، سرریز افقی، رقم لاتین داخل متن فارسی، تارگت لمسی
+زیر ۳۶px، ` ` بدون alt، فیلد بدون label، `id` تکراری، جدول خالی بدون empty-state،
+و `` نیتیو (استاندارد پروژه `SearchableSelect` است).
+
+### ۳. تست دسترسی نقشها (Security)
+
+همان درخواست با همه نقشها + ناشناس:
+
+```bash
+node .claude/skills/qa-clinicpro/driver.mjs authz GET /api/v1/admin/users
+```
+
+```
+AUTHZ GET /api/v1/admin/users
+
+ anonymous 401
+ admin 200
+ clinic 403
+ secretary 403
+ doctor 403
+ representation 403
+
+ 200 for: admin
+```
+
+هر ۲۰۰ غیرمنتظره در این جدول = یک باگ Critical. اگر `anonymous` هم ۲۰۰ گرفت، درایور
+هشدار میدهد.
+
+### ۴. تست قرارداد API
+
+```bash
+node .claude/skills/qa-clinicpro/driver.mjs api GET /api/v1/categorys/state --as admin
+```
+
+```
+GET /api/v1/categorys/state → 301 12ms (as admin)
+
+ENVELOPE
+ (none)
+
+BODY
+{
+ "success": false,
+ "data": null,
+ "errors": [
+ { "code": "ERR_MOVED", "message": "این endpoint منتقل شده. لطفاً از /api/v1/provinces استفاده کنید." }
+ ]
+}
+```
+
+بخش `ENVELOPE` پاکت `BaseController` را چک میکند: نبودِ `success`، پاسخ خطای بدون
+`errors`، و دام معروف **double/triple nesting** (`data.data.data`).
+
+POST هم میشود: `--body '{"name":"x"}'`.
+
+### ۵. کارایی
+
+```bash
+node .claude/skills/qa-clinicpro/driver.mjs perf "https://clinic-pro.ddev.site/admin/doctors" --as admin
+```
+
+```
+PERF https://clinic-pro.ddev.site/admin/doctors (as admin)
+ ttfb 12ms
+ domContentLoaded 232ms
+ load 233ms
+ first-paint 180ms
+ first-contentful-paint 248ms
+ resources 24 · DOM nodes 1132
+
+SLOWEST API CALLS
+ 18ms 2kb v1/admin/doctors?page=1&limit=25
+ 17ms 1kb v1/admin/doctors/stats
+ 17ms 5kb v1/specialties
+ 13ms 1kb v1/provinces
+```
+
+---
+
+## نقش QA و روش کار
+
+وقتی این skill فعال شد، مثل یک **مهندس ارشد تست** رفتار کن، نه فقط اجراکننده دستور:
+
+1. **اول سناریوی واقعی کاربر را بنویس**، بعد اجرا کن. مثال: ورود منشی → لیست نوبتها →
+ تغییر وضعیت یک نوبت → خروج → ورود مجدد → آیا تغییر ماند؟
+2. برای هر بخش این حالتها را پوشش بده:
+ Happy Path · ورودی نامعتبر · داده خالی · داده خیلی زیاد (لیست ۱۰٬۹۳۲ کاربری) ·
+ شرایط مرزی · خطای شبکه · هر پنج نقش · دسکتاپ ۱۴۴۰ و موبایل ۳۹۰.
+3. **هیچ چیز را حدس نزن.** ادعای بدون خروجی دستور، ادعا نیست.
+4. **قبل از گزارش، باگ را دوباره تکرار کن.** همان دستور را دوباره بزن؛ اگر تکرار نشد،
+ flaky بودنش را بنویس نه خودِ باگ را.
+5. باگهای کوچک UI را هم گزارش کن، ولی باگهای Business Logic اولویت بالاترند.
+
+### فرمت Bug Report
+
+هر یافته را با این قالب بنویس (فارسی):
+
+```markdown
+## Title
+<عنوان کوتاه و مشخص>
+
+- **Severity:** Critical | High | Medium | Low
+- **Priority:** فوری | مهم | معمولی | کم
+- **Environment:** Chrome headless · macOS · ddev · نقش: · ویوپورت: x
+
+### Description
+### Steps To Reproduce
+1. `node .claude/skills/qa-clinicpro/driver.mjs …` ← دستور دقیق، نه توضیح
+2.
+### Expected Behavior
+### Actual Behavior
+### Evidence
+<خروجی درایور، مسیر اسکرینشات، پاسخ API>
+### Impact
+### Suggested Fix
+<فایل:خط اگر پیدا کردی>
+```
+
+برای پیدا کردن فایل سورس یک صفحه از روی URL، از skill خواهر استفاده کن:
+
+```bash
+node .claude/skills/redesign-page/driver.mjs inspect "https://clinic-pro.ddev.site/admin/doctors"
+```
+
+### گزارش نهایی
+
+۱. خلاصه وضعیت کلی · ۲. تعداد باگها · ۳. لیست بر اساس Severity ·
+۴. باگهایی که باید فوری رفع شوند · ۵. پیشنهاد بهبود کیفیت.
+
+---
+
+## Gotchas
+
+- **SPA است، پس `curl` صفحه نمیدهد.** `curl /admin/doctors` همیشه همان HTML پوسته را
+ برمیگرداند. هر ادعایی درباره محتوای صفحه باید از `visit` بیاید.
+- **ریدایرکت بیصدای نقش.** `RoleRoute` در `App.tsx` کاربر بدون دسترسی را بیهیچ پیغامی
+ به `/dashboard` میفرستد — اسکرینشات کاملاً سالم بهنظر میرسد ولی صفحهٔ اشتباهی است.
+ همیشه بخش `LANDING` را بخوان.
+- **توکن فقط ۱۵ دقیقه اعتبار دارد.** درایور برای هر دستور دوباره لاگین میکند، پس مسئلهای
+ نیست؛ ولی اگر خودت توکن را جایی کش کردی، انتظار ۴۰۱ داشته باش.
+- **`TEST_USERS.md` دروغ میگوید** (بالا). به آن استناد نکن.
+- **`CLAUDE.md` هم روی `/api/v1/categorys/{bundle}` منسوخ است** — آن مسیر حالا ۳۰۱ با
+ `ERR_MOVED` میدهد و مسیر واقعی `/api/v1/provinces` است.
+- **گواهی TLS ddev را Node قبول نمیکند.** درایور فقط برای هاستهای `*.ddev.site` /
+ `localhost` `NODE_TLS_REJECT_UNAUTHORIZED=0` میگذارد و وارنینگ نویزیاش را خفه میکند.
+- **خطاهای صفحهٔ لاگین به حساب صفحهٔ تحت تست نوشته نشوند.** درایور بافر خطا را بعد از
+ seed کردن `localStorage` و قبل از ناوبری به URL هدف پاک میکند.
+- **دیتای لوکال واقعی و بزرگ است** (۱۰٬۹۳۲ کاربر، ۲۰۲ کلینیک، ۱۷۹ پزشک) — برای تست
+ «داده زیاد» لازم نیست چیزی seed کنی.
+- **CDP روی پورت ۹۴۴۴** است تا با درایور `redesign-page` (پورت ۹۳۳۳) تداخل نکند؛
+ میتوانی هر دو را همزمان اجرا کنی. `CDP_PORT` قابل تغییر است.
+
+## Troubleshooting
+
+| نشانه | علت / رفع |
+|---|---|
+| `login as admin failed: … ERR_AUTH_005` | DB ریست شده؛ پسورد QA را دوباره ست کن (بخش «کاربران تست») |
+| `Chrome did not expose CDP on :9444` | `CHROME_BIN` غلط است، یا نمونهٔ قبلی کروم روی همان پورت مانده — `pkill -f clinicpro-qa` |
+| `⚠ page text only N chars` | رندر SPA کرش کرده یا کند است؛ اول `--wait 8000` را امتحان کن، بعد `CONSOLE ERRORS` را بخوان |
+| `⚠ redirected to /login` | توکن رد شده — با `driver.mjs login ` صحتش را چک کن |
+| `fetch failed` / `ECONNREFUSED` | ddev بالا نیست: `ddev start` |
+
+## باگهای شناختهشده (در همین اجرا پیدا شدند)
+
+نمونههایی از خروجی واقعی همین درایور، بهعنوان مرجعِ اینکه گزارش چطور باشد:
+
+1. **Medium** — در «وضعیت نوبتها»ی داشبورد، برچسبهای `confirmed` و `expired` انگلیسی
+ ماندهاند در حالی که بقیه فارسیاند («تکمیل شده»، «لغو پزشک»).
+ بازتولید: `visit https://clinic-pro.ddev.site/admin/dashboard --as admin`، اسکرینشات.
+2. **Low** — کارت «درآمد این ماه» کلمهٔ «تومان» را دو بار نشان میدهد (یکبار کنار عدد،
+ یکبار بهعنوان زیرنویس کارت). همان اسکرینشات.
+3. **Low** — در ویوپورت ۳۹۰px داشبورد، ۵ تارگت لمسی زیر ۳۶px هستند.
+ بازتولید: `ux … --w 390 --h 844`.
+4. **Medium (مستندات)** — `TEST_USERS.md` و بخش Category در `CLAUDE.md` هر دو منسوخاند.
diff --git a/.claude/skills/qa-clinicpro/driver.mjs b/.claude/skills/qa-clinicpro/driver.mjs
new file mode 100644
index 00000000..bcab1ca6
--- /dev/null
+++ b/.claude/skills/qa-clinicpro/driver.mjs
@@ -0,0 +1,421 @@
+#!/usr/bin/env node
+/**
+ * ClinicPro QA driver — drives the running app the way a real user would, and
+ * reports what broke. No npm dependencies: Node 22's global WebSocket speaks CDP
+ * to a headless Chrome directly, so there is no playwright/puppeteer to install.
+ *
+ * driver.mjs login
+ * driver.mjs visit [--as role] [--out f.png] [--w] [--h] [--wait] [--full]
+ * driver.mjs api [--as role] [--body '{...}']
+ * driver.mjs authz [--body '{...}']
+ * driver.mjs ux [--as role]
+ * driver.mjs perf [--as role]
+ * driver.mjs roles
+ *
+ * `visit` is the workhorse: it logs in over the API, seeds the SPA's auth store
+ * into localStorage, navigates, then reports console errors, failed network
+ * requests, the path it actually landed on, and a screenshot.
+ */
+import { spawn } from 'node:child_process';
+import { writeFileSync } from 'node:fs';
+
+const BASE = process.env.CLINICPRO_BASE ?? 'https://clinic-pro.ddev.site';
+const CHROME = process.env.CHROME_BIN
+ ?? '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome';
+const PORT = Number(process.env.CDP_PORT ?? 9444);
+
+/**
+ * Local QA accounts, one per role. Passwords were set deliberately for testing
+ * (see SKILL.md § Test accounts); the doctor account predates that and still
+ * uses mobile-as-password. TEST_USERS.md is stale — its accounts do not exist.
+ */
+const ROLES = {
+ admin: ['09120671756', 'QaTest@1234'],
+ clinic: ['09127000000', 'QaTest@1234'],
+ secretary: ['09123456778', 'QaTest@1234'],
+ doctor: ['09390039833', '09390039833'],
+ representation: ['09124000001', '09124000001'],
+};
+
+// ddev serves a locally-signed cert Node's fetch refuses. Relax TLS only for it.
+if (/^https:\/\/([\w-]+\.ddev\.site|localhost|127\.0\.0\.1)/.test(BASE)) {
+ process.env.NODE_TLS_REJECT_UNAUTHORIZED = '0';
+ // …which Node then warns about on every run, drowning the actual QA output.
+ process.removeAllListeners('warning');
+ process.on('warning', () => {});
+}
+
+// ── auth ───────────────────────────────────────────────────────────────────
+
+function creds(role) {
+ if (ROLES[role]) return ROLES[role];
+ if (role.includes(':')) return role.split(':'); // "0912...:password"
+ throw new Error(`unknown role "${role}". Known: ${Object.keys(ROLES).join(', ')}`);
+}
+
+async function login(role) {
+ const [mobile_number, password] = creds(role);
+ const r = await fetch(`${BASE}/api/v1/user/login`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ mobile_number, password }),
+ });
+ const j = await r.json();
+ if (!j.access_token) throw new Error(`login as ${role} failed: ${JSON.stringify(j).slice(0, 300)}`);
+ return j;
+}
+
+/** JWT is unsigned-read here purely to report which roles a token carries. */
+function claims(token) {
+ const s = token.split('.')[1];
+ return JSON.parse(Buffer.from(s.replace(/-/g, '+').replace(/_/g, '/'), 'base64').toString());
+}
+
+// ── CDP plumbing ───────────────────────────────────────────────────────────
+
+async function waitForCdp(timeoutMs = 15000) {
+ const deadline = Date.now() + timeoutMs;
+ while (Date.now() < deadline) {
+ try {
+ const r = await fetch(`http://127.0.0.1:${PORT}/json/version`);
+ if (r.ok) return (await r.json()).webSocketDebuggerUrl;
+ } catch { /* not up yet */ }
+ await new Promise((r) => setTimeout(r, 200));
+ }
+ throw new Error(`Chrome did not expose CDP on :${PORT} within ${timeoutMs}ms`);
+}
+
+/** CDP client with both request/response and event subscription. */
+function cdp(ws) {
+ let id = 0;
+ const pending = new Map();
+ const listeners = [];
+ ws.addEventListener('message', (ev) => {
+ const msg = JSON.parse(ev.data);
+ if (msg.id && pending.has(msg.id)) {
+ const { resolve, reject } = pending.get(msg.id);
+ pending.delete(msg.id);
+ msg.error ? reject(new Error(JSON.stringify(msg.error))) : resolve(msg.result);
+ } else if (msg.method) {
+ listeners.forEach((fn) => fn(msg.method, msg.params));
+ }
+ });
+ const send = (method, params = {}, sessionId) =>
+ new Promise((res, rej) => {
+ const msgId = ++id;
+ pending.set(msgId, { resolve: res, reject: rej });
+ ws.send(JSON.stringify({ id: msgId, method, params, sessionId }));
+ });
+ send.on = (fn) => listeners.push(fn);
+ return send;
+}
+
+/**
+ * Boot Chrome, authenticate the SPA, navigate, and hand the page to `fn`.
+ * Collects console errors and failed requests for the whole session.
+ */
+async function withPage(url, opts, fn) {
+ const { access_token, refresh_token } = await login(opts.as);
+
+ const chrome = spawn(CHROME, [
+ '--headless=new', '--disable-gpu', '--no-sandbox', '--hide-scrollbars',
+ '--ignore-certificate-errors', // ddev's local CA
+ `--remote-debugging-port=${PORT}`,
+ `--user-data-dir=/tmp/clinicpro-qa-${process.pid}`,
+ `--window-size=${opts.w},${opts.h}`,
+ 'about:blank',
+ ], { stdio: 'ignore' });
+
+ const errors = [];
+ const netFails = [];
+ try {
+ const ws = new WebSocket(await waitForCdp());
+ await new Promise((res) => ws.addEventListener('open', res, { once: true }));
+ const send = cdp(ws);
+
+ const { targetId } = await send('Target.createTarget', { url: 'about:blank' });
+ const { sessionId } = await send('Target.attachToTarget', { targetId, flatten: true });
+ const S = (m, p) => send(m, p, sessionId);
+
+ await S('Page.enable');
+ await S('Runtime.enable');
+ await S('Log.enable');
+ await S('Network.enable');
+
+ send.on((method, p) => {
+ if (method === 'Runtime.exceptionThrown') {
+ errors.push(`uncaught: ${p.exceptionDetails?.exception?.description ?? p.exceptionDetails?.text}`);
+ } else if (method === 'Runtime.consoleAPICalled' && p.type === 'error') {
+ errors.push('console.error: ' + p.args.map((a) => a.value ?? a.description ?? a.type).join(' '));
+ } else if (method === 'Log.entryAdded' && p.entry.level === 'error') {
+ errors.push(`log(${p.entry.source}): ${p.entry.text}`);
+ } else if (method === 'Network.loadingFailed') {
+ netFails.push(`request failed: ${p.errorText}`);
+ } else if (method === 'Network.responseReceived' && p.response.status >= 400) {
+ netFails.push(`HTTP ${p.response.status} ${p.response.url.replace(BASE, '')}`);
+ }
+ });
+
+ // localStorage is origin-scoped: load the origin before seeding it.
+ await S('Page.navigate', { url: `${BASE}/admin/login` });
+ await new Promise((r) => setTimeout(r, 1500));
+ const auth = {
+ state: { token: access_token, refreshToken: refresh_token, isAuthenticated: true },
+ version: 0,
+ };
+ await S('Runtime.evaluate', {
+ expression: `localStorage.setItem('clinicpro-auth', ${JSON.stringify(JSON.stringify(auth))});
+ localStorage.setItem('pwa-dismissed','1');`,
+ });
+
+ // Errors before this point belong to the login page, not the page under test.
+ errors.length = 0; netFails.length = 0;
+
+ await S('Page.navigate', { url });
+ await new Promise((r) => setTimeout(r, opts.wait));
+
+ const evalJs = async (expression) => {
+ const { result, exceptionDetails } = await S('Runtime.evaluate', {
+ expression, returnByValue: true, awaitPromise: true,
+ });
+ if (exceptionDetails) throw new Error(exceptionDetails.text);
+ return result.value;
+ };
+
+ await fn({ S, evalJs, errors, netFails, url, opts });
+ ws.close();
+ } finally {
+ chrome.kill();
+ }
+}
+
+/** Report the two failure modes a screenshot alone hides: wrong page, blank page. */
+async function landingCheck(evalJs, url) {
+ const v = await evalJs('JSON.stringify({p:location.pathname,t:(document.body.innerText||"").trim().length})');
+ const { p: landed, t: len } = JSON.parse(v);
+ const wanted = new URL(url).pathname;
+ const out = [];
+ if (landed.includes('/login')) out.push('⚠ redirected to /login — token rejected, expired, or route requires auth');
+ else if (landed.replace(/\/$/, '') !== wanted.replace(/\/$/, '')) {
+ out.push(`⚠ WRONG PAGE: asked ${wanted}, landed ${landed} — role likely lacks access (RoleRoute in App.tsx)`);
+ }
+ if (len < 40) out.push(`⚠ page text only ${len} chars — likely blank / crashed render`);
+ return out;
+}
+
+function report(title, lines) {
+ console.log(`\n${title}`);
+ console.log(lines.length ? lines.map((l) => ' ' + l).join('\n') : ' (none)');
+}
+
+// ── commands ───────────────────────────────────────────────────────────────
+
+async function cmdVisit(url, opts) {
+ await withPage(url, opts, async ({ S, evalJs, errors, netFails }) => {
+ const { data } = await S('Page.captureScreenshot', { format: 'png', captureBeyondViewport: opts.full });
+ writeFileSync(opts.out, Buffer.from(data, 'base64'));
+ console.log(`✓ screenshot ${opts.out} (${opts.w}x${opts.h}, as ${opts.as})`);
+ report('LANDING', await landingCheck(evalJs, url));
+ report('CONSOLE ERRORS', [...new Set(errors)]);
+ report('NETWORK FAILURES', [...new Set(netFails)]);
+ });
+}
+
+/**
+ * DOM heuristics for the recurring UX defects of an RTL Persian admin: layout
+ * that overflows sideways, Latin digits leaking into Persian copy, tap targets
+ * too small for the mobile viewport, tables with no empty state.
+ */
+const UX_PROBE = `(() => {
+ const out = [];
+ const de = document.documentElement;
+ if (de.dir !== 'rtl' && getComputedStyle(de).direction !== 'rtl') out.push('root is not RTL');
+ if (de.lang !== 'fa') out.push('html lang is "' + de.lang + '", expected "fa"');
+ if (de.scrollWidth > de.clientWidth + 2)
+ out.push('horizontal overflow: content ' + de.scrollWidth + 'px > viewport ' + de.clientWidth + 'px');
+
+ // Latin digits inside Persian text read as untranslated to a Persian user.
+ const fa = /[\\u0600-\\u06FF]/, latin = /[0-9]/;
+ let mixed = 0;
+ document.querySelectorAll('h1,h2,h3,label,th,button,a').forEach(el => {
+ const t = (el.textContent||'').trim();
+ if (t && fa.test(t) && latin.test(t)) mixed++;
+ });
+ if (mixed) out.push(mixed + ' element(s) mix Persian text with Latin digits (use Persian numerals)');
+
+ // 44px is the usual minimum comfortable touch target.
+ if (innerWidth < 600) {
+ let small = 0;
+ document.querySelectorAll('button,a,[role=button]').forEach(el => {
+ const r = el.getBoundingClientRect();
+ if (r.width > 0 && (r.height < 36 || r.width < 36)) small++;
+ });
+ if (small) out.push(small + ' tap target(s) under 36px on a mobile viewport');
+ }
+
+ document.querySelectorAll('img:not([alt])').forEach(() => {});
+ const noAlt = document.querySelectorAll('img:not([alt])').length;
+ if (noAlt) out.push(noAlt + ' without alt');
+
+ const noLabel = [...document.querySelectorAll('input,select,textarea')]
+ .filter(el => !el.labels?.length && !el.getAttribute('aria-label') && !el.placeholder).length;
+ if (noLabel) out.push(noLabel + ' form field(s) with no label, aria-label, or placeholder');
+
+ const ids = {}; let dup = 0;
+ document.querySelectorAll('[id]').forEach(el => { dup += (ids[el.id] = (ids[el.id]||0) + 1) > 1 ? 1 : 0; });
+ if (dup) out.push(dup + ' duplicate DOM id(s)');
+
+ // A table rendered with zero rows and no empty-state message is a dead end.
+ document.querySelectorAll('table').forEach((t, i) => {
+ const rows = t.querySelectorAll('tbody tr').length;
+ if (rows === 0 && !/(هیچ|یافت نشد|خالی|موردی)/.test(t.parentElement?.textContent||''))
+ out.push('table #' + (i+1) + ' has 0 rows and no empty-state message');
+ });
+
+ if (document.querySelector('select')) out.push('native present — project standard is SearchableSelect');
+ return JSON.stringify(out);
+})()`;
+
+async function cmdUx(url, opts) {
+ await withPage(url, opts, async ({ evalJs, errors, netFails }) => {
+ report('LANDING', await landingCheck(evalJs, url));
+ report(`UX FINDINGS (${opts.w}x${opts.h}, as ${opts.as})`, JSON.parse(await evalJs(UX_PROBE)));
+ report('CONSOLE ERRORS', [...new Set(errors)]);
+ report('NETWORK FAILURES', [...new Set(netFails)]);
+ });
+}
+
+async function cmdPerf(url, opts) {
+ await withPage(url, opts, async ({ evalJs }) => {
+ const t = JSON.parse(await evalJs(`JSON.stringify({
+ nav: performance.getEntriesByType('navigation')[0],
+ paint: performance.getEntriesByType('paint'),
+ api: performance.getEntriesByType('resource')
+ .filter(r => r.name.includes('/api/'))
+ .map(r => ({ u: r.name.split('/api/')[1], ms: Math.round(r.duration), kb: Math.round(r.transferSize/1024) }))
+ .sort((a,b) => b.ms - a.ms).slice(0, 12),
+ res: performance.getEntriesByType('resource').length,
+ dom: document.querySelectorAll('*').length,
+ })`));
+ console.log(`\nPERF ${url} (as ${opts.as})`);
+ if (t.nav) {
+ console.log(` ${'ttfb'.padEnd(24)}${Math.round(t.nav.responseStart)}ms`);
+ console.log(` ${'domContentLoaded'.padEnd(24)}${Math.round(t.nav.domContentLoadedEventEnd)}ms`);
+ console.log(` ${'load'.padEnd(24)}${Math.round(t.nav.loadEventEnd)}ms`);
+ }
+ t.paint.forEach((p) => console.log(` ${p.name.padEnd(24)}${Math.round(p.startTime)}ms`));
+ console.log(` resources ${t.res} · DOM nodes ${t.dom}`);
+ report('SLOWEST API CALLS', t.api.map((a) => `${String(a.ms).padStart(5)}ms ${a.kb}kb ${a.u}`));
+ });
+}
+
+async function apiCall(method, path, role, body) {
+ const { access_token } = await login(role);
+ const t0 = Date.now();
+ const r = await fetch(`${BASE}${path.startsWith('/') ? path : '/' + path}`, {
+ method,
+ headers: {
+ Authorization: `Bearer ${access_token}`,
+ 'Content-Type': 'application/json',
+ },
+ body: body ?? undefined,
+ });
+ const text = await r.text();
+ let json = null;
+ try { json = JSON.parse(text); } catch { /* not json */ }
+ return { status: r.status, ms: Date.now() - t0, json, text };
+}
+
+async function cmdApi(method, path, opts) {
+ const { status, ms, json, text } = await apiCall(method, path, opts.as, opts.body);
+ console.log(`${method} ${path} → ${status} ${ms}ms (as ${opts.as})`);
+
+ // BaseController's envelope is the contract every client depends on.
+ const problems = [];
+ if (!json) problems.push('response is not JSON');
+ else {
+ if (!('success' in json)) problems.push('envelope missing "success"');
+ if (status >= 400 && !json.errors) problems.push('error response has no "errors" array');
+ if (json?.data?.data?.data) problems.push('triple-nested data — BaseController double-nesting pitfall');
+ else if (json?.data?.data && !Array.isArray(json.data)) problems.push('double-nested data (client must read data.data.data)');
+ }
+ report('ENVELOPE', problems);
+ console.log('\nBODY\n' + (json ? JSON.stringify(json, null, 2) : text).slice(0, 2000));
+}
+
+/** Same request as every role plus anonymous — the access-control matrix. */
+async function cmdAuthz(method, path, opts) {
+ console.log(`AUTHZ ${method} ${path}\n`);
+ const rows = [];
+
+ const anon = await fetch(`${BASE}${path}`, { method, headers: { 'Content-Type': 'application/json' }, body: opts.body ?? undefined });
+ rows.push(['anonymous', anon.status]);
+
+ for (const role of Object.keys(ROLES)) {
+ try {
+ const { status } = await apiCall(method, path, role, opts.body);
+ rows.push([role, status]);
+ } catch (e) {
+ rows.push([role, `login failed (${String(e.message).slice(0, 40)})`]);
+ }
+ }
+ rows.forEach(([r, s]) => console.log(` ${r.padEnd(16)} ${s}`));
+
+ const leaks = rows.filter(([r, s]) => r === 'anonymous' && s === 200);
+ if (leaks.length) console.log('\n ⚠ anonymous got 200 — endpoint is public. Intended?');
+ const allowed = rows.filter(([, s]) => s === 200).map(([r]) => r);
+ console.log(`\n 200 for: ${allowed.join(', ') || '(nobody)'}`);
+}
+
+async function cmdRoles() {
+ for (const role of Object.keys(ROLES)) {
+ try {
+ const j = await login(role);
+ const c = claims(j.access_token);
+ const mins = Math.round((c.exp - c.iat) / 60);
+ console.log(`${role.padEnd(16)} ${creds(role)[0]} ${c.roles.join(',')} token ${mins}min`);
+ } catch (e) {
+ console.log(`${role.padEnd(16)} ✗ ${e.message.slice(0, 90)}`);
+ }
+ }
+}
+
+// ── CLI ────────────────────────────────────────────────────────────────────
+
+const [cmd, ...argv] = process.argv.slice(2);
+const flag = (n, d) => { const i = argv.indexOf(`--${n}`); return i >= 0 ? argv[i + 1] : d; };
+const positional = argv.filter((a, i) => !a.startsWith('--') && !(i > 0 && argv[i - 1].startsWith('--') && argv[i - 1] !== '--full'));
+
+const opts = {
+ as: flag('as', 'admin'),
+ out: flag('out', '/tmp/clinicpro-qa.png'),
+ w: Number(flag('w', 1440)),
+ h: Number(flag('h', 900)),
+ wait: Number(flag('wait', 4000)),
+ full: argv.includes('--full'),
+ body: flag('body', null),
+};
+
+try {
+ if (cmd === 'visit' && positional[0]) await cmdVisit(positional[0], opts);
+ else if (cmd === 'ux' && positional[0]) await cmdUx(positional[0], opts);
+ else if (cmd === 'perf' && positional[0]) await cmdPerf(positional[0], opts);
+ else if (cmd === 'api' && positional[1]) await cmdApi(positional[0].toUpperCase(), positional[1], opts);
+ else if (cmd === 'authz' && positional[1]) await cmdAuthz(positional[0].toUpperCase(), positional[1], opts);
+ else if (cmd === 'login' && positional[0]) console.log(JSON.stringify(claims((await login(positional[0])).access_token), null, 2));
+ else if (cmd === 'roles') await cmdRoles();
+ else {
+ console.log(`usage (roles: ${Object.keys(ROLES).join(', ')}, or "mobile:password")
+ driver.mjs roles
+ driver.mjs login
+ driver.mjs visit [--as admin] [--out f.png] [--w 1440] [--h 900] [--wait 4000] [--full]
+ driver.mjs ux [--as admin] [--w] [--h]
+ driver.mjs perf [--as admin]
+ driver.mjs api [--as admin] [--body '{"k":1}']
+ driver.mjs authz [--body '{"k":1}']`);
+ process.exit(1);
+ }
+} catch (e) {
+ console.error('✗ ' + e.message);
+ process.exit(1);
+}