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>/); 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); });