Skip to content

Commit 39bc5a7

Browse files
author
shijiashuai
committed
feat: add frontend tests, E2E tests, UI polish, and media stats
- vitest: 39 unit tests for app.config, app.ui, app.peers, app.media - Playwright: 4 E2E scenarios (join/leave/empty room/two-tab) - UI: status dot indicator with pulse animation, mobile responsive layout, auto-fit video grid, larger touch targets, aria-labels - Media stats: real-time bitrate/resolution/loss/RTT/codec per peer
1 parent f16e709 commit 39bc5a7

16 files changed

+3330
-13
lines changed

e2e/package-lock.json

Lines changed: 75 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

e2e/package.json

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
{
2+
"private": true,
3+
"type": "module",
4+
"scripts": {
5+
"test": "playwright test",
6+
"test:headed": "playwright test --headed"
7+
},
8+
"devDependencies": {
9+
"@playwright/test": "^1.50.0"
10+
}
11+
}

e2e/playwright.config.js

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
import { defineConfig } from '@playwright/test';
2+
3+
export default defineConfig({
4+
testDir: '.',
5+
fullyParallel: false,
6+
retries: 0,
7+
timeout: 30000,
8+
use: {
9+
baseURL: 'http://localhost:8080',
10+
headless: true
11+
}
12+
});

