لماذا يحتاج Cypress إلى task لهذا
يعمل اختبار Cypress داخل المتصفح، في النافذة نفسها التي تحتوي الصفحة قيد الاختبار. وهذا ما يجعل cy.get وcy.contains بهذا القدر من المباشرة، وهو أيضًا سبب عدم قدرة الاختبار على مجرد تكرار طلب على API عبر HTTP لمدة دقيقة: فطابور الأوامر ليس مكانًا لحلقة while فيها انتظار، وسلسلة من نداءات cy.request المعاد محاولتها صعبة القراءة وأصعب إيقافًا.
والطرق الثلاث المعتادة لاختبار نصف البريد من المسار يثبت كل منها شيئًا مختلفًا، وواحدة منها فقط تثبت الشيء الذي أطلقته فعلاً:
- استخدام stub لمُرسِل البريد
- يثبت أن
send()استُدعيت بالوسائط الصحيحة. ولا يقول شيئًا عن القالب، أو الرابط، أو المزوّد الذي رفض الرسالة. - ملتقط SMTP محلي (Mailpit وMailHog وsmtp4dev)
- يثبت أن رسالة سليمة التكوين خرجت من التطبيق. حاوية (container) إضافية في CI، ولا يحدث هنا أي شيء مما يحدث فقط على الإنترنت العام — بحث MX حقيقي، ومزوّد حقيقي، ومستلم حقيقي.
- صندوق بريد مؤقت حقيقي
- يثبت أن الرسالة خرجت من التطبيق، وعبرت الإنترنت، وقبِلها خادم بريد حقيقي، وتحمل رمزًا يعمل فعلاً. والتكلفة أن على الاختبار الانتظار بالطريقة الصحيحة، والمكان الصحيح للانتظار في Cypress هو الـtask.
وAPI الذي يستدعيه الـtask ثلاث نقاط نهاية بلا مفتاح — والمرجع قصير. والنسخة المستقلة عن المُشغِّل من هذا الانضباط موجودة في اختبار مسار تحقق من طرف إلى طرف؛ ونسخة Playwright، بـfixture بدل task، موجودة في دليل Playwright.
الـtask: حلقة استطلاع على جانب Node
كل ما يجب أن ينتظر يعيش هنا، داخل setupNodeEvents. وهو Node عادي: fetch، ومهلة زمنية، وقراءة واحدة في الثانية، وفرع لـ429 ينتظر بدل أن يفشل. ولا يرى الاختبار شيئًا من ذلك أبدًا.
import { defineConfig } from 'cypress';
const API = 'https://grabmail.io/api/v1';
const sleep = (ms: number) => new Promise(r => setTimeout(r, ms));
type Args = { address: string; subjectContains?: string; fromContains?: string; timeoutMs?: number };
export default defineConfig({
e2e: {
baseUrl: 'http://localhost:3000',
taskTimeout: 90_000, // above the mail deadline below, always
setupNodeEvents(on) {
on('task', {
/** Poll a mailbox until a matching message arrives, or the deadline passes. */
async waitForMail({ address, subjectContains, fromContains, timeoutMs = 60_000 }: Args) {
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
const res = await fetch(`${API}/mailbox?address=${encodeURIComponent(address)}`);
if (res.status === 429) { // slow down, do not fail
await sleep(Number(res.headers.get('retry-after') ?? 1) * 1000);
continue;
}
if (!res.ok) throw new Error(`GET /mailbox answered ${res.status} for ${address}`);
const { messages } = (await res.json()) as { messages: { id: string; from: string; subject: string }[] };
const hit = messages.find(m =>
(!subjectContains || m.subject.toLowerCase().includes(subjectContains.toLowerCase())) &&
(!fromContains || m.from.toLowerCase().includes(fromContains.toLowerCase())));
if (hit) {
const full = await fetch(`${API}/message/${hit.id}?mailbox=${encodeURIComponent(address)}`);
if (!full.ok) throw new Error(`GET /message answered ${full.status}`);
return full.json(); // the whole message, both parts
}
await sleep(1000); // one read a second, never throttled
}
return null; // "not yet" is an answer, not an error
},
});
},
},
});هناك قراران في ذلك الملف يستحقان الذكر. الأول أن الـtask يُعيد الرسالة كاملة، لا الملخص، لأن الشيء التالي الذي يريده كل اختبار هو المتن، ونداء ثانٍ للـtask من أجله مجرد ضجيج. والثاني أنه يُعيد null عند بلوغ المهلة بدل إطلاق استثناء: فـ«لا رسالة بعد» إجابة مشروعة يمكن لـtask تقديمها، والأمر أدناه هو المكان الذي تتحول فيه إلى فشل برسالة مفيدة.
أمران مخصصان ودالتا استخراج
الأوامر رفيعة (thin) عمدًا. فـfreshAddress يبتكر صندوق بريد؛ وwaitForMail يستدعي الـtask بمهلة أعلى من المهلة النهائية بفارق مريح، ويتحقق (assert) من الإجابة. أما دوال الاستخراج فهي دوال عادية، لأنها مجرد عمل نصي بسيط، وأمر Cypress لن يفعل شيئًا سوى تصعيب اختبارها بمفردها (unit test).
export type Message = {
id: string; from: string; to: string; subject: string; date: string;
text: string | null; html: string | null;
};
type WaitOpts = { subjectContains?: string; fromContains?: string; timeoutMs?: number };
declare global {
namespace Cypress {
interface Chainable {
/** A mailbox nothing else in this run, or any previous run, is using. */
freshAddress(prefix?: string): Chainable<string>;
/** Block until a matching message arrives. Fails the test at the deadline. */
waitForMail(address: string, opts?: WaitOpts): Chainable<Message>;
}
}
}
Cypress.Commands.add('freshAddress', (prefix = 'cy') =>
cy.wrap(`${prefix}-${Math.random().toString(36).slice(2, 10)}@grabmail.io`, { log: false }));
Cypress.Commands.add('waitForMail', (address, opts = {}) =>
cy.task<Message | null>('waitForMail', { address, ...opts }, { timeout: (opts.timeoutMs ?? 60_000) + 10_000 })
.then(m => {
expect(m, `a message for ${address}`).not.to.be.null;
return cy.wrap(m as Message, { log: false });
}));
/** The whole body, both parts, with the entities a link may carry undone. */
const bodyOf = (m: Message) => `${m.text ?? ''}\n${m.html ?? ''}`.replace(/&/g, '&');
/** Anchored on your own wording, so a reworded template fails loudly. */
export function codeFrom(m: Message, pattern = /code is\s*([0-9]{6})/i): string {
const hit = bodyOf(m).match(pattern);
if (!hit) throw new Error(`no confirmation code in "${m.subject}"`);
return hit[1];
}
/** The link whose path contains a fragment you know — never "the first URL". */
export function linkFrom(m: Message, pathContains: string): string {
const hit = bodyOf(m).match(new RegExp(`https?://[^\\s"'<>]*${pathContains}[^\\s"'<>]*`));
if (!hit) throw new Error(`no link containing "${pathContains}" in "${m.subject}"`);
return hit[0];
}لاحظ timeout الخاص بالأمر نفسه: وهو مهلة الـtask زائد عشر ثوانٍ، بحيث يحصل الـtask دائمًا على فرصة تقديم إجابته. وبدونه، تتسابق مهلة الـtask الافتراضية في Cypress البالغة ستين ثانية مع مهلة البريد البالغة ستين ثانية أيضًا، وتفوز بفارق أجزاء من الثانية، فيُلقي الفشل باللوم على الـtask.
ثلاثة اختبارات، من طرف إلى طرف
مع وجود الـtask والأوامر جاهزة، يُقرأ كل اختبار كأنه الميزة التي يختبرها. فالانتظار والتحليل موجودان في مكان آخر، وهذا هو صُلب الغاية من وضعهما هناك.
التسجيل برمز التحقق
import { codeFrom } from '../support/commands';
describe('sign-up', () => {
it('confirms the address with the emailed code', () => {
cy.freshAddress().then(address => {
cy.visit('/signup');
cy.get('input[name="email"]').type(address);
cy.get('input[name="password"]').type('correct-horse-battery-staple');
cy.contains('button', 'Create account').click();
cy.contains('Check your inbox').should('be.visible');
cy.waitForMail(address, { subjectContains: 'confirm' }).then(message => {
cy.get('input[name="code"]').type(codeFrom(message));
cy.contains('button', 'Confirm').click();
cy.contains('h1', 'Welcome').should('be.visible');
});
});
});
});تسجيل دخول يطلب رمزًا لمرة واحدة يُرسَل بالبريد
يجب أن يكون المستخدم موجودًا أولاً، وهذه مهمة يتولاها منفذ الاختبار الخاص بتطبيقك — نقطة نهاية داخلية، أو fixture لقاعدة البيانات، أو CLI — يُوصَل إليها بـcy.request، لا عبر المتصفح.
import { codeFrom } from '../support/commands';
describe('login with an emailed one-time code', () => {
it('asks for the code and accepts it', () => {
cy.freshAddress().then(address => {
// Your application's own test seam: an internal endpoint, a DB fixture, a CLI.
cy.request('POST', '/internal/test/users', { email: address, password: 'hunter2hunter2', otpByEmail: true });
cy.visit('/login');
cy.get('input[name="email"]').type(address);
cy.get('input[name="password"]').type('hunter2hunter2');
cy.contains('button', 'Log in').click();
cy.contains('Enter the code we emailed you').should('be.visible');
cy.waitForMail(address, { subjectContains: 'code' }).then(message => {
cy.get('input[name="otp"]').type(codeFrom(message, /code is\s*([0-9]{6})/i));
cy.contains('button', 'Continue').click();
cy.url().should('include', '/dashboard');
});
});
});
});إعادة تعيين كلمة المرور، ثم تسجيل الدخول بكلمة المرور الجديدة
يُتبَع رابط إعادة التعيين بـcy.visit عادي حين يشير إلى المصدر (origin) نفسه لـbaseUrl. وإذا كان تطبيقك يرسل المستخدمين إلى مصدر آخر لصفحة إعادة التعيين — نطاق فرعي للمصادقة مثلاً — فلُفَّ الخطوات على تلك الصفحة بـcy.origin()؛ ويبقى استخراج الرابط دون تغيير.
import { linkFrom } from '../support/commands';
describe('password reset', () => {
it('changes the password through the emailed link', () => {
cy.freshAddress().then(address => {
cy.request('POST', '/internal/test/users', { email: address, password: 'old-password-1' });
cy.visit('/forgot-password');
cy.get('input[name="email"]').type(address);
cy.contains('button', 'Send reset link').click();
cy.waitForMail(address, { subjectContains: 'reset' }).then(message => {
cy.visit(linkFrom(message, '/reset/')); // same origin as baseUrl: a plain visit
cy.get('input[name="password"]').type('new-password-2');
cy.contains('button', 'Change password').click();
});
cy.visit('/login');
cy.get('input[name="email"]').type(address);
cy.get('input[name="password"]').type('new-password-2');
cy.contains('button', 'Log in').click();
cy.url().should('include', '/dashboard');
});
});
});جعله يصمد في CI
كل ما سبق يعمل على حاسوب محمول. وهذه هي الأشياء التي لا تتعطل إلا حين يعمل عشرين مرة في اليوم على جهاز شخص آخر.
| العرض | السبب | الحل |
|---|---|---|
| ينجح محليًا، ويفشل في CI | لا يستطيع المُشغِّل الوصول إلى الإنترنت العام، أو تُصفَّى حركة الاتصال الصادرة. | اسمح بـgrabmail.io عبر HTTPS من جانب Node. لا شيء آخر — لا منفذ SMTP، ولا اتصال وارد. |
| «cy.task timed out» دون أي كلمة عن البريد | taskTimeout (60 ثانية افتراضيًا) أقل من مهلة البريد. | اضبط taskTimeout فوق المهلة في الإعداد، ومرِّر مهلة كل نداء التي يحسبها الأمر بالفعل. |
| يفشل في المرة الأولى، وينجح عند إعادة المحاولة | مُرسِلك يضع البريد في طابور، والمهلة أقصر من زمن الطابور. | ارفع المهلة قبل لمس أي شيء آخر. ستون ثانية سقف معقول لبريد معاملاتي. |
429 على شكل دفعات | عدة اختبارات تستطلع عنوانًا واحدًا، أو تجاوز المُشغِّل 1200 طلب في الدقيقة. | عنوان واحد لكل اختبار. وسقف العميل عشرون صندوق بريد تُستطلَع كل واحدة مرة في الثانية. |
| بناء ناجح، وميزة معطَّلة | عنوان أُعيد استخدامه قدَّم رسالة قديمة. | استدعاء freshAddress في متن كل اختبار. هذا هو الأمر المهم. |
| يعمل لمدة أسبوع، ثم لا يعمل أبدًا | fixture خزَّن معرِّف رسالة مؤقتًا؛ وكل شيء هنا يُحذف بعد 5 يومًا. | يجب أن تُطلِق الاختبارات بريدها الخاص في كل تشغيلة. لا شيء يصمد 5 يومًا. |
لا يوجد سر يجب تخزينه: فالنطاقات العامة لا تحتاج مفتاحًا، ولا حسابًا، ولا ترويسة. وإن احتاج خط أنابيبك إلى بيانات اعتماد لتشغيل هذه الاختبارات، فهناك شيء أُسيء فهمه. وسير عمل GitHub Actions الذي يشغِّل مجموعة اختبارات كهذه، بعد حسم مسألة الاتصال الصادر، موجود في دليل CI.
إذا كان تطبيقك يرفض النطاقات المؤقتة
تتحقق بعض نماذج التسجيل من العنوان مقابل القوائم العامة للنطاقات المؤقتة وترفض grabmail.io فور رؤيته. وهذه ميزة في تطبيقك، لا عيب في الاختبار — والحل ليس إضعاف الفحص في بيئة الاختبار. بل وجِّه نطاقًا تملكه إلى هذه الخدمة بدلاً من ذلك: سجل MX واحد، بلا حساب، ويصبح كل عنوان عليه صندوق بريد يستطيع الـtask نفسه قراءته بتغيير ثابت واحد.
وتحويل نطاق إلى صندوق وارد catch-all هو الإعداد؛ وحسابات اختبار غير محدودة على نطاق واحد هو شكل ذلك داخل مجموعة اختبارات.
قبل أن تعتبره منتهيًا
- عنوان مختلف لكل اختبار، مُبتكَر داخل متن الاختبار — لا ثابت أبدًا.
- الانتظار داخل task بمهلة زمنية فعلية؛
nullعند بلوغ المهلة، لاundefinedأبدًا. taskTimeoutومهلة الأمر كلاهما فوق مهلة البريد.- التعامل مع
429بالانتظار حسبRetry-After، لا بالفشل. - مطابقة الرمز أو الرابط بصياغتك أنت، لا بنمط مجرد.
- مرشِّح على الموضوع أو المرسِل، بحيث تفوز الرسالة الصحيحة حين تصل رسالتان.
- بلا أي تحقق على سرعة وصول البريد — فقط أنه وصل.
هذا هو الانضباط كله. وقواعد الاستخراج وحدها، لأي مُشغِّل، موجودة في رموز OTP في الاختبارات الآلية.
أسئلة
هل يمكنني استخدام cy.request في حلقة بدلاً من task؟
يمكنك ذلك: فـcy.request يعمل أيضًا من جانب Node، فلا يخضع لـCORS، وتعمل دالة تكرارية تعيد الطلب حتى تطابق أو مهلة. لكنه أصعب قراءة وأصعب إيقافًا من task فيه حلقة while، ويُبقي الـtask الاختبار خاليًا من منطق إعادة المحاولة.
هل أحتاج إلى مفتاح API أو متغير بيئة في Cypress؟
لا. فالنطاقات العامة لا تحتاج مفتاحًا، ولا حسابًا، ولا ترويسة، فلا يوجد ما تضعه في cypress.env.json أو في أسرار CI. ولا تستخدم رمز bearer إلا مجموعة النطاقات المدفوعة التي تبقى بعيدة عن قوائم حظر البريد المؤقت، وهذا منتج منفصل.
هل يعمل هذا مع إعادات محاولة الاختبار والتوازي في Cypress؟
نعم، تحديدًا لأن العنوان يُبتكر داخل متن الاختبار: تحصل كل إعادة محاولة وكل جهاز متوازٍ على صندوق بريده الخاص. وسقف كل عميل البالغ 1200 طلب في الدقيقة هو عشرون صندوق بريد تُستطلَع كل واحدة مرة في الثانية، وهو ما لا تقترب منه تشغيلة Cypress أبدًا.
ماذا لو وصل البريد قبل أن يبدأ الـtask الاستطلاع؟
لا شيء يتغير. يُعيده أول استطلاع. فصندوق البريد يحتفظ بما يصل لمدة 5 يومًا سواء أكان أحد يقرأ أم لا، فالرسالة التي تصل أثناء النقر تكون ببساطة موجودة عند الطلب التالي.
هل صندوق البريد خاص أثناء استخدام الاختبار له؟
لا. يستطيع أي شخص يعرف العنوان قراءته، سواء على نطاق عام أو على نطاقك الخاص. وبالنسبة لعنوان عشوائي يعيش أحد عشر ثانية ويحمل رمزًا عابرًا واحدًا، هذا أمر لا صلة له بالموضوع؛ أما بالنسبة لبيئة staging ترسل بريد عملاء حقيقيين فهذا أمر يستبعد استخدامها هنا — لا توجِّه واحدة إلى هنا.
كيف أنظِّف بعد ذلك؟
اختياريًا، باستدعاء DELETE على الرسالة من الـtask، وهو idempotent. فكل شيء تنتهي صلاحيته بعد 5 يومًا على أي حال، فتشغيلة تتجاوز التنظيف لا تكلِّف شيئًا — والحذف لا يفعل أكثر من أنه يجعل قراءة الفشل التالي أسهل.


