diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..88bb3ed --- /dev/null +++ b/package-lock.json @@ -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" + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..f25c425 --- /dev/null +++ b/package.json @@ -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" + } +} diff --git a/tests/js/editor-html.test.js b/tests/js/editor-html.test.js new file mode 100644 index 0000000..f257a7c --- /dev/null +++ b/tests/js/editor-html.test.js @@ -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("

One

\n

Two bold it u x2 H2O

"), + "

One

\n

Two bold it u x2 H2O

"); +}); + +test("b and i become strong and em", () => { + assert.equal(clean("

b i

"), "

b i

"); +}); + +test("scripts, styles and frames are dropped with their contents", () => { + assert.equal(clean("

ab

"), "

ab

"); +}); + +test("event handlers and unknown attributes are dropped", () => { + assert.equal(clean('

a

'), "

a

"); +}); + +test("unknown tags keep their contents", () => { + assert.equal(clean("

red plain

"), "

red plain

"); +}); + +test("headings become paragraphs", () => { + assert.equal(clean("

Title

"), "

Title

"); +}); + +test("only safe links are kept", () => { + assert.equal(clean('

x

'), + '

x

'); + assert.equal(clean('

m r h

'), + '

m r h

'); + assert.equal(clean('

x

'), "

x

"); + assert.equal(clean('

x

'), "

x

"); + assert.equal(clean('

x

'), "

x

"); +}); + +test("only alignment and indent styles are kept", () => { + assert.equal(clean('

c

'), '

c

'); + assert.equal(clean('

i

'), '

i

'); + assert.equal(clean('

x

'), "

x

"); + assert.equal(clean('

x

'), "

x

"); + assert.equal(clean('

x

'), '

x

'); +}); + +test("the align attribute is kept only with a real alignment", () => { + assert.equal(clean('

c

'), '

c

'); + assert.equal(clean('

c

'), "

c

"); +}); + +test("text is escaped", () => { + assert.equal(clean("

a < b & c > d \"q\"

"), "

a < b & c > d \"q\"

"); +}); + +test("line breaks, rules and no-break spaces are written as TinyMCE wrote them", () => { + assert.equal(clean("

one
two


a b

"), "

one
two

\n
\n

a b

"); +}); + +test("an empty paragraph is written as  ", () => { + assert.equal(clean("

a


b

"), "

a

\n

 

\n

 

\n

b

"); +}); + +test("a trailing line break in a block is dropped", () => { + assert.equal(clean("

a

"), "

a

"); +}); + +test("an editor with no words gives an empty story", () => { + assert.equal(clean(""), ""); + assert.equal(clean("


"), ""); + assert.equal(clean("

 

​

"), ""); + assert.equal(clean("
"), "
"); +}); + +test("blockquotes and lists put their blocks on new lines", () => { + assert.equal(clean("

q

"), "
\n

q

\n
"); + assert.equal(clean("
  1. c
"), "\n
    \n
  1. c
  2. \n
"); +}); + +test("whitespace between blocks is dropped", () => { + assert.equal(clean("

a

\n\n

b

"), "

a

\n

b

"); +}); + +test("parse does not run anything", () => { + window.hacked = false; + parse(''); + assert.equal(window.hacked, false); +}); diff --git a/tests/js/editor.test.js b/tests/js/editor.test.js new file mode 100644 index 0000000..9c5fab3 --- /dev/null +++ b/tests/js/editor.test.js @@ -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("

Hello

"); + 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, "

Hello

"); +}); + +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('

a

loose text'); + assert.equal(env.textarea.value, "

a

\n

loose text

"); + 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, "


"); + 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, "

Some words

"); + assert.equal(env.textarea.value, "

Some words

"); + assert.equal(inputs, 1); + type(env, "

Some words

"); + 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 text

para

more"); + assert.equal(env.textarea.value, "

loose text

\n

para

\n

more

"); +}); + +test("deleting everything starts again with a plain paragraph", () => { + const env = editorWindow("

bold

"); + type(env, "


", "deleteContentBackward"); + assert.equal(env.box.innerHTML, "


"); +}); + +test("setContent and getContent", () => { + const env = editorWindow(); + env.editor.setContent("

new story

"); + assert.equal(env.editor.getContent(), "

new story

"); + assert.equal(env.textarea.value, "

new story

"); +}); + +test("the form sends the editor's content", () => { + const env = editorWindow("

a

"); + env.box.innerHTML = "

changed without an input event

"; + env.textarea.form.dispatchEvent(new env.window.Event("submit", { cancelable: true })); + assert.equal(env.textarea.value, "

changed without an input event

"); +}); + +test("bold and italic use the browser's commands", () => { + const env = editorWindow("

word

"); + 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("

one

two

"); + 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, '

one

\n

two

'); + button(env.document, "Align right").click(); + assert.equal(env.textarea.value, '

one

\n

two

'); + button(env.document, "Align right").click(); + assert.equal(env.textarea.value, "

one

\n

two

"); +}); + +test("an old align attribute is replaced", () => { + const env = editorWindow('

x

'); + select(env.window, env.box.querySelector("p").firstChild, 0); + button(env.document, "Justify").click(); + assert.equal(env.textarea.value, '

x

'); +}); + +test("indent steps by 30 pixels and never goes below zero", () => { + const env = editorWindow("

x

"); + 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, '

x

'); + button(env.document, "Decrease indent").click(); + assert.equal(env.textarea.value, '

x

'); + button(env.document, "Decrease indent").click(); + button(env.document, "Decrease indent").click(); + assert.equal(env.textarea.value, "

x

"); +}); + +test("indent in a list uses the browser's command", () => { + const env = editorWindow(""); + 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("

one

two

three

"); + 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, "
\n

one

\n

two

\n
\n

three

"); + select(env.window, ps[0].firstChild, 1); + button(env.document, "Blockquote").click(); + assert.equal(env.textarea.value, "

one

\n

two

\n

three

"); +}); + +test("the link dialog adds a link", () => { + const env = editorWindow("

go here

"); + 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, '

go there

'); +}); + +test("the link dialog makes email addresses into mailto links", () => { + const env = editorWindow("

x

"); + 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, '

xed@example.org

'); +}); + +test("the link dialog refuses unsafe links", () => { + const env = editorWindow("

x

"); + 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, "

x

"); +}); + +test("the link dialog edits and removes a link", () => { + const env = editorWindow('

old

'); + 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, '

old

'); + + 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, "

old

"); +}); + +test("Escape and Cancel close a dialog without a change", () => { + const env = editorWindow("

x

"); + 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, "

x

"); +}); + +test("a click beside the link dialog does not close it", () => { + const env = editorWindow("

x

"); + 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('

a link b

'); + select(env.window, env.box.querySelector("a").firstChild, 2); + button(env.document, "Remove link").click(); + assert.equal(env.textarea.value, "

a link b

"); +}); + +test("the special character window inserts a character", () => { + const env = editorWindow("

x

"); + 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("

x

"); + 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("

x

"); + 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("

a

"); + 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 = '

edited

'; + 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, "

edited

"); +}); + +test("setContent leaves source mode", () => { + const env = editorWindow("

a

"); + button(env.document, "Source code").click(); + env.editor.setContent("

b

"); + assert.equal(env.box.hidden, false); + assert.equal(env.editor.getContent(), "

b

"); +}); + +test("undo and redo, with the keys", () => { + const env = editorWindow("

x

"); + select(env.window, env.box.querySelector("p").firstChild, 0); + button(env.document, "Align center").click(); + assert.equal(env.textarea.value, '

x

'); + assert.ok(key(env.window, env.box, "z", { ctrlKey: true }).defaultPrevented); + assert.equal(env.textarea.value, "

x

"); + key(env.window, env.box, "y", { ctrlKey: true }); + assert.equal(env.textarea.value, '

x

'); + key(env.window, env.box, "z", { metaKey: true }); + key(env.window, env.box, "Z", { ctrlKey: true, shiftKey: true }); + assert.equal(env.textarea.value, '

x

'); +}); + +test("undo keeps a run of typing as one step", () => { + const env = editorWindow("

a

"); + type(env, "

ab

"); + type(env, "

abc

"); + key(env.window, env.box, "z", { ctrlKey: true }); + assert.equal(env.textarea.value, "

a

"); +}); + +test("undo from the browser's history input", () => { + const env = editorWindow("

a

"); + type(env, "

b

"); + 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, "

a

"); +}); + +test("the shortcut keys run the toolbar commands", () => { + const env = editorWindow("

x

"); + 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("

x

"); + 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("

x

"); + const event = paste(env, { "text/html": '

Bold

' }); + assert.ok(event.defaultPrevented); + assert.deepEqual(env.window.commands.at(-1), ["insertHTML", "

Bold

"]); +}); + +test("pasted text keeps its paragraphs and line breaks", () => { + const env = editorWindow("

x

"); + 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"]]); +}); diff --git a/tests/js/helpers.js b/tests/js/helpers.js new file mode 100644 index 0000000..ea0e7c4 --- /dev/null +++ b/tests/js/helpers.js @@ -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 = ` +
+ + + + +
+ + +
`; + +function load(scripts, body, story) { + const dom = new JSDOM(`${body}`, { + 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 }; diff --git a/tests/js/utilities.test.js b/tests/js/utilities.test.js new file mode 100644 index 0000000..16cd2be --- /dev/null +++ b/tests/js/utilities.test.js @@ -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 + ` + + `; + +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("

one two

\n

three

"), 3); + assert.equal(window.wc("one
two
three"), 3); + assert.equal(window.wc("

 

"), 0); + assert.equal(window.wc('link'), 1); +}); + +test("wc counts the words around a dash as two", () => { + assert.equal(window.wc("left--right"), 2); + assert.equal(window.wc("left — right"), 2); + assert.equal(window.wc("left–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("

" + "word ".repeat(100) + "

"), 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 word"); + assert.equal(counter.className, "counter"); + story.value = "word ".repeat(100); + w.updateWordCount(); + assert.equal(counter.innerHTML, "100 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("

words

"); + 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 = "

only a few words

"; + 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"], ` + +
text
`); + 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); +});