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:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Generated
+433
-1
@@ -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
@@ -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",
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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">>implying</span>'.replace('>', '>'))
|
||||
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('<b>')
|
||||
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('<a & b>')
|
||||
})
|
||||
})
|
||||
|
||||
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()
|
||||
})
|
||||
})
|
||||
@@ -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'])
|
||||
})
|
||||
})
|
||||
@@ -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: [] })
|
||||
})
|
||||
})
|
||||
@@ -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([])
|
||||
})
|
||||
})
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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'
|
||||
}
|
||||
})
|
||||
Reference in New Issue
Block a user