e2e/room.spec.js

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
import { test, expect } from '@playwright/test';
2+
3+
test.describe('Room lifecycle', function () {
4+
test('join room → shows joined status and own ID', async function ({ page }) {
5+
await page.goto('/');
6+
await page.fill('#room', 'test-room');
7+
await page.click('#join');
8+
9+
await expect(page.locator('#status')).toHaveText(/|joined/i, { timeout: 5000 });
10+
await expect(page.locator('#myId')).not.toBeEmpty();
11+
await expect(page.locator('#join')).toHaveText('Leave');
12+
});
13+
14+
test('join with empty room name shows error', async function ({ page }) {
15+
await page.goto('/');
16+
await page.click('#join');
17+
18+
await expect(page.locator('#error')).toHaveText(//i, { timeout: 3000 });
19+
});
20+
21+
test('leave room → returns to idle state', async function ({ page }) {
22+
await page.goto('/');
23+
await page.fill('#room', 'test-room');
24+
await page.click('#join');
25+
26+
await expect(page.locator('#status')).toHaveText(/|joined/i, { timeout: 5000 });
27+
28+
await page.click('#join'); // Leave button
29+
await expect(page.locator('#status')).toHaveText(/|idle/i, { timeout: 5000 });
30+
await expect(page.locator('#join')).toHaveText('Join');
31+
await expect(page.locator('#members')).toHaveText(//);
32+
});
33+
34+
test('two tabs see each other in members list', async function ({ browser }) {
35+
var context = await browser.newContext();
36+
var pageA = await context.newPage();
37+
var pageB = await context.newPage();
38+
39+
await pageA.goto('/');
40+
await pageA.fill('#room', 'shared-room');
41+
await pageA.click('#join');
42+
await expect(pageA.locator('#status')).toHaveText(/|joined/i, { timeout: 5000 });
43+
var idA = await pageA.locator('#myId').textContent();
44+
45+
await pageB.goto('/');
46+
await pageB.fill('#room', 'shared-room');
47+
await pageB.click('#join');
48+
await expect(pageB.locator('#status')).toHaveText(/|joined/i, { timeout: 5000 });
49+
50+
// Both tabs should see each other
51+
await expect(pageA.locator('.members__list')).toContainText(idA, { timeout: 5000 });
52+
await expect(pageB.locator('.members__list')).toContainText(idA, { timeout: 5000 });
53+
54+
await context.close();
55+
});
56+
});

web/app.js

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import {
99
import { createMediaController } from './app.media.js';
1010
import { createPeerController } from './app.peers.js';
1111
import { createSignalingController } from './app.signaling.js';
12+
import { createStatsController } from './app.stats.js';
1213
import { createUI, getElements } from './app.ui.js';
1314

1415
const elements = getElements();
@@ -79,6 +80,8 @@ const signaling = createSignalingController({
7980
ui: ui
8081
});
8182

83+
const stats = createStatsController(state);
84+
8285
function bindEvents() {
8386
if (elements.joinBtn) {
8487
elements.joinBtn.addEventListener('click', function () {
@@ -172,6 +175,7 @@ function bindEvents() {
172175
}
173176

174177
bindEvents();
178+
stats.start();
175179
ui.initCapabilityHints();
176180
ui.renderMembers([]);
177181
ui.updateControls();

web/app.stats.js

Lines changed: 142 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,142 @@
1+
export function createStatsController(state) {
2+
var pollInterval = null;
3+
var prevStats = new Map();
4+
5+
function formatBitrate(bytes, deltaMs) {
6+
if (deltaMs <= 0 || bytes < 0) return '--';
7+
return Math.round(bytes * 8 / deltaMs) + ' kbps';
8+
}
9+
10+
function extractOutboundVideo(report) {
11+
for (var entry of report.values()) {
12+
if (entry.type === 'outbound-rtp' && entry.kind === 'video') {
13+
return entry;
14+
}
15+
}
16+
return null;
17+
}
18+
19+
function extractCandidatePair(report) {
20+
for (var entry of report.values()) {
21+
if (entry.type === 'candidate-pair' && entry.state === 'succeeded') {
22+
return entry;
23+
}
24+
}
25+
return null;
26+
}
27+
28+
function extractInboundRtp(report, kind) {
29+
for (var entry of report.values()) {
30+
if (entry.type === 'inbound-rtp' && entry.kind === kind) {
31+
return entry;
32+
}
33+
}
34+
return null;
35+
}
36+
37+
function extractTrack(report, kind) {
38+
for (var entry of report.values()) {
39+
if (entry.type === 'track' && entry.kind === kind) {
40+
return entry;
41+
}
42+
}
43+
return null;
44+
}
45+
46+
function computeStats(pc) {
47+
return pc.getStats().then(function (report) {
48+
var video = extractOutboundVideo(report);
49+
var audioIn = extractInboundRtp(report, 'audio');
50+
var videoIn = extractTrack(report, 'video');
51+
var pair = extractCandidatePair(report);
52+
var now = Date.now();
53+
var prev = prevStats.get(pc) || {};
54+
55+
var result = {
56+
videoBitrate: '--',
57+
audioLoss: '--',
58+
rtt: '--',
59+
resolution: '--',
60+
codec: '--'
61+
};
62+
63+
if (video) {
64+
var delta = now - (prev.timestamp || now);
65+
var bytesDelta = (video.bytesSent || 0) - (prev.bytesSent || 0);
66+
if (delta > 0 && bytesDelta > 0) {
67+
result.videoBitrate = formatBitrate(bytesDelta, delta);
68+
}
69+
if (video.frameWidth && video.frameHeight) {
70+
result.resolution = video.frameWidth + 'x' + video.frameHeight;
71+
}
72+
if (video.codecId) {
73+
for (var entry of report.values()) {
74+
if (entry.id === video.codecId && entry.mimeType) {
75+
result.codec = entry.mimeType.replace('video/', '');
76+
break;
77+
}
78+
}
79+
}
80+
}
81+
82+
if (audioIn) {
83+
var total = audioIn.packetsReceived || 0;
84+
var lost = audioIn.packetsLost || 0;
85+
if (total > 0) {
86+
result.audioLoss = (lost / total * 100).toFixed(1) + '%';
87+
}
88+
}
89+
90+
if (pair && typeof pair.currentRoundTripTime === 'number') {
91+
result.rtt = Math.round(pair.currentRoundTripTime * 1000) + ' ms';
92+
}
93+
94+
prevStats.set(pc, { timestamp: now, bytesSent: video ? video.bytesSent : 0 });
95+
return result;
96+
});
97+
}
98+
99+
function renderStats(peerId, stats) {
100+
var peer = state.peers.get(peerId);
101+
if (!peer || !peer.statsEl) {
102+
return;
103+
}
104+
peer.statsEl.innerHTML =
105+
'<span>' + stats.videoBitrate + '</span>' +
106+
'<span>' + stats.resolution + '</span>' +
107+
'<span>Loss ' + stats.audioLoss + '</span>' +
108+
'<span>RTT ' + stats.rtt + '</span>' +
109+
'<span>' + stats.codec + '</span>';
110+
}
111+
112+
function poll() {
113+
state.peers.forEach(function (peer, peerId) {
114+
if (!peer.pc || peer.pc.connectionState !== 'connected') {
115+
return;
116+
}
117+
computeStats(peer.pc).then(function (stats) {
118+
renderStats(peerId, stats);
119+
}).catch(function () {});
120+
});
121+
}
122+
123+
function start() {
124+
if (pollInterval) {
125+
return;
126+
}
127+
pollInterval = setInterval(poll, 2000);
128+
}
129+
130+
function stop() {
131+
if (pollInterval) {
132+
clearInterval(pollInterval);
133+
pollInterval = null;
134+
}
135+
prevStats.clear();
136+
}
137+
138+
return {
139+
start: start,
140+
stop: stop
141+
};
142+
}

web/app.ui.js

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -42,13 +42,27 @@ export function createUI(options) {
4242
return state.roomState;
4343
}
4444

45+
function statusDotClass() {
46+
if (state.peers.size > 0) {
47+
return 'status__dot--calling';
48+
}
49+
if (state.roomState === 'joined') {
50+
return 'status__dot--joined';
51+
}
52+
if (state.roomState === 'connecting' || state.roomState === 'reconnecting') {
53+
return 'status__dot--connecting';
54+
}
55+
return '';
56+
}
57+
4558
function updateControls() {
4659
const joined = state.roomState === 'joined' || state.roomState === 'reconnecting';
4760
const activeCall = state.peers.size > 0;
4861
const localReady = !!state.localStream;
4962

5063
if (elements.statusEl) {
51-
elements.statusEl.textContent = roomStateText[roomStatus()] || roomStateText.idle;
64+
var dotClass = statusDotClass();
65+
elements.statusEl.innerHTML = '<span class="status__dot ' + dotClass + '"></span>' + (roomStateText[roomStatus()] || roomStateText.idle);
5266
}
5367
if (elements.joinBtn) {
5468
elements.joinBtn.textContent = state.roomState === 'idle' ? 'Join' : 'Leave';
@@ -160,13 +174,18 @@ export function createUI(options) {
160174
video.autoplay = true;
161175
video.playsInline = true;
162176

177+
var statsDiv = document.createElement('div');
178+
statsDiv.className = 'video-tile__stats';
179+
163180
tile.appendChild(label);
164181
tile.appendChild(video);
182+
tile.appendChild(statsDiv);
165183
elements.videosEl.appendChild(tile);
166184

167185
peer.tileEl = tile;
168186
peer.labelEl = label;
169187
peer.videoEl = video;
188+
peer.statsEl = statsDiv;
170189
return video;
171190
}
172191

@@ -194,6 +213,7 @@ export function createUI(options) {
194213
peer.videoEl = null;
195214
peer.tileEl = null;
196215
peer.labelEl = null;
216+
peer.statsEl = null;
197217
}
198218

199219
function initCapabilityHints() {

0 commit comments

Comments
 (0)