Files
im_webapp/web/tests/brand-and-notification-sound.test.mjs
T

113 lines
5.6 KiB
JavaScript

import assert from 'node:assert/strict';
import { createHash } from 'node:crypto';
import { readFile, stat } from 'node:fs/promises';
import test from 'node:test';
import { fileURLToPath } from 'node:url';
import { createJiti } from 'jiti';
globalThis.__APP_VERSION__ = 'test';
globalThis.window = { addEventListener() {}, removeEventListener() {}, focus() {} };
globalThis.document = { visibilityState: 'hidden', addEventListener() {}, title: '' };
globalThis.localStorage = { getItem() { return null; }, setItem() {}, removeItem() {} };
window.localStorage = localStorage;
const root = fileURLToPath(new URL('../', import.meta.url));
const jiti = createJiti(import.meta.url, { alias: { '@': `${root}src` } });
const source = (path) => readFile(new URL(`../${path}`, import.meta.url), 'utf8');
let soundAudio;
test('display brand changes while the Tinode client identifier stays MeetingIM', async () => {
const { BASE_APP_NAME, APP_NAME } = await jiti.import('../src/lib/config.ts');
assert.equal(BASE_APP_NAME, '迅工捷使');
assert.equal(APP_NAME, 'MeetingIM/test');
assert.match(await source('index.html'), /<title>迅工捷使<\/title>/);
assert.match(await source('src/features/auth/TenantEntryPage.tsx'), />\{BASE_APP_NAME\}<\/strong>/);
const system = await source('src/features/system/SystemMessagesView.tsx');
assert.equal((system.match(/迅工捷使 安全团队/g) ?? []).length, 2);
});
test('login uses the official icon and current mobile copy without unsupported links', async () => {
const login = await source('src/features/auth/LoginPage.tsx');
const tenant = await source('src/features/auth/TenantEntryPage.tsx');
assert.equal((login.match(/src="\/app-icon\.png"/g) ?? []).length, 2);
assert.match(tenant, /src="\/app-icon\.png"/);
assert.doesNotMatch(login, />\s*M\s*</);
assert.doesNotMatch(tenant, />\s*M\s*</);
assert.match(login, /单聊、群聊、通讯录、文件与收藏、音视频通话,消息与回执在手机端保持一致。/);
for (const stale of ['下载桌面客户端', 'iOS / Android', '帮助中心']) assert.doesNotMatch(login, new RegExp(stale));
});
test('official image and supplied sound are copied byte for byte and used for icons', async () => {
const digest = async (path) => createHash('sha256').update(await readFile(`${root}public/${path}`)).digest('hex');
assert.equal(await digest('app-icon.png'), 'e5d1487e61b70b0df1574b3809ab80072c7e6aecb1231c7dadac2f4cc13be7b6');
assert.equal(await digest('message-notification.mp3'), 'f0d9e1db55d578504b54b752b113b2c2ca91937594edc19247c0f2a3947a9654');
const html = await source('index.html');
const tab = await source('src/lib/notifications/tabIndicator.ts');
const push = await source('src/lib/notifications/push.ts');
assert.match(html, /rel="icon" type="image\/png" href="\/app-icon\.png"/);
assert.match(tab, /FAVICON_HREF = '\/app-icon\.png'/);
assert.match(push, /icon: '\/app-icon\.png'/);
await assert.rejects(stat(`${root}public/favicon.svg`), { code: 'ENOENT' });
});
test('sound is lazy, rewinds on repeat, and ignores sync and async playback failures', async () => {
const sound = await jiti.import('../src/lib/notifications/sound.ts');
assert.equal(typeof sound.playMessageNotificationSound, 'function');
const played = [];
globalThis.Audio = class {
constructor(src) { this.src = src; this.currentTime = 7; played.push(this); }
play() { return Promise.resolve(); }
};
assert.equal(played.length, 0);
sound.playMessageNotificationSound();
sound.playMessageNotificationSound();
assert.equal(played.length, 1);
soundAudio = played[0];
assert.equal(played[0].src, '/message-notification.mp3');
assert.equal(played[0].currentTime, 0);
played[0].play = () => { throw new Error('blocked'); };
assert.doesNotThrow(() => sound.playMessageNotificationSound());
played[0].play = () => Promise.reject(new Error('blocked'));
assert.doesNotThrow(() => sound.playMessageNotificationSound());
await new Promise((resolve) => setImmediate(resolve));
});
test('incoming alerts gate own, visible and muted messages, but sound needs no notification permission', async () => {
const { notifyIncomingMessage } = await jiti.import('../src/lib/notifications/push.ts');
assert.equal(typeof notifyIncomingMessage, 'function');
const { useSessionStore } = await jiti.import('../src/stores/session.ts');
const { setActiveConversationTopic } = await jiti.import('../src/lib/tinode/topics.ts');
useSessionStore.setState({ myUserId: 'me' });
let plays = 0;
soundAudio.play = () => { plays++; return Promise.resolve(); };
let notifications = 0;
globalThis.Notification = class {
static permission = 'default';
constructor() { notifications++; }
};
const contact = { topic: 'p2p-test', public: { fn: '朋友' }, private: {} };
notifyIncomingMessage('p2p-test', { from: 'me' }, contact);
setActiveConversationTopic('p2p-test');
document.visibilityState = 'visible';
notifyIncomingMessage('p2p-test', { from: 'peer' }, contact);
setActiveConversationTopic(null);
document.visibilityState = 'hidden';
notifyIncomingMessage('p2p-test', { from: 'peer' }, { ...contact, private: { mute_notifications: true } });
assert.equal(plays, 0);
notifyIncomingMessage('p2p-test', { from: 'peer' }, contact);
assert.equal(plays, 1);
assert.equal(notifications, 0);
delete globalThis.Notification;
notifyIncomingMessage('p2p-test', { from: 'peer' }, contact);
assert.equal(plays, 2);
assert.equal(notifications, 0);
globalThis.Notification = class {
static permission = 'granted';
constructor() { notifications++; }
};
notifyIncomingMessage('p2p-test', { from: 'peer' }, contact);
assert.equal(plays, 3);
assert.equal(notifications, 1);
});