لنفرض أن فريقك يقترب من إصدار جديد، وحالات الاختبار Test Cases مكتوبة في جدول Excel مشترك، وكل مختبر يلون الصف الذي نفذه بالأخضر أو الأحمر، والأخطاء تكتب في Gitea بنسخ ولصق من الجدول، أما نتائج الاختبارات الآلية فهي في سجل خط CI ولا يراها أحد إلا المطور. وبعد الإصدار بأسبوع يسألك المدير سؤالاً بسيطاً: هل اختبرنا الدفع ببطاقة مدى على هذا الإصدار، ومن اختبره، وما الخطأ الذي ظهر؟ فتكتشف أن الإجابة موزعة على ثلاثة أماكن، وأن الجدول عدله شخص آخر بعد ذلك. وهذا بالضبط ما تحله أداة إدارة الاختبارات Test Management، وفي هذا الدليل سوف نستخدم Kiwi TCMS، وهي أداة مفتوحة المصدر تعمل على سيرفرك.
وقد شرحنا أنواع الاختبارات وما يكشفه كل نوع في أنواع الاختبارات للمبتدئين، أما هنا فالسؤال مختلف: كيف تنظم الاختبارات اليدوية والآلية ونتائجها والأخطاء في مكان واحد يمكن الرجوع إليه.
وسوف نناقش في هذا المقال ما يلي:
- مفاهيم Kiwi TCMS: المنتج والإصدار والبناء Build، وخطة الاختبار Test Plan وحالة الاختبار Test Case والتشغيل Test Run والتنفيذ Test Execution.
- التثبيت مع Docker Compose بإصدار مثبت، ولماذا لا توجد وسوم Tags بأرقام الإصدارات للنسخة المجانية، وما الذي يحدث إذا لم يستطع nginx قراءة مفتاح الشهادة.
- تسجيل نتائج الاختبار اليدوي، وربط الأخطاء بمشكلات Gitea Issues عبر كود تكامل صغير كتبناه لأن Gitea ليس في قائمة الأدوات المدعومة.
- إرسال نتائج pytest من خط CI عبر ملف junit.xml أو عبر إضافة pytest، مع خطأ حقيقي في قراءة التوقيت، ومشكلة تغيير اسم الاختبار.
- الصلاحيات Permissions والنسخ الاحتياطي Backup، ثم استعادة كاملة بعد حذف كل شيء.
لماذا لا يكفي جدول Excel؟
الطريقة البسيطة التي تبدأ بها كل الفرق هي الجدول المشترك، وهي مفيدة في الأسبوع الأول، لكنها تفشل لأسباب تتكرر في كل فريق: الجدول يحفظ آخر حالة فقط، فإذا نفذت الحالة نفسها على ثلاثة إصدارات فلن تعرف نتيجة الإصدار الأول بعد أن تكتب فوقها نتيجة الثالث، ولا يوجد رابط حقيقي بين الصف والخطأ في Gitea، ولا تعرف من غير خطوات الحالة ومتى، والأهم أن نتائج الاختبارات الآلية لا تصل إليه إلا إذا نسخها أحد بيده.
والحل الثاني الذي يخطر على البال هو أن تكتفي بتقارير خط CI، وهذا جيد للاختبارات الآلية، لكنه لا يغطي ما ينفذه الإنسان، مثل الاختبار الاستكشافي Exploratory Testing أو الدفع ببطاقة حقيقية في بيئة الاختبار، ولا يجيب عن سؤال «هل غطينا كل الحالات المطلوبة لهذا الإصدار؟». لذلك الحل الأنسب أداة تحفظ الحالات بإصداراتها، وتسجل كل تنفيذ على كل بناء Build بشكل منفصل، وتستقبل النتائج الآلية عبر API، وتربط الفشل بالخطأ في نظام تتبع الأخطاء Issue Tracker.
مفاهيم Kiwi TCMS في صورة واحدة
قبل التثبيت عليك أن تعرف ستة أسماء، لأن الأداة كلها مبنية عليها، والصورة التالية تبين العلاقة بينها:

