الأدلة التقنية

اختبار سهولة الوصول Accessibility لموقع عربي

ما الذي تجده أدوات فحص سهولة الوصول وما الذي يفوتها، وما الذي يخص المواقع العربية، وكيف تمنع عودة المشاكل في خط CI.

اختبار سهولة الوصول Accessibility لموقع عربي

تفتح صفحة حجز موعد بنيتها قبل أسبوع، فتملأ الحقول بالفأرة وتضغط الزر ويصل الطلب، وتنجح كل الاختبارات في خط CI، ويبدو أن الصفحة جاهزة. ولكن جرب الآن أن تضع الفأرة جانباً وتنتقل بمفتاح Tab وحده، أو أن تشغل قارئ الشاشة Screen Reader وتغمض عينيك، أو أن تفتح الصفحة على جوال في الشمس، وسوف تجد أن الزر لا يصله التركيز، وأن الحقل يقرأ «حقل نص» دون أن تعرف ماذا تكتب فيه، وأن التلميح الرمادي لا يقرأ أصلاً. وهذا ما يسمى سهولة الوصول Accessibility، واختصاراً a11y، أي أن يستطيع كل الناس استخدام موقعك مهما كانت الطريقة التي يصلون بها إليه.

وقد شرحنا في مقال أنواع الاختبارات للمبتدئين أساسيات Playwright، ولاحظنا هناك في فقرة المحددات Locators أن الاختبار الذي لا يجد الزر بدوره واسمه يكشف غالباً مشكلة في سهولة الوصول، وهذا المقال يكمل من هناك، فيشرح المعيار نفسه، والأدوات الآلية وما تجده، وما لا تجده إلا بعينك ويدك، ثم المشاكل الخاصة بالمواقع العربية التي تكتب من اليمين إلى اليسار RTL. وإذا كنت تبني الصفحة من البداية فابدأ بمقال سهولة الوصول Accessibility: كيف تبني موقعاً يستخدمه الجميع، فهو يشرح كيف تكتب النوافذ والتبويبات والنماذج بشكل صحيح قبل أن تحتاج إلى فحصها.

وسوف نناقش في هذا المقال ما يلي:

  • ما هي سهولة الوصول، ولمن نعمل عليها، ولماذا تخص كل موقع وليس المواقع الحكومية فقط.
  • معيار WCAG 2.2: المبادئ الأربعة والمستويات A وAA وAAA، والمعايير التي أضيفت في 2.2، وما يطلبه دليل هيئة الحكومة الرقمية في السعودية.
  • الأدوات الآلية axe-core وpa11y وLighthouse: كيف تعمل، وما الفرق بينها، ورخصها.
  • ما الذي لا تراه الأدوات: لوحة المفاتيح، ومعنى النص البديل، وقارئ الشاشة بالعربية.
  • مشاكل خاصة بالعربية: لغة الصفحة واتجاهها، وخلط الاتجاهين Bidi، والأيقونات التي تنعكس والتي لا تنعكس.
  • تجربة عملية على صفحة حجز عربية فيها 14 مشكلة مقصودة، نفحصها بالأدوات الثلاث، ثم نصلحها، ثم نضيف فحص axe إلى اختبار Playwright يعمل في خط CI.

ما هي سهولة الوصول، ولمن؟

سهولة الوصول Accessibility هي أن يستطيع الشخص صاحب الإعاقة أن يدرك محتوى الموقع ويفهمه ويتنقل فيه ويتفاعل معه، وهذا تعريف مبادرة الوصول في W3C مختصراً. والعدد هنا ليس صغيراً، فبحسب صحيفة منظمة الصحة العالمية المنشورة في مارس 2023 يعيش نحو 1.3 مليار شخص مع إعاقة كبيرة، أي 16% من سكان العالم، أو شخص من كل ستة.

ولكن الفائدة أوسع من ذلك، فالصفحة التي تعمل جيداً مع قارئ الشاشة ولوحة المفاتيح تخدم أيضاً:

  • كبار السن: الذين يكبرون الخط ويحتاجون تبايناً أعلى وأزراراً أكبر.
  • الإعاقة المؤقتة: يد مكسورة تجعل الفأرة صعبة، أو عملية في العين.
  • الظروف المؤقتة Situational: جوال في الشمس يجعل النص الرمادي غير مقروء، أو مكان لا تستطيع فيه تشغيل الصوت.
  • مستخدمي لوحة المفاتيح بالاختيار: وأنت منهم على الأغلب إذا كنت تملأ النماذج بمفتاح Tab.
  • الآلات: فالاختبارات التي تبحث عن العناصر بدورها واسمها، ووكلاء الذكاء الاصطناعي الذين يقرؤون شجرة الوصول Accessibility Tree كما شرحنا في Playwright MCP، كلها تعمل أفضل على صفحة سهلة الوصول.

وقد يتساءل البعض: هل هذا عمل المصمم أم المطور أم فريق الاختبار؟ والإجابة أنه عمل الثلاثة، فالمصمم يختار الألوان والأحجام، والمطور يكتب العناصر الصحيحة والأسماء، وفريق الاختبار يتحقق قبل الإصدار، ولذلك فالأفضل أن تكتب سهولة الوصول كمتطلب غير وظيفي واضح منذ البداية، كما فعلنا في متطلب NFR-09 في مثال المتطلبات الذي يطلب المستوى AA ويحدد طريقة التحقق منه.

معيار WCAG 2.2: أربعة مبادئ وثلاثة مستويات

المعيار الذي تقيس عليه كل الأدوات هو إرشادات الوصول إلى محتوى الويب WCAG 2.2 من W3C، وقد صدر كتوصية W3C Recommendation في 5 أكتوبر 2023 بحسب صفحة ما الجديد في WCAG 2.2، وآخر نسخة منشورة منه بتاريخ 12 ديسمبر 2024. ويقسم المعيار المتطلبات على أربعة مبادئ، وتحت المبادئ إرشادات Guidelines، وتحت الإرشادات معايير نجاح Success Criteria قابلة للاختبار، ولكل معيار مستوى من ثلاثة.

الصورة التالية تبين المبادئ الأربعة بمثال لكل واحد، والمستويات الثلاثة بعدد معاييرها:

أربع بطاقات للمبادئ: قابل للإدراك Perceivable بمثال النص البديل والتباين، وقابل للتشغيل Operable بمثال لوحة المفاتيح والتركيز، وقابل للفهم Understandable بمثال لغة الصفحة والتسميات، ومتين Robust بمثال الزر الحقيقي واسمه، وتحتها شريط المستويات A بواحد وثلاثين معياراً وAA بأربعة وعشرين وAAA بواحد وثلاثين
اختصار المبادئ الأربعة بالإنجليزية POUR، وعدد المعايير محسوب من نص WCAG 2.2 نفسه

وفي الإعداد أعلاه لاحظ التالي:

  • المستوى A: الحد الأدنى، وفيه 31 معياراً، مثل النص البديل للصور ولغة الصفحة والوصول بلوحة المفاتيح.
  • المستوى AA: يضيف 24 معياراً، مثل التباين 4.5:1 للنص العادي و3:1 للنص الكبير في المعيار 1.4.3، والتركيز الظاهر. والتوافق مع AA يعني أن تحقق معايير A وAA معاً، وهو المستوى الذي تطلبه أغلب السياسات والعقود.
  • المستوى AAA: يضيف 31 معياراً متقدماً، والمعيار نفسه يقول إنه لا ينصح بفرض AAA كسياسة عامة على موقع كامل، لأن بعض المحتوى لا يمكن أن يحقق كل معاييره.
  • المجموع: 86 معياراً بمستوى، أحصيناها من نص التوصية، ومعها المعيار 4.1.1 Parsing الذي بقي رقمه في النص وعليه علامة «حذف لأنه أصبح متقادماً».

ما الذي أضافته WCAG 2.2؟

أضافت النسخة 2.2 تسعة معايير وحذفت معياراً واحداً، والمعايير الجديدة في جدول:

