优化
This commit is contained in:
@@ -1,8 +1,8 @@
|
||||
/* eslint-disable no-undef */
|
||||
import fetch from 'node-fetch';
|
||||
import { TextEncoder, TextDecoder } from 'util';
|
||||
import { PeerConnectionMock, SessionDescriptionMock, IceCandidateMock } from './test/peerconnectionmock';
|
||||
import ResizeObserverMock from './test/resizeobservermock';
|
||||
import { PeerConnectionMock, SessionDescriptionMock, IceCandidateMock } from './test/mocks/peerconnectionmock.js';
|
||||
import ResizeObserverMock from './test/helpers/resizeobservermock.js';
|
||||
|
||||
// note: If set testEnvironment `jest-environment-jsdom`, below classes are not defined.
|
||||
|
||||
@@ -32,4 +32,4 @@ if (!window.RTCIceCandidate) {
|
||||
|
||||
if (!window.ResizeObserver) {
|
||||
window.ResizeObserver = ResizeObserverMock;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { createTextElement, textValue } from '../../shared/dom.js';
|
||||
|
||||
export function createMessageElement(message, formatTimestamp) {
|
||||
const messageDiv = document.createElement('div');
|
||||
let messageClass = 'chat-bubble';
|
||||
@@ -13,31 +15,45 @@ export function createMessageElement(message, formatTimestamp) {
|
||||
messageDiv.className = messageClass;
|
||||
messageDiv.dataset.messageId = message.id;
|
||||
|
||||
const contentHTML = message.type === 'file' && message.content.startsWith('data:image/')
|
||||
? `
|
||||
<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>
|
||||
`;
|
||||
const header = document.createElement('div');
|
||||
header.className = 'message-header';
|
||||
|
||||
messageDiv.innerHTML = `
|
||||
<div class="message-header">
|
||||
<img src="${message.senderAvatar}" class="message-avatar">
|
||||
<div>
|
||||
<span class="message-sender">${message.senderName}</span>
|
||||
<span class="message-time">${formatTimestamp(message.timestamp)}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="message-content">
|
||||
${contentHTML}
|
||||
</div>
|
||||
`;
|
||||
const avatar = document.createElement('img');
|
||||
avatar.className = 'message-avatar';
|
||||
avatar.src = textValue(message.senderAvatar);
|
||||
avatar.alt = textValue(message.senderName, '\u7528\u6237');
|
||||
header.appendChild(avatar);
|
||||
|
||||
const headerText = document.createElement('div');
|
||||
headerText.appendChild(createTextElement('span', 'message-sender', message.senderName));
|
||||
headerText.appendChild(createTextElement('span', 'message-time', formatTimestamp(message.timestamp)));
|
||||
header.appendChild(headerText);
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { createIconElement, createTextElement, textValue } from '../../shared/dom.js';
|
||||
|
||||
function createParticipantPlaceholder() {
|
||||
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';
|
||||
@@ -15,32 +17,39 @@ function createParticipantPlaceholder() {
|
||||
export function createParticipantTile(connectionId, displayName) {
|
||||
const tile = document.createElement('div');
|
||||
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');
|
||||
video.className = 'w-full h-full object-contain';
|
||||
video.autoplay = true;
|
||||
video.playsinline = true;
|
||||
video.muted = false;
|
||||
video.id = `participantVideo_${connectionId}`;
|
||||
video.id = `participantVideo_${textValue(connectionId)}`;
|
||||
tile.appendChild(video);
|
||||
tile.appendChild(createParticipantPlaceholder());
|
||||
|
||||
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.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);
|
||||
|
||||
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.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);
|
||||
|
||||
return tile;
|
||||
}
|
||||
|
||||
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) {
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { createIconElement, createTextElement, textValue } from '../../shared/dom.js';
|
||||
|
||||
const DEFAULT_NETWORK_QUALITY = {
|
||||
label: '\u672a\u77e5',
|
||||
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') {
|
||||
return user.isHost
|
||||
? '<span class="text-xs bg-indigo-500 px-1.5 rounded ml-1">\u4e3b\u6301\u4eba</span>'
|
||||
: '<span class="text-xs bg-purple-500 px-1.5 rounded ml-1">\u53c2\u4e0e\u8005</span>';
|
||||
? { className: 'text-xs bg-indigo-500 px-1.5 rounded ml-1', label: '\u4e3b\u6301\u4eba' }
|
||||
: { className: 'text-xs bg-purple-500 px-1.5 rounded ml-1', label: '\u53c2\u4e0e\u8005' };
|
||||
}
|
||||
|
||||
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) {
|
||||
@@ -79,34 +81,55 @@ function getDatasetUserId(role, id) {
|
||||
}
|
||||
}
|
||||
|
||||
function getAvatarMarkup(user, role) {
|
||||
if (role === 'local') {
|
||||
return `<img src="${user.avatar}" class="w-10 h-10 rounded-full object-cover">`;
|
||||
}
|
||||
|
||||
return `
|
||||
<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 createAvatarImage(user) {
|
||||
const image = document.createElement('img');
|
||||
image.src = textValue(user.avatar);
|
||||
image.alt = textValue(user.name, '\u7528\u6237');
|
||||
image.className = 'w-10 h-10 rounded-full object-cover';
|
||||
return image;
|
||||
}
|
||||
|
||||
function getRightMarkup(mediaState, role, muteIconMarkup) {
|
||||
if (role !== 'participant') {
|
||||
return muteIconMarkup;
|
||||
function createAvatarElement(user, role) {
|
||||
if (role === 'local') {
|
||||
return createAvatarImage(user);
|
||||
}
|
||||
|
||||
const speakingMarkup = (mediaState.isSpeaking && mediaState.audio)
|
||||
? '<div class="audio-wave w-6"><span></span><span></span><span></span><span></span><span></span></div>'
|
||||
: '';
|
||||
const wrapper = document.createElement('div');
|
||||
wrapper.className = 'relative';
|
||||
wrapper.appendChild(createAvatarImage(user));
|
||||
|
||||
return `
|
||||
<div class="flex items-center gap-2">
|
||||
${muteIconMarkup}
|
||||
${speakingMarkup}
|
||||
</div>
|
||||
`;
|
||||
const statusDot = document.createElement('div');
|
||||
statusDot.className = 'absolute -bottom-1 -right-1 w-3 h-3 bg-green-500 rounded-full border-2 border-slate-900';
|
||||
wrapper.appendChild(statusDot);
|
||||
|
||||
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) {
|
||||
@@ -163,27 +186,40 @@ export function buildUserCountLabel(userCount) {
|
||||
export function createUserEntryElement({ user, role, id }) {
|
||||
const entry = document.createElement('div');
|
||||
const mediaMeta = getMediaStatusMeta(user.mediaState);
|
||||
const muteIconMarkup = mediaMeta.showMuteIcon
|
||||
? `<i class="${mediaMeta.muteIconClass}"></i>`
|
||||
const muteIcon = mediaMeta.showMuteIcon
|
||||
? createIconElement(mediaMeta.muteIconClass)
|
||||
: '';
|
||||
const baseClass = 'flex items-center gap-3 p-2 rounded-lg';
|
||||
const dataFieldAttr = role === 'local' ? ' data-field="localUser.mediaStatus"' : '';
|
||||
|
||||
entry.className = role === 'local'
|
||||
? `${baseClass} hover:bg-white/5`
|
||||
: `${baseClass} bg-white/5`;
|
||||
entry.dataset.userId = getDatasetUserId(role, id);
|
||||
entry.innerHTML = `
|
||||
${getAvatarMarkup(user, role)}
|
||||
<div class="flex-1">
|
||||
<div class="text-sm font-medium">
|
||||
${user.name}
|
||||
${getRoleTagMarkup(user, role)}
|
||||
</div>
|
||||
<div class="${mediaMeta.className}"${dataFieldAttr}>${mediaMeta.text}</div>
|
||||
</div>
|
||||
${getRightMarkup(user.mediaState, role, muteIconMarkup)}
|
||||
`;
|
||||
|
||||
entry.appendChild(createAvatarElement(user, role));
|
||||
|
||||
const details = document.createElement('div');
|
||||
details.className = 'flex-1';
|
||||
|
||||
const nameRow = document.createElement('div');
|
||||
nameRow.className = 'text-sm font-medium';
|
||||
nameRow.appendChild(document.createTextNode(textValue(user.name)));
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -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_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';
|
||||
@@ -10,13 +12,14 @@ const SELECT_LABEL = '\u9009\u62e9';
|
||||
const USER_COUNT_SUFFIX = '\u4eba';
|
||||
const ONLINE_USERS_SUMMARY_SUFFIX = ' \u4e2aWebSocket\u7528\u6237\u5728\u7ebf';
|
||||
|
||||
function escapeHtml(value) {
|
||||
return String(value || '')
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
function getRoleTagClass(role) {
|
||||
if (role === 'host') {
|
||||
return 'text-xs px-2 py-1 rounded-full bg-indigo-500/20 text-indigo-300';
|
||||
}
|
||||
if (role === 'participant') {
|
||||
return 'text-xs px-2 py-1 rounded-full bg-white/10 text-gray-300';
|
||||
}
|
||||
return 'text-xs px-2 py-1 rounded-full bg-emerald-500/20 text-emerald-300';
|
||||
}
|
||||
|
||||
export async function fetchOnlineUsers() {
|
||||
@@ -115,10 +118,8 @@ export function renderOnlineUsers({ users, currentUserId, onlineUsersList, users
|
||||
|
||||
const roomTitle = document.createElement('div');
|
||||
roomTitle.className = 'flex items-center justify-between mb-2';
|
||||
roomTitle.innerHTML = `
|
||||
<span class="text-sm font-medium text-white">${escapeHtml(groupName)}</span>
|
||||
<span class="text-xs text-gray-400">${roomUsers.length} ${USER_COUNT_SUFFIX}</span>
|
||||
`;
|
||||
roomTitle.appendChild(createTextElement('span', 'text-sm font-medium text-white', groupName));
|
||||
roomTitle.appendChild(createTextElement('span', 'text-xs text-gray-400', `${roomUsers.length} ${USER_COUNT_SUFFIX}`));
|
||||
section.appendChild(roomTitle);
|
||||
|
||||
const roomList = document.createElement('div');
|
||||
@@ -135,19 +136,31 @@ export function renderOnlineUsers({ users, currentUserId, onlineUsersList, users
|
||||
|
||||
const userItem = document.createElement('div');
|
||||
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">
|
||||
<img src="${escapeHtml(avatar)}" alt="${escapeHtml(userName)}" class="w-8 h-8 rounded-full object-cover">
|
||||
<div class="min-w-0">
|
||||
<div class="text-sm text-white truncate">${escapeHtml(userName)}</div>
|
||||
<div class="text-xs text-gray-400 truncate">${escapeHtml(identity)}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<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>` : ''}
|
||||
</div>
|
||||
`;
|
||||
|
||||
const profile = document.createElement('div');
|
||||
profile.className = 'flex items-center gap-3 min-w-0';
|
||||
|
||||
const avatarImage = document.createElement('img');
|
||||
avatarImage.src = textValue(avatar);
|
||||
avatarImage.alt = textValue(userName);
|
||||
avatarImage.className = 'w-8 h-8 rounded-full object-cover';
|
||||
profile.appendChild(avatarImage);
|
||||
|
||||
const info = document.createElement('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);
|
||||
});
|
||||
|
||||
|
||||
18
client/public/shared/dom.js
Normal file
18
client/public/shared/dom.js
Normal 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;
|
||||
}
|
||||
@@ -2,10 +2,11 @@ import * as Logger from "../utils/logger.js";
|
||||
|
||||
export class Signaling extends EventTarget {
|
||||
|
||||
constructor(interval = 1000) {
|
||||
constructor(interval = 1000, baseUrl = null) {
|
||||
super();
|
||||
this.running = false;
|
||||
this.interval = interval;
|
||||
this.baseUrl = baseUrl;
|
||||
this.sleep = msec => new Promise(resolve => setTimeout(resolve, msec));
|
||||
}
|
||||
|
||||
@@ -19,7 +20,7 @@ export class Signaling extends EventTarget {
|
||||
}
|
||||
|
||||
url(method, parameter = '') {
|
||||
let ret = location.origin + '/signaling';
|
||||
let ret = (this.baseUrl || location.origin) + '/signaling';
|
||||
if (method)
|
||||
ret += '/' + method;
|
||||
if (parameter)
|
||||
@@ -151,16 +152,17 @@ export class Signaling extends EventTarget {
|
||||
|
||||
export class WebSocketSignaling extends EventTarget {
|
||||
|
||||
constructor(interval = 1000) {
|
||||
constructor(interval = 1000, websocketUrl = null) {
|
||||
super();
|
||||
this.interval = interval;
|
||||
this.sleep = msec => new Promise(resolve => setTimeout(resolve, msec));
|
||||
|
||||
let websocketUrl;
|
||||
if (location.protocol === "https:") {
|
||||
websocketUrl = "wss://" + location.host;
|
||||
} else {
|
||||
websocketUrl = "ws://" + location.host;
|
||||
if (!websocketUrl) {
|
||||
if (location.protocol === "https:") {
|
||||
websocketUrl = "wss://" + location.host;
|
||||
} else {
|
||||
websocketUrl = "ws://" + location.host;
|
||||
}
|
||||
}
|
||||
|
||||
this.websocket = new WebSocket(websocketUrl);
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { jest } from '@jest/globals';
|
||||
import { MeetingRecorder } from '../../public/call/media/meeting-recorder.js';
|
||||
|
||||
class MediaStreamMock {
|
||||
@@ -107,7 +108,7 @@ describe('MeetingRecorder', () => {
|
||||
const result = await recorder.stop();
|
||||
|
||||
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(windowRef.URL.createObjectURL).not.toHaveBeenCalled();
|
||||
expect(recorder.isRecording()).toBe(false);
|
||||
|
||||
88
client/test/unit/rendering-safety.test.js
Normal file
88
client/test/unit/rendering-safety.test.js
Normal 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);
|
||||
});
|
||||
});
|
||||
@@ -1,18 +1,49 @@
|
||||
import { jest } from '@jest/globals';
|
||||
import fs from 'fs';
|
||||
import * as Path from 'path';
|
||||
import process from 'process';
|
||||
import { setup, teardown } from 'jest-dev-server';
|
||||
import { Signaling, WebSocketSignaling } from "../../src/core/signaling.js";
|
||||
import { MockSignaling, reset } from "../mocks/mocksignaling.js";
|
||||
import { waitFor, sleep, serverExeName } from "../helpers/testutils.js";
|
||||
|
||||
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);
|
||||
|
||||
describe.each([
|
||||
{ mode: "mock" },
|
||||
{ mode: "http" },
|
||||
{ mode: "websocket" },
|
||||
])('signaling test in public mode', ({ mode }) => {
|
||||
function buildServerCommand(args = '') {
|
||||
const binaryPath = Path.resolve(`../bin~/${serverExeName()}`);
|
||||
const buildEntryPath = Path.resolve('../build/index.js');
|
||||
const serverCommand = fs.existsSync(binaryPath)
|
||||
? `"${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 signaling2;
|
||||
const connectionId1 = "12345";
|
||||
@@ -26,22 +57,22 @@ describe.each([
|
||||
signaling1 = new MockSignaling(1);
|
||||
signaling2 = new MockSignaling(1);
|
||||
} else {
|
||||
const path = Path.resolve(`../bin~/${serverExeName()}`);
|
||||
let cmd = `${path} -p ${portNumber}`;
|
||||
const serverPort = getPortForMode(mode, false);
|
||||
let cmd = buildServerCommand(`-p ${serverPort}`);
|
||||
if (mode == "http") {
|
||||
cmd += " -t http";
|
||||
}
|
||||
|
||||
await setup({ command: cmd, port: portNumber, usedPortAction: 'error' });
|
||||
await setup({ command: cmd, port: serverPort, usedPortAction: 'error' });
|
||||
|
||||
if (mode == "http") {
|
||||
signaling1 = new Signaling(1);
|
||||
signaling2 = new Signaling(1);
|
||||
signaling1 = createHttpSignaling(serverPort);
|
||||
signaling2 = createHttpSignaling(serverPort);
|
||||
}
|
||||
|
||||
if (mode == "websocket") {
|
||||
signaling1 = new WebSocketSignaling(1);
|
||||
signaling2 = new WebSocketSignaling(1);
|
||||
signaling1 = createWebSocketSignaling(serverPort);
|
||||
signaling2 = createWebSocketSignaling(serverPort);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -50,8 +81,8 @@ describe.each([
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await signaling1.stop();
|
||||
await signaling2.stop();
|
||||
await signaling1?.stop();
|
||||
await signaling2?.stop();
|
||||
signaling1 = null;
|
||||
signaling2 = null;
|
||||
|
||||
@@ -207,11 +238,7 @@ describe.each([
|
||||
});
|
||||
});
|
||||
|
||||
describe.each([
|
||||
{ mode: "mock" },
|
||||
{ mode: "http" },
|
||||
{ mode: "websocket" },
|
||||
])('signaling test in private mode', ({ mode }) => {
|
||||
describe.each(signalingModes)('signaling test in private mode', ({ mode }) => {
|
||||
let signaling1;
|
||||
let signaling2;
|
||||
const connectionId = "12345";
|
||||
@@ -226,22 +253,22 @@ describe.each([
|
||||
return;
|
||||
}
|
||||
|
||||
const path = Path.resolve(`../bin~/${serverExeName()}`);
|
||||
let cmd = `${path} -p ${portNumber} -m private`;
|
||||
const serverPort = getPortForMode(mode, true);
|
||||
let cmd = buildServerCommand(`-p ${serverPort} -m private`);
|
||||
if (mode == "http") {
|
||||
cmd += " -t http";
|
||||
}
|
||||
|
||||
await setup({ command: cmd, port: portNumber, usedPortAction: 'error' });
|
||||
await setup({ command: cmd, port: serverPort, usedPortAction: 'error' });
|
||||
|
||||
if (mode == "http") {
|
||||
signaling1 = new Signaling(1);
|
||||
signaling2 = new Signaling(1);
|
||||
signaling1 = createHttpSignaling(serverPort);
|
||||
signaling2 = createHttpSignaling(serverPort);
|
||||
}
|
||||
|
||||
if (mode == "websocket") {
|
||||
signaling1 = new WebSocketSignaling(1);
|
||||
signaling2 = new WebSocketSignaling(1);
|
||||
signaling1 = createWebSocketSignaling(serverPort);
|
||||
signaling2 = createWebSocketSignaling(serverPort);
|
||||
}
|
||||
|
||||
await signaling1.start();
|
||||
@@ -249,8 +276,8 @@ describe.each([
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await signaling1.stop();
|
||||
await signaling2.stop();
|
||||
await signaling1?.stop();
|
||||
await signaling2?.stop();
|
||||
signaling1 = null;
|
||||
signaling2 = null;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user