Add JavaScript tests.

Run "npm install", then "npm test".
This commit is contained in:
tim
2026-09-26 03:48:09 +02:00
parent ed959888b4
commit b7022ab486
6 changed files with 1304 additions and 0 deletions
+555
View File
@@ -0,0 +1,555 @@
{
"name": "xxx",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "xxx",
"devDependencies": {
"jsdom": "^29.1.1"
}
},
"node_modules/@asamuzakjp/css-color": {
"version": "5.1.11",
"resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-5.1.11.tgz",
"integrity": "sha512-KVw6qIiCTUQhByfTd78h2yD1/00waTmm9uy/R7Ck/ctUyAPj+AEDLkQIdJW0T8+qGgj3j5bpNKK7Q3G+LedJWg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@asamuzakjp/generational-cache": "^1.0.1",
"@csstools/css-calc": "^3.2.0",
"@csstools/css-color-parser": "^4.1.0",
"@csstools/css-parser-algorithms": "^4.0.0",
"@csstools/css-tokenizer": "^4.0.0"
},
"engines": {
"node": "^20.19.0 || ^22.12.0 || >=24.0.0"
}
},
"node_modules/@asamuzakjp/dom-selector": {
"version": "7.1.1",
"resolved": "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-7.1.1.tgz",
"integrity": "sha512-67RZDnYRc8H/8MLDgQCDE//zoqVFwajkepHZgmXrbwybzXOEwOWGPYGmALYl9J2DOLfFPPs6kKCqmbzV895hTQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@asamuzakjp/generational-cache": "^1.0.1",
"@asamuzakjp/nwsapi": "^2.3.9",
"bidi-js": "^1.0.3",
"css-tree": "^3.2.1",
"is-potential-custom-element-name": "^1.0.1"
},
"engines": {
"node": "^20.19.0 || ^22.12.0 || >=24.0.0"
}
},
"node_modules/@asamuzakjp/generational-cache": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/@asamuzakjp/generational-cache/-/generational-cache-1.0.1.tgz",
"integrity": "sha512-wajfB8KqzMCN2KGNFdLkReeHncd0AslUSrvHVvvYWuU8ghncRJoA50kT3zP9MVL0+9g4/67H+cdvBskj9THPzg==",
"dev": true,
"license": "MIT",
"engines": {
"node": "^20.19.0 || ^22.12.0 || >=24.0.0"
}
},
"node_modules/@asamuzakjp/nwsapi": {
"version": "2.3.9",
"resolved": "https://registry.npmjs.org/@asamuzakjp/nwsapi/-/nwsapi-2.3.9.tgz",
"integrity": "sha512-n8GuYSrI9bF7FFZ/SjhwevlHc8xaVlb/7HmHelnc/PZXBD2ZR49NnN9sMMuDdEGPeeRQ5d0hqlSlEpgCX3Wl0Q==",
"dev": true,
"license": "MIT"
},
"node_modules/@bramus/specificity": {
"version": "2.4.2",
"resolved": "https://registry.npmjs.org/@bramus/specificity/-/specificity-2.4.2.tgz",
"integrity": "sha512-ctxtJ/eA+t+6q2++vj5j7FYX3nRu311q1wfYH3xjlLOsczhlhxAg2FWNUXhpGvAw3BWo1xBcvOV6/YLc2r5FJw==",
"dev": true,
"license": "MIT",
"dependencies": {
"css-tree": "^3.0.0"
},
"bin": {
"specificity": "bin/cli.js"
}
},
"node_modules/@csstools/color-helpers": {
"version": "6.1.2",
"resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-6.1.2.tgz",
"integrity": "sha512-grhRy3OKmniaAEKXMjua5z/EODX0MSqBGjunw8+j/3HQjOnahs2AGhvEOIYVUWcU6ScApbhLhVrQTX8XqrMrow==",
"dev": true,
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/csstools"
},
{
"type": "opencollective",
"url": "https://opencollective.com/csstools"
}
],
"license": "MIT-0",
"engines": {
"node": ">=20.19.0"
}
},
"node_modules/@csstools/css-calc": {
"version": "3.4.1",
"resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-3.4.1.tgz",
"integrity": "sha512-EtC7SoN1j6J4E4DCwg5QgbO5TGxgxIA1RXqe+W+qUM+BUcezx9wT+/tiQ/WO2yCX4i5X+Cuf9ciJ22aP4UEwWw==",
"dev": true,
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/csstools"
},
{
"type": "opencollective",
"url": "https://opencollective.com/csstools"
}
],
"license": "MIT",
"engines": {
"node": ">=20.19.0"
},
"peerDependencies": {
"@csstools/css-parser-algorithms": "^4.0.1",
"@csstools/css-tokenizer": "^4.0.2"
}
},
"node_modules/@csstools/css-color-parser": {
"version": "4.2.4",
"resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-4.2.4.tgz",
"integrity": "sha512-DyefytAZ735mX4Dq/WcDAXFtXhaEFvme0ZS9tVEBAc2whxUthXr0R0L2rmEPm59SrMBkGrFzQviBXMs/UtnABQ==",
"dev": true,
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/csstools"
},
{
"type": "opencollective",
"url": "https://opencollective.com/csstools"
}
],
"license": "MIT",
"dependencies": {
"@csstools/color-helpers": "^6.1.2",
"@csstools/css-calc": "^3.4.1"
},
"engines": {
"node": ">=20.19.0"
},
"peerDependencies": {
"@csstools/css-parser-algorithms": "^4.0.1",
"@csstools/css-tokenizer": "^4.0.2"
}
},
"node_modules/@csstools/css-parser-algorithms": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-4.0.1.tgz",
"integrity": "sha512-ShL8BqPfbKJrJiKFH0xBbN0i7Nrh9HXYRuF+pzyj93R/BL2YAsUxJeqANErzqM+0JGl7vjHp+3OIgd/DL69YIA==",
"dev": true,
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/csstools"
},
{
"type": "opencollective",
"url": "https://opencollective.com/csstools"
}
],
"license": "MIT",
"engines": {
"node": ">=20.19.0"
},
"peerDependencies": {
"@csstools/css-tokenizer": "^4.0.2"
}
},
"node_modules/@csstools/css-syntax-patches-for-csstree": {
"version": "1.1.14",
"resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.1.14.tgz",
"integrity": "sha512-HpbVXyrofRXpHpgkNIjU/3EWR4WJvOkO3emNK/L6X/mTJU7bGUI3AkkpoTNXznQLp0KRjLHELTGeKI5dIkI9JQ==",
"dev": true,
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/csstools"
},
{
"type": "opencollective",
"url": "https://opencollective.com/csstools"
}
],
"license": "MIT-0",
"peerDependencies": {
"css-tree": "^3.2.1"
},
"peerDependenciesMeta": {
"css-tree": {
"optional": true
}
}
},
"node_modules/@csstools/css-tokenizer": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-4.0.2.tgz",
"integrity": "sha512-OoKoR0f76dCY666JlcbhmVTs2drYj1GUXZTYTcbUgJjh9Nv41aFfZ21bPQTERm5+L5cBDo466NltB2lplS5GBw==",
"dev": true,
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/csstools"
},
{
"type": "opencollective",
"url": "https://opencollective.com/csstools"
}
],
"license": "MIT",
"engines": {
"node": ">=20.19.0"
}
},
"node_modules/@exodus/bytes": {
"version": "1.16.0",
"resolved": "https://registry.npmjs.org/@exodus/bytes/-/bytes-1.16.0.tgz",
"integrity": "sha512-IcpW84uEn3N7ETtNZMlxKhfl6Pec8rUNGOTBtWbK1FKhJxIFAptZyVrvVRVBimAJxJCgc3PxepxkdWWG4DVzfA==",
"dev": true,
"license": "MIT",
"engines": {
"node": "^20.19.0 || ^22.12.0 || >=24.0.0"
},
"peerDependencies": {
"@noble/hashes": "^1.8.0 || ^2.0.0"
},
"peerDependenciesMeta": {
"@noble/hashes": {
"optional": true
}
}
},
"node_modules/bidi-js": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.1.0.tgz",
"integrity": "sha512-fX1Onk0tdVPC7obPWB5EbJ1z7NVhLq4m2xZLq2YXBkxzMXIGRpNMU88n0EPgWseKl12J7zXs7qrDxPK4sRs2fg==",
"dev": true,
"license": "MIT",
"dependencies": {
"require-from-string": "^2.0.2"
}
},
"node_modules/css-tree": {
"version": "3.2.1",
"resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.2.1.tgz",
"integrity": "sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==",
"dev": true,
"license": "MIT",
"dependencies": {
"mdn-data": "2.27.1",
"source-map-js": "^1.2.1"
},
"engines": {
"node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0"
}
},
"node_modules/data-urls": {
"version": "7.0.0",
"resolved": "https://registry.npmjs.org/data-urls/-/data-urls-7.0.0.tgz",
"integrity": "sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA==",
"dev": true,
"license": "MIT",
"dependencies": {
"whatwg-mimetype": "^5.0.0",
"whatwg-url": "^16.0.0"
},
"engines": {
"node": "^20.19.0 || ^22.12.0 || >=24.0.0"
}
},
"node_modules/decimal.js": {
"version": "10.6.0",
"resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz",
"integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==",
"dev": true,
"license": "MIT"
},
"node_modules/entities": {
"version": "8.1.0",
"resolved": "https://registry.npmjs.org/entities/-/entities-8.1.0.tgz",
"integrity": "sha512-kxL7msIffSuh9aaFAMD7rxAIuTRMAHMeBtgHW2yUdWw732ZNh4MehkF2gdjvtdmikkaIP9bFDDJOPlsvm7avrA==",
"dev": true,
"license": "BSD-2-Clause",
"engines": {
"node": ">=20.19.0"
},
"funding": {
"url": "https://github.com/fb55/entities?sponsor=1"
}
},
"node_modules/html-encoding-sniffer": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-6.0.0.tgz",
"integrity": "sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@exodus/bytes": "^1.6.0"
},
"engines": {
"node": "^20.19.0 || ^22.12.0 || >=24.0.0"
}
},
"node_modules/is-potential-custom-element-name": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz",
"integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==",
"dev": true,
"license": "MIT"
},
"node_modules/jsdom": {
"version": "29.1.1",
"resolved": "https://registry.npmjs.org/jsdom/-/jsdom-29.1.1.tgz",
"integrity": "sha512-ECi4Fi2f7BdJtUKTflYRTiaMxIB0O6zfR1fX0GXpUrf6flp8QIYn1UT20YQqdSOfk2dfkCwS8LAFoJDEppNK5Q==",
"dev": true,
"license": "MIT",
"dependencies": {
"@asamuzakjp/css-color": "^5.1.11",
"@asamuzakjp/dom-selector": "^7.1.1",
"@bramus/specificity": "^2.4.2",
"@csstools/css-syntax-patches-for-csstree": "^1.1.3",
"@exodus/bytes": "^1.15.0",
"css-tree": "^3.2.1",
"data-urls": "^7.0.0",
"decimal.js": "^10.6.0",
"html-encoding-sniffer": "^6.0.0",
"is-potential-custom-element-name": "^1.0.1",
"lru-cache": "^11.3.5",
"parse5": "^8.0.1",
"saxes": "^6.0.0",
"symbol-tree": "^3.2.4",
"tough-cookie": "^6.0.1",
"undici": "^7.25.0",
"w3c-xmlserializer": "^5.0.0",
"webidl-conversions": "^8.0.1",
"whatwg-mimetype": "^5.0.0",
"whatwg-url": "^16.0.1",
"xml-name-validator": "^5.0.0"
},
"engines": {
"node": "^20.19.0 || ^22.13.0 || >=24.0.0"
},
"peerDependencies": {
"canvas": "^3.0.0"
},
"peerDependenciesMeta": {
"canvas": {
"optional": true
}
}
},
"node_modules/lru-cache": {
"version": "11.5.3",
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.3.tgz",
"integrity": "sha512-U4N8FgzmWxc8k1VH8Kr6lQg18U7Fjvby6wXHVRX/ZZ7IwWbRMgrRbP0Wrb5q5NVinryp4SQampHKdvtecItxUg==",
"dev": true,
"license": "BlueOak-1.0.0",
"engines": {
"node": "20 || >=22"
}
},
"node_modules/mdn-data": {
"version": "2.27.1",
"resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.27.1.tgz",
"integrity": "sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==",
"dev": true,
"license": "CC0-1.0"
},
"node_modules/parse5": {
"version": "8.0.1",
"resolved": "https://registry.npmjs.org/parse5/-/parse5-8.0.1.tgz",
"integrity": "sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw==",
"dev": true,
"license": "MIT",
"dependencies": {
"entities": "^8.0.0"
},
"funding": {
"url": "https://github.com/inikulin/parse5?sponsor=1"
}
},
"node_modules/punycode": {
"version": "2.3.1",
"resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz",
"integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/require-from-string": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz",
"integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/saxes": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz",
"integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==",
"dev": true,
"license": "ISC",
"dependencies": {
"xmlchars": "^2.2.0"
},
"engines": {
"node": ">=v12.22.7"
}
},
"node_modules/source-map-js": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
"integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==",
"dev": true,
"license": "BSD-3-Clause",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/symbol-tree": {
"version": "3.2.4",
"resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz",
"integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==",
"dev": true,
"license": "MIT"
},
"node_modules/tldts": {
"version": "7.4.15",
"resolved": "https://registry.npmjs.org/tldts/-/tldts-7.4.15.tgz",
"integrity": "sha512-SJVBeHOxDbNoq14CvpAoA2mLEWdbldGK8nR+yumpjY5nrlZxOflcA7p1Qj1gbTGmbu9DY9qLj/bENSWhBzjxRQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"tldts-core": "^7.4.15"
},
"bin": {
"tldts": "bin/cli.js"
}
},
"node_modules/tldts-core": {
"version": "7.4.15",
"resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.4.15.tgz",
"integrity": "sha512-ERuv0p98XgSzmlSLJr8vDNxX+uATGInlN97V3R+JpYWaSqn5IG3ewWgCwczk4bkOpgth/o8Nt5FOi4B4w0n/1A==",
"dev": true,
"license": "MIT"
},
"node_modules/tough-cookie": {
"version": "6.0.2",
"resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-6.0.2.tgz",
"integrity": "sha512-exgYmnmL/sJpR3upZfXG5PoatXQii55xAiXGXzY+sROLZ/Y+SLcp9PgJNI9Vz37HpQ74WvDcLT8eqm+kV3FzrA==",
"dev": true,
"license": "BSD-3-Clause",
"dependencies": {
"tldts": "^7.0.5"
},
"engines": {
"node": ">=16"
}
},
"node_modules/tr46": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/tr46/-/tr46-6.0.0.tgz",
"integrity": "sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==",
"dev": true,
"license": "MIT",
"dependencies": {
"punycode": "^2.3.1"
},
"engines": {
"node": ">=20"
}
},
"node_modules/undici": {
"version": "7.30.0",
"resolved": "https://registry.npmjs.org/undici/-/undici-7.30.0.tgz",
"integrity": "sha512-dkrQXeHSaoamnItlYbmzG0wFYrM0ZwDxCIg0A7aKjTyyhh9svRzCNFEzV+Vm05/yehjCzjDZ31KXfGEjYSztDQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=20.18.1"
}
},
"node_modules/w3c-xmlserializer": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz",
"integrity": "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==",
"dev": true,
"license": "MIT",
"dependencies": {
"xml-name-validator": "^5.0.0"
},
"engines": {
"node": ">=18"
}
},
"node_modules/webidl-conversions": {
"version": "8.0.1",
"resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-8.0.1.tgz",
"integrity": "sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==",
"dev": true,
"license": "BSD-2-Clause",
"engines": {
"node": ">=20"
}
},
"node_modules/whatwg-mimetype": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-5.0.0.tgz",
"integrity": "sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=20"
}
},
"node_modules/whatwg-url": {
"version": "16.0.1",
"resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-16.0.1.tgz",
"integrity": "sha512-1to4zXBxmXHV3IiSSEInrreIlu02vUOvrhxJJH5vcxYTBDAx51cqZiKdyTxlecdKNSjj8EcxGBxNf6Vg+945gw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@exodus/bytes": "^1.11.0",
"tr46": "^6.0.0",
"webidl-conversions": "^8.0.1"
},
"engines": {
"node": "^20.19.0 || ^22.12.0 || >=24.0.0"
}
},
"node_modules/xml-name-validator": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz",
"integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==",
"dev": true,
"license": "Apache-2.0",
"engines": {
"node": ">=18"
}
},
"node_modules/xmlchars": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz",
"integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==",
"dev": true,
"license": "MIT"
}
}
}
+11
View File
@@ -0,0 +1,11 @@
{
"name": "xxx",
"private": true,
"description": "Tests for the JavaScript of xxx.org.za",
"scripts": {
"test": "node --test \"tests/js/*.test.js\""
},
"devDependencies": {
"jsdom": "^29.1.1"
}
}
+102
View File
@@ -0,0 +1,102 @@
// XxxEditor.parse() and XxxEditor.serialize(): the HTML that the editor keeps and writes
"use strict";
const test = require("node:test");
const assert = require("node:assert/strict");
const { load } = require("./helpers");
const window = load(["publish/editor.js"], "");
const { parse, serialize } = window.XxxEditor;
// The HTML that the editor writes for *html*
function clean(html) {
const holder = window.document.createElement("div");
holder.appendChild(parse(html));
return serialize(holder);
}
test("simple paragraphs stay the same", () => {
assert.equal(clean("<p>One</p>\n<p>Two <strong>bold</strong> <em>it</em> <u>u</u> x<sup>2</sup> H<sub>2</sub>O</p>"),
"<p>One</p>\n<p>Two <strong>bold</strong> <em>it</em> <u>u</u> x<sup>2</sup> H<sub>2</sub>O</p>");
});
test("b and i become strong and em", () => {
assert.equal(clean("<p><b>b</b> <i>i</i></p>"), "<p><strong>b</strong> <em>i</em></p>");
});
test("scripts, styles and frames are dropped with their contents", () => {
assert.equal(clean("<p>a<script>alert(1)</script>b</p><style>p{}</style><iframe src='x'></iframe>"), "<p>ab</p>");
});
test("event handlers and unknown attributes are dropped", () => {
assert.equal(clean('<p onclick="x()" class="c" id="i">a</p><img src="x" onerror="alert(1)">'), "<p>a</p>");
});
test("unknown tags keep their contents", () => {
assert.equal(clean("<p><font color='red'>red</font> <span>plain</span></p>"), "<p>red plain</p>");
});
test("headings become paragraphs", () => {
assert.equal(clean("<h2>Title</h2>"), "<p>Title</p>");
});
test("only safe links are kept", () => {
assert.equal(clean('<p><a href="https://example.org/?a=1&amp;b=2" title="T &quot;q&quot;">x</a></p>'),
'<p><a href="https://example.org/?a=1&amp;b=2" title="T &quot;q&quot;">x</a></p>');
assert.equal(clean('<p><a href="mailto:a@example.org">m</a> <a href="/about">r</a> <a href="#top">h</a></p>'),
'<p><a href="mailto:a@example.org">m</a> <a href="/about">r</a> <a href="#top">h</a></p>');
assert.equal(clean('<p><a href="javascript:alert(1)">x</a></p>'), "<p><a>x</a></p>");
assert.equal(clean('<p><a href=" JaVaScRiPt:alert(1)">x</a></p>'), "<p><a>x</a></p>");
assert.equal(clean('<p><a href="data:text/html,x">x</a></p>'), "<p><a>x</a></p>");
});
test("only alignment and indent styles are kept", () => {
assert.equal(clean('<p style="text-align: center; color: red">c</p>'), '<p style="text-align: center;">c</p>');
assert.equal(clean('<p style="padding-left: 30px">i</p>'), '<p style="padding-left: 30px;">i</p>');
assert.equal(clean('<p style="margin-left: expression(alert(1))">x</p>'), "<p>x</p>");
assert.equal(clean('<p><span style="color: red">x</span></p>'), "<p>x</p>");
assert.equal(clean('<p><span style="text-align: right">x</span></p>'), '<p><span style="text-align: right;">x</span></p>');
});
test("the align attribute is kept only with a real alignment", () => {
assert.equal(clean('<p align="center">c</p>'), '<p align="center">c</p>');
assert.equal(clean('<p align="evil">c</p>'), "<p>c</p>");
});
test("text is escaped", () => {
assert.equal(clean("<p>a &lt; b &amp; c &gt; d \"q\"</p>"), "<p>a &lt; b &amp; c &gt; d \"q\"</p>");
});
test("line breaks, rules and no-break spaces are written as TinyMCE wrote them", () => {
assert.equal(clean("<p>one<br>two</p><hr><p>a b</p>"), "<p>one<br />two</p>\n<hr />\n<p>a&nbsp;b</p>");
});
test("an empty paragraph is written as &nbsp;", () => {
assert.equal(clean("<p>a</p><p></p><p><br></p><p>b</p>"), "<p>a</p>\n<p>&nbsp;</p>\n<p>&nbsp;</p>\n<p>b</p>");
});
test("a trailing line break in a block is dropped", () => {
assert.equal(clean("<p>a<br></p>"), "<p>a</p>");
});
test("an editor with no words gives an empty story", () => {
assert.equal(clean(""), "");
assert.equal(clean("<p><br></p>"), "");
assert.equal(clean("<p>  </p><p>​</p>"), "");
assert.equal(clean("<hr>"), "<hr />");
});
test("blockquotes and lists put their blocks on new lines", () => {
assert.equal(clean("<blockquote><p>q</p></blockquote>"), "<blockquote>\n<p>q</p>\n</blockquote>");
assert.equal(clean("<ul><li>a</li><li>b</li></ul><ol><li>c</li></ol>"), "<ul>\n<li>a</li>\n<li>b</li>\n</ul>\n<ol>\n<li>c</li>\n</ol>");
});
test("whitespace between blocks is dropped", () => {
assert.equal(clean("<p>a</p>\n\n <p>b</p>"), "<p>a</p>\n<p>b</p>");
});
test("parse does not run anything", () => {
window.hacked = false;
parse('<img src="x" onerror="window.hacked = true"><script>window.hacked = true</script>');
assert.equal(window.hacked, false);
});
+370
View File
@@ -0,0 +1,370 @@
// XxxEditor.attach(): the toolbar, the dialogs, source mode, undo, paste and the keys
"use strict";
const test = require("node:test");
const assert = require("node:assert/strict");
const { editorWindow, select, selectText, button, key } = require("./helpers");
// Type into the editor: replace its HTML and send the input event
function type(env, html, inputType = "insertText") {
env.box.dispatchEvent(new env.window.InputEvent("beforeinput", { inputType, bubbles: true, cancelable: true }));
env.box.innerHTML = html;
env.box.dispatchEvent(new env.window.InputEvent("input", { inputType, bubbles: true }));
}
function paste(env, data) {
const event = new env.window.Event("paste", { bubbles: true, cancelable: true });
event.clipboardData = { getData: (type) => data[type] || "" };
env.box.dispatchEvent(event);
return event;
}
test("the editor takes the place of the textarea in the form", () => {
const env = editorWindow("<p>Hello</p>");
const wrapper = env.document.querySelector("#textparent > .xed");
assert.ok(wrapper);
assert.equal(env.textarea.parentNode, wrapper);
assert.equal(env.textarea.hidden, true);
assert.equal(env.textarea.style.display, "none");
assert.equal(env.textarea.form.id, "form");
assert.equal(env.box.contentEditable, "true");
assert.equal(env.box.getAttribute("aria-label"), "Story:");
assert.equal(env.box.innerHTML, "<p>Hello</p>");
});
test("the toolbar has the TinyMCE buttons, in order", () => {
const env = editorWindow();
const labels = Array.from(env.document.querySelectorAll(".xed-btn"), (b) => b.getAttribute("aria-label"));
assert.deepEqual(labels, ["Bold", "Italic", "Align left", "Align center", "Align right", "Justify", "Blockquote",
"Decrease indent", "Increase indent", "Insert/edit link", "Remove link", "Special character", "Source code"]);
assert.equal(button(env.document, "Bold").title, "Bold (Ctrl+B)");
assert.equal(button(env.document, "Insert/edit link").title, "Insert/edit link (Ctrl+K)");
assert.equal(env.document.querySelectorAll(".xed-group").length, 6);
for (const b of env.document.querySelectorAll(".xed-btn")) {
assert.equal(b.type, "button");
assert.ok(b.querySelector("svg[aria-hidden=true] path"));
}
});
test("the story in the textarea is cleaned when the editor starts", () => {
const env = editorWindow('<p onclick="x()">a</p>loose <b>text</b>');
assert.equal(env.textarea.value, "<p>a</p>\n<p>loose <strong>text</strong></p>");
assert.equal(env.editor.getContent(), env.textarea.value);
});
test("an empty story starts with an empty paragraph", () => {
const env = editorWindow("");
assert.equal(env.box.innerHTML, "<p><br></p>");
assert.equal(env.textarea.value, "");
});
test("typing updates the textarea and tells the word counter", () => {
const env = editorWindow();
let inputs = 0;
env.textarea.addEventListener("input", () => inputs++);
type(env, "<p>Some words</p>");
assert.equal(env.textarea.value, "<p>Some words</p>");
assert.equal(inputs, 1);
type(env, "<p>Some words</p>");
assert.equal(inputs, 1, "no event when nothing changed");
});
test("text typed outside a paragraph is put in one", () => {
const env = editorWindow();
type(env, "loose <em>text</em><p>para</p>more");
assert.equal(env.textarea.value, "<p>loose <em>text</em></p>\n<p>para</p>\n<p>more</p>");
});
test("deleting everything starts again with a plain paragraph", () => {
const env = editorWindow("<p><strong>bold</strong></p>");
type(env, "<p><strong><br></strong></p>", "deleteContentBackward");
assert.equal(env.box.innerHTML, "<p><br></p>");
});
test("setContent and getContent", () => {
const env = editorWindow();
env.editor.setContent("<p>new <i>story</i></p>");
assert.equal(env.editor.getContent(), "<p>new <em>story</em></p>");
assert.equal(env.textarea.value, "<p>new <em>story</em></p>");
});
test("the form sends the editor's content", () => {
const env = editorWindow("<p>a</p>");
env.box.innerHTML = "<p>changed without an input event</p>";
env.textarea.form.dispatchEvent(new env.window.Event("submit", { cancelable: true }));
assert.equal(env.textarea.value, "<p>changed without an input event</p>");
});
test("bold and italic use the browser's commands", () => {
const env = editorWindow("<p>word</p>");
selectText(env.window, env.box.querySelector("p"));
button(env.document, "Bold").click();
button(env.document, "Italic").click();
assert.deepEqual(env.window.commands.filter((c) => c[0] !== "styleWithCSS" && c[0] !== "defaultParagraphSeparator"),
[["bold", null], ["italic", null]]);
});
test("the alignment buttons set and clear text-align", () => {
const env = editorWindow("<p>one</p><p>two</p>");
const [one, two] = env.box.querySelectorAll("p");
select(env.window, one.firstChild, 0, two.firstChild, 3);
button(env.document, "Align center").click();
assert.equal(env.textarea.value, '<p style="text-align: center;">one</p>\n<p style="text-align: center;">two</p>');
button(env.document, "Align right").click();
assert.equal(env.textarea.value, '<p style="text-align: right;">one</p>\n<p style="text-align: right;">two</p>');
button(env.document, "Align right").click();
assert.equal(env.textarea.value, "<p>one</p>\n<p>two</p>");
});
test("an old align attribute is replaced", () => {
const env = editorWindow('<p align="right">x</p>');
select(env.window, env.box.querySelector("p").firstChild, 0);
button(env.document, "Justify").click();
assert.equal(env.textarea.value, '<p style="text-align: justify;">x</p>');
});
test("indent steps by 30 pixels and never goes below zero", () => {
const env = editorWindow("<p>x</p>");
select(env.window, env.box.querySelector("p").firstChild, 0);
button(env.document, "Increase indent").click();
button(env.document, "Increase indent").click();
assert.equal(env.textarea.value, '<p style="padding-left: 60px;">x</p>');
button(env.document, "Decrease indent").click();
assert.equal(env.textarea.value, '<p style="padding-left: 30px;">x</p>');
button(env.document, "Decrease indent").click();
button(env.document, "Decrease indent").click();
assert.equal(env.textarea.value, "<p>x</p>");
});
test("indent in a list uses the browser's command", () => {
const env = editorWindow("<ul><li>a</li><li>b</li></ul>");
select(env.window, env.box.querySelectorAll("li")[1].firstChild, 0);
button(env.document, "Increase indent").click();
assert.deepEqual(env.window.commands.at(-1), ["indent", null]);
});
test("blockquote wraps the selected paragraphs, and unwraps them", () => {
const env = editorWindow("<p>one</p><p>two</p><p>three</p>");
const ps = env.box.querySelectorAll("p");
select(env.window, ps[0].firstChild, 1, ps[1].firstChild, 1);
button(env.document, "Blockquote").click();
assert.equal(env.textarea.value, "<blockquote>\n<p>one</p>\n<p>two</p>\n</blockquote>\n<p>three</p>");
select(env.window, ps[0].firstChild, 1);
button(env.document, "Blockquote").click();
assert.equal(env.textarea.value, "<p>one</p>\n<p>two</p>\n<p>three</p>");
});
test("the link dialog adds a link", () => {
const env = editorWindow("<p>go here</p>");
const text = env.box.querySelector("p").firstChild;
select(env.window, text, 3, text, 7);
button(env.document, "Insert/edit link").click();
const dialog = env.document.querySelector(".xed-window[role=dialog]");
assert.ok(dialog);
assert.equal(dialog.querySelector(".xed-title").textContent, "Insert link");
const inputs = dialog.querySelectorAll("input");
assert.equal(inputs.length, 3);
assert.equal(inputs[1].value, "here");
assert.equal(env.document.activeElement, inputs[0]);
inputs[0].value = "www.example.org";
inputs[1].value = "there";
inputs[2].value = "A title";
dialog.querySelector(".xed-primary").click();
assert.equal(env.document.querySelector(".xed-window"), null);
assert.equal(env.textarea.value, '<p>go <a href="http://www.example.org" title="A title">there</a></p>');
});
test("the link dialog makes email addresses into mailto links", () => {
const env = editorWindow("<p>x</p>");
select(env.window, env.box.querySelector("p").firstChild, 1);
button(env.document, "Insert/edit link").click();
const inputs = env.document.querySelectorAll(".xed-window input");
inputs[0].value = "ed@example.org";
key(env.window, inputs[0], "Enter");
assert.equal(env.textarea.value, '<p>x<a href="mailto:ed@example.org">ed@example.org</a></p>');
});
test("the link dialog refuses unsafe links", () => {
const env = editorWindow("<p>x</p>");
select(env.window, env.box.querySelector("p").firstChild, 1);
button(env.document, "Insert/edit link").click();
env.document.querySelector(".xed-window input").value = "javascript:alert(1)";
env.document.querySelector(".xed-primary").click();
assert.equal(env.textarea.value, "<p>x</p>");
});
test("the link dialog edits and removes a link", () => {
const env = editorWindow('<p><a href="https://old.example/">old</a></p>');
select(env.window, env.box.querySelector("a").firstChild, 1);
button(env.document, "Insert/edit link").click();
let inputs = env.document.querySelectorAll(".xed-window input");
assert.equal(inputs[0].value, "https://old.example/");
inputs[0].value = "https://new.example/";
env.document.querySelector(".xed-primary").click();
assert.equal(env.textarea.value, '<p><a href="https://new.example/">old</a></p>');
select(env.window, env.box.querySelector("a").firstChild, 1);
button(env.document, "Insert/edit link").click();
env.document.querySelector(".xed-window input").value = "";
env.document.querySelector(".xed-primary").click();
assert.equal(env.textarea.value, "<p>old</p>");
});
test("Escape and Cancel close a dialog without a change", () => {
const env = editorWindow("<p>x</p>");
select(env.window, env.box.querySelector("p").firstChild, 1);
button(env.document, "Insert/edit link").click();
key(env.window, env.document.querySelector(".xed-window input"), "Escape");
assert.equal(env.document.querySelector(".xed-window"), null);
assert.equal(env.document.querySelector(".xed-modal-block"), null);
button(env.document, "Insert/edit link").click();
env.document.querySelector(".xed-window input").value = "https://example.org/";
Array.from(env.document.querySelectorAll(".xed-button")).find((b) => b.textContent === "Cancel").click();
assert.equal(env.document.querySelector(".xed-window"), null);
assert.equal(env.textarea.value, "<p>x</p>");
});
test("a click beside the link dialog does not close it", () => {
const env = editorWindow("<p>x</p>");
button(env.document, "Insert/edit link").click();
env.document.querySelector(".xed-modal-block").click();
assert.ok(env.document.querySelector(".xed-window"));
});
test("remove link unwraps the link at the caret", () => {
const env = editorWindow('<p>a <a href="https://example.org/">link</a> b</p>');
select(env.window, env.box.querySelector("a").firstChild, 2);
button(env.document, "Remove link").click();
assert.equal(env.textarea.value, "<p>a link b</p>");
});
test("the special character window inserts a character", () => {
const env = editorWindow("<p>x</p>");
select(env.window, env.box.querySelector("p").firstChild, 1);
button(env.document, "Special character").click();
const cells = env.document.querySelectorAll(".xed-charmap td");
assert.equal(cells.length, 250);
assert.equal(env.document.querySelectorAll(".xed-charmap tr").length, 10);
const euro = Array.from(cells).find((c) => c.title === "euro sign");
euro.dispatchEvent(new env.window.MouseEvent("mouseover"));
assert.equal(env.document.querySelector(".xed-charmap-char").textContent, "€");
assert.equal(env.document.querySelector(".xed-charmap-name").textContent, "euro sign");
euro.click();
assert.equal(env.document.querySelector(".xed-window"), null);
assert.deepEqual(env.window.commands.at(-1), ["insertText", "€"]);
});
test("the special character window answers the keyboard", () => {
const env = editorWindow("<p>x</p>");
button(env.document, "Special character").click();
const cell = env.document.querySelector(".xed-charmap td");
assert.equal(cell.tabIndex, 0);
key(env.window, cell, "Enter");
assert.deepEqual(env.window.commands.at(-1), ["insertText", " "]);
});
test("a click outside the special character window closes it", () => {
const env = editorWindow("<p>x</p>");
button(env.document, "Special character").click();
const backdrop = env.document.querySelector(".xed-modal-block");
assert.ok(backdrop.classList.contains("xed-modal-light"));
backdrop.click();
assert.equal(env.document.querySelector(".xed-window"), null);
assert.equal(env.document.querySelector(".xed-modal-block"), null);
assert.equal(env.window.commands.filter((c) => c[0] === "insertText").length, 0);
});
test("the source button swaps the editor for the textarea and back", () => {
const env = editorWindow("<p>a</p>");
const code = button(env.document, "Source code");
code.click();
assert.equal(env.textarea.hidden, false);
assert.equal(env.textarea.style.display, "");
assert.equal(env.box.hidden, true);
assert.equal(code.getAttribute("aria-pressed"), "true");
assert.ok(code.classList.contains("xed-active"));
for (const b of env.document.querySelectorAll(".xed-btn"))
assert.equal(b.disabled, b !== code);
assert.equal(env.document.querySelector(".xed-window"), null, "no source window");
env.textarea.value = '<p onclick="x()">edited</p><script>bad()</script>';
assert.equal(env.editor.getContent(), env.textarea.value, "the textarea is the story in source mode");
code.click();
assert.equal(env.box.hidden, false);
assert.equal(env.textarea.hidden, true);
assert.equal(code.getAttribute("aria-pressed"), "false");
assert.ok(Array.from(env.document.querySelectorAll(".xed-btn")).every((b) => !b.disabled));
assert.equal(env.textarea.value, "<p>edited</p>");
});
test("setContent leaves source mode", () => {
const env = editorWindow("<p>a</p>");
button(env.document, "Source code").click();
env.editor.setContent("<p>b</p>");
assert.equal(env.box.hidden, false);
assert.equal(env.editor.getContent(), "<p>b</p>");
});
test("undo and redo, with the keys", () => {
const env = editorWindow("<p>x</p>");
select(env.window, env.box.querySelector("p").firstChild, 0);
button(env.document, "Align center").click();
assert.equal(env.textarea.value, '<p style="text-align: center;">x</p>');
assert.ok(key(env.window, env.box, "z", { ctrlKey: true }).defaultPrevented);
assert.equal(env.textarea.value, "<p>x</p>");
key(env.window, env.box, "y", { ctrlKey: true });
assert.equal(env.textarea.value, '<p style="text-align: center;">x</p>');
key(env.window, env.box, "z", { metaKey: true });
key(env.window, env.box, "Z", { ctrlKey: true, shiftKey: true });
assert.equal(env.textarea.value, '<p style="text-align: center;">x</p>');
});
test("undo keeps a run of typing as one step", () => {
const env = editorWindow("<p>a</p>");
type(env, "<p>ab</p>");
type(env, "<p>abc</p>");
key(env.window, env.box, "z", { ctrlKey: true });
assert.equal(env.textarea.value, "<p>a</p>");
});
test("undo from the browser's history input", () => {
const env = editorWindow("<p>a</p>");
type(env, "<p>b</p>");
const event = new env.window.InputEvent("beforeinput", { inputType: "historyUndo", bubbles: true, cancelable: true });
env.box.dispatchEvent(event);
assert.ok(event.defaultPrevented);
assert.equal(env.textarea.value, "<p>a</p>");
});
test("the shortcut keys run the toolbar commands", () => {
const env = editorWindow("<p>x</p>");
selectText(env.window, env.box.querySelector("p"));
assert.ok(key(env.window, env.box, "b", { ctrlKey: true }).defaultPrevented);
assert.deepEqual(env.window.commands.at(-1), ["bold", null]);
key(env.window, env.box, "i", { metaKey: true });
assert.deepEqual(env.window.commands.at(-1), ["italic", null]);
key(env.window, env.box, "k", { ctrlKey: true });
assert.ok(env.document.querySelector(".xed-window"));
});
test("other keys are left to the browser", () => {
const env = editorWindow("<p>x</p>");
assert.equal(key(env.window, env.box, "b").defaultPrevented, false);
assert.equal(key(env.window, env.box, "b", { ctrlKey: true, altKey: true }).defaultPrevented, false);
assert.equal(key(env.window, env.box, "c", { ctrlKey: true }).defaultPrevented, false);
});
test("pasted HTML is cleaned first", () => {
const env = editorWindow("<p>x</p>");
const event = paste(env, { "text/html": '<p style="color: red" class="MsoNormal"><b>Bold</b><script>x()</script></p>' });
assert.ok(event.defaultPrevented);
assert.deepEqual(env.window.commands.at(-1), ["insertHTML", "<p><strong>Bold</strong></p>"]);
});
test("pasted text keeps its paragraphs and line breaks", () => {
const env = editorWindow("<p>x</p>");
env.window.commands.length = 0;
paste(env, { "text/plain": "one\r\ntwo\n\nthree" });
assert.deepEqual(env.window.commands, [["insertText", "one"], ["insertLineBreak", null], ["insertText", "two"],
["insertParagraph", null], ["insertText", "three"]]);
});
+89
View File
@@ -0,0 +1,89 @@
/*
* Load the site's JavaScript into a jsdom window. jsdom has no editing
* commands, so document.execCommand records its calls in window.commands
* (and does nothing else), and queryCommandState throws, as it does in
* some browsers.
*/
"use strict";
const fs = require("node:fs");
const path = require("node:path");
const { JSDOM } = require("jsdom");
const ROOT = path.resolve(__dirname, "..", "..");
// The story field of publish/write.inc.php, in a form
const STORY_FORM = `
<form id="form">
<label for="title">Title:</label><input id="title" name="title">
<label for="author">Author:</label><input id="author" name="author">
<label for="email">Email address:</label><input id="email" name="email">
<label for="story">Story:</label>
<div id="textparent"><textarea id="story" name="story"></textarea></div>
<div id="uploadparent" style="display: none;"><input type="file" id="upload" name="upload"></div>
<span id="wordcounter" class="counter" style="display: none;">0 words</span>
</form>`;
function load(scripts, body, story) {
const dom = new JSDOM(`<!DOCTYPE html><html><body>${body}</body></html>`, {
runScripts: "outside-only",
url: "https://xxx.org.za/publish/",
pretendToBeVisual: true,
});
const window = dom.window;
window.commands = [];
window.document.execCommand = function (command, ui, value) {
window.commands.push([command, value]);
return true;
};
window.document.queryCommandState = function () {
throw new Error("not supported");
};
if (story !== undefined) window.document.getElementById("story").value = story;
for (const script of scripts) window.eval(fs.readFileSync(path.join(ROOT, script), "utf8"));
return window;
}
// A window with the story form and publish/editor.js, and the editor attached
function editorWindow(story = "") {
const window = load(["publish/editor.js"], STORY_FORM, story);
const textarea = window.document.getElementById("story");
const editor = window.XxxEditor.attach(textarea);
const box = window.document.querySelector(".xed-area");
return { window, document: window.document, textarea, editor, box };
}
// Select from (node, offset) to (endNode, endOffset), or put the caret there
function select(window, node, offset, endNode = node, endOffset = offset) {
const range = window.document.createRange();
range.setStart(node, offset);
range.setEnd(endNode, endOffset);
const selection = window.getSelection();
selection.removeAllRanges();
selection.addRange(range);
return range;
}
// Select all the text of an element
function selectText(window, element) {
const range = window.document.createRange();
range.selectNodeContents(element);
const selection = window.getSelection();
selection.removeAllRanges();
selection.addRange(range);
return range;
}
function button(document, label) {
const found = document.querySelector(`.xed-btn[aria-label="${label}"]`);
if (!found) throw new Error(`No button "${label}"`);
return found;
}
function key(window, target, key, options = {}) {
const event = new window.KeyboardEvent("keydown", Object.assign({ key, bubbles: true, cancelable: true }, options));
target.dispatchEvent(event);
return event;
}
module.exports = { ROOT, STORY_FORM, load, editorWindow, select, selectText, button, key };
+177
View File
@@ -0,0 +1,177 @@
// publish/utilities.js (word count, tabs, form checks) and utilities.js (the phone menu)
"use strict";
const test = require("node:test");
const assert = require("node:assert/strict");
const { load, STORY_FORM } = require("./helpers");
const FORM = STORY_FORM + `
<input type="hidden" id="remove_img" value="0">
<img id="remove_img_icon" src="/graphics/trashcan.png">`;
function publishWindow(story = "") {
const window = load(["publish/utilities.js"], FORM, story);
window.alerts = [];
window.confirms = [];
window.confirmAnswer = true;
window.alert = (message) => window.alerts.push(message);
window.confirm = (message) => {
window.confirms.push(message);
return window.confirmAnswer;
};
return window;
}
const window = publishWindow();
test("wc counts words", () => {
assert.equal(window.wc(""), 0);
assert.equal(window.wc(" "), 0);
assert.equal(window.wc("one"), 1);
assert.equal(window.wc("one two three"), 3);
assert.equal(window.wc(" padded words "), 2);
});
test("wc leaves out the HTML", () => {
assert.equal(window.wc("<p>one <strong>two</strong></p>\n<p>three</p>"), 3);
assert.equal(window.wc("one<br />two<br>three"), 3);
assert.equal(window.wc("<p>&nbsp;</p>"), 0);
assert.equal(window.wc('<a href="https://example.org/a b c">link</a>'), 1);
});
test("wc counts the words around a dash as two", () => {
assert.equal(window.wc("left--right"), 2);
assert.equal(window.wc("left &mdash; right"), 2);
assert.equal(window.wc("left&ndash;right"), 2);
assert.equal(window.wc("well-known"), 1);
});
test("wc does not count punctuation on its own", () => {
assert.equal(window.wc("one - two ... three"), 3);
});
test("wc counts one hundred words", () => {
assert.equal(window.wc("<p>" + "word ".repeat(100) + "</p>"), 100);
});
test("the word counter shows the count and whether it is 100", () => {
const w = publishWindow();
const story = w.document.getElementById("story");
const counter = w.document.getElementById("wordcounter");
story.value = "one";
assert.equal(w.updateWordCount(), true);
assert.equal(counter.innerHTML, "1&nbsp;word");
assert.equal(counter.className, "counter");
story.value = "word ".repeat(100);
w.updateWordCount();
assert.equal(counter.innerHTML, "100&nbsp;words");
assert.equal(counter.className, "counter yes");
story.value = "word ".repeat(101);
w.updateWordCount();
assert.equal(counter.className, "counter no");
});
test("the word counter does nothing without its elements", () => {
const w = load(["publish/utilities.js"], "");
assert.equal(w.updateWordCount(), true);
});
test("showHide switches between the text and the image tabs", () => {
const w = publishWindow();
const $ = (id) => w.document.getElementById(id);
let focused = 0;
w.storyEditor = { focus: () => focused++ };
assert.equal(w.showHide("upload"), true);
assert.deepEqual([$("textparent").style.display, $("uploadparent").style.display, $("wordcounter").style.display],
["none", "block", "none"]);
assert.equal(w.showHide("text"), true);
assert.deepEqual([$("textparent").style.display, $("uploadparent").style.display, $("wordcounter").style.display],
["block", "none", "block"]);
assert.equal(focused, 1);
assert.equal(w.showHide("other"), false);
});
test("review asks for a title, then an author", () => {
const w = publishWindow("<p>words</p>");
assert.equal(w.review(), false);
assert.match(w.alerts[0], /add a title/);
w.document.getElementById("title").value = "T";
assert.equal(w.review(), false);
assert.match(w.alerts[1], /haven't added an author/);
});
test("review asks about a missing email address", () => {
const w = publishWindow("word ".repeat(100));
w.document.getElementById("title").value = "T";
w.document.getElementById("author").value = "A";
w.confirmAnswer = false;
assert.equal(w.review(), false);
assert.match(w.confirms[0], /haven't added an email address/);
w.confirmAnswer = true;
assert.equal(w.review(), true);
});
test("review stops an empty story and warns about the length", () => {
const w = publishWindow("");
for (const [id, value] of [["title", "T"], ["author", "A"], ["email", "a@example.org"]])
w.document.getElementById(id).value = value;
assert.equal(w.review(), false);
assert.match(w.alerts[0], /haven't submitted anything/);
w.document.getElementById("story").value = "<p>only a few words</p>";
w.confirmAnswer = false;
assert.equal(w.review(), false);
assert.match(w.confirms[0], /doesn't appear to be 100 words long/);
w.confirmAnswer = true;
assert.equal(w.review(), true);
w.confirms.length = 0;
w.document.getElementById("story").value = "word ".repeat(100);
assert.equal(w.review(), true);
assert.deepEqual(w.confirms, []);
});
test("delete asks first, and then skips the review", () => {
const w = publishWindow("");
w.confirmAnswer = false;
assert.equal(w.do_delete(), false);
assert.equal(w.eval("is_delete"), 0);
w.confirmAnswer = true;
assert.equal(w.do_delete(), true);
assert.equal(w.review(), true);
assert.deepEqual(w.alerts, []);
});
test("toggle_remove marks the image for removal and back", () => {
const w = publishWindow();
const flag = w.document.getElementById("remove_img");
const icon = w.document.getElementById("remove_img_icon");
w.toggle_remove();
assert.equal(flag.value, "1");
assert.equal(icon.getAttribute("src"), "/graphics/trashcan-open.png");
assert.equal(icon.title, "Image is marked for removal.");
w.toggle_remove();
assert.equal(flag.value, "0");
assert.equal(icon.getAttribute("src"), "/graphics/trashcan.png");
assert.equal(icon.title, "Image has been restored; it will not be removed.");
});
test("the phone menu closes on Escape and on a click outside it", () => {
const w = load(["utilities.js"], `
<aside id="sidebar"><input class="mobile menu-toggle" type="checkbox"><a id="inside" href="#">x</a></aside>
<main id="outside">text</main>`);
w.document.dispatchEvent(new w.Event("DOMContentLoaded"));
const toggle = w.document.querySelector("input.menu-toggle");
toggle.checked = true;
w.document.getElementById("inside").dispatchEvent(new w.MouseEvent("click", { bubbles: true }));
assert.equal(toggle.checked, true, "a click in the menu keeps it open");
w.document.getElementById("outside").dispatchEvent(new w.MouseEvent("click", { bubbles: true }));
assert.equal(toggle.checked, false);
toggle.checked = true;
w.document.dispatchEvent(new w.KeyboardEvent("keyup", { key: "a", keyCode: 65, bubbles: true }));
assert.equal(toggle.checked, true);
w.document.dispatchEvent(new w.KeyboardEvent("keyup", { key: "Escape", keyCode: 27, bubbles: true }));
assert.equal(toggle.checked, false);
});