المعيارالمستوىماذا يطلب باختصار
2.4.11 Focus Not Obscured (Minimum)AAالعنصر الذي عليه التركيز Focus لا يختفي كاملاً خلف شريط ثابت أو نافذة منبثقة
2.4.12 Focus Not Obscured (Enhanced)AAAلا يغطى أي جزء من العنصر الذي عليه التركيز
2.4.13 Focus AppearanceAAAإطار التركيز بحجم وتباين كافيين
2.5.7 Dragging MovementsAAكل عملية سحب Drag لها بديل بنقرة واحدة
2.5.8 Target Size (Minimum)AAالهدف الذي يضغط عليه 24 في 24 بكسل CSS على الأقل، مع استثناءات
3.2.6 Consistent HelpAوسيلة المساعدة في المكان نفسه في كل الصفحات
3.3.7 Redundant EntryAلا تطلب من المستخدم إدخال المعلومة نفسها مرتين في العملية نفسها
3.3.8 Accessible Authentication (Minimum)AAتسجيل الدخول لا يعتمد على اختبار ذاكرة أو حل لغز دون بديل، مثل السماح بلصق الباسورد
3.3.9 Accessible Authentication (Enhanced)AAAالنسخة الأشد من المعيار السابق

أما المعيار المحذوف فهو 4.1.1 Parsing، وتقول W3C إنه أصبح متقادماً، والسبب أن المتصفحات والتقنيات المساعدة الحالية تتعامل مع أخطاء بنية HTML بطريقة موحدة، فلم يعد يكشف مشكلة حقيقية للمستخدم. ويجدر الإشارة أيضاً إلى أن المعيار ينص على أن المحتوى المتوافق مع WCAG 2.2 متوافق أيضاً مع 2.1 و2.0، لذلك إذا كان العقد أو النظام يطلب 2.1 فالعمل على 2.2 يغطيه.

في السعودية: دليل هيئة الحكومة الرقمية

نشرت هيئة الحكومة الرقمية DGA في 1 ديسمبر 2022 دليل إمكانية الوصول للمواقع الحكومية برقم DGA-1-2-4-203 والإصدار 1.0، وهو مبني على WCAG 2.1، وينص في نسخته الإنجليزية على أن المستوى AA هو الحد الأدنى المطلوب من الجهات الحكومية، ويضع قائمة تحقق لكل مستوى. وقد راجعنا الصفحة والملف في 12 أكتوبر 2026، وكانت الصفحة العربية تذكر أن آخر تحديث لمحتواها في 23 أغسطس 2026، لذلك إذا كنت تبني منصة لجهة حكومية فارجع إلى صفحة الهيئة نفسها قبل التعاقد، فقد يصدر إصدار أحدث.

الأدوات الآلية: axe-core وpa11y وLighthouse

الأداة الآلية تفتح الصفحة في متصفح حقيقي، ثم تمر على شجرة DOM وتطبق قواعد مكتوبة مسبقاً، مثل «كل صورة لها نص بديل» و«كل حقل له اسم» و«تباين النص كاف»، وتعطيك قائمة بالعناصر المخالفة. وهذا يعني أنها تجد ما يمكن الحكم عليه من الكود والألوان، ولا تجد ما يحتاج فهماً لمعنى الصفحة، كما سوف ترى في التجربة.

  • axe-core: محرك القواعد الأشهر من شركة Deque، مكتبة JavaScript تعمل داخل الصفحة، ورخصتها MPL-2.0. وتأتي معه حزم للربط مع أدوات الاختبار في مستودع axe-core-npm، منها @axe-core/playwright الذي سوف نستخدمه، وتوصي به وثائق Playwright. ويقسم axe النتائج إلى مخالفات Violations، وناجحة Passes، وتحتاج مراجعة Incomplete عندما لا يستطيع الحكم وحده.
  • pa11y: أداة سطر أوامر ومكتبة Node.js رخصتها LGPL-3.0، تفتح الصفحة عبر Puppeteer وتشغل عليها أحد محركين: HTML_CodeSniffer وهو المحرك الافتراضي ورخصته BSD-3-Clause، أو axe. وفائدتها أنها تعطيك محركاً ثانياً بقواعد مختلفة، وأن النسخة pa11y-ci تفحص قائمة صفحات دفعة واحدة.
  • Lighthouse: أداة Google المدمجة في أدوات المطور في Chrome، رخصتها Apache-2.0، وفيها فئة للأداء وأخرى لسهولة الوصول. وتقول صفحة طريقة الحساب إن درجة سهولة الوصول متوسط موزون لكل فحوص الفئة، والأوزان مأخوذة من تقييم axe لأثر كل قاعدة، وإن كل فحص نجاح أو فشل دون درجات جزئية، فإذا كان زر واحد من عشرة بلا اسم فالفحص كله صفر.

والجدول التالي يلخص الفرق:

الأداةالمحركالرخصةأين تستخدمها
axe-core مع PlaywrightaxeMPL-2.0داخل اختبارات E2E في خط CI، فيفشل الدمج عند أي مخالفة
pa11yHTML_CodeSniffer أو axeLGPL-3.0فحص سريع لقائمة روابط من سطر الأوامر، ورأي ثان بمحرك مختلف
Lighthouseaxe مع أوزانApache-2.0درجة من 100 تتابعها مع الأداء، وتقرير يفهمه غير المطور

ولاحظ أن Lighthouse وaxe يشتركان في المحرك نفسه، لذلك تشغيلهما معاً لا يعطيك رأياً ثانياً حقيقياً، أما HTML_CodeSniffer فقواعده مكتوبة بشكل مستقل، وسوف ترى في التجربة أنه وجد مشكلتين فاتتا axe، وفاتته مشكلة وجدها axe.

ما الذي لا تراه الأدوات؟

تقول Deque نفسها في تقرير تغطية الفحص الآلي إن اختباراتها الآلية وجدت في المتوسط 57.38% من إجمالي المشاكل في أكثر من 2000 تدقيق أول، على أكثر من 13 ألف صفحة ونحو 300 ألف مشكلة، ولاحظ أن هذا رقم البائع على بياناته وبعدد المشاكل وليس بعدد المعايير، والمعنى العملي أن قرابة نصف المشاكل تحتاج إنساناً. وتقول وثائق Playwright الشيء نفسه بوضوح: الاختبار الآلي يجد بعض المشاكل الشائعة، وكثير من المشاكل لا يكتشف إلا بالاختبار اليدوي.

والمشاكل التي تحتاج إنساناً هي غالباً ما يحتاج فهم المعنى أو تجربة التفاعل:

  • لوحة المفاتيح: هل يصل Tab إلى كل عنصر تفاعلي بترتيب منطقي؟ وهل إطار التركيز ظاهر؟ والأداة لا تضغط Tab ولا تعرف أن div عليه onclick هو زر في نظر المستخدم.
  • معنى النص البديل: الأداة تعرف أن alt موجود، ولا تعرف أن alt="image1.png" لا يفيد أحداً، أو أن صورة الزخرفة يجب أن يكون نصها البديل فارغاً.
  • هدف الرابط: «اضغط هنا» نص موجود فيرضي الأداة، ولكنه لا يقول لمستخدم قارئ الشاشة الذي يتنقل بين الروابط إلى أين يذهب، وهذا المعيار 2.4.4.
  • ترتيب القراءة وصحة اللغة: الأداة تتحقق أن lang موجود، ولا تتحقق أنه صحيح، وسوف ترى ذلك في التجربة.
  • قارئ الشاشة نفسه: كيف تقرأ الصفحة فعلاً، وهل الرسائل المتغيرة تعلن، وهل النطق مفهوم.

قارئ الشاشة بالعربية

لا تحتاج شراء شيء لتجرب قارئ الشاشة، فعلى Windows يوجد NVDA المجاني ومفتوح المصدر، وتقول دليل استخدامه إنه مترجم إلى 56 لغة منها العربية، وإنه يأتي مع محرك النطق eSpeak NG المفتوح المصدر. وعلى macOS وiOS يوجد VoiceOver مدمجاً في النظام، وعلى Android يوجد TalkBack.

