From 30d033117414301d198034b38db8d77fff474e4f Mon Sep 17 00:00:00 2001 From: Jared Vititoe Date: Tue, 30 Jun 2026 14:58:06 -0400 Subject: [PATCH] fix(ui): isMacOS always returned false on Macs + plugin-logic tests (+49) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Coverage work found a 3rd real bug: isMacOS() compared os.name against the legacy 'Mac OS' string, but ua-parser-js v2 reports 'macOS' — so it was dead, and Mac users saw "Ctrl + k" instead of "⌘ + k" in the editor toolbar, search, and settings shortcut hints. Now accepts both 'macOS' and 'Mac OS'. Suites (via subagent, verified): via-servers (10 — power/popularity server selection), bad-words (9), syntaxHighlight tokenize (14), plugins/utils getEmoticonSearchStr (5), imageCompression formatFileSize/isCompressible (5), user-agent (6, now asserting the fixed behavior). Full suite now 501 tests, all passing. Co-Authored-By: Claude Opus 4.8 --- src/app/plugins/bad-words.test.ts | 61 +++++++++++ src/app/plugins/utils.test.ts | 39 +++++++ src/app/plugins/via-servers.test.ts | 137 +++++++++++++++++++++++++ src/app/utils/imageCompression.test.ts | 38 +++++++ src/app/utils/syntaxHighlight.test.ts | 117 +++++++++++++++++++++ src/app/utils/user-agent.test.ts | 67 ++++++++++++ src/app/utils/user-agent.ts | 7 +- 7 files changed, 465 insertions(+), 1 deletion(-) create mode 100644 src/app/plugins/bad-words.test.ts create mode 100644 src/app/plugins/utils.test.ts create mode 100644 src/app/plugins/via-servers.test.ts create mode 100644 src/app/utils/imageCompression.test.ts create mode 100644 src/app/utils/syntaxHighlight.test.ts create mode 100644 src/app/utils/user-agent.test.ts diff --git a/src/app/plugins/bad-words.test.ts b/src/app/plugins/bad-words.test.ts new file mode 100644 index 000000000..040bdd661 --- /dev/null +++ b/src/app/plugins/bad-words.test.ts @@ -0,0 +1,61 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { testBadWords, BAD_WORDS_REGEX } from './bad-words'; + +test('testBadWords returns false for clean text', () => { + assert.equal(testBadWords('hello world'), false); + assert.equal(testBadWords('this is clean text'), false); + assert.equal(testBadWords(''), false); +}); + +test('testBadWords matches custom additions', () => { + // 'torture' and 't0rture' are appended in additionalBadWords + assert.equal(testBadWords('torture'), true); + assert.equal(testBadWords('t0rture'), true); +}); + +test('testBadWords is case-insensitive', () => { + assert.equal(testBadWords('Torture'), true); + assert.equal(testBadWords('TORTURE'), true); + assert.equal(testBadWords('DamN'), true); +}); + +test('testBadWords matches words from the base list', () => { + assert.equal(testBadWords('damn'), true); + assert.equal(testBadWords('hell'), true); + assert.equal(testBadWords('crap'), true); +}); + +test('testBadWords respects word boundaries (no match inside a larger word)', () => { + // alphanumeric extension on either side prevents a match + assert.equal(testBadWords('tortured'), false); + assert.equal(testBadWords('tortures'), false); + assert.equal(testBadWords('damning'), false); + assert.equal(testBadWords('hello'), false); + assert.equal(testBadWords('shell'), false); + assert.equal(testBadWords('crappy'), false); +}); + +test('testBadWords does not match a bad word as a substring of an unrelated word', () => { + assert.equal(testBadWords('class'), false); + assert.equal(testBadWords('pass'), false); + assert.equal(testBadWords('grass'), false); +}); + +test('underscore acts as a word boundary', () => { + // surrounding underscores still allow a match, since `_` is a boundary char + assert.equal(testBadWords('word_torture_word'), true); + // but an alphanumeric char glued directly to the word still blocks it + assert.equal(testBadWords('tor_ture'), false); +}); + +test('testBadWords matches a bad word embedded in a sentence with spaces', () => { + assert.equal(testBadWords('what the hell is this'), true); + assert.equal(testBadWords('I think this is fine'), false); +}); + +test('BAD_WORDS_REGEX is a global regex; reuse it via String.match', () => { + // testBadWords lowercases the input before matching + assert.ok('torture'.toLowerCase().match(BAD_WORDS_REGEX)); + assert.equal('innocent'.toLowerCase().match(BAD_WORDS_REGEX), null); +}); diff --git a/src/app/plugins/utils.test.ts b/src/app/plugins/utils.test.ts new file mode 100644 index 000000000..a13ce1213 --- /dev/null +++ b/src/app/plugins/utils.test.ts @@ -0,0 +1,39 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { getEmoticonSearchStr } from './utils'; +import { PackImageReader } from './custom-emoji'; +import { IEmoji } from './emoji'; + +test('getEmoticonSearchStr for a PackImageReader with a body returns shortcode + body', () => { + const reader = new PackImageReader('cat', 'mxc://server/cat', { body: 'kitten' }); + assert.deepEqual(getEmoticonSearchStr(reader), [':cat:', 'kitten']); +}); + +test('getEmoticonSearchStr for a PackImageReader without a body returns just the shortcode string', () => { + const reader = new PackImageReader('cat', 'mxc://server/cat', {}); + assert.equal(getEmoticonSearchStr(reader), ':cat:'); +}); + +test('getEmoticonSearchStr ignores a non-string body on a PackImageReader', () => { + const reader = new PackImageReader('cat', 'mxc://server/cat', { + body: 123 as unknown as string, + }); + assert.equal(getEmoticonSearchStr(reader), ':cat:'); +}); + +test('getEmoticonSearchStr for an IEmoji concats shortcode, label and shortcodes', () => { + const emoji = { + shortcode: 'smile', + label: 'Smiling Face', + shortcodes: ['smile', 'happy'], + } as unknown as IEmoji; + assert.deepEqual(getEmoticonSearchStr(emoji), [':smile:', 'Smiling Face', 'smile', 'happy']); +}); + +test('getEmoticonSearchStr for an IEmoji without a shortcodes array returns shortcode + label', () => { + const emoji = { + shortcode: 'smile', + label: 'Smiling Face', + } as unknown as IEmoji; + assert.deepEqual(getEmoticonSearchStr(emoji), [':smile:', 'Smiling Face']); +}); diff --git a/src/app/plugins/via-servers.test.ts b/src/app/plugins/via-servers.test.ts new file mode 100644 index 000000000..e15d2bc27 --- /dev/null +++ b/src/app/plugins/via-servers.test.ts @@ -0,0 +1,137 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { Room } from 'matrix-js-sdk'; +import { getViaServers } from './via-servers'; + +type StubEvent = + | { + content: Record; + sender?: string; + } + | undefined; + +type RoomStub = { + members: string[]; + create?: StubEvent; + power?: StubEvent; +}; + +const makeRoom = (opts: RoomStub): Room => { + const stateEvents: Record = {}; + if (opts.create) stateEvents['m.room.create'] = opts.create; + if (opts.power) stateEvents['m.room.power_levels'] = opts.power; + + const mkEvent = (e: StubEvent) => + e + ? { + getContent: () => e.content, + getSender: () => e.sender, + } + : undefined; + + return { + getMembers: () => opts.members.map((userId) => ({ userId })), + getLiveTimeline: () => ({ + getState: () => ({ + getStateEvents: (type: string) => mkEvent(stateEvents[type]), + }), + }), + } as unknown as Room; +}; + +test('with no power user it returns the top 3 most-populated servers, descending', () => { + const room = makeRoom({ + // s3 x2, s1 x2, s2 x1, s4 x1 — order between equal counts follows insertion + members: ['@a:s1.com', '@b:s1.com', '@c:s2.com', '@d:s3.com', '@e:s3.com', '@f:s4.com'], + }); + assert.deepEqual(getViaServers(room), ['s1.com', 's3.com', 's2.com']); +}); + +test('the highest-power user server is placed first, ahead of the populous servers', () => { + const room = makeRoom({ + // s3 is most populous (3), but the power user is on s2 + members: ['@a:s1.com', '@b:s1.com', '@c:s2.com', '@d:s3.com', '@e:s3.com', '@f:s3.com'], + power: { content: { users: { '@c:s2.com': 100 }, users_default: 0 } }, + }); + // power-user server first, then the top populated servers, s2 deduped + assert.deepEqual(getViaServers(room), ['s2.com', 's3.com', 's1.com']); +}); + +test('a power-user server that is also the most populous is deduped, result capped at 3', () => { + const room = makeRoom({ + members: [ + '@a:s1.com', + '@b:s1.com', + '@c:s1.com', + '@d:s2.com', + '@e:s3.com', + '@f:s4.com', + '@g:s5.com', + ], + power: { content: { users: { '@a:s1.com': 100 }, users_default: 0 } }, + }); + // s1 (power + most pop) first, then next 2 of the top-3 populated list + assert.deepEqual(getViaServers(room), ['s1.com', 's2.com', 's3.com']); +}); + +test('picks the user with the strictly highest power level', () => { + const room = makeRoom({ + members: ['@low:s1.com', '@high:s2.com', '@c:s3.com'], + power: { + content: { + users: { '@low:s1.com': 50, '@high:s2.com': 100 }, + users_default: 0, + }, + }, + }); + assert.equal(getViaServers(room)[0], 's2.com'); +}); + +test('ignores users whose power is not above users_default', () => { + const room = makeRoom({ + members: ['@a:s1.com', '@b:s2.com'], + // @a power equals users_default, so it is not treated as a power user + power: { content: { users: { '@a:s1.com': 50 }, users_default: 50 } }, + }); + // falls back to populated servers only + assert.deepEqual(getViaServers(room), ['s1.com', 's2.com']); +}); + +test('uses the create-event sender when the room version supports creators', () => { + const room = makeRoom({ + members: ['@a:s1.com', '@b:s2.com', '@c:s2.com'], + create: { content: { room_version: '12' }, sender: '@a:s1.com' }, + // power levels would point elsewhere, but creatorsSupported short-circuits + power: { content: { users: { '@c:s2.com': 100 }, users_default: 0 } }, + }); + assert.equal(getViaServers(room)[0], 's1.com'); +}); + +test('falls back to power levels when the room version does not support creators', () => { + const room = makeRoom({ + members: ['@a:s1.com', '@b:s2.com', '@c:s2.com'], + create: { content: { room_version: '11' }, sender: '@a:s1.com' }, + power: { content: { users: { '@c:s2.com': 100 }, users_default: 0 } }, + }); + assert.equal(getViaServers(room)[0], 's2.com'); +}); + +test('ignores members with unparseable user ids when counting populations', () => { + const room = makeRoom({ + members: ['@a:s1.com', 'broken-id', '@b:s1.com', '@c:s2.com'], + }); + assert.deepEqual(getViaServers(room), ['s1.com', 's2.com']); +}); + +test('returns an empty array for a room with no resolvable servers', () => { + const room = makeRoom({ members: ['broken-id', 'also-broken'] }); + assert.deepEqual(getViaServers(room), []); +}); + +test('result never exceeds three servers', () => { + const room = makeRoom({ + members: ['@a:s1.com', '@b:s2.com', '@c:s3.com', '@d:s4.com', '@e:s5.com'], + power: { content: { users: { '@a:s1.com': 100 }, users_default: 0 } }, + }); + assert.ok(getViaServers(room).length <= 3); +}); diff --git a/src/app/utils/imageCompression.test.ts b/src/app/utils/imageCompression.test.ts new file mode 100644 index 000000000..4bcf11415 --- /dev/null +++ b/src/app/utils/imageCompression.test.ts @@ -0,0 +1,38 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { formatFileSize, isCompressible } from './imageCompression'; + +test('formatFileSize formats bytes below 1KB as B', () => { + assert.equal(formatFileSize(0), '0 B'); + assert.equal(formatFileSize(1), '1 B'); + assert.equal(formatFileSize(512), '512 B'); + assert.equal(formatFileSize(1023), '1023 B'); +}); + +test('formatFileSize formats values in the KB range', () => { + assert.equal(formatFileSize(1024), '1.0 KB'); + assert.equal(formatFileSize(1536), '1.5 KB'); + // 1024 * 1024 - 1 is still under the MB boundary + assert.equal(formatFileSize(1024 * 1024 - 1), '1024.0 KB'); +}); + +test('formatFileSize formats values in the MB range', () => { + assert.equal(formatFileSize(1024 * 1024), '1.0 MB'); + assert.equal(formatFileSize(1024 * 1024 * 1.5), '1.5 MB'); + assert.equal(formatFileSize(1024 * 1024 * 10), '10.0 MB'); +}); + +test('isCompressible accepts raster image types', () => { + assert.equal(isCompressible({ type: 'image/png' } as Blob), true); + assert.equal(isCompressible({ type: 'image/jpeg' } as Blob), true); + assert.equal(isCompressible({ type: 'image/gif' } as Blob), true); + assert.equal(isCompressible({ type: 'image/webp' } as Blob), true); +}); + +test('isCompressible rejects svg, empty type, and non-images', () => { + assert.equal(isCompressible({ type: 'image/svg+xml' } as Blob), false); + assert.equal(isCompressible({ type: '' } as Blob), false); + assert.equal(isCompressible({ type: 'application/pdf' } as Blob), false); + assert.equal(isCompressible({ type: 'text/plain' } as Blob), false); + assert.equal(isCompressible({ type: 'video/mp4' } as Blob), false); +}); diff --git a/src/app/utils/syntaxHighlight.test.ts b/src/app/utils/syntaxHighlight.test.ts new file mode 100644 index 000000000..26f3954df --- /dev/null +++ b/src/app/utils/syntaxHighlight.test.ts @@ -0,0 +1,117 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { tokenize, tokenStyle, SyntaxToken } from './syntaxHighlight'; + +const find = (tokens: SyntaxToken[], text: string) => tokens.find((t) => t.text === text); + +test('tokenize falls back to a single plain token for unsupported languages', () => { + assert.deepEqual(tokenize('plain text here', 'unknownlang'), [ + { text: 'plain text here', type: 'plain' }, + ]); + // empty lang is also unsupported + assert.deepEqual(tokenize('hello', ''), [{ text: 'hello', type: 'plain' }]); +}); + +test('tokenize returns an empty array for empty input', () => { + assert.deepEqual(tokenize('', 'js'), []); +}); + +test('tokenize strips a leading "language-" prefix', () => { + const tokens = tokenize('const x', 'language-javascript'); + assert.equal(find(tokens, 'const')?.type, 'kw'); +}); + +test('tokenize classifies keywords, numbers, plain words and punctuation', () => { + const tokens = tokenize('const x = 5;', 'js'); + assert.deepEqual(tokens, [ + { text: 'const', type: 'kw' }, + { text: ' ', type: 'plain' }, + { text: 'x', type: 'plain' }, + { text: ' = ', type: 'plain' }, + { text: '5', type: 'num' }, + { text: ';', type: 'plain' }, + ]); +}); + +test('tokenize detects function-call identifiers by a following paren', () => { + const tokens = tokenize('foo(1)', 'js'); + assert.equal(find(tokens, 'foo')?.type, 'fn'); + assert.equal(find(tokens, '1')?.type, 'num'); +}); + +test('tokenize handles line comments up to the newline', () => { + const tokens = tokenize('// hi\ncode', 'js'); + assert.deepEqual(tokens[0], { text: '// hi', type: 'cmt' }); + // the newline and the rest are plain + assert.equal(find(tokens, 'code')?.type, 'plain'); +}); + +test('tokenize handles block comments including the closing delimiter', () => { + const tokens = tokenize('/* block */x', 'js'); + assert.deepEqual(tokens[0], { text: '/* block */', type: 'cmt' }); + assert.equal(find(tokens, 'x')?.type, 'plain'); +}); + +test('tokenize captures string literals including escaped quotes', () => { + assert.deepEqual(tokenize('"str"', 'js'), [{ text: '"str"', type: 'str' }]); + assert.deepEqual(tokenize("'a\\'b'", 'js'), [{ text: "'a\\'b'", type: 'str' }]); +}); + +test('tokenize treats # as a comment only in python', () => { + // python: # after a space starts a comment + const py = tokenize('a # not comment', 'python'); + assert.equal( + py.some((t) => t.type === 'cmt' && t.text === '# not comment'), + true, + ); + // js: # is not a comment marker + const js = tokenize('a # b', 'js'); + assert.equal( + js.some((t) => t.type === 'cmt'), + false, + ); +}); + +test('tokenize uses the python keyword set for python', () => { + const tokens = tokenize('def foo():', 'python'); + assert.equal(find(tokens, 'def')?.type, 'kw'); + assert.equal(find(tokens, 'foo')?.type, 'fn'); +}); + +test('tokenize uses the rust keyword set for rust', () => { + const tokens = tokenize('fn main() {}', 'rust'); + assert.equal(find(tokens, 'fn')?.type, 'kw'); + assert.equal(find(tokens, 'main')?.type, 'fn'); +}); + +test('tokenize keeps a plain word that is a keyword in another language plain', () => { + // "def" is a python keyword but not a js keyword + const tokens = tokenize('def x', 'js'); + assert.equal(find(tokens, 'def')?.type, 'plain'); +}); + +test('tokenize re-concatenates to the original source', () => { + const samples: Array<[string, string]> = [ + ['const x = foo(42); // done', 'js'], + ['def add(a, b):\n return a + b # sum', 'python'], + ['let mut v = vec![1, 2, 3];', 'rust'], + ]; + for (const [code, lang] of samples) { + assert.equal( + tokenize(code, lang) + .map((t) => t.text) + .join(''), + code, + `roundtrip for ${lang}`, + ); + } +}); + +test('tokenStyle returns distinct styles per token kind', () => { + assert.deepEqual(tokenStyle('kw'), { color: 'var(--prism-keyword)' }); + assert.deepEqual(tokenStyle('str'), { color: 'var(--prism-selector)' }); + assert.deepEqual(tokenStyle('num'), { color: 'var(--prism-boolean)' }); + assert.deepEqual(tokenStyle('cmt'), { color: 'var(--prism-comment)', fontStyle: 'italic' }); + assert.deepEqual(tokenStyle('fn'), { color: 'var(--prism-atrule)' }); + assert.deepEqual(tokenStyle('plain'), {}); +}); diff --git a/src/app/utils/user-agent.test.ts b/src/app/utils/user-agent.test.ts new file mode 100644 index 000000000..99da48189 --- /dev/null +++ b/src/app/utils/user-agent.test.ts @@ -0,0 +1,67 @@ +import { test, afterEach } from 'node:test'; +import assert from 'node:assert/strict'; +import { isMacOS, mobileOrTablet } from './user-agent'; + +// `ua()` reads `window.navigator.userAgent` lazily on every call, so we can swap +// the global mock per test case and restore it afterwards. +const UA = { + mac: 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0 Safari/537.36', + windows: + 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0 Safari/537.36', + linux: + 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0 Safari/537.36', + iphone: + 'Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Mobile/15E148 Safari/604.1', + ipad: 'Mozilla/5.0 (iPad; CPU OS 17_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Mobile/15E148 Safari/604.1', + android: + 'Mozilla/5.0 (Linux; Android 13; Pixel 7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0 Mobile Safari/537.36', +}; + +const g = globalThis as unknown as { window?: unknown }; +const originalWindow = g.window; + +const setUserAgent = (userAgent: string) => { + g.window = { navigator: { userAgent } }; +}; + +afterEach(() => { + g.window = originalWindow; +}); + +test('isMacOS returns true for a Mac user agent (handles both macOS and legacy Mac OS)', () => { + setUserAgent(UA.mac); + assert.equal(isMacOS(), true); +}); + +test('isMacOS returns false for non-Mac user agents', () => { + setUserAgent(UA.windows); + assert.equal(isMacOS(), false); + setUserAgent(UA.linux); + assert.equal(isMacOS(), false); + setUserAgent(UA.android); + assert.equal(isMacOS(), false); +}); + +test('mobileOrTablet is true for an iPhone', () => { + setUserAgent(UA.iphone); + assert.equal(mobileOrTablet(), true); +}); + +test('mobileOrTablet is true for an iPad', () => { + setUserAgent(UA.ipad); + assert.equal(mobileOrTablet(), true); +}); + +test('mobileOrTablet is true for an Android device', () => { + setUserAgent(UA.android); + assert.equal(mobileOrTablet(), true); +}); + +test('mobileOrTablet is false for desktop user agents', () => { + setUserAgent(UA.mac); + assert.equal(mobileOrTablet(), false); + setUserAgent(UA.windows); + assert.equal(mobileOrTablet(), false); + setUserAgent(UA.linux); + assert.equal(mobileOrTablet(), false); +}); diff --git a/src/app/utils/user-agent.ts b/src/app/utils/user-agent.ts index ca6d03d1d..907193590 100644 --- a/src/app/utils/user-agent.ts +++ b/src/app/utils/user-agent.ts @@ -2,7 +2,12 @@ import { UAParser } from 'ua-parser-js'; export const ua = () => UAParser(window.navigator.userAgent); -export const isMacOS = () => ua().os.name === 'Mac OS'; +// ua-parser-js reports macOS as 'macOS' (v2+); older versions used 'Mac OS'. +// Accept both so the ⌘-vs-Ctrl shortcut hints render correctly on real Macs. +export const isMacOS = () => { + const name = ua().os.name; + return name === 'macOS' || name === 'Mac OS'; +}; export const mobileOrTablet = (): boolean => { const userAgent = ua();