171 lines
8.2 KiB
JavaScript
171 lines
8.2 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 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>迅工捷使<\/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);
|
|
});
|
|
|
|
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);
|
|
});
|