From 6e59395fb83b23fa3ec6c1c95aedaef301fca892 Mon Sep 17 00:00:00 2001 From: Jared Vititoe Date: Tue, 30 Jun 2026 14:32:53 -0400 Subject: [PATCH] test: lotus decorations, call caps, crypto, featureCheck, typing, markdown (+34) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Subagent batch (no bugs found) + markdown: - lotus/avatarDecorations (8): decorationUrl, CDN shape, ALL_DECORATIONS flattening, data invariants (unique category ids + slugs, slug charset). - plugins/call/utils (7): getCallCapabilities — static caps + room/user/device scoped state-keys. - utils/matrix-crypto (3): verifiedDevice via a stubbed CryptoApi. - utils/featureCheck (3): checkIndexedDBSupport success/error/throw paths. - state/typingMembers (8): add/dedup-by-latest-ts/per-room-scope/delete reducer via a jotai store (enableMapSet, mirroring app startup). - plugins/markdown/utils (5): inline + block escape/unescape round-trips. Full suite now 231 tests, all passing. Co-Authored-By: Claude Opus 4.8 --- .../features/lotus/avatarDecorations.test.ts | 68 ++++++++++++++ src/app/plugins/call/utils.test.ts | 75 +++++++++++++++ src/app/plugins/markdown/utils.test.ts | 46 ++++++++++ src/app/state/typingMembers.test.ts | 91 +++++++++++++++++++ src/app/utils/featureCheck.test.ts | 63 +++++++++++++ src/app/utils/matrix-crypto.test.ts | 42 +++++++++ 6 files changed, 385 insertions(+) create mode 100644 src/app/features/lotus/avatarDecorations.test.ts create mode 100644 src/app/plugins/call/utils.test.ts create mode 100644 src/app/plugins/markdown/utils.test.ts create mode 100644 src/app/state/typingMembers.test.ts create mode 100644 src/app/utils/featureCheck.test.ts create mode 100644 src/app/utils/matrix-crypto.test.ts diff --git a/src/app/features/lotus/avatarDecorations.test.ts b/src/app/features/lotus/avatarDecorations.test.ts new file mode 100644 index 000000000..9dadb0dce --- /dev/null +++ b/src/app/features/lotus/avatarDecorations.test.ts @@ -0,0 +1,68 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { + DECORATION_CDN, + DECORATION_CATEGORIES, + ALL_DECORATIONS, + decorationUrl, +} from './avatarDecorations'; + +test('decorationUrl builds a CDN png url from the slug', () => { + assert.equal(decorationUrl('joystick'), `${DECORATION_CDN}/joystick.png`); + assert.equal(decorationUrl('lotus_flower'), `${DECORATION_CDN}/lotus_flower.png`); + // slug is used verbatim, no encoding/normalisation + assert.equal(decorationUrl(''), `${DECORATION_CDN}/.png`); +}); + +test('DECORATION_CDN is an https url with no trailing slash', () => { + assert.match(DECORATION_CDN, /^https:\/\//); + assert.equal(DECORATION_CDN.endsWith('/'), false); +}); + +test('ALL_DECORATIONS is the flattened set of every category decoration', () => { + const expectedCount = DECORATION_CATEGORIES.reduce((n, c) => n + c.decorations.length, 0); + assert.equal(ALL_DECORATIONS.length, expectedCount); + + // every decoration in a category is present (by reference) in ALL_DECORATIONS + DECORATION_CATEGORIES.forEach((category) => { + category.decorations.forEach((decoration) => { + assert.ok(ALL_DECORATIONS.includes(decoration)); + }); + }); +}); + +test('every category has a non-empty id, label and decorations list', () => { + DECORATION_CATEGORIES.forEach((category) => { + assert.equal(typeof category.id, 'string'); + assert.ok(category.id.length > 0); + assert.equal(typeof category.label, 'string'); + assert.ok(category.label.length > 0); + assert.ok(Array.isArray(category.decorations)); + assert.ok(category.decorations.length > 0); + }); +}); + +test('category ids are unique', () => { + const ids = DECORATION_CATEGORIES.map((c) => c.id); + assert.equal(new Set(ids).size, ids.length); +}); + +test('every decoration slug is unique across all categories', () => { + const slugs = ALL_DECORATIONS.map((d) => d.slug); + assert.equal(new Set(slugs).size, slugs.length); +}); + +test('every decoration has a non-empty slug and name', () => { + ALL_DECORATIONS.forEach((decoration) => { + assert.equal(typeof decoration.slug, 'string'); + assert.ok(decoration.slug.length > 0); + assert.equal(typeof decoration.name, 'string'); + assert.ok(decoration.name.length > 0); + }); +}); + +test('slugs use the snake_case charset (lowercase, digits, underscore)', () => { + ALL_DECORATIONS.forEach((decoration) => { + assert.match(decoration.slug, /^[a-z0-9_]+$/, `bad slug: ${decoration.slug}`); + }); +}); diff --git a/src/app/plugins/call/utils.test.ts b/src/app/plugins/call/utils.test.ts new file mode 100644 index 000000000..263e98c8b --- /dev/null +++ b/src/app/plugins/call/utils.test.ts @@ -0,0 +1,75 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { MatrixCapabilities } from 'matrix-widget-api'; +import { getCallCapabilities } from './utils'; + +const ROOM = '!room:server'; +const USER = '@user:server'; +const DEVICE = 'DEVICE1'; + +test('getCallCapabilities returns a non-empty Set', () => { + const caps = getCallCapabilities(ROOM, USER, DEVICE); + assert.ok(caps instanceof Set); + assert.ok(caps.size > 0); +}); + +test('includes the static MatrixCapabilities', () => { + const caps = getCallCapabilities(ROOM, USER, DEVICE); + assert.ok(caps.has(MatrixCapabilities.Screenshots)); + assert.ok(caps.has(MatrixCapabilities.AlwaysOnScreen)); + assert.ok(caps.has(MatrixCapabilities.MSC4039UploadFile)); + assert.ok(caps.has(MatrixCapabilities.MSC4039DownloadFile)); + assert.ok(caps.has(MatrixCapabilities.MSC3846TurnServers)); + assert.ok(caps.has(MatrixCapabilities.MSC4157SendDelayedEvent)); + assert.ok(caps.has(MatrixCapabilities.MSC4157UpdateDelayedEvent)); +}); + +test('includes the room-scoped timeline and state capabilities', () => { + const caps = getCallCapabilities(ROOM, USER, DEVICE); + assert.ok(caps.has(`org.matrix.msc2762.timeline:${ROOM}`)); + assert.ok(caps.has(`org.matrix.msc2762.state:${ROOM}`)); +}); + +test('room scoping changes with the roomId', () => { + const a = getCallCapabilities('!a:server', USER, DEVICE); + const b = getCallCapabilities('!b:server', USER, DEVICE); + assert.ok(a.has('org.matrix.msc2762.timeline:!a:server')); + assert.ok(!a.has('org.matrix.msc2762.timeline:!b:server')); + assert.ok(b.has('org.matrix.msc2762.timeline:!b:server')); +}); + +test('includes send capability for the user-scoped call.member state event', () => { + const caps = getCallCapabilities(ROOM, USER, DEVICE); + const sendStateMember = [...caps].filter( + (c) => + c.includes('send') && c.includes('state') && c.includes('org.matrix.msc3401.call.member'), + ); + // five distinct state-keys are registered for the call.member send capability + assert.equal(sendStateMember.length, 5); + // the raw user id and the underscore-prefixed device-scoped key both appear + const joined = sendStateMember.join('\n'); + assert.ok(joined.includes(USER)); + assert.ok(joined.includes(`_${USER}_${DEVICE}_m.call`)); +}); + +test('registers both send and receive for each room-event type', () => { + const caps = getCallCapabilities(ROOM, USER, DEVICE); + [ + 'io.element.call.encryption_keys', + 'org.matrix.rageshake_request', + 'io.element.call.reaction', + 'org.matrix.msc4075.rtc.notification', + 'org.matrix.msc4310.rtc.decline', + ].forEach((type) => { + const matches = [...caps].filter((c) => c.includes(type)); + // one send + one receive room-event capability each + assert.ok(matches.length >= 2, `missing capability for ${type}`); + }); +}); + +test('user/device scoping flows into the device-keyed state keys', () => { + const caps = getCallCapabilities(ROOM, '@bob:srv', 'DEV2'); + const all = [...caps].join('\n'); + assert.ok(all.includes('@bob:srv_DEV2')); + assert.ok(all.includes('_@bob:srv_DEV2_m.call')); +}); diff --git a/src/app/plugins/markdown/utils.test.ts b/src/app/plugins/markdown/utils.test.ts new file mode 100644 index 000000000..6d35f0fea --- /dev/null +++ b/src/app/plugins/markdown/utils.test.ts @@ -0,0 +1,46 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { + escapeMarkdownInlineSequences, + unescapeMarkdownInlineSequences, + escapeMarkdownBlockSequences, + unescapeMarkdownBlockSequences, +} from './utils'; + +const identity = (t: string): string => t; + +test('escapeMarkdownInlineSequences backslash-escapes inline markdown chars', () => { + assert.equal(escapeMarkdownInlineSequences('a*b'), 'a\\*b'); + assert.equal(escapeMarkdownInlineSequences('under_score'), 'under\\_score'); + // plain text without markdown chars is unchanged + assert.equal(escapeMarkdownInlineSequences('plain text'), 'plain text'); +}); + +test('inline escape/unescape round-trips', () => { + for (const s of ['a*b*c', 'under_score', 'plain', 'mix *a* _b_']) { + assert.equal(unescapeMarkdownInlineSequences(escapeMarkdownInlineSequences(s)), s); + } +}); + +test('escapeMarkdownBlockSequences escapes leading block markers', () => { + assert.equal(escapeMarkdownBlockSequences('# heading', identity), '\\# heading'); + assert.equal(escapeMarkdownBlockSequences('> quote', identity), '\\> quote'); +}); + +test('block unescape passes non-escaped text through processPart', () => { + assert.equal(unescapeMarkdownBlockSequences('plain', identity), 'plain'); + // a custom processPart is applied to the (non-escaped) text + assert.equal( + unescapeMarkdownBlockSequences('plain', (t) => t.toUpperCase()), + 'PLAIN', + ); +}); + +test('block escape/unescape round-trips', () => { + for (const s of ['# h', '> quote', 'plain line']) { + assert.equal( + unescapeMarkdownBlockSequences(escapeMarkdownBlockSequences(s, identity), identity), + s, + ); + } +}); diff --git a/src/app/state/typingMembers.test.ts b/src/app/state/typingMembers.test.ts new file mode 100644 index 000000000..287389ac2 --- /dev/null +++ b/src/app/state/typingMembers.test.ts @@ -0,0 +1,91 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { createStore } from 'jotai'; +import { enableMapSet } from 'immer'; +import { + TYPING_TIMEOUT_MS, + roomIdToTypingMembersAtom, + type IRoomIdToTypingMembers, +} from './typingMembers'; + +// The add/remove/dedup/filter logic lives in the unexported putTypingMember / +// deleteTypingMember reducers, reachable only through `roomIdToTypingMembersAtom`. +// We exercise it via a jotai store (the pure reducer path). The PUT branch also +// schedules a real setTimeout for auto-expiry; that timer-driven cleanup and the +// React `useBindRoomIdToTypingMembersAtom` hook wiring are not covered here. +// The reducers `produce` over an immer-managed Map. The app turns this on once +// at startup (src/index.tsx calls enableMapSet()); we replicate that here. +enableMapSet(); + +const ROOM = '!room:server'; +const A = '@alice:server'; +const B = '@bob:server'; + +const members = (store: ReturnType): IRoomIdToTypingMembers => + store.get(roomIdToTypingMembersAtom); + +const userIds = (store: ReturnType, roomId: string): string[] => + (members(store).get(roomId) ?? []).map((r) => r.userId); + +test('TYPING_TIMEOUT_MS is 5 seconds', () => { + assert.equal(TYPING_TIMEOUT_MS, 5000); +}); + +test('starts empty', () => { + const store = createStore(); + assert.equal(members(store).size, 0); +}); + +test('PUT adds a typing member to a room', () => { + const store = createStore(); + store.set(roomIdToTypingMembersAtom, { type: 'PUT', roomId: ROOM, userId: A, ts: 1 }); + assert.deepEqual(userIds(store, ROOM), [A]); +}); + +test('PUT dedups by userId, keeping the latest ts', () => { + const store = createStore(); + store.set(roomIdToTypingMembersAtom, { type: 'PUT', roomId: ROOM, userId: A, ts: 1 }); + store.set(roomIdToTypingMembersAtom, { type: 'PUT', roomId: ROOM, userId: A, ts: 99 }); + + const receipts = members(store).get(ROOM) ?? []; + assert.equal(receipts.length, 1); + assert.equal(receipts[0].userId, A); + assert.equal(receipts[0].ts, 99); +}); + +test('PUT keeps distinct users side by side', () => { + const store = createStore(); + store.set(roomIdToTypingMembersAtom, { type: 'PUT', roomId: ROOM, userId: A, ts: 1 }); + store.set(roomIdToTypingMembersAtom, { type: 'PUT', roomId: ROOM, userId: B, ts: 1 }); + assert.deepEqual(userIds(store, ROOM).sort(), [A, B].sort()); +}); + +test('typing members are scoped per room', () => { + const store = createStore(); + store.set(roomIdToTypingMembersAtom, { type: 'PUT', roomId: '!r1:s', userId: A, ts: 1 }); + store.set(roomIdToTypingMembersAtom, { type: 'PUT', roomId: '!r2:s', userId: B, ts: 1 }); + assert.deepEqual(userIds(store, '!r1:s'), [A]); + assert.deepEqual(userIds(store, '!r2:s'), [B]); +}); + +test('DELETE removes a user and drops the room when it becomes empty', () => { + const store = createStore(); + store.set(roomIdToTypingMembersAtom, { type: 'PUT', roomId: ROOM, userId: A, ts: 1 }); + store.set(roomIdToTypingMembersAtom, { type: 'PUT', roomId: ROOM, userId: B, ts: 1 }); + + store.set(roomIdToTypingMembersAtom, { type: 'DELETE', roomId: ROOM, userId: A }); + assert.deepEqual(userIds(store, ROOM), [B]); + + store.set(roomIdToTypingMembersAtom, { type: 'DELETE', roomId: ROOM, userId: B }); + assert.equal(members(store).has(ROOM), false); +}); + +test('DELETE of an absent user is a no-op (no empty room created)', () => { + const store = createStore(); + store.set(roomIdToTypingMembersAtom, { type: 'DELETE', roomId: ROOM, userId: A }); + assert.equal(members(store).has(ROOM), false); + + store.set(roomIdToTypingMembersAtom, { type: 'PUT', roomId: ROOM, userId: A, ts: 1 }); + store.set(roomIdToTypingMembersAtom, { type: 'DELETE', roomId: ROOM, userId: B }); + assert.deepEqual(userIds(store, ROOM), [A]); +}); diff --git a/src/app/utils/featureCheck.test.ts b/src/app/utils/featureCheck.test.ts new file mode 100644 index 000000000..3ac7d305e --- /dev/null +++ b/src/app/utils/featureCheck.test.ts @@ -0,0 +1,63 @@ +import { test, afterEach } from 'node:test'; +import assert from 'node:assert/strict'; +import { checkIndexedDBSupport } from './featureCheck'; + +// `checkIndexedDBSupport` resolves by talking to the global `indexedDB`. There +// is no real IndexedDB in this Node test environment, so each case installs a +// minimal stub on `globalThis.indexedDB` and restores it afterwards. +type OpenRequest = { + onsuccess?: () => void; + onerror?: () => void; +}; + +const originalIndexedDB = (globalThis as { indexedDB?: unknown }).indexedDB; + +afterEach(() => { + (globalThis as { indexedDB?: unknown }).indexedDB = originalIndexedDB; +}); + +const installIndexedDB = (impl: unknown) => { + (globalThis as { indexedDB?: unknown }).indexedDB = impl; +}; + +test('resolves true when the open request fires onsuccess', async () => { + let deleted = false; + installIndexedDB({ + open: (): OpenRequest => { + const req: OpenRequest = {}; + // fire async, mimicking the real event loop + queueMicrotask(() => req.onsuccess?.()); + return req; + }, + deleteDatabase: () => { + deleted = true; + }, + }); + + assert.equal(await checkIndexedDBSupport(), true); + assert.equal(deleted, true); +}); + +test('resolves false when the open request fires onerror', async () => { + installIndexedDB({ + open: (): OpenRequest => { + const req: OpenRequest = {}; + queueMicrotask(() => req.onerror?.()); + return req; + }, + deleteDatabase: () => {}, + }); + + assert.equal(await checkIndexedDBSupport(), false); +}); + +test('resolves false when indexedDB.open throws synchronously', async () => { + installIndexedDB({ + open: () => { + throw new Error('blocked'); + }, + deleteDatabase: () => {}, + }); + + assert.equal(await checkIndexedDBSupport(), false); +}); diff --git a/src/app/utils/matrix-crypto.test.ts b/src/app/utils/matrix-crypto.test.ts new file mode 100644 index 000000000..d2213b738 --- /dev/null +++ b/src/app/utils/matrix-crypto.test.ts @@ -0,0 +1,42 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { CryptoApi } from 'matrix-js-sdk/lib/crypto-api'; +import { verifiedDevice } from './matrix-crypto'; + +// `verifiedDevice` only touches `api.getDeviceVerificationStatus`, so a tiny +// stub standing in for the real CryptoApi is enough to exercise the pure logic. +// Anything requiring an actual crypto backend (key import, cross-signing setup, +// etc.) is out of scope here and intentionally not covered. +const cryptoApi = (status: unknown): CryptoApi => + ({ + getDeviceVerificationStatus: async () => status, + }) as unknown as CryptoApi; + +test('verifiedDevice returns null when there is no verification status', async () => { + assert.equal(await verifiedDevice(cryptoApi(null), '@a:b', 'DEV'), null); + assert.equal(await verifiedDevice(cryptoApi(undefined), '@a:b', 'DEV'), null); +}); + +test('verifiedDevice surfaces crossSigningVerified when status exists', async () => { + assert.equal( + await verifiedDevice(cryptoApi({ crossSigningVerified: true }), '@a:b', 'DEV'), + true, + ); + assert.equal( + await verifiedDevice(cryptoApi({ crossSigningVerified: false }), '@a:b', 'DEV'), + false, + ); +}); + +test('verifiedDevice forwards userId and deviceId to the crypto api', async () => { + let received: [string, string] | undefined; + const api = { + getDeviceVerificationStatus: async (userId: string, deviceId: string) => { + received = [userId, deviceId]; + return { crossSigningVerified: true }; + }, + } as unknown as CryptoApi; + + await verifiedDevice(api, '@alice:example.org', 'ABCDEF'); + assert.deepEqual(received, ['@alice:example.org', 'ABCDEF']); +});