test(utils): cover scheduledMessages + lotusDenoiseUtils; fix AudioWorklet detect
- scheduledMessages.test.ts (9): pins the MSC4140 request shape (PUT to the room send endpoint with the org.matrix.msc4140.delay query, POST cancel/restart to /delayed_events with the unstable prefix), the delay-floor math (Math.max(1000, round(sendAt-now)) — "now"/past targets still yield a valid >=1000ms delay), rounding, and url-encoding. - lotusDenoiseUtils.test.ts (9): model-catalog data integrity + isMLDenoiseSupported feature detection across AudioContext/webkit/getUserMedia. - Bug found + fixed: isMLDenoiseSupported used `!!AudioWorkletNode`, a bare global reference that throws ReferenceError (not returns false) on a browser with AudioContext but no AudioWorkletNode binding. Switched to `typeof` so the detection helper reports unsupported instead of throwing. Regression test proven to fail on the old code. Suite now 545 tests (4th real bug caught by the prevention work). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
+1
-1
@@ -151,7 +151,7 @@ retry … AbortError: Restart delayed event timed out before the HS responded`,
|
|||||||
|
|
||||||
### Code Hygiene / DevEx
|
### 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.
|
- **Extensive `as any` casts** across `src/` — gradual typing cleanup.
|
||||||
- **`types/matrix/` mirrors SDK types** instead of importing them — drift risk.
|
- **`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).
|
- **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).
|
||||||
|
|||||||
@@ -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<string, unknown>;
|
||||||
|
const NAMES = ['window', 'navigator', 'AudioWorkletNode'] as const;
|
||||||
|
let saved: Record<string, PropertyDescriptor | undefined>;
|
||||||
|
|
||||||
|
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);
|
||||||
|
});
|
||||||
@@ -61,7 +61,11 @@ export const isMLDenoiseSupported = (): boolean => {
|
|||||||
// 2. AudioWorklet (Real-time processing in a background thread)
|
// 2. AudioWorklet (Real-time processing in a background thread)
|
||||||
// 3. getUserMedia (Microphone access)
|
// 3. getUserMedia (Microphone access)
|
||||||
const hasAudioContext = !!(window.AudioContext || (window as any).webkitAudioContext);
|
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);
|
const hasGetUserMedia = !!(navigator.mediaDevices && navigator.mediaDevices.getUserMedia);
|
||||||
|
|
||||||
return hasAudioWorklet && hasGetUserMedia;
|
return hasAudioWorklet && hasGetUserMedia;
|
||||||
|
|||||||
@@ -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');
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user