- المنتج Product والإصدار Version والبناء Build: المنتج هو تطبيقك، والإصدار مثل 1.4، والبناء هو النسخة المحددة التي تختبرها فعلاً، مثل 1.4.0 أو 1.4.0-ci.17 من خط CI.
- خطة الاختبار Test Plan: مجموعة الحالات التي تريد تنفيذها لهدف معين، مثل «اختبار الدفع قبل الإصدار 1.4»، ولها نوع مثل System أو Regression.
- حالة الاختبار Test Case: الخطوات والنتيجة المتوقعة، ولها حالة مثل PROPOSED أو CONFIRMED، وأولوية من P1 إلى P5، وتحفظ الأداة تاريخ تعديلاتها.
- التشغيل Test Run: تنفيذ خطة معينة على بناء معين، وله مدير ومختبر افتراضي ووقت بداية ونهاية.
- التنفيذ Test Execution: نتيجة حالة واحدة داخل تشغيل واحد، وهذا هو الشيء الذي يتغير لونه، ويحمل التعليقات وروابط الأخطاء.
وحالات التنفيذ في نسختنا قرأناها من ال API نفسه، ولكل منها وزن Weight يستخدمه Kiwi ليعرف هل التشغيل نجح أم فشل:
| الحالة | الوزن | المعنى |
|---|---|---|
| IDLE وRUNNING وPAUSED | 0 | لم ينته التنفيذ بعد |
| PASSED | 20 | نجحت الحالة |
| WAIVED | 10 | تم التجاوز عنها بقرار، مثل اختبار متوقف مؤقتاً |
| BLOCKED | -10 | لم نستطع التنفيذ بسبب شيء خارجي، مثل بيئة متوقفة |
| ERROR | -20 | خطأ في الاختبار نفسه أو في بيئته |
| FAILED | -30 | ظهر خطأ في المنتج |
الترخيص والإصدارات: ما الذي تحصل عليه مجاناً؟
Kiwi TCMS مرخصة بترخيص GPL-2.0 كما في ملف الترخيص في مستودعها، ومكتبة ال API الخاصة بها tcms-api بترخيص LGPL-2.1، وإضافتا junit.xml وpytest بترخيص GPL-3.0 بحسب مستودعاتها، وآخر إصدار وقت كتابة المقال هو 16.6 الصادر في أكتوبر 2026. وهناك نسخة تجارية Enterprise ومزايا للمشتركين، والفرق بينهما في صفحة المزايا.
وهنا نقطة مهمة تفاجئ من يريد تثبيت إصدار محدد: تذكر وثائق التثبيت أن الأداة لا توفر Docker Images بأرقام إصدارات عبر Docker Hub، وأن ال Images ذات الوسوم ومتعددة المعماريات متاحة للمشتركين فقط، كما تشرح صفحة ال Containers، أما النسخة المجانية فهي pub.kiwitcms.eu/kiwitcms/kiwi:latest. وقد يتساءل البعض: كيف نثبت الإصدار إذن؟ والإجابة أن تثبت البصمة Digest بدل الوسم، فالبصمة لا تتغير أبداً، وبالتالي يبقى السيرفر على النسخة نفسها حتى تقرر أنت الترقية:
docker pull pub.kiwitcms.eu/kiwitcms/kiwi:latest
docker image inspect pub.kiwitcms.eu/kiwitcms/kiwi:latest --format '{{json .RepoDigests}}'
docker run --rm --entrypoint python3 pub.kiwitcms.eu/kiwitcms/kiwi:latest -c "import tcms; print(tcms.__version__)"والمخرج سوف يكون كما يلي:
["pub.kiwitcms.eu/kiwitcms/kiwi@sha256:7a5e3173cc677fa9efb88aadb621b740c03a86a376cc47ef514d01af2101df2e"]
16.6ولاحظ أن حجم ال Image حوالي 1.2 GB، وفيها nginx وuWSGI وتطبيق Django كاملاً على CentOS Stream 10.
التثبيت مع Docker Compose
سوف نشغل ثلاث خدمات: قاعدة بيانات MariaDB، وKiwi نفسه، وGitea لنربط به الأخطاء، وكلها تبدأ بالاسم lab-qa2- وتعمل على 127.0.0.1 فقط. وقد شرحنا تثبيت Gitea بالتفصيل في دليل Gitea، فإذا كان لديك Gitea يعمل فاحذف خدمته من الملف. وشكل مجلد التجربة كما يلي:
lab-kiwi/
├── compose.yaml
├── certs/ # cert.pem and key.pem
├── kiwi/
│ ├── gitea_tracker.py
│ └── arabroot.py
├── conf/ # ~/.tcms.conf files for each user
└── client/ # a small pytest suite and its Dockerfileوملف compose.yaml كما يلي:
name: lab-qa2-kiwi
services:
db:
image: mariadb:11.8.9
container_name: lab-qa2-kiwi-db
command: --character-set-server=utf8mb4 --collation-server=utf8mb4_unicode_ci
environment:
MARIADB_ROOT_PASSWORD: lab-root-pass
MARIADB_DATABASE: kiwi
MARIADB_USER: kiwi
MARIADB_PASSWORD: lab-kiwi-pass
volumes:
- lab-qa2-kiwi-db:/var/lib/mysql
healthcheck:
test: ["CMD", "healthcheck.sh", "--connect", "--innodb_initialized"]
interval: 5s
retries: 20
networks: [lab-qa2-net]
web:
# Community images on Docker Hub have no version tags, so we pin the digest of 16.6
image: pub.kiwitcms.eu/kiwitcms/kiwi@sha256:7a5e3173cc677fa9efb88aadb621b740c03a86a376cc47ef514d01af2101df2e
container_name: lab-qa2-kiwi-web
depends_on:
db:
condition: service_healthy
environment:
KIWI_DB_HOST: lab-qa2-kiwi-db
KIWI_DB_PORT: 3306
KIWI_DB_NAME: kiwi
KIWI_DB_USER: kiwi
KIWI_DB_PASSWORD: lab-kiwi-pass
KIWI_REGISTRATION_ENABLED: "False"
KIWI_TIME_ZONE: Asia/Riyadh
KIWI_USE_TZ: "True"
ports:
- "127.0.0.1:18443:8443"
volumes:
- lab-qa2-kiwi-uploads:/Kiwi/uploads
- ./certs/cert.pem:/Kiwi/ssl/localhost.crt:ro
- ./certs/key.pem:/Kiwi/ssl/localhost.key:ro
- ./kiwi/gitea_tracker.py:/venv/lib64/python3.12/site-packages/gitea_tracker.py:ro
- ./kiwi/arabroot.py:/venv/lib64/python3.12/site-packages/tcms_settings_dir/arabroot.py:ro
cap_drop: [ALL]
networks:
lab-qa2-net:
aliases: [kiwi.lab.test]
gitea:
image: gitea/gitea:1.27.3
container_name: lab-qa2-gitea
environment:
GITEA__security__INSTALL_LOCK: "true"
GITEA__database__DB_TYPE: sqlite3
GITEA__server__ROOT_URL: http://git.lab.test:3000/
GITEA__service__DISABLE_REGISTRATION: "true"
ports:
- "127.0.0.1:18300:3000"
volumes:
- lab-qa2-gitea:/data
networks:
lab-qa2-net:
aliases: [git.lab.test]
volumes:
lab-qa2-kiwi-db:
name: lab-qa2-kiwi-db
lab-qa2-kiwi-uploads:
name: lab-qa2-kiwi-uploads
lab-qa2-gitea:
name: lab-qa2-gitea
networks:
lab-qa2-net:
name: lab-qa2-netفي الإعداد أعلاه لاحظ التالي:
- البصمة بعد @sha256 هي بصمة الإصدار 16.6 التي رأيناها قبل قليل، وليس الوسم latest.
- KIWI_REGISTRATION_ENABLED=False يمنع أي شخص يصل إلى الصفحة من إنشاء حساب لنفسه، والسبب أن الحساب الجديد يدخل تلقائياً في مجموعة Tester ويستطيع تعديل الحالات وحذفها، كما سنرى في قسم الصلاحيات.
- الأسماء kiwi.lab.test وgit.lab.test أسماء مستعارة Aliases على شبكة Docker، وسوف ترى بعد قليل لماذا اخترنا اسماً فيه نقطة بدل اسم ال Container.
- ملفا gitea_tracker.py وarabroot.py هما كود التكامل مع Gitea، ونشرحهما في قسم الأخطاء.
- Kiwi يعمل بالمستخدم 1001 داخل ال Container، ويقدم الصفحات عبر HTTPS على المنفذ 8443، والمنفذ 8080 يحول كل الطلبات إلى HTTPS.
الشهادة: خطأ صلاحيات قبل أن يبدأ أي شيء
يأتي Kiwi بشهادة موقعة ذاتياً Self-signed، واسمها هو بصمة طبقة ال Container، لذلك لا تطابق أي اسم تستخدمه الأدوات التي ستتصل به، وسوف تحتاج شهادة لاسم السيرفر الحقيقي. وفي التجربة استخدمنا الأمر gitea cert الموجود في Image Gitea لننشئ شهادة واحدة تعمل كجهة إصدار CA، أما في الإنتاج Production فضع Kiwi خلف Reverse Proxy بشهادة حقيقية كما تشرح الوثائق. وفي المحاولة الأولى أنشأنا الشهادة ثم شغلنا الخدمات:
docker run --rm --user 0 -v "$PWD/certs:/certs" -w /certs --entrypoint sh gitea/gitea:1.27.3 \
-c 'gitea cert --host kiwi.lab.test,localhost,127.0.0.1 --ca --duration 720h'
docker compose up -d
docker logs lab-qa2-kiwi-web 2>&1 | grep emergولم يعمل الموقع، والسبب ظهر في السجل:
nginx: [emerg] cannot load certificate key "/Kiwi/ssl/localhost.key": BIO_new_file() failed (SSL: error:8000000D:system library::Permission denied:calling fopen(/Kiwi/ssl/localhost.key, r) error:10080002:BIO routines::system lib)ولنعرف السبب نقارن مالك الملف بالمستخدم الذي يعمل به Kiwi:
docker exec lab-qa2-kiwi-web ls -ln /Kiwi/ssl/
docker exec lab-qa2-kiwi-web id-rw-r--r-- 1 1000 1000 1562 Oct 12 15:55 localhost.crt
-rw------- 1 1000 1000 2455 Oct 12 15:55 localhost.key
uid=1001 gid=0(root) groups=0(root)المفتاح مملوك للمستخدم 1000 وصلاحياته 600، أي أن مالكه فقط يقرؤه، أما nginx داخل ال Container فيعمل بالمستخدم 1001، لذلك رفض النظام فتح الملف. والحل أن يملك المستخدم 1001 الملفين، فأعدنا إنشاءهما مع chown داخل الأمر نفسه:
docker compose down
rm -rf certs && mkdir certs
docker run --rm --user 0 -v "$PWD/certs:/certs" -w /certs --entrypoint sh gitea/gitea:1.27.3 \
-c 'gitea cert --host kiwi.lab.test,localhost,127.0.0.1 --ca --duration 720h && chown 1001:0 key.pem cert.pem'
docker compose up -dوبعدها رد Kiwi ب 200 من nginx/1.26.3. ولا تقم بحل هذه المشكلة بالأمر chmod 644 على المفتاح الخاص، والسبب أن هذا يجعل المفتاح مقروءاً لكل مستخدم على السيرفر.
الإعداد الأول دون أسئلة تفاعلية
توصي الوثائق بالأمر docker exec -it lab-qa2-kiwi-web /Kiwi/manage.py initial_setup، وهو يسألك عن اسم المدير وكلمة المرور واسم النطاق Domain. وإذا قرأت كود هذا الأمر في ال Image فستجد أنه ينفذ أربعة أوامر بالترتيب: migrate ثم createsuperuser ثم set_domain ثم refresh_permissions، لذلك نستطيع تنفيذها دون أسئلة في سكربت أو في Ansible:
docker exec lab-qa2-kiwi-web /Kiwi/manage.py migrate
docker exec -e DJANGO_SUPERUSER_USERNAME=qa-admin \
-e [email protected] \
-e DJANGO_SUPERUSER_PASSWORD='Lab-Admin-2026!' \
lab-qa2-kiwi-web /Kiwi/manage.py createsuperuser --noinput
docker exec lab-qa2-kiwi-web /Kiwi/manage.py set_domain kiwi.lab.test:8443
docker exec lab-qa2-kiwi-web /Kiwi/manage.py refresh_permissionsوالمخرج مختصراً كما يلي:
Applying testruns.0019_alter_testexecutionstatus_color... OK
Applying testruns.0020_testexecutiontag... OK
Superuser created successfully.
Domain updated successfully.
...
testruns.view_testruntag added to Tester group
Done.
Removing stale permissions
Removed 0 object permission entries with no targets
Done.ولاحظ أن set_domain يأخذ اسم النطاق كمعامل مباشر وليس بالخيار --domain، فقد استخدمنا الخيار أولاً ورفضه الأمر بالرسالة unrecognized arguments. واسم النطاق مهم لأن Kiwi يبني منه الروابط التي يضعها في رسائل البريد وفي المشكلات التي ينشئها في Gitea.
واستهلاك الذاكرة بعد التشغيل كان صغيراً على جهازنا، والأرقام للتوضيح فقط:
lab-qa2-kiwi-web 114.8MiB
lab-qa2-kiwi-db 140.7MiB
lab-qa2-gitea 175MiBأول خطة وحالات وتشغيل
كل ما تفعله في الواجهة تستطيع فعله عبر ال API، وKiwi يقدمه بصيغتي XML-RPC وJSON-RPC، وقائمة الدوال في مرجع ال RPC. وسوف نستخدم مكتبة tcms-api من Python، وهي نفسها التي تستخدمها الإضافات، لأن السكربت قابل للتكرار وتستطيع وضعه في المستودع، أما في عملك اليومي فالواجهة من القائمة TESTING ← New Test Plan تفعل الشيء نفسه. ونجهز Container صغيراً فيه pytest والمكتبات بإصدارات محددة:
FROM python:3.13-slim
RUN pip install --no-cache-dir pytest==9.1.1 tcms-api==15.0 \
kiwitcms-junit.xml-plugin==15.0 kiwitcms-pytest-plugin==15.4
WORKDIR /work
COPY cart.py pytest.ini ./
COPY tests ./testsثم سكربت صغير يشغله داخل شبكة التجربة، ويعطيه ملف الإعداد ~/.tcms.conf الخاص بالمستخدم، وشهادة التجربة عبر المتغير SSL_CERT_FILE:
#!/bin/sh
# usage: ./client.sh <conf file> <command...> (runs inside the lab network with the lab CA)
conf=$1; shift
docker run --rm --network lab-qa2-net \
-e SSL_CERT_FILE=/certs/cert.pem -e TCMS_PRODUCT -e TCMS_PRODUCT_VERSION -e TCMS_BUILD \
-v "$PWD/certs/cert.pem:/certs/cert.pem:ro" -v "$PWD/conf/$conf:/root/.tcms.conf:ro" \
-v "$PWD:/lab:ro" lab-qa2-kiwi-client:1 "$@"وملف الإعداد للمدير كما يلي:
[tcms]
url = https://kiwi.lab.test:8443/xml-rpc/
username = qa-admin
password = Lab-Admin-2026!والسكربت seed.py ينشئ المنتج والإصدار والبناء، ثم خطة فيها ثلاث حالات مكتوبة بالعربية، ثم تشغيلاً لها على البناء 1.4.0:
# Product -> version -> build, then a plan with three manual cases and a run for build 1.4.0
from tcms_api import TCMS
rpc = TCMS().exec
classification = rpc.Classification.create({"name": "Web"})
product = rpc.Product.create({"name": "Shop", "description": "متجر التجربة",
"classification": classification["id"]})
version = rpc.Version.create({"product": product["id"], "value": "1.4"})
build = rpc.Build.create({"version": version["id"], "name": "1.4.0"})
category = rpc.Category.filter({"product": product["id"]})[0] # "--default--"
plan = rpc.TestPlan.create({
"name": "اختبار الدفع قبل الإصدار 1.4",
"product": product["id"], "product_version": version["id"],
"type": 4, # System
"text": "المسار الحرج: السلة ثم الكوبون ثم الدفع",
"is_active": True,
})
cases = [
("السلة الفارغة لا تسمح بالدفع", "1. افتح السلة وهي فارغة\n2. اضغط «إتمام الشراء»\n\nالمتوقع: الزر معطل ورسالة «السلة فارغة»"),
("الكوبون SAVE10 يخصم 10% قبل الضريبة", "1. أضف منتجين بسعر 100 ريال\n2. أدخل SAVE10\n\nالمتوقع: الإجمالي 207.00 ريال"),
("الدفع ببطاقة مدى في بيئة الاختبار", "1. أضف منتجاً\n2. ادفع ببطاقة مدى التجريبية\n\nالمتوقع: صفحة «تم الطلب» ورقم الطلب"),
]
case_ids = []
for summary, text in cases:
case = rpc.TestCase.create({
"summary": summary, "text": text, "product": product["id"],
"category": category["id"], "priority": 1,
"case_status": 2, # CONFIRMED
"is_automated": False,
})
rpc.TestPlan.add_case(plan["id"], case["id"])
case_ids.append(case["id"])
run = rpc.TestRun.create({
"summary": "الدفع على 1.4.0", "plan": plan["id"], "build": build["id"],
"manager": "qa-admin",
})
for cid in case_ids:
rpc.TestRun.add_case(run["id"], cid)
print("product", product["id"], "plan", plan["id"], "run", run["id"], "cases", case_ids)
for e in rpc.TestExecution.filter({"run": run["id"]}):
print(" execution", e["id"], e["case__summary"], e["status__name"])وفي المحاولة الأولى لم نضع التصنيف Classification، ففشل إنشاء المنتج بالخطأ التالي، وهو يبين لك شكل أخطاء التحقق Validation التي يرجعها ال API:
xmlrpc.client.Fault: <Fault -32603: "Internal error: [('classification', ['This field is required.'])]">وبعد إضافته شغلنا السكربت:
./client.sh admin.conf python /lab/seed.pyوالمخرج سوف يكون كما يلي:
product 1 plan 1 run 1 cases [1, 2, 3]
execution 1 السلة الفارغة لا تسمح بالدفع IDLE
execution 2 الكوبون SAVE10 يخصم 10% قبل الضريبة IDLE
execution 3 الدفع ببطاقة مدى في بيئة الاختبار IDLEوالصورة التالية تبين الخطة كما تظهر في الواجهة، ومعها الملف المرفق الذي سنستخدمه في اختبار النسخ الاحتياطي:

