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، +و ` 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); +}