diff --git a/package-lock.json b/package-lock.json index 96e059f..4ee8e10 100644 --- a/package-lock.json +++ b/package-lock.json @@ -22,6 +22,7 @@ "eslint-plugin-react": "^7.37.2", "eslint-plugin-react-hooks": "^5.1.0", "husky": "^9.1.7", + "jsdom": "^26.1.0", "lint-staged": "^15.2.10", "prettier": "^3.4.2", "tsup": "^8.3.5", @@ -32,6 +33,27 @@ "node": ">=20" } }, + "node_modules/@asamuzakjp/css-color": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-3.2.0.tgz", + "integrity": "sha512-K1A6z8tS3XsmCMM86xoWdn7Fkdn9m6RSVtocUrJYIwZnFVkng/PvkEoWtOWmP+Scc6saYWHWZYbndEEXxl24jw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@csstools/css-calc": "^2.1.3", + "@csstools/css-color-parser": "^3.0.9", + "@csstools/css-parser-algorithms": "^3.0.4", + "@csstools/css-tokenizer": "^3.0.3", + "lru-cache": "^10.4.3" + } + }, + "node_modules/@asamuzakjp/css-color/node_modules/lru-cache": { + "version": "10.4.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", + "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", + "dev": true, + "license": "ISC" + }, "node_modules/@babel/code-frame": { "version": "7.29.0", "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz", @@ -1705,6 +1727,121 @@ "node": ">=v18" } }, + "node_modules/@csstools/color-helpers": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-5.1.0.tgz", + "integrity": "sha512-S11EXWJyy0Mz5SYvRmY8nJYTFFd1LCNV+7cXyAgQtOOuzb4EsgfqDufL+9esx72/eLhsRdGZwaldu/h+E4t4BA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "engines": { + "node": ">=18" + } + }, + "node_modules/@csstools/css-calc": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-2.1.4.tgz", + "integrity": "sha512-3N8oaj+0juUw/1H3YwmDDJXCgTB1gKU6Hc/bB502u9zR0q2vd786XJH9QfrKIEgFlZmhZiq6epXl4rHqhzsIgQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^3.0.5", + "@csstools/css-tokenizer": "^3.0.4" + } + }, + "node_modules/@csstools/css-color-parser": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-3.1.0.tgz", + "integrity": "sha512-nbtKwh3a6xNVIp/VRuXV64yTKnb1IjTAEEh3irzS+HkKjAOYLTGNb9pmVNntZ8iVBHcWDA2Dof0QtPgFI1BaTA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "dependencies": { + "@csstools/color-helpers": "^5.1.0", + "@csstools/css-calc": "^2.1.4" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^3.0.5", + "@csstools/css-tokenizer": "^3.0.4" + } + }, + "node_modules/@csstools/css-parser-algorithms": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-3.0.5.tgz", + "integrity": "sha512-DaDeUkXZKjdGhgYaHNJTV9pV7Y9B3b644jCLs9Upc3VeNGg6LWARAT6O+Q+/COo+2gg/bM5rhpMAtf70WqfBdQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@csstools/css-tokenizer": "^3.0.4" + } + }, + "node_modules/@csstools/css-tokenizer": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-3.0.4.tgz", + "integrity": "sha512-Vd/9EVDiu6PPJt9yAh6roZP6El1xHrdvIVGjyBsHR0RYwNHgL7FJPyIIW4fANJNG6FtyZfvlRPpFI4ZM/lubvw==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/@esbuild/aix-ppc64": { "version": "0.27.7", "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.7.tgz", @@ -6761,6 +6898,20 @@ "url": "https://github.com/sponsors/fb55" } }, + "node_modules/cssstyle": { + "version": "4.6.0", + "resolved": "https://registry.npmjs.org/cssstyle/-/cssstyle-4.6.0.tgz", + "integrity": "sha512-2z+rWdzbbSZv6/rhtvzvqeZQHrBaqgogqt85sqFNbabZOuFbCVFb8kPeEtZjiKkbrm395irpNKiYeFeLiQnFPg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/css-color": "^3.2.0", + "rrweb-cssom": "^0.8.0" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/csstype": { "version": "3.2.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", @@ -6781,6 +6932,20 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/data-urls": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-5.0.0.tgz", + "integrity": "sha512-ZYP5VBHshaDAiVZxjbRVcFJpc+4xGgT0bK3vzy1HLN8jTO975HEbuYzZJcHoQEY5K1a0z8YayJkyVETa08eNTg==", + "dev": true, + "license": "MIT", + "dependencies": { + "whatwg-mimetype": "^4.0.0", + "whatwg-url": "^14.0.0" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/data-view-buffer": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/data-view-buffer/-/data-view-buffer-1.0.2.tgz", @@ -6853,6 +7018,13 @@ } } }, + "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/deep-eql": { "version": "5.0.2", "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.2.tgz", @@ -8859,6 +9031,19 @@ "license": "ISC", "peer": true }, + "node_modules/html-encoding-sniffer": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-4.0.0.tgz", + "integrity": "sha512-Y22oTqIU4uuPgEemfz7NDJz6OeKf12Lsu+QC+s3BVpda64lTiMYCyGwg5ki4vFxkMwQdeZDl2adZoqUgdFuTgQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "whatwg-encoding": "^3.1.1" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/http-errors": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", @@ -8890,6 +9075,20 @@ "node": ">= 0.8" } }, + "node_modules/http-proxy-agent": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", + "integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.0", + "debug": "^4.3.4" + }, + "engines": { + "node": ">= 14" + } + }, "node_modules/https-proxy-agent": { "version": "7.0.6", "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", @@ -8930,6 +9129,19 @@ "url": "https://github.com/sponsors/typicode" } }, + "node_modules/iconv-lite": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", + "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/ignore": { "version": "7.0.5", "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.5.tgz", @@ -9352,6 +9564,13 @@ "node": ">=8" } }, + "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/is-regex": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.2.1.tgz", @@ -9767,6 +9986,46 @@ "dev": true, "license": "0BSD" }, + "node_modules/jsdom": { + "version": "26.1.0", + "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-26.1.0.tgz", + "integrity": "sha512-Cvc9WUhxSMEo4McES3P7oK3QaXldCfNWp7pl2NNeiIFlCoLr3kfq9kb1fxftiwk1FLV7CvpvDfonxtzUDeSOPg==", + "dev": true, + "license": "MIT", + "dependencies": { + "cssstyle": "^4.2.1", + "data-urls": "^5.0.0", + "decimal.js": "^10.5.0", + "html-encoding-sniffer": "^4.0.0", + "http-proxy-agent": "^7.0.2", + "https-proxy-agent": "^7.0.6", + "is-potential-custom-element-name": "^1.0.1", + "nwsapi": "^2.2.16", + "parse5": "^7.2.1", + "rrweb-cssom": "^0.8.0", + "saxes": "^6.0.0", + "symbol-tree": "^3.2.4", + "tough-cookie": "^5.1.1", + "w3c-xmlserializer": "^5.0.0", + "webidl-conversions": "^7.0.0", + "whatwg-encoding": "^3.1.1", + "whatwg-mimetype": "^4.0.0", + "whatwg-url": "^14.1.1", + "ws": "^8.18.0", + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "canvas": "^3.0.0" + }, + "peerDependenciesMeta": { + "canvas": { + "optional": true + } + } + }, "node_modules/jsesc": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", @@ -11486,6 +11745,13 @@ "dev": true, "license": "MIT" }, + "node_modules/nwsapi": { + "version": "2.2.27", + "resolved": "https://registry.npmjs.org/nwsapi/-/nwsapi-2.2.27.tgz", + "integrity": "sha512-gQPNF78qebCQ6tvVFBYrvJdBNOrYZm90ZlXgpIFm06p6qHDHq/XC4TnJftN6OMbxVE0UTBAoRgcsDeJBBooITw==", + "dev": true, + "license": "MIT" + }, "node_modules/ob1": { "version": "0.83.7", "resolved": "https://registry.npmjs.org/ob1/-/ob1-0.83.7.tgz", @@ -11981,6 +12247,32 @@ "node": ">=10" } }, + "node_modules/parse5": { + "version": "7.3.0", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-7.3.0.tgz", + "integrity": "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "entities": "^6.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, + "node_modules/parse5/node_modules/entities": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz", + "integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, "node_modules/parseurl": { "version": "1.3.3", "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", @@ -12401,9 +12693,9 @@ } }, "node_modules/react": { - "version": "19.2.6", - "resolved": "https://registry.npmjs.org/react/-/react-19.2.6.tgz", - "integrity": "sha512-sfWGGfavi0xr8Pg0sVsyHMAOziVYKgPLNrS7ig+ivMNb3wbCBw3KxtflsGBAwD3gYQlE/AEZsTLgToRrSCjb0Q==", + "version": "19.2.8", + "resolved": "https://registry.npmjs.org/react/-/react-19.2.8.tgz", + "integrity": "sha512-PWaYA1L/q9u2u7xYQi+Y3L3Yfnie7XyLeaJICV1MGD6LprsBxcAqGjYyr0eY3p+QdsA+x/Irkt4Qif8D63+Sbw==", "license": "MIT", "engines": { "node": ">=0.10.0" @@ -12443,15 +12735,15 @@ } }, "node_modules/react-dom": { - "version": "19.2.6", - "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.6.tgz", - "integrity": "sha512-0prMI+hvBbPjsWnxDLxlCGyM8PN6UuWjEUCYmZhO67xIV9Xasa/r/vDnq+Xyq4Lo27g8QSbO5YzARu0D1Sps3g==", + "version": "19.2.8", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.8.tgz", + "integrity": "sha512-rVprimfGBG3DR+Tq0IQG2DT5PxKth1WIGDmj5yPmlzr4YBe7uyE+Du4oVqTDXZSHGGGXRtTJEGSSePyQCMBglQ==", "license": "MIT", "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { - "react": "^19.2.6" + "react": "^19.2.8" } }, "node_modules/react-is": { @@ -13096,6 +13388,13 @@ "dev": true, "license": "MIT" }, + "node_modules/rrweb-cssom": { + "version": "0.8.0", + "resolved": "https://registry.npmjs.org/rrweb-cssom/-/rrweb-cssom-0.8.0.tgz", + "integrity": "sha512-guoltQEx+9aMf2gDZ0s62EcV8lsXR+0w8915TC3ITdn2YueuNjdAYh/levpU9nFaoChh9RUS5ZdQMrKfVEN9tw==", + "dev": true, + "license": "MIT" + }, "node_modules/safe-array-concat": { "version": "1.1.4", "resolved": "https://registry.npmjs.org/safe-array-concat/-/safe-array-concat-1.1.4.tgz", @@ -13173,6 +13472,13 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "dev": true, + "license": "MIT" + }, "node_modules/sax": { "version": "1.6.0", "resolved": "https://registry.npmjs.org/sax/-/sax-1.6.0.tgz", @@ -13184,6 +13490,19 @@ "node": ">=11.0.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/scheduler": { "version": "0.27.0", "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", @@ -13967,6 +14286,13 @@ "url": "https://github.com/sponsors/ljharb" } }, + "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/terminal-link": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/terminal-link/-/terminal-link-2.1.1.tgz", @@ -14187,6 +14513,26 @@ "node": ">=14.0.0" } }, + "node_modules/tldts": { + "version": "6.1.86", + "resolved": "https://registry.npmjs.org/tldts/-/tldts-6.1.86.tgz", + "integrity": "sha512-WMi/OQ2axVTf/ykqCQgXiIct+mSQDFdH2fkwhPwgEwvJ1kSzZRiinb0zF2Xb8u4+OqPChmyI6MEu4EezNJz+FQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "tldts-core": "^6.1.86" + }, + "bin": { + "tldts": "bin/cli.js" + } + }, + "node_modules/tldts-core": { + "version": "6.1.86", + "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-6.1.86.tgz", + "integrity": "sha512-Je6p7pkk+KMzMv2XXKmAE3McmolOQFdxkKw0R8EYNr7sELW46JqnNeTX8ybPiQgvg1ymCoF8LXs5fzFaZvJPTA==", + "dev": true, + "license": "MIT" + }, "node_modules/tmpl": { "version": "1.0.5", "resolved": "https://registry.npmjs.org/tmpl/-/tmpl-1.0.5.tgz", @@ -14225,6 +14571,32 @@ "license": "MIT", "peer": true }, + "node_modules/tough-cookie": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-5.1.2.tgz", + "integrity": "sha512-FVDYdxtnj0G6Qm/DhNPSb8Ju59ULcup3tuJxkFb5K8Bv2pUXILbf0xZWU8PX8Ov19OXljbUyveOFwRMwkXzO+A==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "tldts": "^6.1.32" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/tr46": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-5.1.1.tgz", + "integrity": "sha512-hdF5ZgjTqgAntKkklYw0R03MG2x/bSzTtkxmIRw/sTNV8YXsCJ1tfLAX23lhxhHJlEf3CRCOCGGWw3vI3GaSPw==", + "dev": true, + "license": "MIT", + "dependencies": { + "punycode": "^2.3.1" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/tree-kill": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/tree-kill/-/tree-kill-1.2.2.tgz", @@ -15226,6 +15598,19 @@ "dev": true, "license": "MIT" }, + "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/walker": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/walker/-/walker-1.0.8.tgz", @@ -15247,6 +15632,30 @@ "defaults": "^1.0.3" } }, + "node_modules/webidl-conversions": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-7.0.0.tgz", + "integrity": "sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + } + }, + "node_modules/whatwg-encoding": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/whatwg-encoding/-/whatwg-encoding-3.1.1.tgz", + "integrity": "sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ==", + "deprecated": "Use @exodus/bytes instead for a more spec-conformant and faster implementation", + "dev": true, + "license": "MIT", + "dependencies": { + "iconv-lite": "0.6.3" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/whatwg-fetch": { "version": "3.6.20", "resolved": "https://registry.npmjs.org/whatwg-fetch/-/whatwg-fetch-3.6.20.tgz", @@ -15254,6 +15663,30 @@ "dev": true, "license": "MIT" }, + "node_modules/whatwg-mimetype": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-4.0.0.tgz", + "integrity": "sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/whatwg-url": { + "version": "14.2.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-14.2.0.tgz", + "integrity": "sha512-De72GdQZzNTUBBChsXueQUnPKDkg/5A5zp7pFDuQAj5UFoENpiACU0wlCvzpAGnTkj++ihpKwKyYewn/XNUbKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "tr46": "^5.1.0", + "webidl-conversions": "^7.0.0" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/whatwg-url-minimum": { "version": "0.1.2", "resolved": "https://registry.npmjs.org/whatwg-url-minimum/-/whatwg-url-minimum-0.1.2.tgz", @@ -15455,7 +15888,6 @@ "integrity": "sha512-It4dO0K5v//JtTXuPkfEOaI3uUN87iYPnqo/ZzqCoG3g8uhA66QUMs/SrM0YK7/NAu+r4LMh/9dq2A7k+rHs+w==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=10.0.0" }, @@ -15487,6 +15919,16 @@ "node": ">=10.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/xml2js": { "version": "0.6.0", "resolved": "https://registry.npmjs.org/xml2js/-/xml2js-0.6.0.tgz", @@ -15524,6 +15966,13 @@ "node": ">=8.0" } }, + "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" + }, "node_modules/y18n": { "version": "5.0.8", "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", @@ -15661,6 +16110,7 @@ "@expo/vector-icons": "^15.1.1", "@types/react": "^19.2.14", "react": "^19.2.6", + "react-dom": "^19.2.6", "react-native": "^0.85.3", "react-native-safe-area-context": "^5.7.0", "react-native-svg": "^15.12.1", diff --git a/package.json b/package.json index f21acf8..4269adb 100644 --- a/package.json +++ b/package.json @@ -33,6 +33,7 @@ "eslint-plugin-react": "^7.37.2", "eslint-plugin-react-hooks": "^5.1.0", "husky": "^9.1.7", + "jsdom": "^26.1.0", "lint-staged": "^15.2.10", "prettier": "^3.4.2", "tsup": "^8.3.5", diff --git a/packages/native/package.json b/packages/native/package.json index bc05739..cee312c 100644 --- a/packages/native/package.json +++ b/packages/native/package.json @@ -63,6 +63,7 @@ "@expo/vector-icons": "^15.1.1", "@types/react": "^19.2.14", "react": "^19.2.6", + "react-dom": "^19.2.6", "react-native": "^0.85.3", "react-native-safe-area-context": "^5.7.0", "react-native-svg": "^15.12.1", diff --git a/packages/native/src/components/FileViewerHost.test.tsx b/packages/native/src/components/FileViewerHost.test.tsx new file mode 100644 index 0000000..c33f627 --- /dev/null +++ b/packages/native/src/components/FileViewerHost.test.tsx @@ -0,0 +1,138 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { act, useEffect } from 'react'; +import { createRoot, type Root } from 'react-dom/client'; +import type { FileFetcher, FileReadResult, FileWriteResult } from '@codeam/ide-core'; +import { FileViewerProvider, useFileViewer } from './FileViewerContext'; +import { FileViewerHost } from './FileViewerHost'; +import type { WebViewStubElement } from '../test/setup'; + +const PATH = 'src/index.ts'; +const VERDICT = "This session isn't responding — is it online?"; + +function Opener() { + const { open } = useFileViewer(); + useEffect(() => open({ path: PATH, op: 'Write' }), [open]); + return null; +} + +let root: Root | null = null; +let container: HTMLDivElement | null = null; + +async function mount(fetcher: FileFetcher) { + container = document.createElement('div'); + document.body.appendChild(container); + root = createRoot(container); + await act(async () => { + root!.render( + + + + , + ); + }); + return container; +} + +afterEach(async () => { + await act(async () => root?.unmount()); + container?.remove(); + root = null; + container = null; +}); + +const spinner = (c: HTMLElement) => c.querySelector('[data-rn="activity-indicator"]'); +const webview = (c: HTMLElement) => c.querySelector('[data-rn="webview"]'); +const bridge = (c: HTMLElement, msg: object) => + webview(c)!.__onMessage!({ nativeEvent: { data: JSON.stringify(msg) } }); +const retryButton = (c: HTMLElement) => + Array.from(c.querySelectorAll('button')).find((b) => b.textContent === 'Retry') ?? null; +const occurrences = (haystack: string, needle: string) => haystack.split(needle).length - 1; + +function makeFetcher(read: FileFetcher['read'], write?: FileFetcher['write']): FileFetcher { + return { + label: 'test', + canWrite: true, + read, + write: write ?? (async () => ({}) as FileWriteResult), + }; +} + +describe('FileViewerHost — read failure', () => { + it('renders the fetcher verdict in the body with Retry, no spinner, and no duplicate bar', async () => { + const read = vi.fn(async (): Promise => ({ error: VERDICT })); + const c = await mount(makeFetcher(read)); + + expect(read).toHaveBeenCalledTimes(1); + expect(spinner(c)).toBeNull(); + expect(c.textContent).not.toContain('Fetching'); + expect(occurrences(c.textContent ?? '', VERDICT)).toBe(1); + expect(retryButton(c)).not.toBeNull(); + }); + + it('Retry re-runs fetcher.read, shows the loading state meanwhile, then renders the file', async () => { + let resolveSecond: ((r: FileReadResult) => void) | null = null; + const read = vi + .fn<() => Promise>() + .mockResolvedValueOnce({ error: VERDICT }) + .mockImplementationOnce( + () => + new Promise((resolve) => { + resolveSecond = resolve; + }), + ); + const c = await mount(makeFetcher(read)); + expect(retryButton(c)).not.toBeNull(); + + await act(async () => retryButton(c)!.click()); + + expect(read).toHaveBeenCalledTimes(2); + expect(read).toHaveBeenLastCalledWith(PATH); + expect(spinner(c)).not.toBeNull(); + expect(c.textContent).toContain(`Fetching ${PATH}`); + expect(c.textContent).not.toContain(VERDICT); + expect(retryButton(c)).toBeNull(); + + await act(async () => resolveSecond!({ content: 'export const x = 1;\n' })); + + expect(spinner(c)).toBeNull(); + expect(webview(c)).not.toBeNull(); + expect(c.textContent).not.toContain(VERDICT); + }); + + it('a Retry that fails again shows the new verdict once, with Retry still available', async () => { + const read = vi + .fn<() => Promise>() + .mockResolvedValueOnce({ error: VERDICT }) + .mockResolvedValueOnce({ error: 'Session is offline.' }); + const c = await mount(makeFetcher(read)); + + await act(async () => retryButton(c)!.click()); + + expect(read).toHaveBeenCalledTimes(2); + expect(spinner(c)).toBeNull(); + expect(c.textContent).not.toContain(VERDICT); + expect(occurrences(c.textContent ?? '', 'Session is offline.')).toBe(1); + expect(retryButton(c)).not.toBeNull(); + }); +}); + +describe('FileViewerHost — errors while a file is displayed', () => { + it('a save failure goes to the top bar once and keeps the editor mounted', async () => { + const read = vi.fn(async (): Promise => ({ content: 'hello' })); + const write = vi.fn( + async (): Promise => ({ error: 'Save failed: disk full' }), + ); + const c = await mount(makeFetcher(read, write)); + expect(webview(c)).not.toBeNull(); + + // Same path Cmd+S takes inside Monaco: edit the buffer, then save. + await act(async () => bridge(c, { type: 'change', value: 'hello world' })); + await act(async () => bridge(c, { type: 'save' })); + + expect(write).toHaveBeenCalledWith(PATH, 'hello world'); + expect(webview(c)).not.toBeNull(); + expect(occurrences(c.textContent ?? '', 'Save failed: disk full')).toBe(1); + expect(retryButton(c)).toBeNull(); + expect(spinner(c)).toBeNull(); + }); +}); diff --git a/packages/native/src/components/FileViewerHost.tsx b/packages/native/src/components/FileViewerHost.tsx index 87ed2c1..8fb7ad7 100644 --- a/packages/native/src/components/FileViewerHost.tsx +++ b/packages/native/src/components/FileViewerHost.tsx @@ -97,10 +97,12 @@ export function FileViewerHost() { const [loading, setLoading] = useState(false); const [saving, setSaving] = useState(false); const [savedAt, setSavedAt] = useState(null); + // Bumped by Retry so the read effect re-runs for the same request/fetcher. + const [readAttempt, setReadAttempt] = useState(0); const webRef = useRef(null); const webReadyRef = useRef(false); - // Reset + fetch when a new request arrives. + // Reset + fetch when a new request arrives (or the user retries). useEffect(() => { if (!request || !fetcher) return; setContent(null); @@ -135,7 +137,9 @@ export function FileViewerHost() { return () => { cancelled = true; }; - }, [request, fetcher]); + }, [request, fetcher, readAttempt]); + + const retry = () => setReadAttempt((n) => n + 1); const language = useMemo(() => (request ? detectLanguage(request.path) : 'plaintext'), [request]); const html = useMemo( @@ -225,7 +229,9 @@ export function FileViewerHost() { - {error && ( + {/* With no content on screen the error owns the body (below); the bar + is for failures on top of a displayed file (save, editor bridge). */} + {error && html !== null && ( {error} @@ -233,18 +239,27 @@ export function FileViewerHost() { )} - {loading || html === null ? ( - - - Fetching {request.path}… - - ) : !fetcher ? ( + {!fetcher ? ( No active session. Pair an IDE plugin or CLI first. + ) : loading || (html === null && error === null) ? ( + + + Fetching {request.path}… + + ) : html === null ? ( + + + {error} + + + Retry + + ) : ( void; + disabled?: boolean; + testID?: string; + [key: string]: unknown; +}; + +function domStub(tag: string, marker?: string) { + return function Stub({ children, onPress, disabled, testID }: StubProps) { + return createElement( + tag, + { + onClick: onPress, + disabled, + 'data-testid': testID, + 'data-rn': marker, + }, + children, + ); + }; +} + +vi.mock('react-native', () => ({ + View: domStub('div'), + Text: domStub('span'), + TouchableOpacity: domStub('button'), + Pressable: domStub('button'), + ScrollView: domStub('div'), + TextInput: domStub('input'), + ActivityIndicator: domStub('div', 'activity-indicator'), + Modal: ({ children, visible = true }: StubProps & { visible?: boolean }) => + visible ? createElement('div', { 'data-rn': 'modal' }, children) : null, + StyleSheet: { + create: (styles: T) => styles, + hairlineWidth: 1, + absoluteFill: {}, + flatten: (s: unknown) => s, + }, + Platform: { OS: 'ios', select: (o: Record) => o.ios ?? o.default }, + useWindowDimensions: () => ({ width: 390, height: 844, scale: 3, fontScale: 1 }), +})); + +/** + * The WebView stub parks its `onMessage` handler on the DOM node so a spec can + * drive the Monaco bridge (`change` / `save` / `error` messages) the way the + * real editor would. + */ +export interface WebViewStubElement extends HTMLElement { + __onMessage?: (event: { nativeEvent: { data: string } }) => void; +} + +vi.mock('react-native-webview', () => ({ + WebView: ({ onMessage }: { onMessage?: WebViewStubElement['__onMessage'] }) => + createElement('div', { + 'data-rn': 'webview', + ref: (el: WebViewStubElement | null) => { + if (el) el.__onMessage = onMessage; + }, + }), +})); + +vi.mock('@expo/vector-icons', () => ({ + Ionicons: ({ name }: { name: string }) => createElement('i', { 'data-icon': name }), +})); + +vi.mock('react-native-safe-area-context', () => ({ + useSafeAreaInsets: () => ({ top: 0, right: 0, bottom: 0, left: 0 }), +})); diff --git a/packages/native/vitest.config.ts b/packages/native/vitest.config.ts new file mode 100644 index 0000000..21c2b9e --- /dev/null +++ b/packages/native/vitest.config.ts @@ -0,0 +1,12 @@ +import { defineConfig } from 'vitest/config'; + +// Component specs render the real components through react-dom into jsdom; +// the RN host primitives are stubbed in `src/test/setup.tsx` (react-native's +// own sources are Flow and can't be imported outside the Jest preset). +export default defineConfig({ + test: { + environment: 'jsdom', + setupFiles: ['./src/test/setup.tsx'], + include: ['src/**/*.test.{ts,tsx}'], + }, +});