تنفيذ الحالات وتسجيل النتائج
الآن يعمل المختبر على التشغيل TR-1، فالحالة الأولى نجحت، والثانية فشلت فكتب تعليقاً ثم أبلغ عن الخطأ في Gitea من داخل Kiwi، والثالثة لم يستطع تنفيذها لأن بيئة الدفع متوقفة، فسجلها BLOCKED وليس FAILED، والسبب أن FAILED تعني خطأ في المنتج، أما BLOCKED فتعني أن الحالة لم تختبر أصلاً، والخلط بينهما يجعل تقرير الإصدار يكذب في الاتجاهين:
# A tester works through run 1: one pass, one failure reported to Gitea, one blocked
from tcms_api import TCMS
rpc = TCMS().exec
PASSED, FAILED, BLOCKED = 4, 5, 6
e1, e2, e3 = [e["id"] for e in rpc.TestExecution.filter({"run": 1})]
rpc.TestExecution.update(e1, {"status": PASSED})
rpc.TestExecution.update(e2, {"status": FAILED})
rpc.TestExecution.add_comment(e2, "الإجمالي 208.00 بدل 207.00 عند الكوبون SAVE10")
GITEA_TRACKER_ID = 3 # ADMIN -> Bug trackers -> "Gitea shop"
report = rpc.Bug.report(e2, GITEA_TRACKER_ID)
print("Bug.report:", report)
rpc.TestExecution.update(e3, {"status": BLOCKED})
rpc.TestExecution.add_comment(e3, "بيئة الدفع التجريبية متوقفة")
for e in rpc.TestExecution.filter({"run": 1}):
print(e["id"], e["status__name"], e["case__summary"])
for link in rpc.TestExecution.get_links({"execution": e2}):
print("link:", link["url"], "is_defect =", link["is_defect"])
print("details:", rpc.Bug.details(report["response"]))والمخرج سوف يكون كما يلي:
Bug.report: {'rc': 0, 'response': 'http://git.lab.test:3000/qa/shop/issues/1'}
1 PASSED السلة الفارغة لا تسمح بالدفع
2 FAILED الكوبون SAVE10 يخصم 10% قبل الضريبة
3 BLOCKED الدفع ببطاقة مدى في بيئة الاختبار
link: http://git.lab.test:3000/qa/shop/issues/1 is_defect = True
details: {'id': 1, 'title': 'Failed test: الكوبون SAVE10 يخصم 10% قبل الضريبة', 'description': 'Filed from execution https://kiwi.lab.test:8443/runs/1/ ...', 'status': 'open', 'url': 'http://git.lab.test:3000/qa/shop/issues/1'}والصورة التالية تبين التشغيل في الواجهة، ولاحظ رقم المشكلة وحالتها open بجانب كل حالة فاشلة أو متوقفة، فهذه المعلومة يجلبها Kiwi من Gitea نفسه:

