test: lotus decorations, call caps, crypto, featureCheck, typing, markdown (+34)
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 <noreply@anthropic.com>
This commit is contained in:
@@ -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}`);
|
||||
});
|
||||
});
|
||||
@@ -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'));
|
||||
});
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
});
|
||||
@@ -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<typeof createStore>): IRoomIdToTypingMembers =>
|
||||
store.get(roomIdToTypingMembersAtom);
|
||||
|
||||
const userIds = (store: ReturnType<typeof createStore>, 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]);
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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']);
|
||||
});
|
||||
Reference in New Issue
Block a user