diff --git a/LOTUS_BUGS.md b/LOTUS_BUGS.md index 098f81293..3fb62221f 100644 --- a/LOTUS_BUGS.md +++ b/LOTUS_BUGS.md @@ -151,7 +151,7 @@ retry … AbortError: Restart delayed event timed out before the HS responded`, ### Code Hygiene / DevEx -- **Automated test suite — 527 tests across 60 modules, a hard CI gate.** `npm test` runs Node's built-in runner via `tsx` (not vitest — Vite 8 is ahead of vitest's range) and **blocks the build job on failure**. Broad pure-logic coverage: utils (common, regex, sanitize/XSS, time, matrix, matrix-uia, mimeTypes, sort, accentColor, findAndReplace, AsyncSearch, ASCIILexicalTable, keyboard, room, matrix-crypto, featureCheck, syntaxHighlight, imageCompression, user-agent, callSounds), state (settings, sessions, recentSearches, upload, typingMembers, lists, room-list, toast, scheduledMessages, backupRestore, callEmbed/callPreferences, spaceRooms, …), plugins (matrix-to, call/utils, via-servers, bad-words, recent-emoji, custom-emoji, markdown block/inline/utils), OIDC (cs-api, useParsedLoginFlows, oidcState), lotus/avatarDecorations, message-search, search filters. Prevention work has caught + fixed **3 real bugs** (`findAndReplace` infinite-loop; `getSettings` crash-on-load when storage is blocked; `isMacOS` never matching modern Macs). **Next:** component/integration tests (the untestable-under-tsx DOM/React surface). +- **Automated test suite — 545 tests across 62 modules, a hard CI gate.** `npm test` runs Node's built-in runner via `tsx` (not vitest — Vite 8 is ahead of vitest's range) and **blocks the build job on failure**. Broad pure-logic coverage: utils (common, regex, sanitize/XSS, time, matrix, matrix-uia, mimeTypes, sort, accentColor, findAndReplace, AsyncSearch, ASCIILexicalTable, keyboard, room, matrix-crypto, featureCheck, syntaxHighlight, imageCompression, user-agent, callSounds), state (settings, sessions, recentSearches, upload, typingMembers, lists, room-list, toast, scheduledMessages, backupRestore, callEmbed/callPreferences, spaceRooms, …), plugins (matrix-to, call/utils, via-servers, bad-words, recent-emoji, custom-emoji, markdown block/inline/utils), OIDC (cs-api, useParsedLoginFlows, oidcState), lotus/avatarDecorations, message-search, search filters. Prevention work has caught + fixed **4 real bugs** (`findAndReplace` infinite-loop; `getSettings` crash-on-load when storage is blocked; `isMacOS` never matching modern Macs; `isMLDenoiseSupported` throwing `ReferenceError` instead of returning false on browsers lacking the `AudioWorkletNode` binding). **Next:** component/integration tests (the untestable-under-tsx DOM/React surface). - **Extensive `as any` casts** across `src/` — gradual typing cleanup. - **`types/matrix/` mirrors SDK types** instead of importing them — drift risk. - **Hardcoded CDN URL** should move to an env var (the decoration CDN is now single-sourced in `avatarDecorations.ts`, but the literal is still in-repo). diff --git a/src/app/utils/lotusDenoiseUtils.test.ts b/src/app/utils/lotusDenoiseUtils.test.ts new file mode 100644 index 000000000..ad5e8ed42 --- /dev/null +++ b/src/app/utils/lotusDenoiseUtils.test.ts @@ -0,0 +1,108 @@ +import { test, beforeEach, afterEach } from 'node:test'; +import assert from 'node:assert/strict'; + +import { + DENOISE_MODELS, + ML_DENOISE_REQUIREMENTS, + isMLDenoiseSupported, +} from './lotusDenoiseUtils'; + +// ── Model catalog (data integrity) ────────────────────────────────────────── + +test('DENOISE_MODELS lists the four expected models in order', () => { + assert.deepEqual( + DENOISE_MODELS.map((m) => m.id), + ['rnnoise', 'speex', 'dtln', 'deepfilternet'], + ); +}); + +test('DENOISE_MODELS ids are unique', () => { + const ids = DENOISE_MODELS.map((m) => m.id); + assert.equal(new Set(ids).size, ids.length); +}); + +test('every model has non-empty display fields and valid rating enums', () => { + const transients = new Set(['Poor', 'Good', 'Excellent']); + const voice = new Set(['Moderate', 'High', 'Very High']); + for (const m of DENOISE_MODELS) { + for (const field of ['name', 'description', 'cpuUsage', 'binarySize'] as const) { + assert.ok(typeof m[field] === 'string' && m[field].length > 0, `${m.id}.${field} empty`); + } + assert.ok(transients.has(m.transients), `${m.id} bad transients: ${m.transients}`); + assert.ok(voice.has(m.voiceQuality), `${m.id} bad voiceQuality: ${m.voiceQuality}`); + } +}); + +test('ML_DENOISE_REQUIREMENTS is a non-empty list of strings', () => { + assert.ok(Array.isArray(ML_DENOISE_REQUIREMENTS) && ML_DENOISE_REQUIREMENTS.length > 0); + assert.ok(ML_DENOISE_REQUIREMENTS.every((r) => typeof r === 'string' && r.length > 0)); +}); + +// ── isMLDenoiseSupported (feature detection) ──────────────────────────────── + +const g = globalThis as Record; +const NAMES = ['window', 'navigator', 'AudioWorkletNode'] as const; +let saved: Record; + +const setGlobal = (name: string, value: unknown): void => { + Object.defineProperty(g, name, { value, configurable: true, writable: true }); +}; +const removeGlobal = (name: string): void => { + // Make a bare reference to `name` throw ReferenceError (simulate an absent + // global binding), as it would in a browser lacking the API entirely. + if (Object.getOwnPropertyDescriptor(g, name)) delete g[name]; +}; + +const withMediaDevices = { mediaDevices: { getUserMedia: () => Promise.resolve() } }; + +beforeEach(() => { + saved = {}; + for (const n of NAMES) saved[n] = Object.getOwnPropertyDescriptor(g, n); +}); +afterEach(() => { + for (const n of NAMES) { + const d = saved[n]; + if (d) Object.defineProperty(g, n, d); + else if (Object.getOwnPropertyDescriptor(g, n)) delete g[n]; + } +}); + +test('returns false when there is no window (non-browser)', () => { + setGlobal('window', undefined); + assert.equal(isMLDenoiseSupported(), false); +}); + +test('returns true when AudioContext, AudioWorklet and getUserMedia are present', () => { + setGlobal('window', { AudioContext: function AudioContext() {} }); + setGlobal('AudioWorkletNode', function AudioWorkletNode() {}); + setGlobal('navigator', withMediaDevices); + assert.equal(isMLDenoiseSupported(), true); +}); + +test('accepts the legacy webkitAudioContext prefix', () => { + setGlobal('window', { webkitAudioContext: function webkitAudioContext() {} }); + setGlobal('AudioWorkletNode', function AudioWorkletNode() {}); + setGlobal('navigator', withMediaDevices); + assert.equal(isMLDenoiseSupported(), true); +}); + +test('returns false when getUserMedia is unavailable', () => { + setGlobal('window', { AudioContext: function AudioContext() {} }); + setGlobal('AudioWorkletNode', function AudioWorkletNode() {}); + setGlobal('navigator', {}); // no mediaDevices + assert.equal(isMLDenoiseSupported(), false); +}); + +test('returns false (does NOT throw) when the AudioWorkletNode binding is absent', () => { + // Regression: a bare `!!AudioWorkletNode` threw ReferenceError on browsers + // with AudioContext but no AudioWorkletNode; a detection helper must report + // false, not throw. + setGlobal('window', { AudioContext: function AudioContext() {} }); + setGlobal('navigator', withMediaDevices); + removeGlobal('AudioWorkletNode'); + let result: boolean | undefined; + assert.doesNotThrow(() => { + result = isMLDenoiseSupported(); + }); + assert.equal(result, false); +}); diff --git a/src/app/utils/lotusDenoiseUtils.ts b/src/app/utils/lotusDenoiseUtils.ts index 9e6ae5800..73c559ab2 100644 --- a/src/app/utils/lotusDenoiseUtils.ts +++ b/src/app/utils/lotusDenoiseUtils.ts @@ -61,7 +61,11 @@ export const isMLDenoiseSupported = (): boolean => { // 2. AudioWorklet (Real-time processing in a background thread) // 3. getUserMedia (Microphone access) const hasAudioContext = !!(window.AudioContext || (window as any).webkitAudioContext); - const hasAudioWorklet = hasAudioContext && !!AudioWorkletNode; + // Use `typeof` rather than `!!AudioWorkletNode`: on a browser that has + // AudioContext but no AudioWorkletNode binding (older Safari/Edge), a bare + // reference throws ReferenceError, making this feature-detection helper throw + // instead of returning false. + const hasAudioWorklet = hasAudioContext && typeof AudioWorkletNode !== 'undefined'; const hasGetUserMedia = !!(navigator.mediaDevices && navigator.mediaDevices.getUserMedia); return hasAudioWorklet && hasGetUserMedia; diff --git a/src/app/utils/scheduledMessages.test.ts b/src/app/utils/scheduledMessages.test.ts new file mode 100644 index 000000000..69869ba31 --- /dev/null +++ b/src/app/utils/scheduledMessages.test.ts @@ -0,0 +1,121 @@ +import { test, beforeEach, afterEach } from 'node:test'; +import assert from 'node:assert/strict'; +import { Method } from 'matrix-js-sdk'; + +import { + scheduleMessage, + cancelScheduledMessage, + restartScheduledMessage, +} from './scheduledMessages'; + +// Minimal MatrixClient stub: records every authedRequest call and returns a +// canned { delay_id } so we can assert the request shape MSC4140 expects. +type Call = unknown[]; +const makeMx = (response: unknown = { delay_id: 'delay-123' }) => { + const calls: Call[] = []; + const mx = { + http: { + authedRequest: (...args: unknown[]) => { + calls.push(args); + return Promise.resolve(response); + }, + }, + }; + return { mx: mx as never, calls }; +}; + +const FIXED_NOW = 1_700_000_000_000; +let realNow: () => number; +beforeEach(() => { + realNow = Date.now; + Date.now = () => FIXED_NOW; +}); +afterEach(() => { + Date.now = realNow; +}); + +// ── scheduleMessage ───────────────────────────────────────────────────────── + +test('scheduleMessage sends PUT to the room message endpoint with the delay query', async () => { + const { mx, calls } = makeMx(); + const content = { msgtype: 'm.text', body: 'later' }; + const delayId = await scheduleMessage(mx, '!room:lotusguild.org', content, FIXED_NOW + 5000); + + assert.equal(calls.length, 1); + const [method, path, query, body] = calls[0]; + assert.equal(method, Method.Put); + // encodeURIComponent leaves '!' untouched but encodes ':'. + assert.ok( + (path as string).startsWith('/rooms/!room%3Alotusguild.org/send/m.room.message/sched_'), + `unexpected path: ${path as string}`, + ); + assert.deepEqual(query, { 'org.matrix.msc4140.delay': 5000 }); + assert.equal(body, content); // content passed through unchanged + assert.equal(delayId, 'delay-123'); // returns server delay_id +}); + +test('scheduleMessage rounds a fractional delay', async () => { + const { mx, calls } = makeMx(); + await scheduleMessage(mx, '!r:x', {}, FIXED_NOW + 1500.6); + assert.deepEqual(calls[0][2], { 'org.matrix.msc4140.delay': 1501 }); +}); + +test('scheduleMessage floors the delay at 1000ms when target is too soon', async () => { + const { mx, calls } = makeMx(); + await scheduleMessage(mx, '!r:x', {}, FIXED_NOW + 300); + assert.deepEqual(calls[0][2], { 'org.matrix.msc4140.delay': 1000 }); +}); + +test('scheduleMessage floors at 1000ms even for a target in the past', async () => { + const { mx, calls } = makeMx(); + await scheduleMessage(mx, '!r:x', {}, FIXED_NOW - 60_000); + assert.deepEqual(calls[0][2], { 'org.matrix.msc4140.delay': 1000 }); +}); + +test('scheduleMessage uses a unique sched_ transaction id per call', async () => { + const { mx, calls } = makeMx(); + await scheduleMessage(mx, '!r:x', {}, FIXED_NOW + 2000); + await scheduleMessage(mx, '!r:x', {}, FIXED_NOW + 2000); + const txn = (p: unknown) => (p as string).split('/').pop() as string; + const a = txn(calls[0][1]); + const b = txn(calls[1][1]); + assert.ok(a.startsWith('sched_')); + assert.ok(b.startsWith('sched_')); + assert.notEqual(a, b); +}); + +test('scheduleMessage url-encodes the room id', async () => { + const { mx, calls } = makeMx(); + await scheduleMessage(mx, '!a/b:c.org', {}, FIXED_NOW + 2000); + assert.ok((calls[0][1] as string).startsWith('/rooms/!a%2Fb%3Ac.org/send/')); +}); + +// ── cancel / restart ──────────────────────────────────────────────────────── + +test('cancelScheduledMessage POSTs action:cancel to the delayed_events endpoint', async () => { + const { mx, calls } = makeMx(); + await cancelScheduledMessage(mx, 'delay-xyz'); + assert.equal(calls.length, 1); + const [method, path, query, body, opts] = calls[0]; + assert.equal(method, Method.Post); + assert.equal(path, '/delayed_events/delay-xyz'); + assert.equal(query, undefined); + assert.deepEqual(body, { action: 'cancel' }); + assert.deepEqual(opts, { prefix: '/_matrix/client/unstable/org.matrix.msc4140' }); +}); + +test('restartScheduledMessage POSTs action:restart to the delayed_events endpoint', async () => { + const { mx, calls } = makeMx(); + await restartScheduledMessage(mx, 'delay-xyz'); + const [method, path, , body, opts] = calls[0]; + assert.equal(method, Method.Post); + assert.equal(path, '/delayed_events/delay-xyz'); + assert.deepEqual(body, { action: 'restart' }); + assert.deepEqual(opts, { prefix: '/_matrix/client/unstable/org.matrix.msc4140' }); +}); + +test('cancel/restart url-encode the delay id', async () => { + const { mx, calls } = makeMx(); + await cancelScheduledMessage(mx, 'a/b c'); + assert.equal(calls[0][1], '/delayed_events/a%2Fb%20c'); +});