ربط الأخطاء بمشكلات Gitea
يدعم Kiwi في نسخته المجانية أنظمة تتبع الأخطاء التالية فقط: Azure Boards وBugzilla وJIRA وGitHub وGitLab وRedmine، ومعها نظام الأخطاء الداخلي في Kiwi نفسه، كما في الإعداد EXTERNAL_BUG_TRACKERS في ملف الإعدادات. ولا يوجد Gitea في القائمة، لكن دليل الإدارة يذكر أن هذا الإعداد قائمة من الكلاسات تستطيع إضافة غيرها إليها، وأن كل نظام هو كلاس يرث من IssueTrackerType. لذلك كتبنا كلاساً صغيراً يتحدث مع API Gitea مباشرة:
"""
Gitea issues for Kiwi TCMS (lab code, not part of Kiwi TCMS).
Bug tracker fields in ADMIN -> Bug trackers:
base_url : web URL of the repository, e.g. http://git.lab.test:3000/qa/shop
api_url : Gitea API root, e.g. http://git.lab.test:3000/api/v1
api_password : Gitea access token with write:issue (api_username stays empty)
"""
from urllib.parse import urlparse
import requests
from tcms.core.contrib.linkreference.models import LinkReference
from tcms.issuetracker.base import IssueTrackerType
class Gitea(IssueTrackerType):
def is_adding_testcase_to_issue_disabled(self):
_, token = self.rpc_credentials
return not (self.bug_system.base_url and self.bug_system.api_url and token)
def _rpc_connection(self):
_, token = self.rpc_credentials
session = requests.Session()
session.headers["Authorization"] = f"token {token}"
return session
@property
def repo_api(self):
# http://host/owner/repo -> <api_url>/repos/owner/repo
owner_repo = urlparse(self.bug_system.base_url).path.strip("/")
return f"{self.bug_system.api_url.rstrip('/')}/repos/{owner_repo}"
def _report_issue(self, execution, user):
body = {
"title": f"Failed test: {execution.case.summary}",
"body": self._report_comment(execution, user),
}
response = self.rpc.post(f"{self.repo_api}/issues", json=body, timeout=10)
response.raise_for_status()
issue = response.json()
LinkReference.objects.get_or_create(
execution=execution, url=issue["html_url"], is_defect=True
)
return (issue, issue["html_url"])
def details(self, url):
number = self.bug_id_from_url(url)
issue = self.rpc.get(f"{self.repo_api}/issues/{number}", timeout=10).json()
return {
"id": issue["number"],
"title": issue["title"],
"description": issue["body"],
"status": issue["state"],
"url": url,
}
def post_comment(self, execution, bug_id):
self.rpc.post(
f"{self.repo_api}/issues/{bug_id}/comments",
json={"body": self.text(execution)},
timeout=10,
).raise_for_status()ثم ملف إعدادات يضيفه إلى القائمة، ويقرأ Kiwi تلقائياً كل ملف Python في المجلد tcms_settings_dir بعد إعداداته كما في ملف product.py داخل ال Image:
# Loaded by Kiwi TCMS from tcms_settings_dir/ after its own settings
EXTERNAL_BUG_TRACKERS.append("gitea_tracker.Gitea") # noqa: F821في الكود أعلاه لاحظ التالي:
_report_issueتنشئ المشكلة في Gitea وتضيف رابطها إلى التنفيذ، وهي التي تعمل عندما تضغط Report في صفحة التشغيل أو تستدعي Bug.report.- details تجلب العنوان والحالة من Gitea، وهي التي أظهرت «1 - open» في الصورة السابقة.
- post_comment تكتب في المشكلة تعليقاً يشير إلى التشغيل والتنفيذ، وتعمل عندما تربط مشكلة موجودة مع خيار تحديث النظام.
- هذا كود تجربة بلا اختبارات، فإذا اعتمدته فأضف له اختبارات وراقبه عند كل ترقية لأن IssueTrackerType واجهة داخلية قد تتغير.
بعد ذلك ننشئ في Gitea مستخدماً ورمز وصول Access Token بأقل صلاحيات تكفي، أي كتابة المشكلات وقراءة المستودع:
docker exec -u git lab-qa2-gitea gitea admin user create --admin --username qa \
--password 'Lab-Gitea-2026!' --email [email protected] --must-change-password=false
docker exec -u git lab-qa2-gitea gitea admin user generate-access-token \
--username qa --token-name kiwi --scopes write:issue,read:repository --rawوقد حاولنا أولاً إنشاء المستودع بالرمز نفسه فرفض Gitea، وهذا هو السلوك الصحيح، فالرمز الذي تضعه في أداة أخرى يجب ألا يملك أكثر مما تحتاجه، ونطاقات الرموز Scopes مشروحة في وثائق API Gitea:
{"message":"token does not have at least one of required scope(s), required=[write:user], token scope=write:issue,write:repository"}ثم نضيف النظام من ADMIN ← Everything else ← Bug trackers، ونختار النوع gitea_tracker.Gitea الذي ظهر في القائمة بعد إضافة الملف، ونضع الرمز في حقل Api password، والصورة التالية تبين الإعداد:

