آزمون و دیباگ چت؛ دو کاربر، قطع اتصال و ورودی نامعتبر
در این درس ۶ بخش
اکنون فایلهای اصلی سرور و رابط را داریم. پیش از اجرای عمومی، باید رفتارشان را با انتظار مشخص مقایسه کنیم. باز شدن صفحه نشان میدهد HTTP بخشی از کار را انجام داده است؛ هنوز نمیدانیم دو اتصال پیام یکسان میگیرند، ورودی نامعتبر رد میشود یا منابع پس از توقف آزاد میشوند. در این درس، آزمون خودکار، بررسی دو تب و خواندن خطای شبکه را کنار هم انجام میدهیم. همهٔ دادهها ساختگی و اتصالها محلیاند. آزمون فشار روی سرویس دیگران یا حذف محدودیت برای سبز شدن نتیجه، بخشی از این تمرین نیست.
سه روش بررسی، سه نوع شاهد
آزمون واحد، تابع یا سیاست محدودی مثل نگهداری پنجاه پیام را بررسی میکند. آزمون یکپارچه، سرور واقعی و کلاینت WebSocket واقعی را در یک فرایند آزمایش به هم متصل میکند. بررسی دستی مرورگر، نتیجهٔ قابل مشاهده و رفتار دکمهها را میسنجد. هیچکدام جای دیگری را کامل نمیگیرد: تست سرور نمیگوید فوکوس فرم درست است و دیدن صفحه نمیگوید بستهٔ بیش از سقف رد میشود. در گزارش، برای هر ادعا نام بررسی مربوط را بنویسید. اصطلاح «تست شد» بدون ورودی، انتظار و شرایط اجرا، به نفر بعدی برای بازتولید کمک چندانی نمیکند.
بستهٔ همراه شامل package-lock.json است و وابستگی ws نسخهٔ دقیق دارد. در ریشهٔ نسخهٔ کامل پروژه و با Node.js ۲۴، ابتدا npm ci و سپس npm test و npm run check را اجرا کنید. در PowerShell، اگر سیاست اجرا جلوی npm.ps1 را گرفت، از npm.cmd ci و npm.cmd test استفاده کنید؛ نیاز نیست سیاست کل ویندوز را برای این تمرین تغییر دهید. npm ci وابستگی را مطابق lock نصب میکند؛ نصب دستی آخرین ws کنار یک lock قدیمی، همان شرایط آزمون پروژه را بازتولید نمیکند. خطای دانلود وابستگی را نیز از شکست منطق برنامه جدا ثبت کنید.
# In the extracted project directory, with Node.js 24+
node --version
npm ci
npm test
npm run checkآزمونها واقعاً چه میکنند؟
فایل کامل app.test.mjs زیر را در ریشهٔ پروژه قرار دهید. fixture یک سرور روی پورت آزاد محلی میسازد، منتظر listening میماند و در finally آن را میبندد. بنابراین لازم نیست سرور معمولی روی پورت ۳۰۰۱ را برای اجرای این مجموعه روشن کنید. کلاینتهای آزمون با کتابخانهٔ ws اتصال واقعی ایجاد میکنند و برای هر انتظار، مهلت محدود دارند. اگر ACK نرسد، آزمون معلق ابدی نمیماند. اتصالهای آزمون، Origin قراردادی مشخص میفرستند؛ این توانایی کلاینت Node بار دیگر نشان میدهد Origin، احراز هویت کاربر نیست.
import test from 'node:test';
import assert from 'node:assert/strict';
import { once } from 'node:events';
import { randomUUID, webcrypto } from 'node:crypto';
import { readFile } from 'node:fs/promises';
import vm from 'node:vm';
import WebSocket from 'ws';
import { createChatServer } from './app.mjs';
import { createRoom } from './room.mjs';
import { parsePacket, LIMITS } from './protocol.mjs';
import { sendBounded, heartbeat } from './connection.mjs';
const ORIGIN = 'http://127.0.0.1:3001';
const pause = ms => new Promise(resolve => setTimeout(resolve, ms));
async function fixture(run, options = {}) {
const app = await createChatServer({ publicOrigin: ORIGIN, ...options });
app.server.listen(0, '127.0.0.1');
await once(app.server, 'listening');
const base = `http://127.0.0.1:${app.server.address().port}`;
try { await run({ ...app, base, url: base.replace('http:', 'ws:') + '/chat' }); }
finally { await app.close(); }
}
async function client(url, options = {}) {
const socket = new WebSocket(url, { origin: ORIGIN, ...options });
const packets = [];
const waiters = new Set();
socket.on('error', () => {});
socket.on('message', data => {
const packet = JSON.parse(data.toString());
packets.push(packet);
for (const waiter of waiters) {
if (waiter.match(packet)) { clearTimeout(waiter.timer); waiters.delete(waiter); waiter.resolve(packet); break; }
}
});
await once(socket, 'open');
function next(type, predicate = () => true) {
return new Promise((resolve, reject) => {
const waiter = { match: p => p.type === type && predicate(p), resolve };
waiter.timer = setTimeout(() => { waiters.delete(waiter); reject(new Error(`No ${type} packet`)); }, 1500);
waiters.add(waiter);
});
}
async function join(name = 'سارا') {
const waiting = next('history');
socket.send(JSON.stringify({ type: 'join', name }));
return waiting;
}
async function chat(text, clientMessageId = randomUUID()) {
const waiting = next('ack', p => p.clientMessageId === clientMessageId);
socket.send(JSON.stringify({ type: 'chat', clientMessageId, text }));
return waiting;
}
return { socket, packets, next, join, chat };
}
async function rejected(url, options) {
return new Promise((resolve, reject) => {
const socket = new WebSocket(url, options);
socket.on('error', () => {});
socket.on('open', () => { socket.terminate(); reject(new Error('Unexpected upgrade')); });
socket.on('unexpected-response', (req, res) => { const code = res.statusCode; res.resume(); req.destroy(); resolve(code); });
});
}
test('HTTP page and exact assets are real, UTF-8 and self-hosted', async () => {
await fixture(async ({ base }) => {
const page = await fetch(base);
assert.equal(page.status, 200);
assert.match(await page.text(), /گفتوگوی <span>کوچک/);
for (const path of ['/app.js', '/style.css']) assert.equal((await fetch(base + path)).status, 200);
assert.equal((await fetch(base + '/server.mjs')).status, 404);
assert.equal((await fetch(base + '/.env')).status, 404);
const head = await fetch(base, { method: 'HEAD' });
assert.equal(head.status, 200); assert.equal(await head.text(), '');
const post = await fetch(base, { method: 'POST' });
assert.equal(post.status, 405); assert.equal(post.headers.get('allow'), 'GET, HEAD');
});
});
test('wrong, missing and null browser origins are rejected', async () => {
await fixture(async ({ url }) => {
assert.equal(await rejected(url, { origin: 'https://wrong.example' }), 403);
assert.equal(await rejected(url, {}), 403);
assert.equal(await rejected(url, { origin: 'null' }), 403);
});
});
test('only the exact /chat upgrade path is accepted', async () => {
await fixture(async ({ url }) => assert.equal(await rejected(url.replace('/chat', '/ws'), { origin: ORIGIN }), 404));
});
test('two real clients receive the same server-issued message and sender ACK', async () => {
await fixture(async ({ url, base }) => {
const a = await client(url); const b = await client(url);
const first = await a.join('سارا'); const second = await b.join('آرمان');
assert.equal(first.type, 'history'); assert.equal(first.latestSeq, 0); assert.deepEqual(first.messages, []);
assert.equal(first.serverId, second.serverId); assert.notEqual(first.clientId, second.clientId);
const broadcast = b.next('message');
const ack = await a.chat('سلام از پنجرهٔ اول');
const received = (await broadcast).message;
assert.equal(received.id, ack.messageId); assert.equal(received.seq, ack.seq);
assert.equal(received.text, 'سلام از پنجرهٔ اول'); assert.equal(received.name, 'سارا');
assert.ok(Number.isFinite(Date.parse(received.at)));
assert.deepEqual(await (await fetch(base + '/api/status')).json(), {
ok: true, service: 'darsavand-chat', online: 2, historySize: 1
});
});
});
test('chat before join gives a structured rejection', async () => {
await fixture(async ({ url }) => {
const a = await client(url); const id = randomUUID(); const error = a.next('error');
a.socket.send(JSON.stringify({ type: 'chat', clientMessageId: id, text: 'سلام' }));
const packet = await error; assert.equal(packet.code, 'JOIN_REQUIRED'); assert.equal(packet.clientMessageId, id);
});
});
test('malformed JSON and extra fields are rejected without accepting history', async () => {
await fixture(async ({ url, getStats }) => {
const a = await client(url);
let waiting = a.next('error'); a.socket.send('{broken'); assert.equal((await waiting).code, 'INVALID_JSON');
waiting = a.next('error'); a.socket.send(JSON.stringify({ type: 'join', name: 'سارا', admin: true }));
assert.equal((await waiting).code, 'INVALID_PACKET'); assert.equal(getStats().historySize, 0);
});
});
test('a joined connection cannot silently change its display name', async () => {
await fixture(async ({ url }) => {
const a = await client(url); await a.join(); const waiting = a.next('error');
a.socket.send(JSON.stringify({ type: 'join', name: 'دیگری' }));
assert.equal((await waiting).code, 'ALREADY_JOINED');
});
});
test('Unicode normalization and code-point/UTF-8 bounds are explicit', () => {
assert.equal(parsePacket(JSON.stringify({ type: 'join', name: ' e\u0301 ' })).name, 'é');
assert.equal([...parsePacket(JSON.stringify({ type: 'join', name: '😀'.repeat(24) })).name].length, 24);
assert.throws(() => parsePacket(JSON.stringify({ type: 'join', name: '😀'.repeat(25) })), /INVALID_NAME/);
assert.equal([...parsePacket(JSON.stringify({ type: 'chat', clientMessageId: randomUUID(), text: '😀'.repeat(500) })).text].length, 500);
assert.throws(() => parsePacket(JSON.stringify({ type: 'chat', clientMessageId: randomUUID(), text: '😀'.repeat(501) })), /INVALID_TEXT/);
assert.throws(() => parsePacket(JSON.stringify({ type: 'chat', clientMessageId: randomUUID(), text: '\ud800' })), /INVALID_TEXT/);
assert.throws(() => parsePacket(JSON.stringify({ type: 'chat', clientMessageId: 'fake', text: 'سلام' })), /INVALID_PACKET/);
});
test('invalid and binary messages never become chat messages', async () => {
await fixture(async ({ url, getStats }) => {
const a = await client(url); await a.join();
let waiting = a.next('error');
a.socket.send(JSON.stringify({ type: 'chat', clientMessageId: randomUUID(), text: ' ' }));
assert.equal((await waiting).code, 'INVALID_TEXT');
waiting = a.next('error'); const closed = once(a.socket, 'close');
a.socket.send(Buffer.from('binary'));
assert.equal((await waiting).code, 'TEXT_ONLY'); assert.equal((await closed)[0], 1003);
assert.equal(getStats().historySize, 0);
});
});
test('the payload limit covers the aggregate of fragmented messages', async () => {
await fixture(async ({ url }) => {
const a = await client(url); const closed = once(a.socket, 'close');
a.socket.send('x'.repeat(3000), { fin: false });
a.socket.send('x'.repeat(2000), { fin: true });
assert.equal((await closed)[0], 1009);
});
});
test('duplicate request ID returns original ACK; different text rejects', async () => {
await fixture(async ({ url, getStats }) => {
const a = await client(url); await a.join(); const id = randomUUID();
const first = await a.chat('متن نخست', id); const duplicate = await a.chat('متن نخست', id);
assert.equal(first.messageId, duplicate.messageId); assert.equal(first.seq, duplicate.seq);
assert.equal(getStats().historySize, 1);
const waiting = a.next('error'); a.socket.send(JSON.stringify({ type: 'chat', clientMessageId: id, text: 'متن دیگر' }));
assert.equal((await waiting).code, 'DUPLICATE_ID'); assert.equal(getStats().historySize, 1);
assert.equal(a.packets.filter(p => p.type === 'message').length, 1);
});
});
test('rate bound counts join and invalid packets; 11th closes with 1008', async () => {
await fixture(async ({ url, getStats }) => {
const a = await client(url); await a.join();
for (let i = 0; i < 9; i++) {
const waiting = a.next('error'); a.socket.send('{}'); assert.equal((await waiting).code, 'INVALID_PACKET');
}
const waiting = a.next('error', p => p.code === 'RATE_LIMIT'); const closed = once(a.socket, 'close');
a.socket.send('{}'); assert.equal((await waiting).code, 'RATE_LIMIT'); assert.equal((await closed)[0], 1008);
assert.equal(getStats().historySize, 0);
});
});
test('room history is bounded to 50, sequenced, and snapshot copies cannot mutate it', () => {
const room = createRoom();
for (let i = 0; i < 60; i++) room.accept('سارا', `پیام ${i + 1}`);
const state = room.snapshot(); assert.equal(state.messages.length, 50); assert.equal(state.latestSeq, 60);
assert.equal(state.messages[0].seq, 11); assert.equal(state.messages.at(-1).seq, 60);
state.messages[0].text = 'changed'; assert.notEqual(room.snapshot().messages[0].text, 'changed');
assert.equal(new Set(state.messages.map(m => m.id)).size, 50);
});
test('real new connection receives an ordered 50-message snapshot; join adds no chat', async () => {
await fixture(async ({ url }) => {
let previousServerId;
for (let group = 0; group < 10; group++) {
const a = await client(url); const history = await a.join(`کاربر ${group}`); previousServerId = history.serverId;
for (let i = 0; i < 6; i++) await a.chat(`پیام ${group * 6 + i + 1}`);
const closed = once(a.socket, 'close'); a.socket.close(1000); await closed;
}
const b = await client(url); const history = await b.join('ناظر');
assert.equal(history.serverId, previousServerId); assert.equal(history.messages.length, 50);
assert.equal(history.messages[0].seq, 11); assert.equal(history.latestSeq, 60);
assert.equal(history.messages.at(-1).seq, 60);
});
});
test('a new server process model has a new identity and empty history', () => {
const a = createRoom(); a.accept('سارا', 'سلام'); const b = createRoom();
assert.notEqual(a.snapshot().serverId, b.snapshot().serverId);
assert.equal(b.snapshot().latestSeq, 0); assert.deepEqual(b.snapshot().messages, []);
});
test('unjoined connections are closed at the join deadline', async () => {
await fixture(async ({ url, getStats }) => {
const a = await client(url); assert.equal((await once(a.socket, 'close'))[0], 1008);
assert.equal(getStats().online, 0);
}, { joinTimeoutMs: 50 });
});
test('responsive clients answer real Ping/Pong and stay connected', async () => {
await fixture(async ({ url, getStats }) => {
const a = await client(url); await a.join(); let pings = 0;
a.socket.on('ping', () => pings++); await pause(150);
assert.ok(pings >= 2); assert.equal(a.socket.readyState, WebSocket.OPEN); assert.equal(getStats().online, 1);
}, { heartbeatMs: 40 });
});
test('a real client that disables automatic Pong is terminated', async () => {
await fixture(async ({ url, getStats }) => {
const a = await client(url, { autoPong: false }); await a.join();
assert.equal((await once(a.socket, 'close'))[0], 1006); assert.equal(getStats().online, 0);
}, { heartbeatMs: 40 });
});
test('controlled slow-consumer policy terminates instead of growing the queue', () => {
let terminated = 0; let sent = 0;
const slow = { readyState: WebSocket.OPEN, bufferedAmount: LIMITS.maxBufferedBytes, terminate() { terminated++; }, send() { sent++; } };
assert.equal(sendBounded(slow, { type: 'presence', online: 1 }), false);
assert.equal(terminated, 1); assert.equal(sent, 0);
const healthy = { readyState: WebSocket.OPEN, bufferedAmount: 0, terminate() { terminated++; }, send(text, callback) { sent++; assert.equal(JSON.parse(text).online, 1); callback(); } };
assert.equal(sendBounded(healthy, { type: 'presence', online: 1 }), true); assert.equal(sent, 1);
});
test('heartbeat controlled policy sets liveness then terminates a missing-Pong peer', () => {
let pinged = 0; let terminated = 0;
const socket = { readyState: WebSocket.OPEN, isAlive: true, ping() { pinged++; }, terminate() { terminated++; } };
const wss = { clients: new Set([socket]) };
heartbeat(wss); assert.equal(socket.isAlive, false); assert.equal(pinged, 1);
heartbeat(wss); assert.equal(terminated, 1);
});
test('capacity rejects the 21st concurrent client without weakening limits', async () => {
await fixture(async ({ url }) => {
const clients = [];
for (let i = 0; i < 20; i++) clients.push(await client(url));
assert.equal(await rejected(url, { origin: ORIGIN }), 503);
for (const a of clients) a.socket.close(1000);
});
});
test('shutdown closes real clients with 1001 and cleans connections', async () => {
await fixture(async ({ url, close, wss, getStats }) => {
const a = await client(url); await a.join(); const closed = once(a.socket, 'close');
await close(); assert.equal((await closed)[0], 1001); assert.equal(wss.clients.size, 0); assert.equal(getStats().online, 0);
await close();
});
});
test('browser request IDs use secure UUID fallback on ordinary HTTP origins', async () => {
const source = await readFile(new URL('./public/app.js', import.meta.url), 'utf8');
const start = source.indexOf('function createRequestId()');
const end = source.indexOf('\nfunction setStatus', start);
assert.ok(start >= 0 && end > start);
const code = source.slice(start, end) + '\ncreateRequestId();';
const context = { crypto: { getRandomValues: bytes => webcrypto.getRandomValues(bytes) }, Uint8Array };
const ids = Array.from({ length: 100 }, () => vm.runInNewContext(code, context));
assert.equal(new Set(ids).size, 100);
for (const id of ids) assert.match(id, /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/);
const native = vm.runInNewContext(code, { crypto: { randomUUID: () => 'native-uuid' } });
assert.equal(native, 'native-uuid');
assert.throws(() => vm.runInNewContext(code, {}), /Secure random unavailable/);
});انتظار مجموعهٔ همراه، عبور همهٔ آزمونها و نبود نتیجهٔ fail است؛ مدت اجرا روی دستگاه شما میتواند متفاوت باشد. موارد شامل فایلهای HTTP، رد مسیر اشتباه و Origin نامعتبر، پخش به دو کلاینت، ACK، ارسال پیش از join، بستهٔ نامعتبر، تکرار شناسه، محدودیت نرخ و اندازه، تاریخچه و توقفاند. آزمون کندی خروجی با socket کنترلشده سیاست صف را بررسی میکند؛ آزمون Ping/Pong و حذف کلاینت بیپاسخ اتصال واقعی دارد. این تفاوت را در نام تستها ببینید. عبور مجموعه، گواه ظرفیت نامحدود یا ممیزی کامل امنیت یک محصول عمومی نیست.
در بررسی تاریخچه، چند اتصال دادهٔ لازم را میسازند تا محدودیت نرخ یک اتصال با آزمون پنجاه پیام مخلوط نشود. در بررسی اندازه، پیام قطعهقطعه نیز در مجموع سنجیده میشود؛ محدود کردن هر فریم بهتنهایی کافی نیست. در #validation-contracts به همین مرز دقیق توجه کنید. برای خواندن فایل طولانی، ابتدا نام تستها را مرور کنید و سپس فقط fixture و تستی را که شکست خورده بخوانید. تعویض همهٔ عددهای محدودیت با عدد بزرگ، علت شکست را توضیح نمیدهد و ممکن است رفتار محافظتی مورد نیاز را حذف کند.
بررسی دستی با دو تب مرورگر
در ترمینال دیگری npm start را اجرا و نشانی http://127.0.0.1:3001 را باز کنید. همان نشانی را در تب دوم باز کنید؛ در این تمرین localhost را با 127.0.0.1 مخلوط نکنید، چون Origin متفاوت میشود. نام ساختگی «آوند» و «نمونه» را وارد کنید. پس از عضویت هر دو، عدد آنلاین باید دو اتصال را نشان دهد. از اولی «سلام از تب اول» بفرستید و بررسی کنید در هر دو فهرست فقط یک نسخهٔ قطعی میبینید. از دومی نیز پاسخ دهید و ترتیب را در هر دو صفحه مقایسه کنید.
در ابزار توسعهٔ مرورگر، بخش Network و فیلتر WS را باز کنید و اتصال /chat را انتخاب کنید. آغاز موفق با پاسخ 101 دیده میشود؛ دریافت HTML با 200 جای آن نیست. در پیامها ابتدا join و history و سپس message و ack را پیدا کنید. شناسهٔ message.id باید با messageId پاسخ فرستنده برابر باشد. زمان نمایشی میتواند فارسی باشد، ولی بستهٔ سرور زمان استاندارد دارد. #debugging-console را برای کار با ابزار مرورگر مرور کنید؛ چاپ همهٔ بستهها در کنسول، بهویژه با دادهٔ واقعی، پیشفرض مناسبی برای نسخهٔ عمومی نیست.
فقط یکی از تبها را ببندید و در دیگری کاهش حضور را ببینید. سپس سرور خود را با Ctrl+C متوقف کنید و وضعیت رابط را بررسی کنید. اگر ارسال تأییدنشدهای وجود داشته باشد، برچسب درست نامعلوم است؛ حدس «حتماً ناموفق» ممکن است کاربر را به ارسال دوبارهٔ پیام پذیرفتهشده وادار کند. پس از شروع دوباره، اتصال تازه و تاریخچهٔ خالی قابل انتظار است. رابط پیشنویس ارسالنشده را حفظ میکند، اما پیامهای قبلی را خودکار دوباره نمیفرستد. این رفتار را با باز کردن فایل app.js در درس هشتم مسیر جاوااسکریپت مقایسه کنید.
خطای اتصال را از خطای بسته جدا بخوانید
اگر پیش از 101 پاسخ 403 دارید، مبدأ مرورگر را با PUBLIC_ORIGIN مقایسه کنید: روش ارتباط، میزبان و پورت باید دقیقاً یکی باشند. مسیر Upgrade اشتباه 404 میگیرد و ظرفیت پر یا توقف سرور میتواند 503 ایجاد کند. اگر اتصال برقرار شده و سپس error با INVALID_PACKET میآید، مشکل دیگر HTTP آغازین نیست و شکل #json را باید بررسی کنید. در خطای بستن، 1003 برای دادهٔ باینری، 1009 برای اندازهٔ بیش از حد و 1008 برای سیاستهایی مانند نرخ یا مهلت عضویت معنا دارند. اینها را با عدد وضعیت HTTP یکی ندانید.
برای یادگیری بستهٔ نامعتبر، تست موجود را بخوانید و فقط روی سرور محلی خود یک فیلد اضافی بیفزایید. انتظار یک خطای ساختاریافته داریم و پیام نباید به همه پخش شود. اگر میخواهید پاسخ خطا را از اسکریپت بخوانید، پیش از send شنونده یا Promise انتظار را ثبت کنید؛ در غیر این صورت پاسخ سریع ممکن است پیش از آماده شدن انتظار برسد. #try-catch فقط استثنای همان مسیر اجرای تحت کنترل را میگیرد و جای شنوندهٔ خطای اتصال یا timeout انتظار شبکه را نمیگیرد. آزمون معلق نیز نتیجهٔ موفق محسوب نمیشود.
از نخستین شکست، یک مسئلهٔ کوچک بسازید
وقتی تستی شکست خورد، نامش، نسخهٔ Node، خروجی انتظار و مقدار واقعی را ثبت کنید. اگر EADDRINUSE دیدید، بررسی کنید کدام نمونهٔ خودتان از پورت استفاده میکند؛ پردازههای دیگر سیستم را کورکورانه نبندید. اگر اتصال باز نمیشود، ابتدا HTTP وضعیت محلی را امتحان کنید، بعد Upgrade و سپس پیام کاربردی را بررسی کنید. در خطای متن فارسی، طول code point و بایت را جدا اندازه بگیرید. تغییر همزمان سرور، کلاینت و پراکسی پیدا کردن علت را دشوار میکند؛ یک تغییر کوچک ایجاد کنید و بررسی مرتبط را دوباره اجرا کنید.
آزمون موفق باید بتواند در حالت خراب شکست بخورد. در یک کپی تمرینی، شرط رد Origin را موقتاً بیاثر کنید و ببینید تست مربوط به مبدأ نامعتبر قرمز میشود؛ سپس تغییر را برگردانید. این کار را روی نسخهٔ عمومی انجام ندهید. برای تاریخچه، حذف شرط سقف باید تست محدودیت را بشکند. اگر تست با وجود حذف رفتار هنوز سبز است، ادعای آن را دوباره بخوانید. هدف، افزایش تعداد assert نیست؛ هر بررسی باید یک تصمیم قابل توضیح و خطایی واقعی را آشکار کند.
تمرین و پاسخ راهنما
سه گزارش کوتاه بسازید: «صفحه باز میشود ولی چت وصل نیست»، «پیام پذیرفته شده ولی ACK دیده نمیشود» و «پس از شروع دوباره تاریخچه خالی است». پاسخ اول با بررسی 101، مسیر و Origin آغاز میشود؛ پاسخ دوم باید حالت نامعلوم و خطر ارسال تکراری را توضیح دهد؛ پاسخ سوم در قرارداد حافظهای این پروژه رفتار مورد انتظار است. سپس برای یکی از موارد یک تست هدفمند اضافه کنید، بدون کند کردن همهٔ مجموعه با انتظار ثابت طولانی. معیار تحویل، یک بازتولید کوچک، انتظار روشن و توضیح دلیل نتیجه است.
برای مبانی آزمون Node و assert، این درس را مرور کنید: https://www.darsavand.com/courses/nodejs-windows-backend-start?lesson=96fd0f9a-28ae-41f3-a5fd-76d21bae6b93
برای استفاده از اشکالزدا و خواندن خطا، درس زیر پیشنیاز مستقیم است: https://www.darsavand.com/courses/nodejs-windows-backend-start?lesson=7f46ca6f-c14d-45dd-b9b9-2ef88ae773aa
این مجموعه را پیش از تغییر بعدی پروژه ذخیره و اجرا کنید. در گزارش نتیجه، آزمون سرور، بررسی دستی رابط و آزمون پراکسی را جدا نام ببرید تا روشن باشد کدام رفتار واقعاً مشاهده شده و کدام بخش هنوز به محیط اجرای شما وابسته است.
منابع رسمی انگلیسی:
Node.js — Test runner: https://nodejs.org/api/test.html
Node.js — Assert: https://nodejs.org/api/assert.html
ws — WebSocket API: https://github.com/websockets/ws/blob/master/doc/ws.md
MDN — WebSocket API: https://developer.mozilla.org/en-US/docs/Web/API/WebSocket
npm — npm ci: https://docs.npmjs.com/cli/v11/commands/npm-ci/
تألیف، سناریوهای آزمون و راهنمای دیباگ: تحریریه درسآوند. آزمونها روی اتصال محلی و دادهٔ ساختگی اجرا میشوند.
تألیف و مثالهای اختصاصی: تحریریه درسآوند. فایلهای کامل پروژه و راهنمای اجرا و دیباگ:
https://www.darsavand.com/resources/simple-websocket-chat-project
متن درسهای عمومی را بدون ورود بخوانید.