Text channels are real Mumble channels with a marker in the description. Their messages travel as plugin data to everyone on the server, are kept by the clients and handed to people who connect later. Channels can be dropped in front of or behind another channel, with a line where they land. A description preview no longer sticks after a drop. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1170 lines
72 KiB
TypeScript
1170 lines
72 KiB
TypeScript
// Drives the built Electron app against a real Mumble server, with a headless second client.
|
|
// npm run build && MUMBLE_TEST_HOST=localhost:64739 npm run test:e2e
|
|
// Optional: UPLOAD_HOST + UPLOAD_KEY to also test file sharing, SHOTS_DIR to save screenshots.
|
|
import { _electron as electron } from 'playwright-core';
|
|
import electronPath from 'electron';
|
|
import assert from 'node:assert/strict';
|
|
import { mkdtempSync, writeFileSync } from 'node:fs';
|
|
import { tmpdir } from 'node:os';
|
|
import path from 'node:path';
|
|
import zlib from 'node:zlib';
|
|
import { createCodec } from '../../src/core/proto.ts';
|
|
import { encodeVoice, decodeVoice, type VoicePacket } from '../../src/core/voice-packet.ts';
|
|
import { MumbleClient } from '../../src/core/client.ts';
|
|
import type { Transport } from '../../src/core/transport.ts';
|
|
import { openTls } from '../../electron/tls-transport.ts';
|
|
import { generateIdentity } from '../../electron/identity.ts';
|
|
import { byOrder } from '../../src/core/channel-order.ts';
|
|
import { TEXT, TEXT_DATA_ID, TEXT_MARK, TextAssembler, encodeText, parseMsg, parseHistory } from '../../src/core/text-signal.ts';
|
|
|
|
const target = process.env.MUMBLE_TEST_HOST;
|
|
if (!target) {
|
|
console.log('MUMBLE_TEST_HOST not set, skipping');
|
|
process.exit(0);
|
|
}
|
|
const [host, port] = target.split(':');
|
|
const shots = process.env.SHOTS_DIR;
|
|
const root = path.resolve(import.meta.dirname, '../..');
|
|
|
|
function headless(username: string, password = ''): Promise<MumbleClient> {
|
|
const codec = createCodec();
|
|
const id = generateIdentity(username);
|
|
const client = new MumbleClient(codec);
|
|
const t: Transport = { onData: null, onClose: null, send: b => conn.send(b), close: () => conn.close() };
|
|
const conn = openTls(host, Number(port), id.certPem, id.keyPem, {
|
|
onSecure: () => {}, onData: c => t.onData?.(c), onClose: r => t.onClose?.(r)
|
|
});
|
|
client.connect(t, { username, password, os: 'test' });
|
|
return new Promise((res, rej) => { client.on('synced', () => res(client)); client.on('close', rej); });
|
|
}
|
|
|
|
// Noisy PNG, no image library needed. Noise keeps the JPEG preview big enough (over the
|
|
// server's 5000-char text limit) to exercise Murmur's XML check on image messages.
|
|
function testPng(w = 400, h = 300): Buffer {
|
|
const raw = Buffer.alloc((w * 3 + 1) * h);
|
|
for (let y = 0; y < h; y++) for (let x = 0; x < w; x++) {
|
|
raw.set([(x * 7 + Math.random() * 90) & 255, (y * 5 + Math.random() * 90) & 255, Math.random() * 255], y * (w * 3 + 1) + 1 + x * 3);
|
|
}
|
|
const chunk = (type: string, data: Buffer) => {
|
|
const len = Buffer.alloc(4); len.writeUInt32BE(data.length);
|
|
const td = Buffer.concat([Buffer.from(type), data]);
|
|
const crc = Buffer.alloc(4); crc.writeUInt32BE(zlib.crc32(td));
|
|
return Buffer.concat([len, td, crc]);
|
|
};
|
|
const ihdr = Buffer.alloc(13); ihdr.writeUInt32BE(w, 0); ihdr.writeUInt32BE(h, 4); ihdr.set([8, 2, 0, 0, 0], 8);
|
|
return Buffer.concat([Buffer.from('89504e470d0a1a0a', 'hex'), chunk('IHDR', ihdr), chunk('IDAT', zlib.deflateSync(raw)), chunk('IEND', Buffer.alloc(0))]);
|
|
}
|
|
|
|
// Fails with a label instead of hanging when an expected event never comes
|
|
function within<T>(p: Promise<T>, label: string, ms = 10000): Promise<T> {
|
|
return Promise.race([p, new Promise<T>((_, rej) => setTimeout(() => rej(new Error(`Timed out waiting for: ${label}`)), ms))]);
|
|
}
|
|
|
|
const { ELECTRON_RUN_AS_NODE, ...env } = process.env;
|
|
const userData = mkdtempSync(path.join(tmpdir(), 'mumh5-e2e-'));
|
|
const app = await electron.launch({
|
|
executablePath: electronPath as unknown as string,
|
|
// Stay on $DISPLAY (e.g. Xvfb); Electron would otherwise pick the Wayland session automatically
|
|
// Fake microphone (a beeping tone) and auto-granted permission for the voice tests
|
|
args: [root, `--user-data-dir=${userData}`, '--ozone-platform=x11', '--use-fake-device-for-media-stream', '--use-fake-ui-for-media-stream'],
|
|
env: env as Record<string, string>
|
|
});
|
|
const bob = await headless('bob');
|
|
// SuperUser can create channels; needs MUMBLE_SUPERUSER_PASSWORD on the test server
|
|
const admin = process.env.MUMBLE_SUPERUSER_PASSWORD ? await headless('SuperUser', process.env.MUMBLE_SUPERUSER_PASSWORD) : null;
|
|
// The text channel made further down; checked again after the app reconnects
|
|
let notesId: number | null = null;
|
|
try {
|
|
const page = await app.firstWindow();
|
|
page.on('console', m => { if (m.type() === 'error') console.log('[renderer]', m.text()); });
|
|
await page.setViewportSize({ width: 1280, height: 800 });
|
|
|
|
// First start: identity setup wizard
|
|
await page.getByRole('dialog', { name: 'Set up your identity' }).waitFor();
|
|
await page.getByRole('button', { name: /Create a new identity/ }).click();
|
|
await page.getByLabel('Name', { exact: true }).fill('alice');
|
|
assert.ok(await page.getByLabel('Use this name as my username').isChecked());
|
|
await page.getByRole('button', { name: 'Create', exact: true }).click();
|
|
await page.getByRole('button', { name: 'Skip for now' }).click();
|
|
await page.getByRole('button', { name: 'Done' }).click();
|
|
await page.getByRole('dialog', { name: 'Set up your identity' }).waitFor({ state: 'detached' });
|
|
console.log('ok: identity wizard creates an identity');
|
|
|
|
// Welcome page: no message box while offline, identities one click away
|
|
assert.equal(await page.locator('.chat .composer').count(), 0);
|
|
await page.getByRole('button', { name: 'Manage identities' }).click();
|
|
await page.getByRole('tab', { name: 'Identities', selected: true }).waitFor();
|
|
await page.getByRole('button', { name: 'Done', exact: true }).click();
|
|
console.log('ok: welcome page without composer, manage identities');
|
|
|
|
// Mute and deafen are there without a connection
|
|
const me = page.locator('.me');
|
|
await me.getByText('Not connected').waitFor();
|
|
assert.ok(await me.getByRole('button', { name: 'Server information' }).isDisabled());
|
|
await me.getByRole('button', { name: 'Deafen', exact: true }).click();
|
|
await me.getByRole('button', { name: 'Unmute', exact: true }).waitFor();
|
|
await me.getByText('Deafened').waitFor();
|
|
await me.getByRole('button', { name: 'Unmute', exact: true }).click();
|
|
await me.getByRole('button', { name: 'Deafen', exact: true }).waitFor();
|
|
await me.getByRole('button', { name: 'Mute', exact: true }).waitFor();
|
|
console.log('ok: mute and deafen shown while disconnected');
|
|
|
|
// Settings: upload host + key
|
|
if (process.env.UPLOAD_HOST && process.env.UPLOAD_KEY) {
|
|
await page.getByTitle('Settings').click();
|
|
await page.getByRole('tab', { name: 'Chat and files' }).click();
|
|
await page.getByLabel('Upload host').fill(process.env.UPLOAD_HOST);
|
|
await page.getByLabel('Upload key').fill(process.env.UPLOAD_KEY);
|
|
await page.getByRole('button', { name: 'Test key' }).click();
|
|
await page.getByText(/^Key works\./).waitFor();
|
|
await page.getByRole('button', { name: 'Done', exact: true }).click();
|
|
}
|
|
|
|
// Add and connect to the server
|
|
await page.getByTitle('Add a server').click();
|
|
await page.getByLabel('Address').fill(host);
|
|
await page.getByLabel('Port').fill(port);
|
|
assert.equal(await page.getByLabel('Username').inputValue(), 'alice', 'username suggested from the identity');
|
|
await page.getByLabel('Label').fill('Test Server');
|
|
await page.getByRole('button', { name: 'Save and connect' }).click();
|
|
await page.getByText(/Connected/).first().waitFor();
|
|
console.log('ok: connected');
|
|
|
|
// Bob is listed in the channel tree
|
|
await page.locator('.sidebar').getByText('bob').waitFor();
|
|
console.log('ok: sees bob');
|
|
|
|
// ─── Voice ───────────────────────────────────────────────────────────────
|
|
const aliceSession = () => [...bob.users.values()].find(u => u.name === 'alice')!.session;
|
|
const collect = (n: number, stopOnLast = false) => new Promise<VoicePacket[]>(res => {
|
|
const got: VoicePacket[] = [];
|
|
const off = bob.on('voice', raw => {
|
|
const p = decodeVoice(raw);
|
|
if (!p || p.session !== aliceSession()) return;
|
|
got.push(p);
|
|
if (got.length >= n || (stopOnLast && p.last)) { off(); res(got); }
|
|
});
|
|
});
|
|
const setMode = async (label: string) => {
|
|
await page.getByTitle('Settings').click();
|
|
await page.getByRole('radio', { name: new RegExp(label) }).click();
|
|
await page.getByRole('button', { name: 'Done', exact: true }).click();
|
|
};
|
|
|
|
// Always on: a steady stream of 20 ms Opus packets
|
|
const stream = collect(25);
|
|
await setMode('Always on');
|
|
// Generous: on a freshly started server and fake microphone the first packets can take a while
|
|
const packets = await within(stream, 'voice packets from the app', 20000);
|
|
assert.ok(packets.every(p => p.opus.length > 0 && !p.last));
|
|
const steps = packets.slice(1).map((p, i) => p.frame - packets[i].frame);
|
|
assert.ok(steps.every(d => d === 2), `sequence advances 2 frames per 20 ms packet: ${steps.join(',')}`);
|
|
console.log(`ok: app sends Opus voice (${packets[0].opus.length} bytes per packet)`);
|
|
|
|
// Echo alice's own audio back as bob: the app decodes it and shows bob speaking
|
|
const echo = packets.map((p, i) => encodeVoice({ target: 0, frame: i * 2, opus: p.opus, last: i === packets.length - 1 }, bob.protobufVoice));
|
|
const bobRow = page.locator('.sidebar .user', { hasText: 'bob' });
|
|
const pump = setInterval(() => { const pk = echo.shift(); if (pk) bob.sendVoiceTunnel(pk); }, 20);
|
|
await bobRow.locator('.avatar.speaking').waitFor({ timeout: 3000 });
|
|
console.log('ok: incoming voice decoded, speaker highlighted');
|
|
await bobRow.locator('.avatar.speaking').waitFor({ state: 'detached', timeout: 3000 });
|
|
clearInterval(pump);
|
|
console.log('ok: speaking indicator clears after the terminator');
|
|
|
|
// Push to talk: silent until the key is held, terminator on release
|
|
const ended = collect(1000, true);
|
|
await setMode('Push to talk');
|
|
const tail = await within(ended, 'terminator after leaving always-on');
|
|
assert.equal(tail.at(-1)!.last, true);
|
|
let leaked = 0;
|
|
const offLeak = bob.on('voice', raw => { if (decodeVoice(raw)?.session === aliceSession()) leaked++; });
|
|
await page.waitForTimeout(400);
|
|
offLeak();
|
|
assert.equal(leaked, 0, 'no voice while the push-to-talk key is up');
|
|
await page.locator('.chat h1').click();
|
|
const ptt = collect(1000, true);
|
|
await page.keyboard.down('Backquote');
|
|
await page.waitForTimeout(300);
|
|
await page.keyboard.up('Backquote');
|
|
const burst = await within(ptt, 'push-to-talk burst');
|
|
assert.ok(burst.length >= 8 && burst.length <= 30, `about 300 ms of audio (${burst.length} packets)`);
|
|
assert.equal(burst.at(-1)!.last, true);
|
|
console.log(`ok: push to talk (${burst.length} packets, ends with terminator)`);
|
|
|
|
// Voice activity with the fake beeping microphone: transmits in bursts
|
|
await setMode('Voice activity');
|
|
const vad = await within(collect(10), 'voice activity packets', 8000);
|
|
assert.ok(vad.length === 10);
|
|
const biggest = Math.max(...vad.map(p => p.opus.length));
|
|
assert.ok(biggest > 40, `packets during the tone carry real audio (largest ${biggest} bytes)`);
|
|
console.log(`ok: voice activity detection transmits on the tone (up to ${biggest} bytes per packet)`);
|
|
|
|
// The app's own statistics confirm it decoded the echoed audio
|
|
await page.getByTitle('Settings').click();
|
|
const statsText = await page.locator('.stats').textContent();
|
|
const decoded = Number(/decoded (\d+)/.exec(statsText ?? '')?.[1]);
|
|
assert.ok(decoded >= 20, `decoded frames reported: ${statsText}`);
|
|
// Voice runs over encrypted UDP once the server answers UDP pings
|
|
await page.locator('.stats', { hasText: /Transport: UDP, \d+ ms ping/ }).waitFor({ timeout: 10000 });
|
|
await page.getByLabel(/Send voice over TCP only/).check();
|
|
await page.locator('.stats', { hasText: 'Transport: TCP tunnel' }).waitFor();
|
|
await page.getByLabel(/Send voice over TCP only/).uncheck();
|
|
await page.locator('.stats', { hasText: /Transport: UDP/ }).waitFor();
|
|
console.log('ok: voice transport UDP, with a TCP-only switch');
|
|
await page.getByRole('button', { name: 'Done', exact: true }).click();
|
|
console.log(`ok: ${decoded} frames decoded`);
|
|
await setMode('Push to talk');
|
|
await page.waitForTimeout(300);
|
|
|
|
// Alice -> channel
|
|
const gotText = new Promise<string>(res => bob.on('text', m => res(m.html)));
|
|
await page.getByLabel('Message').fill('hello <from> alice\nsecond line https://example.com/x');
|
|
await page.getByLabel('Message').press('Enter');
|
|
const html = await within(gotText, 'channel text');
|
|
assert.equal(html, 'hello <from> alice<br/>second line <a href="https://example.com/x">https://example.com/x</a>');
|
|
console.log('ok: bob received channel text');
|
|
|
|
// Bob -> channel, rendered sanitized
|
|
bob.sendText({ channels: [0] }, 'hi alice <b>bold</b><script>window.pwned=1</script><img src="https://evil.example/t.png">');
|
|
await page.locator('.msg', { hasText: 'hi alice' }).waitFor();
|
|
assert.equal(await page.evaluate(() => (window as any).pwned), undefined);
|
|
assert.equal(await page.locator('.msg b', { hasText: 'bold' }).count(), 1);
|
|
assert.equal(await page.locator('img[src^="https://evil"]').count(), 0);
|
|
// The full date appears at the right edge while the pointer is on a message
|
|
const anyMsg = page.locator('.chat .msg').last();
|
|
const stamp = anyMsg.locator('.stamp');
|
|
assert.equal(await stamp.evaluate(el => getComputedStyle(el).visibility), 'hidden');
|
|
await anyMsg.hover();
|
|
assert.equal(await stamp.evaluate(el => getComputedStyle(el).visibility), 'visible');
|
|
assert.match(await stamp.innerText(), /\d{4}.*\d{1,2}:\d{2}:\d{2}/);
|
|
const gap = await anyMsg.evaluate(el => el.getBoundingClientRect().right - el.querySelector('.stamp')!.getBoundingClientRect().right);
|
|
assert.ok(gap >= 0 && gap < 20, `stamp at the right edge: ${gap}`);
|
|
await page.mouse.move(5, 5);
|
|
console.log('ok: incoming text rendered and sanitized');
|
|
|
|
// Right-click menus for text: composer editing, links, messages, authors
|
|
const composer = page.locator('.chat .composer [contenteditable]');
|
|
const composerText = async () => (await composer.innerText()).trim();
|
|
const menuItem = (name: string) => page.getByRole('menuitem', { name, exact: true });
|
|
await composer.fill('abc 123');
|
|
await composer.click({ button: 'right' });
|
|
await menuItem('Select all').click();
|
|
await composer.click({ button: 'right' });
|
|
await menuItem('Cut').click();
|
|
assert.equal(await composerText(), '');
|
|
await composer.click({ button: 'right' });
|
|
await menuItem('Paste').click();
|
|
assert.equal(await composerText(), 'abc 123');
|
|
await composer.fill('');
|
|
await page.locator('.msg a[href="https://example.com/x"]').click({ button: 'right' });
|
|
await menuItem('Copy link address').click();
|
|
await composer.click({ button: 'right' });
|
|
await menuItem('Paste').click();
|
|
assert.equal(await composerText(), 'https://example.com/x');
|
|
await composer.fill('');
|
|
await page.locator('.msg .html', { hasText: 'hi alice' }).click({ button: 'right' });
|
|
await menuItem('Copy message text').waitFor();
|
|
await page.keyboard.press('Escape');
|
|
await page.locator('.msg header strong', { hasText: 'bob' }).first().click({ button: 'right' });
|
|
await menuItem('Send message').waitFor();
|
|
await page.keyboard.press('Escape');
|
|
console.log('ok: right-click menus for text fields, links, messages and authors');
|
|
|
|
// Formatting in the message box: toolbar behind the Aa button, sent as Mumble-compatible HTML
|
|
await page.locator('.chat .composer').getByRole('button', { name: 'Text formatting' }).click();
|
|
await composer.fill('bold words https://example.com/b');
|
|
await composer.press('Control+a');
|
|
const gotBold = new Promise<string>(res => bob.on('text', m => { if (m.html.includes('bold words')) res(m.html); }));
|
|
await page.locator('.chat .composer').getByRole('button', { name: 'Bold (Ctrl+B)' }).click();
|
|
await composer.press('Enter');
|
|
const bold = await within(gotBold, 'formatted message');
|
|
assert.match(bold, /(<b>|font-weight:bold)/, `bold arrives: ${bold}`);
|
|
assert.match(bold, /<a href="https:\/\/example\.com\/b">/, 'links still work in formatted messages');
|
|
await page.locator('.chat .composer').getByRole('button', { name: 'Text formatting' }).click();
|
|
console.log('ok: formatted message from the toolbar');
|
|
|
|
// YouTube links are plain links by default
|
|
bob.sendText({ channels: [0] }, 'watch <a href="https://youtu.be/dQw4w9WgXcQ?t=1m30s">https://youtu.be/dQw4w9WgXcQ?t=1m30s</a>');
|
|
const ytMsg = page.locator('.msg', { hasText: 'watch' });
|
|
await ytMsg.getByRole('link', { name: 'https://youtu.be/dQw4w9WgXcQ?t=1m30s' }).waitFor();
|
|
assert.equal(await ytMsg.locator('.yt').count(), 0, 'no YouTube card by default');
|
|
// Turned on, they become a click-to-play card; nothing loads from YouTube before the click
|
|
await page.getByTitle('Settings').click();
|
|
await page.getByRole('tab', { name: 'Chat and files' }).click();
|
|
await page.getByLabel('YouTube links').selectOption('click');
|
|
await page.getByRole('button', { name: 'Done', exact: true }).click();
|
|
const card = ytMsg.locator('.yt');
|
|
await card.getByText('from 1:30, click to play').waitFor();
|
|
assert.equal(await card.locator('iframe').count(), 0);
|
|
assert.equal(await card.locator('.facade').evaluate(el => getComputedStyle(el).backgroundImage), 'none', 'no thumbnail request in click mode');
|
|
await card.locator('.facade').click();
|
|
assert.equal(await card.locator('iframe').getAttribute('src'), 'https://www.youtube-nocookie.com/embed/dQw4w9WgXcQ?autoplay=1&rel=0&start=90');
|
|
console.log('ok: YouTube link plain by default, click-to-play when turned on');
|
|
|
|
// Bob -> DM
|
|
const alice = [...bob.users.values()].find(u => u.name === 'alice')!;
|
|
bob.sendText({ users: [alice.session] }, 'psst');
|
|
await page.locator('.sidebar').getByText('Direct messages').waitFor();
|
|
await page.locator('.dm', { hasText: 'bob' }).click();
|
|
await page.locator('.msg', { hasText: 'psst' }).waitFor();
|
|
console.log('ok: direct message shown in its own view');
|
|
const soundLog = () => page.evaluate(() => [...((window as any).__mumh5Sounds ?? [])] as string[]);
|
|
const played = await soundLog();
|
|
for (const ev of ['connected', 'message', 'privateMessage']) assert.ok(played.includes(ev), `sound for ${ev}: ${played}`);
|
|
console.log('ok: sounds for connect, channel and direct messages');
|
|
await page.locator('.channel .name').first().dblclick();
|
|
|
|
// Mute toggles propagate
|
|
const muted = new Promise<boolean>(res => bob.on('user', (u, changed) => { if (u.name === 'alice' && changed.includes('selfMute')) res(u.selfMute); }));
|
|
await page.locator('.me').getByRole('button', { name: 'Mute', exact: true }).click();
|
|
assert.equal(await within(muted, 'mute state'), true);
|
|
await page.locator('.me').getByRole('button', { name: 'Unmute', exact: true }).waitFor();
|
|
|
|
// Right-click on mute / deafen opens quick audio options
|
|
await page.locator('.me').getByRole('button', { name: 'Unmute', exact: true }).click({ button: 'right' });
|
|
const micPop = page.getByRole('dialog', { name: 'Microphone' });
|
|
await micPop.getByLabel('Microphone').waitFor();
|
|
await micPop.getByRole('radio', { name: 'Always on' }).waitFor();
|
|
await micPop.getByLabel(/Input volume/).fill('1.5');
|
|
await micPop.getByText('Input volume: 150%').waitFor();
|
|
await page.keyboard.press('Escape');
|
|
await micPop.waitFor({ state: 'detached' });
|
|
await page.locator('.me').getByRole('button', { name: 'Deafen', exact: true }).click({ button: 'right' });
|
|
const outPop = page.getByRole('dialog', { name: 'Output' });
|
|
await outPop.getByLabel('Output device').waitFor();
|
|
await outPop.getByRole('button', { name: 'Voice settings...' }).click();
|
|
await page.getByRole('tab', { name: 'Voice', selected: true }).waitFor();
|
|
await page.getByText('Microphone volume: 150%').waitFor();
|
|
await page.getByRole('button', { name: 'Done', exact: true }).click();
|
|
console.log('ok: quick audio options on right-click');
|
|
|
|
// STUN server for screen sharing: none by default, presets, or a typed address
|
|
await page.getByTitle('Settings').click();
|
|
const stun = page.getByLabel('STUN server', { exact: true });
|
|
assert.equal(await stun.inputValue(), '');
|
|
await stun.selectOption({ label: 'Cloudflare (stun.cloudflare.com:3478)' });
|
|
assert.equal(await page.evaluate(() => JSON.parse(localStorage.getItem('mumh5.settings')!).stunServer), 'stun.cloudflare.com:3478');
|
|
await stun.selectOption('custom');
|
|
await page.getByLabel('STUN server address').fill('stun.example.org:3478');
|
|
await page.getByLabel('STUN server address').blur();
|
|
assert.equal(await page.evaluate(() => JSON.parse(localStorage.getItem('mumh5.settings')!).stunServer), 'stun.example.org:3478');
|
|
await stun.selectOption('');
|
|
assert.equal(await page.evaluate(() => JSON.parse(localStorage.getItem('mumh5.settings')!).stunServer), '');
|
|
await page.getByRole('button', { name: 'Done', exact: true }).click();
|
|
console.log('ok: STUN server choices');
|
|
|
|
// Color schemes apply right away and are remembered
|
|
await page.getByTitle('Settings').click();
|
|
await page.getByRole('tab', { name: 'Appearance' }).click();
|
|
await page.getByRole('radio', { name: 'Windows 95' }).click();
|
|
assert.equal(await page.evaluate(() => document.documentElement.dataset.theme), 'win95');
|
|
assert.equal(await page.locator('.rail').evaluate(el => getComputedStyle(el).backgroundColor), 'rgb(0, 128, 128)');
|
|
await page.getByRole('radio', { name: 'Dark' }).click();
|
|
assert.equal(await page.evaluate(() => document.documentElement.dataset.theme), undefined);
|
|
// Hints are off by default: the title under the pointer is set aside while hovered, and back afterwards
|
|
const hintTarget = page.locator('.rail').getByRole('button', { name: 'Add a server' });
|
|
const titleOf = () => page.locator('.rail .tile.add').first().getAttribute('title');
|
|
await page.getByRole('tab', { name: 'Accessibility' }).click();
|
|
assert.equal(await page.getByLabel('Show hints when the pointer rests').isChecked(), false);
|
|
await page.getByRole('button', { name: 'Done', exact: true }).click();
|
|
await hintTarget.hover();
|
|
assert.equal(await titleOf(), null, 'no tooltip while hints are off');
|
|
await page.mouse.move(700, 400);
|
|
assert.equal(await titleOf(), 'Add a server');
|
|
await page.getByTitle('Settings').click();
|
|
await page.getByRole('tab', { name: 'Accessibility' }).click();
|
|
await page.getByLabel('Show hints when the pointer rests').check();
|
|
await page.getByRole('button', { name: 'Done', exact: true }).click();
|
|
await hintTarget.hover();
|
|
assert.equal(await titleOf(), 'Add a server', 'tooltip kept while hints are on');
|
|
await page.mouse.move(700, 400);
|
|
await page.getByTitle('Settings').click();
|
|
await page.getByRole('tab', { name: 'Accessibility' }).click();
|
|
await page.getByLabel('Show hints when the pointer rests').uncheck();
|
|
await page.getByRole('button', { name: 'Done', exact: true }).click();
|
|
console.log('ok: color schemes, hints off by default and can be turned on');
|
|
|
|
// Layouts like the desktop client: classic (channels right of chat), stacked (channels above)
|
|
const box = (sel: string) => page.locator(sel).first().boundingBox().then(b => b!);
|
|
await page.getByTitle('Settings').click();
|
|
await page.getByRole('tab', { name: 'Appearance' }).click();
|
|
await page.getByRole('radio', { name: 'Classic' }).click();
|
|
await page.getByRole('button', { name: 'Done', exact: true }).click();
|
|
await page.locator('.app.classic').waitFor();
|
|
assert.ok((await box('.sidebar')).x > (await box('.chat')).x, 'classic: channel list right of chat');
|
|
await page.getByTitle('Settings').click();
|
|
await page.getByRole('tab', { name: 'Appearance' }).click();
|
|
await page.getByRole('radio', { name: 'Stacked' }).click();
|
|
await page.getByRole('button', { name: 'Done', exact: true }).click();
|
|
await page.locator('.app.stacked').waitFor();
|
|
const chatBox = await box('.chat'), treeBox = await box('.sidebar');
|
|
assert.ok(chatBox.y >= treeBox.y + treeBox.height - 1, 'stacked: chat below channel list');
|
|
// The split between chat and channel list drags
|
|
const handle = page.getByRole('separator', { name: 'Resize channel list' });
|
|
const hb = (await handle.boundingBox())!;
|
|
await page.mouse.move(hb.x + hb.width / 2, hb.y + hb.height / 2);
|
|
await page.mouse.down();
|
|
await page.mouse.move(hb.x + hb.width / 2, hb.y + 60, { steps: 4 });
|
|
await page.mouse.up();
|
|
assert.ok((await box('.sidebar')).height > treeBox.height + 40, 'stacked: split resizes');
|
|
await page.getByTitle('Settings').click();
|
|
await page.getByRole('tab', { name: 'Appearance' }).click();
|
|
await page.getByRole('radio', { name: 'Modern' }).click();
|
|
await page.getByRole('button', { name: 'Done', exact: true }).click();
|
|
await page.locator('.app.side').waitFor();
|
|
console.log('ok: classic and stacked layouts');
|
|
|
|
// Public server list, from a local list in the official XML format
|
|
const http = await import('node:http');
|
|
const listServer = http.createServer((_req, res) => {
|
|
res.writeHead(200, { 'Content-Type': 'text/xml' });
|
|
res.end(`<?xml version="1.0"?><servers><server name="E2E Murmur" ip="${host}" port="${port}" country="Testland" country_code="TL" continent_code="EU" url="https://example.org" ca="0"/><server name="Offline one" ip="127.0.0.1" port="9" country="Nowhere" continent_code="NA"/></servers>`);
|
|
}).listen(0);
|
|
const listPort = (listServer.address() as { port: number }).port;
|
|
await page.getByTitle('Browse public servers').click();
|
|
const pub = page.getByRole('dialog', { name: 'Public servers' });
|
|
await pub.locator('.link', { hasText: 'List source' }).click();
|
|
await page.getByLabel('URL').fill(`http://127.0.0.1:${listPort}/v1/list`);
|
|
await page.getByRole('button', { name: 'Use this list' }).click();
|
|
const row = pub.locator('tr', { hasText: 'E2E Murmur' });
|
|
await row.getByText(/^\d+ \/ \d+$/).waitFor({ timeout: 8000 });
|
|
await row.getByText(/ms$/).waitFor();
|
|
await row.getByRole('button', { name: 'Connect' }).waitFor();
|
|
// Column headers sort; a second click reverses
|
|
const firstRow = pub.locator('tbody tr').first();
|
|
await pub.getByRole('button', { name: 'Server', exact: true }).click();
|
|
await firstRow.getByText('E2E Murmur').waitFor();
|
|
await pub.getByRole('button', { name: 'Server', exact: true }).click();
|
|
await firstRow.getByText('Offline one').waitFor();
|
|
await pub.getByRole('button', { name: 'People', exact: true }).click();
|
|
await firstRow.getByText('E2E Murmur').waitFor();
|
|
await row.getByRole('button', { name: 'Add' }).click();
|
|
assert.equal(await page.getByLabel('Address').inputValue(), host);
|
|
assert.equal(await page.getByLabel('Port').inputValue(), port);
|
|
assert.equal(await page.getByLabel('Label').inputValue(), 'E2E Murmur');
|
|
await page.getByRole('button', { name: 'Cancel' }).click();
|
|
listServer.close();
|
|
console.log('ok: public server list with live ping and prefilled add');
|
|
|
|
// Server menus are titled with the server's name
|
|
await page.locator('.rail .tile').first().click({ button: 'right' });
|
|
await page.locator('.menu .head', { hasText: 'Test Server' }).waitFor();
|
|
await page.keyboard.press('Escape');
|
|
|
|
// Tray icon mirrors the voice state; its menu acts on it
|
|
const trayState = () => app.evaluate(() => (globalThis as any).__mumh5Tray && {
|
|
tooltip: (globalThis as any).__mumh5Tray.tooltip, items: (globalThis as any).__mumh5Tray.items, icon: (globalThis as any).__mumh5Tray.state.icon
|
|
});
|
|
const trayUntil = async (label: string, pred: (t: any) => boolean) => {
|
|
for (let i = 0; i < 50; i++) { const t = await trayState(); if (t && pred(t)) return t; await page.waitForTimeout(100); }
|
|
throw new Error(`Tray never reached: ${label} (${JSON.stringify(await trayState())})`);
|
|
};
|
|
const tMuted = await trayUntil('muted', t => /muted on Test Server/.test(t.tooltip));
|
|
assert.equal(tMuted.icon, 'data:image/png;base64,');
|
|
assert.equal(tMuted.items.find((i: any) => i.label === 'Mute').checked, true);
|
|
await app.evaluate(() => (globalThis as any).__mumh5Tray.click('Mute'));
|
|
await within(new Promise<void>(res => bob.on('user', (u, ch) => { if (u.name === 'alice' && ch.includes('selfMute') && !u.selfMute) res(); })), 'unmuted from the tray');
|
|
await trayUntil('connected', t => /connected on Test Server/.test(t.tooltip) && !t.items.find((i: any) => i.label === 'Mute').checked);
|
|
await app.evaluate(() => (globalThis as any).__mumh5Tray.click('Mute'));
|
|
await trayUntil('muted again', t => /muted/.test(t.tooltip));
|
|
console.log('ok: tray icon follows voice state and its menu mutes');
|
|
await page.locator('.channel + .users, .users').getByTitle('Muted', { exact: true }).first().waitFor();
|
|
console.log('ok: self mute propagated and shown');
|
|
assert.ok((await soundLog()).includes('mute'), 'mute sound');
|
|
|
|
// Sound pack in TeamSpeak 3 format: settings.ini maps events to files exactly
|
|
await page.getByTitle('Settings').click();
|
|
await page.getByRole('tab', { name: 'Sounds' }).click();
|
|
// Real 16-bit PCM WAVs (a short tone), like the files in a TeamSpeak pack
|
|
const wav = (name: string) => {
|
|
const rate = 22050, n = rate / 10, b = Buffer.alloc(44 + n * 2);
|
|
b.write('RIFF', 0); b.writeUInt32LE(36 + n * 2, 4); b.write('WAVEfmt ', 8); b.writeUInt32LE(16, 16); b.writeUInt16LE(1, 20); b.writeUInt16LE(1, 22);
|
|
b.writeUInt32LE(rate, 24); b.writeUInt32LE(rate * 2, 28); b.writeUInt16LE(2, 32); b.writeUInt16LE(16, 34); b.write('data', 36); b.writeUInt32LE(n * 2, 40);
|
|
for (let i = 0; i < n; i++) b.writeInt16LE(Math.round(Math.sin(i / 8) * 8000), 44 + i * 2);
|
|
return { name, mimeType: 'audio/wav', buffer: b };
|
|
};
|
|
const ini = { name: 'settings.ini', mimeType: 'text/plain', buffer: Buffer.from([
|
|
'[soundfiles]',
|
|
'SOUND_CAPTURE_MUTED = play("mic_muted.wav")',
|
|
'CHAT_RECEIVED_MESSAGE_CHANNEL = play("chat_message_inbound.wav")',
|
|
'CHAT_RECEIVED_MESSAGE_CLIENT = play("chat_message_inbound.wav")',
|
|
'CLIENT_SWITCHED_TO_CURRENT_CHANNEL_APPEARS = play("${clientType}_switched_tocurrentchannel.wav")',
|
|
'CHANNEL_CREATED_BY_OTHER = '
|
|
].join('\n')) };
|
|
const packChooser = page.waitForEvent('filechooser');
|
|
await page.getByRole('button', { name: 'Load sound pack...' }).click();
|
|
await (await packChooser).setFiles([ini, wav('mic_muted.wav'), wav('chat_message_inbound.wav'),
|
|
wav('blue_switched_tocurrentchannel.wav'), wav('neutral_switched_tocurrentchannel.wav'), wav('extra.wav')]);
|
|
const rep = page.locator('.report');
|
|
for (const line of ['mic_muted.wav: You muted', 'chat_message_inbound.wav: Channel message', 'chat_message_inbound.wav: Direct message',
|
|
'neutral_switched_tocurrentchannel.wav: Someone joined your channel']) await rep.getByText(line).waitFor();
|
|
await rep.getByText(/Not matched.*extra\.wav/).waitFor();
|
|
assert.ok(!(await rep.textContent())!.includes('blue_switched_tocurrentchannel.wav: '), 'neutral variant preferred');
|
|
// A custom sound really plays (blob URL, allowed by the security policy) and survives a reload
|
|
const before = (await soundLog()).filter(x => x === 'mute').length;
|
|
await page.locator('.events li', { hasText: 'You muted' }).getByRole('button', { name: /^Play/ }).click();
|
|
await page.waitForFunction(n => ((window as any).__mumh5Sounds ?? []).filter((x: string) => x === 'mute').length > n, before);
|
|
assert.equal(await page.getByText(/^Could not play/).count(), 0, 'no playback errors');
|
|
await page.getByRole('button', { name: 'Reset all to built-in' }).click();
|
|
await page.getByRole('button', { name: 'Done', exact: true }).click();
|
|
console.log('ok: TeamSpeak sound pack mapped from settings.ini');
|
|
|
|
if (process.env.UPLOAD_HOST) {
|
|
const png = path.join(userData, 'gradient.png');
|
|
writeFileSync(png, testPng());
|
|
const gotUpload = new Promise<string>(res => bob.on('text', m => res(m.html)));
|
|
await page.locator('.chat .composer input[type=file]').first().setInputFiles(png);
|
|
const up = await within(gotUpload, 'upload message');
|
|
assert.match(up, /<a href="http[^"]+\/cu\/[A-Za-z0-9_-]{12}\/gradient\.png">gradient\.png<\/a>/);
|
|
assert.match(up, /<br\/><img src="data:image\/jpeg;base64,[^"]+"\/>$/);
|
|
assert.ok(up.length > 5000, `preview should exceed the text limit (${up.length})`);
|
|
await page.locator('.embed img').waitFor();
|
|
await page.waitForFunction(() => (document.querySelector('.embed img') as HTMLImageElement)?.naturalWidth === 400);
|
|
console.log('ok: upload posted with vanilla thumbnail, embed loads for mumh5');
|
|
|
|
// Link previews through the upload host: fetched and image-proxied by f0ckm (needs internet)
|
|
bob.sendText({ channels: [0] }, '<a href="https://github.com/mumble-voip/mumble">https://github.com/mumble-voip/mumble</a>');
|
|
const gh = page.locator('.msg', { hasText: 'github.com/mumble-voip' }).locator('.card');
|
|
await gh.locator('.title', { hasText: /mumble/i }).waitFor({ timeout: 15000 });
|
|
await gh.locator('.site', { hasText: 'GitHub' }).waitFor();
|
|
const ghImg = gh.locator('img');
|
|
assert.ok((await ghImg.getAttribute('src'))!.startsWith(`${process.env.UPLOAD_HOST}/api/chat/preview/image?`), 'image proxied by the upload host');
|
|
await page.waitForFunction(() => ((document.querySelector('.card img') as HTMLImageElement)?.naturalWidth ?? 0) > 0, null, { timeout: 15000 });
|
|
console.log('ok: link preview through the upload host');
|
|
|
|
// Direct mode: this computer fetches the preview itself
|
|
await page.getByTitle('Settings').click();
|
|
await page.getByRole('tab', { name: 'Chat and files' }).click();
|
|
await page.getByLabel('Link previews').selectOption('direct');
|
|
await page.getByRole('button', { name: 'Done', exact: true }).click();
|
|
bob.sendText({ channels: [0] }, '<a href="https://www.mumble.info/">https://www.mumble.info/</a>');
|
|
const direct = page.locator('.msg', { hasText: 'www.mumble.info' }).locator('.card');
|
|
await direct.locator('.title').waitFor({ timeout: 15000 });
|
|
assert.ok(!(await direct.locator('img').getAttribute('src').catch(() => ''))?.includes('/api/chat/preview/image'), 'direct mode does not use the proxy');
|
|
await page.getByTitle('Settings').click();
|
|
await page.getByRole('tab', { name: 'Chat and files' }).click();
|
|
await page.getByLabel('Link previews').selectOption('host');
|
|
await page.getByRole('button', { name: 'Done', exact: true }).click();
|
|
console.log('ok: link preview fetched directly');
|
|
|
|
// Types the upload host does not allow are refused before uploading
|
|
const txt = path.join(userData, 'notes.txt');
|
|
writeFileSync(txt, 'plain text');
|
|
let leaked = false;
|
|
const off = bob.on('text', () => { leaked = true; });
|
|
await page.locator('.chat .composer input[type=file]').first().setInputFiles(txt);
|
|
await page.getByText('notes.txt: file type not allowed on this upload host').waitFor();
|
|
await page.waitForTimeout(500);
|
|
off();
|
|
assert.equal(leaked, false);
|
|
console.log('ok: disallowed file type refused');
|
|
}
|
|
|
|
// Server certificate viewer from the header (the Docker server is self-signed, so no green header)
|
|
assert.equal(await page.locator('.sidebar header.trusted').count(), 0);
|
|
await page.locator('.sidebar header .title').click();
|
|
await page.getByRole('dialog', { name: /Certificate of/ }).getByText('self-signed').waitFor();
|
|
await page.getByRole('button', { name: 'Close', exact: true }).last().click();
|
|
console.log('ok: server certificate viewer');
|
|
|
|
// Own description: rich text with a picture, as Mumble-compatible XHTML
|
|
await page.locator('.me .profile').click();
|
|
await page.getByRole('button', { name: 'Edit description' }).click();
|
|
const area = page.locator('.rich .area');
|
|
await area.click();
|
|
await page.keyboard.type('I test things ');
|
|
await page.keyboard.press('Control+b');
|
|
await page.keyboard.type('bold');
|
|
await page.keyboard.press('Control+b');
|
|
await page.keyboard.press('Enter');
|
|
await page.keyboard.type('second line');
|
|
const pic = path.join(userData, 'avatar.png');
|
|
writeFileSync(pic, testPng(300, 200));
|
|
await page.locator('.rich input[type=file]').setInputFiles(pic);
|
|
await area.locator('img').waitFor();
|
|
const aliceId = [...bob.users.values()].find(u => u.name === 'alice')!.session;
|
|
const commented = new Promise<string>(res => bob.on('user', (u, changed) => {
|
|
if (u.session !== aliceId) return;
|
|
if (changed.includes('comment') && u.comment) res(u.comment);
|
|
// Long comments are announced by hash only; fetch the text
|
|
else if (changed.includes('commentHash')) bob.requestBlob({ comments: [aliceId] });
|
|
}));
|
|
await page.getByRole('button', { name: 'Save', exact: true }).click();
|
|
const comment = await within(commented, 'comment update');
|
|
assert.match(comment, /^<p>I test things <span style="font-weight:bold">bold<\/span><\/p><p>second line<img src="data:image\/jpeg;base64,[^"]+"\/><\/p>$/);
|
|
assert.ok(comment.length > 5000, 'long enough that the server ran its XML check and accepted it');
|
|
await page.locator('.panel .html img').waitFor();
|
|
console.log('ok: own description edited');
|
|
|
|
// Other user's profile and context menu
|
|
await page.locator('.sidebar .user', { hasText: 'bob' }).click({ button: 'right' });
|
|
for (const item of ['View profile', 'Send message', 'Server mute', 'Kick...', 'Ban...']) {
|
|
await page.getByRole('menuitem', { name: item }).waitFor();
|
|
}
|
|
// Per-person volume lives in the menu
|
|
await page.getByRole('slider', { name: 'Volume' }).fill('1.5');
|
|
await page.locator('.menu .slider-label', { hasText: '150%' }).waitFor();
|
|
await page.keyboard.press('Escape');
|
|
await page.locator('.sidebar .user', { hasText: 'bob' }).click({ button: 'right' });
|
|
assert.equal(await page.getByRole('slider', { name: 'Volume' }).inputValue(), '1.5', 'volume remembered');
|
|
await page.getByRole('menuitem', { name: 'View profile' }).click();
|
|
await page.locator('.panel h3', { hasText: 'bob' }).waitFor();
|
|
console.log('ok: user context menu and profile');
|
|
|
|
// Server information from the button next to mute
|
|
await page.locator('.me').getByRole('button', { name: 'Server information' }).click();
|
|
const srvInfo = page.getByRole('dialog', { name: 'Server information' });
|
|
await srvInfo.getByText(`${host}:${port}`).waitFor();
|
|
await srvInfo.getByText(/^\d+ of \d+$/).waitFor();
|
|
await srvInfo.getByText(/^\d+ characters$/).first().waitFor();
|
|
await srvInfo.getByRole('button', { name: 'Close', exact: true }).last().click();
|
|
await srvInfo.waitFor({ state: 'detached' });
|
|
console.log('ok: server information dialog');
|
|
|
|
// Toolbar: description editor and settings
|
|
await page.locator('.me').getByRole('button', { name: 'Change your description' }).click();
|
|
await page.locator('.panel .rich').waitFor();
|
|
await page.locator('.panel').getByRole('button', { name: 'Cancel' }).click();
|
|
await page.locator('.me').getByRole('button', { name: 'Configure' }).click();
|
|
await page.getByRole('dialog', { name: 'Settings' }).waitFor();
|
|
await page.getByRole('button', { name: 'Done', exact: true }).click();
|
|
console.log('ok: toolbar opens the description editor and settings');
|
|
|
|
// Information: own connection shows client details and the full certificate
|
|
await page.locator('.sidebar .user.self').click({ button: 'right' });
|
|
await page.getByRole('menuitem', { name: 'Information' }).click();
|
|
const info = page.getByRole('dialog', { name: /^Information: alice/ });
|
|
await info.getByText('mumh5 0.1.0').waitFor();
|
|
await info.getByText('linux', { exact: false }).first().waitFor();
|
|
await info.getByText('Online for').waitFor();
|
|
// Live: the online time advances while the dialog is open
|
|
const onlineText = () => info.locator('dt', { hasText: 'Online for' }).locator('xpath=following-sibling::dd[1]').textContent();
|
|
const t1 = await onlineText();
|
|
await page.waitForTimeout(2200);
|
|
assert.notEqual(await onlineText(), t1, 'online time ticks live');
|
|
await info.getByRole('button', { name: 'View certificate' }).click();
|
|
await page.getByRole('dialog', { name: /^Certificate of alice/ }).getByText('alice', { exact: true }).first().waitFor();
|
|
await page.getByRole('dialog', { name: /^Certificate of alice/ }).getByRole('button', { name: 'Close', exact: true }).last().click();
|
|
await info.getByRole('button', { name: 'Close', exact: true }).last().click();
|
|
console.log('ok: own connection information with certificate chain');
|
|
|
|
// Someone else as a normal user: times and pings, details withheld by the server
|
|
await page.locator('.sidebar .user', { hasText: 'bob' }).click({ button: 'right' });
|
|
await page.getByRole('menuitem', { name: 'Information' }).click();
|
|
const bobInfo = page.getByRole('dialog', { name: 'Information: bob' });
|
|
await bobInfo.getByText('Ping (TCP)').waitFor();
|
|
await bobInfo.getByText('only shown to admins', { exact: false }).first().waitFor();
|
|
assert.equal(await bobInfo.getByText('mumh5 0.1.0').count(), 0);
|
|
await bobInfo.getByRole('button', { name: 'Close', exact: true }).last().click();
|
|
console.log('ok: other user information respects server permissions');
|
|
|
|
if (admin) {
|
|
// Remove channels left over from an earlier run (the server refuses duplicate names)
|
|
for (const c of [...admin.channels.values()]) {
|
|
if (c.id !== 0 && c.parent === 0) admin.send('ChannelRemove', { channel_id: c.id });
|
|
}
|
|
await new Promise(r => setTimeout(r, 300));
|
|
|
|
// Channels: a long description (only sent as a hash), and a nested pair
|
|
const longDesc = 'Welcome to the lobby. ' + 'This description is long enough that the server only announces its hash. '.repeat(3);
|
|
const created = new Promise<void>(res => {
|
|
let n = 0;
|
|
admin.on('channel', c => { if (['Lobby', 'Games', 'Minecraft'].includes(c.name) && ++n === 3) res(); });
|
|
});
|
|
admin.send('ChannelState', { parent: 0, name: 'Lobby', description: longDesc });
|
|
admin.send('ChannelState', { parent: 0, name: 'Games' });
|
|
await new Promise(r => setTimeout(r, 300));
|
|
const games = [...admin.channels.values()].find(c => c.name === 'Games')!;
|
|
admin.send('ChannelState', { parent: games.id, name: 'Minecraft' });
|
|
await within(created, 'channels created');
|
|
|
|
await page.locator('.channel', { hasText: 'Games' }).waitFor();
|
|
assert.equal(await page.locator('.channel', { hasText: 'Minecraft' }).count(), 0, 'empty subchannels start collapsed');
|
|
console.log('ok: channels collapsed by default');
|
|
|
|
// Single click previews the description (fetched on demand), without joining
|
|
await page.locator('.channel .name', { hasText: 'Lobby' }).click();
|
|
await page.locator('.panel .html', { hasText: 'Welcome to the lobby' }).waitFor();
|
|
assert.equal(await page.locator('.me .who span').textContent(), 'Root');
|
|
console.log('ok: single click shows description without joining');
|
|
|
|
// Description marker: only on channels that have one. Resting on the row previews, clicking the marker opens the panel
|
|
const marker = page.locator('.channel', { hasText: 'Lobby' }).getByRole('button', { name: 'Show description of Lobby' });
|
|
assert.equal(await page.locator('.channel', { hasText: 'Games' }).locator('.desc').count(), 0);
|
|
await page.locator('.panel').getByRole('button', { name: 'Close' }).click();
|
|
await page.locator('.channel .name', { hasText: 'Games' }).hover();
|
|
await page.waitForTimeout(2000);
|
|
assert.equal(await page.locator('.desc-pop').count(), 0, 'no preview for a channel without description');
|
|
for (const spot of [page.locator('.channel .name', { hasText: 'Lobby' }), marker]) {
|
|
await spot.hover();
|
|
await page.waitForTimeout(500);
|
|
assert.equal(await page.locator('.desc-pop').count(), 0, 'no preview before 1 second');
|
|
await page.locator('.desc-pop .html', { hasText: 'Welcome to the lobby' }).waitFor({ timeout: 3000 });
|
|
// Opens at the pointer
|
|
const at = (await spot.boundingBox())!;
|
|
const pop = (await page.locator('.desc-pop').boundingBox())!;
|
|
assert.ok(Math.abs(pop.x - (at.x + at.width / 2 + 12)) < 3 && Math.abs(pop.y - (at.y + at.height / 2 + 12)) < 3, 'preview at the pointer');
|
|
await page.mouse.move(700, 400);
|
|
await page.locator('.desc-pop').waitFor({ state: 'detached' });
|
|
}
|
|
await marker.click();
|
|
await page.locator('.panel .html', { hasText: 'Welcome to the lobby' }).waitFor();
|
|
console.log('ok: description previews on hover, marker opens it on click');
|
|
|
|
// Side chat sends to that channel while staying in Root
|
|
const lobby = [...bob.channels.values()].find(c => c.name === 'Lobby')!;
|
|
await page.locator('.panel [role=tab]', { hasText: 'Chat' }).click();
|
|
// Channel messages only reach people in that channel, so listen from inside Lobby
|
|
admin.joinChannel(lobby.id);
|
|
await new Promise(r => setTimeout(r, 300));
|
|
const sideMsg = new Promise<number[]>(res => admin.on('text', m => { if (m.html === 'side hello') res(m.channels); }));
|
|
await page.locator('.panel .composer [contenteditable]').fill('side hello');
|
|
await page.locator('.panel .composer [contenteditable]').press('Enter');
|
|
assert.deepEqual(await within(sideMsg, 'side chat message'), [lobby.id]);
|
|
await page.locator('.panel .msg', { hasText: 'side hello' }).waitFor();
|
|
assert.equal(await page.locator('.chat .msg', { hasText: 'side hello' }).count(), 0);
|
|
console.log('ok: side chat sends to the previewed channel');
|
|
|
|
// Double click joins; the joined channel opens up
|
|
await page.locator('.channel .name', { hasText: 'Games' }).dblclick();
|
|
await page.locator('.me .who span', { hasText: 'Games' }).waitFor();
|
|
await page.locator('.channel', { hasText: 'Minecraft' }).waitFor();
|
|
console.log('ok: double click joins and expands');
|
|
|
|
// Channel context menu
|
|
await page.locator('.channel .name', { hasText: 'Lobby' }).click({ button: 'right' });
|
|
await page.getByRole('menuitem', { name: 'Join channel' }).click();
|
|
await page.locator('.me .who span', { hasText: 'Lobby' }).waitFor();
|
|
// Messaging a channel from its menu opens the side chat, ready to type, without joining
|
|
await page.locator('.channel .name', { hasText: 'Games' }).click({ button: 'right' });
|
|
await page.getByRole('menuitem', { name: 'Send message to channel' }).click();
|
|
const sideInput = page.locator('.panel .composer [contenteditable]');
|
|
await sideInput.waitFor();
|
|
assert.equal(await sideInput.getAttribute('data-placeholder'), 'Message #Games');
|
|
await page.waitForFunction(() => document.activeElement === document.querySelector('.panel .composer [contenteditable]'), null, { timeout: 3000 });
|
|
await page.locator('.me .who span', { hasText: 'Lobby' }).waitFor();
|
|
console.log('ok: channel context menu joins, and messages a channel from the side chat');
|
|
|
|
// Side panel can be resized by dragging its left edge
|
|
const panelBox = (await page.locator('aside.panel').boundingBox())!;
|
|
const handle = (await page.locator('aside.panel .resize').boundingBox())!;
|
|
await page.mouse.move(handle.x + 4, handle.y + 200);
|
|
await page.mouse.down();
|
|
await page.mouse.move(handle.x - 150, handle.y + 200, { steps: 5 });
|
|
await page.mouse.up();
|
|
const grown = (await page.locator('aside.panel').boundingBox())!;
|
|
assert.ok(Math.abs(grown.width - (panelBox.width + 154)) < 6, `panel resized ${panelBox.width} -> ${grown.width}`);
|
|
console.log('ok: side panel resizable');
|
|
|
|
// The channel list is resizable too (from its right edge)
|
|
const sideBox = (await page.locator('aside.sidebar').boundingBox())!;
|
|
const sideHandle = (await page.locator('aside.sidebar .resize').boundingBox())!;
|
|
await page.mouse.move(sideHandle.x + 4, sideHandle.y + 300);
|
|
await page.mouse.down();
|
|
await page.mouse.move(sideHandle.x + 104, sideHandle.y + 300, { steps: 5 });
|
|
await page.mouse.up();
|
|
const sideAfter = (await page.locator('aside.sidebar').boundingBox())!;
|
|
assert.ok(Math.abs(sideAfter.width - (sideBox.width + 100)) < 6, `sidebar resized ${sideBox.width} -> ${sideAfter.width}`);
|
|
await page.locator('aside.sidebar .resize').dblclick();
|
|
console.log('ok: channel list resizable');
|
|
|
|
// ─── Channel management and permissions, done by alice through the UI ───
|
|
// Give alice (by certificate hash) full rights first, as SuperUser. Not @all: Write
|
|
// overrides denies, and bob must stay an ordinary user for the permission checks.
|
|
const aliceHash = [...admin.users.values()].find(u => u.name === 'alice')!.hash;
|
|
const rootAcl = new Promise<any>(res => admin.on('acl', a => { if (a.channelId === 0) res(a); }));
|
|
admin.queryAcl(0);
|
|
const ra = await within(rootAcl, 'root ACL');
|
|
ra.acls.push({ applyHere: true, applySubs: true, inherited: false, userId: null, group: `$${aliceHash}`, grant: 0x1f0fff, deny: 0 });
|
|
admin.saveAcl(ra);
|
|
await page.waitForTimeout(1500);
|
|
const chByName = (n: string) => [...bob.channels.values()].find(c => c.name === n);
|
|
const bobSees = (label: string, cond: () => boolean) => within(new Promise<void>(res => {
|
|
if (cond()) return res();
|
|
const off = bob.on('channel', () => { if (cond()) { off(); res(); } });
|
|
const off2 = bob.on('channelRemove', () => { if (cond()) { off2(); res(); } });
|
|
}), label);
|
|
|
|
// Create from the context menu on free space in the channel list
|
|
const listBox = (await page.locator('.sidebar .scroll').boundingBox())!;
|
|
await page.mouse.click(listBox.x + listBox.width / 2, listBox.y + listBox.height - 20, { button: 'right' });
|
|
await page.getByRole('menuitem', { name: 'Create channel...' }).click();
|
|
await page.getByLabel('Name').fill('Team');
|
|
await page.getByRole('button', { name: 'Create channel' }).click();
|
|
await page.locator('.channel .name', { hasText: 'Team' }).waitFor();
|
|
console.log('ok: channel created');
|
|
|
|
// Edit name and position
|
|
await page.locator('.channel .name', { hasText: 'Team' }).click({ button: 'right' });
|
|
await page.getByRole('menuitem', { name: 'Edit channel...' }).click();
|
|
await page.getByLabel('Name').fill('Team Room');
|
|
await page.getByLabel('Position').fill('7');
|
|
await page.getByRole('button', { name: 'Save', exact: true }).click();
|
|
await bobSees('rename', () => chByName('Team Room')?.position === 7);
|
|
const teamId = chByName('Team Room')!.id;
|
|
console.log('ok: channel edited');
|
|
|
|
// Permissions: deny Enter for everyone
|
|
await page.locator('.channel .name', { hasText: 'Team Room' }).click({ button: 'right' });
|
|
await page.getByRole('menuitem', { name: 'Permissions...' }).click();
|
|
const dlg = page.getByRole('dialog', { name: 'Channel: Team Room' });
|
|
await dlg.getByRole('listbox', { name: 'Rules' }).waitFor();
|
|
await dlg.getByRole('button', { name: 'Add', exact: true }).click();
|
|
await dlg.getByLabel('Deny Enter').check();
|
|
if (shots) await page.screenshot({ path: path.join(shots, 'acl.png') });
|
|
// Group with a registered member
|
|
await dlg.getByRole('tab', { name: 'Groups' }).click();
|
|
await dlg.getByLabel('New group name').fill('mods');
|
|
await dlg.getByRole('button', { name: 'Add', exact: true }).click();
|
|
await dlg.getByLabel('Member name').fill('SuperUser');
|
|
await dlg.getByRole('button', { name: 'Add member' }).click();
|
|
await dlg.locator('.members', { hasText: 'SuperUser' }).waitFor();
|
|
if (shots) await page.screenshot({ path: path.join(shots, 'groups.png') });
|
|
await dlg.getByRole('button', { name: 'Save', exact: true }).click();
|
|
await page.waitForTimeout(1500);
|
|
const deniedEnter = new Promise<number>(res => bob.on('permissionDenied', d => res(d.permission)));
|
|
bob.joinChannel(teamId);
|
|
assert.equal(await within(deniedEnter, 'enter denied'), 0x4);
|
|
const teamAcl = new Promise<any>(res => admin.on('acl', a => { if (a.channelId === teamId) res(a); }));
|
|
admin.queryAcl(teamId);
|
|
const ta = await within(teamAcl, 'team ACL');
|
|
assert.deepEqual(ta.groups.find((g: any) => g.name === 'mods')?.add, [0]);
|
|
console.log('ok: permissions and groups saved and enforced');
|
|
|
|
// Drag the channel into Games, and bob into Lobby
|
|
await page.locator('.channel .name', { hasText: 'Team Room' }).dragTo(page.locator('.channel .name', { hasText: 'Games' }));
|
|
const gamesId = chByName('Games')!.id;
|
|
await bobSees('channel moved', () => chByName('Team Room')?.parent === gamesId);
|
|
await page.locator('.sidebar .user', { hasText: 'bob' }).dragTo(page.locator('.channel', { hasText: 'Lobby' }).first());
|
|
await within(new Promise<void>(res => {
|
|
if (bob.self?.channelId === lobby.id) return res();
|
|
bob.on('user', u => { if (u.session === bob.session && u.channelId === lobby.id) res(); });
|
|
}), 'bob moved');
|
|
bob.joinChannel(0);
|
|
console.log('ok: drag and drop moves channels and people');
|
|
|
|
// Dropped on the lower or upper third of a row, a channel goes behind or in front of it.
|
|
// A line shows where it will land.
|
|
const topOrder = () => [...bob.channels.values()].filter(c => c.parent === 0).sort(byOrder).map(c => c.name);
|
|
assert.deepEqual(topOrder(), ['Games', 'Lobby']);
|
|
await page.locator('.channel .name', { hasText: 'Games' }).hover();
|
|
await page.mouse.down();
|
|
const lobbyBox = (await page.locator('.channel', { hasText: 'Lobby' }).first().boundingBox())!;
|
|
await page.mouse.move(lobbyBox.x + 60, lobbyBox.y + lobbyBox.height - 4, { steps: 8 });
|
|
// Lobby is open (people inside), so the line sits below them
|
|
await page.locator('li.after-end', { hasText: 'Lobby' }).waitFor();
|
|
if (shots) await page.screenshot({ path: path.join(shots, 'drag-line.png') });
|
|
assert.equal(await page.locator('.channel.dragging', { hasText: 'Games' }).count(), 1, 'the dragged channel is dimmed');
|
|
await page.mouse.up();
|
|
await bobSees('Games behind Lobby', () => topOrder().join() === 'Lobby,Games');
|
|
assert.equal(await page.locator('li.after-end, .channel.after, .channel.before, .channel.into').count(), 0);
|
|
await page.locator('.channel .name', { hasText: 'Games' }).dragTo(page.locator('.channel', { hasText: 'Lobby' }).first(), { targetPosition: { x: 60, y: 3 } });
|
|
await bobSees('Games in front of Lobby again', () => topOrder().join() === 'Games,Lobby');
|
|
// The pointer rests on a row with a description; move away so no preview covers the list
|
|
await page.mouse.move(700, 400);
|
|
await page.locator('.desc-pop').waitFor({ state: 'detached' });
|
|
console.log('ok: drag and drop puts a channel in front of or behind another, with a line where it lands');
|
|
|
|
// Delete (Team Room is now inside Games, which stays collapsed while empty)
|
|
await page.locator('.channel', { hasText: 'Games' }).first().getByRole('button', { name: 'Expand' }).click();
|
|
await page.locator('.channel .name', { hasText: 'Team Room' }).click({ button: 'right' });
|
|
await page.getByRole('menuitem', { name: 'Delete channel...' }).click();
|
|
await page.getByRole('button', { name: 'Delete channel' }).click();
|
|
await bobSees('deleted', () => !chByName('Team Room'));
|
|
console.log('ok: channel deleted');
|
|
|
|
// Toolbar: expand and collapse the whole tree
|
|
await page.locator('.me').getByRole('button', { name: 'Expand all channels' }).click();
|
|
await page.locator('.channel', { hasText: 'Minecraft' }).waitFor();
|
|
await page.locator('.me').getByRole('button', { name: 'Collapse all channels' }).click();
|
|
await page.locator('.channel', { hasText: 'Minecraft' }).waitFor({ state: 'detached' });
|
|
// Channels with people in them stay open
|
|
assert.notEqual(await page.locator('.me .who span').textContent(), 'Root');
|
|
await page.locator('.sidebar .row.user.self').waitFor();
|
|
console.log('ok: toolbar expands and collapses all channels, occupied ones stay open');
|
|
|
|
// ─── Text channels: read and written without joining, between mumh5 clients ───
|
|
// Bob plays a second mumh5 client by speaking the plugin data protocol
|
|
const assembler = new TextAssembler();
|
|
const signals: { type: number; text?: string }[] = [];
|
|
const waiting: { want: (s: { type: number; text?: string }) => boolean; res: (s: { type: number; text?: string }) => void }[] = [];
|
|
const bobSend = async (to: number[], type: Parameters<typeof encodeText>[0], body?: unknown) => {
|
|
for (const p of await encodeText(type, body)) { bob.sendPluginData(to, TEXT_DATA_ID, p); await new Promise(r => setTimeout(r, 120)); }
|
|
};
|
|
bob.on('pluginData', async (sender, dataId, data) => {
|
|
if (dataId !== TEXT_DATA_ID) return;
|
|
const signal = await assembler.push(sender, data);
|
|
if (!signal) return;
|
|
// Like a mumh5 client that was here before: answers a newcomer and hands over an old message
|
|
if (signal.type === TEXT.hello) bobSend([sender], TEXT.here);
|
|
if (signal.type === TEXT.want && notesId != null) {
|
|
bobSend([sender], TEXT.history, { c: notesId, m: [{ i: 'from-before', t: Date.now() - 3600_000, n: 'dave', k: '', h: 'said while you were away' }] });
|
|
}
|
|
signals.push(signal);
|
|
const at = waiting.findIndex(w => w.want(signal));
|
|
if (at >= 0) waiting.splice(at, 1)[0].res(signal);
|
|
});
|
|
const bobGets = (label: string, want: (s: { type: number; text?: string }) => boolean) =>
|
|
within(new Promise<{ type: number; text?: string }>(res => waiting.push({ want, res })), label);
|
|
|
|
await page.mouse.click(listBox.x + listBox.width / 2, listBox.y + listBox.height - 20, { button: 'right' });
|
|
await page.getByRole('menuitem', { name: 'Create channel...' }).click();
|
|
await page.getByLabel('Name').fill('notes');
|
|
await page.getByLabel('Text channel (mumh5 only)').check();
|
|
await page.getByRole('button', { name: 'Create channel' }).click();
|
|
await bobSees('text channel', () => !!chByName('notes')?.description.startsWith(TEXT_MARK));
|
|
notesId = chByName('notes')!.id;
|
|
const notesRow = page.locator('.channel', { hasText: 'notes' });
|
|
// No description marker for a channel whose description is only the text channel mark
|
|
assert.equal(await notesRow.locator('.desc').count(), 0);
|
|
const voiceChannel = await page.locator('.me .who span').textContent();
|
|
await notesRow.locator('.name').click();
|
|
await page.locator('.chat h1', { hasText: 'notes' }).waitFor();
|
|
assert.equal(await page.locator('.me .who span').textContent(), voiceChannel, 'opening a text channel does not join it');
|
|
const sent = bobGets('text channel message', m => m.type === TEXT.msg);
|
|
await page.locator('.chat .composer [contenteditable]').fill('text hello');
|
|
await page.locator('.chat .composer [contenteditable]').press('Enter');
|
|
const got = parseMsg((await sent).text);
|
|
assert.equal(got?.c, notesId);
|
|
assert.equal(got?.h, 'text hello');
|
|
await page.locator('.chat .msg', { hasText: 'text hello' }).waitFor();
|
|
console.log('ok: text channel created, opened without joining, message reaches others');
|
|
|
|
// A message from someone in another channel arrives; unread while another chat is open
|
|
await bobSend([aliceSession()], TEXT.msg, { i: 'bob-1', c: notesId, h: 'hello from <b>bob</b>' });
|
|
await page.locator('.chat .msg', { hasText: 'hello from bob' }).locator('strong', { hasText: 'bob' }).first().waitFor();
|
|
await page.locator('.channel .name', { hasText: voiceChannel! }).first().dblclick();
|
|
await page.locator('.chat h1', { hasText: voiceChannel! }).waitFor();
|
|
await bobSend([aliceSession()], TEXT.msg, { i: 'bob-2', c: notesId, h: 'second from bob' });
|
|
await notesRow.locator('.badge', { hasText: '1' }).waitFor();
|
|
// The same message again is not shown twice
|
|
await bobSend([aliceSession()], TEXT.msg, { i: 'bob-2', c: notesId, h: 'second from bob' });
|
|
await page.waitForTimeout(500);
|
|
await notesRow.locator('.name').click();
|
|
await page.locator('.chat .msg', { hasText: 'second from bob' }).waitFor();
|
|
assert.equal(await page.locator('.chat .html', { hasText: 'second from bob' }).count(), 1);
|
|
assert.equal(await notesRow.locator('.badge').count(), 0);
|
|
// Not into ordinary channels
|
|
await bobSend([aliceSession()], TEXT.msg, { i: 'bob-3', c: gamesId, h: 'sneaked in' });
|
|
await page.waitForTimeout(500);
|
|
assert.equal(await page.locator('.channel', { hasText: 'Games' }).first().locator('.badge').count(), 0);
|
|
console.log('ok: text channel messages arrive, count as unread, no duplicates, only in text channels');
|
|
|
|
// The app hands its messages to a client that asks
|
|
const handed = bobGets('history', m => m.type === TEXT.history && parseHistory(m.text).length > 0);
|
|
await bobSend([aliceSession()], TEXT.want);
|
|
const history = parseHistory((await handed).text);
|
|
assert.deepEqual(history.map(m => m.h), ['text hello', 'hello from <b>bob</b>', 'second from bob']);
|
|
assert.deepEqual(history.map(m => m.n), ['alice', 'bob', 'bob']);
|
|
assert.equal(history[0].c, notesId);
|
|
await page.locator('.channel .name', { hasText: voiceChannel! }).first().dblclick();
|
|
await page.locator('.chat h1', { hasText: voiceChannel! }).waitFor();
|
|
console.log('ok: text channel messages handed to a client that asks');
|
|
}
|
|
|
|
// Without an upload host, images are sent inline within Mumble's image limit
|
|
await page.locator('.channel .name', { hasText: 'Root' }).dblclick();
|
|
await page.locator('.me .who span', { hasText: 'Root' }).waitFor();
|
|
await page.getByTitle('Settings').click();
|
|
await page.getByRole('tab', { name: 'Chat and files' }).click();
|
|
await page.getByLabel('Upload host').fill('');
|
|
// Settings that need the upload host are disabled and previews show as off
|
|
assert.ok(await page.getByLabel('Files expire after').isDisabled());
|
|
assert.ok(await page.getByLabel('Send image previews for regular Mumble clients').isDisabled());
|
|
assert.equal(await page.getByLabel('Link previews').inputValue(), 'off');
|
|
assert.ok(await page.getByLabel('Link previews').locator('option[value=host]').isDisabled());
|
|
await page.getByRole('button', { name: 'Done', exact: true }).click();
|
|
const big = path.join(userData, 'big.png');
|
|
writeFileSync(big, testPng(1600, 1200));
|
|
const gotInline = new Promise<string>(res => bob.on('text', m => { if (m.html.startsWith('<img')) res(m.html); }));
|
|
await page.locator('.chat .composer input[type=file]').first().setInputFiles(big);
|
|
const inline = await within(gotInline, 'inline image', 15000);
|
|
assert.match(inline, /^<img src="data:image\/jpeg;base64,[^"]+"\/>$/);
|
|
assert.ok(inline.length <= 131072, `inline image within the server limit (${inline.length})`);
|
|
console.log(`ok: inline image without upload host (${inline.length} chars)`);
|
|
|
|
// A click on an image in the chat shows it large; Escape or a click closes it
|
|
await page.locator('.chat .msg .html img').last().click();
|
|
const viewer = page.getByRole('dialog', { name: 'Image' });
|
|
await viewer.waitFor();
|
|
const shown = await viewer.locator('img').evaluate((i: HTMLImageElement) => [i.naturalWidth > 0, i.getBoundingClientRect().width]);
|
|
assert.ok(shown[0] && (shown[1] as number) > 300, `image shown large: ${shown}`);
|
|
await page.keyboard.press('Escape');
|
|
await viewer.waitFor({ state: 'detached' });
|
|
await page.locator('.chat .msg .html img').last().click();
|
|
await viewer.getByRole('button', { name: 'Close image' }).click({ position: { x: 5, y: 5 } });
|
|
await viewer.waitFor({ state: 'detached' });
|
|
console.log('ok: images open large on click');
|
|
|
|
// ─── Several servers at once ─────────────────────────────────────────────
|
|
const userState = (name: string) => [...bob.users.values()].find(u => u.name === name);
|
|
const until2 = (label: string, cond: () => boolean) => within(new Promise<void>(res => {
|
|
if (cond()) return res();
|
|
const off = bob.on('user', () => { if (cond()) { off(); res(); } });
|
|
}), label);
|
|
|
|
await page.getByTitle('Add a server').click();
|
|
await page.getByLabel('Address').fill(host);
|
|
await page.getByLabel('Port').fill(port);
|
|
await page.getByLabel('Username').fill('alice-b');
|
|
await page.getByLabel('Label').fill('Second');
|
|
await page.getByRole('button', { name: 'Save and connect' }).click();
|
|
await page.locator('.rail .tile.live').nth(1).waitFor();
|
|
assert.equal(await page.locator('.rail .tile.live').count(), 2, 'both servers connected');
|
|
await until2('background server shows deafened', () => !!userState('alice-b')?.selfDeaf);
|
|
assert.equal(await page.locator('.rail .tile.voice').getAttribute('title').then(t => t?.startsWith('Test Server')), true);
|
|
await page.getByText('Voice is on Test Server').waitFor();
|
|
console.log('ok: second server connected, deafened in the background');
|
|
assert.ok((await soundLog()).includes('userConnected'), 'sound when someone connects into your channel');
|
|
|
|
// Joining a channel on the second server moves voice there
|
|
await page.locator('.channel .name', { hasText: 'Root' }).dblclick();
|
|
await until2('voice moved to the second server', () => !userState('alice-b')?.selfDeaf && !!userState('alice')?.selfDeaf);
|
|
assert.equal(await page.locator('.rail .tile.voice').getAttribute('title').then(t => t?.startsWith('Second')), true);
|
|
// The server with voice leads the rail, although it was added last
|
|
const railTop = (title: string) => page.locator(`.rail .tile[title^="${title}"]`).boundingBox().then(b => b!.y);
|
|
assert.ok(await railTop('Second') < await railTop('Test Server'), 'voice server on top');
|
|
assert.match(await page.locator('.rail .tile').first().getAttribute('title') ?? '', /^Test Server/, 'the saved order is untouched');
|
|
console.log('ok: voice follows the channel you join');
|
|
|
|
// Messages on the server not on screen show as unread on its tile
|
|
bob.sendText({ channels: [0] }, 'ping both');
|
|
await page.locator('.rail .tile[title^="Test Server"] .unread').waitFor();
|
|
console.log('ok: unread badge on the background server');
|
|
|
|
// Reorder by dragging Second above Test Server
|
|
await page.locator('.rail .tile[title^="Second"]').dragTo(page.locator('.rail .tile[title^="Test Server"]'), { targetPosition: { x: 22, y: 4 } });
|
|
assert.match(await page.locator('.rail .tile').first().getAttribute('title') ?? '', /^Second/);
|
|
console.log('ok: servers reordered by drag and drop');
|
|
|
|
// Custom icon from the tile's context menu: square-cropped and shown instead of initials
|
|
const iconFile = path.join(userData, 'icon.png');
|
|
writeFileSync(iconFile, testPng(300, 200));
|
|
// With hints off, the title of whatever the pointer rests on is set aside; move off the tile first
|
|
await page.mouse.move(700, 400);
|
|
await page.locator('.rail .tile[title^="Second"]').click({ button: 'right' });
|
|
const chooser = page.waitForEvent('filechooser');
|
|
await page.getByRole('menuitem', { name: 'Change icon...' }).click();
|
|
await (await chooser).setFiles(iconFile);
|
|
const icon = page.locator('.rail .tile[title^="Second"] img.icon');
|
|
await icon.waitFor();
|
|
assert.deepEqual(await icon.evaluate((i: HTMLImageElement) => [i.naturalWidth, i.naturalHeight, i.src.slice(0, 15)]), [128, 128, 'data:image/webp']);
|
|
console.log('ok: custom server icon');
|
|
|
|
// Disconnecting the voice server hands voice back and restores the earlier state
|
|
await page.locator('.rail .tile[title^="Second"]').click({ button: 'right' });
|
|
await page.getByRole('menuitem', { name: 'Disconnect' }).click();
|
|
await until2('voice back on the first server', () => !userState('alice-b') && !userState('alice')?.selfDeaf);
|
|
assert.equal(userState('alice')?.selfMute, true, 'earlier self-mute kept');
|
|
await page.locator('.rail .tile[title^="Test Server"]').click();
|
|
await page.locator('.rail .tile[title^="Test Server"] .unread').waitFor({ state: 'detached' });
|
|
console.log('ok: voice returns when the other server disconnects');
|
|
|
|
// Narrow window: voice channel as tiles, speaking lights up the tile
|
|
await page.setViewportSize({ width: 420, height: 800 });
|
|
const stage = page.getByRole('region', { name: 'Voice channel' });
|
|
const bobTile = stage.locator('.tile', { hasText: 'bob' });
|
|
await bobTile.waitFor();
|
|
const echo2 = packets.map((p, i) => encodeVoice({ target: 0, frame: 5000 + i * 2, opus: p.opus, last: i === packets.length - 1 }, bob.protobufVoice));
|
|
const pump2 = setInterval(() => { const pk = echo2.shift(); if (pk) bob.sendVoiceTunnel(pk); }, 20);
|
|
await page.locator('.stage .tile.talking', { hasText: 'bob' }).waitFor({ timeout: 3000 });
|
|
clearInterval(pump2);
|
|
await page.setViewportSize({ width: 1280, height: 800 });
|
|
await stage.waitFor({ state: 'detached' }); // wide layouts use the member list instead
|
|
console.log('ok: voice tiles on small windows show who is talking');
|
|
|
|
// Change name: Mumble needs a reconnect, the app does it and remembers the name.
|
|
// Unique per run: a reused test server may still have an earlier run's name registered.
|
|
const newName = `alice${Date.now() % 100000}`;
|
|
await page.locator('.me .profile').click();
|
|
await page.getByRole('button', { name: 'Change name' }).click();
|
|
await page.getByLabel('New name').fill(newName);
|
|
await page.getByRole('button', { name: 'Reconnect with this name' }).click();
|
|
await page.locator('.me .who strong', { hasText: newName }).waitFor();
|
|
await within(new Promise<void>(res => {
|
|
if ([...bob.users.values()].some(u => u.name === newName)) return res();
|
|
bob.on('user', u => { if (u.name === newName) res(); });
|
|
}), 'bob sees the new name');
|
|
console.log('ok: name changed by reconnecting');
|
|
|
|
// Text channel after reconnecting: what this computer kept, plus what another client hands over
|
|
if (notesId != null) {
|
|
await page.locator('.channel .name', { hasText: 'notes' }).click();
|
|
await page.locator('.chat .msg', { hasText: 'said while you were away' }).locator('strong', { hasText: 'dave' }).waitFor();
|
|
const texts = await page.locator('.chat .msg .html').allTextContents();
|
|
assert.deepEqual(texts.map(t => t.trim()).filter(t => !t.startsWith('You joined')), ['said while you were away', 'text hello', 'hello from bob', 'second from bob']);
|
|
assert.equal(await page.locator('.channel', { hasText: 'notes' }).locator('.badge').count(), 0, 'old messages are not unread');
|
|
await page.locator('.channel .name', { hasText: 'Root' }).dblclick();
|
|
await page.locator('.chat h1', { hasText: 'Root' }).waitFor();
|
|
console.log('ok: text channel keeps its messages and catches up from another client');
|
|
}
|
|
|
|
// Self-registration from the right-click menu on yourself; the profile shows no badge either way
|
|
const meRow = page.locator('.sidebar .user.self');
|
|
await meRow.click({ button: 'right' });
|
|
await page.getByRole('menuitem', { name: 'Register on this server' }).click();
|
|
await meRow.locator('.reg').waitFor();
|
|
await meRow.click({ button: 'right' });
|
|
assert.equal(await page.getByRole('menuitem', { name: 'Register on this server' }).count(), 0, 'no register item once registered');
|
|
await page.keyboard.press('Escape');
|
|
console.log('ok: registered on the server');
|
|
|
|
// Kicked: the reason is shown with a Reconnect button
|
|
if (admin) {
|
|
const me = [...admin.users.values()].find(u => u.name === newName)!;
|
|
admin.kick(me.session, 'testing kicks');
|
|
const card = page.locator('.ended');
|
|
await card.getByText('You were kicked by SuperUser').waitFor();
|
|
await card.getByText('testing kicks').waitFor();
|
|
if (shots) await page.screenshot({ path: path.join(shots, 'kicked.png') });
|
|
await card.getByRole('button', { name: 'Reconnect' }).click();
|
|
await page.locator('.sidebar .status', { hasText: 'Connected' }).waitFor();
|
|
assert.equal(await card.count(), 0);
|
|
console.log('ok: kick shows the reason and reconnects');
|
|
}
|
|
|
|
if (shots) {
|
|
await page.waitForTimeout(300);
|
|
for (const [name, w, h] of [['wide', 1280, 800], ['medium', 900, 700], ['narrow', 400, 780]] as const) {
|
|
await page.setViewportSize({ width: w, height: h });
|
|
await page.waitForTimeout(200);
|
|
await page.screenshot({ path: path.join(shots, `${name}.png`) });
|
|
}
|
|
await page.setViewportSize({ width: 1280, height: 900 });
|
|
await page.locator('.me').getByRole('button', { name: /^(Mute|Unmute)$/ }).click({ button: 'right' });
|
|
await page.waitForTimeout(300);
|
|
await page.screenshot({ path: path.join(shots, 'quick-audio.png') });
|
|
await page.keyboard.press('Escape');
|
|
await page.locator('.me .profile').click();
|
|
await page.getByRole('button', { name: 'Edit description' }).click();
|
|
await page.waitForTimeout(300);
|
|
await page.screenshot({ path: path.join(shots, 'editor.png') });
|
|
await page.getByRole('button', { name: 'Cancel' }).click();
|
|
await page.getByTitle('Settings').click();
|
|
await page.waitForTimeout(600);
|
|
await page.screenshot({ path: path.join(shots, 'settings-voice.png') });
|
|
await page.getByRole('button', { name: 'Done', exact: true }).click();
|
|
await page.locator('.sidebar .user', { hasText: 'bob' }).click();
|
|
await page.waitForTimeout(400);
|
|
await page.screenshot({ path: path.join(shots, 'profile.png') });
|
|
await page.setViewportSize({ width: 400, height: 780 });
|
|
await page.waitForTimeout(200);
|
|
// Small windows show the voice channel as tiles above the chat
|
|
const stage = page.getByRole('region', { name: 'Voice channel' });
|
|
await stage.locator('.tile', { hasText: 'bob' }).waitFor();
|
|
await stage.getByRole('button', { name: /^(Mute|Unmute)$/ }).waitFor();
|
|
await page.screenshot({ path: path.join(shots, 'narrow-stage.png') });
|
|
await page.getByLabel('Open channels').click();
|
|
await page.waitForTimeout(200);
|
|
await page.screenshot({ path: path.join(shots, 'narrow-drawer.png') });
|
|
}
|
|
console.log('E2E PASSED');
|
|
} catch (e) {
|
|
if (shots) await (await app.firstWindow()).screenshot({ path: path.join(shots, 'failure.png') });
|
|
throw e;
|
|
} finally {
|
|
bob.disconnect();
|
|
admin?.disconnect();
|
|
await app.close();
|
|
}
|