Compare commits

...

14 Commits

Author SHA1 Message Date
jared 4d55e45962 docs(bugs): test suite at 123 tests, now a hard CI gate
CI / Build & Quality Checks (push) Successful in 10m49s
CI / Trigger Desktop Build (push) Successful in 11s
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-30 13:26:58 -04:00
jared e3532064b5 test: add suites for accentColor (color math) + matrix-uia (auth flows) (+15)
- utils/accentColor (8): hexToRgb parsing, lighten/darken channel math, rgba
  clamping, WCAG relativeLuminance (black=0/white=1), contrastingText threshold,
  varNameFromToken, and derivePrimaryPalette's full 10-token output.
- utils/matrix-uia (7): UIA flow helpers — getSupportedUIAFlows,
  completed/params/session/errcode/error accessors, getUIAFlowForStages
  (incl. the single-extra-dummy rule), has/requiredStageInFlows, and
  getLoginTermUrl language fallback.

Full suite now 123 tests, all passing.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-30 13:23:36 -04:00
jared 1e37b20c6a ci: promote unit tests to a hard gate
108 deterministic pure-logic tests now block the build job (and thus deploy) on
failure, alongside the Build step. Moved out of the informational quality-checks
section and dropped continue-on-error.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-30 13:17:20 -04:00
jared 4f03775e04 docs(bugs): test suite at 108 tests across 11 modules
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-30 13:07:41 -04:00
jared 9678b02aba test: add suite for utils/sort room-list comparators (+5)
Covers byTsOldToNew, byOrderKey (undefined-last + the no-equality-branch
quirk for two present keys), and the factory comparators
factoryRoomIdByUnreadCount / factoryRoomIdByActivity / factoryRoomIdByAtoZ
(activity-desc, unread-desc, A–Z case-insensitive with leading-# stripped)
using minimal MatrixClient mocks. Full suite now 108 tests.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-30 13:04:45 -04:00
jared a926487f5e fix(utils): findAndReplace infinite loop on non-global regex + tests (+28)
Prevention work surfaced a real latent bug: findAndReplace looped forever
(OOM) on any non-global regex with a match — `match` was only reassigned
inside `if (regex.global)`, so a non-global regex never advanced. Fixed by
treating a non-global regex as a single match (`match = regex.global ?
regex.exec(text) : null`) and added a regression test. Latent in practice
(all current callers pass global regexes), but a crash waiting to happen.

New suites (tsx + node:test), verified empirically:
- utils/findAndReplace (10, incl. the regression)
- utils/AsyncSearch (9): normalize + matchQuery (the timer-based class is
  skipped — needs window.performance/setTimeout, unavailable in node)
- utils/ASCIILexicalTable (10): orderKeys gap-filling + invariants

Full suite now 103 tests, all passing.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-30 11:15:39 -04:00
jared ae1d30bc5a test: add suites for time, matrix, mimeTypes, and search filters (+47 tests)
Expands pure-logic coverage (harness: tsx + node:test):
- utils/time (21): date/time formatters — exact values where timezone-independent,
  structure/regex where locale/tz-sensitive (written via subagent).
- utils/matrix (13): pure id/mxc helpers (isUserId/isRoomId/isRoomAlias/
  getMxIdLocalPart/getMxIdServer/isServerName + room-version gates). (subagent)
- utils/mimeTypes (7): getBlobSafeMimeType allowlist+remap, safeFile rewrap,
  mimeTypeToExt, getFileNameExt/WithoutExt edge cases.
- message-search filters (6): filterGroupsByMsgType (union, empty-group drop,
  non-string msgtype) + filterGroupsByPinned (disabled passthrough, pinned-only).

All assertions verified against actual runtime behavior. Suite now 74 tests.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-30 10:27:57 -04:00
jared a7d145aa70 docs(bugs): manifest:false verified OK (not a bug)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-30 09:39:06 -04:00
jared 472d4ba008 test: add XSS-prevention suite for utils/sanitize
8 tests locking in security-critical behavior of sanitizeCustomHtml /
sanitizeText: script-content removal, event-handler stripping, javascript:
link neutralization, anchor hardening (noreferrer/noopener/_blank), non-mxc
<img> → link conversion, and the N100 <pre class> language-* restriction.
Verified against actual sanitize-html behavior. Suite now 27 tests.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-30 09:29:08 -04:00
jared 2a0478cad8 docs(bugs): N105 fixed → Needs Verification
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-30 09:13:34 -04:00
jared cee0c591e2 fix(pwa): N105 — notification clicks work after the tab is closed
OS notifications were shown via page-level `new Notification()` whose onclick
only works while the originating tab is alive — clicking a notification after
closing the tab did nothing.

- New `showOsNotification()` (utils/dom) prefers `registration.showNotification()`
  so the notification is service-worker-owned and persists; falls back to
  `new Notification()` (with the previous onclick) when no SW is available, so
  worst case is unchanged behaviour.
- sw.ts gains a `notificationclick` handler: focuses an existing app window and
  forwards the target path, or opens the app if none is open.
- ClientNonUIFeatures forwards the SW `notificationClick` message to react-router
  `navigate()` (works for both hash and browser router configs), and uses a
  per-room `tag` to coalesce notifications (replacing the old notifRef.close()
  dedup a SW notification can't hold).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-30 09:09:57 -04:00
jared 68b6ffffd7 docs(bugs): test-suite gap now seeded (harness + 19 tests)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-30 08:50:50 -04:00
jared 9bc8c4b47f test: add suite for utils/regex (sanitizeForRegex, URL/EMAIL/JUMBO_EMOJI)
Second pure-logic suite — another zero-import module. 4 tests covering regex
metacharacter escaping (with round-trip), the http(s) URL pattern, email
validation, and the jumbo-emoji matcher. Total suite now 19 tests.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-30 08:49:54 -04:00
jared e80ebd35cb test: add unit-test harness (tsx + node:test) + first suite for utils/common
Addresses the "no automated test suite" gap. Chose Node's built-in test runner
via tsx rather than vitest: the project is on Vite 8.0.14, ahead of vitest's
supported Vite range, so vitest would fight peer deps. tsx is build-independent.

- `npm test` → `node --import tsx --test $(find src -name '*.test.ts')` (works on
  Node 20 local + 24 CI without relying on --test glob support).
- src/app/utils/common.test.ts: 15 tests covering the pure helpers (bytesToSize,
  time formatters, binarySearch, parseGeoUri, slash trimmers, nameInitials,
  randomStr, suffixRename, splitWithSpace, promise-settled helpers, etc.) —
  asserts actual behavior, traced from source.
- common.ts: folds import made `import type` (it's types only) so the module is
  pure and testable without loading folds/CSS.
- tsconfig excludes *.test.ts (tsx transpiles tests; eslint isn't type-aware so
  it still lints them); added an informational CI "Unit tests" step (promote to a
  hard gate by dropping continue-on-error).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-30 08:45:22 -04:00
23 changed files with 1304 additions and 51 deletions
+6
View File
@@ -47,6 +47,12 @@ jobs:
NODE_OPTIONS: '--max_old_space_size=4096'
VITE_APP_VERSION: ${{ github.sha }}
# Unit tests are a hard gate too — deterministic pure-logic tests on Node's
# built-in runner via tsx (no vitest — Vite 8 is ahead of vitest's range).
# A failure blocks the deploy.
- name: Unit tests
run: npm test
# ── Quality checks (informational — pre-existing issues exist) ───────
- name: TypeScript
run: npm run typecheck
+4 -4
View File
@@ -16,7 +16,7 @@ step-by-step checks in [`LOTUS_TESTING.md`](./LOTUS_TESTING.md).
Implemented and gate-green; confirm each per `LOTUS_TESTING.md`, then delete the row.
| ID | Item | File / area | Test |
| :--- | :---------------------------------------------------------------------- | :--------------------------------------------------- | :------- |
| :--- | :------------------------------------------------------------------------------------- | :--------------------------------------------------- | :-------------------------------------------------------------------------------- |
| #2 | Chat-background animation flicker (`contain:paint`) | `lotus/chatBackground.ts` | F1 |
| #4 | Ringtone re-fixes: classic loudness + caller decline notice (A2 ✓ live) | `CallEmbedProvider.tsx`, `ringtones.ts` | A1,A3,A4 |
| #6 | Background vs. seasonal theme mutual exclusion | `state/settings.ts`, `General.tsx` | F2 |
@@ -29,6 +29,7 @@ Implemented and gate-green; confirm each per `LOTUS_TESTING.md`, then delete the
| N95 | AFK-monitor mic released on mute (OS indicator clears) | `hooks/useAfkAutoMute.ts` | L1 |
| N108 | Maskable PWA icons (Android adaptive) | `public/manifest.json` + `res/android/maskable-*` | L2 |
| EC | EC iframe load watchdog + self-heal + recovery UI | `plugins/call/CallEmbed.ts`, `CallView.tsx` | A7 |
| N105 | Notification clicks work after tab close (SW `notificationclick` + `showNotification`) | `sw.ts`, `utils/dom.ts`, `ClientNonUIFeatures.tsx` | get a msg notif, close the tab, click it → app focuses/opens + routes to the room |
| Gal | MediaGallery lazy-decrypt (true virtualization deferred) | `room/MediaGallery.tsx` | H1 |
| a11y | aria-labels: edit-history / reaction / thread / reply | `message/*` (`FallbackContent`, `Reaction`, `Reply`) | I |
@@ -87,10 +88,9 @@ Items from testing, with their fork-level fix path:
### PWA / Offline / Notifications
- **N105 — Service worker has no `notificationclick` handler** — notification clicks are broken when the tab is closed. Needs `showNotification()` via the SW + a `notificationclick` listener.
- **N107 — SW has no `push` handler** — Web Push delivery is entirely non-functional. Needs a `push` listener + a Matrix push-gateway integration.
- **No app-asset caching strategy** (`src/sw.ts`) — no offline capability.
- **`manifest: false`** in `vite.config.js` — may block correct PWA install if not handled externally.
- ~~**`manifest: false`** may block PWA install~~ — **verified OK (2026-06):** `index.html` links `/manifest.json`, which exists in `public/` and is copied to `dist/`; VitePWA intentionally doesn't generate one. Not a bug.
### Dependencies & Build
@@ -99,7 +99,7 @@ Items from testing, with their fork-level fix path:
### Code Hygiene / DevEx
- **No automated test suite** (`src/`) — no unit/integration tests configured.
- **Automated test suite — harness in place, 123 tests, now 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**. Covered: `utils/common`, `regex`, `sanitize` (XSS guards), `time`, `matrix`, `matrix-uia` (auth flows), `mimeTypes`, `sort`, `accentColor` (color math), `findAndReplace`, `AsyncSearch`, `ASCIILexicalTable`, message-search filters. Prevention work already caught + fixed a real bug (`findAndReplace` infinite-loop on non-global regex). **Next:** component/integration tests; more state/reducer logic.
- **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).
+20
View File
@@ -108,6 +108,7 @@
"husky": "9.1.7",
"lint-staged": "17.0.5",
"prettier": "3.8.3",
"tsx": "4.22.4",
"typescript": "6.0.3",
"vite": "8.0.14",
"vite-plugin-pwa": "1.3.0",
@@ -12357,6 +12358,25 @@
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="
},
"node_modules/tsx": {
"version": "4.22.4",
"resolved": "https://registry.npmjs.org/tsx/-/tsx-4.22.4.tgz",
"integrity": "sha512-X8EX+XV4QR5xCsrgxaED954zTDfY8KqlDtskKEL0cHhyS/P8b4IFOvGDQpsC9Q1XnLq915wEfwwY/zzskCtmhg==",
"dev": true,
"license": "MIT",
"dependencies": {
"esbuild": "~0.28.0"
},
"bin": {
"tsx": "dist/cli.mjs"
},
"engines": {
"node": ">=18.0.0"
},
"optionalDependencies": {
"fsevents": "~2.3.3"
}
},
"node_modules/type-check": {
"version": "0.4.0",
"resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz",
+2
View File
@@ -16,6 +16,7 @@
"check:prettier": "prettier --check .",
"fix:prettier": "prettier --write .",
"typecheck": "tsc --noEmit",
"test": "node --import tsx --test $(find src -name '*.test.ts')",
"prepare": "husky",
"commit": "git-cz",
"postinstall": "node scripts/patch-folds.mjs",
@@ -132,6 +133,7 @@
"husky": "9.1.7",
"lint-staged": "17.0.5",
"prettier": "3.8.3",
"tsx": "4.22.4",
"typescript": "6.0.3",
"vite": "8.0.14",
"vite-plugin-pwa": "1.3.0",
@@ -0,0 +1,74 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { filterGroupsByMsgType, filterGroupsByPinned, ResultGroup } from './useMessageSearch';
// Minimal ResultGroup/ResultItem fixtures — only the fields the filters read
// (event.content.msgtype, event.event_id, group.roomId).
const item = (msgtype: string | undefined, eventId: string) => ({
rank: 1,
event: { event_id: eventId, content: msgtype === undefined ? {} : { msgtype } },
context: {},
});
const mkGroups = (
...groups: { roomId: string; items: ReturnType<typeof item>[] }[]
): ResultGroup[] => groups as unknown as ResultGroup[];
test('filterGroupsByMsgType: empty filter returns groups unchanged', () => {
const groups = mkGroups({ roomId: '!r1', items: [item('m.text', '$1'), item('m.image', '$2')] });
assert.equal(filterGroupsByMsgType(groups, []), groups);
});
test('filterGroupsByMsgType: keeps only matching msgtypes (union)', () => {
const groups = mkGroups({
roomId: '!r1',
items: [item('m.text', '$1'), item('m.image', '$2'), item('m.file', '$3')],
});
const out = filterGroupsByMsgType(groups, ['m.image', 'm.file']);
assert.equal(out.length, 1);
assert.deepEqual(
out[0].items.map((i) => i.event.event_id),
['$2', '$3'],
);
});
test('filterGroupsByMsgType: drops groups left empty', () => {
const groups = mkGroups(
{ roomId: '!r1', items: [item('m.text', '$1')] },
{ roomId: '!r2', items: [item('m.image', '$2')] },
);
const out = filterGroupsByMsgType(groups, ['m.image']);
assert.equal(out.length, 1);
assert.equal(out[0].roomId, '!r2');
});
test('filterGroupsByMsgType: ignores items with a non-string msgtype', () => {
const groups = mkGroups({ roomId: '!r1', items: [item(undefined, '$1'), item('m.video', '$2')] });
const out = filterGroupsByMsgType(groups, ['m.video']);
assert.equal(out[0].items.length, 1);
assert.equal(out[0].items[0].event.event_id, '$2');
});
test('filterGroupsByPinned: disabled returns groups unchanged', () => {
const groups = mkGroups({ roomId: '!r1', items: [item('m.text', '$1')] });
assert.equal(
filterGroupsByPinned(groups, false, () => false),
groups,
);
});
test('filterGroupsByPinned: keeps pinned items and drops empty groups', () => {
const groups = mkGroups(
{ roomId: '!r1', items: [item('m.text', '$1'), item('m.text', '$2')] },
{ roomId: '!r2', items: [item('m.text', '$3')] },
);
const pinned = new Set(['!r1/$2']);
const out = filterGroupsByPinned(groups, true, (roomId, eventId) =>
pinned.has(`${roomId}/${eventId}`),
);
assert.equal(out.length, 1);
assert.equal(out[0].roomId, '!r1');
assert.deepEqual(
out[0].items.map((i) => i.event.event_id),
['$2'],
);
});
+38 -19
View File
@@ -8,7 +8,7 @@ import LogoUnreadSVG from '../../../../public/res/lotus-unread.png';
import LogoHighlightSVG from '../../../../public/res/lotus-highlight.png';
import NotificationSound from '../../../../public/sound/notification.ogg';
import InviteSound from '../../../../public/sound/invite.ogg';
import { notificationPermission, setFavicon } from '../../utils/dom';
import { notificationPermission, setFavicon, showOsNotification } from '../../utils/dom';
import { NOTIFICATION_SOUND_MAP } from '../../utils/notificationSounds';
import { useSetting } from '../../state/hooks/settings';
import { settingsAtom } from '../../state/settings';
@@ -141,17 +141,21 @@ function InviteNotifications() {
return;
}
const noti = new window.Notification('Invitation', {
const invitesPath = getInboxInvitesPath();
showOsNotification(
'Invitation',
{
icon: LogoSVG,
badge: LogoSVG,
body: `You have ${count} new invitation request.`,
silent: true,
});
noti.onclick = () => {
if (!window.closed) navigate(getInboxInvitesPath());
noti.close();
};
tag: 'lotus-invites',
data: { path: invitesPath },
},
() => {
if (!window.closed) navigate(invitesPath);
},
);
},
[navigate, setToast],
);
@@ -202,7 +206,6 @@ function PresenceUpdater() {
function MessageNotifications() {
const audioRef = useRef<HTMLAudioElement>(null);
const notifRef = useRef<Notification | undefined>(undefined);
const unreadCacheRef = useRef<Map<string, UnreadInfo>>(new Map());
const mx = useMatrixClient();
const useAuthentication = useMediaAuthentication();
@@ -276,26 +279,42 @@ function MessageNotifications() {
// persists in the notification center / lock screen / is readable by other
// apps). For encrypted rooms show only the sender; the in-page toast above
// still shows the preview while the user is actively looking at the screen.
const noti = new window.Notification(roomName, {
showOsNotification(
roomName,
{
icon: LogoSVG,
badge: LogoSVG,
body: !encrypted && body ? `${username}: ${body}`.slice(0, 120) : username,
silent: true,
});
noti.onclick = () => {
// Coalesce repeated notifications for the same room (replaces the old
// manual notifRef.close() dedup, which a SW notification can't hold).
tag: roomId,
data: { path: roomPath },
},
() => {
window.focus();
navigate(roomPath);
noti.close();
notifRef.current = undefined;
};
notifRef.current?.close();
notifRef.current = noti;
},
);
},
[navigate, setToast, mDirects],
);
// N105: when a service-worker-owned notification is clicked, the SW focuses
// this tab and forwards the target path here so we can route to it (works even
// when the click happened while the tab was in the background / reopened).
useEffect(() => {
if (!('serviceWorker' in navigator)) return undefined;
const onMessage = (event: MessageEvent) => {
const data = event.data ?? {};
if (data.type === 'notificationClick' && typeof data.path === 'string') {
navigate(data.path);
}
};
navigator.serviceWorker.addEventListener('message', onMessage);
return () => navigator.serviceWorker.removeEventListener('message', onMessage);
}, [navigate]);
const playSound = useCallback(() => {
const audioElement = audioRef.current;
audioElement?.play();
+90
View File
@@ -0,0 +1,90 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { ASCIILexicalTable, orderKeys } from './ASCIILexicalTable';
const a = 'a'.charCodeAt(0);
const z = 'z'.charCodeAt(0);
const makeLex = (maxWidth = 4) => new ASCIILexicalTable(a, z, maxWidth);
const isStrictlyIncreasing = (keys: string[]): boolean =>
keys.every((key, i) => i === 0 || keys[i - 1] < key);
test('orderKeys returns an empty array for empty input', () => {
const lex = makeLex();
assert.deepEqual(orderKeys(lex, []), []);
});
test('orderKeys preserves all existing keys when none are missing', () => {
const lex = makeLex();
assert.deepEqual(orderKeys(lex, ['a', 'b', 'c']), ['a', 'b', 'c']);
});
test('orderKeys keeps existing keys and fills a single interior gap', () => {
const lex = makeLex();
const result = orderKeys(lex, ['b', undefined, 'd']);
assert.deepEqual(result, ['b', 'c', 'd']);
});
test('orderKeys output length always matches input length', () => {
const lex = makeLex();
const inputs: Array<Array<string | undefined>> = [
[undefined],
[undefined, undefined],
[undefined, undefined, undefined],
['b', undefined, undefined, 'y'],
];
inputs.forEach((input) => {
const result = orderKeys(lex, input);
assert.ok(result, 'expected a defined result');
assert.equal(result?.length, input.length);
});
});
test('orderKeys produces strictly increasing, valid keys', () => {
const lex = makeLex();
const result = orderKeys(lex, [undefined, undefined, undefined, undefined]);
assert.ok(result);
assert.equal(result?.length, 4);
assert.ok(isStrictlyIncreasing(result as string[]));
assert.ok((result as string[]).every((key) => lex.has(key)));
});
test('orderKeys keeps fixed keys at their positions when filling gaps', () => {
const lex = makeLex();
const result = orderKeys(lex, ['b', undefined, undefined, 'y']) as string[];
assert.equal(result[0], 'b');
assert.equal(result[3], 'y');
assert.ok(isStrictlyIncreasing(result));
});
test('orderKeys is deterministic for the same input', () => {
const lex = makeLex();
const first = orderKeys(lex, [undefined, undefined, undefined]);
const second = orderKeys(lex, [undefined, undefined, undefined]);
assert.deepEqual(first, second);
});
test('orderKeys handles a leading gap before an existing key', () => {
const lex = makeLex();
const result = orderKeys(lex, [undefined, 'm']) as string[];
assert.equal(result.length, 2);
assert.equal(result[1], 'm');
assert.ok(result[0] < 'm');
});
test('orderKeys handles a trailing gap after an existing key', () => {
const lex = makeLex();
const result = orderKeys(lex, ['m', undefined]) as string[];
assert.equal(result.length, 2);
assert.equal(result[0], 'm');
assert.ok(result[1] > 'm');
});
test('orderKeys works with a tiny table', () => {
const tiny = new ASCIILexicalTable('a'.charCodeAt(0), 'b'.charCodeAt(0), 2);
const result = orderKeys(tiny, [undefined, undefined]) as string[];
assert.equal(result.length, 2);
assert.ok(isStrictlyIncreasing(result));
assert.ok(result.every((key) => tiny.has(key)));
});
+52
View File
@@ -0,0 +1,52 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { normalize, matchQuery } from './AsyncSearch';
test('normalize lowercases and strips whitespace by default', () => {
assert.equal(normalize('Hello World'), 'helloworld');
assert.equal(normalize(' A B '), 'ab');
assert.equal(normalize(''), '');
});
test('normalize strips all whitespace kinds by default', () => {
assert.equal(normalize('a\tb\nc\r d'), 'abcd');
});
test('normalize caseSensitive keeps original case', () => {
assert.equal(normalize('Hello World', { caseSensitive: true }), 'HelloWorld');
});
test('normalize ignoreWhitespace=false keeps whitespace', () => {
assert.equal(normalize('Hello World', { ignoreWhitespace: false }), 'hello world');
});
test('normalize default uses NFKC (compatibility) unicode normalization', () => {
// U+FB01 (fi ligature) decomposes to "fi" under NFKC but not NFC.
assert.equal(normalize('file'), 'file');
// With normalizeUnicode disabled (NFC), the ligature is preserved.
assert.equal(normalize('file', { normalizeUnicode: false }), 'file');
});
test('normalize does not strip diacritics', () => {
// normalize only does NFKC/NFC + lowercase; it does not remove accents.
assert.equal(normalize('Café'), 'café');
});
test('matchQuery default matches by prefix', () => {
assert.equal(matchQuery('hello world', 'hello'), true);
assert.equal(matchQuery('hello world', 'world'), false);
assert.equal(matchQuery('hello', ''), true);
assert.equal(matchQuery('', 'a'), false);
});
test('matchQuery contain option matches anywhere', () => {
assert.equal(matchQuery('hello world', 'world', { contain: true }), true);
assert.equal(matchQuery('hello world', 'lo wo', { contain: true }), true);
assert.equal(matchQuery('hello world', 'xyz', { contain: true }), false);
assert.equal(matchQuery('hello', '', { contain: true }), true);
});
test('matchQuery is case sensitive (does not normalize internally)', () => {
assert.equal(matchQuery('Hello', 'hello'), false);
assert.equal(matchQuery('Hello', 'Hello'), true);
});
+68
View File
@@ -0,0 +1,68 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import {
hexToRgb,
lighten,
darken,
rgba,
relativeLuminance,
contrastingText,
varNameFromToken,
derivePrimaryPalette,
} from './accentColor';
test('hexToRgb parses 6-digit hex (with/without #, trimmed)', () => {
assert.deepEqual(hexToRgb('#ff8800'), { r: 255, g: 136, b: 0 });
assert.deepEqual(hexToRgb('ff8800'), { r: 255, g: 136, b: 0 });
assert.deepEqual(hexToRgb(' #FF8800 '), { r: 255, g: 136, b: 0 });
assert.equal(hexToRgb('#fff'), undefined); // 3-digit not supported
assert.equal(hexToRgb('nope'), undefined);
});
test('lighten moves channels toward white', () => {
assert.deepEqual(lighten({ r: 255, g: 0, b: 0 }, 0.5), { r: 255, g: 127.5, b: 127.5 });
assert.deepEqual(lighten({ r: 0, g: 0, b: 0 }, 1), { r: 255, g: 255, b: 255 });
assert.deepEqual(lighten({ r: 10, g: 20, b: 30 }, 0), { r: 10, g: 20, b: 30 });
});
test('darken moves channels toward black', () => {
assert.deepEqual(darken({ r: 200, g: 100, b: 50 }, 0.5), { r: 100, g: 50, b: 25 });
assert.deepEqual(darken({ r: 255, g: 255, b: 255 }, 1), { r: 0, g: 0, b: 0 });
assert.deepEqual(darken({ r: 10, g: 20, b: 30 }, 0), { r: 10, g: 20, b: 30 });
});
test('rgba formats and clamps channels', () => {
assert.equal(rgba({ r: 255, g: 136, b: 0 }, 0.5), 'rgba(255, 136, 0, 0.5)');
assert.equal(rgba({ r: 300, g: -5, b: 128 }, 1), 'rgba(255, 0, 128, 1)');
});
test('relativeLuminance: black is 0, white is 1', () => {
assert.ok(Math.abs(relativeLuminance({ r: 0, g: 0, b: 0 })) < 1e-9);
assert.ok(Math.abs(relativeLuminance({ r: 255, g: 255, b: 255 }) - 1) < 1e-9);
// green contributes more than blue (per WCAG coefficients)
assert.ok(relativeLuminance({ r: 0, g: 255, b: 0 }) > relativeLuminance({ r: 0, g: 0, b: 255 }));
});
test('contrastingText picks black on light, white on dark', () => {
assert.equal(contrastingText({ r: 255, g: 255, b: 255 }), '#000');
assert.equal(contrastingText({ r: 0, g: 0, b: 0 }), '#fff');
});
test('varNameFromToken extracts the CSS var name', () => {
assert.equal(varNameFromToken('var(--oq6d07f)'), '--oq6d07f');
assert.equal(varNameFromToken('--bare'), undefined);
assert.equal(varNameFromToken('not a token'), undefined);
});
test('derivePrimaryPalette produces the full Primary token set', () => {
const palette = derivePrimaryPalette({ r: 255, g: 136, b: 0 });
assert.equal(Object.keys(palette).length, 10);
assert.equal(palette.Main, '#ff8800');
assert.equal(palette.MainLine, '#ff8800');
assert.equal(palette.Container, 'rgba(255, 136, 0, 0.12)');
assert.equal(palette.ContainerLine, 'rgba(255, 136, 0, 0.4)');
assert.equal(palette.OnMain, contrastingText({ r: 255, g: 136, b: 0 }));
// hover/active are valid 6-digit hex strings
assert.match(palette.MainHover, /^#[0-9a-f]{6}$/);
assert.match(palette.MainActive, /^#[0-9a-f]{6}$/);
});
+146
View File
@@ -0,0 +1,146 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import {
bytesToSize,
millisecondsToMinutesAndSeconds,
millisecondsToMinutes,
secondsToMinutesAndSeconds,
binarySearch,
randomNumberBetween,
scaleYDimension,
parseGeoUri,
trimLeadingSlash,
trimTrailingSlash,
trimSlash,
nameInitials,
randomStr,
suffixRename,
replaceSpaceWithDash,
splitWithSpace,
fulfilledPromiseSettledResult,
promiseFulfilledResult,
promiseRejectedResult,
} from './common';
test('bytesToSize', () => {
assert.equal(bytesToSize(0), '0KB');
assert.equal(bytesToSize(500), '0.5 KB'); // sub-KB is floored up to the KB unit
assert.equal(bytesToSize(1500), '1.5 KB');
assert.equal(bytesToSize(1_500_000), '1.5 MB');
assert.equal(bytesToSize(1_500_000_000), '1.5 GB');
});
test('millisecondsToMinutesAndSeconds zero-pads seconds', () => {
assert.equal(millisecondsToMinutesAndSeconds(0), '0:00');
assert.equal(millisecondsToMinutesAndSeconds(5000), '0:05');
assert.equal(millisecondsToMinutesAndSeconds(65_000), '1:05');
assert.equal(millisecondsToMinutesAndSeconds(125_000), '2:05');
});
test('millisecondsToMinutes floors to whole minutes', () => {
assert.equal(millisecondsToMinutes(0), '0');
assert.equal(millisecondsToMinutes(59_000), '0');
assert.equal(millisecondsToMinutes(125_000), '2');
});
test('secondsToMinutesAndSeconds', () => {
assert.equal(secondsToMinutesAndSeconds(5), '0:05');
assert.equal(secondsToMinutesAndSeconds(65), '1:05');
assert.equal(secondsToMinutesAndSeconds(90), '1:30');
});
test('binarySearch finds and misses on an ascending array', () => {
const arr = [1, 3, 5, 7, 9];
const matcher = (target: number) => (item: number) =>
item === target ? 0 : item > target ? 1 : -1;
assert.equal(binarySearch(arr, matcher(7)), 7);
assert.equal(binarySearch(arr, matcher(1)), 1);
assert.equal(binarySearch(arr, matcher(9)), 9);
assert.equal(binarySearch(arr, matcher(4)), undefined);
assert.equal(binarySearch([], matcher(1)), undefined);
});
test('randomNumberBetween stays within inclusive bounds', () => {
for (let i = 0; i < 1000; i += 1) {
const n = randomNumberBetween(3, 7);
assert.ok(n >= 3 && n <= 7, `${n} out of [3,7]`);
assert.equal(Number.isInteger(n), true);
}
assert.equal(randomNumberBetween(5, 5), 5);
});
test('scaleYDimension preserves aspect ratio', () => {
assert.equal(scaleYDimension(100, 50, 200), 100);
assert.equal(scaleYDimension(200, 200, 80), 80);
});
test('parseGeoUri', () => {
assert.deepEqual(parseGeoUri('geo:37.7,-122.4'), { latitude: '37.7', longitude: '-122.4' });
assert.deepEqual(parseGeoUri('geo:37.7,-122.4;u=10'), {
latitude: '37.7',
longitude: '-122.4',
});
assert.equal(parseGeoUri('not-a-geo-uri'), undefined);
assert.equal(parseGeoUri(''), undefined);
});
test('slash trimmers', () => {
assert.equal(trimLeadingSlash('///a/b'), 'a/b');
assert.equal(trimTrailingSlash('a/b///'), 'a/b');
assert.equal(trimSlash('//a/b//'), 'a/b');
assert.equal(trimSlash('a/b'), 'a/b');
});
test('nameInitials', () => {
assert.equal(nameInitials('Alice'), 'A');
assert.equal(nameInitials('alice'), 'a');
assert.equal(nameInitials('Alice Bob', 2), 'Al'); // takes characters, not words
// empty/nullish all collapse to the same single-char fallback
const fallback = nameInitials('');
assert.equal(fallback.length, 1);
assert.equal(nameInitials(null), fallback);
assert.equal(nameInitials(undefined), fallback);
});
test('randomStr length and charset', () => {
const allowed = /^[A-Za-z0-9]+$/;
assert.equal(randomStr(12).length, 12);
assert.equal(randomStr(1).length, 1);
assert.match(randomStr(32), allowed);
});
test('suffixRename increments until the validator rejects', () => {
// validator returns true while the candidate is still "taken"
assert.equal(
suffixRename('file', (n) => n === 'file1'),
'file2',
);
assert.equal(
suffixRename('doc', () => false),
'doc1',
);
});
test('replaceSpaceWithDash', () => {
assert.equal(replaceSpaceWithDash('a b c'), 'a-b-c');
assert.equal(replaceSpaceWithDash('nospaces'), 'nospaces');
});
test('splitWithSpace trims and drops empties', () => {
assert.deepEqual(splitWithSpace(' a b '), ['a', 'b']);
assert.deepEqual(splitWithSpace(' '), []);
assert.deepEqual(splitWithSpace('hello'), ['hello']);
});
test('promise settled-result helpers', () => {
const settled: PromiseSettledResult<number>[] = [
{ status: 'fulfilled', value: 1 },
{ status: 'rejected', reason: 'boom' },
{ status: 'fulfilled', value: 2 },
];
assert.deepEqual(fulfilledPromiseSettledResult(settled), [1, 2]);
assert.equal(promiseFulfilledResult(settled[0]), 1);
assert.equal(promiseFulfilledResult(settled[1]), undefined);
assert.equal(promiseRejectedResult(settled[1]), 'boom');
assert.equal(promiseRejectedResult(settled[0]), undefined);
});
+1 -1
View File
@@ -1,4 +1,4 @@
import { IconName, IconSrc } from 'folds';
import type { IconName, IconSrc } from 'folds';
export const bytesToSize = (bytes: number): string => {
const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB'];
+36
View File
@@ -254,6 +254,42 @@ export const notificationPermission = (permission: NotificationPermission) => {
return true;
};
/**
* Show an OS notification.
*
* Prefers a service-worker-owned notification (`registration.showNotification`)
* so the notification persists and its click is handled by the SW
* `notificationclick` listener even after the originating tab is closed — the
* data.path is forwarded to the SW. Falls back to a page-level `Notification`
* (with the provided `onClick`) when no service worker is available, preserving
* the previous behaviour.
*/
export const showOsNotification = async (
title: string,
options: NotificationOptions & { data?: { path?: string } },
onClick?: () => void,
): Promise<void> => {
try {
if ('serviceWorker' in navigator) {
const registration = await navigator.serviceWorker.ready;
if (registration && typeof registration.showNotification === 'function') {
await registration.showNotification(title, options);
return;
}
}
} catch {
// fall through to the page-level Notification below
}
const noti = new Notification(title, options);
if (onClick) {
noti.onclick = () => {
onClick();
noti.close();
};
}
};
export const getMouseEventCords = (event: MouseEvent) => ({
x: event.clientX,
y: event.clientY,
+110
View File
@@ -0,0 +1,110 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { findAndReplace } from './findAndReplace';
// Helpers that record exactly what the callbacks receive.
const tagPart = (text: string, pushIndex: number) => ({ part: text, at: pushIndex });
const tagMatch = (match: RegExpExecArray | RegExpMatchArray, pushIndex: number) => ({
match: match[0],
at: pushIndex,
});
test('findAndReplace interleaves converted parts and replacements (global regex)', () => {
const result = findAndReplace('a1b2c', /\d/g, tagMatch, tagPart);
assert.deepEqual(result, [
{ part: 'a', at: 0 },
{ match: '1', at: 1 },
{ part: 'b', at: 2 },
{ match: '2', at: 3 },
{ part: 'c', at: 4 },
]);
});
test('findAndReplace handles a NON-global regex as a single match (regression: no infinite loop)', () => {
// Before the fix, a non-global regex with a match looped forever (match was
// only reassigned inside `if (regex.global)`), OOM-crashing the process.
const result = findAndReplace('a1b2c', /\d/, tagMatch, tagPart);
assert.deepEqual(result, [
{ part: 'a', at: 0 },
{ match: '1', at: 1 },
// remainder after the first (only) match is a single converted part
{ part: 'b2c', at: 2 },
]);
});
test('findAndReplace pushIndex reflects result.length at push time', () => {
// The indices above already assert this; here we double-check the trailing part.
const result = findAndReplace('x9', /\d/g, tagMatch, tagPart);
// 'x' at 0, '9' at 1, '' at 2
assert.equal(result[result.length - 1].at, 2);
});
test('findAndReplace with no match returns the whole text as a single converted part', () => {
const result = findAndReplace('hello', /\d/g, tagMatch, tagPart);
assert.deepEqual(result, [{ part: 'hello', at: 0 }]);
});
test('findAndReplace on empty input returns a single empty converted part', () => {
const result = findAndReplace('', /\d/g, tagMatch, tagPart);
assert.deepEqual(result, [{ part: '', at: 0 }]);
});
test('findAndReplace emits empty parts between adjacent matches and at edges', () => {
const result = findAndReplace('12', /\d/g, tagMatch, tagPart);
assert.deepEqual(result, [
{ part: '', at: 0 },
{ match: '1', at: 1 },
{ part: '', at: 2 },
{ match: '2', at: 3 },
{ part: '', at: 4 },
]);
});
test('findAndReplace with a leading match emits an empty leading part', () => {
const result = findAndReplace('1abc', /\d/g, tagMatch, tagPart);
assert.deepEqual(result, [
{ part: '', at: 0 },
{ match: '1', at: 1 },
{ part: 'abc', at: 2 },
]);
});
// NOTE: A non-global regex is intentionally NOT tested here. With `regex.global`
// false, the source's `while (match !== null)` loop never reassigns `match`
// (reassignment is guarded by `if (regex.global)`), so it loops forever on any
// matching input. See the report. All real callers pass global regexes.
test('findAndReplace supports multi-character matches', () => {
const result = findAndReplace('foo<<bar>>baz', /<<|>>/g, tagMatch, tagPart);
assert.deepEqual(result, [
{ part: 'foo', at: 0 },
{ match: '<<', at: 1 },
{ part: 'bar', at: 2 },
{ match: '>>', at: 3 },
{ part: 'baz', at: 4 },
]);
});
test('findAndReplace can build a transformed string', () => {
const out = findAndReplace<string, string>(
'cat and dog',
/cat|dog/g,
(m) => (m[0] === 'cat' ? 'CAT' : 'DOG'),
(text) => text,
).join('');
assert.equal(out, 'CAT and DOG');
});
test('findAndReplace passes the full match object to replace', () => {
const captured: Array<string | undefined> = [];
findAndReplace(
'name=value',
/(\w+)=(\w+)/g,
(m) => {
captured.push(m[1], m[2]);
return null;
},
() => null,
);
assert.deepEqual(captured, ['name', 'value']);
});
+4 -1
View File
@@ -19,7 +19,10 @@ export const findAndReplace = <ReplaceReturnType, ConvertReturnType>(
result.push(replace(match, result.length));
lastEnd = match.index + match[0].length;
if (regex.global) match = regex.exec(text);
// A non-global regex always returns the same first match from exec() (its
// lastIndex never advances), so re-running it would loop forever. Treat a
// non-global regex as a single match and stop after processing it.
match = regex.global ? regex.exec(text) : null;
}
result.push(convertPart(text.slice(lastEnd), result.length));
+84
View File
@@ -0,0 +1,84 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { AuthType, IAuthData, UIAFlow } from 'matrix-js-sdk';
import {
getSupportedUIAFlows,
getUIACompleted,
getUIAParams,
getUIASession,
getUIAErrorCode,
getUIAError,
getUIAFlowForStages,
hasStageInFlows,
requiredStageInFlows,
getLoginTermUrl,
} from './matrix-uia';
const flows = (...stageLists: string[][]): UIAFlow[] =>
stageLists.map((stages) => ({ stages })) as UIAFlow[];
const auth = (data: Record<string, unknown>): IAuthData => data as unknown as IAuthData;
test('getSupportedUIAFlows keeps only fully-supported flows', () => {
const f = flows(['a', 'b'], ['a', 'c']);
assert.deepEqual(getSupportedUIAFlows(f, ['a', 'b']), [{ stages: ['a', 'b'] }]);
assert.deepEqual(getSupportedUIAFlows(f, ['a', 'b', 'c']), f);
assert.deepEqual(getSupportedUIAFlows(f, ['x']), []);
});
test('getUIACompleted / Params / Session default sensibly', () => {
assert.deepEqual(getUIACompleted(auth({ completed: ['m.login.password'] })), [
'm.login.password',
]);
assert.deepEqual(getUIACompleted(auth({})), []);
assert.deepEqual(getUIAParams(auth({ params: { x: { y: 1 } } })), { x: { y: 1 } });
assert.deepEqual(getUIAParams(auth({})), {});
assert.equal(getUIASession(auth({ session: 'abc' })), 'abc');
assert.equal(getUIASession(auth({})), undefined);
});
test('getUIAErrorCode / getUIAError read string fields only', () => {
assert.equal(getUIAErrorCode(auth({ errcode: 'M_FORBIDDEN' })), 'M_FORBIDDEN');
assert.equal(getUIAErrorCode(auth({ errcode: 42 })), undefined);
assert.equal(getUIAErrorCode(auth({})), undefined);
assert.equal(getUIAError(auth({ error: 'Bad' })), 'Bad');
assert.equal(getUIAError(auth({})), undefined);
});
test('getUIAFlowForStages: exact match and no match', () => {
const f = flows(['m.login.password'], ['m.login.recaptcha', 'm.login.password']);
assert.deepEqual(getUIAFlowForStages(f, ['m.login.password']), { stages: ['m.login.password'] });
assert.equal(getUIAFlowForStages(f, ['m.login.sso']), undefined);
});
test('getUIAFlowForStages allows a single extra m.login.dummy stage', () => {
const f = flows(['m.login.recaptcha', AuthType.Dummy]);
assert.deepEqual(getUIAFlowForStages(f, ['m.login.recaptcha']), {
stages: ['m.login.recaptcha', AuthType.Dummy],
});
// two extra stages (more than dummy) → no match
assert.equal(getUIAFlowForStages(flows(['a', 'b', AuthType.Dummy]), ['a']), undefined);
});
test('hasStageInFlows / requiredStageInFlows', () => {
const f = flows(['m.login.password'], ['m.login.recaptcha', 'm.login.password']);
assert.equal(hasStageInFlows(f, 'm.login.recaptcha'), true);
assert.equal(hasStageInFlows(f, 'm.login.sso'), false);
assert.equal(requiredStageInFlows(f, 'm.login.password'), true);
assert.equal(requiredStageInFlows(f, 'm.login.recaptcha'), false);
});
test('getLoginTermUrl prefers en, else the first language', () => {
const base = (policies: unknown) => ({ [AuthType.Terms]: { policies } });
assert.equal(
getLoginTermUrl(
base({ privacy_policy: { en: { url: 'https://en' }, fr: { url: 'https://fr' } } }),
),
'https://en',
);
assert.equal(
getLoginTermUrl(base({ privacy_policy: { fr: { url: 'https://fr' } } })),
'https://fr',
);
assert.equal(getLoginTermUrl({}), undefined);
assert.equal(getLoginTermUrl(base(null)), undefined);
});
+132
View File
@@ -0,0 +1,132 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import {
isServerName,
getMxIdServer,
getMxIdLocalPart,
isUserId,
isRoomId,
isRoomAlias,
knockSupported,
restrictedSupported,
knockRestrictedSupported,
creatorsSupported,
} from './matrix';
test('isServerName matches domain-like strings', () => {
assert.equal(isServerName('matrix.org'), true);
assert.equal(isServerName('a.b.example.com'), true);
assert.equal(isServerName('my-server.io'), true);
// The regex looks for a domain substring anywhere, so embedded domains match.
assert.equal(isServerName('user@matrix.org'), true);
assert.equal(isServerName('matrix.org:8448'), true);
});
test('isServerName rejects strings without a dotted TLD', () => {
assert.equal(isServerName('localhost'), false);
assert.equal(isServerName(''), false);
assert.equal(isServerName('nodot'), false);
// TLD must be at least two letters.
assert.equal(isServerName('a.b'), false);
// Numeric "TLD" is not matched by the [a-zA-Z]{2,} part.
assert.equal(isServerName('127.0.0.1'), false);
});
test('getMxIdServer extracts the server portion after the colon', () => {
assert.equal(getMxIdServer('@alice:matrix.org'), 'matrix.org');
assert.equal(getMxIdServer('#room:example.com'), 'example.com');
assert.equal(getMxIdServer('+group:server.io'), 'server.io');
assert.equal(getMxIdServer('$event:host.net'), 'host.net');
});
test('getMxIdServer returns undefined for ids that do not match', () => {
// '!' is not an accepted sigil in the matcher.
assert.equal(getMxIdServer('!roomid:matrix.org'), undefined);
assert.equal(getMxIdServer('alice:matrix.org'), undefined);
assert.equal(getMxIdServer('@alice'), undefined);
assert.equal(getMxIdServer(''), undefined);
// A space in the local part breaks the [^\s:]+ group.
assert.equal(getMxIdServer('@al ice:matrix.org'), undefined);
});
test('getMxIdLocalPart extracts the local part between sigil and colon', () => {
assert.equal(getMxIdLocalPart('@alice:matrix.org'), 'alice');
assert.equal(getMxIdLocalPart('#general:example.com'), 'general');
assert.equal(getMxIdLocalPart('+group:server.io'), 'group');
assert.equal(getMxIdLocalPart('$event:host.net'), 'event');
});
test('getMxIdLocalPart returns undefined for non-matching ids', () => {
assert.equal(getMxIdLocalPart('!roomid:matrix.org'), undefined);
assert.equal(getMxIdLocalPart('alice'), undefined);
assert.equal(getMxIdLocalPart('@alice'), undefined);
assert.equal(getMxIdLocalPart(''), undefined);
});
test('isUserId requires a valid mxid starting with @', () => {
assert.equal(isUserId('@alice:matrix.org'), true);
assert.equal(isUserId('@bob:example.com'), true);
// Valid mxid shape but wrong sigil.
assert.equal(isUserId('#room:matrix.org'), false);
assert.equal(isUserId('+group:matrix.org'), false);
// Starts with @ but has no server (invalid mxid).
assert.equal(isUserId('@alice'), false);
assert.equal(isUserId('alice:matrix.org'), false);
assert.equal(isUserId(''), false);
});
test('isRoomId only checks the leading ! sigil', () => {
assert.equal(isRoomId('!abc:matrix.org'), true);
// No validity check is performed, so a bare '!' passes.
assert.equal(isRoomId('!'), true);
assert.equal(isRoomId('!anything-at-all'), true);
assert.equal(isRoomId('@alice:matrix.org'), false);
assert.equal(isRoomId('#room:matrix.org'), false);
assert.equal(isRoomId(''), false);
});
test('isRoomAlias requires a valid mxid starting with #', () => {
assert.equal(isRoomAlias('#general:matrix.org'), true);
assert.equal(isRoomAlias('#room:example.com'), true);
// Wrong sigils.
assert.equal(isRoomAlias('@alice:matrix.org'), false);
assert.equal(isRoomAlias('!room:matrix.org'), false);
// Starts with # but missing server part.
assert.equal(isRoomAlias('#general'), false);
assert.equal(isRoomAlias(''), false);
});
test('knockSupported gates room versions 1-6 out', () => {
['1', '2', '3', '4', '5', '6'].forEach((v) => {
assert.equal(knockSupported(v), false, `version ${v}`);
});
assert.equal(knockSupported('7'), true);
assert.equal(knockSupported('8'), true);
assert.equal(knockSupported('10'), true);
// Unknown / non-numeric versions are treated as supported.
assert.equal(knockSupported('org.matrix.msc'), true);
});
test('restrictedSupported gates room versions 1-7 out', () => {
['1', '2', '3', '4', '5', '6', '7'].forEach((v) => {
assert.equal(restrictedSupported(v), false, `version ${v}`);
});
assert.equal(restrictedSupported('8'), true);
assert.equal(restrictedSupported('9'), true);
});
test('knockRestrictedSupported gates room versions 1-9 out', () => {
['1', '2', '3', '4', '5', '6', '7', '8', '9'].forEach((v) => {
assert.equal(knockRestrictedSupported(v), false, `version ${v}`);
});
assert.equal(knockRestrictedSupported('10'), true);
assert.equal(knockRestrictedSupported('11'), true);
});
test('creatorsSupported gates room versions 1-11 out', () => {
['1', '2', '3', '4', '5', '6', '7', '8', '9', '10', '11'].forEach((v) => {
assert.equal(creatorsSupported(v), false, `version ${v}`);
});
assert.equal(creatorsSupported('12'), true);
assert.equal(creatorsSupported('13'), true);
});
+60
View File
@@ -0,0 +1,60 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import {
ALLOWED_BLOB_MIME_TYPES,
FALLBACK_MIMETYPE,
getBlobSafeMimeType,
safeFile,
mimeTypeToExt,
getFileNameExt,
getFileNameWithoutExt,
} from './mimeTypes';
test('getBlobSafeMimeType falls back for disallowed types', () => {
assert.equal(getBlobSafeMimeType('application/x-totally-not-allowed'), FALLBACK_MIMETYPE);
// non-string input is coerced to the fallback
assert.equal(getBlobSafeMimeType(undefined as unknown as string), FALLBACK_MIMETYPE);
});
test('getBlobSafeMimeType passes allowed types and strips codec params', () => {
const allowed = ALLOWED_BLOB_MIME_TYPES[0];
assert.equal(getBlobSafeMimeType(allowed), allowed);
// everything after the first ';' is dropped
assert.equal(getBlobSafeMimeType(`${allowed}; charset=utf-8`), allowed);
});
test('getBlobSafeMimeType remaps quicktime/ogg (when allowlisted)', () => {
if (ALLOWED_BLOB_MIME_TYPES.includes('video/quicktime')) {
assert.equal(getBlobSafeMimeType('video/quicktime'), 'video/mp4');
}
if (ALLOWED_BLOB_MIME_TYPES.includes('application/ogg')) {
assert.equal(getBlobSafeMimeType('application/ogg'), 'audio/ogg');
}
});
test('safeFile rewraps a file whose type is not blob-safe', () => {
const f = new File(['x'], 'note.txt', { type: 'application/x-bad' });
const safe = safeFile(f);
assert.equal(safe.type, FALLBACK_MIMETYPE);
assert.equal(safe.name, 'note.txt');
});
test('mimeTypeToExt returns the subtype', () => {
assert.equal(mimeTypeToExt('image/png'), 'png');
assert.equal(mimeTypeToExt('application/vnd.ms-excel'), 'vnd.ms-excel');
});
test('getFileNameExt', () => {
assert.equal(getFileNameExt('photo.PNG'), 'PNG');
assert.equal(getFileNameExt('archive.tar.gz'), 'gz');
// no dot → returns the whole name (lastIndexOf('.') === -1 → slice(0))
assert.equal(getFileNameExt('noext'), 'noext');
});
test('getFileNameWithoutExt', () => {
assert.equal(getFileNameWithoutExt('photo.png'), 'photo');
assert.equal(getFileNameWithoutExt('a.b.c'), 'a.b');
assert.equal(getFileNameWithoutExt('noext'), 'noext');
// a leading-dot dotfile keeps its name (extStart === 0)
assert.equal(getFileNameWithoutExt('.hidden'), '.hidden');
});
+39
View File
@@ -0,0 +1,39 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { sanitizeForRegex, HTTP_URL_PATTERN, URL_REG, EMAIL_REGEX, JUMBO_EMOJI_REG } from './regex';
test('sanitizeForRegex escapes regex metacharacters', () => {
assert.equal(sanitizeForRegex('a.b'), 'a\\.b');
assert.equal(sanitizeForRegex('a+b'), 'a\\+b');
assert.equal(sanitizeForRegex('(grp)'), '\\(grp\\)');
// hyphen is escaped as a hex code (safe inside character classes)
assert.equal(sanitizeForRegex('a-b'), 'a\\x2db');
// round-trip: the escaped form matches the literal, not as a metacharacter
assert.equal(new RegExp(`^${sanitizeForRegex('a.b')}$`).test('a.b'), true);
assert.equal(new RegExp(`^${sanitizeForRegex('a.b')}$`).test('axb'), false);
});
test('URL pattern matches http(s) urls', () => {
const urlRe = new RegExp(HTTP_URL_PATTERN); // fresh, non-global for stateless .test
assert.equal(urlRe.test('https://example.com'), true);
assert.equal(urlRe.test('http://www.foo.com/path?q=1'), true);
assert.equal(urlRe.test('just some text'), false);
assert.ok(URL_REG instanceof RegExp);
assert.equal(URL_REG.global, true); // used with matchAll elsewhere
});
test('EMAIL_REGEX', () => {
assert.equal(EMAIL_REGEX.test('a@b.com'), true);
assert.equal(EMAIL_REGEX.test('john.doe@example.co'), true);
assert.equal(EMAIL_REGEX.test('nope'), false);
assert.equal(EMAIL_REGEX.test('a@b'), false); // no TLD
assert.equal(EMAIL_REGEX.test('@b.com'), false); // empty local part
});
test('JUMBO_EMOJI_REG matches 1-10 emoji / custom emoji only', () => {
assert.equal(JUMBO_EMOJI_REG.test('😀'), true);
assert.equal(JUMBO_EMOJI_REG.test('😀😀😀'), true);
assert.equal(JUMBO_EMOJI_REG.test(':custom_emoji:'), true);
assert.equal(JUMBO_EMOJI_REG.test('hello'), false);
assert.equal(JUMBO_EMOJI_REG.test('hi 😀'), false); // leading text disqualifies
});
+52
View File
@@ -0,0 +1,52 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { sanitizeCustomHtml, sanitizeText } from './sanitize';
test('sanitizeText escapes HTML metacharacters', () => {
assert.equal(sanitizeText('<script>'), '&lt;script&gt;');
assert.equal(sanitizeText('a & b'), 'a &amp; b');
assert.equal(sanitizeText(`"'`), '&quot;&#39;');
assert.equal(sanitizeText('plain text'), 'plain text');
});
test('sanitizeCustomHtml removes <script> content', () => {
const out = sanitizeCustomHtml('<script>alert(1)</script>hello');
assert.ok(!out.includes('alert'));
assert.ok(out.includes('hello'));
});
test('sanitizeCustomHtml strips event-handler attributes', () => {
const out = sanitizeCustomHtml('<b onclick="evil()">hi</b>');
assert.ok(!out.includes('onclick'));
assert.ok(out.includes('hi'));
});
test('sanitizeCustomHtml drops disallowed tags, keeps allowed ones', () => {
assert.ok(!sanitizeCustomHtml('<iframe src="x"></iframe>').includes('iframe'));
assert.ok(sanitizeCustomHtml('<strong>bold</strong>').includes('<strong>'));
});
test('sanitizeCustomHtml neutralizes javascript: links', () => {
const out = sanitizeCustomHtml('<a href="javascript:alert(1)">x</a>');
// eslint-disable-next-line no-script-url -- asserting the scheme was stripped
assert.ok(!out.includes('javascript:'));
});
test('sanitizeCustomHtml hardens anchors (noreferrer/noopener/_blank)', () => {
const out = sanitizeCustomHtml('<a href="https://example.com">x</a>');
assert.ok(out.includes('rel="noreferrer noopener"'));
assert.ok(out.includes('target="_blank"'));
});
test('sanitizeCustomHtml converts a non-mxc <img> into a link', () => {
const out = sanitizeCustomHtml('<img src="https://evil.example/x.png" alt="pic">');
assert.ok(!out.includes('<img'));
assert.ok(out.includes('href="https://evil.example/x.png"'));
});
test('N100: <pre> class is restricted to the language-* whitelist', () => {
assert.ok(sanitizeCustomHtml('<pre class="language-js">x</pre>').includes('language-js'));
assert.ok(
!sanitizeCustomHtml('<pre class="evil-site-class">x</pre>').includes('evil-site-class'),
);
});
+56
View File
@@ -0,0 +1,56 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import type { MatrixClient } from 'matrix-js-sdk';
import {
byTsOldToNew,
byOrderKey,
factoryRoomIdByUnreadCount,
factoryRoomIdByActivity,
factoryRoomIdByAtoZ,
} from './sort';
test('byTsOldToNew sorts ascending by timestamp', () => {
assert.ok(byTsOldToNew(1, 2) < 0);
assert.ok(byTsOldToNew(5, 3) > 0);
assert.equal(byTsOldToNew(2, 2), 0);
assert.deepEqual([30, 10, 20].sort(byTsOldToNew), [10, 20, 30]);
});
test('byOrderKey: undefined sorts last, otherwise lexical', () => {
assert.equal(byOrderKey(undefined, undefined), 0);
assert.equal(byOrderKey('a', undefined), -1); // defined before undefined
assert.equal(byOrderKey(undefined, 'a'), 1);
assert.equal(byOrderKey('a', 'b'), -1);
assert.equal(byOrderKey('b', 'a'), 1);
// equal non-empty keys return 1 (not 0) — there is no equality branch for two
// present keys, so a stable sort keeps input order for equal keys.
assert.equal(byOrderKey('a', 'a'), 1);
assert.deepEqual(['c', undefined, 'a', 'b'].sort(byOrderKey), ['a', 'b', 'c', undefined]);
});
test('factoryRoomIdByUnreadCount sorts by unread count descending', () => {
const counts: Record<string, number> = { r1: 0, r2: 5, r3: 2 };
const cmp = factoryRoomIdByUnreadCount((id) => counts[id]);
assert.deepEqual(['r1', 'r2', 'r3'].sort(cmp), ['r2', 'r3', 'r1']);
});
test('factoryRoomIdByActivity sorts most-recently-active first', () => {
const ts: Record<string, number> = { old: 100, new: 300, mid: 200 };
const mx = {
getRoom: (id: string) => (id in ts ? { getLastActiveTimestamp: () => ts[id] } : null),
} as unknown as MatrixClient;
const cmp = factoryRoomIdByActivity(mx);
assert.deepEqual(['old', 'new', 'mid'].sort(cmp), ['new', 'mid', 'old']);
// a room the client can't resolve sinks to the bottom
assert.deepEqual(['missing', 'new'].sort(cmp), ['new', 'missing']);
});
test('factoryRoomIdByAtoZ sorts case-insensitively and ignores leading #', () => {
const names: Record<string, string> = { a: 'Banana', b: 'apple', c: '#Cherry' };
const mx = {
getRoom: (id: string) => ({ name: names[id] ?? '' }),
} as unknown as MatrixClient;
const cmp = factoryRoomIdByAtoZ(mx);
// apple < Banana < Cherry (# stripped, case-insensitive)
assert.deepEqual(['a', 'b', 'c'].sort(cmp), ['b', 'a', 'c']);
});
+171
View File
@@ -0,0 +1,171 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import dayjs from 'dayjs';
import {
today,
yesterday,
timeHour,
timeMinute,
timeAmPm,
timeDay,
timeMon,
timeMonth,
timeYear,
timeHourMinute,
timeDayMonYear,
timeDayMonthYear,
daysInMonth,
dateFor,
inSameDay,
minuteDifference,
hour24to12,
hour12to24,
secondsToMs,
minutesToMs,
hoursToMs,
daysToMs,
getToday,
getYesterday,
} from './time';
test('today is true for now and false for other days', () => {
assert.equal(today(Date.now()), true);
assert.equal(today(dayjs().subtract(1, 'day').valueOf()), false);
assert.equal(today(dayjs().add(1, 'day').valueOf()), false);
});
test('yesterday is true only for the previous day', () => {
assert.equal(yesterday(dayjs().subtract(1, 'day').valueOf()), true);
assert.equal(yesterday(Date.now()), false);
assert.equal(yesterday(dayjs().subtract(2, 'day').valueOf()), false);
});
test('timeHour formats 24h and 12h zero-padded', () => {
const ts = Date.now();
// 24-hour clock: two digits 00-23
assert.match(timeHour(ts, true), /^([01]\d|2[0-3])$/);
// 12-hour clock: two digits 01-12
assert.match(timeHour(ts, false), /^(0[1-9]|1[0-2])$/);
});
test('timeMinute is two digits 00-59', () => {
assert.match(timeMinute(Date.now()), /^[0-5]\d$/);
});
test('timeAmPm is AM or PM', () => {
assert.match(timeAmPm(Date.now()), /^(AM|PM)$/);
});
test('timeDay is day-of-month without leading zero', () => {
const out = timeDay(Date.now());
assert.match(out, /^([1-9]|[12]\d|3[01])$/);
});
test('timeMon is a three-letter month abbreviation', () => {
// Use a fixed UTC timestamp far from month boundaries to avoid tz drift.
const midMonth = dayjs('2021-06-15T12:00:00Z').valueOf();
assert.equal(timeMon(midMonth), 'Jun');
assert.match(timeMon(Date.now()), /^[A-Z][a-z]{2}$/);
});
test('timeMonth is a full month name', () => {
const midMonth = dayjs('2021-06-15T12:00:00Z').valueOf();
assert.equal(timeMonth(midMonth), 'June');
});
test('timeYear is a four-digit year', () => {
const ts = dayjs('2021-06-15T12:00:00Z').valueOf();
assert.equal(timeYear(ts), '2021');
assert.match(timeYear(Date.now()), /^\d{4}$/);
});
test('timeHourMinute structure for 24h and 12h', () => {
const ts = Date.now();
assert.match(timeHourMinute(ts, true), /^([01]\d|2[0-3]):[0-5]\d$/);
assert.match(timeHourMinute(ts, false), /^(0[1-9]|1[0-2]):[0-5]\d (AM|PM)$/);
});
test('timeDayMonYear honors the provided format string', () => {
const ts = dayjs('2021-06-15T12:00:00Z').valueOf();
assert.equal(timeDayMonYear(ts, 'YYYY'), '2021');
assert.equal(timeDayMonYear(ts, 'MMMM'), 'June');
});
test('timeDayMonthYear shape is "D MMMM YYYY"', () => {
assert.match(timeDayMonthYear(Date.now()), /^([1-9]|[12]\d|3[01]) [A-Z][a-z]+ \d{4}$/);
});
test('daysInMonth returns correct counts', () => {
assert.equal(daysInMonth(2, 2020), 29); // leap year February
assert.equal(daysInMonth(2, 2021), 28);
assert.equal(daysInMonth(4, 2021), 30); // April
assert.equal(daysInMonth(1, 2021), 31); // January
});
test('dateFor returns a timestamp matching the given date', () => {
const ts = dateFor(2021, 6, 15);
const d = dayjs(ts);
assert.equal(d.year(), 2021);
assert.equal(d.month() + 1, 6);
assert.equal(d.date(), 15);
});
test('inSameDay compares calendar days in local time', () => {
const a = new Date(2021, 5, 15, 1, 0, 0).getTime();
const b = new Date(2021, 5, 15, 23, 0, 0).getTime();
const c = new Date(2021, 5, 16, 0, 0, 0).getTime();
assert.equal(inSameDay(a, b), true);
assert.equal(inSameDay(a, c), false);
});
test('minuteDifference is absolute and rounded', () => {
const base = 1_600_000_000_000;
assert.equal(minuteDifference(base, base + 60_000), 1);
assert.equal(minuteDifference(base + 60_000, base), 1); // absolute value
assert.equal(minuteDifference(base, base + 90_000), 2); // rounds 1.5 -> 2
assert.equal(minuteDifference(base, base), 0);
});
test('hour24to12 maps 24h to 12h clock', () => {
assert.equal(hour24to12(0), 12);
assert.equal(hour24to12(12), 12);
assert.equal(hour24to12(13), 1);
assert.equal(hour24to12(23), 11);
assert.equal(hour24to12(9), 9);
});
test('hour12to24 maps 12h clock to 24h', () => {
assert.equal(hour12to24(12, false), 0); // 12 AM -> 0
assert.equal(hour12to24(12, true), 12); // 12 PM -> 12
assert.equal(hour12to24(1, false), 1); // 1 AM
assert.equal(hour12to24(1, true), 13); // 1 PM
assert.equal(hour12to24(11, true), 23); // 11 PM
});
test('millisecond conversion helpers', () => {
assert.equal(secondsToMs(1), 1000);
assert.equal(minutesToMs(1), 60_000);
assert.equal(hoursToMs(1), 3_600_000);
assert.equal(daysToMs(1), 86_400_000);
assert.equal(daysToMs(2), 172_800_000);
});
test('getToday returns the start-of-today timestamp', () => {
const ts = getToday();
assert.equal(today(ts), true);
const d = dayjs(ts);
const now = dayjs();
assert.equal(d.year(), now.year());
assert.equal(d.month(), now.month());
assert.equal(d.date(), now.date());
});
test('getYesterday returns the start-of-yesterday timestamp', () => {
const ts = getYesterday();
assert.equal(yesterday(ts), true);
const d = dayjs(ts);
const expected = dayjs().subtract(1, 'day');
assert.equal(d.year(), expected.year());
assert.equal(d.month(), expected.month());
assert.equal(d.date(), expected.date());
});
+33
View File
@@ -104,6 +104,39 @@ self.addEventListener('message', (event: ExtendableMessageEvent) => {
}
});
/**
* N105: handle clicks on service-worker-owned notifications. This fires even
* when the originating tab was closed (a page-level `Notification.onclick`
* would not). Focus an existing app window and forward the target path so it
* can route there; if no window is open, open the app.
*/
self.addEventListener('notificationclick', (event: NotificationEvent) => {
event.notification.close();
const data = event.notification.data ?? {};
const path = typeof data.path === 'string' ? data.path : undefined;
event.waitUntil(
(async () => {
const windowClients = await self.clients.matchAll({
type: 'window',
includeUncontrolled: true,
});
const client = windowClients.find((c): c is WindowClient => 'focus' in c);
if (client) {
await client.focus();
if (path) client.postMessage({ type: 'notificationClick', path });
return;
}
// No app window open — open one at the app root (the client routes from
// there). Router config (hash vs browser) is page-owned, so we don't try
// to deep-link the URL here.
if (self.clients.openWindow) {
await self.clients.openWindow(self.registration.scope);
}
})(),
);
});
const MEDIA_PATHS = ['/_matrix/client/v1/media/download', '/_matrix/client/v1/media/thumbnail'];
function mediaPath(url: string): boolean {
+1 -1
View File
@@ -13,6 +13,6 @@
"skipLibCheck": true,
"lib": ["ES2020", "DOM"]
},
"exclude": ["node_modules", "dist"],
"exclude": ["node_modules", "dist", "src/**/*.test.ts"],
"include": ["src"]
}