وهناك أمران يخصان العربية ويمكن التحقق منهما من الوثائق نفسها:

  • NVDA يغير لغة النطق حسب lang: فإعداد التبديل التلقائي للغة Automatic Language Switching مفعل افتراضياً بحسب دليل NVDA، ويعني أن القارئ يغير محرك النطق إذا حدد النص لغته. فإذا كتبت lang="en" على صفحة عربية، أو نسيت lang، فقد يقرأ النص العربي بمحرك وقواعد لغة أخرى.
  • التشكيل يؤثر على النطق: جدول اللغات في وثائق eSpeak NG يذكر أن دعم العربية مخصص للنص المشكول بالكامل Fully Diacritized، والنص العربي على المواقع غير مشكول غالباً، فالمحرك يخمن الحركات، وقد يخطئ في كلمات تتغير قراءتها بالحركات كما شرحنا في الحركات والالتباس. والحل العملي أن تجرب الصفحات المهمة بقارئ الشاشة والمحرك الذي يستخدمه جمهورك، وأن تضيف الحركات في الكلمة الملتبسة إذا كان سوء قراءتها يغير المعنى.

وطريقة فحص يدوي سريع لصفحة واحدة، تأخذ عشر دقائق تقريباً:

  1. ضع الفأرة جانباً، وانتقل بمفتاح Tab وShift+Tab من أول الصفحة إلى آخرها، وتأكد أنك ترى التركيز دائماً وأن الترتيب منطقي.
  2. نفذ المهمة الأساسية كاملة بلوحة المفاتيح: تسجيل أو شراء أو حجز، بما فيها القوائم المنسدلة والنوافذ المنبثقة ومفتاح Escape لإغلاقها.
  3. كبر الصفحة إلى 200% ثم 400%، وتأكد أن المحتوى لا يختفي ولا يحتاج تمريراً أفقياً في عرض صغير.
  4. شغل NVDA أو VoiceOver، وتنقل بالعناوين ثم بالروابط ثم بحقول النموذج، واسأل: هل أفهم الصفحة من هذا وحده؟
  5. اكتب في النموذج خطأ متعمداً، واسمع هل تعلن رسالة الخطأ، وهل تعرف أي حقل فيه الخطأ.

مشاكل خاصة بالمواقع العربية

أغلب أمثلة سهولة الوصول مكتوبة لمواقع إنجليزية، والموقع العربي يضيف مشاكل لا تظهر هناك، وبعضها لا تراه أي أداة آلية.

لغة الصفحة واتجاهها في HTML وليس في CSS

اكتب اللغة والاتجاه على العنصر html نفسه: lang="ar" وdir="rtl". واللغة مطلوبة في المعيار 3.1.1 Language of Page من المستوى A، والفقرة التي بلغة أخرى داخل الصفحة تأخذ lang خاصاً بها بحسب المعيار 3.1.2. أما الاتجاه فتقول مقالة W3C عن الاتجاه في HTML بوضوح: لا تستخدم CSS لتحديد الاتجاه الأساسي للصفحة، والسبب أن الاتجاه جزء من معنى المحتوى، فيجب أن يبقى في الـ HTML ويعمل حتى لو لم يحمل ملف CSS. والخطأ الشائع هنا أن الصفحة تظهر صحيحة لأن المطور كتب direction: rtl في CSS، فيظن أن كل شيء سليم، وهذا ما فعلناه عمداً في صفحة التجربة.

خلط الاتجاهين Bidi

عندما يأتي نص إنجليزي أو رقم مع رموز داخل جملة عربية، يرتبه المتصفح بخوارزمية الاتجاه الثنائي Bidirectional Algorithm، وقد تكون النتيجة ترتيباً مقلوباً، مثل رقم الهاتف مع التحويلة في صفحة التجربة. والحل في مقالة W3C عن الاتجاه داخل السطر أن تعزل الجزء ذا الاتجاه المختلف بالعنصر bdi أو بخاصية dir، وأن تستخدم dir="auto" للنص الذي يكتبه المستخدمون ولا تعرف لغته مسبقاً، مثل الأسماء والتعليقات.

الأيقونات التي تنعكس والتي لا تنعكس

تشرح إرشادات Material Design عن الاتجاه الثنائي القاعدة بشكل عملي: ما يدل على اتجاه أو تسلسل في الزمن ينعكس، مثل سهم الرجوع وسهم التالي، فالرجوع في الواجهة العربية يشير إلى اليمين. وما يمثل شيئاً من الواقع لا ينعكس، مثل أزرار تشغيل الوسائط وشريط تقدمها لأنها تمثل اتجاه الشريط وليس الزمن، والساعة التي تدور مع عقاربها دائماً، وأيقونة البحث التي تمثل عدسة تمسكها اليد اليمنى. والأرقام نفسها لا تنعكس.

الصورة التالية تجمع هذه القواعد، ومعها مشكلة رقم الهاتف من التجربة قبل العزل وبعده:

لوحتان: في الأولى سهم الرجوع وسهم التالي بنسختين، الحمراء خاطئة والخضراء صحيحة للواجهة العربية، وفي الثانية زر التشغيل والساعة وأيقونة البحث وهي لا تنعكس، وتحتهما جملة رقم الهاتف مع التحويلة قبل العزل بترتيب مختلط وبعد العزل بالعنصر bdi بترتيب صحيح
سهم الرجوع غير المعكوس لا تكتشفه أي أداة آلية، فهو رسم صحيح تقنياً واتجاهه خاطئ للقارئ العربي

ومن الأمور التي تستحق الانتباه أيضاً حجم الخط وارتفاع السطر، فالحروف العربية تحتاج مساحة رأسية للنقاط والحركات، ومعيار 1.4.12 Text Spacing يطلب ألا يضيع محتوى إذا رفع المستخدم ارتفاع السطر إلى 1.5 من حجم الخط وزاد المسافات بين الفقرات والحروف والكلمات، فلا تقم بتثبيت ارتفاع الصناديق التي فيها نص عربي، والسبب أنها سوف تقص الأسطر عند أول تكبير.

تجربة عملية: صفحة حجز عربية فيها 14 مشكلة

سوف نبني صفحة حجز موعد لعيادة بالعربية، ونضع فيها 14 مشكلة مقصودة نعرفها مسبقاً، ثم نفحصها بالأدوات الثلاث لنرى ماذا تجد كل أداة وماذا يفوتها، ثم نصلح الصفحة ونفحص مرة أخرى، وفي النهاية نضيف اختبار Playwright يمنع عودة المشاكل. وكل شيء يعمل داخل Container واحد من Docker Image الرسمية لـ Playwright، فلا تحتاج تثبيت Node.js أو متصفح على جهازك، ولا ننشر أي منفذ على الجهاز لأن الفحص كله داخل الـ Container.

ملفات التجربة

الملفات كلها في مجلد واحد بالشكل التالي:

lab/
├── package.json            إصدارات الحزم مثبتة
├── package-lock.json
├── scan-axe.mjs            axe-core عبر Playwright
├── scan-all.sh             axe ثم pa11y بمحركيه ثم Lighthouse
├── contrast-check.mjs      كيف تعامل axe مع تباين النص العربي
├── aria-tree.mjs           شجرة الوصول كما يراها قارئ الشاشة
├── pa11y.config.json
├── playwright.config.ts
├── tests/a11y.spec.ts      اختبار CI
└── site/
    ├── broken/             الصفحة بمشاكلها
    ├── fixed/              الصفحة بعد الإصلاح
    └── contrast-ar/        صفحة صغيرة لاختبار التباين

والإصدارات مثبتة في package.json كما يلي:

{
  "name": "lab-a11y",
  "private": true,
  "type": "module",
  "devDependencies": {
    "@axe-core/playwright": "4.13.0",
    "@playwright/test": "1.64.0",
    "http-server": "14.1.1",
    "lighthouse": "13.5.0",
    "pa11y": "10.0.0"
  }
}

أما الصفحة نفسها فهذا ملفها كاملاً، ولاحظ أنها تظهر صحيحة في المتصفح، فالاتجاه مكتوب في CSS:

