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 freshSound = () => createJiti(import.meta.url, { alias: { '@': `${root}src` }, moduleCache: false }).import('../src/lib/notifications/sound.ts'); const source = (path) => readFile(new URL(`../${path}`, import.meta.url), 'utf8'); 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 freshSound(); 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); 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('first gesture silently primes one audio element for later message playback', async () => { const handlers = new Map(); window.addEventListener = (type, handler) => handlers.set(type, handler); window.removeEventListener = (type) => handlers.delete(type); const instances = []; globalThis.Audio = class { constructor(src) { this.src = src; this.currentTime = 8; this.muted = false; this.plays = []; this.pauses = 0; instances.push(this); } play() { this.plays.push(this.muted); return Promise.resolve(); } pause() { this.pauses++; } }; const sound = await freshSound(); assert.equal(instances.length, 0); assert.equal(typeof handlers.get('pointerdown'), 'function'); assert.equal(typeof handlers.get('keydown'), 'function'); const prime = handlers.get('pointerdown')(); assert.equal(instances.length, 1); assert.equal(instances[0].muted, true); assert.deepEqual(instances[0].plays, [true]); await prime; assert.equal(instances[0].pauses, 1); assert.equal(instances[0].currentTime, 0); assert.equal(instances[0].muted, false); assert.equal(handlers.size, 0); sound.playMessageNotificationSound(); assert.equal(instances.length, 1); assert.deepEqual(instances[0].plays, [true, false]); }); test('failed gesture prime unmutes and retries on the next gesture', async () => { const handlers = new Map(); window.addEventListener = (type, handler) => handlers.set(type, handler); window.removeEventListener = (type) => handlers.delete(type); let attempts = 0; let pauses = 0; let instance; globalThis.Audio = class { constructor() { instance = this; } currentTime = 4; muted = false; play() { return ++attempts === 1 ? Promise.reject(new Error('blocked')) : Promise.resolve(); } pause() { pauses++; } }; const sound = await freshSound(); assert.equal(typeof handlers.get('pointerdown'), 'function'); await handlers.get('pointerdown')(); assert.equal(attempts, 1); assert.equal(instance.muted, false); assert.equal(handlers.size, 2); await handlers.get('keydown')(); assert.equal(attempts, 2); assert.equal(pauses, 1); assert.equal(instance.muted, false); assert.equal(instance.currentTime, 0); assert.equal(handlers.size, 0); sound.playMessageNotificationSound(); assert.equal(attempts, 3); }); async function delayedPrimeFixture() { const handlers = new Map(); window.addEventListener = (type, handler) => handlers.set(type, handler); window.removeEventListener = (type) => handlers.delete(type); const plays = []; let resolvePrime; let rejectPrime; const primePlayback = new Promise((resolve, reject) => { resolvePrime = resolve; rejectPrime = reject; }); let instance; globalThis.Audio = class { constructor() { instance = this; } currentTime = 7; muted = false; play() { plays.push(this.muted); return plays.length === 1 ? primePlayback : Promise.resolve(); } pause() {} }; const sound = await freshSound(); return { handlers, plays, sound, get instance() { return instance; }, resolvePrime, rejectPrime }; } test('messages during a successful delayed prime play once unmuted after priming', async () => { const fixture = await delayedPrimeFixture(); const gesture = fixture.handlers.get('pointerdown')(); fixture.sound.playMessageNotificationSound(); fixture.sound.playMessageNotificationSound(); assert.deepEqual(fixture.plays, [true]); fixture.resolvePrime(); await gesture; assert.deepEqual(fixture.plays, [true, false]); assert.equal(fixture.instance.currentTime, 0); assert.equal(fixture.instance.muted, false); assert.equal(fixture.handlers.size, 0); }); test('messages during a failed delayed prime play once unmuted after priming', async () => { const fixture = await delayedPrimeFixture(); const gesture = fixture.handlers.get('pointerdown')(); fixture.sound.playMessageNotificationSound(); assert.deepEqual(fixture.plays, [true]); fixture.rejectPrime(new Error('blocked')); await gesture; assert.deepEqual(fixture.plays, [true, false]); assert.equal(fixture.instance.muted, false); assert.equal(fixture.handlers.size, 2); }); test('incoming alerts gate own, visible and muted messages, but sound needs no notification permission', async () => { let plays = 0; globalThis.Audio = class { currentTime = 0; play() { plays++; return Promise.resolve(); } }; 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' }); const notifications = []; globalThis.Notification = class { static permission = 'default'; constructor(title, options) { notifications.push({ title, options }); } }; 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.length, 0); delete globalThis.Notification; notifyIncomingMessage('p2p-test', { from: 'peer' }, contact); assert.equal(plays, 2); assert.equal(notifications.length, 0); globalThis.Notification = class { static permission = 'granted'; constructor(title, options) { notifications.push({ title, options }); } }; notifyIncomingMessage('p2p-test', { from: 'peer' }, contact); assert.equal(plays, 3); assert.equal(notifications.length, 1); assert.equal(notifications[0].options.silent, true); });