Tests for the window: unit tests, and a layout check against the real app

Vitest, with 70 tests across the pure lib modules - formatting (IRC codes,
BBCode, spoilers, emoji and emotes, media and YouTube detection), media URL
resolution including the file-version fragment, the rail and its folders,
mentions, search filters, nick completion, Kick watching, emote and
interruption state - and the media scheme's boundary in the main process,
with Electron stubbed.

scripts/ui-check.mjs runs the built app against a scratch home and a fake
IRC server, opens the emoji picker, the channel, message and member menus
(right-clicked in the window's far corner) and the mentions inbox at two
window sizes, and fails if any of them leaves the window at 80 ms, 400 ms,
1.5 s or 3.5 s. Checked that it fails: with the context menu's clamping
removed it reports every corner-opened menu outside the window.

npm test and the layout check (under xvfb) run in the Linux release job.
#212

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
2026-10-02 18:07:08 -04:00
co-authored by Claude Opus 5.5
parent 891a0840df
commit 53a6f92ae4
16 changed files with 1402 additions and 3 deletions
+16 -1
View File
@@ -121,7 +121,7 @@ jobs:
run: |
sudo apt-get update
sudo apt-get install -y --no-install-recommends \
build-essential cmake pkg-config libasound2-dev
build-essential cmake pkg-config libasound2-dev xvfb
# The image ships a stable rust, but its version floats and this tree's
# effective floor is 1.93 (matrix-sdk-crypto). Ask explicitly.
@@ -207,6 +207,13 @@ jobs:
- name: typecheck
run: npm run typecheck
# The window's own unit tests: its formatting, its emote and media URL
# rewriting, the rail, search, mentions - and the media scheme's
# boundary. Seconds, so here beside the typecheck rather than after the
# long build.
- name: unit tests
run: npm test
# Two jobs in one line. It warms the registry, and --locked turns a
# stale lockfile into a loud failure here rather than a silent rewrite
# later: the npm scripts call plain `cargo build`, which would quietly
@@ -246,6 +253,14 @@ jobs:
- name: cargo clippy
run: cargo clippy --release --locked --all-targets --manifest-path nobilis/Cargo.toml -- -D warnings
# Layout, which only a real browser can check: the built app is run
# against a scratch home and a fake IRC server, every popup is opened at
# two window sizes, and each must stay inside the window - measured at
# several moments, because a popup that grows after it opens is the bug
# this was written for. Needs the daemon built above.
- name: layout check
run: xvfb-run -a npm run test:ui
# MOHO_BUILD is computed inside the npm script - a shell assignment on
# the electron-builder line - so it cannot be injected from out here.
# The only way to control what the artifact is called is to guarantee
+11
View File
@@ -69,6 +69,17 @@ without them there is no Electron to run:
npm install-scripts approve electron esbuild
```
## Test it
```bash
npm test # the window's unit tests (Vitest)
npm run test:ui # layout checks against the real app - popups must stay inside the window
cd nobilis && cargo test --release
```
`test:ui` builds the window, runs it against a scratch home and a fake IRC server, and needs the
daemon built first. On a machine with no display, run it under `xvfb-run`.
## Run it
```bash
+433 -1
View File
@@ -20,13 +20,15 @@
"electron": "^44.4.4",
"electron-builder": "^26.15.3",
"electron-vite": "^2.3.0",
"happy-dom": "^15.11.7",
"hls.js": "^1.7.2",
"livekit-client": "^2.22.2",
"lottie-web": "^5.13.0",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"typescript": "^5.7.2",
"vite": "^5.4.11"
"vite": "^5.4.11",
"vitest": "^2.1.9"
}
},
"node_modules/@babel/code-frame": {
@@ -2067,6 +2069,119 @@
"vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0"
}
},
"node_modules/@vitest/expect": {
"version": "2.1.9",
"resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-2.1.9.tgz",
"integrity": "sha512-UJCIkTBenHeKT1TTlKMJWy1laZewsRIzYighyYiJKZreqtdxSos/S1t+ktRMQWu2CKqaarrkeszJx1cgC5tGZw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@vitest/spy": "2.1.9",
"@vitest/utils": "2.1.9",
"chai": "^5.1.2",
"tinyrainbow": "^1.2.0"
},
"funding": {
"url": "https://opencollective.com/vitest"
}
},
"node_modules/@vitest/mocker": {
"version": "2.1.9",
"resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-2.1.9.tgz",
"integrity": "sha512-tVL6uJgoUdi6icpxmdrn5YNo3g3Dxv+IHJBr0GXHaEdTcw3F+cPKnsXFhli6nO+f/6SDKPHEK1UN+k+TQv0Ehg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@vitest/spy": "2.1.9",
"estree-walker": "^3.0.3",
"magic-string": "^0.30.12"
},
"funding": {
"url": "https://opencollective.com/vitest"
},
"peerDependencies": {
"msw": "^2.4.9",
"vite": "^5.0.0"
},
"peerDependenciesMeta": {
"msw": {
"optional": true
},
"vite": {
"optional": true
}
}
},
"node_modules/@vitest/pretty-format": {
"version": "2.1.9",
"resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-2.1.9.tgz",
"integrity": "sha512-KhRIdGV2U9HOUzxfiHmY8IFHTdqtOhIzCpd8WRdJiE7D/HUcZVD0EgQCVjm+Q9gkUXWgBvMmTtZgIG48wq7sOQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"tinyrainbow": "^1.2.0"
},
"funding": {
"url": "https://opencollective.com/vitest"
}
},
"node_modules/@vitest/runner": {
"version": "2.1.9",
"resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-2.1.9.tgz",
"integrity": "sha512-ZXSSqTFIrzduD63btIfEyOmNcBmQvgOVsPNPe0jYtESiXkhd8u2erDLnMxmGrDCwHCCHE7hxwRDCT3pt0esT4g==",
"dev": true,
"license": "MIT",
"dependencies": {
"@vitest/utils": "2.1.9",
"pathe": "^1.1.2"
},
"funding": {
"url": "https://opencollective.com/vitest"
}
},
"node_modules/@vitest/snapshot": {
"version": "2.1.9",
"resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-2.1.9.tgz",
"integrity": "sha512-oBO82rEjsxLNJincVhLhaxxZdEtV0EFHMK5Kmx5sJ6H9L183dHECjiefOAdnqpIgT5eZwT04PoggUnW88vOBNQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@vitest/pretty-format": "2.1.9",
"magic-string": "^0.30.12",
"pathe": "^1.1.2"
},
"funding": {
"url": "https://opencollective.com/vitest"
}
},
"node_modules/@vitest/spy": {
"version": "2.1.9",
"resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-2.1.9.tgz",
"integrity": "sha512-E1B35FwzXXTs9FHNK6bDszs7mtydNi5MIfUWpceJ8Xbfb1gBMscAnwLbEu+B44ed6W3XjL9/ehLPHR1fkf1KLQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"tinyspy": "^3.0.2"
},
"funding": {
"url": "https://opencollective.com/vitest"
}
},
"node_modules/@vitest/utils": {
"version": "2.1.9",
"resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-2.1.9.tgz",
"integrity": "sha512-v0psaMSkNJ3A2NMrUEHFRzJtDPFn+/VWZ5WxImB21T9fjucJRmS7xCS3ppEnARb9y11OAzaD+P2Ps+b+BGX5iQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@vitest/pretty-format": "2.1.9",
"loupe": "^3.1.2",
"tinyrainbow": "^1.2.0"
},
"funding": {
"url": "https://opencollective.com/vitest"
}
},
"node_modules/@xmldom/xmldom": {
"version": "0.8.15",
"resolved": "https://registry.npmjs.org/@xmldom/xmldom/-/xmldom-0.8.15.tgz",
@@ -2333,6 +2448,16 @@
"node": ">=12.0.0"
}
},
"node_modules/assertion-error": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz",
"integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=12"
}
},
"node_modules/async": {
"version": "3.2.6",
"resolved": "https://registry.npmjs.org/async/-/async-3.2.6.tgz",
@@ -2650,6 +2775,23 @@
],
"license": "CC-BY-4.0"
},
"node_modules/chai": {
"version": "5.3.3",
"resolved": "https://registry.npmjs.org/chai/-/chai-5.3.3.tgz",
"integrity": "sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==",
"dev": true,
"license": "MIT",
"dependencies": {
"assertion-error": "^2.0.1",
"check-error": "^2.1.1",
"deep-eql": "^5.0.1",
"loupe": "^3.1.0",
"pathval": "^2.0.0"
},
"engines": {
"node": ">=18"
}
},
"node_modules/chalk": {
"version": "4.1.2",
"resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz",
@@ -2667,6 +2809,16 @@
"url": "https://github.com/chalk/chalk?sponsor=1"
}
},
"node_modules/check-error": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/check-error/-/check-error-2.1.3.tgz",
"integrity": "sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 16"
}
},
"node_modules/chownr": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/chownr/-/chownr-3.0.0.tgz",
@@ -2902,6 +3054,16 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/deep-eql": {
"version": "5.0.2",
"resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.2.tgz",
"integrity": "sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/defer-to-connect": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/defer-to-connect/-/defer-to-connect-2.0.1.tgz",
@@ -3389,6 +3551,19 @@
"once": "^1.4.0"
}
},
"node_modules/entities": {
"version": "4.5.0",
"resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz",
"integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==",
"dev": true,
"license": "BSD-2-Clause",
"engines": {
"node": ">=0.12"
},
"funding": {
"url": "https://github.com/fb55/entities?sponsor=1"
}
},
"node_modules/env-paths": {
"version": "2.2.1",
"resolved": "https://registry.npmjs.org/env-paths/-/env-paths-2.2.1.tgz",
@@ -3426,6 +3601,13 @@
"node": ">= 0.4"
}
},
"node_modules/es-module-lexer": {
"version": "1.7.0",
"resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz",
"integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==",
"dev": true,
"license": "MIT"
},
"node_modules/es-object-atoms": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz",
@@ -3526,6 +3708,16 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/estree-walker": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz",
"integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/estree": "^1.0.0"
}
},
"node_modules/events": {
"version": "3.3.0",
"resolved": "https://registry.npmjs.org/events/-/events-3.3.0.tgz",
@@ -3536,6 +3728,16 @@
"node": ">=0.8.x"
}
},
"node_modules/expect-type": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz",
"integrity": "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==",
"dev": true,
"license": "Apache-2.0",
"engines": {
"node": ">=12.0.0"
}
},
"node_modules/exponential-backoff": {
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/exponential-backoff/-/exponential-backoff-3.1.3.tgz",
@@ -3898,6 +4100,21 @@
"integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==",
"license": "ISC"
},
"node_modules/happy-dom": {
"version": "15.11.7",
"resolved": "https://registry.npmjs.org/happy-dom/-/happy-dom-15.11.7.tgz",
"integrity": "sha512-KyrFvnl+J9US63TEzwoiJOQzZBJY7KgBushJA8X61DMbNsH+2ONkDuLDnCnwUiPTF42tLoEmrPyoqbenVA5zrg==",
"dev": true,
"license": "MIT",
"dependencies": {
"entities": "^4.5.0",
"webidl-conversions": "^7.0.0",
"whatwg-mimetype": "^3.0.0"
},
"engines": {
"node": ">=18.0.0"
}
},
"node_modules/has-flag": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz",
@@ -4318,6 +4535,13 @@
"dev": true,
"license": "MIT"
},
"node_modules/loupe": {
"version": "3.2.1",
"resolved": "https://registry.npmjs.org/loupe/-/loupe-3.2.1.tgz",
"integrity": "sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==",
"dev": true,
"license": "MIT"
},
"node_modules/lowercase-keys": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/lowercase-keys/-/lowercase-keys-2.0.0.tgz",
@@ -4742,6 +4966,23 @@
"node": ">=8"
}
},
"node_modules/pathe": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/pathe/-/pathe-1.1.2.tgz",
"integrity": "sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ==",
"dev": true,
"license": "MIT"
},
"node_modules/pathval": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/pathval/-/pathval-2.0.1.tgz",
"integrity": "sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 14.16"
}
},
"node_modules/pe-library": {
"version": "0.4.1",
"resolved": "https://registry.npmjs.org/pe-library/-/pe-library-0.4.1.tgz",
@@ -5318,6 +5559,13 @@
"node": ">=8"
}
},
"node_modules/siginfo": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz",
"integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==",
"dev": true,
"license": "ISC"
},
"node_modules/signal-exit": {
"version": "3.0.7",
"resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz",
@@ -5390,6 +5638,13 @@
"license": "BSD-3-Clause",
"optional": true
},
"node_modules/stackback": {
"version": "0.0.2",
"resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz",
"integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==",
"dev": true,
"license": "MIT"
},
"node_modules/stat-mode": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/stat-mode/-/stat-mode-1.0.0.tgz",
@@ -5400,6 +5655,13 @@
"node": ">= 6"
}
},
"node_modules/std-env": {
"version": "3.10.0",
"resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz",
"integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==",
"dev": true,
"license": "MIT"
},
"node_modules/string_decoder": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz",
@@ -5574,6 +5836,20 @@
"semver": "bin/semver"
}
},
"node_modules/tinybench": {
"version": "2.9.0",
"resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz",
"integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==",
"dev": true,
"license": "MIT"
},
"node_modules/tinyexec": {
"version": "0.3.2",
"resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-0.3.2.tgz",
"integrity": "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==",
"dev": true,
"license": "MIT"
},
"node_modules/tinyglobby": {
"version": "0.2.17",
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz",
@@ -5591,6 +5867,36 @@
"url": "https://github.com/sponsors/SuperchupuDev"
}
},
"node_modules/tinypool": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/tinypool/-/tinypool-1.1.1.tgz",
"integrity": "sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==",
"dev": true,
"license": "MIT",
"engines": {
"node": "^18.0.0 || >=20.0.0"
}
},
"node_modules/tinyrainbow": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-1.2.0.tgz",
"integrity": "sha512-weEDEq7Z5eTHPDh4xjX789+fHfF+P8boiFB+0vbWzpbnbsEr/GRaohi/uMKxg8RZMXnl1ItAi/IUHWMsjDV7kQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=14.0.0"
}
},
"node_modules/tinyspy": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/tinyspy/-/tinyspy-3.0.2.tgz",
"integrity": "sha512-n1cw8k1k0x4pgA2+9XrOkFydTerNcJ1zWCO5Nn9scWHTD+5tp8dghT2x1uduQePZTZgd3Tupf+x9BxJjeJi77Q==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=14.0.0"
}
},
"node_modules/tmp": {
"version": "0.2.7",
"resolved": "https://registry.npmjs.org/tmp/-/tmp-0.2.7.tgz",
@@ -5850,6 +6156,95 @@
}
}
},
"node_modules/vite-node": {
"version": "2.1.9",
"resolved": "https://registry.npmjs.org/vite-node/-/vite-node-2.1.9.tgz",
"integrity": "sha512-AM9aQ/IPrW/6ENLQg3AGY4K1N2TGZdR5e4gu/MmmR2xR3Ll1+dib+nook92g4TV3PXVyeyxdWwtaCAiUL0hMxA==",
"dev": true,
"license": "MIT",
"dependencies": {
"cac": "^6.7.14",
"debug": "^4.3.7",
"es-module-lexer": "^1.5.4",
"pathe": "^1.1.2",
"vite": "^5.0.0"
},
"bin": {
"vite-node": "vite-node.mjs"
},
"engines": {
"node": "^18.0.0 || >=20.0.0"
},
"funding": {
"url": "https://opencollective.com/vitest"
}
},
"node_modules/vitest": {
"version": "2.1.9",
"resolved": "https://registry.npmjs.org/vitest/-/vitest-2.1.9.tgz",
"integrity": "sha512-MSmPM9REYqDGBI8439mA4mWhV5sKmDlBKWIYbA3lRb2PTHACE0mgKwA8yQ2xq9vxDTuk4iPrECBAEW2aoFXY0Q==",
"dev": true,
"license": "MIT",
"dependencies": {
"@vitest/expect": "2.1.9",
"@vitest/mocker": "2.1.9",
"@vitest/pretty-format": "^2.1.9",
"@vitest/runner": "2.1.9",
"@vitest/snapshot": "2.1.9",
"@vitest/spy": "2.1.9",
"@vitest/utils": "2.1.9",
"chai": "^5.1.2",
"debug": "^4.3.7",
"expect-type": "^1.1.0",
"magic-string": "^0.30.12",
"pathe": "^1.1.2",
"std-env": "^3.8.0",
"tinybench": "^2.9.0",
"tinyexec": "^0.3.1",
"tinypool": "^1.0.1",
"tinyrainbow": "^1.2.0",
"vite": "^5.0.0",
"vite-node": "2.1.9",
"why-is-node-running": "^2.3.0"
},
"bin": {
"vitest": "vitest.mjs"
},
"engines": {
"node": "^18.0.0 || >=20.0.0"
},
"funding": {
"url": "https://opencollective.com/vitest"
},
"peerDependencies": {
"@edge-runtime/vm": "*",
"@types/node": "^18.0.0 || >=20.0.0",
"@vitest/browser": "2.1.9",
"@vitest/ui": "2.1.9",
"happy-dom": "*",
"jsdom": "*"
},
"peerDependenciesMeta": {
"@edge-runtime/vm": {
"optional": true
},
"@types/node": {
"optional": true
},
"@vitest/browser": {
"optional": true
},
"@vitest/ui": {
"optional": true
},
"happy-dom": {
"optional": true
},
"jsdom": {
"optional": true
}
}
},
"node_modules/webcrypto-core": {
"version": "1.9.2",
"resolved": "https://registry.npmjs.org/webcrypto-core/-/webcrypto-core-1.9.2.tgz",
@@ -5864,6 +6259,16 @@
"tslib": "^2.8.1"
}
},
"node_modules/webidl-conversions": {
"version": "7.0.0",
"resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-7.0.0.tgz",
"integrity": "sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==",
"dev": true,
"license": "BSD-2-Clause",
"engines": {
"node": ">=12"
}
},
"node_modules/webrtc-adapter": {
"version": "9.0.6",
"resolved": "https://registry.npmjs.org/webrtc-adapter/-/webrtc-adapter-9.0.6.tgz",
@@ -5878,6 +6283,16 @@
"npm": ">=3.10.0"
}
},
"node_modules/whatwg-mimetype": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-3.0.0.tgz",
"integrity": "sha512-nt+N2dzIutVRxARx1nghPKGv1xHikU7HKdfafKkLNLindmPU/ch3U31NOCGGA/dmPcmb1VlofO0vnKAcsm0o/Q==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=12"
}
},
"node_modules/which": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/which/-/which-5.0.0.tgz",
@@ -5894,6 +6309,23 @@
"node": "^18.17.0 || >=20.5.0"
}
},
"node_modules/why-is-node-running": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz",
"integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==",
"dev": true,
"license": "MIT",
"dependencies": {
"siginfo": "^2.0.0",
"stackback": "0.0.2"
},
"bin": {
"why-is-node-running": "cli.js"
},
"engines": {
"node": ">=8"
}
},
"node_modules/wrap-ansi": {
"version": "7.0.0",
"resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz",
+5 -1
View File
@@ -19,6 +19,8 @@
"build": "npm run typecheck && electron-vite build",
"start": "electron-vite preview",
"typecheck": "tsc --noEmit -p tsconfig.node.json && tsc --noEmit -p tsconfig.web.json",
"test": "vitest run",
"test:ui": "electron-vite build && node scripts/ui-check.mjs",
"pack": "npm run daemon && npm run build && MOHO_BUILD=$(git rev-parse --short HEAD)$(git diff --quiet HEAD || echo -dirty) electron-builder --linux AppImage",
"pack:deb": "npm run daemon && npm run build && MOHO_BUILD=$(git rev-parse --short HEAD)$(git diff --quiet HEAD || echo -dirty) electron-builder --linux deb",
"pack:linux": "npm run daemon && npm run build && MOHO_BUILD=$(git rev-parse --short HEAD)$(git diff --quiet HEAD || echo -dirty) electron-builder --linux AppImage deb",
@@ -40,13 +42,15 @@
"electron": "^44.4.4",
"electron-builder": "^26.15.3",
"electron-vite": "^2.3.0",
"happy-dom": "^15.11.7",
"hls.js": "^1.7.2",
"livekit-client": "^2.22.2",
"lottie-web": "^5.13.0",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"typescript": "^5.7.2",
"vite": "^5.4.11"
"vite": "^5.4.11",
"vitest": "^2.1.9"
},
"build": {
"appId": "com.salastil.moho",
+332
View File
@@ -0,0 +1,332 @@
#!/usr/bin/env node
/**
* Layout checks against the real app: nothing that opens over the UI may
* extend outside the window.
*
* Unit tests cannot see this. A DOM without a layout engine measures every
* element as 0x0 at 0,0, so a popup hanging off the bottom of the screen
* passes there - which is how one shipped (#211: the emoji picker opened 134
* pixels below the screen, because it was placed once against its first,
* short measurement and then grew). So this launches the built app with a
* debugging port, gives it something to open popups over, and measures.
*
* Measured several times after each popup opens, not once: the bug above was
* precisely that the first measurement was not the last one.
*
* Self-contained. The app runs against a throwaway home directory, so no real
* account is touched, and the conversation it opens popups over is served by
* a small fake IRC server started here: a channel, some people in it, and a
* few things said.
*
* npm run test:ui (builds first; needs the daemon built too)
*
* On a machine with no display, run it under xvfb-run.
*/
import { spawn } from 'node:child_process'
import fs from 'node:fs'
import net from 'node:net'
import os from 'node:os'
import path from 'node:path'
import { fileURLToPath } from 'node:url'
import electronPath from 'electron'
const root = path.dirname(path.dirname(fileURLToPath(import.meta.url)))
/** When each popup is measured after it is opened, in milliseconds. */
const SAMPLES = [80, 400, 1500, 3500]
/** Window sizes to check at: an ordinary window, and a cramped one. */
const SIZES = [
{ width: 1280, height: 800 },
{ width: 760, height: 560 }
]
/** Everything that opens on top of the UI. */
const POPUPS = '.context-menu, .emoji-picker, .header-popover, .profile-card, [role="dialog"], [role="menu"]'
const sleep = (ms) => new Promise((r) => setTimeout(r, ms))
// --- a fake IRC server ---------------------------------------------------
const PEOPLE = ['alice', 'bob', 'carol', 'dave_the_longer_named', 'eve']
function fakeIrc() {
const server = net.createServer((socket) => {
let nick = 'checker'
const send = (line) => socket.write(line + '\r\n')
let buffered = ''
socket.on('error', () => {})
socket.on('data', (chunk) => {
buffered += chunk.toString()
let at
while ((at = buffered.indexOf('\n')) >= 0) {
const line = buffered.slice(0, at).replace(/\r$/, '')
buffered = buffered.slice(at + 1)
const [command, ...args] = line.split(' ')
switch (command.toUpperCase()) {
case 'CAP':
if (args[0] === 'LS') send(':fake CAP * LS :')
break
case 'NICK':
nick = args[0]
break
case 'USER':
send(`:fake 001 ${nick} :Welcome`)
send(`:fake 376 ${nick} :End of MOTD`)
break
case 'PING':
send(`:fake PONG fake ${args.join(' ')}`)
break
case 'JOIN': {
const channel = args[0].split(',')[0]
send(`:${nick}!u@h JOIN ${channel}`)
send(`:fake 353 ${nick} = ${channel} :${[nick, ...PEOPLE].join(' ')}`)
send(`:fake 366 ${nick} ${channel} :End of NAMES`)
for (let i = 0; i < 12; i++) {
const who = PEOPLE[i % PEOPLE.length]
send(`:${who}!u@h PRIVMSG ${channel} :line ${i + 1} from ${who}, long enough to wrap on a narrow window`)
}
break
}
}
}
})
})
return new Promise((resolve) => server.listen(0, '127.0.0.1', () => resolve(server)))
}
// --- the app, and a line to it --------------------------------------------
async function freePort() {
const probe = net.createServer()
await new Promise((r) => probe.listen(0, '127.0.0.1', r))
const { port } = probe.address()
await new Promise((r) => probe.close(r))
return port
}
async function pageTarget(port) {
for (let i = 0; i < 120; i++) {
try {
const list = await (await fetch(`http://127.0.0.1:${port}/json/list`)).json()
const page = list.find((t) => t.type === 'page' && !t.url.startsWith('devtools:'))
if (page) return page
} catch {
/* not up yet */
}
await sleep(500)
}
throw new Error('the app never opened a window')
}
function cdp(url) {
const socket = new WebSocket(url)
let id = 0
const waiting = new Map()
socket.addEventListener('message', (e) => {
const msg = JSON.parse(e.data)
if (msg.id && waiting.has(msg.id)) {
const { resolve, reject } = waiting.get(msg.id)
waiting.delete(msg.id)
if (msg.error) reject(new Error(msg.error.message))
else resolve(msg.result)
}
})
const ready = new Promise((r) => socket.addEventListener('open', r))
const call = async (method, params = {}) => {
await ready
const n = ++id
socket.send(JSON.stringify({ id: n, method, params }))
return new Promise((resolve, reject) => waiting.set(n, { resolve, reject }))
}
const evaluate = async (expression) => {
const r = await call('Runtime.evaluate', { expression, awaitPromise: true, returnByValue: true })
if (r.exceptionDetails) throw new Error(r.exceptionDetails.exception?.description ?? r.exceptionDetails.text)
return r.result.value
}
return { call, evaluate, close: () => socket.close() }
}
/** Waits until an expression in the page is truthy. */
async function until(page, expression, what, ms = 30000) {
const end = Date.now() + ms
while (Date.now() < end) {
if (await page.evaluate(`!!(${expression})`).catch(() => false)) return
await sleep(250)
}
throw new Error(`timed out waiting for ${what}`)
}
// --- the checks -------------------------------------------------------------
/** Where every open popup is, against the window. */
const MEASURE = `(() => {
const w = innerWidth, h = innerHeight
return [...document.querySelectorAll(${JSON.stringify(POPUPS)})]
.filter((el) => el.getClientRects().length > 0)
.map((el) => {
const r = el.getBoundingClientRect()
return {
what: el.className || el.getAttribute('role'),
left: Math.round(r.left), top: Math.round(r.top),
right: Math.round(r.right), bottom: Math.round(r.bottom),
inside: r.left >= -1 && r.top >= -1 && r.right <= w + 1 && r.bottom <= h + 1,
viewport: w + 'x' + h
}
})
})()`
/** A right-click at the window's far corner, on whatever element is named. */
const rightClickInCorner = (selector, text) => `(() => {
const el = [...document.querySelectorAll(${JSON.stringify(selector)})]
.find((e) => ${JSON.stringify(text ?? '')} === '' || e.innerText.includes(${JSON.stringify(text ?? '')}))
if (!el) return false
el.dispatchEvent(new MouseEvent('contextmenu', { bubbles: true, cancelable: true, clientX: innerWidth - 2, clientY: innerHeight - 2, button: 2 }))
return true
})()`
const click = (selector, text) => `(() => {
const el = [...document.querySelectorAll(${JSON.stringify(selector)})]
.find((e) => ${JSON.stringify(text ?? '')} === '' || (e.innerText + ' ' + (e.title || '')).includes(${JSON.stringify(text ?? '')}))
if (!el) return false
el.click()
return true
})()`
const SCENARIOS = [
{ name: 'emoji picker', open: click('button[title="Emoji"]') },
{ name: 'channel list menu, opened in the corner', open: rightClickInCorner('.buffer-row', '#ui') },
{ name: 'message menu, opened in the corner', open: rightClickInCorner('.message-row', 'line 12') },
{ name: 'member menu, opened in the corner', open: rightClickInCorner('.nick-row', 'alice') },
{ name: 'mentions inbox', open: click('button[title="Mentions"]') }
]
async function closePopups(page) {
for (const type of ['keyDown', 'keyUp']) {
await page.call('Input.dispatchKeyEvent', { type, key: 'Escape', code: 'Escape', windowsVirtualKeyCode: 27 })
}
await sleep(150)
// Whatever Escape did not close, a click on empty space does.
if ((await page.evaluate(MEASURE)).length > 0) {
for (const type of ['mousePressed', 'mouseReleased']) {
await page.call('Input.dispatchMouseEvent', { type, x: 5, y: 5, button: 'left', clickCount: 1 })
}
await sleep(150)
}
}
async function main() {
const irc = await fakeIrc()
const ircPort = irc.address().port
const scratch = fs.mkdtempSync(path.join(os.tmpdir(), 'moho-ui-'))
const run = path.join(scratch, 'run')
fs.mkdirSync(run, { mode: 0o700 })
const port = await freePort()
// A CI runner's kernel refuses Chromium's sandbox (Ubuntu 24.04 restricts
// the unprivileged namespaces it needs); there it runs without, as an
// ordinary Chromium in a container does.
const flags = [`--remote-debugging-port=${port}`, ...(process.env.CI ? ['--no-sandbox'] : [])]
const app = spawn(electronPath, [root, ...flags], {
cwd: root,
detached: true,
stdio: process.env.MOHO_UI_VERBOSE ? 'inherit' : 'ignore',
env: {
...process.env,
HOME: scratch,
XDG_CONFIG_HOME: path.join(scratch, 'config'),
XDG_CACHE_HOME: path.join(scratch, 'cache'),
XDG_DATA_HOME: path.join(scratch, 'data'),
XDG_STATE_HOME: path.join(scratch, 'state'),
XDG_RUNTIME_DIR: run,
// The runtime directory is moved so the daemon's socket is this run's
// own - which also moves where a Wayland client looks for the
// compositor. An absolute path keeps it pointing at the real one.
...(process.env.WAYLAND_DISPLAY && process.env.XDG_RUNTIME_DIR && !path.isAbsolute(process.env.WAYLAND_DISPLAY)
? { WAYLAND_DISPLAY: path.join(process.env.XDG_RUNTIME_DIR, process.env.WAYLAND_DISPLAY) }
: {})
}
})
const failures = []
let checked = 0
let page
try {
page = cdp((await pageTarget(port)).webSocketDebuggerUrl)
await until(page, 'window.moho && document.querySelector("#root > *")', 'the window to load')
// An account on the fake server, with a channel to open popups over.
await until(page, 'window.moho.rpc("listAccounts", {}).then(() => true)', 'the daemon')
await page.evaluate(
`window.moho.rpc("addAccount", { nick: "checker", host: "localhost", port: ${ircPort}, ssl: false, autojoin: "#ui" })`
)
await until(page, 'window.moho.rpc("listBuffers", {}).then((b) => b.some((x) => x.id.endsWith("|#ui")))', 'the channel')
await page.call('Page.reload')
await sleep(1000)
await until(page, 'document.querySelector("[title*=\\"localhost\\"]")', 'the account in the rail')
await page.evaluate(click('[title*="localhost"]'))
await until(page, `[...document.querySelectorAll(".buffer-row")].some((r) => r.innerText.includes("#ui"))`, 'the channel row')
await page.evaluate(click('.buffer-row', '#ui'))
await until(page, `[...document.querySelectorAll(".message-row")].some((r) => r.innerText.includes("line 12"))`, 'the messages')
for (const size of SIZES) {
await page.call('Emulation.setDeviceMetricsOverride', { ...size, deviceScaleFactor: 1, mobile: false })
await sleep(400)
for (const scenario of SCENARIOS) {
await closePopups(page)
const opened = await page.evaluate(scenario.open)
const label = `${scenario.name} at ${size.width}x${size.height}`
if (!opened) {
failures.push(`${label}: nothing to open it from`)
continue
}
let seen = false
let started = 0
for (const at of SAMPLES) {
await sleep(at - started)
started = at
const rects = await page.evaluate(MEASURE)
if (rects.length > 0) seen = true
for (const r of rects) {
checked++
if (!r.inside) {
failures.push(
`${label}, ${at}ms: "${r.what}" at ${r.left},${r.top} to ${r.right},${r.bottom} is outside the ${r.viewport} window`
)
}
}
}
if (!seen) failures.push(`${label}: it did not open`)
else console.log(` ok ${label}`)
}
}
} finally {
page?.close()
try {
process.kill(-app.pid, 'SIGTERM')
} catch {
/* already gone */
}
await sleep(1500)
try {
process.kill(-app.pid, 'SIGKILL')
} catch {
/* already gone */
}
irc.close()
fs.rmSync(scratch, { recursive: true, force: true })
}
if (failures.length > 0) {
console.error(`\n${failures.length} layout failure(s):`)
for (const f of failures) console.error(` FAIL ${f}`)
process.exit(1)
}
console.log(`\n${checked} measurements, every popup inside the window.`)
}
main().catch((e) => {
console.error(e)
process.exit(1)
})
+53
View File
@@ -0,0 +1,53 @@
import fs from 'node:fs'
import path from 'node:path'
import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest'
// The module registers an Electron protocol; none of that runs here, only
// the predicate that decides what the page may load.
vi.mock('electron', () => ({ net: {}, protocol: {} }))
vi.mock('./log', () => ({ log: { warn: () => {}, info: () => {} } }))
const { isAllowed } = await import('./media-protocol')
describe('the media scheme boundary', () => {
let home: string
let cache: string
let outside: string
beforeAll(() => {
// Not under the system temp directory, which the scheme also serves
// (the daemon writes a few things there) and which would make every
// "outside" path here an inside one.
const scratch = path.join(process.cwd(), 'node_modules', '.cache')
fs.mkdirSync(scratch, { recursive: true })
home = fs.mkdtempSync(path.join(scratch, 'moho-media-'))
cache = path.join(home, 'cache', 'nobilis', 'matrix-media')
fs.mkdirSync(cache, { recursive: true })
outside = path.join(home, 'secret.txt')
fs.writeFileSync(path.join(cache, 'a.png'), 'x')
fs.writeFileSync(outside, 'x')
fs.symlinkSync(outside, path.join(cache, 'link.png'))
process.env.XDG_CACHE_HOME = path.join(home, 'cache')
process.env.XDG_CONFIG_HOME = path.join(home, 'config')
})
afterAll(() => fs.rmSync(home, { recursive: true, force: true }))
it('serves a file inside the daemon\'s cache', () => {
expect(isAllowed(path.join(cache, 'a.png'))).toBe(true)
})
it('refuses anything outside it, however it is spelled', () => {
expect(isAllowed(outside)).toBe(false)
expect(isAllowed(path.join(cache, '..', '..', '..', 'secret.txt'))).toBe(false)
expect(isAllowed('/etc/passwd')).toBe(false)
})
it('refuses a link planted inside that points out', () => {
expect(isAllowed(path.join(cache, 'link.png'))).toBe(false)
})
it('refuses a file that is not there', () => {
expect(isAllowed(path.join(cache, 'missing.png'))).toBe(false)
})
})
+26
View File
@@ -0,0 +1,26 @@
import { describe, expect, it } from 'vitest'
import { bufferLink } from './bufferlink'
import type { Account, Buffer } from '../../../shared/wire'
const account = (state: string): Account => ({ id: 'a', displayName: 'Ancient Pioneer', state }) as Account
const buffer = (link?: Buffer['link']): Buffer => ({ id: 'a|#general', accountId: 'a', link }) as Buffer
describe('whether a conversation is receiving', () => {
it('is, when the account is connected and the buffer has no link of its own', () => {
expect(bufferLink(buffer(), account('connected'))).toBeNull()
})
it('follows the account while the account is down, with its progress', () => {
expect(bufferLink(buffer(), account('connecting'), 'logging in...')).toEqual({ retrying: true, detail: 'logging in...' })
expect(bufferLink(buffer(), account('auth_failed'))?.retrying).toBe(false)
expect(bufferLink(buffer(), account('disconnected'))?.detail).toMatch(/disconnected/)
})
it("takes the buffer's own link when the account is fine", () => {
expect(bufferLink(buffer({ state: 'connecting' }), account('connected'))?.retrying).toBe(true)
const chat = bufferLink(buffer({ state: 'down', cause: 'chat', detail: 'The chat is down' }), account('connected'))
expect(chat).toEqual({ retrying: true, detail: 'The chat is down', cause: 'chat' })
// A refused join is not retried by anything.
expect(bufferLink(buffer({ state: 'down', cause: 'refused', detail: 'banned' }), account('connected'))?.retrying).toBe(false)
})
})
+28
View File
@@ -0,0 +1,28 @@
import { describe, expect, it } from 'vitest'
import { completeNick, cyclePrefix } from './completion'
describe('nick completion', () => {
const nicks = ['Salastil', 'sally', 'bob']
it('completes at the start of a line with a colon, and mid-line without', () => {
expect(completeNick('sa', nicks, 0)).toEqual({ replace: 2, insert: 'Salastil: ', nick: 'Salastil' })
expect(completeNick('hi sa', nicks, 0)).toEqual({ replace: 2, insert: 'Salastil ', nick: 'Salastil' })
})
it('cycles through the matches on each press', () => {
expect(completeNick('sa', nicks, 1)?.nick).toBe('sally')
expect(completeNick('sa', nicks, 2)?.nick).toBe('Salastil')
})
it('has nothing to say with no word or no match', () => {
expect(completeNick('hi ', nicks, 0)).toBeNull()
expect(completeNick('zz', nicks, 0)).toBeNull()
})
it('takes the last completion back off before cycling to the next', () => {
const first = completeNick('sa', nicks, 0)
expect(cyclePrefix('Salastil: ', first)).toBe('')
expect(cyclePrefix('something else', first)).toBeNull()
expect(cyclePrefix('x', null)).toBeNull()
})
})
+18
View File
@@ -0,0 +1,18 @@
import { afterEach, describe, expect, it } from 'vitest'
import { drawableEmoteUrl, kickEmoteId, resetLocalEmotes } from './emotecache'
afterEach(resetLocalEmotes)
describe('Kick emote cache', () => {
it('reads the id out of Kick fullsize URLs only', () => {
expect(kickEmoteId('https://files.kick.com/emotes/39261/fullsize')).toBe('39261')
expect(kickEmoteId('https://files.kick.com/emotes/39261/small')).toBeNull()
expect(kickEmoteId('https://cdn.7tv.app/emote/x/1x.webp')).toBeNull()
})
it("draws Kick's own picture until there is a local copy, and anything else as it is", () => {
const kick = 'https://files.kick.com/emotes/1/fullsize'
expect(drawableEmoteUrl(kick)).toBe(kick)
expect(drawableEmoteUrl('https://example.com/e.png')).toBe('https://example.com/e.png')
})
})
+204
View File
@@ -0,0 +1,204 @@
import { describe, expect, it } from 'vitest'
import {
buildSmilieIndex,
discordEmojiUrl,
embedColor,
emojiPreview,
escapeHtml,
extractCodeBlocks,
extractMedia,
extractQuoteBlocks,
formatMessage,
hostnameOf,
ircFormat,
normalizeBBCode,
stripCodeBlocks,
stripEmbeddedUrls,
stripQuoteBlocks,
thumbnailLinks,
youtubeId
} from './format'
describe('emoji and emotes', () => {
it('asks Discord for the animated WebP, whatever the emoji claims', () => {
expect(discordEmojiUrl('123')).toBe('https://cdn.discordapp.com/emojis/123.webp?size=44&animated=true')
})
it('shows a stand-in as the picture it stands for', () => {
expect(emojiPreview('<:lettyCrazy:1413156421880647762>')).toEqual({
src: discordEmojiUrl('1413156421880647762'),
label: ':lettyCrazy:'
})
expect(emojiPreview('[emote:39261:catJAM]')?.label).toBe(':catJAM:')
// A Unicode emoji is its own picture.
expect(emojiPreview('😂')).toBeNull()
})
it('draws Discord custom emoji and Kick emotes inline', () => {
const html = formatMessage('hi <a:dance:42> and [emote:7:wave]')
expect(html).toContain(`<img src="${discordEmojiUrl('42', 48)}" class="custom-emoji" alt=":dance:">`)
expect(html).toContain('alt=":wave:"')
expect(html).toContain('files.kick.com/emotes/7/fullsize')
})
it('replaces the longest Sneedchat shortcode first', () => {
const index = buildSmilieIndex([
{ aliases: [':lol:'], label: 'lol', url: 'a.png' } as never,
{ aliases: [':lolol:'], label: 'lolol', url: 'b.png' } as never
])
const html = formatMessage('x :lolol: y', { isSneedchat: true, smilies: index })
expect(html).toContain('src="b.png"')
expect(html).not.toContain('src="a.png"')
})
})
describe('IRC formatting codes', () => {
it('strips them for every protocol that does not use them', () => {
expect(ircFormat('\u0002bold\u0002 \u000304red\u0003 plain', 'strip')).toBe('bold red plain')
expect(ircFormat('no codes here', 'strip')).toBe('no codes here')
})
it('renders bold and colour, and closes what it opens', () => {
const html = ircFormat('\u0002b\u000f \u000304,01x\u0003', 'render')
expect(html).toContain('font-weight:600')
expect(html).toContain('color:#ff0000')
expect(html).toContain('background:#000000')
expect(html.match(/<span/g)?.length).toBe(html.match(/<\/span>/g)?.length)
})
it('strips them before formatting even when not rendering them', () => {
expect(formatMessage('https://ex\u0002ample.com')).toContain('href="https://example.com"')
})
})
describe('BBCode', () => {
it('unwraps pictures to bare URLs a link scan can find', () => {
expect(normalizeBBCode('[img]https://a/x.png[/img][img]https://a/y.png[/img]').split('\n').filter(Boolean)).toEqual([
'https://a/x.png',
'https://a/y.png'
])
})
it('turns quotes, spoilers, code and line breaks into the shared conventions', () => {
expect(normalizeBBCode('a[br]b')).toBe('a\nb')
expect(normalizeBBCode('[spoiler]boo[/spoiler]')).toBe('||boo||')
expect(normalizeBBCode('[quote]one\ntwo[/quote]')).toBe('\n> one\n> two\n')
expect(normalizeBBCode('[php]x[/php]')).toBe('```x```')
expect(normalizeBBCode('[code]x[/code]')).toBe('`x`')
})
it('pairs a thumbnail with the different page it links to', () => {
expect(thumbnailLinks('[url=https://host/page][img]https://host/t.jpg[/img][/url]')).toEqual({
'https://host/t.jpg': 'https://host/page'
})
expect(thumbnailLinks('[url=https://host/t.jpg][img]https://host/t.jpg[/img][/url]')).toEqual({})
})
it('formats greentext colour and strips tags it does not know', () => {
const html = formatMessage('[color=#72ff72]>implying[/color] [blink]x[/blink]', { isSneedchat: true })
expect(html).toContain('<span style="color:#72ff72">&gt;implying</span>'.replace('&gt;', '>'))
expect(html).not.toContain('[blink]')
})
})
describe('formatMessage', () => {
it('numbers spoilers in order and reveals by number', () => {
const html = formatMessage('||one|| ||two||', { revealedSpoilers: { 1: true } })
expect(html).toContain('<a href="spoiler:0" class="spoiler">one</a>')
expect(html).toContain('<span class="spoiler revealed">two</span>')
})
it('keeps inline code literal and escaped', () => {
expect(formatMessage('`**not bold** <b>`')).toContain('&lt;b&gt;')
expect(formatMessage('`**not bold**`')).not.toContain('<b>')
})
it('links Discord channel mentions it knows, and says so when it does not', () => {
const channels = { '55': { bufferId: 'discord:1|g/#general', name: '#general' } }
expect(formatMessage('see <#55>', { channels })).toContain(
`<a href="channel:${encodeURIComponent('discord:1|g/#general')}" class="channel-mention">#general</a>`
)
expect(formatMessage('see <#99>')).toContain('#unknown-channel')
})
it('turns markdown and bare links into markup', () => {
const html = formatMessage('**b** *i* ~~s~~ https://example.com/x')
expect(html).toContain('<b>b</b>')
expect(html).toContain('<i>i</i>')
expect(html).toContain('<s>s</s>')
expect(html).toContain('<a href="https://example.com/x">https://example.com/x</a>')
})
it('does not italicise snake_case in the middle of a word', () => {
expect(formatMessage('a snake_case_name here')).not.toContain('<i>')
})
it('escapes what it is asked to', () => {
expect(escapeHtml('<a & b>')).toBe('&lt;a &amp; b&gt;')
})
})
describe('media in a message', () => {
it('finds YouTube in every form a link arrives in', () => {
for (const url of [
'https://www.youtube.com/watch?v=dQw4w9WgXcQ',
'https://youtu.be/dQw4w9WgXcQ',
'https://www.youtube.com/shorts/dQw4w9WgXcQ',
'https://www.youtube.com/embed/dQw4w9WgXcQ',
'https://www.youtube.com/live/dQw4w9WgXcQ'
]) {
expect(youtubeId(url), url).toBe('dQw4w9WgXcQ')
}
expect(youtubeId('https://example.com/watch?v=dQw4w9WgXcQ')).toBeNull()
})
it('classifies pictures, video and YouTube, once each', () => {
const items = extractMedia(
'https://a.com/x.png?w=1 https://a.com/v.webm https://youtu.be/dQw4w9WgXcQ https://www.youtube.com/watch?v=dQw4w9WgXcQ https://a.com/x.png?w=1.'
)
expect(items.map((i) => i.kind)).toEqual(['image', 'video', 'youtube'])
})
it('asks for a sniff only for links it cannot classify, and only when allowed', () => {
const asked: string[] = []
extractMedia('https://a.com/page', { contentSniffing: true, onNeedSniff: (u) => asked.push(u) })
extractMedia('https://a.com/other', { onNeedSniff: (u) => asked.push(u) })
expect(asked).toEqual(['https://a.com/page'])
})
it('gives a thumbnail the full-size copy it was linked to', () => {
const items = extractMedia('https://h/t.jpg', { thumbnails: { 'https://h/t.jpg': 'https://h/full.jpg' } })
expect(items).toEqual([{ url: 'https://h/t.jpg', kind: 'image', full: 'https://h/full.jpg' }])
})
it('takes embedded links out of the text, YouTube by video rather than by URL', () => {
const text = 'look https://youtu.be/dQw4w9WgXcQ\nand https://www.youtube.com/watch?v=dQw4w9WgXcQ'
const items = extractMedia(text)
expect(stripEmbeddedUrls(text, items)).toBe('look\nand')
})
it('reads a hostname without its port, in lower case', () => {
expect(hostnameOf('https://I.DDOS.LGBT:8443/x')).toBe('i.ddos.lgbt')
expect(hostnameOf('not a url')).toBe('')
})
})
describe('blocks', () => {
it('pulls fenced code out, language line and all', () => {
const text = 'before\n```rust\nfn main() {}\n```\nafter'
expect(extractCodeBlocks(text)).toEqual(['fn main() {}'])
expect(stripCodeBlocks(text)).toBe('before\n\nafter')
})
it('groups consecutive quote lines into one quote', () => {
const text = '> a\n> b\nmiddle\n> c'
expect(extractQuoteBlocks(text)).toEqual(['a\nb', 'c'])
expect(stripQuoteBlocks(text)).toBe('middle')
})
it('writes Discord embed colours as hex', () => {
expect(embedColor(0x5865f2)).toBe('#5865f2')
expect(embedColor(255)).toBe('#0000ff')
expect(embedColor(undefined)).toBeUndefined()
})
})
+77
View File
@@ -0,0 +1,77 @@
import { describe, expect, it } from 'vitest'
import {
fileInFolder,
foldTogether,
orderedGroups,
railEntries,
removeFromFolders,
reorder,
reorderFolder,
type RailFolder,
type RailGroup
} from './groups'
const group = (id: string, kind = 'guild', position = 0, name = id): RailGroup =>
({ id, kind, position, name }) as RailGroup
describe('the server rail', () => {
const groups = [group('b', 'guild', 2), group('pin', 'pinned'), group('a', 'guild', 1), group('dm', 'dms'), group('c', 'guild', 3)]
it('puts direct messages first and the fixed entries above everything movable', () => {
expect(orderedGroups(groups, []).map((g) => g.id)).toEqual(['dm', 'pin', 'a', 'b', 'c'])
})
it('follows the saved order, and puts groups it has never seen after it', () => {
expect(orderedGroups(groups, ['c', 'a']).map((g) => g.id)).toEqual(['dm', 'pin', 'c', 'a', 'b'])
})
it('moves a dragged group to where it was dropped, never moving the fixed ones', () => {
const ordered = orderedGroups(groups, [])
expect(reorder(ordered, 'c', 'a')).toEqual(['c', 'a', 'b'])
expect(reorder(ordered, 'dm', 'a')).toEqual(['a', 'b', 'c'])
expect(reorder(ordered, 'a', 'a')).toEqual(['a', 'b', 'c'])
})
it('moves a folder as one block', () => {
const ordered = orderedGroups(groups, [])
const folder: RailFolder = { id: 'f', name: 'F', members: ['b', 'c'] }
expect(reorderFolder(ordered, folder, 'a')).toEqual(['b', 'c', 'a'])
// Dropped onto one of its own members: nothing moves.
expect(reorderFolder(ordered, folder, 'c')).toEqual(['a', 'b', 'c'])
})
})
describe('folders', () => {
const ordered = [group('a'), group('b'), group('c'), group('d')]
it('sits where its first member is, and keeps the rest of the column in order', () => {
const entries = railEntries(ordered, [{ id: 'f', name: 'F', members: ['c', 'b'] }])
expect(entries.map((e) => e.id)).toEqual(['a', 'f', 'd'])
const folder = entries[1]
expect(folder.kind === 'folder' && folder.members.map((g) => g.id)).toEqual(['c', 'b'])
})
it('still shows a folder whose servers are all gone', () => {
expect(railEntries([], [{ id: 'f', name: 'F', members: ['gone'] }]).map((e) => e.id)).toEqual(['f'])
})
it('makes a folder of two, or adds to the folder already there', () => {
const made = foldTogether([], 'a', 'b')
expect(made).toHaveLength(1)
expect(made[0].members).toEqual(['a', 'b'])
const added = foldTogether(made, 'a', 'c')
expect(added).toHaveLength(1)
expect(added[0].members).toEqual(['a', 'b', 'c'])
})
it('keeps a server in one folder at a time', () => {
const folders: RailFolder[] = [
{ id: 'x', name: 'X', members: ['a', 'b'] },
{ id: 'y', name: 'Y', members: ['c'] }
]
const moved = fileInFolder(folders, 'y', 'a')
expect(moved.find((f) => f.id === 'x')?.members).toEqual(['b'])
expect(moved.find((f) => f.id === 'y')?.members).toEqual(['c', 'a'])
expect(removeFromFolders(moved, 'c').find((f) => f.id === 'y')?.members).toEqual(['a'])
})
})
+22
View File
@@ -0,0 +1,22 @@
import { describe, expect, it } from 'vitest'
import { watchDelta, watchedKickBuffers } from './kickwatch'
import type { BufferEntry } from '../state/store'
const buffer = (id: string, kind = 'channel'): BufferEntry => ({ id, kind }) as BufferEntry
describe('which Kick channels count as watched', () => {
const buffers = [buffer('kick:a|one'), buffer('kick:a|two'), buffer('irc|#x'), buffer('kick:a|dm', 'dm')]
const serviceOf = (id: string): string => id.split(':')[0].split('|')[0]
it('is the open channel and the stream on screen, Kick channels only, once each', () => {
expect(watchedKickBuffers(buffers, 'kick:a|one', 'kick:a|two', serviceOf)).toEqual(['kick:a|one', 'kick:a|two'])
expect(watchedKickBuffers(buffers, 'kick:a|one', 'kick:a|one', serviceOf)).toEqual(['kick:a|one'])
expect(watchedKickBuffers(buffers, 'irc|#x', undefined, serviceOf)).toEqual([])
expect(watchedKickBuffers(buffers, 'kick:a|dm', undefined, serviceOf)).toEqual([])
})
it('says what to start and stop as that changes', () => {
expect(watchDelta(['a', 'b'], ['b', 'c'])).toEqual({ start: ['c'], stop: ['a'] })
expect(watchDelta([], [])).toEqual({ start: [], stop: [] })
})
})
+34
View File
@@ -0,0 +1,34 @@
import { describe, expect, it } from 'vitest'
import { fuzzyScore, mentionInsert, mentionKeywords, mentionQuery, rankMentions } from './mentions'
describe('mentions', () => {
it('reads what is being typed after an @', () => {
expect(mentionQuery('hello @sal')).toBe('sal')
expect(mentionQuery('(@')).toBe('')
expect(mentionQuery('mail@example')).toBeNull()
expect(mentionQuery('no mention')).toBeNull()
})
it('inserts what each service understands', () => {
expect(mentionInsert('irc', 'nick', true)).toBe('nick: ')
expect(mentionInsert('irc', 'nick', false)).toBe('nick ')
expect(mentionInsert('discord', 'nick', true)).toBe('@nick ')
})
it('offers each service its own room-wide words', () => {
expect(mentionKeywords('discord').map((k) => k.word)).toEqual(['everyone', 'here'])
expect(mentionKeywords('matrix').map((k) => k.word)).toEqual(['room'])
expect(mentionKeywords('irc')).toEqual([])
})
it('ranks a prefix over a word start over scattered letters', () => {
expect(fuzzyScore('salastil', 'sal')).toBeGreaterThan(fuzzyScore('big_sal', 'sal'))
expect(fuzzyScore('big_sal', 'sal')).toBeGreaterThan(fuzzyScore('sxaxl', 'sal'))
expect(fuzzyScore('bob', 'sal')).toBe(0)
})
it('drops what does not match, best first, up to the limit', () => {
const targets = ['salastil', 'big_sal', 'bob', 's_a_l'].map((name) => ({ name, kind: 'member' as const }))
expect(rankMentions(targets, 'sal', 2).map((t) => t.name)).toEqual(['salastil', 'big_sal'])
})
})
@@ -0,0 +1,37 @@
import { describe, expect, it } from 'vitest'
import { hasQuery, parseSearch, suggestions } from './searchfilters'
describe('search filters', () => {
it('takes known filters out and leaves the words', () => {
const parsed = parseSearch('cats from:salastil in:"general chat" has:file meow')
expect(parsed.text).toBe('cats meow')
expect(parsed.from).toBe('salastil')
expect(parsed.in).toBe('general chat')
expect(parsed.has).toBe('file')
})
it('reads dates, and keeps a malformed one as text', () => {
const parsed = parseSearch('before:2026-01-31 after:last-week')
expect(parsed.before).toBe(Math.floor(Date.parse('2026-01-31T00:00:00') / 1000))
expect(parsed.after).toBeUndefined()
expect(parsed.text).toBe('after:last-week')
})
it('notes a word: that is not a filter, and keeps it in the text', () => {
const parsed = parseSearch('http://x.com subject:hi')
expect(parsed.unknown).toEqual(['http', 'subject'])
expect(parsed.text).toBe('http://x.com subject:hi')
})
it('knows when there is nothing to search for', () => {
expect(hasQuery(parseSearch(' '))).toBe(false)
expect(hasQuery(parseSearch('from:'))).toBe(false)
expect(hasQuery(parseSearch('from:x'))).toBe(true)
})
it('suggests filters for a partial word, and none inside one', () => {
expect(suggestions('cats f').map((f) => f.key)).toEqual(['from'])
expect(suggestions('').length).toBe(6)
expect(suggestions('from:')).toEqual([])
})
})
+91
View File
@@ -0,0 +1,91 @@
import { describe, expect, it } from 'vitest'
import {
bufferDisplayName,
classes,
fileNameOf,
formatRelativeTime,
guildOf,
humanBytes,
isChatKind,
isImageFile,
resolveMediaUrl
} from './util'
describe('buffer names', () => {
it('splits a Discord channel from its guild, and leaves everything else alone', () => {
expect(bufferDisplayName('My Guild/#general')).toBe('#general')
expect(guildOf('My Guild/#general')).toBe('My Guild')
expect(bufferDisplayName('#irc-channel')).toBe('#irc-channel')
expect(guildOf('#irc-channel')).toBe('')
})
})
describe('resolveMediaUrl', () => {
const decoded = (url: string): string | null => new URL(url).searchParams.get('p')
it('routes local files through the media scheme, path as a parameter', () => {
const url = resolveMediaUrl('file:///home/a/.cache/nobilis/x%20y.png')
expect(url.startsWith('moho-media://file/?p=')).toBe(true)
expect(decoded(url)).toBe('/home/a/.cache/nobilis/x y.png')
expect(decoded(resolveMediaUrl('/abs/path.png'))).toBe('/abs/path.png')
})
it('takes a Windows drive letter as absolute, with or without the URL slash', () => {
expect(decoded(resolveMediaUrl('file:///C:/Users/a/x.png'))).toBe('C:/Users/a/x.png')
expect(decoded(resolveMediaUrl('C:\\Users\\a\\x.png'))).toBe('C:\\Users\\a\\x.png')
})
it('carries a file version onto the URL rather than into the path', () => {
const url = resolveMediaUrl('file:///c/nobilis/sneedchat-avatars/7.webp#1790000000')
expect(decoded(url)).toBe('/c/nobilis/sneedchat-avatars/7.webp')
expect(new URL(url).searchParams.get('v')).toBe('1790000000')
})
it('leaves remote URLs as they are', () => {
expect(resolveMediaUrl('https://example.com/a.png')).toBe('https://example.com/a.png')
expect(resolveMediaUrl('')).toBe('')
})
})
describe('files', () => {
it('names a file from either kind of path', () => {
expect(fileNameOf('/home/a/b.PNG')).toBe('b.PNG')
expect(fileNameOf('C:\\Users\\a\\b.png')).toBe('b.png')
expect(isImageFile('C:\\x\\shot.JPEG')).toBe(true)
expect(isImageFile('/x/notes.txt')).toBe(false)
})
it('writes sizes for a person to read', () => {
expect(humanBytes(512)).toBe('512 B')
expect(humanBytes(312 * 1024)).toBe('312 KB')
expect(humanBytes(5.5 * 1024 ** 2)).toBe('5.5 MB')
expect(humanBytes(1.2 * 1024 ** 3)).toBe('1.2 GB')
})
})
describe('time', () => {
const now = 1_790_000_000_000
const at = (secondsAgo: number): number => now / 1000 - secondsAgo
it('says how long ago, up to a day', () => {
expect(formatRelativeTime(at(10), now)).toBe('just now')
expect(formatRelativeTime(at(8 * 60), now)).toBe('8m ago')
expect(formatRelativeTime(at(5 * 3600), now)).toBe('5h ago')
expect(formatRelativeTime(at(30 * 3600), now)).toMatch(/^Yesterday at /)
})
it('never says a time in the future', () => {
expect(formatRelativeTime(at(-60), now)).toBe('just now')
})
})
describe('small things', () => {
it('knows which kinds are somebody speaking', () => {
for (const kind of ['chat', 'message', 'whisper', 'notice']) expect(isChatKind(kind)).toBe(true)
for (const kind of ['join', 'system', undefined]) expect(isChatKind(kind)).toBe(false)
})
it('joins class names, skipping the falsy ones', () => {
expect(classes('a', false, null, undefined, 'b', '')).toBe('a b')
})
})
+15
View File
@@ -0,0 +1,15 @@
import { defineConfig } from 'vitest/config'
/**
* The unit tests: the window's pure logic, and the main process's media
* boundary. Node by default - most of what is tested is data in, data out,
* and needs no DOM at all. Layout is not tested here: a DOM without a layout
* engine measures everything as 0x0, which is exactly how a popup that hangs
* off the screen passes. That is scripts/ui-check.mjs's job.
*/
export default defineConfig({
test: {
include: ['src/**/*.test.ts'],
environment: 'node'
}
})