feat: add QA driver for ClinicPro to automate testing and reporting
This commit is contained in:
@@ -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، `<img>` بدون alt، فیلد بدون label، `id` تکراری، جدول خالی بدون empty-state،
|
||||
و `<select>` نیتیو (استاندارد پروژه `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 · نقش: <role> · ویوپورت: <w>x<h>
|
||||
|
||||
### 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 <role>` صحتش را چک کن |
|
||||
| `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` هر دو منسوخاند.
|
||||
@@ -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 <role>
|
||||
* driver.mjs visit <url> [--as role] [--out f.png] [--w] [--h] [--wait] [--full]
|
||||
* driver.mjs api <METHOD> <path> [--as role] [--body '{...}']
|
||||
* driver.mjs authz <METHOD> <path> [--body '{...}']
|
||||
* driver.mjs ux <url> [--as role]
|
||||
* driver.mjs perf <url> [--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 + ' <img> 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 <select> 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 <role>
|
||||
driver.mjs visit <url> [--as admin] [--out f.png] [--w 1440] [--h 900] [--wait 4000] [--full]
|
||||
driver.mjs ux <url> [--as admin] [--w] [--h]
|
||||
driver.mjs perf <url> [--as admin]
|
||||
driver.mjs api <METHOD> <path> [--as admin] [--body '{"k":1}']
|
||||
driver.mjs authz <METHOD> <path> [--body '{"k":1}']`);
|
||||
process.exit(1);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('✗ ' + e.message);
|
||||
process.exit(1);
|
||||
}
|
||||
Reference in New Issue
Block a user