This commit is contained in:
2026-05-25 22:58:11 +08:00
parent e6dfb28ef2
commit d74a0c8121
12 changed files with 347 additions and 189 deletions

View File

@@ -1,8 +1,8 @@
/* eslint-disable no-undef */ /* eslint-disable no-undef */
import fetch from 'node-fetch'; import fetch from 'node-fetch';
import { TextEncoder, TextDecoder } from 'util'; import { TextEncoder, TextDecoder } from 'util';
import { PeerConnectionMock, SessionDescriptionMock, IceCandidateMock } from './test/peerconnectionmock'; import { PeerConnectionMock, SessionDescriptionMock, IceCandidateMock } from './test/mocks/peerconnectionmock.js';
import ResizeObserverMock from './test/resizeobservermock'; import ResizeObserverMock from './test/helpers/resizeobservermock.js';
// note: If set testEnvironment `jest-environment-jsdom`, below classes are not defined. // note: If set testEnvironment `jest-environment-jsdom`, below classes are not defined.
@@ -32,4 +32,4 @@ if (!window.RTCIceCandidate) {
if (!window.ResizeObserver) { if (!window.ResizeObserver) {
window.ResizeObserver = ResizeObserverMock; window.ResizeObserver = ResizeObserverMock;
} }

View File

@@ -1,3 +1,5 @@
import { createTextElement, textValue } from '../../shared/dom.js';
export function createMessageElement(message, formatTimestamp) { export function createMessageElement(message, formatTimestamp) {
const messageDiv = document.createElement('div'); const messageDiv = document.createElement('div');
let messageClass = 'chat-bubble'; let messageClass = 'chat-bubble';
@@ -13,31 +15,45 @@ export function createMessageElement(message, formatTimestamp) {
messageDiv.className = messageClass; messageDiv.className = messageClass;
messageDiv.dataset.messageId = message.id; messageDiv.dataset.messageId = message.id;
const contentHTML = message.type === 'file' && message.content.startsWith('data:image/') const header = document.createElement('div');
? ` header.className = 'message-header';
<div class="message-image-container">
<img src="${message.content}" class="message-image" alt="${message.fileName || '\u56fe\u7247'}">
${message.fileName ? `<div class="message-image-name">${message.fileName}</div>` : ''}
</div>
`
: `
<div class="message-text">
${message.content}
</div>
`;
messageDiv.innerHTML = ` const avatar = document.createElement('img');
<div class="message-header"> avatar.className = 'message-avatar';
<img src="${message.senderAvatar}" class="message-avatar"> avatar.src = textValue(message.senderAvatar);
<div> avatar.alt = textValue(message.senderName, '\u7528\u6237');
<span class="message-sender">${message.senderName}</span> header.appendChild(avatar);
<span class="message-time">${formatTimestamp(message.timestamp)}</span>
</div> const headerText = document.createElement('div');
</div> headerText.appendChild(createTextElement('span', 'message-sender', message.senderName));
<div class="message-content"> headerText.appendChild(createTextElement('span', 'message-time', formatTimestamp(message.timestamp)));
${contentHTML} header.appendChild(headerText);
</div>
`; const content = document.createElement('div');
content.className = 'message-content';
const rawContent = textValue(message.content);
if (message.type === 'file' && rawContent.startsWith('data:image/')) {
const imageContainer = document.createElement('div');
imageContainer.className = 'message-image-container';
const image = document.createElement('img');
image.src = rawContent;
image.className = 'message-image';
image.alt = textValue(message.fileName, '\u56fe\u7247');
imageContainer.appendChild(image);
if (message.fileName) {
imageContainer.appendChild(createTextElement('div', 'message-image-name', message.fileName));
}
content.appendChild(imageContainer);
} else {
content.appendChild(createTextElement('div', 'message-text', rawContent));
}
messageDiv.appendChild(header);
messageDiv.appendChild(content);
return messageDiv; return messageDiv;
} }

View File

@@ -1,3 +1,5 @@
import { createIconElement, createTextElement, textValue } from '../../shared/dom.js';
function createParticipantPlaceholder() { function createParticipantPlaceholder() {
const placeholder = document.createElement('div'); const placeholder = document.createElement('div');
placeholder.className = 'participant-video-placeholder absolute inset-0 flex items-center justify-center bg-gradient-to-br from-indigo-900/80 to-purple-900/80 hidden'; placeholder.className = 'participant-video-placeholder absolute inset-0 flex items-center justify-center bg-gradient-to-br from-indigo-900/80 to-purple-900/80 hidden';
@@ -15,32 +17,39 @@ function createParticipantPlaceholder() {
export function createParticipantTile(connectionId, displayName) { export function createParticipantTile(connectionId, displayName) {
const tile = document.createElement('div'); const tile = document.createElement('div');
tile.className = 'relative bg-black/60 rounded-xl overflow-hidden flex items-center justify-center'; tile.className = 'relative bg-black/60 rounded-xl overflow-hidden flex items-center justify-center';
tile.dataset.participantId = connectionId; tile.dataset.participantId = textValue(connectionId);
const video = document.createElement('video'); const video = document.createElement('video');
video.className = 'w-full h-full object-contain'; video.className = 'w-full h-full object-contain';
video.autoplay = true; video.autoplay = true;
video.playsinline = true; video.playsinline = true;
video.muted = false; video.muted = false;
video.id = `participantVideo_${connectionId}`; video.id = `participantVideo_${textValue(connectionId)}`;
tile.appendChild(video); tile.appendChild(video);
tile.appendChild(createParticipantPlaceholder()); tile.appendChild(createParticipantPlaceholder());
const label = document.createElement('div'); const label = document.createElement('div');
label.className = 'absolute bottom-3 left-3 glass px-3 py-1 rounded-full text-xs flex items-center gap-2'; label.className = 'absolute bottom-3 left-3 glass px-3 py-1 rounded-full text-xs flex items-center gap-2';
label.innerHTML = `<i class="fas fa-user text-purple-400"></i><span>${displayName || '\u53c2\u4e0e\u8005'}</span>`; label.appendChild(createIconElement('fas fa-user text-purple-400'));
label.appendChild(createTextElement('span', '', displayName, '\u53c2\u4e0e\u8005'));
tile.appendChild(label); tile.appendChild(label);
const liveTag = document.createElement('div'); const liveTag = document.createElement('div');
liveTag.className = 'absolute top-3 right-3 bg-green-500/80 px-2 py-0.5 rounded-full text-xs flex items-center gap-1'; liveTag.className = 'absolute top-3 right-3 bg-green-500/80 px-2 py-0.5 rounded-full text-xs flex items-center gap-1';
liveTag.innerHTML = `<span class="w-1.5 h-1.5 bg-white rounded-full animate-pulse"></span><span>\u5728\u7ebf</span>`; const pulse = document.createElement('span');
pulse.className = 'w-1.5 h-1.5 bg-white rounded-full animate-pulse';
liveTag.appendChild(pulse);
liveTag.appendChild(createTextElement('span', '', '\u5728\u7ebf'));
tile.appendChild(liveTag); tile.appendChild(liveTag);
return tile; return tile;
} }
export function getParticipantTile(grid, participantId) { export function getParticipantTile(grid, participantId) {
return grid?.querySelector(`[data-participant-id="${participantId}"]`) || null; if (!grid) return null;
const expectedId = textValue(participantId);
return Array.from(grid.querySelectorAll('[data-participant-id]'))
.find(tile => tile.dataset.participantId === expectedId) || null;
} }
export function updateParticipantTilePlaceholder(grid, participantId, showPlaceholder) { export function updateParticipantTilePlaceholder(grid, participantId, showPlaceholder) {

View File

@@ -1,3 +1,5 @@
import { createIconElement, createTextElement, textValue } from '../../shared/dom.js';
const DEFAULT_NETWORK_QUALITY = { const DEFAULT_NETWORK_QUALITY = {
label: '\u672a\u77e5', label: '\u672a\u77e5',
statusIconClass: 'fas fa-question-circle text-gray-400', statusIconClass: 'fas fa-question-circle text-gray-400',
@@ -50,18 +52,18 @@ const NETWORK_QUALITY_DISPLAY = {
} }
}; };
function getRoleTagMarkup(user, role) { function getRoleTagMeta(user, role) {
if (role === 'local') { if (role === 'local') {
return user.isHost return user.isHost
? '<span class="text-xs bg-indigo-500 px-1.5 rounded ml-1">\u4e3b\u6301\u4eba</span>' ? { className: 'text-xs bg-indigo-500 px-1.5 rounded ml-1', label: '\u4e3b\u6301\u4eba' }
: '<span class="text-xs bg-purple-500 px-1.5 rounded ml-1">\u53c2\u4e0e\u8005</span>'; : { className: 'text-xs bg-purple-500 px-1.5 rounded ml-1', label: '\u53c2\u4e0e\u8005' };
} }
if (role === 'participant') { if (role === 'participant') {
return '<span class="text-xs bg-purple-500 px-1.5 rounded ml-1">\u53c2\u4e0e\u8005</span>'; return { className: 'text-xs bg-purple-500 px-1.5 rounded ml-1', label: '\u53c2\u4e0e\u8005' };
} }
return '<span class="text-xs bg-indigo-500 px-1.5 rounded ml-1">\u4e3b\u6301\u4eba</span>'; return { className: 'text-xs bg-indigo-500 px-1.5 rounded ml-1', label: '\u4e3b\u6301\u4eba' };
} }
function getDatasetUserId(role, id) { function getDatasetUserId(role, id) {
@@ -79,34 +81,55 @@ function getDatasetUserId(role, id) {
} }
} }
function getAvatarMarkup(user, role) { function createAvatarImage(user) {
if (role === 'local') { const image = document.createElement('img');
return `<img src="${user.avatar}" class="w-10 h-10 rounded-full object-cover">`; image.src = textValue(user.avatar);
} image.alt = textValue(user.name, '\u7528\u6237');
image.className = 'w-10 h-10 rounded-full object-cover';
return ` return image;
<div class="relative">
<img src="${user.avatar}" class="w-10 h-10 rounded-full object-cover">
<div class="absolute -bottom-1 -right-1 w-3 h-3 bg-green-500 rounded-full border-2 border-slate-900"></div>
</div>
`;
} }
function getRightMarkup(mediaState, role, muteIconMarkup) { function createAvatarElement(user, role) {
if (role !== 'participant') { if (role === 'local') {
return muteIconMarkup; return createAvatarImage(user);
} }
const speakingMarkup = (mediaState.isSpeaking && mediaState.audio) const wrapper = document.createElement('div');
? '<div class="audio-wave w-6"><span></span><span></span><span></span><span></span><span></span></div>' wrapper.className = 'relative';
: ''; wrapper.appendChild(createAvatarImage(user));
return ` const statusDot = document.createElement('div');
<div class="flex items-center gap-2"> statusDot.className = 'absolute -bottom-1 -right-1 w-3 h-3 bg-green-500 rounded-full border-2 border-slate-900';
${muteIconMarkup} wrapper.appendChild(statusDot);
${speakingMarkup}
</div> return wrapper;
`; }
function createAudioWaveElement() {
const wave = document.createElement('div');
wave.className = 'audio-wave w-6';
for (let i = 0; i < 5; i += 1) {
wave.appendChild(document.createElement('span'));
}
return wave;
}
function createRightElement(mediaState, role, muteIcon) {
if (role !== 'participant') {
return muteIcon;
}
const right = document.createElement('div');
right.className = 'flex items-center gap-2';
if (muteIcon) {
right.appendChild(muteIcon);
}
if (mediaState.isSpeaking && mediaState.audio) {
right.appendChild(createAudioWaveElement());
}
return right.childNodes.length > 0 ? right : null;
} }
export function getCallTitle(connectionId) { export function getCallTitle(connectionId) {
@@ -163,27 +186,40 @@ export function buildUserCountLabel(userCount) {
export function createUserEntryElement({ user, role, id }) { export function createUserEntryElement({ user, role, id }) {
const entry = document.createElement('div'); const entry = document.createElement('div');
const mediaMeta = getMediaStatusMeta(user.mediaState); const mediaMeta = getMediaStatusMeta(user.mediaState);
const muteIconMarkup = mediaMeta.showMuteIcon const muteIcon = mediaMeta.showMuteIcon
? `<i class="${mediaMeta.muteIconClass}"></i>` ? createIconElement(mediaMeta.muteIconClass)
: ''; : '';
const baseClass = 'flex items-center gap-3 p-2 rounded-lg'; const baseClass = 'flex items-center gap-3 p-2 rounded-lg';
const dataFieldAttr = role === 'local' ? ' data-field="localUser.mediaStatus"' : '';
entry.className = role === 'local' entry.className = role === 'local'
? `${baseClass} hover:bg-white/5` ? `${baseClass} hover:bg-white/5`
: `${baseClass} bg-white/5`; : `${baseClass} bg-white/5`;
entry.dataset.userId = getDatasetUserId(role, id); entry.dataset.userId = getDatasetUserId(role, id);
entry.innerHTML = `
${getAvatarMarkup(user, role)} entry.appendChild(createAvatarElement(user, role));
<div class="flex-1">
<div class="text-sm font-medium"> const details = document.createElement('div');
${user.name} details.className = 'flex-1';
${getRoleTagMarkup(user, role)}
</div> const nameRow = document.createElement('div');
<div class="${mediaMeta.className}"${dataFieldAttr}>${mediaMeta.text}</div> nameRow.className = 'text-sm font-medium';
</div> nameRow.appendChild(document.createTextNode(textValue(user.name)));
${getRightMarkup(user.mediaState, role, muteIconMarkup)} const roleTag = getRoleTagMeta(user, role);
`; nameRow.appendChild(createTextElement('span', roleTag.className, roleTag.label));
details.appendChild(nameRow);
const mediaStatus = createTextElement('div', mediaMeta.className, mediaMeta.text);
if (role === 'local') {
mediaStatus.dataset.field = 'localUser.mediaStatus';
}
details.appendChild(mediaStatus);
entry.appendChild(details);
const right = createRightElement(user.mediaState, role, muteIcon || null);
if (right) {
entry.appendChild(right);
}
return entry; return entry;
} }

View File

@@ -1,3 +1,5 @@
import { createTextElement, textValue } from '../../shared/dom.js';
const EMPTY_CONNECTION_IDS_HTML = '<p class="text-gray-500 text-sm">\u6682\u65e0\u53ef\u7528\u7684\u8fde\u63a5ID</p>'; const EMPTY_CONNECTION_IDS_HTML = '<p class="text-gray-500 text-sm">\u6682\u65e0\u53ef\u7528\u7684\u8fde\u63a5ID</p>';
const EMPTY_USERS_HTML = '<p class="text-gray-500 text-sm">\u6682\u65e0\u5728\u7ebf\u7528\u6237</p>'; const EMPTY_USERS_HTML = '<p class="text-gray-500 text-sm">\u6682\u65e0\u5728\u7ebf\u7528\u6237</p>';
const HALL_LABEL = '\u5927\u5385\uff08\u672a\u52a0\u5165\u623f\u95f4\uff09'; const HALL_LABEL = '\u5927\u5385\uff08\u672a\u52a0\u5165\u623f\u95f4\uff09';
@@ -10,13 +12,14 @@ const SELECT_LABEL = '\u9009\u62e9';
const USER_COUNT_SUFFIX = '\u4eba'; const USER_COUNT_SUFFIX = '\u4eba';
const ONLINE_USERS_SUMMARY_SUFFIX = ' \u4e2aWebSocket\u7528\u6237\u5728\u7ebf'; const ONLINE_USERS_SUMMARY_SUFFIX = ' \u4e2aWebSocket\u7528\u6237\u5728\u7ebf';
function escapeHtml(value) { function getRoleTagClass(role) {
return String(value || '') if (role === 'host') {
.replace(/&/g, '&amp;') return 'text-xs px-2 py-1 rounded-full bg-indigo-500/20 text-indigo-300';
.replace(/</g, '&lt;') }
.replace(/>/g, '&gt;') if (role === 'participant') {
.replace(/"/g, '&quot;') return 'text-xs px-2 py-1 rounded-full bg-white/10 text-gray-300';
.replace(/'/g, '&#39;'); }
return 'text-xs px-2 py-1 rounded-full bg-emerald-500/20 text-emerald-300';
} }
export async function fetchOnlineUsers() { export async function fetchOnlineUsers() {
@@ -115,10 +118,8 @@ export function renderOnlineUsers({ users, currentUserId, onlineUsersList, users
const roomTitle = document.createElement('div'); const roomTitle = document.createElement('div');
roomTitle.className = 'flex items-center justify-between mb-2'; roomTitle.className = 'flex items-center justify-between mb-2';
roomTitle.innerHTML = ` roomTitle.appendChild(createTextElement('span', 'text-sm font-medium text-white', groupName));
<span class="text-sm font-medium text-white">${escapeHtml(groupName)}</span> roomTitle.appendChild(createTextElement('span', 'text-xs text-gray-400', `${roomUsers.length} ${USER_COUNT_SUFFIX}`));
<span class="text-xs text-gray-400">${roomUsers.length} ${USER_COUNT_SUFFIX}</span>
`;
section.appendChild(roomTitle); section.appendChild(roomTitle);
const roomList = document.createElement('div'); const roomList = document.createElement('div');
@@ -135,19 +136,31 @@ export function renderOnlineUsers({ users, currentUserId, onlineUsersList, users
const userItem = document.createElement('div'); const userItem = document.createElement('div');
userItem.className = 'flex items-center justify-between rounded-lg bg-black/20 px-3 py-2'; userItem.className = 'flex items-center justify-between rounded-lg bg-black/20 px-3 py-2';
userItem.innerHTML = `
<div class="flex items-center gap-3 min-w-0"> const profile = document.createElement('div');
<img src="${escapeHtml(avatar)}" alt="${escapeHtml(userName)}" class="w-8 h-8 rounded-full object-cover"> profile.className = 'flex items-center gap-3 min-w-0';
<div class="min-w-0">
<div class="text-sm text-white truncate">${escapeHtml(userName)}</div> const avatarImage = document.createElement('img');
<div class="text-xs text-gray-400 truncate">${escapeHtml(identity)}</div> avatarImage.src = textValue(avatar);
</div> avatarImage.alt = textValue(userName);
</div> avatarImage.className = 'w-8 h-8 rounded-full object-cover';
<div class="flex items-center gap-2"> profile.appendChild(avatarImage);
<span class="text-xs px-2 py-1 rounded-full ${user.role === 'host' ? 'bg-indigo-500/20 text-indigo-300' : (user.role === 'participant' ? 'bg-white/10 text-gray-300' : 'bg-emerald-500/20 text-emerald-300')}">${roleLabel}</span>
${isSelf ? `<span class="text-xs text-gray-500">${SELF_LABEL}</span>` : ''} const info = document.createElement('div');
</div> info.className = 'min-w-0';
`; info.appendChild(createTextElement('div', 'text-sm text-white truncate', userName));
info.appendChild(createTextElement('div', 'text-xs text-gray-400 truncate', identity));
profile.appendChild(info);
const status = document.createElement('div');
status.className = 'flex items-center gap-2';
status.appendChild(createTextElement('span', getRoleTagClass(user.role), roleLabel));
if (isSelf) {
status.appendChild(createTextElement('span', 'text-xs text-gray-500', SELF_LABEL));
}
userItem.appendChild(profile);
userItem.appendChild(status);
roomList.appendChild(userItem); roomList.appendChild(userItem);
}); });

View File

@@ -0,0 +1,18 @@
export function textValue(value, fallback = '') {
return value == null || value === '' ? fallback : String(value);
}
export function createTextElement(tagName, className, value, fallback = '') {
const element = document.createElement(tagName);
if (className) {
element.className = className;
}
element.textContent = textValue(value, fallback);
return element;
}
export function createIconElement(className) {
const icon = document.createElement('i');
icon.className = className;
return icon;
}

View File

@@ -2,10 +2,11 @@ import * as Logger from "../utils/logger.js";
export class Signaling extends EventTarget { export class Signaling extends EventTarget {
constructor(interval = 1000) { constructor(interval = 1000, baseUrl = null) {
super(); super();
this.running = false; this.running = false;
this.interval = interval; this.interval = interval;
this.baseUrl = baseUrl;
this.sleep = msec => new Promise(resolve => setTimeout(resolve, msec)); this.sleep = msec => new Promise(resolve => setTimeout(resolve, msec));
} }
@@ -19,7 +20,7 @@ export class Signaling extends EventTarget {
} }
url(method, parameter = '') { url(method, parameter = '') {
let ret = location.origin + '/signaling'; let ret = (this.baseUrl || location.origin) + '/signaling';
if (method) if (method)
ret += '/' + method; ret += '/' + method;
if (parameter) if (parameter)
@@ -151,16 +152,17 @@ export class Signaling extends EventTarget {
export class WebSocketSignaling extends EventTarget { export class WebSocketSignaling extends EventTarget {
constructor(interval = 1000) { constructor(interval = 1000, websocketUrl = null) {
super(); super();
this.interval = interval; this.interval = interval;
this.sleep = msec => new Promise(resolve => setTimeout(resolve, msec)); this.sleep = msec => new Promise(resolve => setTimeout(resolve, msec));
let websocketUrl; if (!websocketUrl) {
if (location.protocol === "https:") { if (location.protocol === "https:") {
websocketUrl = "wss://" + location.host; websocketUrl = "wss://" + location.host;
} else { } else {
websocketUrl = "ws://" + location.host; websocketUrl = "ws://" + location.host;
}
} }
this.websocket = new WebSocket(websocketUrl); this.websocket = new WebSocket(websocketUrl);

View File

@@ -1,3 +1,4 @@
import { jest } from '@jest/globals';
import { MeetingRecorder } from '../../public/call/media/meeting-recorder.js'; import { MeetingRecorder } from '../../public/call/media/meeting-recorder.js';
class MediaStreamMock { class MediaStreamMock {
@@ -107,7 +108,7 @@ describe('MeetingRecorder', () => {
const result = await recorder.stop(); const result = await recorder.stop();
expect(result.filename).toContain('meeting-recording-123-456-789'); expect(result.filename).toContain('meeting-recording-123-456-789');
expect(result.mimeType).toBe('video/mp4;codecs=avc1.42E01E,mp4a.40.2'); expect(result.mimeType.toLowerCase()).toBe('video/mp4;codecs=avc1.42e01e,mp4a.40.2');
expect(result.filename).toMatch(/\.mp4$/); expect(result.filename).toMatch(/\.mp4$/);
expect(windowRef.URL.createObjectURL).not.toHaveBeenCalled(); expect(windowRef.URL.createObjectURL).not.toHaveBeenCalled();
expect(recorder.isRecording()).toBe(false); expect(recorder.isRecording()).toBe(false);

View File

@@ -0,0 +1,88 @@
import { createMessageElement } from '../../public/call/chat/renderer-chat.js';
import { createParticipantTile, getParticipantTile } from '../../public/call/participants/renderer-participant-grid.js';
import { createUserEntryElement } from '../../public/call/renderers/renderer-ui.js';
import { renderOnlineUsers } from '../../public/call/signaling/connect-directory.js';
const formatTimestamp = value => value;
const unsafeText = '<img src=x onerror=alert(1)>Alice';
function mediaState(overrides = {}) {
return {
audio: true,
video: true,
isSpeaking: false,
...overrides
};
}
describe('safe dynamic rendering', () => {
afterEach(() => {
document.body.innerHTML = '';
});
test('renders chat text as text, not markup', () => {
const element = createMessageElement({
id: 'msg-1',
type: 'text',
isSelf: false,
senderName: unsafeText,
senderAvatar: '/images/p1.png',
content: unsafeText,
timestamp: 'now'
}, formatTimestamp);
expect(element.querySelector('.message-text').textContent).toBe(unsafeText);
expect(element.querySelector('.message-content img')).toBeNull();
expect(element.querySelector('.message-sender').textContent).toBe(unsafeText);
});
test('renders participant names safely and finds ids without selector injection', () => {
const participantId = 'room"] [data-bad="1';
const tile = createParticipantTile(participantId, unsafeText);
const grid = document.createElement('div');
grid.appendChild(tile);
expect(tile.querySelector('.absolute.bottom-3 span').textContent).toBe(unsafeText);
expect(tile.querySelector('.absolute.bottom-3 img')).toBeNull();
expect(getParticipantTile(grid, participantId)).toBe(tile);
});
test('renders user list entries without interpreting user profile fields as HTML', () => {
const entry = createUserEntryElement({
role: 'participant',
id: 'participant-1',
user: {
name: unsafeText,
avatar: '/images/p2.png',
mediaState: mediaState({ audio: false })
}
});
expect(entry.textContent).toContain(unsafeText);
expect(entry.querySelectorAll('img')).toHaveLength(1);
});
test('renders online users without injecting markup from directory data', () => {
const onlineUsersList = document.createElement('div');
const usersContainer = document.createElement('div');
const onlineUsersSummary = document.createElement('div');
renderOnlineUsers({
users: [{
name: unsafeText,
userId: unsafeText,
avatar: '/images/p1.png',
role: 'participant',
connectionId: 'room-1'
}],
currentUserId: 'other-user',
onlineUsersList,
usersContainer,
onlineUsersSummary
});
expect(usersContainer.textContent).toContain(unsafeText);
expect(usersContainer.querySelector('button')).toBeNull();
expect(usersContainer.querySelectorAll('img')).toHaveLength(1);
});
});

View File

@@ -1,18 +1,49 @@
import { jest } from '@jest/globals'; import { jest } from '@jest/globals';
import fs from 'fs';
import * as Path from 'path'; import * as Path from 'path';
import process from 'process';
import { setup, teardown } from 'jest-dev-server'; import { setup, teardown } from 'jest-dev-server';
import { Signaling, WebSocketSignaling } from "../../src/core/signaling.js"; import { Signaling, WebSocketSignaling } from "../../src/core/signaling.js";
import { MockSignaling, reset } from "../mocks/mocksignaling.js"; import { MockSignaling, reset } from "../mocks/mocksignaling.js";
import { waitFor, sleep, serverExeName } from "../helpers/testutils.js"; import { waitFor, sleep, serverExeName } from "../helpers/testutils.js";
const portNumber = 8081; const portNumber = 8081;
const runSignalingIntegration = process.env.RUN_SIGNALING_INTEGRATION === '1';
const signalingModes = runSignalingIntegration
? [{ mode: "mock" }, { mode: "http" }, { mode: "websocket" }]
: [{ mode: "mock" }];
jest.setTimeout(10000); jest.setTimeout(10000);
describe.each([ function buildServerCommand(args = '') {
{ mode: "mock" }, const binaryPath = Path.resolve(`../bin~/${serverExeName()}`);
{ mode: "http" }, const buildEntryPath = Path.resolve('../build/index.js');
{ mode: "websocket" }, const serverCommand = fs.existsSync(binaryPath)
])('signaling test in public mode', ({ mode }) => { ? `"${binaryPath}"`
: `"${process.execPath}" "${buildEntryPath}"`;
return `${serverCommand} ${args}`.trim();
}
function getPortForMode(mode, isPrivate) {
if (mode === 'mock') {
return portNumber;
}
const publicPorts = { http: portNumber + 1, websocket: portNumber + 2 };
const privatePorts = { http: portNumber + 3, websocket: portNumber + 4 };
return (isPrivate ? privatePorts : publicPorts)[mode];
}
function createHttpSignaling(port) {
return new Signaling(1, `http://localhost:${port}`);
}
function createWebSocketSignaling(port) {
return new WebSocketSignaling(1, `ws://localhost:${port}`);
}
describe.each(signalingModes)('signaling test in public mode', ({ mode }) => {
let signaling1; let signaling1;
let signaling2; let signaling2;
const connectionId1 = "12345"; const connectionId1 = "12345";
@@ -26,22 +57,22 @@ describe.each([
signaling1 = new MockSignaling(1); signaling1 = new MockSignaling(1);
signaling2 = new MockSignaling(1); signaling2 = new MockSignaling(1);
} else { } else {
const path = Path.resolve(`../bin~/${serverExeName()}`); const serverPort = getPortForMode(mode, false);
let cmd = `${path} -p ${portNumber}`; let cmd = buildServerCommand(`-p ${serverPort}`);
if (mode == "http") { if (mode == "http") {
cmd += " -t http"; cmd += " -t http";
} }
await setup({ command: cmd, port: portNumber, usedPortAction: 'error' }); await setup({ command: cmd, port: serverPort, usedPortAction: 'error' });
if (mode == "http") { if (mode == "http") {
signaling1 = new Signaling(1); signaling1 = createHttpSignaling(serverPort);
signaling2 = new Signaling(1); signaling2 = createHttpSignaling(serverPort);
} }
if (mode == "websocket") { if (mode == "websocket") {
signaling1 = new WebSocketSignaling(1); signaling1 = createWebSocketSignaling(serverPort);
signaling2 = new WebSocketSignaling(1); signaling2 = createWebSocketSignaling(serverPort);
} }
} }
@@ -50,8 +81,8 @@ describe.each([
}); });
afterAll(async () => { afterAll(async () => {
await signaling1.stop(); await signaling1?.stop();
await signaling2.stop(); await signaling2?.stop();
signaling1 = null; signaling1 = null;
signaling2 = null; signaling2 = null;
@@ -207,11 +238,7 @@ describe.each([
}); });
}); });
describe.each([ describe.each(signalingModes)('signaling test in private mode', ({ mode }) => {
{ mode: "mock" },
{ mode: "http" },
{ mode: "websocket" },
])('signaling test in private mode', ({ mode }) => {
let signaling1; let signaling1;
let signaling2; let signaling2;
const connectionId = "12345"; const connectionId = "12345";
@@ -226,22 +253,22 @@ describe.each([
return; return;
} }
const path = Path.resolve(`../bin~/${serverExeName()}`); const serverPort = getPortForMode(mode, true);
let cmd = `${path} -p ${portNumber} -m private`; let cmd = buildServerCommand(`-p ${serverPort} -m private`);
if (mode == "http") { if (mode == "http") {
cmd += " -t http"; cmd += " -t http";
} }
await setup({ command: cmd, port: portNumber, usedPortAction: 'error' }); await setup({ command: cmd, port: serverPort, usedPortAction: 'error' });
if (mode == "http") { if (mode == "http") {
signaling1 = new Signaling(1); signaling1 = createHttpSignaling(serverPort);
signaling2 = new Signaling(1); signaling2 = createHttpSignaling(serverPort);
} }
if (mode == "websocket") { if (mode == "websocket") {
signaling1 = new WebSocketSignaling(1); signaling1 = createWebSocketSignaling(serverPort);
signaling2 = new WebSocketSignaling(1); signaling2 = createWebSocketSignaling(serverPort);
} }
await signaling1.start(); await signaling1.start();
@@ -249,8 +276,8 @@ describe.each([
}); });
afterAll(async () => { afterAll(async () => {
await signaling1.stop(); await signaling1?.stop();
await signaling2.stop(); await signaling2?.stop();
signaling1 = null; signaling1 = null;
signaling2 = null; signaling2 = null;

View File

@@ -7,7 +7,7 @@ import Offer from './offer';
import Answer from './answer'; import Answer from './answer';
import Candidate from './candidate'; import Candidate from './candidate';
import { v4 as uuid } from 'uuid'; import { v4 as uuid } from 'uuid';
import { onGetAllConnectionIds, onGetOnlineUsers as onGetWsOnlineUsers, onGetRooms as onGetWsRooms } from './websockethandler'; import { onGetAllConnectionIds, onGetOnlineUsers as onGetWsOnlineUsers, } from './websockethandler';
import { log, LogLevel } from '../log'; import { log, LogLevel } from '../log';
/** /**
* 断开连接记录类 * 断开连接记录类
@@ -996,57 +996,6 @@ function postCandidate(req: Request, res: Response): void {
arr.push(candidate); arr.push(candidate);
res.sendStatus(200); res.sendStatus(200);
} }
/**
* @swagger
* /signaling/rooms:
* get:
* summary: 获取房间和用户信息
* description: 获取所有房间的信息包括房间ID和链接的用户
* security:
* - sessionAuth: []
* responses:
* 200:
* description: 成功获取房间和用户信息
* content:
* application/json:
* schema:
* type: object
* properties:
* rooms:
* type: array
* items:
* type: object
* properties:
* roomId:
* type: string
* description: 房间ID
* users:
* type: array
* items:
* type: object
* properties:
* sessionId:
* type: string
* description: 会话ID
* connected:
* type: boolean
* description: 连接状态
* userCount:
* type: number
* description: 用户数量
* totalRooms:
* type: number
* description: 总房间数
*/
function onGetConnections(req: Request, res: Response): void {
const connectionId = typeof req.query.connectionId === 'string' ? req.query.connectionId : undefined;
const wsRooms = onGetWsRooms(connectionId).map((room) => ({
...room,
users: room.members
}));
res.json({ rooms: wsRooms, totalRooms: wsRooms.length });
}
/** /**
* @swagger * @swagger
@@ -1160,7 +1109,6 @@ export {
postOffer, // 处理offer信令消息 postOffer, // 处理offer信令消息
postAnswer, // 处理answer信令消息 postAnswer, // 处理answer信令消息
postCandidate, // 处理candidate信令消息 postCandidate, // 处理candidate信令消息
onGetConnections, // 获取房间和用户信息
getAllConnectionIds, // 获取所有连接ID getAllConnectionIds, // 获取所有连接ID
getOnlineUsers // 获取在线WebSocket用户列表 getOnlineUsers // 获取在线WebSocket用户列表
}; };

View File

@@ -6,7 +6,7 @@ const router: express.Router = express.Router();
// 不需要会话ID的路由 // 不需要会话ID的路由
router.get('/connection-ids', handler.getAllConnectionIds); router.get('/connection-ids', handler.getAllConnectionIds);
router.get('/users', handler.getOnlineUsers); router.get('/users', handler.getOnlineUsers);
router.get('/rooms', handler.onGetConnections); // router.get('/rooms', handler.onGetConnections);
// 需要会话ID的路由 // 需要会话ID的路由
router.use(handler.checkSessionId); router.use(handler.checkSessionId);