وعندما استدعى السكربت السابق Bug.report أنشأ Kiwi المشكلة التالية في Gitea، وفيها رابط التشغيل والمنتج والإصدار والبناء وخطوات الحالة كما كتبها المختبر:

أما إذا كانت المشكلة موجودة من قبل، مثل مشكلة بيئة الدفع رقم 2، فنربطها بالتنفيذ ونطلب من Kiwi أن يعلق عليها:
# Link an issue that already exists in Gitea to execution 3, and let Kiwi comment on it
from tcms_api import TCMS
rpc = TCMS().exec
link = rpc.TestExecution.add_link(
{"execution_id": 3, "url": "http://git.lab.test:3000/qa/shop/issues/2", "is_defect": True},
True, # update_tracker
)
print(link["url"], link["is_defect"])./client.sh admin.conf python /lab/link.py
curl -s -H "Authorization: token $GITEA_TOKEN" \
http://127.0.0.1:18300/api/v1/repos/qa/shop/issues/2/commentsوالمخرج سوف يكون كما يلي، أولاً الرابط، ثم التعليق الذي كتبه Kiwi في Gitea:
http://git.lab.test:3000/qa/shop/issues/2 True
qa:
---- Confirmed via test execution ----
TR-1: الدفع على 1.4.0
https://kiwi.lab.test:8443/runs/1/
TE-3: الدفع ببطاقة مدى في بيئة الاختبارFault -32603: "Internal error: [('url', ['Enter a valid URL.'])]"، والسبب أن Kiwi يتحقق من الرابط بمدقق Django الذي يقبل localhost والأسماء التي فيها نطاق مثل .test، ولا يقبل اسماً مفرداً. لذلك استخدم لنظام تتبع الأخطاء اسماً كاملاً مثل git.example.com، وهو ما يحدث طبيعياً في الإنتاج.نتائج الاختبارات الآلية من خط CI
الاختبار اليدوي جزء من الصورة، والجزء الأكبر عادة اختبارات آلية تعمل في خط CI. وKiwi لا يشغل الاختبارات بنفسه، وإنما يستقبل نتائجها عبر إضافات Plugins تتبع مواصفة واحدة: تقرأ النتائج، ثم تبحث عن الحالة بعنوانها أو تنشئها، ثم تضيفها إلى خطة وتشغيل، ثم تسجل حالة التنفيذ. ولدينا اختبارات pytest صغيرة لدالة تحسب إجمالي السلة مع ضريبة 15%، وفيها خطأ مقصود في التقريب:
from decimal import Decimal, ROUND_HALF_UP
VAT = Decimal("0.15")
def total(items, coupon=None):
"""Cart total in SAR: sum of price x qty, minus coupon, plus 15% VAT."""
subtotal = sum(Decimal(str(p)) * q for p, q in items)
if coupon == "SAVE10":
subtotal -= subtotal * Decimal("0.10")
if subtotal < 0:
raise ValueError("negative total")
# Bug on purpose: VAT is rounded before it is added, not the final total
vat = (subtotal * VAT).quantize(Decimal("1"), rounding=ROUND_HALF_UP)
return (subtotal + vat).quantize(Decimal("0.01"))import pytest
from cart import total
def test_empty_cart_is_zero():
"""Empty cart costs 0.00"""
assert str(total([])) == "0.00"
def test_vat_is_added():
"""100 SAR plus 15% VAT is 115.00"""
assert str(total([(100, 1)])) == "115.00"
def test_coupon_save10():
"""SAVE10 takes 10% off before VAT"""
assert str(total([(100, 2)], coupon="SAVE10")) == "207.00"
def test_vat_on_small_amounts():
"""9.99 SAR plus VAT is 11.49, not rounded to whole riyals"""
assert str(total([(9.99, 1)])) == "11.49"
@pytest.mark.skip(reason="payment gateway sandbox is down")
def test_pay_with_mada():
"""Pay with a mada card"""والنتيجة عند التشغيل اختبار فاشل وثلاثة ناجحة وواحد متجاوز:
FAILED tests/test_cart.py::test_vat_on_small_amounts - AssertionError: assert...
1 failed, 3 passed, 1 skipped in 0.10sمستخدم خاص بخط CI
لا تقم بوضع حساب المدير في خط CI، والسبب أن أي شخص يستطيع تعديل ملف ال workflow يستطيع قراءة الأسرار التي يستخدمها، لذلك ننشئ مستخدماً اسمه ci-bot في مجموعة Tester فقط:
docker exec lab-qa2-kiwi-web /Kiwi/manage.py shell -c "
from django.contrib.auth.models import User, Group
u = User.objects.create_user('ci-bot', '[email protected]', 'Lab-CiBot-2026!')
u.groups.add(Group.objects.get(name='Tester'))
print(u.username, [g.name for g in u.groups.all()], u.is_staff, u.is_superuser)"ci-bot ['Tester'] False Falseإضافة junit.xml وثلاثة أخطاء قبل أن تعمل
أكثر أدوات الاختبار تكتب تقريراً بصيغة JUnit XML، لذلك إضافة junit.xml-plugin تناسب أي لغة. وأول ما واجهناه هو الشهادة، فعندما شغلنا الإضافة دون أن نعطيها شهادة التجربة:
docker run --rm --network lab-qa2-net -v "$PWD/conf/ci-bot.conf:/root/.tcms.conf:ro" \
-e TCMS_PRODUCT=Shop -e TCMS_PRODUCT_VERSION=1.4 -e TCMS_BUILD=1.4.0-ci.17 \
lab-qa2-kiwi-client:1 sh -c 'pytest -q --junitxml=/tmp/junit.xml; tcms-junit.xml-plugin /tmp/junit.xml'كان المخرج كما يلي، فالمكتبة تستخدم التحقق المعتاد في Python ولا تعرف جهة الإصدار الخاصة بنا:
ssl.SSLCertVerificationError: [SSL: CERTIFICATE_VERIFY_FAILED] certificate verify failed: self-signed certificate (_ssl.c:1032)والحل هو المتغير SSL_CERT_FILE الذي يضعه client.sh، أو شهادة من جهة إصدار يثق بها السيرفر. ولا تقم بتعطيل التحقق من الشهادة لتتجاوز الخطأ، والسبب أن الإضافة ترسل كلمة مرور ci-bot مع كل اتصال. والخطأ الثاني ظهر عندما نسينا المتغيرات التي تحدد المنتج والإصدار والبناء:
./client.sh ci-bot.conf sh -c 'pytest -q --junitxml=/tmp/junit.xml; tcms-junit.xml-plugin /tmp/junit.xml'RuntimeError: Product name not defined, missing one of TCMS_PRODUCT, TRAVIS_REPO_SLUG or JOB_NAMEفالإضافة تبحث عن TCMS_PRODUCT أو متغيرات Travis وJenkins، ولا تعرف متغيرات GitHub Actions أو Gitea Actions، لذلك تحددها أنت. والخطأ الثالث كان الأغرب، فبعد إضافة المتغيرات:
export TCMS_PRODUCT=Shop TCMS_PRODUCT_VERSION=1.4 TCMS_BUILD=1.4.0-ci.17
./client.sh ci-bot.conf sh -c 'pytest -q --junitxml=/tmp/junit.xml; tcms-junit.xml-plugin --verbose /tmp/junit.xml'added: TP-2
added: TR-2
reuse: TP-2
Parsing /tmp/junit.xml ...
Traceback (most recent call last):
...
File "/usr/local/lib/python3.13/site-packages/tcms_junit_plugin/__init__.py", line 99, in parse_timestamp
raise ValueError(f"Unknown timestamp format {value}")
ValueError: Unknown timestamp format 2026-10-12T16:09:32.325206+00:00في المخرج أعلاه لاحظ التالي:
- الإصدار 15.0 من الإضافة يقرأ ثلاث صيغ فقط للتوقيت كما في كودها، وليس منها صيغة فيها المنطقة الزمنية، بينما أضاف pytest المنطقة الزمنية إلى توقيت التقرير منذ الإصدار 8.3.0 كما في سجل تغييرات pytest (#7662)، فأي pytest حديث يكسر الإضافة.
- الإضافة أنشأت الخطة TP-2 والتشغيل TR-2 قبل أن تقرأ الملف، ثم توقفت، فبقي في Kiwi تشغيل ناقص فيه تنفيذ واحد بحالة IDLE، كما رأينا لاحقاً:
TR-2: [junit.xml] Results for Shop, 1.4, 1.4.0-ci.17
plan TP-2: [junit.xml] Plan for Shop (1.4) build: 1.4.0-ci.17
TE-4 IDLE TC-4 tests.test_cart.test_empty_cart_is_zeroوالحل المؤقت أن نحذف المنطقة الزمنية من الملف قبل إرساله، ثم نعرض التشغيل الجديد بسكربت صغير show_run.py:
./client.sh ci-bot.conf sh -c '
pytest -q --junitxml=/tmp/junit.xml
# junit.xml-plugin 15.0 does not read the time zone that pytest 8.3+ writes
sed -i -E "s/(timestamp=\"[^\"+]*)\+00:00\"/\1\"/" /tmp/junit.xml
tcms-junit.xml-plugin --verbose /tmp/junit.xml'
./client.sh ci-bot.conf python /lab/show_run.py 3reuse: TP-2
added: TR-3
reuse: TP-2
Parsing /tmp/junit.xml ...
TR-3: [junit.xml] Results for Shop, 1.4, 1.4.0-ci.17
plan TP-2: [junit.xml] Plan for Shop (1.4) build: 1.4.0-ci.17
TE-5 PASSED TC-4 tests.test_cart.test_empty_cart_is_zero
TE-6 PASSED TC-5 tests.test_cart.test_vat_is_added
TE-7 PASSED TC-6 tests.test_cart.test_coupon_save10
TE-8 FAILED TC-7 tests.test_cart.test_vat_on_small_amounts
TE-9 WAIVED TC-8 tests.test_cart.test_pay_with_madaولاحظ أن الاختبار المتجاوز Skipped ظهر بحالة WAIVED، والفاشل FAILED، وأن عنوان الحالة هو اسم الاختبار كاملاً بنقاط.
إضافة pytest: الطريق الأقصر لمشاريع Python
إذا كانت اختباراتك pytest فلا تحتاج ملف junit.xml أصلاً، فإضافة kiwitcms-pytest-plugin ترسل النتائج أثناء التشغيل، وتضع نص ال docstring في الحالة منذ الإصدار 15.4:
export TCMS_BUILD=1.4.0-ci.18
./client.sh ci-bot.conf pytest -q -p tcms_pytest_plugin --kiwitcms
./client.sh ci-bot.conf python /lab/show_run.py 41 failed, 3 passed, 1 skipped in 2.09s
TR-4: [pytest] Results for Shop, 1.4, 1.4.0-ci.18
plan TP-3: [pytest] Plan for Shop (1.4) build: 1.4.0-ci.18
TE-10 PASSED TC-9 tests/test_cart.py::test_empty_cart_is_zero
TE-11 PASSED TC-10 tests/test_cart.py::test_vat_is_added
TE-12 PASSED TC-11 tests/test_cart.py::test_coupon_save10
TE-13 FAILED TC-12 tests/test_cart.py::test_vat_on_small_amounts
TE-14 WAIVED TC-13 tests/test_cart.py::test_pay_with_madaولاحظ أن العناوين هنا بصيغة tests/test_cart.py::test_vat_is_added، أما إضافة junit.xml فكتبتها tests.test_cart.test_vat_is_added، أي أن الإضافتين أنشأتا حالات منفصلة للاختبارات نفسها، وبالتالي اختر إضافة واحدة لكل مشروع ولا تنتقل بينهما.
ماذا يحدث إذا غيرت اسم اختبار؟
الإضافات تعرف الحالة من عنوانها فقط، فماذا يحدث إذا غير مطور اسم الدالة test_vat_on_small_amounts إلى اسم أوضح؟ شغلنا الإضافة على بناء جديد بعد تغيير الاسم، ثم عرضنا الحالات في الخطة TP-3 مع عدد مرات تنفيذ كل منها:
# How many test cases does a plan hold, and when did each one last run?
import sys
from tcms_api import TCMS
rpc = TCMS().exec
plan_id = int(sys.argv[1])
for case in rpc.TestCase.filter({"plan": plan_id}):
runs = rpc.TestExecution.filter({"case": case["id"]})
last = max(runs, key=lambda e: e["id"])
print(f"TC-{case['id']:<3} runs={len(runs)} last=TE-{last['id']} {last['status__name']:<7} {case['summary']}")export TCMS_BUILD=1.4.0-ci.19
./client.sh ci-bot.conf sh -c 'cp -r /work /tmp/w && cd /tmp/w &&
sed -i "s/def test_vat_on_small_amounts/def test_vat_rounding_small_amounts/" tests/test_cart.py &&
pytest -q -p tcms_pytest_plugin --kiwitcms'
./client.sh ci-bot.conf python /lab/plan_cases.py 3TC-9 runs=2 last=TE-15 PASSED tests/test_cart.py::test_empty_cart_is_zero
TC-10 runs=2 last=TE-16 PASSED tests/test_cart.py::test_vat_is_added
TC-11 runs=2 last=TE-17 PASSED tests/test_cart.py::test_coupon_save10
TC-12 runs=1 last=TE-13 FAILED tests/test_cart.py::test_vat_on_small_amounts
TC-13 runs=2 last=TE-19 WAIVED tests/test_cart.py::test_pay_with_mada
TC-14 runs=1 last=TE-18 FAILED tests/test_cart.py::test_vat_rounding_small_amountsالحالة القديمة TC-12 بقيت في الخطة بآخر نتيجة FAILED إلى الأبد، والجديدة TC-14 بدأت تاريخاً جديداً بتشغيل واحد، فإذا نظر أحد إلى تقرير الخطة فسيرى حالة فاشلة لم تعد موجودة، ولن يعرف أن الخطأ نفسه ظهر في بناءين. لذلك عامل أسماء الاختبارات كجزء من العقد، وإذا غيرت اسماً فعدل عنوان الحالة القديمة في Kiwi بالاسم الجديد، أو اجعل حالتها DISABLED حتى لا تظهر في التقارير.
الخطوة في خط CI
وفي خط CI على Gitea Actions أو GitHub Actions تكتب الخطوة بهذا الشكل، والقيم من متغيرات خط CI نفسه، وكلمة المرور من الأسرار Secrets، وقد شرحنا تشغيل خط CI على سيرفرك في دليل Gitea Actions:
- name: Send results to Kiwi TCMS
if: ${{ !cancelled() }}
env:
TCMS_PRODUCT: Shop
TCMS_PRODUCT_VERSION: ${{ github.ref_name }}
TCMS_BUILD: ${{ github.run_number }}-${{ github.sha }}
KIWI_PASSWORD: ${{ secrets.KIWI_CI_BOT_PASSWORD }}
run: |
pip install kiwitcms-pytest-plugin==15.4
printf '[tcms]\nurl = https://kiwi.example.com/xml-rpc/\nusername = ci-bot\npassword = %s\n' "$KIWI_PASSWORD" > ~/.tcms.conf
pytest -p tcms_pytest_plugin --kiwitcmsوالشرط !cancelled() يجعل الخطوة تعمل حتى عندما تفشل الاختبارات، وهذا هو المطلوب، فالفشل هو أهم ما تريد تسجيله.
الصلاحيات: من يقرأ ومن يعدل
يأتي Kiwi بمجموعتين لا تستطيع حذفهما بحسب دليل الإدارة: Administrator ولها كل الصلاحيات، وTester ولها الإضافة والتعديل والحذف والقراءة على الخطط والحالات والتشغيلات والأخطاء والمرفقات، وكل حساب جديد يدخل Tester تلقائياً. ويحذر الدليل من تعديل صلاحيات هاتين المجموعتين لأن الأمرين upgrade وrefresh_permissions يعيدانها كما كانت، ويوصي بإنشاء مجموعات جديدة. ولنفرض أن مدير المنتج يريد أن يرى النتائج دون أن يعدلها، فننشئ مجموعة Viewer فيها صلاحيات القراءة فقط:
# The manager can read the results but cannot change them
from xmlrpc.client import Fault
from tcms_api import TCMS
rpc = TCMS().exec
print("read:", [(e["id"], e["status__name"]) for e in rpc.TestExecution.filter({"run": 1})])
try:
rpc.TestExecution.update(2, {"status": 4}) # FAILED -> PASSED
except Fault as err:
print("update:", err.faultString)docker exec lab-qa2-kiwi-web /Kiwi/manage.py shell -c "
from django.contrib.auth.models import User, Group, Permission
g = Group.objects.create(name='Viewer')
g.permissions.set(Permission.objects.filter(codename__startswith='view_',
content_type__app_label__in=['testplans','testcases','testruns','management','linkreference','bugs']))
u = User.objects.create_user('manager', '[email protected]', 'Lab-Manager-2026!')
u.groups.add(g)
print(g.name, g.permissions.count(), 'view permissions')"
./client.sh manager.conf python /lab/perms.pyوالمخرج سوف يكون كما يلي:
Viewer 44 view permissions
read: [(1, 'PASSED'), (2, 'FAILED'), (3, 'BLOCKED')]
update: Authentication failed when calling "TestExecution.update"القراءة نجحت، أما التعديل فرفضه ال API، ولاحظ أن الرسالة تقول Authentication failed مع أن المستخدم سجل دخوله فعلاً، أي أن الرسالة تعني هنا «لا تملك الصلاحية»، فلا تبحث عن خطأ في كلمة المرور إذا رأيتها. وهناك صلاحيتان يذكرهما الدليل بالاسم: auth.view_user ليست ممنوحة افتراضياً لأن صفحة المستخدم فيها اسمه وبريده، وإنشاء المستخدمين يحتاج auth.add_user وauth.change_user معاً.
النسخ الاحتياطي والاستعادة
بيانات Kiwi في مكانين: قاعدة البيانات، ومجلد uploads الذي فيه المرفقات، والمرفق في قاعدة البيانات مجرد سجل يشير إلى الملف. وتشرح تدوينة Kiwi عن النسخ الاحتياطي نسخ القاعدة بالأمر mysqldump، ونحن استخدمنا mariadb-dump بالخيار --single-transaction حتى تكون النسخة متسقة دون إيقاف الخدمة، ثم نسخنا المرفقات:
mkdir -p backup
docker exec lab-qa2-kiwi-db mariadb-dump --single-transaction -u kiwi -plab-kiwi-pass kiwi > backup/kiwi.sql
docker run --rm -v lab-qa2-kiwi-uploads:/uploads:ro -v "$PWD/backup:/backup" \
mariadb:11.8.9 tar czf /backup/uploads.tgz -C /uploads .
ls -l backup
tar tzvf backup/uploads.tgz-rw-r--r-- 1 wajdy 197609 152565 Oct 12 19:14 kiwi.sql
-rw-r--r-- 1 wajdy 197609 294 Oct 12 19:14 uploads.tgz
drwxr-xr-x 1001/root 0 2026-10-12 19:12 ./
drwxr-xr-x 1001/root 0 2026-10-12 19:12 ./attachments/
drwxr-xr-x 1001/root 0 2026-10-12 19:12 ./attachments/testplans_testplan/
drwxr-xr-x 1001/root 0 2026-10-12 19:12 ./attachments/testplans_testplan/1/
-rw-r--r-- 1001/root 80 2026-10-12 19:12 ./attachments/testplans_testplan/1/release-checklist.txtوالنسخة التي لم تختبر استعادتها ليست نسخة، كما شرحنا في النسخ الاحتياطي بقاعدة 3-2-1. لذلك حذفنا Containers قاعدة البيانات وKiwi مع ال Volumes تماماً، ثم شغلناها فارغة، واستعدنا القاعدة وحدها أولاً، ثم حاولنا تنزيل الملف المرفق بسكربت يسجل الدخول من نموذج الدخول:
# Log in through the web form, then download the attachment of plan 1
import re, requests
K = "https://kiwi.lab.test:8443"
s = requests.Session()
s.verify = "/certs/cert.pem"
page = s.get(f"{K}/accounts/login/")
token = re.search(r'name="csrfmiddlewaretoken" value="([^"]+)"', page.text).group(1)
s.post(f"{K}/accounts/login/", headers={"Referer": f"{K}/accounts/login/"},
data={"csrfmiddlewaretoken": token, "username": "qa-admin", "password": "Lab-Admin-2026!"})
r = s.get(f"{K}/uploads/attachments/testplans_testplan/1/release-checklist.txt")
print(r.status_code, r.content.decode("utf-8").splitlines()[0] if r.ok else "")docker compose rm -sf web db
docker volume rm lab-qa2-kiwi-db lab-qa2-kiwi-uploads
docker compose up -d db web
docker exec -i lab-qa2-kiwi-db mariadb -u kiwi -plab-kiwi-pass kiwi < backup/kiwi.sql
./client.sh admin.conf python /lab/show_run.py 1
./client.sh admin.conf python /lab/fetch_upload.pyTR-1: الدفع على 1.4.0
plan TP-1: اختبار الدفع قبل الإصدار 1.4 build: 1.4.0
TE-1 PASSED TC-1 السلة الفارغة لا تسمح بالدفع
TE-2 FAILED TC-2 الكوبون SAVE10 يخصم 10% قبل الضريبة
TE-3 BLOCKED TC-3 الدفع ببطاقة مدى في بيئة الاختبار
404التشغيل والنتائج والروابط رجعت كلها، أما الملف المرفق فرد ب 404، مع أن ال API ما زال يعرض سجله في list_attachments، وهذا ما سيراه فريقك إذا نسخت القاعدة ونسيت ال Volume. ثم استعدنا المرفقات:
docker run --rm -v lab-qa2-kiwi-uploads:/uploads -v "$PWD/backup:/backup:ro" \
mariadb:11.8.9 tar xzf /backup/uploads.tgz -C /uploads
./client.sh admin.conf python /lab/fetch_upload.py200 قائمة فحص الإصدار 1.4وعند الترقية إلى إصدار أحدث تغير البصمة في compose.yaml، ثم تنفذ ما تذكره وثائق التثبيت، وتتأكد أن showmigrations لا يعرض أي ترحيل Migration غير منفذ، وخذ نسخة احتياطية قبل ذلك دائماً:
docker compose pull web # after you change the digest in compose.yaml
docker compose up -d web
docker exec -it lab-qa2-kiwi-web /Kiwi/manage.py upgrade
docker exec -it lab-qa2-kiwi-web /Kiwi/manage.py showmigrationsإزالة التجربة
نحذف ال Containers وال Volumes والشبكة التي أنشأها ملف Compose، ثم ال Images التي بنيناها أو سحبناها، بالأسماء فقط:
docker compose down -v
docker rmi lab-qa2-kiwi-client:1 pub.kiwitcms.eu/kiwitcms/kiwi:latest gitea/gitea:1.27.3أخطاء شائعة
- استخدام الوسم latest في الإنتاج: كل docker compose pull قد يرقي Kiwi دون أن تنفذ upgrade، فثبت البصمة وغيرها بقرار.
- ترك التسجيل مفتوحاً: كل من يصل إلى الصفحة ينشئ حساباً في Tester ويستطيع حذف الحالات.
- حساب المدير في خط CI: استخدم حساباً في Tester فقط، أو مجموعة أضيق منه.
- FAILED بدل BLOCKED: حالة لم تنفذ لأن البيئة متوقفة ليست خطأ في المنتج، والخلط يفسد تقرير الإصدار.
- تغيير أسماء الاختبارات دون متابعة: كل اسم جديد حالة جديدة بتاريخ فارغ، والقديمة تبقى بآخر نتيجة.
- إضافتان للمشروع نفسه: junit.xml وpytest تكتبان العناوين بشكلين مختلفين، فتتضاعف الحالات.
- pytest حديث مع junit.xml-plugin 15.0: صيغة التوقيت تكسر الإضافة بعد أن تنشئ تشغيلاً ناقصاً، فاستخدم إضافة pytest أو عدل الملف قبل الإرسال.
- نسخ القاعدة دون uploads: المرفقات تبقى سجلات بلا ملفات.
- تعطيل التحقق من الشهادة: الحل جهة إصدار يثق بها العميل، وليس إيقاف التحقق.
الخلاصة
وصلنا لنهاية الموضوع، وأهم ما فيه:
- أداة إدارة الاختبارات تحفظ لكل حالة نتيجتها على كل بناء، ومن نفذها، والخطأ المرتبط بها، وهذا ما لا يفعله جدول Excel ولا سجل خط CI وحده.
- Kiwi TCMS مفتوحة المصدر بترخيص GPL-2.0، والنسخة المجانية بلا وسوم إصدارات، فثبت البصمة Digest.
- Gitea ليس من الأنظمة المدعومة، لكن كلاساً صغيراً يكفي لإنشاء المشكلات وجلب حالتها والتعليق عليها.
- النتائج الآلية تصل عبر الإضافات، والحالة تعرف من عنوانها، فاختر إضافة واحدة وعامل أسماء الاختبارات كجزء من العقد.
- الصلاحيات عبر مجموعات جديدة، وليس بتعديل Tester وAdministrator.
- النسخة الاحتياطية تعني القاعدة والمرفقات معاً، واختبرها بالاستعادة.
وفي مقالات قادمة بإذن الله سوف نتحدث عن اختبارات الواجهات E2E وعن اختبار ال API في خط CI.
دليلأنواع الاختبارات للمبتدئين: من Unit إلى E2E مع Playwrightما الذي يكشفه كل نوع من الاختبارات وما الذي يفوته، وكم اختباراً تكتب من كل نوع، وأين يعمل كل منها في خط CI.
دليلاختبارات E2E مع Playwright: من أول اختبار إلى CIكيف تكتب اختبارات متصفح ثابتة لموقع عربي مع Playwright، وتشخص الاختبار المتقلب، وتقسمها على أكثر من جهاز في خط CI.
دليلاختبار ال API مع Bruno وPostman في خط CIكيف تكتب اختبارات API تعيش في المستودع وتتحقق من العقد والصلاحيات، وتعمل في خط CI مع Bruno أو Postman.
دليلGitea Actions: تشغيل CI/CD وبناء Docker Images على سيرفركاجعل سيرفرك يختبر الكود ويبني Docker Images تلقائياً مع كل push، دون أن يخرج كودك أو أسرارك إلى خدمة CI خارجية.
دليلالنسخ الاحتياطي بقاعدة 3-2-1 واختبار الاستعادةكيف تحدد كم بيانات تقبل أن تخسر، وتبني نسخاً لا يستطيع المخترق حذفها، وتتأكد أنك تستطيع استعادتها فعلاً.سجل التحديثات
- أكتوبر 2026: كتابة المقال، وتنفيذ تجربة على Kiwi TCMS 16.6 (بالبصمة) مع MariaDB 11.8.9 وGitea 1.27.3، وtcms-api 15.0 وjunit.xml-plugin 15.0 وpytest-plugin 15.4 وpytest 9.1.1 على Python 3.13، والتحقق من التراخيص من مستودعات الأدوات.