<!doctype html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>حجز موعد</title>
<style>
  body { direction: rtl; font-family: "IBM Plex Sans Arabic", Tahoma, sans-serif; margin: 0; background: #fff; color: #222; }
  header { display: flex; align-items: center; gap: 12px; padding: 12px 24px; border-bottom: 1px solid #eee; }
  header img { width: 40px; height: 40px; }
  .back { font-size: 22px; text-decoration: none; color: #222; }
  main { max-width: 520px; margin: 24px auto; padding: 0 16px; }
  h4 { margin-top: 28px; }
  .hint { color: #b5b5b5; font-size: 14px; }
  input, select { display: block; width: 100%; padding: 10px; margin: 8px 0; font: inherit; border: 1px solid #ccc; border-radius: 6px; }
  *:focus { outline: none; }
  .search { width: 32px; height: 32px; border: 0; background: #f2f2f2; border-radius: 50%; }
  .submit { display: inline-block; padding: 12px 28px; background: #2b7a4b; color: #fff; border-radius: 6px; cursor: pointer; margin-top: 12px; }
</style>
</head>
<body>
<header>
  <a class="back" href="#"><svg width="22" height="22" viewBox="0 0 24 24"><path d="M20 12 H5 M11 5 L4 12 L11 19" fill="none" stroke="#222" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"/></svg></a>
  <img src="logo.svg">
  <span>عيادة الشفاء</span>
  <button class="search"><svg width="16" height="16" viewBox="0 0 16 16"><circle cx="7" cy="7" r="5" fill="none" stroke="#333" stroke-width="2"/><path d="M11 11 L15 15" stroke="#333" stroke-width="2"/></svg></button>
</header>
<main>
  <h1>حجز موعد</h1>
  <p class="hint">املأ البيانات التالية وسوف نتواصل معك لتأكيد الموعد.</p>

  <h4>بيانات المريض</h4>
  <input type="text" placeholder="الاسم الكامل">
  <input type="tel" placeholder="رقم الجوال">
  <p>للاستفسار اتصل على 920012345 ext. 12 من الأحد إلى الخميس.</p>

  <h4>الموعد</h4>
  <select>
    <option>اختر العيادة</option>
    <option>الأسنان</option>
    <option>الجلدية</option>
  </select>
  <input type="date">

  <p>لقراءة سياسة الخصوصية <a href="#">اضغط هنا</a>.</p>
  <div class="submit" onclick="alert('تم إرسال الطلب')">إرسال الطلب</div>
</main>
</body>
</html>

والصورة التالية تبين شكل الصفحة كما يراها مستخدم الفأرة، ولا يبدو فيها شيء خاطئ تقريباً، إلا التلميح الرمادي الباهت وسهم الرجوع الذي يشير إلى اليسار:

صفحة حجز موعد بالعربية: في الأعلى سهم يشير إلى اليسار وشعار وزر بحث دائري، ثم عنوان حجز موعد وتلميح رمادي باهت، ثم حقلان بنص داخلي بدل التسمية، وجملة رقم الهاتف بترتيب مختلط، وقائمة العيادات وحقل التاريخ، ورابط اضغط هنا، وزر أخضر لإرسال الطلب
الصفحة قبل الإصلاح: تعمل بالفأرة، ومعظم مشاكلها لا ترى بالعين

والمشاكل الأربع عشرة المقصودة هي: لا توجد لغة lang للصفحة، والشعار بلا نص بديل، ورابط الرجوع أيقونة بلا اسم، وزر البحث أيقونة بلا اسم، والقائمة بلا تسمية، وحقل التاريخ بلا تسمية، وقفزة في العناوين من h1 إلى h4، وحقلا الاسم والجوال بتلميح Placeholder بدل التسمية، والتلميح الرمادي بتباين ضعيف، وإطار التركيز محذوف بالسطر *:focus { outline: none; }، وزر الإرسال عنصر div لا يصله Tab، وسهم الرجوع غير معكوس، والرابط «اضغط هنا»، ورقم الهاتف مع التحويلة بلا عزل.

تشغيل الـ Container والفحص

الآن سوف نقوم بتشغيل Container من Docker Image الخاصة بـ Playwright، ونثبت الحزم داخله، ثم نشغل سيرفر الملفات الثابتة على المنفذ 8080 داخل الـ Container فقط:

cd lab
docker run -d --name lab-qa3-a11y-run --ipc=host -v "$PWD:/src:ro" \
  mcr.microsoft.com/playwright:v1.64.0-noble sleep infinity
docker exec lab-qa3-a11y-run sh -c 'cp -r /src /work && cd /work && PUPPETEER_SKIP_DOWNLOAD=1 npm ci'
docker exec -d -w /work lab-qa3-a11y-run npx http-server site -p 8080 -a 127.0.0.1 -s

في الإعداد أعلاه لاحظ التالي:

  • المجلد مركب للقراءة فقط :ro، وننسخه إلى /work داخل الـ Container، فلا يكتب node_modules على جهازك.
  • PUPPETEER_SKIP_DOWNLOAD=1 يمنع pa11y من تنزيل متصفح خاص به، لأننا نوجهه إلى Chromium الموجود أصلاً في الـ Docker Image عبر ملف الإعداد.
  • --ipc=host توصي به وثائق Playwright لتشغيل Chromium داخل Docker دون أن تنفد الذاكرة المشتركة.

وملف إعداد pa11y يحدد مسار المتصفح والمعيار:

{
  "chromeLaunchConfig": {
    "executablePath": "/ms-playwright/chromium-1248/chrome-linux64/chrome",
    "args": ["--no-sandbox"]
  },
  "standard": "WCAG2AA"
}

وسكربت axe يفتح الصفحة في Chromium ويطبق قواعد WCAG من A إلى 2.2 AA، ومعها قواعد أفضل الممارسات Best Practices، ويطبع كل مخالفة بأثرها Impact وعدد العناصر:

// Runs axe-core (through @axe-core/playwright) on one URL and prints each violation with its impact and node count.
//   node scan-axe.mjs http://127.0.0.1:8080/broken/
import { chromium } from '@playwright/test';
import { AxeBuilder } from '@axe-core/playwright';

const url = process.argv[2];
const browser = await chromium.launch();
const context = await browser.newContext();
const page = await context.newPage();
await page.goto(url);
const r = await new AxeBuilder({ page })
  .withTags(['wcag2a', 'wcag2aa', 'wcag21a', 'wcag21aa', 'wcag22aa', 'best-practice'])
  .analyze();
console.log(`axe-core ${r.testEngine.version} on ${url}`);
for (const v of r.violations) console.log(`${v.impact.padEnd(9)} ${v.id.padEnd(26)} nodes=${v.nodes.length}  ${v.help}`);
console.log(`violations: ${r.violations.length} rules, ${r.violations.reduce((n, v) => n + v.nodes.length, 0)} nodes | passes: ${r.passes.length} | incomplete (needs review): ${r.incomplete.map(i => i.id).join(', ') || 'none'}`);
await browser.close();

أما scan-all.sh فيشغل الأدوات الثلاث على الصفحة نفسها:

#!/bin/sh
# Scans one page with axe-core, pa11y (two runners) and Lighthouse. Run inside the Playwright container:
#   sh scan-all.sh broken    |    sh scan-all.sh fixed
P=${1:-broken}
URL="http://127.0.0.1:8080/$P/"
export CHROME_PATH=/ms-playwright/chromium-1248/chrome-linux64/chrome

echo "=== axe-core"
node scan-axe.mjs "$URL"

for RUNNER in htmlcs axe; do
  echo "=== pa11y --runner $RUNNER"
  npx pa11y --config pa11y.config.json --runner "$RUNNER" --reporter json "$URL" | node -e '
const r = JSON.parse(require("fs").readFileSync(0, "utf8"));
for (const i of r) console.log(i.type.padEnd(6), i.code.split(".").slice(-2).join(".").padEnd(28), i.selector);
console.log(r.length + " issue(s)");'

done

echo "=== lighthouse (accessibility only)"
npx lighthouse "$URL" --only-categories=accessibility --output=json --output-path="/tmp/lh-$P.json" \
  --chrome-flags="--headless=new --no-sandbox" --quiet
node -e '
const r = require(process.argv[1]);
console.log("lighthouse", r.lighthouseVersion, "score", Math.round(r.categories.accessibility.score * 100));
for (const a of Object.values(r.audits)) if (a.score === 0) console.log("  failed:", a.id);
' "/tmp/lh-$P.json"

والآن نفحص الصفحة المكسورة:

docker exec -w /work lab-qa3-a11y-run sh scan-all.sh broken

والمخرج سوف يكون كما يلي:

=== axe-core
axe-core 4.13.0 on http://127.0.0.1:8080/broken/
critical  button-name                nodes=1  Buttons must have discernible text
moderate  heading-order              nodes=1  Heading levels should only increase by one
serious   html-has-lang              nodes=1  <html> element must have a lang attribute
critical  image-alt                  nodes=1  Images must have alternative text
critical  label                      nodes=1  Form elements must have labels
serious   link-name                  nodes=1  Links must have discernible text
critical  select-name                nodes=1  Select element must have an accessible name
violations: 7 rules, 7 nodes | passes: 24 | incomplete (needs review): none
=== pa11y --runner htmlcs
error  H57.2                        html
error  A.NoContent                  html > body > header > a
error  1_1_1.H37                    html > body > header > img
error  Button.Name                  html > body > header > button
error  G18.Fail                     html > body > main > p:nth-child(2)
error  InputText.Name               html > body > main > input:nth-child(4)
error  1_3_1.F68                    html > body > main > input:nth-child(4)
error  InputTel.Name                html > body > main > input:nth-child(5)
error  Select.Name                  html > body > main > select
error  1_3_1.F68                    html > body > main > select
error  InputDate.Name               html > body > main > input:nth-child(9)
11 issue(s)
=== pa11y --runner axe
error  button-name                  html > body > header > button
error  html-has-lang                html
error  image-alt                    html > body > header > img
error  label                        html > body > main > input:nth-child(9)
error  link-name                    html > body > header > a
error  select-name                  html > body > main > select
6 issue(s)
=== lighthouse (accessibility only)
lighthouse 13.5.0 score 47
  failed: button-name
  failed: heading-order
  failed: html-has-lang
  failed: image-alt
  failed: label
  failed: link-name
  failed: select-name

في المخرج أعلاه لاحظ التالي:

  • axe وجد 7 قواعد: منها heading-order بأثر moderate، وهي من قواعد أفضل الممارسات وليست من WCAG، لذلك لم تظهر عندما شغلنا axe عبر pa11y، لأن pa11y يطلب قواعد المعيار فقط.
  • axe وجد حقل التاريخ فقط بلا تسمية: لأنه يقبل التلميح Placeholder اسماً للحقل، فحقلا الاسم والجوال مقبولان عنده، بينما HTML_CodeSniffer اعتبرهما بلا اسم. والصحيح هنا رأي HTMLCS عملياً، فالتلميح يختفي عند أول حرف تكتبه، والمعيار 3.3.2 يطلب تسمية أو تعليمات واضحة للحقل.
  • HTMLCS وجد التباين 2.05:1 في التلميح الرمادي p:nth-child(2)، وaxe وLighthouse لم يذكراه أصلاً، لا كمخالفة ولا كمراجعة، وسوف نفهم السبب بعد قليل.
  • HTMLCS لم يذكر قفزة العناوين بين أخطائه، فهي عنده من نوع التنبيه Notice وليست خطأ.
  • Lighthouse أعطى 47 من 100، والقواعد الفاشلة عنده هي نفسها قواعد axe السبع، لأنه يستخدم axe في الداخل.

الصورة التالية تلخص المشاكل الأربع عشرة، وأي أداة وجدت كل واحدة منها:

جدول بأربع عشرة مشكلة وثلاثة أعمدة للأدوات axe-core وpa11y HTMLCS وLighthouse، فيه ست مشاكل وجدتها الأدوات الثلاث، وقفزة العناوين وجدها axe وLighthouse، والتلميح والتباين وجدهما HTMLCS وحده، وخمس مشاكل لم تجدها أي أداة: إطار التركيز المحذوف وزر div وسهم الرجوع غير المعكوس ورابط اضغط هنا ورقم الهاتف المقلوب
9 مشاكل من 14 وجدتها أداة واحدة على الأقل، و5 لا يجدها إلا إنسان

لماذا لم يفحص axe تباين النص العربي؟

هذه نتيجة لم نكن نتوقعها، فالتلميح الرمادي واضح الضعف، وaxe يفحص التباين عادة. لذلك صنعنا صفحة صغيرة فيها أربع فقرات بالرمادي نفسه #b5b5b5 على خلفية بيضاء: جملة عربية طويلة، وجملة إنجليزية، وكلمتان عربيتان، وجملة عربية قصيرة، وكتبنا سكربتاً يطبع ماذا فعل axe مع كل فقرة، ويستدعي الدالة الداخلية في axe التي تقرر هل النص أيقونة من خط أيقونات Icon Ligature:

// Prints how axe-core treated each paragraph of the contrast page: violation, pass, or skipped (not checked at all),
// and whether axe's icon-ligature heuristic classified its text as an icon font ligature.
//   node contrast-check.mjs http://127.0.0.1:8080/contrast-ar/
import { chromium } from '@playwright/test';
import { AxeBuilder } from '@axe-core/playwright';
import { readFileSync } from 'node:fs';

const browser = await chromium.launch();
const context = await browser.newContext();
const page = await context.newPage();
await page.goto(process.argv[2]);
const r = await new AxeBuilder({ page }).withRules(['color-contrast']).analyze();
const seen = {};
for (const group of ['violations', 'passes', 'incomplete'])
  for (const rule of r[group]) for (const n of rule.nodes) seen[n.target[0]] = `${group} (ratio ${n.any[0]?.data?.contrastRatio})`;

await page.addScriptTag({ content: readFileSync('node_modules/axe-core/axe.min.js', 'utf8') });
const lig = await page.evaluate(() => {
  axe.setup(document);
  return [...document.querySelectorAll('p')].map(p => [p.id, axe.commons.text.isIconLigature(axe.utils.getNodeFromTree(p.firstChild)), p.textContent]);
});
for (const [id, isLig, text] of lig) console.log(`#${id}  ${(seen['#' + id] ?? 'skipped').padEnd(26)} iconLigature=${isLig}  ${text}`);
await browser.close();

والمخرج سوف يكون كما يلي:

#a  skipped                    iconLigature=true  املأ البيانات التالية وسوف نتواصل معك لتأكيد الموعد.
#b  violations (ratio 2.05)    iconLigature=false  Fill in the following details and we will contact you.
#c  violations (ratio 2.05)    iconLigature=false  حجز موعد
#d  skipped                    iconLigature=true  املأ البيانات

والسبب واضح في المخرج: axe يستبعد من فحص التباين أي نص يظن أنه أيقونة مرسومة بخط أيقونات، مثل خطوط Material Icons التي تحول كلمة إلى رسم، ويكتشف ذلك بمقارنة شكل النص المرسوم بشكل حروفه. والحروف العربية تتصل وتتغير أشكالها حسب موقعها في الكلمة، فيبدو للدالة أن النص العربي أيقونة، فتتخطى الفقرتين #a و#d دون أي رسالة، بينما فحصت الجملة الإنجليزية وكلمتي «حجز موعد». وقد وجدنا السلوك نفسه مع axe-core 4.14.0 وهو أحدث إصدار وقت الكتابة، ومع Chrome على Windows خارج الـ Container، ولم نجد بلاغاً عنه في مستودع axe-core، لذلك تعامل معه كقيد معروف لديك وليس كنتيجة نهائية. أما pa11y بمحرك HTMLCS فوجد الفقرات الأربع بنسبة 2.05:1، وLighthouse أعطى الصفحة 83 وذكر الفقرتين #b و#c فقط.

⚠️
إذا كان موقعك عربياً فلا تعتمد على axe أو Lighthouse وحدهما في التباين، وأضف pa11y بمحرك HTMLCS أو أداة حساب تباين يدوية على ألوان النصوص الأساسية، والسبب أن غياب المخالفة هنا لا يعني أن النص نجح، وإنما أنه لم يفحص أصلاً.

ما الذي يسمعه مستخدم قارئ الشاشة؟

قبل الإصلاح لنرى الصفحة كما تصل إلى قارئ الشاشة، أي شجرة الوصول Accessibility Tree، عبر الدالة ariaSnapshot في Playwright:

// Prints the accessibility tree (what a screen reader gets) of the header and the form fields, as Playwright sees it.
//   node aria-tree.mjs http://127.0.0.1:8080/broken/
import { chromium } from '@playwright/test';
const browser = await chromium.launch();
const page = await (await browser.newContext()).newPage();
await page.goto(process.argv[2]);
console.log(await page.locator('header').ariaSnapshot());
console.log(await page.locator('main').ariaSnapshot());
await browser.close();

والمخرج للصفحة المكسورة سوف يكون كما يلي:

- banner:
  - link:
    - /url: "#"
    - img
  - img
  - text: عيادة الشفاء
  - button:
    - img
- main:
  - heading "حجز موعد" [level=1]
  - paragraph: املأ البيانات التالية وسوف نتواصل معك لتأكيد الموعد.
  - heading "بيانات المريض" [level=4]
  - textbox "الاسم الكامل"
  - textbox "رقم الجوال"
  - paragraph: للاستفسار اتصل على 920012345 ext. 12 من الأحد إلى الخميس.
  - heading "الموعد" [level=4]
  - combobox:
    - option "اختر العيادة" [selected]
    - option "الأسنان"
    - option "الجلدية"
  - textbox
  - paragraph:
    - text: لقراءة سياسة الخصوصية
    - link "اضغط هنا":
      - /url: "#"
    - text: .
  - text: إرسال الطلب

وهنا ترى المشاكل كما يسمعها المستخدم: رابط بلا اسم، وصورة بلا اسم، وزر بلا اسم، وقائمة وحقل تاريخ بلا اسم، وأهم من ذلك السطر الأخير، فـ «إرسال الطلب» يظهر كنص عادي text وليس زراً، أي أن مستخدم قارئ الشاشة لن يعرف أن هناك زراً يضغطه، ولن يصل إليه بمفتاح Tab أصلاً. ولاحظ أن حقلي الاسم والجوال أخذا اسماً من التلميح، وهذا ما جعل axe يقبلهما.

إصلاح الصفحة

الإصلاحات كلها في HTML وCSS، ولا يوجد أي سطر JavaScript إضافي للمكتبات أو ARIA معقد، وأهمها:

<html lang="ar" dir="rtl">
<style>
  /* directional icons follow the reading direction */
  [dir="rtl"] .back .icon { transform: scaleX(-1); }
  .hint { color: #595959; font-size: 15px; }
  :focus-visible { outline: 3px solid #1a5fb4; outline-offset: 2px; }
</style>

<a class="back" href="#"><svg class="icon" aria-hidden="true" focusable="false">…</svg><span class="visually-hidden">رجوع</span></a>
<img src="logo.svg" alt="">
<button class="search" aria-label="بحث"><svg aria-hidden="true" focusable="false">…</svg></button>

<form onsubmit="event.preventDefault(); document.getElementById('status').textContent = 'تم إرسال الطلب';">
  <h2>بيانات المريض</h2>
  <label for="name">الاسم الكامل</label>
  <input id="name" type="text" autocomplete="name" required>
  <label for="phone">رقم الجوال</label>
  <input id="phone" type="tel" autocomplete="tel" dir="ltr" required>
  <p>للاستفسار اتصل على <bdi dir="ltr">920012345 ext. 12</bdi> من الأحد إلى الخميس.</p>

  <h2>الموعد</h2>
  <label for="clinic">العيادة</label>
  <select id="clinic" required>…</select>
  <label for="date">التاريخ</label>
  <input id="date" type="date" required>

  <p><a href="#">سياسة الخصوصية وطريقة استخدام بياناتك</a></p>
  <button class="submit" type="submit">إرسال الطلب</button>
  <p id="status" role="status"></p>
</form>

في الإعداد أعلاه لاحظ التالي:

  • lang="ar" dir="rtl" على html، وحذفنا direction من CSS.
  • الشعار صار alt="" لأن اسم العيادة مكتوب بجانبه، فالنص البديل الفارغ يخبر قارئ الشاشة أن يتجاهل الصورة بدل أن يقرأ الاسم مرتين، وهذا قرار لا تستطيع الأداة أن تتخذه عنك.
  • الأيقونات صارت aria-hidden="true"، والاسم جاء من نص مخفي بصرياً «رجوع» ومن aria-label="بحث"، وسهم الرجوع معكوس بالسطر [dir="rtl"] .back .icon { transform: scaleX(-1); } فقط في الواجهة العربية، أما أيقونة البحث فبقيت كما هي.
  • كل حقل له label مرتبط بـ for، وautocomplete للاسم والجوال بحسب المعيار 1.3.5، وحقل الجوال dir="ltr" لأن الأرقام تكتب من اليسار.
  • العناوين h2 بدل h4، والتلميح بلون #595959 بدل #b5b5b5، وإطار تركيز واضح عبر :focus-visible بدل حذفه.
  • زر حقيقي button type="submit" داخل form، ورسالة النجاح في عنصر role="status" ليعلنها قارئ الشاشة دون أن ينتقل إليها.
  • الرابط صار «سياسة الخصوصية وطريقة استخدام بياناتك»، ورقم الهاتف معزول بالعنصر bdi.

ثم نفحص الصفحة المصلحة بالسكربت نفسه:

docker exec -w /work lab-qa3-a11y-run sh scan-all.sh fixed

والمخرج سوف يكون كما يلي:

=== axe-core
axe-core 4.13.0 on http://127.0.0.1:8080/fixed/
violations: 0 rules, 0 nodes | passes: 41 | incomplete (needs review): none
=== pa11y --runner htmlcs
0 issue(s)
=== pa11y --runner axe
0 issue(s)
=== lighthouse (accessibility only)
lighthouse 13.5.0 score 100

صفر مخالفات في الأدوات الثلاث، وLighthouse أعطى 100. وشجرة الوصول بعد الإصلاح أصبحت كما يلي، ولاحظ الزر الحقيقي في آخرها ومنطقة الحالة status:

- banner:
  - link "رجوع":
    - /url: "#"
  - text: عيادة الشفاء
  - button "بحث"
- main:
  - heading "حجز موعد" [level=1]
  - paragraph: املأ البيانات التالية وسوف نتواصل معك لتأكيد الموعد.
  - heading "بيانات المريض" [level=2]
  - text: الاسم الكامل
  - textbox "الاسم الكامل"
  - text: رقم الجوال
  - textbox "رقم الجوال"
  - paragraph: للاستفسار اتصل على 920012345 ext. 12 من الأحد إلى الخميس.
  - heading "الموعد" [level=2]
  - text: العيادة
  - combobox "العيادة":
    - option "اختر العيادة" [selected]
    - option "الأسنان"
    - option "الجلدية"
  - text: التاريخ
  - textbox "التاريخ"
  - paragraph:
    - link "سياسة الخصوصية وطريقة استخدام بياناتك":
      - /url: "#"
  - button "إرسال الطلب"
  - status

والصورة التالية تبين شكل الصفحة بعد الإصلاح، والفرق المرئي صغير: تسميات فوق الحقول، وتلميح أوضح، وسهم يشير إلى اليمين، ورقم هاتف بترتيب صحيح:

صفحة الحجز بعد الإصلاح: سهم الرجوع يشير إلى اليمين، والتلميح بلون أغمق، ولكل حقل تسمية ظاهرة فوقه، ورقم الهاتف مع التحويلة بترتيب صحيح، والرابط بنص يصف هدفه
الصفحة بعد الإصلاح: أغلب التغيير في الكود وليس في الشكل

نكسرها مرة أخرى: لغة خاطئة تنجح في كل الفحوص

الآن سوف نغير سطراً واحداً في الصفحة المصلحة، فنجعل اللغة lang="en" والمحتوى كله عربي، وهذا يحدث فعلاً عندما يبدأ المطور من قالب إنجليزي وينسى السطر الأول:

docker exec -w /work lab-qa3-a11y-run sh -c '
  mkdir -p site/lang-en && cp site/fixed/logo.svg site/lang-en/ &&
  sed "s/lang=\"ar\"/lang=\"en\"/" site/fixed/index.html > site/lang-en/index.html &&
  node scan-axe.mjs http://127.0.0.1:8080/lang-en/'

والمخرج سوف يكون كما يلي:

axe-core 4.13.0 on http://127.0.0.1:8080/lang-en/
violations: 0 rules, 0 nodes | passes: 41 | incomplete (needs review): none

صفر مخالفات، والسبب أن القاعدة html-has-lang تتحقق أن اللغة موجودة، والقاعدة html-lang-valid تتحقق أن en رمز لغة صحيح، ولا توجد قاعدة تقارن اللغة المكتوبة بلغة المحتوى. ولكن NVDA سوف يقرأ هذه الصفحة بمحرك اللغة الإنجليزية إذا كان التبديل التلقائي مفعلاً، وهذا خطأ يجده من يستمع للصفحة في دقيقة، ولا تجده الأداة أبداً. لذلك أضفنا في اختبار CI التالي تحققاً صريحاً من قيمة اللغة.

اختبار Playwright مع axe في خط CI

الفحص من سطر الأوامر مفيد مرة، ولكن ما يمنع عودة المشاكل هو اختبار يعمل في كل طلب دمج. والاختبار التالي فيه ثلاث حالات: لا مخالفات WCAG من A إلى 2.2 AA، واللغة والاتجاه عربيان، والنموذج يرسل بلوحة المفاتيح عبر زر حقيقي يصله Tab:

// Fails the build when axe-core finds WCAG A/AA violations on the booking page.
import { test, expect } from '@playwright/test';
import AxeBuilder from '@axe-core/playwright';

const PAGE = process.env.PAGE_PATH ?? '/fixed/';

test('booking page has no WCAG A/AA violations', async ({ page }) => {
  await page.goto(PAGE);
  const results = await new AxeBuilder({ page })
    .withTags(['wcag2a', 'wcag2aa', 'wcag21a', 'wcag21aa', 'wcag22aa'])
    .analyze();
  expect(results.violations.map(v => `${v.id} (${v.nodes.length})`)).toEqual([]);
});

test('page language and direction are Arabic RTL', async ({ page }) => {
  await page.goto(PAGE);
  await expect(page.locator('html')).toHaveAttribute('lang', 'ar');
  await expect(page.locator('html')).toHaveAttribute('dir', 'rtl');
});

test('the form can be submitted with the keyboard only', async ({ page }) => {
  await page.goto(PAGE);
  await page.getByLabel('الاسم الكامل').fill('سارة أحمد');
  await page.getByLabel('رقم الجوال').fill('0500000000');
  await page.getByLabel('العيادة').selectOption('الأسنان');
  await page.getByLabel('التاريخ').fill('2026-11-01');
  // from the privacy link, one Tab must reach the submit button
  await page.getByRole('link', { name: 'سياسة الخصوصية وطريقة استخدام بياناتك' }).focus();
  await page.keyboard.press('Tab');
  await expect(page.getByRole('button', { name: 'إرسال الطلب' })).toBeFocused();
  await page.keyboard.press('Enter');
  await expect(page.getByRole('status')).toHaveText('تم إرسال الطلب');
});

وملف الإعداد يشغل سيرفر الملفات تلقائياً قبل الاختبارات:

import { defineConfig } from '@playwright/test';

export default defineConfig({
  testDir: './tests',
  reporter: 'list',
  use: { baseURL: 'http://127.0.0.1:8080' },
  webServer: {
    command: 'npx http-server site -p 8080 -a 127.0.0.1 -s',
    url: 'http://127.0.0.1:8080/fixed/',
    reuseExistingServer: true,
  },
});

في الإعداد أعلاه لاحظ التالي:

  • الاختبار يطلب الوسوم Tags wcag2a وwcag2aa وwcag21a وwcag21aa وwcag22aa، فلا تدخل قواعد أفضل الممارسات فيه، وهذا مقصود حتى لا يفشل الدمج على ملاحظة غير ملزمة، ويمكنك إضافتها إذا اتفق الفريق عليها.
  • الحالة الثالثة تستخدم getByLabel وgetByRole، أي أنها تجد العناصر كما يجدها قارئ الشاشة، فإذا فقد الحقل تسميته فشل الاختبار حتى لو بقي التلميح.
  • التحقق من التركيز toBeFocused بعد ضغط Tab هو ما يكشف زر div الذي لا يراه axe.

نشغل الاختبار على الصفحة المصلحة:

docker exec -w /work lab-qa3-a11y-run npx playwright test

والمخرج سوف يكون كما يلي:

Running 3 tests using 1 worker

  ✓  1 tests/a11y.spec.ts:7:1 › booking page has no WCAG A/AA violations (1.1s)
  ✓  2 tests/a11y.spec.ts:15:1 › page language and direction are Arabic RTL (181ms)
  ✓  3 tests/a11y.spec.ts:21:1 › the form can be submitted with the keyboard only (266ms)

  3 passed (2.8s)

ثم على الصفحة المكسورة، لنتأكد أن الاختبار يفشل فعلاً، وقد اختصرنا من المخرج تفاصيل الحالة الأولى:

docker exec -w /work -e PAGE_PATH=/broken/ lab-qa3-a11y-run npx playwright test --timeout=8000
Running 3 tests using 1 worker

  ✘  1 tests/a11y.spec.ts:7:1 › booking page has no WCAG A/AA violations (2.2s)
  ✘  2 tests/a11y.spec.ts:15:1 › page language and direction are Arabic RTL (5.4s)
  ✘  3 tests/a11y.spec.ts:21:1 › the form can be submitted with the keyboard only (8.1s)

  1) booking page has no WCAG A/AA violations
    + Array [
    +   "button-name (1)",
    +   "html-has-lang (1)",
    +   "image-alt (1)",
    +   "label (1)",
    +   "link-name (1)",
    +   "select-name (1)",
    + ]

  2) page language and direction are Arabic RTL
    Error: expect(locator).toHaveAttribute(expected) failed
    Locator:  locator('html')
    Expected: "ar"
    Received: ""

  3) the form can be submitted with the keyboard only
    Error: locator.fill: Test timeout of 8000ms exceeded.
    Call log:
      - waiting for getByLabel('الاسم الكامل')

  3 failed

وهذا ملف خط CI لـ GitHub Actions يشغل الاختبار نفسه داخل Docker Image الخاصة بـ Playwright، ويعمل الشكل نفسه في Gitea Actions وForgejo Actions مع Runner يدعم Docker. ولم نشغل هذا الملف على خدمة CI مستضافة في هذه التجربة، وإنما شغلنا الأمر نفسه npx playwright test داخل الـ Docker Image نفسها كما رأيت أعلاه:

# .github/workflows/a11y.yml (also works in Gitea/Forgejo Actions with a Docker runner)
name: accessibility
on:
  pull_request:
  push:
    branches: [main]

jobs:
  axe:
    runs-on: ubuntu-latest
    container:
      image: mcr.microsoft.com/playwright:v1.64.0-noble
    steps:
      - uses: actions/checkout@v4
      - run: npm ci
      - run: npx playwright test tests/a11y.spec.ts
      - uses: actions/upload-artifact@v4
        if: failure()
        with:
          name: playwright-report
          path: test-results/

إزالة التجربة

نحذف الـ Container بالاسم، ولم ننشئ أي Volume أو شبكة، وDocker Image الخاصة بـ Playwright يمكنك حذفها إذا لم تكن تستخدمها في مشاريع أخرى:

docker rm -f lab-qa3-a11y-run
# optional, only if nothing else uses it:
# docker image rm mcr.microsoft.com/playwright:v1.64.0-noble

أين يعمل كل فحص؟

بعد التجربة يصبح التقسيم واضحاً: الفحص الآلي رخيص وسريع، فيعمل في كل طلب دمج ويمنع المشاكل المعروفة من العودة، والمراجعة اليدوية أبطأ، فتعمل قبل الإصدار على الصفحات التي تغيرت وعلى المسارات المهمة. الصورة التالية تبين ذلك:

ثلاث مراحل من اليمين إلى اليسار: المطور يفتح طلب دمج، ثم خط CI يشغل Playwright مع axe ويمنع الدمج عند أي مخالفة، ثم مراجعة يدوية قبل الإصدار بلوحة المفاتيح وقارئ الشاشة NVDA بالعربية وفحص الأسهم والأرقام ومعنى النص البديل والروابط
الأداة تحرس ما تعرفه، والإنسان يجد ما لا تعرفه الأداة

وإذا كان خط CI عندك يعمل على فرع رئيسي واحد مع طلبات دمج قصيرة، فاختبار سهولة الوصول يأخذ مكانه مع اختبارات E2E نفسها، وقد شرحنا ترتيب الاختبارات في خط CI في فقرة أين يعمل كل اختبار في خط CI.

أخطاء شائعة

  • اعتبار درجة Lighthouse 100 شهادة توافق: الدرجة تقيس القواعد الآلية فقط، والصفحة المكسورة في التجربة كانت ستأخذ 100 لو أصلحنا القواعد السبع وتركنا المشاكل الخمس التي لا تراها الأداة.
  • الاتجاه في CSS بدل HTML: الصفحة تبدو صحيحة، ولكن lang وdir غائبان عن قارئ الشاشة وعن محركات البحث.
  • التلميح Placeholder بدل التسمية: يختفي عند الكتابة، ولونه الافتراضي باهت غالباً، وبعض الأدوات تقبله فلا تنبهك.
  • حذف إطار التركيز لأنه «قبيح»: استخدم :focus-visible ليظهر الإطار لمستخدم لوحة المفاتيح فقط بدل حذفه للجميع.
  • div أو span مكان الزر: كل زر يحتاج بعدها tabindex ومعالجة Enter وSpace ودوراً ARIA، والزر الحقيقي يعطيك ذلك مجاناً.
  • إضافة ARIA على كل شيء: aria-label على عنصر له نص ظاهر قد يجعل قارئ الشاشة يقرأ اسماً مختلفاً عما يراه المستخدم، فاستخدم العناصر الأصلية أولاً، وARIA عند الحاجة فقط.
  • عكس كل الأيقونات أو عدم عكس أي منها: الأسهم تنعكس، والتشغيل والساعة والبحث لا تنعكس.
  • الثقة في غياب مخالفات التباين على نص عربي: كما رأيت، قد لا يكون النص قد فحص أصلاً.

قائمة تحقق قبل نشر صفحة عربية

  • lang="ar" وdir="rtl" على html، وlang مختلف لأي فقرة بلغة أخرى.
  • كل صورة لها alt يصف وظيفتها، أو alt="" إذا كانت زخرفة.
  • كل حقل له label ظاهر، وautocomplete للاسم والجوال والبريد.
  • كل زر وكل رابط له اسم يفهم وحده، والأيقونات بلا نص لها aria-label أو نص مخفي بصرياً.
  • التباين 4.5:1 للنص العادي و3:1 للنص الكبير، ومفحوص بأداة لا تتخطى النص العربي.
  • الصفحة تعمل كاملة بلوحة المفاتيح، والتركيز ظاهر دائماً وغير مغطى بشريط ثابت.
  • الأهداف التي يضغط عليها 24 في 24 بكسل CSS على الأقل.
  • الأسهم معكوسة في الواجهة العربية، والأرقام والإنجليزية داخل الجمل معزولة بالعنصر bdi أو dir.
  • اختبار Playwright مع axe في خط CI يمنع الدمج، ومعه تحقق من قيمة اللغة ومن إرسال النموذج بلوحة المفاتيح.
  • مراجعة بقارئ الشاشة بالعربية للمسارات المهمة قبل كل إصدار كبير.

الخلاصة

وصلنا لنهاية الموضوع، وأهم ما فيه:

  • سهولة الوصول تخدم 16% من الناس بإعاقة كبيرة بحسب منظمة الصحة العالمية، وتخدم معهم كبار السن ومن يستخدم لوحة المفاتيح ومن يقرأ في الشمس، وتجعل اختباراتك أثبت.
  • WCAG 2.2 فيه 86 معياراً بأربعة مبادئ وثلاثة مستويات، والهدف العملي هو AA، وهو أيضاً الحد الأدنى في دليل هيئة الحكومة الرقمية المبني على WCAG 2.1.
  • axe-core وLighthouse يشتركان في المحرك، وpa11y بمحرك HTMLCS رأي ثان حقيقي، وفي التجربة وجدت الأدوات معاً 9 مشاكل من 14.
  • axe-core لم يفحص تباين الجمل العربية في تجربتنا لأنه ظنها أيقونات خط، فلا تعتمد عليه وحده في التباين لموقع عربي.
  • لغة خاطئة lang="en" تنجح في كل الفحوص الآلية وتفسد النطق، لذلك تحقق من قيمتها في الاختبار.
  • الأسهم تنعكس في الواجهة العربية، والتشغيل والساعة والبحث والأرقام لا تنعكس، والجمل المختلطة تحتاج عزلاً بالعنصر bdi.
  • اختبار Playwright مع axe في خط CI يحرس ما تعرفه الأداة، والمراجعة اليدوية بلوحة المفاتيح وقارئ الشاشة تجد الباقي.

وفي المقالات القادمة من هذه المرحلة بإذن الله سوف نتحدث عن اختبار تطبيقات الجوال، وهناك سوف ترى أن TalkBack وVoiceOver يعتمدان على الأسماء والأدوار نفسها التي رأيتها هنا.

دليلأنواع الاختبارات للمبتدئين: من Unit إلى E2E مع Playwrightما الذي يكشفه كل نوع من الاختبارات وما الذي يفوته، وكم اختباراً تكتب من كل نوع، وأين يعمل كل منها في خط CI.دليلاختبارات يكتبها الذكاء الاصطناعي: متى تثق بها؟أين يساعدك الذكاء الاصطناعي في كتابة الاختبارات وأين يخدعك، ولماذا يبقى قرار ما هو الصحيح بيد الإنسان.دليلاختبار الطفرات Mutation Testing: هل تكشف اختباراتك الأخطاء؟لماذا تمر الأخطاء من اختبارات تغطيتها 100%، وكيف يقيس اختبار الطفرات قوتها فعلاً ويعمل في خط CI دون أن يبطئه.دليلاختبار تطبيقات الجوال مع Appium وMaestroكيف تختار أداة اختبار تطبيقات الجوال، وأين تشغل الاختبارات، ولماذا تنكسر بعض الاختبارات عندما تصبح الواجهة عربية.

سجل التحديثات

  • أكتوبر 2026: كتابة المقال، وتنفيذ التجربة على Docker Engine 29.8.0 داخل mcr.microsoft.com/playwright:v1.64.0-noble (Ubuntu 24.04.5، Node.js 24.21.0، Chrome for Testing 156.0.8078.4) مع @playwright/test 1.64.0 و@axe-core/playwright 4.13.0 (axe-core 4.13.0) وpa11y 10.0.0 (HTML_CodeSniffer 2.6.0) وLighthouse 13.5.0 وhttp-server 14.1.1، والتحقق من سلوك التباين مع axe-core 4.14.0 ومع Chrome 154 على Windows، ومراجعة دليل هيئة الحكومة الرقمية في 12 أكتوبر 2026.
  • أكتوبر 2026: إضافة رابط في المقدمة إلى مقال بناء الصفحات والمكونات سهلة الوصول.
نشرة عرب رووت | ArabRoot

معرفة تستحق مكاناً في بريدك.

مقالات مختارة وأدوات مفيدة وأفكار لمشروعك القادم، في رسالة واحدة كل أسبوع.

يمكنك إلغاء الاشتراك متى شئت. الخصوصية

تم استلام طلبك. افتح بريدك واضغط رابط التأكيد لإتمام الاشتراك.