Compare commits
14 Commits
36343baecc
...
4d55e45962
| Author | SHA1 | Date | |
|---|---|---|---|
| 4d55e45962 | |||
| e3532064b5 | |||
| 1e37b20c6a | |||
| 4f03775e04 | |||
| 9678b02aba | |||
| a926487f5e | |||
| ae1d30bc5a | |||
| a7d145aa70 | |||
| 472d4ba008 | |||
| 2a0478cad8 | |||
| cee0c591e2 | |||
| 68b6ffffd7 | |||
| 9bc8c4b47f | |||
| e80ebd35cb |
@@ -47,6 +47,12 @@ jobs:
|
|||||||
NODE_OPTIONS: '--max_old_space_size=4096'
|
NODE_OPTIONS: '--max_old_space_size=4096'
|
||||||
VITE_APP_VERSION: ${{ github.sha }}
|
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) ───────
|
# ── Quality checks (informational — pre-existing issues exist) ───────
|
||||||
- name: TypeScript
|
- name: TypeScript
|
||||||
run: npm run typecheck
|
run: npm run typecheck
|
||||||
|
|||||||
+4
-4
@@ -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.
|
Implemented and gate-green; confirm each per `LOTUS_TESTING.md`, then delete the row.
|
||||||
|
|
||||||
| ID | Item | File / area | Test |
|
| ID | Item | File / area | Test |
|
||||||
| :--- | :---------------------------------------------------------------------- | :--------------------------------------------------- | :------- |
|
| :--- | :------------------------------------------------------------------------------------- | :--------------------------------------------------- | :-------------------------------------------------------------------------------- |
|
||||||
| #2 | Chat-background animation flicker (`contain:paint`) | `lotus/chatBackground.ts` | F1 |
|
| #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 |
|
| #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 |
|
| #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 |
|
| 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 |
|
| 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 |
|
| 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 |
|
| Gal | MediaGallery lazy-decrypt (true virtualization deferred) | `room/MediaGallery.tsx` | H1 |
|
||||||
| a11y | aria-labels: edit-history / reaction / thread / reply | `message/*` (`FallbackContent`, `Reaction`, `Reply`) | I |
|
| 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
|
### 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.
|
- **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.
|
- **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
|
### Dependencies & Build
|
||||||
|
|
||||||
@@ -99,7 +99,7 @@ Items from testing, with their fork-level fix path:
|
|||||||
|
|
||||||
### Code Hygiene / DevEx
|
### 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.
|
- **Extensive `as any` casts** across `src/` — gradual typing cleanup.
|
||||||
- **`types/matrix/` mirrors SDK types** instead of importing them — drift risk.
|
- **`types/matrix/` mirrors SDK types** instead of importing them — drift risk.
|
||||||
- **Hardcoded CDN URL** should move to an env var (the decoration CDN is now single-sourced in `avatarDecorations.ts`, but the literal is still in-repo).
|
- **Hardcoded CDN URL** should move to an env var (the decoration CDN is now single-sourced in `avatarDecorations.ts`, but the literal is still in-repo).
|
||||||
|
|||||||
Generated
+20
@@ -108,6 +108,7 @@
|
|||||||
"husky": "9.1.7",
|
"husky": "9.1.7",
|
||||||
"lint-staged": "17.0.5",
|
"lint-staged": "17.0.5",
|
||||||
"prettier": "3.8.3",
|
"prettier": "3.8.3",
|
||||||
|
"tsx": "4.22.4",
|
||||||
"typescript": "6.0.3",
|
"typescript": "6.0.3",
|
||||||
"vite": "8.0.14",
|
"vite": "8.0.14",
|
||||||
"vite-plugin-pwa": "1.3.0",
|
"vite-plugin-pwa": "1.3.0",
|
||||||
@@ -12357,6 +12358,25 @@
|
|||||||
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
|
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
|
||||||
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="
|
"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": {
|
"node_modules/type-check": {
|
||||||
"version": "0.4.0",
|
"version": "0.4.0",
|
||||||
"resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz",
|
"resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz",
|
||||||
|
|||||||
@@ -16,6 +16,7 @@
|
|||||||
"check:prettier": "prettier --check .",
|
"check:prettier": "prettier --check .",
|
||||||
"fix:prettier": "prettier --write .",
|
"fix:prettier": "prettier --write .",
|
||||||
"typecheck": "tsc --noEmit",
|
"typecheck": "tsc --noEmit",
|
||||||
|
"test": "node --import tsx --test $(find src -name '*.test.ts')",
|
||||||
"prepare": "husky",
|
"prepare": "husky",
|
||||||
"commit": "git-cz",
|
"commit": "git-cz",
|
||||||
"postinstall": "node scripts/patch-folds.mjs",
|
"postinstall": "node scripts/patch-folds.mjs",
|
||||||
@@ -132,6 +133,7 @@
|
|||||||
"husky": "9.1.7",
|
"husky": "9.1.7",
|
||||||
"lint-staged": "17.0.5",
|
"lint-staged": "17.0.5",
|
||||||
"prettier": "3.8.3",
|
"prettier": "3.8.3",
|
||||||
|
"tsx": "4.22.4",
|
||||||
"typescript": "6.0.3",
|
"typescript": "6.0.3",
|
||||||
"vite": "8.0.14",
|
"vite": "8.0.14",
|
||||||
"vite-plugin-pwa": "1.3.0",
|
"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'],
|
||||||
|
);
|
||||||
|
});
|
||||||
@@ -8,7 +8,7 @@ import LogoUnreadSVG from '../../../../public/res/lotus-unread.png';
|
|||||||
import LogoHighlightSVG from '../../../../public/res/lotus-highlight.png';
|
import LogoHighlightSVG from '../../../../public/res/lotus-highlight.png';
|
||||||
import NotificationSound from '../../../../public/sound/notification.ogg';
|
import NotificationSound from '../../../../public/sound/notification.ogg';
|
||||||
import InviteSound from '../../../../public/sound/invite.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 { NOTIFICATION_SOUND_MAP } from '../../utils/notificationSounds';
|
||||||
import { useSetting } from '../../state/hooks/settings';
|
import { useSetting } from '../../state/hooks/settings';
|
||||||
import { settingsAtom } from '../../state/settings';
|
import { settingsAtom } from '../../state/settings';
|
||||||
@@ -141,17 +141,21 @@ function InviteNotifications() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const noti = new window.Notification('Invitation', {
|
const invitesPath = getInboxInvitesPath();
|
||||||
|
showOsNotification(
|
||||||
|
'Invitation',
|
||||||
|
{
|
||||||
icon: LogoSVG,
|
icon: LogoSVG,
|
||||||
badge: LogoSVG,
|
badge: LogoSVG,
|
||||||
body: `You have ${count} new invitation request.`,
|
body: `You have ${count} new invitation request.`,
|
||||||
silent: true,
|
silent: true,
|
||||||
});
|
tag: 'lotus-invites',
|
||||||
|
data: { path: invitesPath },
|
||||||
noti.onclick = () => {
|
},
|
||||||
if (!window.closed) navigate(getInboxInvitesPath());
|
() => {
|
||||||
noti.close();
|
if (!window.closed) navigate(invitesPath);
|
||||||
};
|
},
|
||||||
|
);
|
||||||
},
|
},
|
||||||
[navigate, setToast],
|
[navigate, setToast],
|
||||||
);
|
);
|
||||||
@@ -202,7 +206,6 @@ function PresenceUpdater() {
|
|||||||
|
|
||||||
function MessageNotifications() {
|
function MessageNotifications() {
|
||||||
const audioRef = useRef<HTMLAudioElement>(null);
|
const audioRef = useRef<HTMLAudioElement>(null);
|
||||||
const notifRef = useRef<Notification | undefined>(undefined);
|
|
||||||
const unreadCacheRef = useRef<Map<string, UnreadInfo>>(new Map());
|
const unreadCacheRef = useRef<Map<string, UnreadInfo>>(new Map());
|
||||||
const mx = useMatrixClient();
|
const mx = useMatrixClient();
|
||||||
const useAuthentication = useMediaAuthentication();
|
const useAuthentication = useMediaAuthentication();
|
||||||
@@ -276,26 +279,42 @@ function MessageNotifications() {
|
|||||||
// persists in the notification center / lock screen / is readable by other
|
// persists in the notification center / lock screen / is readable by other
|
||||||
// apps). For encrypted rooms show only the sender; the in-page toast above
|
// 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.
|
// still shows the preview while the user is actively looking at the screen.
|
||||||
const noti = new window.Notification(roomName, {
|
showOsNotification(
|
||||||
|
roomName,
|
||||||
|
{
|
||||||
icon: LogoSVG,
|
icon: LogoSVG,
|
||||||
badge: LogoSVG,
|
badge: LogoSVG,
|
||||||
body: !encrypted && body ? `${username}: ${body}`.slice(0, 120) : username,
|
body: !encrypted && body ? `${username}: ${body}`.slice(0, 120) : username,
|
||||||
silent: true,
|
silent: true,
|
||||||
});
|
// Coalesce repeated notifications for the same room (replaces the old
|
||||||
|
// manual notifRef.close() dedup, which a SW notification can't hold).
|
||||||
noti.onclick = () => {
|
tag: roomId,
|
||||||
|
data: { path: roomPath },
|
||||||
|
},
|
||||||
|
() => {
|
||||||
window.focus();
|
window.focus();
|
||||||
navigate(roomPath);
|
navigate(roomPath);
|
||||||
noti.close();
|
},
|
||||||
notifRef.current = undefined;
|
);
|
||||||
};
|
|
||||||
|
|
||||||
notifRef.current?.close();
|
|
||||||
notifRef.current = noti;
|
|
||||||
},
|
},
|
||||||
[navigate, setToast, mDirects],
|
[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 playSound = useCallback(() => {
|
||||||
const audioElement = audioRef.current;
|
const audioElement = audioRef.current;
|
||||||
audioElement?.play();
|
audioElement?.play();
|
||||||
|
|||||||
@@ -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)));
|
||||||
|
});
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
@@ -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}$/);
|
||||||
|
});
|
||||||
@@ -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,4 +1,4 @@
|
|||||||
import { IconName, IconSrc } from 'folds';
|
import type { IconName, IconSrc } from 'folds';
|
||||||
|
|
||||||
export const bytesToSize = (bytes: number): string => {
|
export const bytesToSize = (bytes: number): string => {
|
||||||
const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB'];
|
const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB'];
|
||||||
|
|||||||
@@ -254,6 +254,42 @@ export const notificationPermission = (permission: NotificationPermission) => {
|
|||||||
return true;
|
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) => ({
|
export const getMouseEventCords = (event: MouseEvent) => ({
|
||||||
x: event.clientX,
|
x: event.clientX,
|
||||||
y: event.clientY,
|
y: event.clientY,
|
||||||
|
|||||||
@@ -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']);
|
||||||
|
});
|
||||||
@@ -19,7 +19,10 @@ export const findAndReplace = <ReplaceReturnType, ConvertReturnType>(
|
|||||||
result.push(replace(match, result.length));
|
result.push(replace(match, result.length));
|
||||||
|
|
||||||
lastEnd = match.index + match[0].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));
|
result.push(convertPart(text.slice(lastEnd), result.length));
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
@@ -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');
|
||||||
|
});
|
||||||
@@ -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
|
||||||
|
});
|
||||||
@@ -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>'), '<script>');
|
||||||
|
assert.equal(sanitizeText('a & b'), 'a & b');
|
||||||
|
assert.equal(sanitizeText(`"'`), '"'');
|
||||||
|
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'),
|
||||||
|
);
|
||||||
|
});
|
||||||
@@ -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']);
|
||||||
|
});
|
||||||
@@ -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());
|
||||||
|
});
|
||||||
@@ -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'];
|
const MEDIA_PATHS = ['/_matrix/client/v1/media/download', '/_matrix/client/v1/media/thumbnail'];
|
||||||
|
|
||||||
function mediaPath(url: string): boolean {
|
function mediaPath(url: string): boolean {
|
||||||
|
|||||||
+1
-1
@@ -13,6 +13,6 @@
|
|||||||
"skipLibCheck": true,
|
"skipLibCheck": true,
|
||||||
"lib": ["ES2020", "DOM"]
|
"lib": ["ES2020", "DOM"]
|
||||||
},
|
},
|
||||||
"exclude": ["node_modules", "dist"],
|
"exclude": ["node_modules", "dist", "src/**/*.test.ts"],
|
||||||
"include": ["src"]
|
"include": ["src"]
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user