Files
xianyan/docs/toolsapi/scripts/test_signaling_comprehensive.js
Developer f9c19463f9 chore: 批量更新v6.5.21版本,整合多项功能修复与优化
主要变更:
1. 新增多风格音效资源与管理文档
2. 修复翻译服务空响应处理与Dio日志异常捕获
3. 完善Web端平台适配与路径获取Stub
4. 优化设备配对与文件传输功能
5. 新增角色命名常量与摇一摇检测器
6. 修复Riverpod dispose与鸿蒙导航路由
7. 新增每日通知服务与流体着色器
8. 优化备份服务与数据管理页面
9. 新增隐私设置附近设备发现选项
10. 重构诗词提供者支持历史记录
11. 完善桌面端构建配置与开发脚本
12. 清理旧版工具部署脚本
2026-05-21 00:19:14 +08:00

699 lines
24 KiB
JavaScript

// ============================================================
// 闲言APP — 信令服务器综合测试脚本
// 创建时间: 2026-05-20
// 更新时间: 2026-05-20
// 作用: 测试信令服务器的文件传输/屏幕共享/画布/配对码/设备发现等功能
// 上次更新: 初始创建
// ============================================================
const WebSocket = require('ws');
const SERVER_URL = 'wss://tools.wktyl.com:9443';
const HEALTH_URL = 'https://tools.wktyl.com:9443/health';
const TEST_TIMEOUT = 30000;
let passed = 0;
let failed = 0;
const results = [];
function log(tag, msg) {
console.log(`[${new Date().toISOString().slice(11, 19)}] [${tag}] ${msg}`);
}
function assert(condition, testName, detail) {
if (condition) {
passed++;
results.push({ name: testName, status: 'PASS', detail: '' });
log('PASS', testName);
} else {
failed++;
results.push({ name: testName, status: 'FAIL', detail: detail || 'Assertion failed' });
log('FAIL', `${testName}${detail || 'Assertion failed'}`);
}
}
function createClient(id, extraPayload) {
return new Promise((resolve, reject) => {
const ws = new WebSocket(SERVER_URL);
const messages = [];
let registered = false;
let peerId = '';
const timeout = setTimeout(() => {
reject(new Error(`Client ${id} connection timeout`));
}, TEST_TIMEOUT);
ws.on('open', () => {
log(id, 'WebSocket connected');
const registerMsg = {
type: 'register',
payload: {
userId: `test_user_${id}`,
fingerprint: `fp_${id}_${Date.now()}`,
alias: `测试设备${id}`,
deviceModel: `TestModel-${id}`,
deviceType: 'mobile',
platform: 'test',
...extraPayload
}
};
ws.send(JSON.stringify(registerMsg));
});
ws.on('message', (data) => {
const msg = JSON.parse(data.toString());
messages.push(msg);
if (!registered && msg.type === 'registered') {
registered = true;
peerId = msg.id;
log(id, `Registered, peerId=${peerId}`);
clearTimeout(timeout);
resolve({ ws, messages, id, peerId, registered });
} else if (registered) {
log(id, `Received: ${msg.type}`);
}
});
ws.on('error', (err) => {
clearTimeout(timeout);
log(id, `Error: ${err.message}`);
});
ws.on('close', () => {
log(id, 'Disconnected');
});
});
}
function waitForMessage(client, type, timeoutMs = 8000) {
return new Promise((resolve, reject) => {
const timeout = setTimeout(() => {
client.ws.removeListener('message', handler);
reject(new Error(`Timeout waiting for message type: ${type}`));
}, timeoutMs);
const handler = (data) => {
try {
const msg = JSON.parse(data.toString());
if (msg.type === type) {
clearTimeout(timeout);
client.ws.removeListener('message', handler);
resolve(msg);
}
} catch (e) {}
};
client.ws.on('message', handler);
});
}
function sendAndWait(client, msg, responseType, timeoutMs = 8000) {
const promise = waitForMessage(client, responseType, timeoutMs);
send(client, msg);
return promise;
}
function sendAndWaitBoth(clientA, msgA, typeA, clientB, msgB, typeB, timeoutMs = 8000) {
const promiseA = waitForMessage(clientA, typeA, timeoutMs);
const promiseB = waitForMessage(clientB, typeB, timeoutMs);
if (msgA) send(clientA, msgA);
if (msgB) send(clientB, msgB);
return Promise.all([promiseA, promiseB]);
}
function send(client, msg) {
client.ws.send(JSON.stringify(msg));
log(client.id, `Sent: ${msg.type}`);
}
function closeClient(client) {
if (client.ws.readyState === WebSocket.OPEN) {
client.ws.close();
}
}
async function sleep(ms) {
return new Promise(r => setTimeout(r, ms));
}
// ============================================================
// Test 1: Health Check
// ============================================================
async function testHealthCheck() {
log('TEST', '=== Test 1: Health Check ===');
try {
const http = require('https');
const url = new URL('https://tools.wktyl.com/api/file_transfer/health');
const health = await new Promise((resolve, reject) => {
http.get(url, (res) => {
let data = '';
res.on('data', chunk => data += chunk);
res.on('end', () => {
try { resolve(JSON.parse(data)); } catch (e) { reject(e); }
});
}).on('error', reject);
});
assert(health.code === 1 || health.status === 'healthy' || health.data, 'Health check: server healthy', `code=${health.code}`);
log('TEST', `Health response: ${JSON.stringify(health).slice(0, 200)}`);
} catch (e) {
assert(false, 'Health check: server reachable', e.message);
}
}
// ============================================================
// Test 2: Device Registration & Discovery
// ============================================================
async function testRegistrationAndDiscovery() {
log('TEST', '=== Test 2: Registration & Discovery ===');
let clientA, clientB;
try {
clientA = await createClient('A');
clientB = await createClient('B');
assert(clientA.registered, 'Client A registered', '');
assert(clientB.registered, 'Client B registered', '');
assert(clientA.peerId !== clientB.peerId, 'Different peer IDs', `A=${clientA.peerId} B=${clientB.peerId}`);
const discoverResp = await sendAndWait(clientA, { type: 'discover' }, 'discover_response');
const devices = discoverResp.devices || [];
const foundB = devices.find(d => d.id === clientB.peerId);
assert(foundB, 'Discovery: Client A finds Client B', `devices count=${devices.length}`);
if (foundB) {
const alias = foundB.alias || foundB.name?.displayName || '';
assert(alias.includes('测试设备B'), 'Discovery: alias correct', `alias=${alias}`);
assert(foundB.deviceModel === 'TestModel-B', 'Discovery: deviceModel correct', `deviceModel=${foundB.deviceModel}`);
}
const myDevicesResp = await sendAndWait(clientA, {
type: 'discoverMyDevices',
payload: { userId: 'test_user_A' }
}, 'myDevicesResponse');
assert(Array.isArray(myDevicesResp.devices), 'My devices: response is array', '');
} catch (e) {
assert(false, 'Registration & Discovery', e.message);
} finally {
closeClient(clientA);
closeClient(clientB);
}
}
// ============================================================
// Test 3: Pairing Code (4-digit numeric)
// ============================================================
async function testPairingCode() {
log('TEST', '=== Test 3: Pairing Code (4-digit numeric) ===');
let clientA, clientB;
try {
clientA = await createClient('A-pair');
clientB = await createClient('B-pair');
const codeCreated = await sendAndWait(clientA, {
type: 'pairing-code-create',
payload: { alias: '设备A', deviceModel: 'Model-A', deviceType: 'mobile' }
}, 'pairing-code-created');
const code = codeCreated.pairingCode;
log('TEST', `Pairing code created: ${code}`);
assert(!!code, 'Pairing code: code exists', '');
assert(/^\d{4}$/.test(code), 'Pairing code: 4-digit numeric only', `code=${code}`);
assert(codeCreated.expiresAt > Date.now(), 'Pairing code: not expired', `expiresAt=${codeCreated.expiresAt}`);
const [matchedA, matchedB] = await sendAndWaitBoth(
clientA, null, 'pairing-matched',
clientB, {
type: 'pairing-code-join',
payload: { pairingCode: code }
}, 'pairing-matched'
);
assert(matchedA.success === true, 'Pairing: A matched successfully', `success=${matchedA.success}`);
assert(matchedB.success === true, 'Pairing: B matched successfully', `success=${matchedB.success}`);
assert(matchedB.peer.alias === '设备A', 'Pairing: B sees A alias', `alias=${matchedB.peer.alias}`);
assert(matchedB.peer.deviceModel === 'Model-A', 'Pairing: B sees A deviceModel', `deviceModel=${matchedB.peer.deviceModel}`);
// Test invalid code
const clientC = await createClient('C-pair');
const invalidMatch = await sendAndWait(clientC, {
type: 'pairing-code-join',
payload: { pairingCode: 'abcd' }
}, 'pairing-matched');
assert(invalidMatch.success === false, 'Pairing: non-numeric code rejected', `success=${invalidMatch.success}, error=${invalidMatch.error}`);
const invalidLen = await sendAndWait(clientC, {
type: 'pairing-code-join',
payload: { pairingCode: '12345' }
}, 'pairing-matched');
assert(invalidLen.success === false, 'Pairing: 5-digit code rejected', `success=${invalidLen.success}, error=${invalidLen.error}`);
closeClient(clientC);
} catch (e) {
assert(false, 'Pairing Code', e.message);
} finally {
closeClient(clientA);
closeClient(clientB);
}
}
// ============================================================
// Test 4: Text Message & File Meta
// ============================================================
async function testTextMessageAndFileMeta() {
log('TEST', '=== Test 4: Text Message & File Meta ===');
let clientA, clientB;
try {
clientA = await createClient('A-msg');
clientB = await createClient('B-msg');
await sleep(500);
const textMsgPromise = waitForMessage(clientB, 'textMessage');
send(clientA, {
type: 'textMessage',
to: clientB.peerId,
message: { text: 'Hello from A!', type: 'text' },
timestamp: Date.now()
});
const textMsg = await textMsgPromise;
assert(textMsg.message.text === 'Hello from A!', 'Text message: content correct', `text=${textMsg.message?.text}`);
assert(textMsg.from === clientA.peerId || textMsg.fromPeerId === clientA.peerId, 'Text message: from correct', `from=${textMsg.from}`);
const fileMeta = {
name: 'test_image.png',
size: 102400,
mimeType: 'image/png',
hash: 'abc123def456'
};
const fileMsgPromise = waitForMessage(clientB, 'fileMeta');
send(clientA, {
type: 'fileMeta',
to: clientB.peerId,
file: fileMeta,
timestamp: Date.now()
});
const fileMsg = await fileMsgPromise;
assert(fileMsg.file.name === 'test_image.png', 'File meta: name correct', `name=${fileMsg.file?.name}`);
assert(fileMsg.file.size === 102400, 'File meta: size correct', `size=${fileMsg.file?.size}`);
assert(fileMsg.file.mimeType === 'image/png', 'File meta: mimeType correct', `mimeType=${fileMsg.file?.mimeType}`);
} catch (e) {
assert(false, 'Text Message & File Meta', e.message);
} finally {
closeClient(clientA);
closeClient(clientB);
}
}
// ============================================================
// Test 5: Screen Share
// ============================================================
async function testScreenShare() {
log('TEST', '=== Test 5: Screen Share ===');
let clientA, clientB;
try {
clientA = await createClient('A-ss');
clientB = await createClient('B-ss');
await sleep(500);
const ssRequestPromise = waitForMessage(clientB, 'screen-share-request');
send(clientA, {
type: 'screen-share-request',
to: clientB.peerId,
payload: { direction: 'share' }
});
const ssRequest = await ssRequestPromise;
assert(ssRequest.direction === 'share', 'Screen share: request direction correct', `direction=${ssRequest.direction}`);
assert(ssRequest.fromPeerId === clientA.peerId || ssRequest.from === clientA.peerId, 'Screen share: from correct', '');
const ssAcceptPromise = waitForMessage(clientA, 'screen-share-accept');
send(clientB, {
type: 'screen-share-accept',
to: clientA.peerId,
payload: { direction: 'share' }
});
const ssAccept = await ssAcceptPromise;
assert(!!ssAccept, 'Screen share: accept received', '');
const offerMsgPromise = waitForMessage(clientB, 'screen-share-offer');
send(clientA, {
type: 'screen-share-offer',
to: clientB.peerId,
payload: { sdp: 'fake_sdp_offer', hotZones: [{ id: 'zone1', label: 'top' }] }
});
const offerMsg = await offerMsgPromise;
assert(offerMsg.payload?.sdp === 'fake_sdp_offer', 'Screen share: SDP offer relayed', `sdp=${offerMsg.payload?.sdp}`);
const answerMsgPromise = waitForMessage(clientA, 'screen-share-answer');
send(clientB, {
type: 'screen-share-answer',
to: clientA.peerId,
payload: { sdp: 'fake_sdp_answer' }
});
const answerMsg = await answerMsgPromise;
assert(answerMsg.payload?.sdp === 'fake_sdp_answer', 'Screen share: SDP answer relayed', '');
const iceMsgPromise = waitForMessage(clientB, 'screen-share-ice-candidate');
send(clientA, {
type: 'screen-share-ice-candidate',
to: clientB.peerId,
payload: { candidate: 'fake_ice_candidate_a' }
});
const iceMsg = await iceMsgPromise;
assert(iceMsg.payload?.candidate === 'fake_ice_candidate_a', 'Screen share: ICE candidate relayed', '');
const stopMsgPromise = waitForMessage(clientB, 'screen-share-stop');
send(clientA, {
type: 'screen-share-stop',
to: clientB.peerId
});
const stopMsg = await stopMsgPromise;
assert(!!stopMsg, 'Screen share: stop received', '');
const rejectReqPromise = waitForMessage(clientB, 'screen-share-request');
const rejectRespPromise = waitForMessage(clientA, 'screen-share-reject');
send(clientA, {
type: 'screen-share-request',
to: clientB.peerId,
payload: { direction: 'view' }
});
await rejectReqPromise;
send(clientB, {
type: 'screen-share-reject',
to: clientA.peerId
});
const rejectMsg = await rejectRespPromise;
assert(!!rejectMsg, 'Screen share: reject received', '');
} catch (e) {
assert(false, 'Screen Share', e.message);
} finally {
closeClient(clientA);
closeClient(clientB);
}
}
// ============================================================
// Test 6: Canvas Collaboration
// ============================================================
async function testCanvas() {
log('TEST', '=== Test 6: Canvas Collaboration ===');
let clientA, clientB;
try {
clientA = await createClient('A-canvas');
clientB = await createClient('B-canvas');
await sleep(500);
const canvasId = `test_canvas_${Date.now()}`;
const joinA = await sendAndWait(clientA, {
type: 'canvas-join',
payload: { canvasId }
}, 'canvas-join');
assert(joinA.payload.canvasId === canvasId, 'Canvas: A joined', `canvasId=${joinA.payload.canvasId}`);
assert(joinA.payload.members.includes(clientA.peerId), 'Canvas: A in members list', '');
const [joinB1, joinB2] = await sendAndWaitBoth(
clientB, { type: 'canvas-join', payload: { canvasId } }, 'canvas-join',
clientA, null, 'canvas-join'
);
assert(joinB1.payload.canvasId === canvasId, 'Canvas: B joined', '');
assert(joinB2.payload.members.length === 2, 'Canvas: A notified of B joining', `members=${joinB2.payload.members.length}`);
const strokeData = {
canvasId,
strokeId: 'stroke_001',
points: [{ x: 10, y: 20 }, { x: 30, y: 40 }],
color: '#FF0000',
width: 3,
tool: 'pen'
};
const strokeMsgPromise = waitForMessage(clientB, 'canvas-stroke');
send(clientA, {
type: 'canvas-stroke',
payload: strokeData
});
const strokeMsg = await strokeMsgPromise;
assert(strokeMsg.payload.strokeId === 'stroke_001', 'Canvas: stroke relayed', `strokeId=${strokeMsg.payload.strokeId}`);
assert(strokeMsg.payload.color === '#FF0000', 'Canvas: stroke color correct', `color=${strokeMsg.payload.color}`);
const cursorMsgPromise = waitForMessage(clientB, 'canvas-cursor');
send(clientA, {
type: 'canvas-cursor',
payload: { canvasId, x: 50, y: 60 }
});
const cursorMsg = await cursorMsgPromise;
assert(cursorMsg.payload.x === 50, 'Canvas: cursor relayed', `x=${cursorMsg.payload.x}`);
const snapshotReqPromise = waitForMessage(clientB, 'canvas-snapshot');
send(clientA, {
type: 'canvas-snapshot',
payload: { canvasId, action: 'request' }
});
const snapshotReq = await snapshotReqPromise;
assert(snapshotReq.payload.action === 'request', 'Canvas: snapshot request relayed', '');
const snapshotRespPromise = waitForMessage(clientA, 'canvas-snapshot');
send(clientB, {
type: 'canvas-snapshot',
to: clientA.peerId,
payload: { canvasId, action: 'response', data: 'base64_fake_snapshot_data' }
});
const snapshotResp = await snapshotRespPromise;
assert(snapshotResp.payload.data === 'base64_fake_snapshot_data', 'Canvas: snapshot response relayed', '');
const leaveMsgPromise = waitForMessage(clientA, 'canvas-leave');
send(clientB, {
type: 'canvas-leave',
payload: { canvasId }
});
const leaveMsg = await leaveMsgPromise;
assert(leaveMsg.payload.canvasId === canvasId, 'Canvas: B leave notified to A', '');
} catch (e) {
assert(false, 'Canvas Collaboration', e.message);
} finally {
closeClient(clientA);
closeClient(clientB);
}
}
// ============================================================
// Test 7: Pair Request (direct pairing)
// ============================================================
async function testDirectPairing() {
log('TEST', '=== Test 7: Direct Pairing ===');
let clientA, clientB;
try {
clientA = await createClient('A-dpair');
clientB = await createClient('B-dpair');
await sleep(500);
const pairReqPromise = waitForMessage(clientB, 'pair-request');
send(clientA, {
type: 'pair-request',
to: clientB.peerId,
fingerprint: `fp_A-dpair_${Date.now()}`,
payload: { fingerprint: `fp_A-dpair_${Date.now()}` }
});
const pairReq = await pairReqPromise;
assert(!!pairReq.requestId, 'Direct pair: request has requestId', `requestId=${pairReq.requestId}`);
const [pairRespA, pairRespB] = await sendAndWaitBoth(
clientA, null, 'pair-response',
clientB, { type: 'pair-accept', requestId: pairReq.requestId }, 'pair-response'
);
assert(pairRespA.success === true, 'Direct pair: A success', `success=${pairRespA.success}`);
assert(pairRespB.success === true, 'Direct pair: B success', `success=${pairRespB.success}`);
} catch (e) {
assert(false, 'Direct Pairing', e.message);
} finally {
closeClient(clientA);
closeClient(clientB);
}
}
// ============================================================
// Test 8: Radar Broadcast & Scan
// ============================================================
async function testRadar() {
log('TEST', '=== Test 8: Radar Broadcast & Scan ===');
let clientA, clientB;
try {
clientA = await createClient('A-radar');
clientB = await createClient('B-radar');
await sleep(500);
const broadcastResp = await sendAndWait(clientA, {
type: 'radar-broadcast',
payload: {
alias: '雷达设备A',
deviceModel: 'RadarModel-A',
deviceType: 'mobile',
ipRange: '192.168.1',
ipCity: '深圳'
}
}, 'radar-broadcast');
assert(broadcastResp.success === true, 'Radar: A broadcast success', '');
await sendAndWait(clientB, {
type: 'radar-broadcast',
payload: {
alias: '雷达设备B',
deviceModel: 'RadarModel-B',
deviceType: 'desktop',
ipRange: '192.168.1',
ipCity: '深圳'
}
}, 'radar-broadcast');
const scanResp = await sendAndWait(clientA, {
type: 'radar-scan',
payload: { ipRange: '192.168.1', ipCity: '深圳' }
}, 'radar-devices');
assert(Array.isArray(scanResp.devices), 'Radar: scan returns array', '');
const foundB = scanResp.devices.find(d => d.alias === '雷达设备B');
assert(!!foundB, 'Radar: A finds B', `devices count=${scanResp.devices.length}`);
if (foundB) {
assert(foundB.deviceModel === 'RadarModel-B', 'Radar: B deviceModel correct', `deviceModel=${foundB.deviceModel}`);
assert(foundB.matchType === 'same-network' || foundB.matchType === 'my-device', 'Radar: matchType correct', `matchType=${foundB.matchType}`);
}
} catch (e) {
assert(false, 'Radar Broadcast & Scan', e.message);
} finally {
closeClient(clientA);
closeClient(clientB);
}
}
// ============================================================
// Test 9: WsRelay
// ============================================================
async function testWsRelay() {
log('TEST', '=== Test 9: WebSocket Relay ===');
let clientA, clientB;
try {
clientA = await createClient('A-relay');
clientB = await createClient('B-relay');
await sleep(500);
const relayMsgPromise = waitForMessage(clientB, 'wsRelay');
send(clientA, {
type: 'wsRelay',
to: clientB.peerId,
relayType: 'webrtc-signal',
payload: {
relayType: 'webrtc-signal',
signalType: 'offer',
sdp: 'fake_relay_sdp'
}
});
const relayMsg = await relayMsgPromise;
assert(relayMsg.payload.relayType === 'webrtc-signal', 'WsRelay: relayType correct', `relayType=${relayMsg.payload?.relayType}`);
assert(relayMsg.payload.signalType === 'offer', 'WsRelay: signalType correct', `signalType=${relayMsg.payload?.signalType}`);
const errorRelay = await sendAndWait(clientA, {
type: 'wsRelay',
to: 'non_existent_peer_id',
relayType: 'data',
payload: { relayType: 'data', test: true }
}, 'wsRelay');
assert(errorRelay.payload?.relayType === 'error', 'WsRelay: error for non-existent target', `relayType=${errorRelay.payload?.relayType}`);
} catch (e) {
assert(false, 'WebSocket Relay', e.message);
} finally {
closeClient(clientA);
closeClient(clientB);
}
}
// ============================================================
// Test 10: Heartbeat & Ping
// ============================================================
async function testHeartbeat() {
log('TEST', '=== Test 10: Heartbeat & Ping ===');
let clientA;
try {
clientA = await createClient('A-hb');
const pong = await sendAndWait(clientA, { type: 'ping' }, 'pong');
assert(!!pong.timestamp, 'Ping: pong has timestamp', `timestamp=${pong.timestamp}`);
const hbAck = await sendAndWait(clientA, { type: 'heartbeat' }, 'heartbeat_ack');
assert(!!hbAck.timestamp, 'Heartbeat: ack has timestamp', `timestamp=${hbAck.timestamp}`);
} catch (e) {
assert(false, 'Heartbeat & Ping', e.message);
} finally {
closeClient(clientA);
}
}
// ============================================================
// Test 11: DeviceModel not localhost
// ============================================================
async function testDeviceModelNotLocalhost() {
log('TEST', '=== Test 11: DeviceModel not localhost ===');
let clientA;
try {
clientA = await createClient('A-model', { deviceModel: 'Samsung Galaxy S24' });
send(clientA, { type: 'discover' });
// We check the registered data via discover response from another client
const clientB = await createClient('B-model');
await sleep(500);
const discoverResp = await sendAndWait(clientB, { type: 'discover' }, 'discover_response');
const foundA = discoverResp.devices.find(d => d.id === clientA.peerId);
assert(!!foundA, 'DeviceModel: A found in discovery', '');
if (foundA) {
assert(foundA.deviceModel === 'Samsung Galaxy S24', 'DeviceModel: custom model preserved', `deviceModel=${foundA.deviceModel}`);
assert(foundA.deviceModel !== 'localhost', 'DeviceModel: not localhost', `deviceModel=${foundA.deviceModel}`);
}
closeClient(clientB);
} catch (e) {
assert(false, 'DeviceModel not localhost', e.message);
} finally {
closeClient(clientA);
}
}
// ============================================================
// Main
// ============================================================
async function main() {
console.log('\n' + '='.repeat(70));
console.log(' 闲言APP 信令服务器综合测试');
console.log(` Server: ${SERVER_URL}`);
console.log(` Time: ${new Date().toISOString()}`);
console.log('='.repeat(70) + '\n');
await testHealthCheck();
await testRegistrationAndDiscovery();
await testPairingCode();
await testTextMessageAndFileMeta();
await testScreenShare();
await testCanvas();
await testDirectPairing();
await testRadar();
await testWsRelay();
await testHeartbeat();
await testDeviceModelNotLocalhost();
console.log('\n' + '='.repeat(70));
console.log(' Test Results Summary');
console.log('='.repeat(70));
for (const r of results) {
const icon = r.status === 'PASS' ? '✅' : '❌';
const detail = r.detail ? ` (${r.detail})` : '';
console.log(` ${icon} ${r.name}${detail}`);
}
console.log('-'.repeat(70));
console.log(` Total: ${passed + failed} | Passed: ${passed} | Failed: ${failed}`);
console.log('='.repeat(70) + '\n');
process.exit(failed > 0 ? 1 : 0);
}
main().catch(err => {
console.error('Test runner error:', err);
process.exit(2);
});