تفتح صفحة حجز موعد بنيتها قبل أسبوع، فتملأ الحقول بالفأرة وتضغط الزر ويصل الطلب، وتنجح كل الاختبارات في خط 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 قابلة للاختبار، ولكل معيار مستوى من ثلاثة.
الصورة التالية تبين المبادئ الأربعة بمثال لكل واحد، والمستويات الثلاثة بعدد معاييرها:

وفي الإعداد أعلاه لاحظ التالي:
- المستوى 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 Appearance | AAA | إطار التركيز بحجم وتباين كافيين |
| 2.5.7 Dragging Movements | AA | كل عملية سحب Drag لها بديل بنقرة واحدة |
| 2.5.8 Target Size (Minimum) | AA | الهدف الذي يضغط عليه 24 في 24 بكسل CSS على الأقل، مع استثناءات |
| 3.2.6 Consistent Help | A | وسيلة المساعدة في المكان نفسه في كل الصفحات |
| 3.3.7 Redundant Entry | A | لا تطلب من المستخدم إدخال المعلومة نفسها مرتين في العملية نفسها |
| 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 مع Playwright | axe | MPL-2.0 | داخل اختبارات E2E في خط CI، فيفشل الدمج عند أي مخالفة |
| pa11y | HTML_CodeSniffer أو axe | LGPL-3.0 | فحص سريع لقائمة روابط من سطر الأوامر، ورأي ثان بمحرك مختلف |
| Lighthouse | axe مع أوزان | 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، والنص العربي على المواقع غير مشكول غالباً، فالمحرك يخمن الحركات، وقد يخطئ في كلمات تتغير قراءتها بالحركات كما شرحنا في الحركات والالتباس. والحل العملي أن تجرب الصفحات المهمة بقارئ الشاشة والمحرك الذي يستخدمه جمهورك، وأن تضيف الحركات في الكلمة الملتبسة إذا كان سوء قراءتها يغير المعنى.
وطريقة فحص يدوي سريع لصفحة واحدة، تأخذ عشر دقائق تقريباً:
- ضع الفأرة جانباً، وانتقل بمفتاح Tab وShift+Tab من أول الصفحة إلى آخرها، وتأكد أنك ترى التركيز دائماً وأن الترتيب منطقي.
- نفذ المهمة الأساسية كاملة بلوحة المفاتيح: تسجيل أو شراء أو حجز، بما فيها القوائم المنسدلة والنوافذ المنبثقة ومفتاح Escape لإغلاقها.
- كبر الصفحة إلى 200% ثم 400%، وتأكد أن المحتوى لا يختفي ولا يحتاج تمريراً أفقياً في عرض صغير.
- شغل NVDA أو VoiceOver، وتنقل بالعناوين ثم بالروابط ثم بحقول النموذج، واسأل: هل أفهم الصفحة من هذا وحده؟
- اكتب في النموذج خطأ متعمداً، واسمع هل تعلن رسالة الخطأ، وهل تعرف أي حقل فيه الخطأ.
مشاكل خاصة بالمواقع العربية
أغلب أمثلة سهولة الوصول مكتوبة لمواقع إنجليزية، والموقع العربي يضيف مشاكل لا تظهر هناك، وبعضها لا تراه أي أداة آلية.
لغة الصفحة واتجاهها في 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 عن الاتجاه الثنائي القاعدة بشكل عملي: ما يدل على اتجاه أو تسلسل في الزمن ينعكس، مثل سهم الرجوع وسهم التالي، فالرجوع في الواجهة العربية يشير إلى اليمين. وما يمثل شيئاً من الواقع لا ينعكس، مثل أزرار تشغيل الوسائط وشريط تقدمها لأنها تمثل اتجاه الشريط وليس الزمن، والساعة التي تدور مع عقاربها دائماً، وأيقونة البحث التي تمثل عدسة تمسكها اليد اليمنى. والأرقام نفسها لا تنعكس.
الصورة التالية تجمع هذه القواعد، ومعها مشكلة رقم الهاتف من التجربة قبل العزل وبعده:

ومن الأمور التي تستحق الانتباه أيضاً حجم الخط وارتفاع السطر، فالحروف العربية تحتاج مساحة رأسية للنقاط والحركات، ومعيار 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 تباين النص العربي؟
هذه نتيجة لم نكن نتوقعها، فالتلميح الرمادي واضح الضعف، و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 فقط.
ما الذي يسمعه مستخدم قارئ الشاشة؟
قبل الإصلاح لنرى الصفحة كما تصل إلى قارئ الشاشة، أي شجرة الوصول 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=8000Running 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 عندك يعمل على فرع رئيسي واحد مع طلبات دمج قصيرة، فاختبار سهولة الوصول يأخذ مكانه مع اختبارات 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: إضافة رابط في المقدمة إلى مقال بناء الصفحات والمكونات سهلة الوصول.