diff --git a/.gitignore b/.gitignore index aea1036b67..2f734f5f94 100644 --- a/.gitignore +++ b/.gitignore @@ -11,6 +11,7 @@ node_modules dist dist-ssr *.local +coverage # Editor directories and files .vscode/* diff --git a/package-lock.json b/package-lock.json index c1aade4607..1e14794299 100644 --- a/package-lock.json +++ b/package-lock.json @@ -57,6 +57,7 @@ "@typescript-eslint/eslint-plugin": "^8.56.1", "@typescript-eslint/parser": "^8.56.1", "@vitejs/plugin-react": "^4.3.1", + "@vitest/coverage-v8": "^3.2.6", "colors": "^1.4.0", "commander": "^14.0.0", "commitizen": "^4.3.1", @@ -133,6 +134,20 @@ "integrity": "sha512-Elp+iwUx5rN5+Y8xLt5/GRoG20WGoDCQ/1Fb+1LiGtvwbDavuSk0jhD/eZdckHAuzcDzccnkv+rEjyWfRx18gg==", "dev": true }, + "node_modules/@ampproject/remapping": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/@ampproject/remapping/-/remapping-2.3.0.tgz", + "integrity": "sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + }, + "engines": { + "node": ">=6.0.0" + } + }, "node_modules/@asamuzakjp/css-color": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-3.2.0.tgz", @@ -432,6 +447,16 @@ "node": ">=6.9.0" } }, + "node_modules/@bcoe/v8-coverage": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@bcoe/v8-coverage/-/v8-coverage-1.0.2.tgz", + "integrity": "sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/@colors/colors": { "version": "1.5.0", "resolved": "https://registry.npmjs.org/@colors/colors/-/colors-1.5.0.tgz", @@ -1428,6 +1453,119 @@ "deprecated": "Use @eslint/object-schema instead", "dev": true }, + "node_modules/@isaacs/cliui": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz", + "integrity": "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==", + "dev": true, + "license": "ISC", + "dependencies": { + "string-width": "^5.1.2", + "string-width-cjs": "npm:string-width@^4.2.0", + "strip-ansi": "^7.0.1", + "strip-ansi-cjs": "npm:strip-ansi@^6.0.1", + "wrap-ansi": "^8.1.0", + "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/@isaacs/cliui/node_modules/ansi-regex": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", + "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/@isaacs/cliui/node_modules/ansi-styles": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", + "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/@isaacs/cliui/node_modules/emoji-regex": { + "version": "9.2.2", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz", + "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@isaacs/cliui/node_modules/string-width": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz", + "integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "eastasianwidth": "^0.2.0", + "emoji-regex": "^9.2.2", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@isaacs/cliui/node_modules/strip-ansi": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz", + "integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^6.2.2" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, + "node_modules/@isaacs/cliui/node_modules/wrap-ansi": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz", + "integrity": "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^6.1.0", + "string-width": "^5.0.1", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/@istanbuljs/schema": { + "version": "0.1.6", + "resolved": "https://registry.npmjs.org/@istanbuljs/schema/-/schema-0.1.6.tgz", + "integrity": "sha512-+Sg6GCR/wy1oSmQDFq4LQDAhm3ETKnorxN+y5nbLULOR3P0c14f2Wurzj3/xqPXtasLFfHd5iRFQ7AJt4KH2cw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/@jridgewell/gen-mapping": { "version": "0.3.13", "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", @@ -3124,6 +3262,17 @@ "@octokit/openapi-types": "^27.0.0" } }, + "node_modules/@pkgjs/parseargs": { + "version": "0.11.0", + "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", + "integrity": "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==", + "dev": true, + "license": "MIT", + "optional": true, + "engines": { + "node": ">=14" + } + }, "node_modules/@pnpm/config.env-replace": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@pnpm/config.env-replace/-/config.env-replace-1.1.0.tgz", @@ -4528,6 +4677,7 @@ "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", "dev": true, + "license": "MIT", "dependencies": { "@types/deep-eql": "*", "assertion-error": "^2.0.1" @@ -4547,7 +4697,8 @@ "version": "4.0.2", "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", - "dev": true + "dev": true, + "license": "MIT" }, "node_modules/@types/estree": { "version": "1.0.8", @@ -4896,15 +5047,50 @@ "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" } }, + "node_modules/@vitest/coverage-v8": { + "version": "3.2.6", + "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-3.2.6.tgz", + "integrity": "sha512-LsAdmUapA0qSN306d8+zOyawM0hFm2m2Hg9IwVNIKBm+qJV8cijiq2c+gxKZcB1HCfIWAy+0qEZDCUQA58A1cw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@ampproject/remapping": "^2.3.0", + "@bcoe/v8-coverage": "^1.0.2", + "ast-v8-to-istanbul": "^0.3.3", + "debug": "^4.4.1", + "istanbul-lib-coverage": "^3.2.2", + "istanbul-lib-report": "^3.0.1", + "istanbul-lib-source-maps": "^5.0.6", + "istanbul-reports": "^3.1.7", + "magic-string": "^0.30.17", + "magicast": "^0.3.5", + "std-env": "^3.9.0", + "test-exclude": "^7.0.1", + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@vitest/browser": "3.2.6", + "vitest": "3.2.6" + }, + "peerDependenciesMeta": { + "@vitest/browser": { + "optional": true + } + } + }, "node_modules/@vitest/expect": { - "version": "3.2.4", - "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-3.2.4.tgz", - "integrity": "sha512-Io0yyORnB6sikFlt8QW5K7slY4OjqNX9jmJQ02QDda8lyM6B5oNgVWoSoKPac8/kgnCUzuHQKrSLtu/uOqqrig==", + "version": "3.2.6", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-3.2.6.tgz", + "integrity": "sha512-1+7q9BtaKzEmO+fmNT3kYvoNn5Y71XWAx2Q5HRim4tTVRQVRv4uJFAQ5FbK0OPUeNP/WmVCpxYxoJdvuHVjzBQ==", "dev": true, + "license": "MIT", "dependencies": { "@types/chai": "^5.2.2", - "@vitest/spy": "3.2.4", - "@vitest/utils": "3.2.4", + "@vitest/spy": "3.2.6", + "@vitest/utils": "3.2.6", "chai": "^5.2.0", "tinyrainbow": "^2.0.0" }, @@ -4913,12 +5099,13 @@ } }, "node_modules/@vitest/mocker": { - "version": "3.2.4", - "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-3.2.4.tgz", - "integrity": "sha512-46ryTE9RZO/rfDd7pEqFl7etuyzekzEhUbTW3BvmeO/BcCMEgq59BKhek3dXDWgAj4oMK6OZi+vRr1wPW6qjEQ==", + "version": "3.2.6", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-3.2.6.tgz", + "integrity": "sha512-EZOrpDbkKotFAP7wPAQV1UIyoGOk4oX7ynWhBhLB7v+meMHbQhU16oPpIYGTTe4oFlhpryGpgpcZP/sin3hYuw==", "dev": true, + "license": "MIT", "dependencies": { - "@vitest/spy": "3.2.4", + "@vitest/spy": "3.2.6", "estree-walker": "^3.0.3", "magic-string": "^0.30.17" }, @@ -4943,15 +5130,17 @@ "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", "dev": true, + "license": "MIT", "dependencies": { "@types/estree": "^1.0.0" } }, "node_modules/@vitest/pretty-format": { - "version": "3.2.4", - "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-3.2.4.tgz", - "integrity": "sha512-IVNZik8IVRJRTr9fxlitMKeJeXFFFN0JaB9PHPGQ8NKQbGpfjlTx9zO4RefN8gp7eqjNy8nyK3NZmBzOPeIxtA==", + "version": "3.2.6", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-3.2.6.tgz", + "integrity": "sha512-lb7XXXzmm2h2ASzFnRvQpDo6onT1NmMJA3tkGTWiBFtRJ9lxGY3d3mm/Apt36gej2bkkOVLL/yTOtufDaFa/jA==", "dev": true, + "license": "MIT", "dependencies": { "tinyrainbow": "^2.0.0" }, @@ -4960,12 +5149,13 @@ } }, "node_modules/@vitest/runner": { - "version": "3.2.4", - "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-3.2.4.tgz", - "integrity": "sha512-oukfKT9Mk41LreEW09vt45f8wx7DordoWUZMYdY/cyAk7w5TWkTRCNZYF7sX7n2wB7jyGAl74OxgwhPgKaqDMQ==", + "version": "3.2.6", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-3.2.6.tgz", + "integrity": "sha512-HYcoSj1w5tcgUnzoF0HcyaAQjpA1gj9ftUJ7iSJSuipc02jW9gKkigwZbjFldAfYHA1fa8UZVRftdMY5msWM9Q==", "dev": true, + "license": "MIT", "dependencies": { - "@vitest/utils": "3.2.4", + "@vitest/utils": "3.2.6", "pathe": "^2.0.3", "strip-literal": "^3.0.0" }, @@ -4974,12 +5164,13 @@ } }, "node_modules/@vitest/snapshot": { - "version": "3.2.4", - "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-3.2.4.tgz", - "integrity": "sha512-dEYtS7qQP2CjU27QBC5oUOxLE/v5eLkGqPE0ZKEIDGMs4vKWe7IjgLOeauHsR0D5YuuycGRO5oSRXnwnmA78fQ==", + "version": "3.2.6", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-3.2.6.tgz", + "integrity": "sha512-H+ZjNTWGpObenh0YnlBctAPnJSI20P81PL8BPzWpx54YXLLTm8hEsWawtcYLMrwvpK48hGxLLbCS+1KRXhsKhw==", "dev": true, + "license": "MIT", "dependencies": { - "@vitest/pretty-format": "3.2.4", + "@vitest/pretty-format": "3.2.6", "magic-string": "^0.30.17", "pathe": "^2.0.3" }, @@ -4988,10 +5179,11 @@ } }, "node_modules/@vitest/spy": { - "version": "3.2.4", - "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-3.2.4.tgz", - "integrity": "sha512-vAfasCOe6AIK70iP5UD11Ac4siNUNJ9i/9PZ3NKx07sG6sUxeag1LWdNrMWeKKYBLlzuK+Gn65Yd5nyL6ds+nw==", + "version": "3.2.6", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-3.2.6.tgz", + "integrity": "sha512-oq6BbH68WzcWmwtBrU9nqLeaXTR4XwJF7FSLkKEZo4i6eoXcrxjcwSuTvWBIRUTC6VC72nXYunzqgZA+IKdtxg==", "dev": true, + "license": "MIT", "dependencies": { "tinyspy": "^4.0.3" }, @@ -5000,12 +5192,13 @@ } }, "node_modules/@vitest/utils": { - "version": "3.2.4", - "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-3.2.4.tgz", - "integrity": "sha512-fB2V0JFrQSMsCo9HiSq3Ezpdv4iYaXRG1Sx8edX3MwxfyNn83mKiGzOcH+Fkxt4MHxr3y42fQi1oeAInqgX2QA==", + "version": "3.2.6", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-3.2.6.tgz", + "integrity": "sha512-lI23nIs4bnT3T8NIoh+vFaz5s2/DdP0Jgt2jxwgWljvwn82cLJtyi/If+fjFyoLMGIOz0U/fKvWE0d4jsNQEfg==", "dev": true, + "license": "MIT", "dependencies": { - "@vitest/pretty-format": "3.2.4", + "@vitest/pretty-format": "3.2.6", "loupe": "^3.1.4", "tinyrainbow": "^2.0.0" }, @@ -5444,10 +5637,40 @@ "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", "dev": true, + "license": "MIT", "engines": { "node": ">=12" } }, + "node_modules/ast-v8-to-istanbul": { + "version": "0.3.12", + "resolved": "https://registry.npmjs.org/ast-v8-to-istanbul/-/ast-v8-to-istanbul-0.3.12.tgz", + "integrity": "sha512-BRRC8VRZY2R4Z4lFIL35MwNXmwVqBityvOIwETtsCSwvjl0IdgFsy9NhdaA6j74nUdtJJlIypeRhpDam19Wq3g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/trace-mapping": "^0.3.31", + "estree-walker": "^3.0.3", + "js-tokens": "^10.0.0" + } + }, + "node_modules/ast-v8-to-istanbul/node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/ast-v8-to-istanbul/node_modules/js-tokens": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-10.0.0.tgz", + "integrity": "sha512-lM/UBzQmfJRo9ABXbPWemivdCW8V2G8FHaHdypQaIy523snUjog0W71ayWXTjiR+ixeMyVHN2XcpnTd/liPg/Q==", + "dev": true, + "license": "MIT" + }, "node_modules/astral-regex": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/astral-regex/-/astral-regex-1.0.0.tgz", @@ -5772,6 +5995,7 @@ "resolved": "https://registry.npmjs.org/chai/-/chai-5.3.3.tgz", "integrity": "sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==", "dev": true, + "license": "MIT", "dependencies": { "assertion-error": "^2.0.1", "check-error": "^2.1.1", @@ -5826,6 +6050,7 @@ "resolved": "https://registry.npmjs.org/check-error/-/check-error-2.1.3.tgz", "integrity": "sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==", "dev": true, + "license": "MIT", "engines": { "node": ">= 16" } @@ -6711,6 +6936,7 @@ "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.2.tgz", "integrity": "sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==", "dev": true, + "license": "MIT", "engines": { "node": ">=6" } @@ -6970,6 +7196,13 @@ "safe-buffer": "~5.1.0" } }, + "node_modules/eastasianwidth": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz", + "integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==", + "dev": true, + "license": "MIT" + }, "node_modules/electron-to-chromium": { "version": "1.5.267", "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.267.tgz", @@ -8082,6 +8315,23 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/foreground-child": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz", + "integrity": "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==", + "dev": true, + "license": "ISC", + "dependencies": { + "cross-spawn": "^7.0.6", + "signal-exit": "^4.0.1" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/form-data": { "version": "4.0.5", "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz", @@ -8397,6 +8647,28 @@ "traverse": "0.6.8" } }, + "node_modules/glob": { + "version": "10.5.0", + "resolved": "https://registry.npmjs.org/glob/-/glob-10.5.0.tgz", + "integrity": "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==", + "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", + "dev": true, + "license": "ISC", + "dependencies": { + "foreground-child": "^3.1.0", + "jackspeak": "^3.1.2", + "minimatch": "^9.0.4", + "minipass": "^7.1.2", + "package-json-from-dist": "^1.0.0", + "path-scurry": "^1.11.1" + }, + "bin": { + "glob": "dist/esm/bin.mjs" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/glob-parent": { "version": "6.0.2", "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", @@ -8409,6 +8681,32 @@ "node": ">=10.13.0" } }, + "node_modules/glob/node_modules/brace-expansion": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.1.tgz", + "integrity": "sha512-WR1cURNjuvBLMZBMbqM0UoE+WAfdUcEV1ccD8PVBVOI+Z3ND4+SZbN8RsfT2bMuG1qwz5RFvPukSZm5fF2D5eA==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/glob/node_modules/minimatch": { + "version": "9.0.9", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz", + "integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.2" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/global-directory": { "version": "4.0.1", "resolved": "https://registry.npmjs.org/global-directory/-/global-directory-4.0.1.tgz", @@ -8731,6 +9029,13 @@ "node": ">=18" } }, + "node_modules/html-escaper": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz", + "integrity": "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==", + "dev": true, + "license": "MIT" + }, "node_modules/http-proxy-agent": { "version": "7.0.2", "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", @@ -9569,6 +9874,83 @@ "node": "^18.17 || >=20.6.1" } }, + "node_modules/istanbul-lib-coverage": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.2.tgz", + "integrity": "sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=8" + } + }, + "node_modules/istanbul-lib-report": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/istanbul-lib-report/-/istanbul-lib-report-3.0.1.tgz", + "integrity": "sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "istanbul-lib-coverage": "^3.0.0", + "make-dir": "^4.0.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/istanbul-lib-report/node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/istanbul-lib-report/node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/istanbul-lib-source-maps": { + "version": "5.0.6", + "resolved": "https://registry.npmjs.org/istanbul-lib-source-maps/-/istanbul-lib-source-maps-5.0.6.tgz", + "integrity": "sha512-yg2d+Em4KizZC5niWhQaIomgf5WlL4vOOjZ5xGCmF8SnPE/mDWWXgvRExdcpCgh9lLRRa1/fSYp2ymmbJ1pI+A==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "@jridgewell/trace-mapping": "^0.3.23", + "debug": "^4.1.1", + "istanbul-lib-coverage": "^3.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/istanbul-reports": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/istanbul-reports/-/istanbul-reports-3.2.0.tgz", + "integrity": "sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "html-escaper": "^2.0.0", + "istanbul-lib-report": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/iterator.prototype": { "version": "1.1.5", "resolved": "https://registry.npmjs.org/iterator.prototype/-/iterator.prototype-1.1.5.tgz", @@ -9586,6 +9968,22 @@ "node": ">= 0.4" } }, + "node_modules/jackspeak": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-3.4.3.tgz", + "integrity": "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "@isaacs/cliui": "^8.0.2" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + }, + "optionalDependencies": { + "@pkgjs/parseargs": "^0.11.0" + } + }, "node_modules/java-properties": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/java-properties/-/java-properties-1.0.2.tgz", @@ -10380,7 +10778,8 @@ "version": "3.2.1", "resolved": "https://registry.npmjs.org/loupe/-/loupe-3.2.1.tgz", "integrity": "sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==", - "dev": true + "dev": true, + "license": "MIT" }, "node_modules/lower-case": { "version": "2.0.2", @@ -10417,6 +10816,18 @@ "@jridgewell/sourcemap-codec": "^1.5.5" } }, + "node_modules/magicast": { + "version": "0.3.5", + "resolved": "https://registry.npmjs.org/magicast/-/magicast-0.3.5.tgz", + "integrity": "sha512-L0WhttDl+2BOsybvEOLK7fW3UA0OQ0IQ2d6Zl2x/a6vVRs3bAY0ECOSHHeL5jD+SbOpOCUEi0y1DgHEn9Qn1AQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.25.4", + "@babel/types": "^7.25.4", + "source-map-js": "^1.2.0" + } + }, "node_modules/make-asynchronous": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/make-asynchronous/-/make-asynchronous-1.0.1.tgz", @@ -10446,6 +10857,35 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/make-dir": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-4.0.0.tgz", + "integrity": "sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==", + "dev": true, + "license": "MIT", + "dependencies": { + "semver": "^7.5.3" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/make-dir/node_modules/semver": { + "version": "7.8.2", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.2.tgz", + "integrity": "sha512-c8jsqUZm3omBOI66G90z1Dyw5z622G8oLG+omfsHBJf3CWQTlOcwOjvOG6wtiNfW6anKm/eA39LMwMtMez2TiQ==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/markdown-eslint-parser": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/markdown-eslint-parser/-/markdown-eslint-parser-1.2.1.tgz", @@ -11183,6 +11623,16 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/minipass": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz", + "integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, "node_modules/mkdirp": { "version": "0.5.6", "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.6.tgz", @@ -13227,7 +13677,7 @@ } }, "node_modules/npm/node_modules/tinyglobby/node_modules/picomatch": { - "version": "4.0.4", + "version": "4.0.3", "dev": true, "inBundle": true, "license": "MIT", @@ -13721,6 +14171,13 @@ "node": ">=4" } }, + "node_modules/package-json-from-dist": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz", + "integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==", + "dev": true, + "license": "BlueOak-1.0.0" + }, "node_modules/parent-module": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", @@ -13834,6 +14291,30 @@ "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==" }, + "node_modules/path-scurry": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.11.1.tgz", + "integrity": "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "lru-cache": "^10.2.0", + "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" + }, + "engines": { + "node": ">=16 || 14 >=14.18" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/path-scurry/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/path-type": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/path-type/-/path-type-4.0.0.tgz", @@ -13852,6 +14333,7 @@ "resolved": "https://registry.npmjs.org/pathval/-/pathval-2.0.1.tgz", "integrity": "sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==", "dev": true, + "license": "MIT", "engines": { "node": ">= 14.16" } @@ -15652,6 +16134,32 @@ "node": ">=8" } }, + "node_modules/string-width-cjs": { + "name": "string-width", + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/string-width-cjs/node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/string-width/node_modules/is-fullwidth-code-point": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", @@ -15766,6 +16274,20 @@ "node": ">=8" } }, + "node_modules/strip-ansi-cjs": { + "name": "strip-ansi", + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/strip-bom": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/strip-bom/-/strip-bom-4.0.0.tgz", @@ -15815,6 +16337,7 @@ "resolved": "https://registry.npmjs.org/strip-literal/-/strip-literal-3.1.0.tgz", "integrity": "sha512-8r3mkIM/2+PpjHoOtiAW8Rg3jJLHaV7xPwG+YRGrv6FP0wwk/toTpATxWYOW0BKdWwl82VT2tFYi5DlROa0Mxg==", "dev": true, + "license": "MIT", "dependencies": { "js-tokens": "^9.0.1" }, @@ -15826,7 +16349,8 @@ "version": "9.0.1", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-9.0.1.tgz", "integrity": "sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==", - "dev": true + "dev": true, + "license": "MIT" }, "node_modules/stylis": { "version": "4.2.0", @@ -16079,6 +16603,21 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/test-exclude": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/test-exclude/-/test-exclude-7.0.2.tgz", + "integrity": "sha512-u9E6A+ZDYdp7a4WnarkXPZOx8Ilz46+kby6p1yZ8zsGTz9gYa6FIS7lj2oezzNKmtdyyJNNmmXDppga5GB7kSw==", + "dev": true, + "license": "ISC", + "dependencies": { + "@istanbuljs/schema": "^0.1.2", + "glob": "^10.4.1", + "minimatch": "^10.2.2" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/text-table": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/text-table/-/text-table-0.2.0.tgz", @@ -16241,6 +16780,7 @@ "resolved": "https://registry.npmjs.org/tinyspy/-/tinyspy-4.0.4.tgz", "integrity": "sha512-azl+t0z7pw/z958Gy9svOTuzqIk6xq+NSheJzn5MMWtWTFywIacg2wUlzKFGtt3cthx0r2SxMK0yzJOR0IES7Q==", "dev": true, + "license": "MIT", "engines": { "node": ">=14.0.0" } @@ -16842,19 +17382,20 @@ } }, "node_modules/vitest": { - "version": "3.2.4", - "resolved": "https://registry.npmjs.org/vitest/-/vitest-3.2.4.tgz", - "integrity": "sha512-LUCP5ev3GURDysTWiP47wRRUpLKMOfPh+yKTx3kVIEiu5KOMeqzpnYNsKyOoVrULivR8tLcks4+lga33Whn90A==", + "version": "3.2.6", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-3.2.6.tgz", + "integrity": "sha512-xejya+bT/j/+R/AGa1XOfRxLmNUlLtlwjRsFUILF+xHfzElmGcmFydy2gqqIrd62ptIEfwVMofd19uNWD9L7Nw==", "dev": true, + "license": "MIT", "dependencies": { "@types/chai": "^5.2.2", - "@vitest/expect": "3.2.4", - "@vitest/mocker": "3.2.4", - "@vitest/pretty-format": "^3.2.4", - "@vitest/runner": "3.2.4", - "@vitest/snapshot": "3.2.4", - "@vitest/spy": "3.2.4", - "@vitest/utils": "3.2.4", + "@vitest/expect": "3.2.6", + "@vitest/mocker": "3.2.6", + "@vitest/pretty-format": "^3.2.6", + "@vitest/runner": "3.2.6", + "@vitest/snapshot": "3.2.6", + "@vitest/spy": "3.2.6", + "@vitest/utils": "3.2.6", "chai": "^5.2.0", "debug": "^4.4.1", "expect-type": "^1.2.1", @@ -16884,8 +17425,8 @@ "@edge-runtime/vm": "*", "@types/debug": "^4.1.12", "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", - "@vitest/browser": "3.2.4", - "@vitest/ui": "3.2.4", + "@vitest/browser": "3.2.6", + "@vitest/ui": "3.2.6", "happy-dom": "*", "jsdom": "*" }, @@ -17149,6 +17690,61 @@ "url": "https://github.com/chalk/wrap-ansi?sponsor=1" } }, + "node_modules/wrap-ansi-cjs": { + "name": "wrap-ansi", + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/wrap-ansi-cjs/node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/wrap-ansi-cjs/node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/wrap-ansi-cjs/node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true, + "license": "MIT" + }, "node_modules/wrap-ansi/node_modules/ansi-styles": { "version": "4.3.0", "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", diff --git a/package.json b/package.json index 1f786bf421..93bb647a5f 100644 --- a/package.json +++ b/package.json @@ -31,6 +31,7 @@ "preview": "vite preview", "prepare": "husky", "test": "vitest run", + "test:coverage": "vitest run --coverage", "watch": "vitest", "i18n": "node scripts/i18n.js", "logBuildDate": "echo 'Last build: '$(date \"+%c\") | tee ./dist/lastBuild.txt", @@ -89,6 +90,7 @@ "@typescript-eslint/eslint-plugin": "^8.56.1", "@typescript-eslint/parser": "^8.56.1", "@vitejs/plugin-react": "^4.3.1", + "@vitest/coverage-v8": "^3.2.6", "colors": "^1.4.0", "commander": "^14.0.0", "commitizen": "^4.3.1", diff --git a/src/__tests__/Connect-test.tsx b/src/__tests__/Connect-test.tsx new file mode 100644 index 0000000000..96f239aa7d --- /dev/null +++ b/src/__tests__/Connect-test.tsx @@ -0,0 +1,252 @@ +import React from 'react' +import { beforeEach, describe, it, expect, vi } from 'vitest' +import { render, screen, waitFor } from 'src/utilities/testingLibrary' +import { Connect } from '../Connect' +import { initialState, masterData } from 'src/services/mockedData' +import { STEPS } from 'src/const/Connect' +import PostMessage from 'src/utilities/PostMessage' + +vi.mock('react-confetti', () => ({ + default: () =>
, +})) + +vi.mock('src/utilities/PostMessage', () => ({ + default: { + send: vi.fn(), + }, +})) + +describe('', () => { + /* eslint-disable @typescript-eslint/no-explicit-any */ + const defaultProps: any = { + clientConfig: {}, + profiles: { loading: false, ...masterData }, + userFeatures: {}, + experimentalFeatures: {}, + availableAccountTypes: [] as [], + onManualAccountAdded: vi.fn(), + onMemberDeleted: vi.fn(), + onSuccessfulAggregation: vi.fn(), + onUpsertMember: vi.fn(), + onAnalyticEvent: vi.fn(), + onAnalyticPageview: vi.fn(), + onShowConnectSuccessSurvey: () => {}, + onSubmitConnectSuccessSurvey: vi.fn(), + } + /* eslint-enable @typescript-eslint/no-explicit-any */ + + beforeEach(() => { + vi.clearAllMocks() + }) + + describe('loading states', () => { + it('displays loading spinner when component is loading', () => { + const preloadedState = { + ...initialState, + connect: { + ...initialState.connect, + isComponentLoading: true, + }, + } + + render(, { preloadedState }) + + expect(screen.getByText(/Loading/i)).toBeInTheDocument() + }) + + it('renders without crashing when there is a config error', () => { + const preloadedState = { + ...initialState, + connect: { + ...initialState.connect, + isComponentLoading: false, + loadError: { + type: 'config', + title: 'Configuration Error', + message: 'This mode is not available for your account', + }, + }, + } + + const { container } = render(, { preloadedState }) + + expect(container).toBeInTheDocument() + }) + + it('renders without crashing when there is a network error', () => { + const preloadedState = { + ...initialState, + connect: { + ...initialState.connect, + isComponentLoading: false, + loadError: { + type: 'network', + title: 'Network Error', + message: 'Unable to connect to the server', + }, + }, + } + + const { container } = render(, { preloadedState }) + + expect(container).toBeInTheDocument() + }) + }) + + describe('legacy Atrium API support', () => { + it('sends legacy post message for Atrium with old ui_message_version', async () => { + const preloadedState = { + ...initialState, + profiles: { + ...initialState.profiles, + client: { + ...initialState.profiles.client, + has_atrium_api: true, + }, + }, + config: { + ...initialState.config, + is_mobile_webview: false, + ui_message_version: 3, + }, + connect: { + ...initialState.connect, + isComponentLoading: false, + }, + } + + render(, { preloadedState }) + + await waitFor(() => { + expect(PostMessage.send).toHaveBeenCalledWith('mxConnect:widgetLoaded') + }) + }) + }) + + describe('version metadata', () => { + it('stores version prop in redux state', async () => { + const preloadedState = { + ...initialState, + connect: { + ...initialState.connect, + isComponentLoading: false, + }, + } + + const { store } = render(, { preloadedState }) + + await waitFor(() => { + expect(store.getState().app.version).toBe('v1.2.3') + }) + }) + + it('handles missing version prop', async () => { + const preloadedState = { + ...initialState, + connect: { + ...initialState.connect, + isComponentLoading: false, + }, + } + + const { store } = render(, { preloadedState }) + + await waitFor(() => { + const version = store.getState().app.version + expect(version === null || version === undefined).toBe(true) + }) + }) + }) + + describe('profiles loading', () => { + it('loads profiles on mount', async () => { + const customProfiles = { + loading: false, + ...masterData, + client: { ...masterData.client, name: 'Custom Client Name' }, + } + + const preloadedState = { + ...initialState, + connect: { + ...initialState.connect, + isComponentLoading: false, + }, + } + + const { store } = render(, { + preloadedState, + }) + + await waitFor(() => { + expect(store.getState().profiles.client.name).toBe('Custom Client Name') + }) + }) + }) + + describe('renders main connect flow', () => { + it('renders search view when on search step', async () => { + const preloadedState = { + ...initialState, + connect: { + ...initialState.connect, + isComponentLoading: false, + location: [{ step: STEPS.SEARCH }], + }, + } + + render(, { preloadedState }) + + await waitFor(() => { + expect(screen.getByTestId('search-input')).toBeInTheDocument() + }) + }) + + it('includes ConnectNavigationHeader in the rendered output', async () => { + const preloadedState = { + ...initialState, + connect: { + ...initialState.connect, + isComponentLoading: false, + location: [{ step: STEPS.SEARCH }], + }, + } + + render(, { preloadedState }) + + await waitFor(() => { + expect(document.querySelector('#connect-wrapper')).toBeInTheDocument() + }) + }) + }) + + describe('analytic context provider', () => { + it('provides analytic callbacks to child components', async () => { + const onAnalyticEvent = vi.fn() + const onAnalyticPageview = vi.fn() + const onSubmitConnectSuccessSurvey = vi.fn() + + const preloadedState = { + ...initialState, + connect: { + ...initialState.connect, + isComponentLoading: false, + location: [{ step: STEPS.SEARCH }], + }, + } + render( + , + { preloadedState }, + ) + + await waitFor(() => { + expect(screen.getByTestId('search-input')).toBeInTheDocument() + }) + }) + }) +}) diff --git a/src/__tests__/main-test.tsx b/src/__tests__/main-test.tsx new file mode 100644 index 0000000000..326460ae55 --- /dev/null +++ b/src/__tests__/main-test.tsx @@ -0,0 +1,109 @@ +import React from 'react' +import { beforeEach, describe, it, expect, vi } from 'vitest' +import { render, waitFor } from 'src/utilities/testingLibrary' +import { AGG_MODE } from 'src/const/Connect' +import ConnectWidget from '../ConnectWidget' + +vi.mock('../global.css', () => ({})) +vi.mock('../styles.css', () => ({})) + +vi.mock('react-confetti', () => ({ + default: () =>
, +})) + +describe('main.tsx entry point', () => { + const defaultProps = { + clientConfig: { connect: { mode: AGG_MODE } }, + profiles: {}, + userFeatures: {}, + language: { locale: 'en', localizedContent: {} }, + } + + beforeEach(() => { + vi.clearAllMocks() + }) + + describe('ConnectWidget initialization', () => { + it('renders ConnectWidget with aggregation mode config', async () => { + render() + + await waitFor(() => { + expect(document.querySelector('#connect-wrapper')).toBeInTheDocument() + }) + }) + + it('uses the correct default mode configuration', () => { + const expectedConfig = { connect: { mode: AGG_MODE } } + + expect(expectedConfig.connect.mode).toBe('aggregation') + }) + + it('renders without errors when using main.tsx config structure', () => { + expect(() => { + render() + }).not.toThrow() + }) + }) + + describe('DOM mounting', () => { + it('renders to a root element', () => { + const container = document.createElement('div') + container.id = 'root' + document.body.appendChild(container) + + render(, { container }) + + expect(container.children.length).toBeGreaterThan(0) + + document.body.removeChild(container) + }) + + it('mounts ConnectWidget successfully', () => { + const { container } = render() + + expect(container.firstChild).toBeInTheDocument() + }) + }) + + describe('configuration validation', () => { + it('accepts the main.tsx config format', () => { + const { container } = render() + + expect(container).toBeInTheDocument() + }) + + it('uses aggregation mode as specified in main.tsx', async () => { + render() + + await waitFor(() => { + expect(document.querySelector('#connect-wrapper')).toBeInTheDocument() + }) + }) + + it('renders the widget with correct mode constant', () => { + expect(AGG_MODE).toBe('aggregation') + + const { container } = render() + + expect(container).toBeTruthy() + }) + }) + + describe('React 18 compatibility', () => { + it('is compatible with React 18 createRoot API', () => { + expect(() => { + render() + }).not.toThrow() + }) + + it('renders without strict mode violations', () => { + const { container } = render( + + + , + ) + + expect(container).toBeInTheDocument() + }) + }) +}) diff --git a/src/components/Container.js b/src/components/Container.js deleted file mode 100644 index 51d48c7c1c..0000000000 --- a/src/components/Container.js +++ /dev/null @@ -1,40 +0,0 @@ -import React from 'react' -import PropTypes from 'prop-types' - -import { useTokens } from '@kyper/tokenprovider' - -import { STEPS } from 'src/const/Connect' -/** - * Our root container to handle our widgets min/max widths, positioning and padding for all views - */ -export const Container = (props) => { - const tokens = useTokens() - const styles = getStyles(tokens, props.step) - - return ( -
-
{props.children}
-
- ) -} -Container.propTypes = { - step: PropTypes.string, -} - -const getStyles = (tokens, step) => { - return { - container: { - backgroundColor: tokens.BackgroundColor.Container, - minHeight: '100%', - maxHeight: step === STEPS.SEARCH ? '100%' : null, - display: 'flex', - justifyContent: 'center', - }, - content: { - maxWidth: '400px', // Our max content width (does not include side margin) - minWidth: '270px', // Our min content width (does not include side margin) - width: '100%', // We want this container to shrink and grow between our min-max - margin: tokens.Spacing.Large, - }, - } -} diff --git a/src/components/Container.tsx b/src/components/Container.tsx index 7fa7c9f0ae..b44d8766a7 100644 --- a/src/components/Container.tsx +++ b/src/components/Container.tsx @@ -1,9 +1,11 @@ import React from 'react' import { useTokens } from '@kyper/tokenprovider' +import { STEPS } from 'src/const/Connect' interface ContainerProps { children?: React.ReactNode + step?: string } /** @@ -11,7 +13,7 @@ interface ContainerProps { */ export const Container: React.FC = (props) => { const tokens = useTokens() - const styles = getStyles(tokens) + const styles = getStyles(tokens, props.step) return (
@@ -21,11 +23,12 @@ export const Container: React.FC = (props) => { } // eslint-disable-next-line @typescript-eslint/no-explicit-any -const getStyles = (tokens: any) => { +const getStyles = (tokens: any, step?: string) => { return { container: { backgroundColor: tokens.BackgroundColor.Container, minHeight: '100%', + maxHeight: step === STEPS.SEARCH ? '100%' : undefined, display: 'flex', justifyContent: 'center', }, diff --git a/src/components/__tests__/ConnectInstitutionHeader-test.tsx b/src/components/__tests__/ConnectInstitutionHeader-test.tsx new file mode 100644 index 0000000000..75a9603496 --- /dev/null +++ b/src/components/__tests__/ConnectInstitutionHeader-test.tsx @@ -0,0 +1,131 @@ +import React from 'react' +import { describe, it, expect, vi } from 'vitest' +import { render, screen } from 'src/utilities/testingLibrary' +import { ConnectInstitutionHeader } from 'src/components/ConnectInstitutionHeader' +import { COLOR_SCHEME } from 'src/const/Connect' +import { initialState } from 'src/services/mockedData' + +// Mock SVG imports +vi.mock('src/images/header/HeaderDevice.svg', () => ({ + default: () =>
, +})) +vi.mock('src/images/header/HeaderDefaultInstitution.svg', () => ({ + default: () =>
, +})) +vi.mock('src/images/header/HeaderBackdropDark.svg', () => ({ + default: () =>
, +})) +vi.mock('src/images/header/HeaderBackdropLight.svg', () => ({ + default: () =>
, +})) + +// Mock InstitutionLogo component +vi.mock('@kyper/institutionlogo', () => ({ + InstitutionLogo: ({ institutionGuid, size }: { institutionGuid: string; size: number }) => ( +
+ ), +})) + +describe('ConnectInstitutionHeader', () => { + const createPreloadedState = (colorScheme: string) => ({ + ...initialState, + config: { + ...initialState.config, + color_scheme: colorScheme, + }, + }) + + describe('rendering', () => { + it('renders the header container with correct data-test attribute', () => { + const preloadedState = createPreloadedState(COLOR_SCHEME.LIGHT) + const { container } = render(, { preloadedState }) + + const header = container.querySelector('[data-test="disclosure-svg-header"]') + expect(header).toBeInTheDocument() + }) + + it('renders HeaderDevice in all cases', () => { + const preloadedState = createPreloadedState(COLOR_SCHEME.LIGHT) + render(, { preloadedState }) + + expect(screen.getByTestId('header-device')).toBeInTheDocument() + }) + }) + + describe('color scheme - light mode', () => { + it('renders HeaderBackdropLight when color scheme is light', () => { + const preloadedState = createPreloadedState(COLOR_SCHEME.LIGHT) + render(, { preloadedState }) + + expect(screen.getByTestId('header-backdrop-light')).toBeInTheDocument() + expect(screen.queryByTestId('header-backdrop-dark')).not.toBeInTheDocument() + }) + }) + + describe('color scheme - dark mode', () => { + it('renders HeaderBackdropDark when color scheme is dark', () => { + const preloadedState = createPreloadedState(COLOR_SCHEME.DARK) + render(, { preloadedState }) + + expect(screen.getByTestId('header-backdrop-dark')).toBeInTheDocument() + expect(screen.queryByTestId('header-backdrop-light')).not.toBeInTheDocument() + }) + }) + + describe('institution logo', () => { + it('renders InstitutionLogo when institutionGuid is provided', () => { + const preloadedState = createPreloadedState(COLOR_SCHEME.LIGHT) + const institutionGuid = 'INS-12345' + + render(, { preloadedState }) + + const logo = screen.getByTestId('institution-logo') + expect(logo).toBeInTheDocument() + expect(logo.getAttribute('data-institution-guid')).toBe(institutionGuid) + expect(logo.getAttribute('data-size')).toBe('64') + }) + + it('renders default institution icon when no institutionGuid is provided', () => { + const preloadedState = createPreloadedState(COLOR_SCHEME.LIGHT) + render(, { preloadedState }) + + expect(screen.getByTestId('header-default-institution')).toBeInTheDocument() + expect(screen.queryByTestId('institution-logo')).not.toBeInTheDocument() + }) + + it('renders default institution icon when institutionGuid is undefined', () => { + const preloadedState = createPreloadedState(COLOR_SCHEME.LIGHT) + render(, { preloadedState }) + + expect(screen.getByTestId('header-default-institution')).toBeInTheDocument() + expect(screen.queryByTestId('institution-logo')).not.toBeInTheDocument() + }) + }) + + describe('integration', () => { + it('renders all elements together correctly in light mode with institution', () => { + const preloadedState = createPreloadedState(COLOR_SCHEME.LIGHT) + const institutionGuid = 'INS-BANK-001' + + const { container } = render(, { + preloadedState, + }) + + expect(container.querySelector('[data-test="disclosure-svg-header"]')).toBeInTheDocument() + expect(screen.getByTestId('header-backdrop-light')).toBeInTheDocument() + expect(screen.getByTestId('header-device')).toBeInTheDocument() + expect(screen.getByTestId('institution-logo')).toBeInTheDocument() + }) + + it('renders all elements together correctly in dark mode without institution', () => { + const preloadedState = createPreloadedState(COLOR_SCHEME.DARK) + + const { container } = render(, { preloadedState }) + + expect(container.querySelector('[data-test="disclosure-svg-header"]')).toBeInTheDocument() + expect(screen.getByTestId('header-backdrop-dark')).toBeInTheDocument() + expect(screen.getByTestId('header-device')).toBeInTheDocument() + expect(screen.getByTestId('header-default-institution')).toBeInTheDocument() + }) + }) +}) diff --git a/src/components/__tests__/Container-test.tsx b/src/components/__tests__/Container-test.tsx new file mode 100644 index 0000000000..e48c3ca4ff --- /dev/null +++ b/src/components/__tests__/Container-test.tsx @@ -0,0 +1,266 @@ +import React from 'react' +import { describe, it, expect } from 'vitest' +import { render, screen } from 'src/utilities/testingLibrary' +import { Container } from 'src/components/Container.tsx' +import { STEPS } from 'src/const/Connect' +import { initialState } from 'src/services/mockedData' + +describe('Container', () => { + const preloadedState = initialState + + describe('rendering', () => { + it('renders the container with correct data-test attribute', () => { + const { container } = render( + +
Test Content
+
, + { preloadedState }, + ) + + const containerDiv = container.querySelector('[data-test="container"]') + expect(containerDiv).toBeInTheDocument() + }) + + it('renders children content', () => { + render( + +
Test Content
+
, + { preloadedState }, + ) + + expect(screen.getByTestId('child-content')).toBeInTheDocument() + expect(screen.getByText('Test Content')).toBeInTheDocument() + }) + + it('renders multiple children', () => { + render( + +
First Child
+
Second Child
+ Third Child +
, + { preloadedState }, + ) + + expect(screen.getByTestId('child-1')).toBeInTheDocument() + expect(screen.getByTestId('child-2')).toBeInTheDocument() + expect(screen.getByTestId('child-3')).toBeInTheDocument() + }) + + it('renders without children', () => { + const { container } = render(, { preloadedState }) + + const containerDiv = container.querySelector('[data-test="container"]') + expect(containerDiv).toBeInTheDocument() + }) + + it('renders with null children', () => { + const { container } = render({null}, { preloadedState }) + + const containerDiv = container.querySelector('[data-test="container"]') + expect(containerDiv).toBeInTheDocument() + }) + }) + + describe('step prop', () => { + it('renders without step prop', () => { + const { container } = render( + +
Content
+
, + { preloadedState }, + ) + + const containerDiv = container.querySelector('[data-test="container"]') + expect(containerDiv).toBeInTheDocument() + // Without step prop, maxHeight should be null + expect(containerDiv).not.toHaveStyle({ maxHeight: '100%' }) + }) + + it('renders with SEARCH step and applies maxHeight', () => { + const { container } = render( + +
Search Content
+
, + { preloadedState }, + ) + + const containerDiv = container.querySelector('[data-test="container"]') + expect(containerDiv).toBeInTheDocument() + // When step is SEARCH, maxHeight should be '100%' + expect(containerDiv).toHaveStyle({ maxHeight: '100%' }) + }) + + it('renders with non-SEARCH step without maxHeight constraint', () => { + const { container } = render( + +
Connected Content
+
, + { preloadedState }, + ) + + const containerDiv = container.querySelector('[data-test="container"]') + expect(containerDiv).toBeInTheDocument() + // When step is not SEARCH, maxHeight should not be '100%' + expect(containerDiv).not.toHaveStyle({ maxHeight: '100%' }) + }) + + it('renders correctly with ENTER_CREDENTIALS step', () => { + render( + +
Enter Credentials
+
, + { preloadedState }, + ) + + expect(screen.getByTestId('credentials-content')).toBeInTheDocument() + }) + + it('renders correctly with MFA step', () => { + render( + +
MFA Content
+
, + { preloadedState }, + ) + + expect(screen.getByTestId('mfa-content')).toBeInTheDocument() + }) + }) + + describe('styling', () => { + it('applies consistent container styles', () => { + const { container } = render( + +
Content
+
, + { preloadedState }, + ) + + const containerDiv = container.querySelector('[data-test="container"]') + expect(containerDiv).toHaveStyle({ + minHeight: '100%', + display: 'flex', + justifyContent: 'center', + }) + }) + + it('has a content wrapper with proper constraints', () => { + const { container } = render( + +
Content
+
, + { preloadedState }, + ) + + const containerDiv = container.querySelector('[data-test="container"]') + const contentWrapper = containerDiv?.firstChild as HTMLElement + + expect(contentWrapper).toBeInTheDocument() + expect(contentWrapper).toHaveStyle({ + maxWidth: '400px', + minWidth: '270px', + width: '100%', + }) + }) + + it('applies background color from tokens', () => { + const { container } = render( + +
Content
+
, + { preloadedState }, + ) + + const containerDiv = container.querySelector('[data-test="container"]') + // Background color should be set (actual value comes from tokens) + expect(containerDiv).toHaveStyle({ backgroundColor: expect.any(String) }) + }) + }) + + describe('integration', () => { + it('renders complete structure with SEARCH step', () => { + const { container } = render( + +
Search for institution
+
, + { preloadedState }, + ) + + const containerDiv = container.querySelector('[data-test="container"]') + expect(containerDiv).toBeInTheDocument() + expect(containerDiv).toHaveStyle({ maxHeight: '100%' }) + expect(screen.getByTestId('search-content')).toBeInTheDocument() + expect(screen.getByText('Search for institution')).toBeInTheDocument() + }) + + it('renders complete structure with CONNECTED step', () => { + const { container } = render( + +
Successfully connected!
+
, + { preloadedState }, + ) + + const containerDiv = container.querySelector('[data-test="container"]') + expect(containerDiv).toBeInTheDocument() + expect(screen.getByTestId('success-message')).toBeInTheDocument() + expect(screen.getByText('Successfully connected!')).toBeInTheDocument() + }) + + it('renders nested component structure', () => { + render( + +
+

Title

+
+

Nested Content

+
+
+
, + { preloadedState }, + ) + + expect(screen.getByTestId('outer')).toBeInTheDocument() + expect(screen.getByTestId('inner')).toBeInTheDocument() + expect(screen.getByText('Title')).toBeInTheDocument() + expect(screen.getByText('Nested Content')).toBeInTheDocument() + }) + + it('maintains structure with form elements', () => { + render( + +
+ + +
+
, + { preloadedState }, + ) + + expect(screen.getByTestId('test-form')).toBeInTheDocument() + expect(screen.getByTestId('test-input')).toBeInTheDocument() + expect(screen.getByTestId('test-button')).toBeInTheDocument() + }) + + it('wraps components consistently regardless of content type', () => { + const { container } = render( + +
+ Text + test + +
+
, + { preloadedState }, + ) + + const containerDiv = container.querySelector('[data-test="container"]') + expect(containerDiv).toBeInTheDocument() + expect(screen.getByText('Text')).toBeInTheDocument() + expect(screen.getByAltText('test')).toBeInTheDocument() + expect(screen.getByRole('button', { name: 'Click' })).toBeInTheDocument() + }) + }) +}) diff --git a/src/components/__tests__/DeleteMemberSurvey-test.tsx b/src/components/__tests__/DeleteMemberSurvey-test.tsx new file mode 100644 index 0000000000..f511f67130 --- /dev/null +++ b/src/components/__tests__/DeleteMemberSurvey-test.tsx @@ -0,0 +1,353 @@ +import React from 'react' +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { render, screen, waitFor } from 'src/utilities/testingLibrary' +import { DeleteMemberSurvey } from 'src/components/DeleteMemberSurvey' +import { initialState, CONNECTED_MEMBER, NON_CONNECTED_MEMBER } from 'src/services/mockedData' +import userEvent from '@testing-library/user-event' + +describe('DeleteMemberSurvey', () => { + const preloadedState = initialState + + const mockOnCancel = vi.fn() + const mockOnDeleteSuccess = vi.fn() + + beforeEach(() => { + vi.clearAllMocks() + }) + + describe('rendering', () => { + it('renders the disconnect institution dialog', () => { + const { container } = render( + , + { preloadedState }, + ) + + const dialog = container.querySelector('[role="dialog"]') + expect(dialog).toBeInTheDocument() + }) + + it('renders the disconnect institution heading', () => { + render( + , + { preloadedState }, + ) + + expect(screen.getByText('Disconnect institution')).toBeInTheDocument() + }) + + it('renders the disclaimer with member name', () => { + render( + , + { preloadedState }, + ) + + const disclaimer = screen.getByTestId('disconnect-disclaimer') + expect(disclaimer).toBeInTheDocument() + expect(disclaimer.textContent).toContain('Chase Bank') + }) + + it('renders disconnect and cancel buttons', () => { + render( + , + { preloadedState }, + ) + + expect(screen.getByTestId('disconnect-button')).toBeInTheDocument() + expect(screen.getByTestId('disconnect-cancel-button')).toBeInTheDocument() + }) + + it('renders required field indicator', () => { + render( + , + { preloadedState }, + ) + + expect(screen.getByText('Required')).toBeInTheDocument() + }) + }) + + describe('connected member reasons', () => { + it('renders correct reasons for connected member', () => { + render( + , + { preloadedState }, + ) + + expect(screen.getByText("I no longer use this account or it's not mine")).toBeInTheDocument() + expect(screen.getByText("I don't want to share my data")).toBeInTheDocument() + expect(screen.getByText("I don't want to use this app")).toBeInTheDocument() + expect(screen.getByText('Other')).toBeInTheDocument() + }) + + it('does not render non-connected reasons for connected member', () => { + render( + , + { preloadedState }, + ) + + expect(screen.queryByText('I am unable to connect this account here')).not.toBeInTheDocument() + expect( + screen.queryByText('The account information is old or inaccurate'), + ).not.toBeInTheDocument() + expect(screen.queryByText("I don't want this account connected here")).not.toBeInTheDocument() + }) + }) + + describe('non-connected member reasons', () => { + it('renders correct reasons for non-connected member', () => { + render( + , + { preloadedState }, + ) + + expect(screen.getByText('I am unable to connect this account here')).toBeInTheDocument() + expect(screen.getByText('The account information is old or inaccurate')).toBeInTheDocument() + expect(screen.getByText("I don't want this account connected here")).toBeInTheDocument() + expect(screen.getByText('Other')).toBeInTheDocument() + }) + + it('does not render connected-only reasons for non-connected member', () => { + render( + , + { preloadedState }, + ) + + expect( + screen.queryByText("I no longer use this account or it's not mine"), + ).not.toBeInTheDocument() + expect(screen.queryByText("I don't want to share my data")).not.toBeInTheDocument() + expect(screen.queryByText("I don't want to use this app")).not.toBeInTheDocument() + }) + }) + + describe('user interactions', () => { + it('calls onCancel when cancel button is clicked', async () => { + const user = userEvent.setup() + render( + , + { preloadedState }, + ) + + await user.click(screen.getByTestId('disconnect-cancel-button')) + + expect(mockOnCancel).toHaveBeenCalledTimes(1) + }) + + it('allows selecting a reason', async () => { + const user = userEvent.setup() + render( + , + { preloadedState }, + ) + + const options = screen.getAllByRole('radio') + await user.click(options[0]) + + expect(options[0]).toBeChecked() + }) + + it('allows changing selected reason', async () => { + const user = userEvent.setup() + render( + , + { preloadedState }, + ) + + const options = screen.getAllByRole('radio') + await user.click(options[0]) + expect(options[0]).toBeChecked() + + await user.click(options[1]) + expect(options[1]).toBeChecked() + expect(options[0]).not.toBeChecked() + }) + }) + + describe('form validation', () => { + it('shows validation error when disconnect clicked without selecting reason', async () => { + const user = userEvent.setup() + render( + , + { preloadedState }, + ) + + await user.click(screen.getByTestId('disconnect-button')) + + await waitFor(() => { + expect(screen.getByText('Choose a reason for deleting')).toBeInTheDocument() + }) + }) + + it('does not show validation error before first submit attempt', () => { + render( + , + { preloadedState }, + ) + + expect(screen.queryByText('Choose a reason for deleting')).not.toBeInTheDocument() + }) + + it('validation error disappears after selecting a reason', async () => { + const user = userEvent.setup() + render( + , + { preloadedState }, + ) + + // First click without selection to trigger error + await user.click(screen.getByTestId('disconnect-button')) + + await waitFor(() => { + expect(screen.getByText('Choose a reason for deleting')).toBeInTheDocument() + }) + + // Select a reason - error should disappear + const options = screen.getAllByRole('radio') + await user.click(options[0]) + + // Error should no longer be visible + await waitFor(() => { + expect(screen.queryByText('Choose a reason for deleting')).not.toBeInTheDocument() + }) + }) + }) + + describe('delete member flow', () => { + it('initiates delete when disconnect clicked with valid selection', async () => { + const user = userEvent.setup() + render( + , + { preloadedState }, + ) + + const options = screen.getAllByRole('radio') + await user.click(options[0]) + + await user.click(screen.getByTestId('disconnect-button')) + + // Validation error should not appear + expect(screen.queryByText('Choose a reason for deleting')).not.toBeInTheDocument() + }) + }) + + describe('integration', () => { + it('renders complete structure for connected member', () => { + const { container } = render( + , + { preloadedState }, + ) + + expect(container.querySelector('[role="dialog"]')).toBeInTheDocument() + expect(screen.getByText('Disconnect institution')).toBeInTheDocument() + expect(screen.getByTestId('disconnect-disclaimer')).toBeInTheDocument() + expect(screen.getAllByRole('radio').length).toBeGreaterThan(0) + expect(screen.getByTestId('disconnect-button')).toBeInTheDocument() + expect(screen.getByTestId('disconnect-cancel-button')).toBeInTheDocument() + }) + + it('renders complete structure for non-connected member', () => { + const { container } = render( + , + { preloadedState }, + ) + + expect(container.querySelector('[role="dialog"]')).toBeInTheDocument() + expect(screen.getByText('Disconnect institution')).toBeInTheDocument() + expect(screen.getByTestId('disconnect-disclaimer').textContent).toContain('Wells Fargo') + expect(screen.getAllByRole('radio').length).toBeGreaterThan(0) + }) + + it('handles complete user flow from selection to cancel', async () => { + const user = userEvent.setup() + render( + , + { preloadedState }, + ) + + // Select a reason + const options = screen.getAllByRole('radio') + await user.click(options[0]) + expect(options[0]).toBeChecked() + + // Cancel + await user.click(screen.getByTestId('disconnect-cancel-button')) + expect(mockOnCancel).toHaveBeenCalledTimes(1) + }) + }) +}) diff --git a/src/components/__tests__/DetailReviewItem-test.tsx b/src/components/__tests__/DetailReviewItem-test.tsx new file mode 100644 index 0000000000..e96c8f01de --- /dev/null +++ b/src/components/__tests__/DetailReviewItem-test.tsx @@ -0,0 +1,274 @@ +import React from 'react' +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { render, screen } from 'src/utilities/testingLibrary' +import { DetailReviewItem } from 'src/components/DetailReviewItem' +import { initialState } from 'src/services/mockedData' +import userEvent from '@testing-library/user-event' + +describe('DetailReviewItem', () => { + const preloadedState = initialState + + const defaultProps = { + label: 'Email', + value: 'user@example.com', + ariaButtonLabel: 'Edit email', + isEditable: false, + onEditClick: vi.fn(), + } + + beforeEach(() => { + vi.clearAllMocks() + }) + + describe('rendering', () => { + it('renders the label', () => { + render(, { preloadedState }) + + expect(screen.getByText('Email')).toBeInTheDocument() + }) + + it('renders the value', () => { + render(, { preloadedState }) + + expect(screen.getByText('user@example.com')).toBeInTheDocument() + }) + + it('renders with correct data-test attributes for label', () => { + const { container } = render(, { preloadedState }) + + const labelElement = container.querySelector('[data-test="Email-row"]') + expect(labelElement).toBeInTheDocument() + }) + + it('renders with correct data-test attributes for value', () => { + const { container } = render(, { preloadedState }) + + const valueElement = container.querySelector('[data-test="user@example.com-row"]') + expect(valueElement).toBeInTheDocument() + }) + + it('renders edit button with correct aria-label', () => { + render(, { preloadedState }) + + const button = screen.getByRole('button', { name: 'Edit email' }) + expect(button).toBeInTheDocument() + }) + + it('renders edit icon', () => { + const { container } = render(, { preloadedState }) + + expect(container.querySelector('[data-test="Email-edit-button"]')).toBeInTheDocument() + }) + + it('sanitizes label with spaces for data-test attribute', () => { + const { container } = render(, { + preloadedState, + }) + + expect(container.querySelector('[data-test="Full-Name-row"]')).toBeInTheDocument() + expect(container.querySelector('[data-test="Full-Name-edit-button"]')).toBeInTheDocument() + }) + }) + + describe('edit button functionality', () => { + it('calls onEditClick when edit button is clicked', async () => { + const user = userEvent.setup() + const mockOnEditClick = vi.fn() + + render(, { + preloadedState, + }) + + const button = screen.getByRole('button', { name: 'Edit email' }) + await user.click(button) + + expect(mockOnEditClick).toHaveBeenCalledTimes(1) + }) + + it('does not call onEditClick when button is disabled', async () => { + const user = userEvent.setup() + const mockOnEditClick = vi.fn() + + render( + , + { preloadedState }, + ) + + const button = screen.getByRole('button', { name: 'Edit email' }) + expect(button).toBeDisabled() + + try { + await user.click(button) + } catch (_e) { + // Expected to fail on disabled button + } + expect(mockOnEditClick).not.toHaveBeenCalled() + }) + + it('enables edit button when isEditable is false', () => { + render(, { preloadedState }) + + const button = screen.getByRole('button', { name: 'Edit email' }) + expect(button).toBeEnabled() + }) + + it('disables edit button when isEditable is true', () => { + render(, { preloadedState }) + + const button = screen.getByRole('button', { name: 'Edit email' }) + expect(button).toBeDisabled() + }) + }) + + describe('different content types', () => { + it('renders with phone number', () => { + render( + , + { preloadedState }, + ) + + expect(screen.getByText('Phone')).toBeInTheDocument() + expect(screen.getByText('555-123-4567')).toBeInTheDocument() + }) + + it('renders with address', () => { + render( + , + { preloadedState }, + ) + + expect(screen.getByText('Address')).toBeInTheDocument() + expect(screen.getByText('123 Main St, City, ST 12345')).toBeInTheDocument() + }) + + it('renders with date', () => { + render( + , + { preloadedState }, + ) + + expect(screen.getByText('Date of Birth')).toBeInTheDocument() + expect(screen.getByText('01/01/1990')).toBeInTheDocument() + }) + + it('renders with long text value', () => { + const longValue = + 'This is a very long value that might wrap to multiple lines depending on the container width' + + render( + , + { preloadedState }, + ) + + expect(screen.getByText('Description')).toBeInTheDocument() + expect(screen.getByText(longValue)).toBeInTheDocument() + }) + }) + + describe('data-test attribute handling', () => { + it('handles special characters in label', () => { + const { container } = render( + , + { preloadedState }, + ) + + // Spaces are replaced with hyphens + expect(container.querySelector('[data-test="First-&-Last-Name-row"]')).toBeInTheDocument() + expect( + container.querySelector('[data-test="First-&-Last-Name-edit-button"]'), + ).toBeInTheDocument() + }) + + it('handles special characters in value', () => { + const { container } = render( + , + { preloadedState }, + ) + + expect(container.querySelector('[data-test="user+test@example.com-row"]')).toBeInTheDocument() + }) + }) + + describe('integration', () => { + it('renders complete structure with all elements', () => { + const { container } = render(, { preloadedState }) + + expect(screen.getByText('Email')).toBeInTheDocument() + + expect(screen.getByText('user@example.com')).toBeInTheDocument() + + expect(screen.getByRole('button', { name: 'Edit email' })).toBeInTheDocument() + + expect(container.querySelector('[data-test="Email-row"]')).toBeInTheDocument() + expect(container.querySelector('[data-test="user@example.com-row"]')).toBeInTheDocument() + expect(container.querySelector('[data-test="Email-edit-button"]')).toBeInTheDocument() + }) + + it('handles complete user interaction flow', async () => { + const user = userEvent.setup() + const mockOnEditClick = vi.fn() + + render(, { + preloadedState, + }) + + expect(screen.getByText('Email')).toBeInTheDocument() + expect(screen.getByText('user@example.com')).toBeInTheDocument() + + const button = screen.getByRole('button', { name: 'Edit email' }) + expect(button).toBeEnabled() + + await user.click(button) + + expect(mockOnEditClick).toHaveBeenCalledTimes(1) + }) + + it('renders correctly with multiple items scenario', () => { + const { rerender } = render(, { preloadedState }) + + expect(screen.getByText('Email')).toBeInTheDocument() + + rerender( + , + ) + + expect(screen.getByText('Phone')).toBeInTheDocument() + expect(screen.getByText('555-1234')).toBeInTheDocument() + }) + }) +}) diff --git a/src/components/__tests__/RenderConnectStep-test.jsx b/src/components/__tests__/RenderConnectStep-test.jsx index 6207b671f2..4f3212d70d 100644 --- a/src/components/__tests__/RenderConnectStep-test.jsx +++ b/src/components/__tests__/RenderConnectStep-test.jsx @@ -1,16 +1,19 @@ import React from 'react' import { render, screen } from 'src/utilities/testingLibrary' import RenderConnectStep from 'src/components/RenderConnectStep' -import { STEPS } from 'src/const/Connect' +import { VERIFY_MODE, STEPS } from 'src/const/Connect' import { createRenderConnectStepInitialState } from 'src/utilities/test/createRenderConnectStepInitialState' +import { initialState } from 'src/services/mockedData' describe('RenderConnectStep', () => { const defaultProps = { availableAccountTypes: [], handleConsentGoBack: vi.fn(), handleCredentialsGoBack: vi.fn(), + handleOAuthGoBack: vi.fn(), navigationRef: React.createRef(), onManualAccountAdded: vi.fn(), + onSuccessfulAggregation: vi.fn(), onUpsertMember: vi.fn(), setConnectLocalState: vi.fn(), } @@ -23,30 +26,236 @@ describe('RenderConnectStep', () => { url: 'https://testbank.com', } - it('should render DemoConnectGuard when step is DEMO_CONNECT_GUARD', () => { - const initialState = createRenderConnectStepInitialState( - STEPS.DEMO_CONNECT_GUARD, - mockInstitution, - ) + beforeEach(() => { + vi.clearAllMocks() + }) + + describe('Step Rendering', () => { + it('should render DemoConnectGuard when step is DEMO_CONNECT_GUARD', () => { + const state = createRenderConnectStepInitialState(STEPS.DEMO_CONNECT_GUARD, mockInstitution) + + const { container } = render(, { + preloadedState: state, + }) + + expect(screen.getByText('Demo mode active')).toBeInTheDocument() + expect( + screen.getByText(/Live institutions are not available in the demo environment/i), + ).toBeInTheDocument() + expect(screen.getByText('MX Bank')).toBeInTheDocument() + + const logo = screen.getByAltText('Logo for Test Bank') + expect(logo).toBeInTheDocument() + expect(logo).toHaveAttribute('src', mockInstitution.logo_url) + + const errorIcon = container.querySelector('svg.MuiSvgIcon-colorError') + expect(errorIcon).toBeInTheDocument() + + const button = screen.getByRole('button', { name: /return to institution selection/i }) + expect(button).toBeInTheDocument() + }) + + it('should render Search view for SEARCH step', () => { + const state = createRenderConnectStepInitialState(STEPS.SEARCH) + + const { container } = render(, { + preloadedState: state, + }) + + const stepWrapper = container.firstChild + expect(stepWrapper).toBeInTheDocument() + }) + + it('should render Connecting view for CONNECTING step', () => { + const state = createRenderConnectStepInitialState(STEPS.CONNECTING, mockInstitution) + + const { container } = render(, { + preloadedState: state, + }) + + const stepWrapper = container.firstChild + expect(stepWrapper).toBeInTheDocument() + }) - const { container } = render(, { - preloadedState: initialState, + it('should default to SEARCH step when location is empty', () => { + const state = { + ...initialState, + connect: { + ...initialState.connect, + location: [], + }, + } + + const { container } = render(, { + preloadedState: state, + }) + + const stepWrapper = container.firstChild + expect(stepWrapper).toBeInTheDocument() + }) + }) + + describe('Styling', () => { + it('should apply maxHeight for SEARCH step', () => { + const state = createRenderConnectStepInitialState(STEPS.SEARCH) + + const { container } = render(, { + preloadedState: state, + }) + + const containerDiv = container.firstChild + expect(containerDiv).toHaveStyle({ + maxHeight: 'calc(100% - 60px)', + }) + }) + + it('should not apply maxHeight for non-SEARCH steps', () => { + const state = createRenderConnectStepInitialState(STEPS.DEMO_CONNECT_GUARD, mockInstitution) + + const { container } = render(, { + preloadedState: state, + }) + + const containerDiv = container.firstChild + expect(containerDiv.style.maxHeight).toBe('') }) - expect(screen.getByText('Demo mode active')).toBeInTheDocument() - expect( - screen.getByText(/Live institutions are not available in the demo environment/i), - ).toBeInTheDocument() - expect(screen.getByText('MX Bank')).toBeInTheDocument() + it('should apply correct container styles', () => { + const state = createRenderConnectStepInitialState(STEPS.SEARCH) + + const { container } = render(, { + preloadedState: state, + }) - const logo = screen.getByAltText('Logo for Test Bank') - expect(logo).toBeInTheDocument() - expect(logo).toHaveAttribute('src', mockInstitution.logo_url) + const containerDiv = container.firstChild + expect(containerDiv).toHaveStyle({ + display: 'flex', + justifyContent: 'center', + minHeight: 'calc(100% - 60px)', + }) + }) + }) - const errorIcon = container.querySelector('svg.MuiSvgIcon-colorError') - expect(errorIcon).toBeInTheDocument() + describe('Configuration-Dependent Rendering', () => { + it('should render in AGG_MODE by default', () => { + const state = createRenderConnectStepInitialState(STEPS.SEARCH) - const button = screen.getByRole('button', { name: /return to institution selection/i }) - expect(button).toBeInTheDocument() + const { container } = render(, { + preloadedState: state, + }) + + const stepWrapper = container.firstChild + expect(stepWrapper).toBeInTheDocument() + }) + + it('should render in VERIFY_MODE when configured', () => { + const state = { + ...createRenderConnectStepInitialState(STEPS.SEARCH), + config: { + ...initialState.config, + mode: VERIFY_MODE, + }, + } + + const { container } = render(, { + preloadedState: state, + }) + + const stepWrapper = container.firstChild + expect(stepWrapper).toBeInTheDocument() + }) + + it('should apply widget profile settings', () => { + const state = { + ...createRenderConnectStepInitialState(STEPS.SEARCH), + profiles: { + ...initialState.profiles, + widgetProfile: { + ...initialState.profiles.widgetProfile, + enable_support_requests: false, + }, + }, + } + + const { container } = render(, { + preloadedState: state, + }) + + const stepWrapper = container.firstChild + expect(stepWrapper).toBeInTheDocument() + }) + }) + + describe('Integration', () => { + it('should render complete component with all providers', () => { + const state = createRenderConnectStepInitialState(STEPS.SEARCH) + + const { container } = render(, { + preloadedState: state, + }) + + const stepWrapper = container.firstChild + expect(stepWrapper).toBeInTheDocument() + }) + + it('should handle step navigation', () => { + const state1 = createRenderConnectStepInitialState(STEPS.SEARCH) + + const { container, rerender } = render(, { + preloadedState: state1, + }) + + const stepWrapper = container.firstChild + expect(stepWrapper).toBeInTheDocument() + + rerender() + }) + + it('should pass props correctly to views', () => { + const state = createRenderConnectStepInitialState(STEPS.SEARCH) + + const { container } = render(, { + preloadedState: state, + }) + + const stepWrapper = container.firstChild + expect(stepWrapper).toBeInTheDocument() + }) + + it('should handle missing optional props gracefully', () => { + const minimalProps = { + handleConsentGoBack: vi.fn(), + handleCredentialsGoBack: vi.fn(), + handleOAuthGoBack: vi.fn(), + navigationRef: React.createRef(), + setConnectLocalState: vi.fn(), + } + + const state = createRenderConnectStepInitialState(STEPS.SEARCH) + + const { container } = render(, { + preloadedState: state, + }) + + const stepWrapper = container.firstChild + expect(stepWrapper).toBeInTheDocument() + }) + }) + + describe('Error Handling', () => { + it('should handle invalid step gracefully', () => { + const state = { + ...initialState, + connect: { + ...initialState.connect, + location: [{ step: 'INVALID_STEP' }], + }, + } + + render(, { + preloadedState: state, + }) + expect(true).toBe(true) + }) }) }) diff --git a/src/components/app/__tests__/IEDeprecationDialog-test.tsx b/src/components/app/__tests__/IEDeprecationDialog-test.tsx new file mode 100644 index 0000000000..a2ccda720e --- /dev/null +++ b/src/components/app/__tests__/IEDeprecationDialog-test.tsx @@ -0,0 +1,309 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +import React from 'react' +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { render, screen } from 'src/utilities/testingLibrary' +import userEvent from '@testing-library/user-event' +import { initialState } from 'src/services/mockedData' +import { IEDeprecationDialog } from '../IEDeprecationDialog' +import { PageviewInfo } from 'src/const/Analytics' +import { isIE } from 'src/utilities/Browser' + +// Mock Browser utility +vi.mock('src/utilities/Browser') + +describe('IEDeprecationDialog', () => { + const mockOnAnalyticPageview = vi.fn() + + const defaultProps = { + onAnalyticPageview: mockOnAnalyticPageview, + } + + const preloadedState = { + ...initialState, + profiles: { + ...initialState.profiles, + widgetProfile: { + enable_ie_11_deprecation: true, + }, + }, + } as any + + beforeEach(() => { + vi.clearAllMocks() + }) + + describe('Rendering', () => { + it('renders dialog when isIE is true and feature flag is enabled', () => { + vi.mocked(isIE).mockReturnValue(true) + + render(, { preloadedState }) + + expect(screen.getByText('This browser is not supported')).toBeInTheDocument() + }) + + it('does not render when isIE is false', () => { + vi.mocked(isIE).mockReturnValue(false) + + render(, { preloadedState }) + + expect(screen.queryByText('This browser is not supported')).not.toBeInTheDocument() + }) + + it('does not render when feature flag is disabled', () => { + vi.mocked(isIE).mockReturnValue(true) + + const stateWithoutFlag = { + ...initialState, + profiles: { + ...initialState.profiles, + widgetProfile: { + enable_ie_11_deprecation: false, + }, + }, + } as any + + render(, { + preloadedState: stateWithoutFlag, + }) + + expect(screen.queryByText('This browser is not supported')).not.toBeInTheDocument() + }) + + it('does not render when widgetProfile is null', () => { + vi.mocked(isIE).mockReturnValue(true) + + const stateWithoutProfile = { + ...initialState, + profiles: { + ...initialState.profiles, + widgetProfile: null, + }, + } as any + + render(, { + preloadedState: stateWithoutProfile, + }) + + expect(screen.queryByText('This browser is not supported')).not.toBeInTheDocument() + }) + + it('renders all text content', () => { + vi.mocked(isIE).mockReturnValue(true) + + render(, { preloadedState }) + + expect(screen.getByText('This browser is not supported')).toBeInTheDocument() + expect(screen.getByText(/We no longer support Internet Explorer/i)).toBeInTheDocument() + expect(screen.getByText('Continue')).toBeInTheDocument() + expect(screen.getByText(/Clicking the links to supported browsers/i)).toBeInTheDocument() + }) + + it('renders browser links with correct hrefs', () => { + vi.mocked(isIE).mockReturnValue(true) + + render(, { preloadedState }) + + const edgeLink = screen.getByText('Edge').closest('a') + const chromeLink = screen.getByText('Chrome').closest('a') + const firefoxLink = screen.getByText('Firefox').closest('a') + + expect(edgeLink).toHaveAttribute('href', 'https://www.microsoft.com/edge') + expect(edgeLink).toHaveAttribute('target', '_blank') + expect(edgeLink).toHaveAttribute('rel', 'noreferrer noopener') + + expect(chromeLink).toHaveAttribute('href', 'https://www.google.com/chrome/') + expect(chromeLink).toHaveAttribute('target', '_blank') + expect(chromeLink).toHaveAttribute('rel', 'noreferrer noopener') + + expect(firefoxLink).toHaveAttribute('href', 'https://www.mozilla.org/firefox/') + expect(firefoxLink).toHaveAttribute('target', '_blank') + expect(firefoxLink).toHaveAttribute('rel', 'noreferrer noopener') + }) + + it('renders close button with correct aria-label', () => { + vi.mocked(isIE).mockReturnValue(true) + + render(, { preloadedState }) + + const closeButton = screen.getByRole('button', { name: /close modal/i }) + expect(closeButton).toBeInTheDocument() + }) + }) + + describe('User Interactions', () => { + it('hides dialog when close button is clicked', async () => { + vi.mocked(isIE).mockReturnValue(true) + const user = userEvent.setup() + render(, { preloadedState }) + + const closeButton = screen.getByRole('button', { name: /close modal/i }) + expect(screen.getByText('This browser is not supported')).toBeInTheDocument() + + await user.click(closeButton) + + expect(screen.queryByText('This browser is not supported')).not.toBeInTheDocument() + }) + + it('hides dialog when continue button is clicked', async () => { + vi.mocked(isIE).mockReturnValue(true) + const user = userEvent.setup() + + render(, { preloadedState }) + + const continueButton = screen.getByRole('button', { name: /continue/i }) + expect(screen.getByText('This browser is not supported')).toBeInTheDocument() + + await user.click(continueButton) + + expect(screen.queryByText('This browser is not supported')).not.toBeInTheDocument() + }) + + it('keeps dialog hidden after being closed', async () => { + vi.mocked(isIE).mockReturnValue(true) + const user = userEvent.setup() + + const { rerender } = render(, { preloadedState }) + + const closeButton = screen.getByRole('button', { name: /close modal/i }) + await user.click(closeButton) + + expect(screen.queryByText('This browser is not supported')).not.toBeInTheDocument() + + // Rerender - dialog should stay hidden + rerender() + + expect(screen.queryByText('This browser is not supported')).not.toBeInTheDocument() + }) + }) + + describe('Analytics', () => { + it('tracks pageview when dialog is shown', () => { + vi.mocked(isIE).mockReturnValue(true) + + render(, { preloadedState }) + + expect(mockOnAnalyticPageview).toHaveBeenCalledWith(PageviewInfo.CONNECT_IE_11_DEPRECATION[1]) + expect(mockOnAnalyticPageview).toHaveBeenCalledTimes(1) + }) + + it('does not track pageview when not IE', () => { + vi.mocked(isIE).mockReturnValue(false) + + render(, { preloadedState }) + + expect(mockOnAnalyticPageview).not.toHaveBeenCalled() + }) + + it('does not track pageview when feature flag is disabled', () => { + vi.mocked(isIE).mockReturnValue(true) + + const stateWithoutFlag = { + ...initialState, + profiles: { + ...initialState.profiles, + widgetProfile: { + enable_ie_11_deprecation: false, + }, + }, + } as any + + render(, { + preloadedState: stateWithoutFlag, + }) + + expect(mockOnAnalyticPageview).not.toHaveBeenCalled() + }) + + it('does not track pageview after dialog is closed', async () => { + vi.mocked(isIE).mockReturnValue(true) + const user = userEvent.setup() + + render(, { preloadedState }) + + expect(mockOnAnalyticPageview).toHaveBeenCalledTimes(1) + + const closeButton = screen.getByRole('button', { name: /close modal/i }) + await user.click(closeButton) + + // Should not be called again after closing + expect(mockOnAnalyticPageview).toHaveBeenCalledTimes(1) + }) + }) + + describe('Integration', () => { + it('renders complete dialog structure with all elements', () => { + vi.mocked(isIE).mockReturnValue(true) + + render(, { preloadedState }) + + expect(screen.getByRole('button', { name: /close modal/i })).toBeInTheDocument() + expect(screen.getByRole('button', { name: /continue/i })).toBeInTheDocument() + + expect(screen.getByText('This browser is not supported')).toBeInTheDocument() + + expect(screen.getByText('Edge')).toBeInTheDocument() + expect(screen.getByText('Chrome')).toBeInTheDocument() + expect(screen.getByText('Firefox')).toBeInTheDocument() + }) + + it('handles full user interaction flow', async () => { + vi.mocked(isIE).mockReturnValue(true) + const user = userEvent.setup() + + render(, { preloadedState }) + + // Dialog visible + expect(screen.getByText('This browser is not supported')).toBeInTheDocument() + + // Analytics tracked + expect(mockOnAnalyticPageview).toHaveBeenCalledWith(PageviewInfo.CONNECT_IE_11_DEPRECATION[1]) + + // Close dialog + const continueButton = screen.getByRole('button', { name: /continue/i }) + await user.click(continueButton) + + // Dialog hidden + expect(screen.queryByText('This browser is not supported')).not.toBeInTheDocument() + + // Analytics called only once + expect(mockOnAnalyticPageview).toHaveBeenCalledTimes(1) + }) + + it('respects all conditional rendering flags', () => { + // Test all combinations + const testCases = [ + { isIE: false, flag: false, shouldRender: false }, + { isIE: false, flag: true, shouldRender: false }, + { isIE: true, flag: false, shouldRender: false }, + { isIE: true, flag: true, shouldRender: true }, + ] + + testCases.forEach(({ isIE: ieValue, flag, shouldRender }) => { + vi.mocked(isIE).mockReturnValue(ieValue) + + const testState = { + ...initialState, + profiles: { + ...initialState.profiles, + widgetProfile: { + enable_ie_11_deprecation: flag, + }, + }, + } as any + + const { unmount } = render(, { + preloadedState: testState, + }) + + if (shouldRender) { + expect(screen.getByText('This browser is not supported')).toBeInTheDocument() + } else { + expect(screen.queryByText('This browser is not supported')).not.toBeInTheDocument() + } + + unmount() + vi.clearAllMocks() + }) + }) + }) +}) diff --git a/src/const/__tests__/Accounts-test.js b/src/const/__tests__/Accounts-test.js new file mode 100644 index 0000000000..86001eb95f --- /dev/null +++ b/src/const/__tests__/Accounts-test.js @@ -0,0 +1,162 @@ +import { AccountTypeNames, ReadableAccountTypes } from '../Accounts' + +describe('Accounts Constants', () => { + describe('ReadableAccountTypes', () => { + it('should have UNKNOWN as 0', () => { + expect(ReadableAccountTypes.UNKNOWN).toBe(0) + }) + + it('should have CHECKING as 1', () => { + expect(ReadableAccountTypes.CHECKING).toBe(1) + }) + + it('should have SAVINGS as 2', () => { + expect(ReadableAccountTypes.SAVINGS).toBe(2) + }) + + it('should have LOAN as 3', () => { + expect(ReadableAccountTypes.LOAN).toBe(3) + }) + + it('should have CREDIT_CARD as 4', () => { + expect(ReadableAccountTypes.CREDIT_CARD).toBe(4) + }) + + it('should have INVESTMENT as 5', () => { + expect(ReadableAccountTypes.INVESTMENT).toBe(5) + }) + + it('should have LINE_OF_CREDIT as 6', () => { + expect(ReadableAccountTypes.LINE_OF_CREDIT).toBe(6) + }) + + it('should have MORTGAGE as 7', () => { + expect(ReadableAccountTypes.MORTGAGE).toBe(7) + }) + + it('should have PROPERTY as 8', () => { + expect(ReadableAccountTypes.PROPERTY).toBe(8) + }) + + it('should have CASH as 9', () => { + expect(ReadableAccountTypes.CASH).toBe(9) + }) + + it('should have INSURANCE as 10', () => { + expect(ReadableAccountTypes.INSURANCE).toBe(10) + }) + + it('should have PREPAID as 11', () => { + expect(ReadableAccountTypes.PREPAID).toBe(11) + }) + + it('should have CHECKING_LINE_OF_CREDIT as 12', () => { + expect(ReadableAccountTypes.CHECKING_LINE_OF_CREDIT).toBe(12) + }) + + it('should have exactly 13 account types', () => { + expect(Object.keys(ReadableAccountTypes)).toHaveLength(13) + }) + + it('should have all numeric values', () => { + Object.values(ReadableAccountTypes).forEach((value) => { + expect(typeof value).toBe('number') + }) + }) + + it('should have unique values', () => { + const values = Object.values(ReadableAccountTypes) + const uniqueValues = new Set(values) + expect(uniqueValues.size).toBe(values.length) + }) + }) + + describe('AccountTypeNames', () => { + it('should have 13 account type names', () => { + expect(AccountTypeNames).toHaveLength(13) + }) + + it('should have "Other" at index 0 for UNKNOWN', () => { + expect(AccountTypeNames[ReadableAccountTypes.UNKNOWN]).toBe('Other') + }) + + it('should have "Checking" at index 1 for CHECKING', () => { + expect(AccountTypeNames[ReadableAccountTypes.CHECKING]).toBe('Checking') + }) + + it('should have "Savings" at index 2 for SAVINGS', () => { + expect(AccountTypeNames[ReadableAccountTypes.SAVINGS]).toBe('Savings') + }) + + it('should have "Loan" at index 3 for LOAN', () => { + expect(AccountTypeNames[ReadableAccountTypes.LOAN]).toBe('Loan') + }) + + it('should have "Credit Card" at index 4 for CREDIT_CARD', () => { + expect(AccountTypeNames[ReadableAccountTypes.CREDIT_CARD]).toBe('Credit Card') + }) + + it('should have "Investment" at index 5 for INVESTMENT', () => { + expect(AccountTypeNames[ReadableAccountTypes.INVESTMENT]).toBe('Investment') + }) + + it('should have "Line of Credit" at index 6 for LINE_OF_CREDIT', () => { + expect(AccountTypeNames[ReadableAccountTypes.LINE_OF_CREDIT]).toBe('Line of Credit') + }) + + it('should have "Mortgage" at index 7 for MORTGAGE', () => { + expect(AccountTypeNames[ReadableAccountTypes.MORTGAGE]).toBe('Mortgage') + }) + + it('should have "Property" at index 8 for PROPERTY', () => { + expect(AccountTypeNames[ReadableAccountTypes.PROPERTY]).toBe('Property') + }) + + it('should have "Cash" at index 9 for CASH', () => { + expect(AccountTypeNames[ReadableAccountTypes.CASH]).toBe('Cash') + }) + + it('should have "Insurance" at index 10 for INSURANCE', () => { + expect(AccountTypeNames[ReadableAccountTypes.INSURANCE]).toBe('Insurance') + }) + + it('should have "Prepaid" at index 11 for PREPAID', () => { + expect(AccountTypeNames[ReadableAccountTypes.PREPAID]).toBe('Prepaid') + }) + + it('should have "Checking" at index 12 for CHECKING_LINE_OF_CREDIT', () => { + expect(AccountTypeNames[ReadableAccountTypes.CHECKING_LINE_OF_CREDIT]).toBe('Checking') + }) + + it('should have all string values', () => { + AccountTypeNames.forEach((name) => { + expect(typeof name).toBe('string') + }) + }) + }) + + describe('Integration between ReadableAccountTypes and AccountTypeNames', () => { + it('should map all ReadableAccountTypes to valid AccountTypeNames', () => { + Object.entries(ReadableAccountTypes).forEach(([_key, value]) => { + expect(AccountTypeNames[value]).toBeDefined() + expect(typeof AccountTypeNames[value]).toBe('string') + }) + }) + + it('should have correct mapping for UNKNOWN type', () => { + const name = AccountTypeNames[ReadableAccountTypes.UNKNOWN] + expect(name).toBe('Other') + }) + + it('should have correct mapping for standard account types', () => { + expect(AccountTypeNames[ReadableAccountTypes.CHECKING]).toBe('Checking') + expect(AccountTypeNames[ReadableAccountTypes.SAVINGS]).toBe('Savings') + expect(AccountTypeNames[ReadableAccountTypes.CREDIT_CARD]).toBe('Credit Card') + }) + + it('should handle CHECKING_LINE_OF_CREDIT as Checking', () => { + const name = AccountTypeNames[ReadableAccountTypes.CHECKING_LINE_OF_CREDIT] + expect(name).toBe('Checking') + }) + }) +}) diff --git a/src/const/__tests__/jobDetailCode-test.js b/src/const/__tests__/jobDetailCode-test.js new file mode 100644 index 0000000000..017071d0b3 --- /dev/null +++ b/src/const/__tests__/jobDetailCode-test.js @@ -0,0 +1,51 @@ +import { JOB_DETAIL_CODE } from '../jobDetailCode' + +describe('JOB_DETAIL_CODE Constants', () => { + describe('Structure', () => { + it('should be an object', () => { + expect(typeof JOB_DETAIL_CODE).toBe('object') + expect(JOB_DETAIL_CODE).not.toBeNull() + }) + + it('should have exactly 1 property', () => { + expect(Object.keys(JOB_DETAIL_CODE)).toHaveLength(1) + }) + + it('should have all numeric values', () => { + Object.values(JOB_DETAIL_CODE).forEach((value) => { + expect(typeof value).toBe('number') + }) + }) + + it('should have unique values', () => { + const values = Object.values(JOB_DETAIL_CODE) + const uniqueValues = new Set(values) + expect(uniqueValues.size).toBe(values.length) + }) + }) + + describe('NO_VERIFIABLE_ACCOUNTS', () => { + it('should exist', () => { + expect(JOB_DETAIL_CODE.NO_VERIFIABLE_ACCOUNTS).toBeDefined() + }) + + it('should equal 1000', () => { + expect(JOB_DETAIL_CODE.NO_VERIFIABLE_ACCOUNTS).toBe(1000) + }) + + it('should be a number', () => { + expect(typeof JOB_DETAIL_CODE.NO_VERIFIABLE_ACCOUNTS).toBe('number') + }) + }) + + describe('Export', () => { + it('should export JOB_DETAIL_CODE as a named export', () => { + expect(JOB_DETAIL_CODE).toBeDefined() + }) + + it('should not be frozen or sealed', () => { + expect(Object.isFrozen(JOB_DETAIL_CODE)).toBe(false) + expect(Object.isSealed(JOB_DETAIL_CODE)).toBe(false) + }) + }) +}) diff --git a/src/services/mockedData.ts b/src/services/mockedData.ts index a5f2bc3a9b..dd57dd2302 100644 --- a/src/services/mockedData.ts +++ b/src/services/mockedData.ts @@ -280,6 +280,33 @@ export const NEW_MEMBER = { tax_statement_is_enabled: true, successfully_aggregated_at: null, } + +export const CONNECTED_MEMBER = { + guid: 'MBR-123', + name: 'Chase Bank', + connection_status: 6, + aggregation_status: 1, + institution_guid: 'INS-123', + user_guid: 'USR-123', + is_being_aggregated: false, + is_manual: false, + is_managed_by_user: true, + is_oauth: false, +} + +export const NON_CONNECTED_MEMBER = { + guid: 'MBR-456', + name: 'Wells Fargo', + connection_status: 1, + aggregation_status: 0, + institution_guid: 'INS-456', + user_guid: 'USR-123', + is_being_aggregated: false, + is_manual: false, + is_managed_by_user: true, + is_oauth: false, +} + export const memberCredentialsData = { credentials: [ { diff --git a/src/views/credentials/__tests__/CreateMemberForm-test.tsx b/src/views/credentials/__tests__/CreateMemberForm-test.tsx new file mode 100644 index 0000000000..cb459a4baa --- /dev/null +++ b/src/views/credentials/__tests__/CreateMemberForm-test.tsx @@ -0,0 +1,396 @@ +import React from 'react' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { render, screen, waitFor } from 'src/utilities/testingLibrary' +import { CreateMemberForm } from '../CreateMemberForm' +import { + initialState, + institutionCredentialsData, + institutionData, + member, +} from 'src/services/mockedData' +import { apiValue as baseApiValue } from 'src/const/apiProviderMock' +import { PostMessageContext } from 'src/ConnectWidget' +import { ReadableStatuses } from 'src/const/Statuses' + +const mockPostMessage = { + onPostMessage: vi.fn(), + postMessageEventOverrides: {}, +} + +const defaultProps = { + navigationRef: vi.fn(), + onGoBackClick: vi.fn(), + onUpsertMember: vi.fn(), +} + +const preloadedState = { + ...initialState, + connect: { + ...initialState.connect, + selectedInstitution: institutionData.institution, + members: [], + }, + app: { + humanEvent: true, + }, +} + +const renderWithContext = (props = defaultProps, state = preloadedState, apiOverrides = {}) => { + const mockApi = { + ...baseApiValue, + addMember: vi.fn().mockResolvedValue(member), + getInstitutionCredentials: vi.fn().mockResolvedValue(institutionCredentialsData.credentials), + loadMemberByGuid: vi.fn().mockResolvedValue(member.member), + updateMember: vi.fn().mockResolvedValue(member.member), + ...apiOverrides, + } + + return { + ...render( + + + , + { + apiValue: mockApi, + preloadedState: state, + }, + ), + mockApi, + } +} + +describe('', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + describe('Loading State', () => { + it('displays loading spinner while fetching credentials', () => { + renderWithContext(defaultProps, preloadedState, { + getInstitutionCredentials: vi.fn().mockImplementation(() => new Promise(() => {})), + }) + + expect(screen.queryByText('Continue')).not.toBeInTheDocument() + }) + + it('calls getInstitutionCredentials on mount', () => { + const { mockApi } = renderWithContext() + + expect(mockApi.getInstitutionCredentials).toHaveBeenCalledWith( + institutionData.institution.guid, + ) + }) + }) + + describe('Credentials Display', () => { + it('renders Credentials component after loading credentials', async () => { + renderWithContext() + + expect(await screen.findByText('Continue')).toBeInTheDocument() + }) + + it('passes credentials to Credentials component', async () => { + renderWithContext() + + expect(await screen.findByLabelText('Username *')).toBeInTheDocument() + expect(await screen.findByLabelText('Password *')).toBeInTheDocument() + }) + + it('renders institution header', async () => { + renderWithContext() + + await screen.findByText('Continue') + + expect(screen.getByTestId('institution-block')).toBeInTheDocument() + }) + }) + + describe('Error Handling', () => { + it('handles error when fetching credentials fails', async () => { + const error = new Error('Failed to fetch credentials') + renderWithContext(defaultProps, preloadedState, { + getInstitutionCredentials: vi.fn().mockRejectedValue(error), + }) + + await waitFor(() => { + expect(screen.queryByText('Continue')).not.toBeInTheDocument() + }) + + expect(screen.getByTestId('institution-block')).toBeInTheDocument() + }) + }) + + describe('Member Creation', () => { + it('submits credentials and creates member', async () => { + const { mockApi, user } = renderWithContext() + + await user.type(await screen.findByLabelText('Username *'), 'testuser') + await user.type(await screen.findByLabelText('Password *'), 'testpass') + await user.click(screen.getByText('Continue')) + + await waitFor(() => { + expect(mockApi.addMember).toHaveBeenCalled() + }) + }) + + it('posts connect/enterCredentials message when creating member', async () => { + const { user } = renderWithContext() + + await user.type(await screen.findByLabelText('Username *'), 'testuser') + await user.type(await screen.findByLabelText('Password *'), 'testpass') + await user.click(screen.getByText('Continue')) + + await waitFor(() => { + expect(mockPostMessage.onPostMessage).toHaveBeenCalledWith('connect/enterCredentials', { + institution: { + guid: institutionData.institution.guid, + code: institutionData.institution.code, + }, + }) + }) + }) + + it('calls onUpsertMember callback when member is created', async () => { + const onUpsertMember = vi.fn() + const { user } = renderWithContext({ ...defaultProps, onUpsertMember }) + + await user.type(await screen.findByLabelText('Username *'), 'testuser') + await user.type(await screen.findByLabelText('Password *'), 'testpass') + await user.click(screen.getByText('Continue')) + + await waitFor( + () => { + expect(onUpsertMember).toHaveBeenCalledWith(member.member) + }, + { timeout: 1000 }, + ) + }) + + it('includes institution data in member creation request', async () => { + const { mockApi, user } = renderWithContext() + + await user.type(await screen.findByLabelText('Username *'), 'testuser') + await user.type(await screen.findByLabelText('Password *'), 'testpass') + await user.click(screen.getByText('Continue')) + + await waitFor(() => { + expect(mockApi.addMember).toHaveBeenCalledWith( + expect.objectContaining({ + institution_guid: institutionData.institution.guid, + rawInstitutionData: institutionData.institution, + }), + expect.any(Object), + true, + ) + }) + }) + }) + + describe('409 Conflict Handling', () => { + it('handles 409 error when member already exists and is challenged', async () => { + const existingMemberGuid = 'MBR-EXISTING' + const challengedMember = { + guid: existingMemberGuid, + connection_status: ReadableStatuses.CHALLENGED, + } + + /* eslint-disable @typescript-eslint/no-explicit-any */ + const stateWithMember: any = { + ...preloadedState, + connect: { + ...preloadedState.connect, + members: [challengedMember], + }, + } + + const { mockApi, user } = renderWithContext(defaultProps, stateWithMember, { + addMember: vi.fn().mockRejectedValue({ + response: { + status: 409, + data: { guid: existingMemberGuid }, + }, + }), + loadMemberByGuid: vi.fn().mockResolvedValue(challengedMember), + }) + + await user.type(await screen.findByLabelText('Username *'), 'testuser') + await user.type(await screen.findByLabelText('Password *'), 'testpass') + await user.click(screen.getByText('Continue')) + + await waitFor(() => { + expect(mockApi.loadMemberByGuid).toHaveBeenCalledWith(existingMemberGuid, 'en') + }) + }) + + it('handles 409 error when member exists and needs update', async () => { + const existingMemberGuid = 'MBR-EXISTING' + const existingMember = { + guid: existingMemberGuid, + connection_status: ReadableStatuses.CONNECTED, + use_cases: ['verification'], + } + const updatedMember = { + ...existingMember, + connection_status: ReadableStatuses.CONNECTED, + } + + /* eslint-disable @typescript-eslint/no-explicit-any */ + const stateWithMember: any = { + ...preloadedState, + connect: { + ...preloadedState.connect, + members: [existingMember], + }, + } + + const { mockApi, user } = renderWithContext(defaultProps, stateWithMember, { + addMember: vi.fn().mockRejectedValue({ + response: { + status: 409, + data: { guid: existingMemberGuid }, + }, + }), + loadMemberByGuid: vi.fn().mockResolvedValue(existingMember), + updateMember: vi.fn().mockResolvedValue(updatedMember), + }) + + await user.type(await screen.findByLabelText('Username *'), 'testuser') + await user.type(await screen.findByLabelText('Password *'), 'testpass') + await user.click(screen.getByText('Continue')) + + await waitFor( + () => { + expect(mockApi.updateMember).toHaveBeenCalled() + }, + { timeout: 1000 }, + ) + }) + + it('calls onUpsertMember when updating existing member', async () => { + const existingMemberGuid = 'MBR-EXISTING' + const existingMember = { + guid: existingMemberGuid, + connection_status: ReadableStatuses.CONNECTED, + use_cases: ['verification'], + } + const updatedMember = { + ...existingMember, + connection_status: ReadableStatuses.CONNECTED, + } + + const onUpsertMember = vi.fn() + /* eslint-disable @typescript-eslint/no-explicit-any */ + const stateWithMember: any = { + ...preloadedState, + connect: { + ...preloadedState.connect, + members: [existingMember], + }, + } + + const { user } = renderWithContext({ ...defaultProps, onUpsertMember }, stateWithMember, { + addMember: vi.fn().mockRejectedValue({ + response: { + status: 409, + data: { guid: existingMemberGuid }, + }, + }), + loadMemberByGuid: vi.fn().mockResolvedValue(existingMember), + updateMember: vi.fn().mockResolvedValue(updatedMember), + }) + + await user.type(await screen.findByLabelText('Username *'), 'testuser') + await user.type(await screen.findByLabelText('Password *'), 'testpass') + await user.click(screen.getByText('Continue')) + + await waitFor( + () => { + expect(onUpsertMember).toHaveBeenCalledWith(updatedMember) + }, + { timeout: 1000 }, + ) + }) + }) + + describe('Props', () => { + it('accepts navigationRef prop', async () => { + const navigationRef = vi.fn() + + renderWithContext({ ...defaultProps, navigationRef }) + + await screen.findByText('Continue') + + expect(navigationRef).toHaveBeenCalled() + }) + + it('works without onUpsertMember callback', async () => { + const { onUpsertMember: _onUpsertMember, ...propsWithoutCallback } = defaultProps + /* eslint-disable @typescript-eslint/no-explicit-any */ + const { mockApi, user } = renderWithContext({ + ...propsWithoutCallback, + onUpsertMember: undefined, + } as any) + + await user.type(await screen.findByLabelText('Username *'), 'testuser') + await user.type(await screen.findByLabelText('Password *'), 'testpass') + await user.click(screen.getByText('Continue')) + + await waitFor(() => { + expect(mockApi.addMember).toHaveBeenCalled() + }) + }) + }) + + describe('Integration', () => { + it('passes isProcessingMember to Credentials while creating member', async () => { + const { user } = renderWithContext(defaultProps, preloadedState, { + addMember: vi.fn().mockImplementation(() => new Promise(() => {})), // Never resolves + }) + + await user.type(await screen.findByLabelText('Username *'), 'testuser') + await user.type(await screen.findByLabelText('Password *'), 'testpass') + + const button = screen.getByTestId('credentials-continue') + expect(button).not.toBeDisabled() + + await user.click(button) + + await waitFor( + () => { + const processingButton = screen.getByTestId('credentials-continue') + expect(processingButton).toBeDisabled() + }, + { timeout: 2000 }, + ) + }) + + it('displays error in Credentials when member creation fails', async () => { + const errorResponse = { + response: { + status: 500, + data: { message: 'Server error' }, + }, + } + const { user } = renderWithContext(defaultProps, preloadedState, { + addMember: vi.fn().mockRejectedValue(errorResponse), + }) + + await user.type(await screen.findByLabelText('Username *'), 'testuser') + await user.type(await screen.findByLabelText('Password *'), 'testpass') + await user.click(screen.getByText('Continue')) + + await waitFor(() => { + expect(screen.getByText('Something went wrong')).toBeInTheDocument() + }) + }) + + it('unsubscribes from credentials request on unmount', async () => { + const { mockApi, unmount } = renderWithContext() + + await screen.findByText('Continue') + + unmount() + expect(mockApi.getInstitutionCredentials).toHaveBeenCalledTimes(1) + }) + }) +}) diff --git a/src/views/credentials/__tests__/UpdateMemberForm-test.tsx b/src/views/credentials/__tests__/UpdateMemberForm-test.tsx new file mode 100644 index 0000000000..29f073df8e --- /dev/null +++ b/src/views/credentials/__tests__/UpdateMemberForm-test.tsx @@ -0,0 +1,279 @@ +import React from 'react' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { render, screen, waitFor } from 'src/utilities/testingLibrary' +import { UpdateMemberForm } from '../UpdateMemberForm' +import { + initialState, + institutionData, + member, + memberCredentialsData, +} from 'src/services/mockedData' +import { apiValue as baseApiValue } from 'src/const/apiProviderMock' +import { PostMessageContext } from 'src/ConnectWidget' + +const mockPostMessage = { + onPostMessage: vi.fn(), + postMessageEventOverrides: {}, +} + +const defaultProps = { + navigationRef: vi.fn(), + onDeleteConnectionClick: vi.fn(), + onGoBackClick: vi.fn(), + onUpsertMember: vi.fn(), +} + +const preloadedState = { + ...initialState, + connect: { + ...initialState.connect, + currentMemberGuid: member.member.guid, + members: [member.member], + selectedInstitution: institutionData.institution, + }, + app: { + humanEvent: true, + }, +} + +const renderWithContext = (props = defaultProps, state = preloadedState, apiOverrides = {}) => { + const mockApi = { + ...baseApiValue, + getMemberCredentials: vi.fn().mockResolvedValue(memberCredentialsData.credentials), + updateMember: vi.fn().mockResolvedValue(member.member), + ...apiOverrides, + } + + return { + ...render( + + + , + { + apiValue: mockApi, + preloadedState: state, + }, + ), + mockApi, + } +} + +describe('', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + describe('Loading State', () => { + it('displays loading spinner while fetching credentials', () => { + renderWithContext(defaultProps, preloadedState, { + getMemberCredentials: vi.fn().mockImplementation(() => new Promise(() => {})), + }) + + expect(screen.queryByText('Continue')).not.toBeInTheDocument() + }) + + it('calls getMemberCredentials on mount', () => { + const { mockApi } = renderWithContext() + + expect(mockApi.getMemberCredentials).toHaveBeenCalledWith(member.member.guid) + }) + }) + + describe('Credentials Display', () => { + it('renders Credentials component after loading credentials', async () => { + renderWithContext() + + expect(await screen.findByText('Continue')).toBeInTheDocument() + }) + + it('passes credentials to Credentials component', async () => { + renderWithContext() + + expect(await screen.findByLabelText('Username *')).toBeInTheDocument() + expect(await screen.findByLabelText('Password *')).toBeInTheDocument() + }) + + it('renders institution header', async () => { + renderWithContext() + + await screen.findByText('Continue') + + expect(screen.getByTestId('institution-block')).toBeInTheDocument() + }) + }) + + describe('Error Handling', () => { + it('handles error when fetching credentials fails', async () => { + const error = new Error('Failed to fetch credentials') + renderWithContext(defaultProps, preloadedState, { + getMemberCredentials: vi.fn().mockRejectedValue(error), + }) + + await waitFor(() => { + expect(screen.queryByText('Continue')).not.toBeInTheDocument() + }) + + expect(screen.getByTestId('institution-block')).toBeInTheDocument() + }) + }) + + describe('Member Update', () => { + it('submits credentials and updates member', async () => { + const { mockApi, user } = renderWithContext() + + await user.type(await screen.findByLabelText('Username *'), 'newuser') + await user.type(await screen.findByLabelText('Password *'), 'newpass') + await user.click(screen.getByText('Continue')) + + await waitFor(() => { + expect(mockApi.updateMember).toHaveBeenCalled() + }) + }) + + it('posts connect/updateCredentials message when updating member', async () => { + const { user } = renderWithContext() + + await user.type(await screen.findByLabelText('Username *'), 'newuser') + await user.type(await screen.findByLabelText('Password *'), 'newpass') + await user.click(screen.getByText('Continue')) + + await waitFor(() => { + expect(mockPostMessage.onPostMessage).toHaveBeenCalledWith('connect/updateCredentials', { + institution: { + guid: institutionData.institution.guid, + code: institutionData.institution.code, + }, + member_guid: member.member.guid, + }) + }) + }) + + it('calls onUpsertMember callback when member is updated', async () => { + const onUpsertMember = vi.fn() + const { user } = renderWithContext({ ...defaultProps, onUpsertMember }) + + await user.type(await screen.findByLabelText('Username *'), 'newuser') + await user.type(await screen.findByLabelText('Password *'), 'newpass') + await user.click(screen.getByText('Continue')) + + await waitFor( + () => { + expect(onUpsertMember).toHaveBeenCalledWith(member.member) + }, + { timeout: 1000 }, + ) + }) + + it('includes member data in update request', async () => { + const { mockApi, user } = renderWithContext() + + await user.type(await screen.findByLabelText('Username *'), 'newuser') + await user.type(await screen.findByLabelText('Password *'), 'newpass') + await user.click(screen.getByText('Continue')) + + await waitFor(() => { + expect(mockApi.updateMember).toHaveBeenCalledWith( + expect.objectContaining({ + guid: member.member.guid, + }), + expect.any(Object), + true, + ) + }) + }) + }) + + describe('Error in Update', () => { + it('displays error in Credentials when member update fails', async () => { + const errorResponse = { + response: { + status: 500, + data: { message: 'Server error' }, + }, + } + const { user } = renderWithContext(defaultProps, preloadedState, { + updateMember: vi.fn().mockRejectedValue(errorResponse), + }) + + await user.type(await screen.findByLabelText('Username *'), 'newuser') + await user.type(await screen.findByLabelText('Password *'), 'newpass') + await user.click(screen.getByText('Continue')) + + await waitFor(() => { + expect(screen.getByText('Something went wrong')).toBeInTheDocument() + }) + }) + }) + + describe('Props', () => { + it('accepts navigationRef prop', async () => { + const navigationRef = vi.fn() + + renderWithContext({ ...defaultProps, navigationRef }) + + await screen.findByText('Continue') + + expect(navigationRef).toHaveBeenCalled() + }) + + it('accepts onDeleteConnectionClick prop', async () => { + const onDeleteConnectionClick = vi.fn() + + renderWithContext({ ...defaultProps, onDeleteConnectionClick }) + + await screen.findByText('Continue') + expect(screen.getByTestId('institution-block')).toBeInTheDocument() + }) + + it('works without onUpsertMember callback', async () => { + const { onUpsertMember: _onUpsertMember, ...propsWithoutCallback } = defaultProps + /* eslint-disable @typescript-eslint/no-explicit-any */ + const { mockApi, user } = renderWithContext({ + ...propsWithoutCallback, + onUpsertMember: undefined, + } as any) + + await user.type(await screen.findByLabelText('Username *'), 'newuser') + await user.type(await screen.findByLabelText('Password *'), 'newpass') + await user.click(screen.getByText('Continue')) + + await waitFor(() => { + expect(mockApi.updateMember).toHaveBeenCalled() + }) + }) + }) + + describe('Integration', () => { + it('passes isProcessingMember to Credentials while updating member', async () => { + const { user } = renderWithContext(defaultProps, preloadedState, { + updateMember: vi.fn().mockImplementation(() => new Promise(() => {})), + }) + + await user.type(await screen.findByLabelText('Username *'), 'newuser') + await user.type(await screen.findByLabelText('Password *'), 'newpass') + + const button = screen.getByTestId('credentials-continue') + expect(button).not.toBeDisabled() + + await user.click(button) + + await waitFor( + () => { + const processingButton = screen.getByTestId('credentials-continue') + expect(processingButton).toBeDisabled() + }, + { timeout: 2000 }, + ) + }) + + it('unsubscribes from credentials request on unmount', async () => { + const { mockApi, unmount } = renderWithContext() + + await screen.findByText('Continue') + + unmount() + + expect(mockApi.getMemberCredentials).toHaveBeenCalledTimes(1) + }) + }) +}) diff --git a/src/views/deleteMemberSuccess/__tests__/DeleteMemberSuccess-test.tsx b/src/views/deleteMemberSuccess/__tests__/DeleteMemberSuccess-test.tsx new file mode 100644 index 0000000000..e84c42249b --- /dev/null +++ b/src/views/deleteMemberSuccess/__tests__/DeleteMemberSuccess-test.tsx @@ -0,0 +1,91 @@ +import React from 'react' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { render, screen } from 'src/utilities/testingLibrary' +import { DeleteMemberSuccess } from '../DeleteMemberSuccess' +import { initialState, institutionData } from 'src/services/mockedData' + +const defaultProps = { + institution: institutionData.institution, + onContinueClick: vi.fn(), +} + +const preloadedState = { + ...initialState, +} + +const renderWithContext = (props = defaultProps, state = preloadedState) => { + return render(, { + preloadedState: state, + }) +} + +describe('', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + describe('Content Display', () => { + it('renders the disconnected primary text', () => { + renderWithContext() + + expect(screen.getByTestId('disconnected-primary-text')).toHaveTextContent('Disconnected') + }) + + it('renders the disconnected secondary text with institution name', () => { + renderWithContext() + + expect(screen.getByTestId('disconnected-secondary-text')).toHaveTextContent( + `You have successfully disconnected ${institutionData.institution.name}.`, + ) + }) + + it('renders the Done button', () => { + renderWithContext() + + expect(screen.getByTestId('done-button')).toHaveTextContent('Done') + }) + + it('renders the PrivateAndSecure component', () => { + renderWithContext() + + expect(screen.getByText('Private and secure')).toBeInTheDocument() + }) + }) + + describe('User Interactions', () => { + it('calls onContinueClick when Done button is clicked', async () => { + const onContinueClick = vi.fn() + const { user } = renderWithContext({ ...defaultProps, onContinueClick }) + + await user.click(screen.getByTestId('done-button')) + + expect(onContinueClick).toHaveBeenCalledTimes(1) + }) + }) + + describe('Props', () => { + it('displays custom institution name', () => { + const customInstitution = { + ...institutionData.institution, + name: 'Custom Bank', + } + + renderWithContext({ ...defaultProps, institution: customInstitution }) + + expect(screen.getByTestId('disconnected-secondary-text')).toHaveTextContent( + 'You have successfully disconnected Custom Bank.', + ) + }) + }) + + describe('Integration', () => { + it('renders all sections together', () => { + renderWithContext() + + expect(screen.getByTestId('disconnected-primary-text')).toBeInTheDocument() + expect(screen.getByTestId('disconnected-secondary-text')).toBeInTheDocument() + expect(screen.getByTestId('done-button')).toBeInTheDocument() + expect(screen.getByText('Private and secure')).toBeInTheDocument() + }) + }) +}) diff --git a/src/views/disclosure/Disclosure.js b/src/views/disclosure/Disclosure.js index 2030ccf153..ce68617efe 100644 --- a/src/views/disclosure/Disclosure.js +++ b/src/views/disclosure/Disclosure.js @@ -29,6 +29,7 @@ import { goToUrlLink } from 'src/utilities/global' export const Disclosure = React.forwardRef((_, disclosureRef) => { const containerRef = useRef(null) useAnalyticsPath(...PageviewInfo.CONNECT_DISCLOSURE) + const size = useSelector(getSize) const isSmall = size === 'small' const tokens = useTokens() const styles = getStyles(tokens, isSmall) @@ -37,7 +38,6 @@ export const Disclosure = React.forwardRef((_, disclosureRef) => { // Redux const { isInAggMode, isInTaxMode, isInVerifyMode } = useSelector(selectCurrentMode) const connectConfig = useSelector(selectConnectConfig) - const size = useSelector(getSize) const showExternalLinkPopup = useSelector( (state) => state.profiles.clientProfile.show_external_link_popup, ) diff --git a/src/views/disclosure/__tests__/Disclosure-test.tsx b/src/views/disclosure/__tests__/Disclosure-test.tsx new file mode 100644 index 0000000000..1bec2b640e --- /dev/null +++ b/src/views/disclosure/__tests__/Disclosure-test.tsx @@ -0,0 +1,325 @@ +import React from 'react' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { render, screen, waitFor } from 'src/utilities/testingLibrary' +import { Disclosure } from '../Disclosure' +import { initialState, institutionData } from 'src/services/mockedData' +import * as globalUtils from 'src/utilities/global' +import * as scrollToTopUtils from 'src/utilities/ScrollToTop' + +const preloadedState = { + ...initialState, + browser: { + height: 0, + isMobile: false, + isTablet: false, + size: '', + width: 0, + }, + connect: { + ...initialState.connect, + selectedInstitution: institutionData.institution, + }, +} + +describe('', () => { + let goToUrlLinkSpy: ReturnType + let scrollToTopSpy: ReturnType + + beforeEach(() => { + vi.clearAllMocks() + goToUrlLinkSpy = vi.spyOn(globalUtils, 'goToUrlLink').mockImplementation(() => {}) + scrollToTopSpy = vi.spyOn(scrollToTopUtils, 'scrollToTop').mockImplementation(() => {}) + }) + + afterEach(() => { + goToUrlLinkSpy.mockRestore() + scrollToTopSpy.mockRestore() + }) + + describe('Content Display', () => { + it('renders the disclosure title', () => { + render(, { preloadedState }) + + expect(screen.getByTestId('disclosure-title')).toHaveTextContent('Connect your account') + }) + + it('renders the disclosure paragraph', () => { + render(, { preloadedState }) + + expect(screen.getByTestId('disclosure-paragraph1')).toHaveTextContent( + 'This app will have access to the information below unless you choose to disconnect:', + ) + }) + + it('renders the security message', () => { + render(, { preloadedState }) + + expect(screen.getByTestId('disclosure-paragraph-2')).toHaveTextContent( + 'Your information is protected with bank-level security.', + ) + }) + + it('renders the privacy policy text', () => { + render(, { preloadedState }) + + expect(screen.getByTestId('disclosure-privacy-policy-text')).toHaveTextContent( + 'By clicking Continue, you agree to the', + ) + }) + + it('renders the privacy policy link', () => { + render(, { preloadedState }) + + expect(screen.getByTestId('disclosure-privacy-policy-link')).toHaveTextContent( + 'MX Privacy Policy.', + ) + }) + + it('renders the Continue button', () => { + render(, { preloadedState }) + + expect(screen.getByTestId('disclosure-continue')).toHaveTextContent('Continue') + }) + + it('renders the Powered by MX text', () => { + render(, { preloadedState }) + + expect(screen.getByTestId('disclosure-databymx')).toBeInTheDocument() + }) + + it('renders the institution header', () => { + render(, { preloadedState }) + + expect(screen.getByTestId('disclosure-svg-header')).toBeInTheDocument() + }) + }) + + describe('Mode-specific Content', () => { + it('renders aggregation mode list items', () => { + const aggState = { + ...preloadedState, + config: { + ...preloadedState.config, + mode: 'aggregation', + }, + } + + render(, { preloadedState: aggState }) + + expect(screen.getByTestId('disclosure-agg-mode-list-item1')).toHaveTextContent( + 'Account details', + ) + expect(screen.getByTestId('disclosure-agg-mode-list-item2')).toHaveTextContent( + 'Account balances and transactions', + ) + }) + + it('renders verification mode list items', () => { + const verifyState = { + ...preloadedState, + config: { + ...preloadedState.config, + mode: 'verification', + }, + } + + render(, { preloadedState: verifyState }) + + expect(screen.getByTestId('disclosure-ver-mode-list-item1')).toHaveTextContent( + 'Routing and account numbers', + ) + expect(screen.getByTestId('disclosure-ver-mode-list-item2')).toHaveTextContent( + 'Account balances', + ) + }) + + it('renders tax mode list items', () => { + const taxState = { + ...preloadedState, + config: { + ...preloadedState.config, + mode: 'tax', + }, + } + + render(, { preloadedState: taxState }) + + expect(screen.getByTestId('disclosure-tax-mode-list-item1')).toHaveTextContent( + 'Basic account information', + ) + expect(screen.getByTestId('disclosure-tax-mode-list-item2')).toHaveTextContent( + 'Tax documents', + ) + }) + }) + + describe('Privacy Policy Link', () => { + it('opens privacy policy externally when show_external_link_popup is false', async () => { + const stateWithoutPopup = { + ...preloadedState, + profiles: { + ...preloadedState.profiles, + clientProfile: { + ...preloadedState.profiles.clientProfile, + show_external_link_popup: false, + }, + }, + } + + const { user } = render(, { preloadedState: stateWithoutPopup }) + + await user.click(screen.getByTestId('disclosure-privacy-policy-link')) + + expect(goToUrlLinkSpy).toHaveBeenCalledWith('https://www.mx.com/privacy/', true) + }) + + it('shows inline privacy policy when show_external_link_popup is true', async () => { + const stateWithPopup = { + ...preloadedState, + profiles: { + ...preloadedState.profiles, + clientProfile: { + ...preloadedState.profiles.clientProfile, + show_external_link_popup: true, + }, + }, + } + + const { user } = render( +
+ +
, + { preloadedState: stateWithPopup }, + ) + + await user.click(screen.getByTestId('disclosure-privacy-policy-link')) + + await waitFor(() => { + expect(screen.getByTestId('leaving-notice-flat-header')).toBeInTheDocument() + }) + + expect(goToUrlLinkSpy).not.toHaveBeenCalled() + }) + }) + + describe('Continue Button', () => { + it('dispatches ACCEPT_DISCLOSURE action when Continue is clicked', async () => { + const { user } = render(, { preloadedState }) + + const continueButton = screen.getByTestId('disclosure-continue') + expect(continueButton).toBeEnabled() + + await user.click(continueButton) + + // Button interaction is tested; action dispatch is tested via integration tests + }) + }) + + describe('Imperative Handle', () => { + it('exposes handleBackButton and showBackButton methods', () => { + const ref = React.createRef<{ + handleBackButton: () => void + showBackButton: () => boolean + }>() + + render(, { preloadedState }) + + expect(ref.current).toHaveProperty('handleBackButton') + expect(ref.current).toHaveProperty('showBackButton') + }) + + it('showBackButton returns false when privacy policy is not shown', () => { + const ref = React.createRef<{ + handleBackButton: () => void + showBackButton: () => boolean + }>() + + render(, { preloadedState }) + + expect(ref.current?.showBackButton()).toBe(false) + }) + + it('showBackButton returns true when privacy policy is shown', async () => { + const ref = React.createRef<{ + handleBackButton: () => void + showBackButton: () => boolean + }>() + const stateWithPopup = { + ...preloadedState, + profiles: { + ...preloadedState.profiles, + clientProfile: { + ...preloadedState.profiles.clientProfile, + show_external_link_popup: true, + }, + }, + } + + const { user } = render( +
+ +
, + { preloadedState: stateWithPopup }, + ) + + await user.click(screen.getByTestId('disclosure-privacy-policy-link')) + + await waitFor(() => { + expect(ref.current?.showBackButton()).toBe(true) + }) + }) + + it('handleBackButton hides privacy policy', async () => { + const ref = React.createRef<{ + handleBackButton: () => void + showBackButton: () => boolean + }>() + const stateWithPopup = { + ...preloadedState, + profiles: { + ...preloadedState.profiles, + clientProfile: { + ...preloadedState.profiles.clientProfile, + show_external_link_popup: true, + }, + }, + } + + const { user } = render( +
+ +
, + { preloadedState: stateWithPopup }, + ) + + await user.click(screen.getByTestId('disclosure-privacy-policy-link')) + + await waitFor(() => { + expect(screen.getByTestId('leaving-notice-flat-header')).toBeInTheDocument() + }) + + await waitFor(() => { + ref.current?.handleBackButton() + }) + + await waitFor(() => { + expect(screen.queryByTestId('leaving-notice-flat-header')).not.toBeInTheDocument() + expect(screen.getByTestId('disclosure-title')).toBeInTheDocument() + }) + }) + }) + + describe('Integration', () => { + it('renders all main sections together', () => { + render(, { preloadedState }) + + expect(screen.getByTestId('disclosure-title')).toBeInTheDocument() + expect(screen.getByTestId('disclosure-paragraph1')).toBeInTheDocument() + expect(screen.getByTestId('disclosure-list')).toBeInTheDocument() + expect(screen.getByTestId('disclosure-paragraph-2')).toBeInTheDocument() + expect(screen.getByTestId('disclosure-privacy-policy-text')).toBeInTheDocument() + expect(screen.getByTestId('disclosure-continue')).toBeInTheDocument() + expect(screen.getByTestId('disclosure-databymx')).toBeInTheDocument() + }) + }) +}) diff --git a/src/views/loginError/NoEligibleAccountsError.js b/src/views/loginError/NoEligibleAccountsError.js deleted file mode 100644 index 01306e10f7..0000000000 --- a/src/views/loginError/NoEligibleAccountsError.js +++ /dev/null @@ -1,154 +0,0 @@ -import React, { useContext } from 'react' -import { useDispatch, useSelector } from 'react-redux' - -import { useTokens } from '@kyper/tokenprovider' -import { Text } from '@mxenabled/mxui' -import { AttentionFilled } from '@kyper/icon/AttentionFilled' -import { Button } from '@mui/material' - -import { __ } from 'src/utilities/Intl' -import { ActionTypes } from 'src/redux/actions/Connect' - -import { getCurrentMember, getSelectedInstitution } from 'src/redux/selectors/Connect' - -import { AriaLive } from 'src/components/AriaLive' -import { SlideDown } from 'src/components/SlideDown' -import { getDelay } from 'src/utilities/getDelay' -import useAnalyticsEvent from 'src/hooks/useAnalyticsEvent' -import { AnalyticEvents, AuthenticationMethods } from 'src/const/Analytics' -import { POST_MESSAGES } from 'src/const/postMessages' -import { PostMessageContext } from 'src/ConnectWidget' - -export const NoEligibleAccounts = () => { - const sendAnalyticsEvent = useAnalyticsEvent() - const tokens = useTokens() - const styles = getStyles(tokens) - const postMessageFunctions = useContext(PostMessageContext) - const dispatch = useDispatch() - - const currentMember = useSelector(getCurrentMember) - const selectedInstitution = useSelector(getSelectedInstitution) - - const postHogEventMetadata = { - authentication_method: currentMember.is_oauth - ? AuthenticationMethods.OAUTH - : AuthenticationMethods.NON_OAUTH, - institution_guid: selectedInstitution.guid, - institution_name: selectedInstitution.name, - } - - const getNextDelay = getDelay() - - return ( - - -
- - {__('Accounts not eligible for transfers')} - - -
-
- - {__( - "We've connected to your financial institution, but couldn't find eligible checking or savings accounts for money movement; however, other account information may still have been shared.", - )} - - - {__('Please try linking a checking or savings account.')} - - -
- -
- - -
- ) -} - -const getStyles = (tokens) => { - return { - headerText: { - fontWeight: tokens.FontWeight.Bold, - }, - headerContianer: { - display: 'flex', - alignItems: 'center', - }, - icon: { - marginLeft: tokens.Spacing.Small, - }, - paragraphOne: { - fontWeight: tokens.FontWeight.Regular, - fontSize: tokens.FontSize.Small, - marginTop: tokens.Spacing.XSmall, - }, - paragraphTwo: { - fontWeight: tokens.FontWeight.Regular, - fontSize: tokens.FontSize.Small, - marginTop: tokens.Spacing.Medium, - }, - tryAgainButton: { - background: tokens.BackgroundColor.ButtonPrimary, - color: tokens.Color.NeutralWhite, - marginTop: tokens.Spacing.XLarge, - borderRadius: tokens.BorderRadius.Medium, - display: 'flex', - flexDirection: 'column', - alignItems: 'center', - padding: '12px 16px', - gap: '10px', - height: '44px', - width: '100%', - }, - } -} - -NoEligibleAccounts.propTypes = {} - -export default NoEligibleAccounts diff --git a/src/views/loginError/__tests__/ImpededMemberError-test.tsx b/src/views/loginError/__tests__/ImpededMemberError-test.tsx new file mode 100644 index 0000000000..cf4753dae9 --- /dev/null +++ b/src/views/loginError/__tests__/ImpededMemberError-test.tsx @@ -0,0 +1,184 @@ +import React from 'react' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { render, screen } from 'src/utilities/testingLibrary' +import { ImpededMemberError } from '../ImpededMemberError' +import { institutionData } from 'src/services/mockedData' +import * as globalUtils from 'src/utilities/global' +import * as institutionUtils from 'src/utilities/Institution' + +describe('', () => { + const defaultProps = { + institution: institutionData.institution, + message: 'Your account has been locked for security reasons.', + onRefreshClick: vi.fn(), + setIsLeaving: vi.fn(), + showExternalLinkPopup: false, + title: 'Account locked', + } + + let goToUrlLinkSpy: ReturnType + let getInstitutionLoginUrlSpy: ReturnType + + beforeEach(() => { + vi.clearAllMocks() + goToUrlLinkSpy = vi.spyOn(globalUtils, 'goToUrlLink').mockImplementation(() => {}) + getInstitutionLoginUrlSpy = vi + .spyOn(institutionUtils, 'getInstitutionLoginUrl') + .mockReturnValue('https://testbank.com/login') + }) + + afterEach(() => { + goToUrlLinkSpy.mockRestore() + getInstitutionLoginUrlSpy.mockRestore() + }) + + describe('Content Display', () => { + it('renders the title', () => { + render() + + expect(screen.getByText('Account locked')).toBeInTheDocument() + }) + + it('renders the message', () => { + render() + + expect( + screen.getByText('Your account has been locked for security reasons.'), + ).toBeInTheDocument() + }) + + it('renders step 1 with institution name', () => { + render() + + expect( + screen.getByText("Log in to Test Bank's website and resolve the issue."), + ).toBeInTheDocument() + }) + + it('renders the "Visit website" link', () => { + render() + + expect(screen.getByText('Visit website')).toBeInTheDocument() + }) + + it('renders step 2 text', () => { + render() + + expect( + screen.getByText('Come back here and try to connect your account again.'), + ).toBeInTheDocument() + }) + + it('renders the "Try again" link', () => { + render() + + expect(screen.getByText('Try again')).toBeInTheDocument() + }) + + it('renders numbered icons for steps', () => { + render() + + expect(screen.getByText('1')).toBeInTheDocument() + expect(screen.getByText('2')).toBeInTheDocument() + }) + }) + + describe('Visit Website Link', () => { + it('opens institution URL externally when showExternalLinkPopup is false', async () => { + const { user } = render() + + await user.click(screen.getByText('Visit website')) + + expect(getInstitutionLoginUrlSpy).toHaveBeenCalledWith(institutionData.institution) + expect(goToUrlLinkSpy).toHaveBeenCalledWith('https://testbank.com/login') + expect(defaultProps.setIsLeaving).not.toHaveBeenCalled() + }) + + it('shows leaving notice when showExternalLinkPopup is true', async () => { + const propsWithPopup = { + ...defaultProps, + showExternalLinkPopup: true, + } + + const { user } = render() + + await user.click(screen.getByText('Visit website')) + + expect(propsWithPopup.setIsLeaving).toHaveBeenCalled() + expect(goToUrlLinkSpy).not.toHaveBeenCalled() + }) + }) + + describe('Try Again Link', () => { + it('calls onRefreshClick when clicked', async () => { + const { user } = render() + + await user.click(screen.getByText('Try again')) + + expect(defaultProps.onRefreshClick).toHaveBeenCalledTimes(1) + }) + + it('does not call setIsLeaving when Try again is clicked', async () => { + const { user } = render() + + await user.click(screen.getByText('Try again')) + + expect(defaultProps.setIsLeaving).not.toHaveBeenCalled() + }) + }) + + describe('Integration', () => { + it('renders both steps with all elements', () => { + render() + + // Title and message + expect(screen.getByText('Account locked')).toBeInTheDocument() + expect( + screen.getByText('Your account has been locked for security reasons.'), + ).toBeInTheDocument() + + // Step 1 + expect(screen.getByText('1')).toBeInTheDocument() + expect( + screen.getByText("Log in to Test Bank's website and resolve the issue."), + ).toBeInTheDocument() + expect(screen.getByText('Visit website')).toBeInTheDocument() + + // Step 2 + expect(screen.getByText('2')).toBeInTheDocument() + expect( + screen.getByText('Come back here and try to connect your account again.'), + ).toBeInTheDocument() + expect(screen.getByText('Try again')).toBeInTheDocument() + }) + + it('handles different institution names correctly', () => { + const customProps = { + ...defaultProps, + institution: { + ...institutionData.institution, + name: 'Custom Credit Union', + }, + } + + render() + + expect( + screen.getByText("Log in to Custom Credit Union's website and resolve the issue."), + ).toBeInTheDocument() + }) + + it('handles different title and message correctly', () => { + const customProps = { + ...defaultProps, + message: 'Too many failed login attempts.', + title: 'Account temporarily suspended', + } + + render() + + expect(screen.getByText('Account temporarily suspended')).toBeInTheDocument() + expect(screen.getByText('Too many failed login attempts.')).toBeInTheDocument() + }) + }) +}) diff --git a/src/views/loginError/__tests__/LeavingAction-test.tsx b/src/views/loginError/__tests__/LeavingAction-test.tsx new file mode 100644 index 0000000000..d499487672 --- /dev/null +++ b/src/views/loginError/__tests__/LeavingAction-test.tsx @@ -0,0 +1,144 @@ +import React from 'react' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { render, screen } from 'src/utilities/testingLibrary' +import { LeavingAction } from '../LeavingAction' +import { institutionData } from 'src/services/mockedData' +import * as globalUtils from 'src/utilities/global' +import * as institutionUtils from 'src/utilities/Institution' + +describe('', () => { + const defaultProps = { + institution: institutionData.institution, + setIsLeaving: vi.fn(), + size: 'medium', + } + + let goToUrlLinkSpy: ReturnType + let getInstitutionLoginUrlSpy: ReturnType + let portalRoot: HTMLDivElement + + beforeEach(() => { + vi.clearAllMocks() + goToUrlLinkSpy = vi.spyOn(globalUtils, 'goToUrlLink').mockImplementation(() => {}) + getInstitutionLoginUrlSpy = vi + .spyOn(institutionUtils, 'getInstitutionLoginUrl') + .mockReturnValue('https://testbank.com/login') + + portalRoot = document.createElement('div') + portalRoot.setAttribute('id', 'connect-wrapper') + document.body.appendChild(portalRoot) + }) + + afterEach(() => { + goToUrlLinkSpy.mockRestore() + getInstitutionLoginUrlSpy.mockRestore() + document.body.removeChild(portalRoot) + }) + + describe('Content Display', () => { + it('renders the LeavingNoticeFlat component', () => { + render() + + expect(screen.getByTestId('leaving-notice-flat-header')).toBeInTheDocument() + }) + + it('renders the continue button', () => { + render() + + expect(screen.getByTestId('leaving-notice-flat-continue-button')).toBeInTheDocument() + }) + + it('renders the cancel button', () => { + render() + + expect(screen.getByTestId('leaving-notice-flat-cancel-button')).toBeInTheDocument() + }) + }) + + describe('Cancel Button', () => { + it('calls setIsLeaving(false) when cancel button is clicked', async () => { + const { user } = render() + + await user.click(screen.getByTestId('leaving-notice-flat-cancel-button')) + + expect(defaultProps.setIsLeaving).toHaveBeenCalledWith(false) + expect(defaultProps.setIsLeaving).toHaveBeenCalledTimes(1) + }) + + it('does not call goToUrlLink when cancel is clicked', async () => { + const { user } = render() + + await user.click(screen.getByTestId('leaving-notice-flat-cancel-button')) + + expect(goToUrlLinkSpy).not.toHaveBeenCalled() + }) + }) + + describe('Continue Button', () => { + it('calls getInstitutionLoginUrl with institution when continue is clicked', async () => { + const { user } = render() + + await user.click(screen.getByTestId('leaving-notice-flat-continue-button')) + + expect(getInstitutionLoginUrlSpy).toHaveBeenCalledWith(institutionData.institution) + expect(getInstitutionLoginUrlSpy).toHaveBeenCalledTimes(1) + }) + + it('calls goToUrlLink with institution login URL when continue is clicked', async () => { + const { user } = render() + + await user.click(screen.getByTestId('leaving-notice-flat-continue-button')) + + expect(goToUrlLinkSpy).toHaveBeenCalledWith('https://testbank.com/login') + expect(goToUrlLinkSpy).toHaveBeenCalledTimes(1) + }) + + it('does not call setIsLeaving when continue is clicked', async () => { + const { user } = render() + + await user.click(screen.getByTestId('leaving-notice-flat-continue-button')) + + expect(defaultProps.setIsLeaving).not.toHaveBeenCalled() + }) + }) + + describe('Integration', () => { + it('handles complete user flow - cancel', async () => { + const { user } = render() + + expect(screen.getByTestId('leaving-notice-flat-header')).toBeInTheDocument() + expect(screen.getByTestId('leaving-notice-flat-continue-button')).toBeInTheDocument() + expect(screen.getByTestId('leaving-notice-flat-cancel-button')).toBeInTheDocument() + + await user.click(screen.getByTestId('leaving-notice-flat-cancel-button')) + + expect(defaultProps.setIsLeaving).toHaveBeenCalledWith(false) + expect(goToUrlLinkSpy).not.toHaveBeenCalled() + }) + + it('handles complete user flow - continue', async () => { + const { user } = render() + + expect(screen.getByTestId('leaving-notice-flat-header')).toBeInTheDocument() + expect(screen.getByTestId('leaving-notice-flat-continue-button')).toBeInTheDocument() + expect(screen.getByTestId('leaving-notice-flat-cancel-button')).toBeInTheDocument() + + await user.click(screen.getByTestId('leaving-notice-flat-continue-button')) + + expect(getInstitutionLoginUrlSpy).toHaveBeenCalledWith(institutionData.institution) + expect(goToUrlLinkSpy).toHaveBeenCalledWith('https://testbank.com/login') + expect(defaultProps.setIsLeaving).not.toHaveBeenCalled() + }) + + it('passes size prop to LeavingNoticeFlat', () => { + const customProps = { + ...defaultProps, + size: 'small', + } + + render() + + expect(screen.getByTestId('leaving-notice-flat-header')).toBeInTheDocument() + }) + }) +}) diff --git a/src/views/manualAccount/__tests__/ManualAccountConnect-test.tsx b/src/views/manualAccount/__tests__/ManualAccountConnect-test.tsx new file mode 100644 index 0000000000..ab7632f815 --- /dev/null +++ b/src/views/manualAccount/__tests__/ManualAccountConnect-test.tsx @@ -0,0 +1,478 @@ +import React from 'react' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { render, screen, waitFor } from 'src/utilities/testingLibrary' +import { ManualAccountConnect } from '../ManualAccountConnect' +import { initialState } from 'src/services/mockedData' +import { PostMessageContext } from 'src/ConnectWidget' +import { POST_MESSAGES } from 'src/const/postMessages' +import { ActionTypes } from 'src/redux/actions/Connect' +import { AccountTypes } from 'src/views/manualAccount/constants' +import * as animationUtils from 'src/utilities/Animation' + +vi.mock('src/utilities/Animation', () => ({ + fadeOut: vi.fn(() => Promise.resolve()), +})) + +const mockDispatch = vi.fn() +vi.mock('react-redux', async (importActual) => { + const actual = (await importActual()) as object + return { ...actual, useDispatch: () => mockDispatch } +}) + +// Only mock the complex ManualAccountForm component +vi.mock('src/views/manualAccount/ManualAccountForm', () => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const MockManualAccountForm = React.forwardRef((props, ref) => ( +
+ + +
{props.accountType}
+
{props.showDayPicker ? 'true' : 'false'}
+
+ )) + MockManualAccountForm.displayName = 'ManualAccountForm' + return { ManualAccountForm: MockManualAccountForm } +}) + +interface ManualAccountConnectProps { + availableAccountTypes?: number[] + onManualAccountAdded?: () => void +} + +describe('', () => { + const mockPostMessage = { + onPostMessage: vi.fn(), + postMessageEventOverrides: {}, + } + + const defaultProps = { + availableAccountTypes: [AccountTypes.CHECKING, AccountTypes.SAVINGS], + onManualAccountAdded: vi.fn(), + } satisfies ManualAccountConnectProps + + const preloadedState = { + ...initialState, + config: { + ...initialState.config, + _initialValues: JSON.stringify({ connect_widget_url: 'https://test.com' }), + }, + } + + const renderWithContext = ( + props: ManualAccountConnectProps = defaultProps, + state = preloadedState, + ) => { + return render( + + {/* eslint-disable-next-line @typescript-eslint/no-explicit-any */} + + , + { + preloadedState: state, + }, + ) + } + + beforeEach(() => { + vi.clearAllMocks() + mockDispatch.mockClear() + }) + + describe('Content Display', () => { + it('renders ManualAccountMenu by default', () => { + renderWithContext() + + expect(screen.getByTestId('manual-account-menu-container')).toBeInTheDocument() + expect(screen.queryByTestId('manual-account-form')).not.toBeInTheDocument() + expect(screen.queryByTestId('manual-account-success-header')).not.toBeInTheDocument() + }) + + it('pre-selects account type when only one is available', () => { + const singleTypeProps = { + ...defaultProps, + availableAccountTypes: [AccountTypes.CHECKING], + } + + renderWithContext(singleTypeProps) + + expect(screen.getByTestId('manual-account-menu-container')).toBeInTheDocument() + expect(screen.getByTestId('Checking-button')).toBeInTheDocument() + }) + + it('passes available account types to menu', () => { + renderWithContext() + + expect(screen.getByTestId('Checking-button')).toBeInTheDocument() + expect(screen.getByTestId('Savings-button')).toBeInTheDocument() + }) + }) + + describe('Account Type Selection', () => { + it('transitions from menu to form when account type is selected', async () => { + const { user } = renderWithContext() + + expect(screen.getByTestId('manual-account-menu-container')).toBeInTheDocument() + + await user.click(screen.getByTestId('Checking-button')) + + expect(screen.getByTestId('manual-account-form')).toBeInTheDocument() + expect(screen.queryByTestId('manual-account-menu-container')).not.toBeInTheDocument() + expect(screen.getByTestId('account-type')).toHaveTextContent(AccountTypes.CHECKING.toString()) + }) + + it('can select different account types', async () => { + const { user } = renderWithContext() + + await user.click(screen.getByTestId('Savings-button')) + + expect(screen.getByTestId('account-type')).toHaveTextContent(AccountTypes.SAVINGS.toString()) + }) + }) + + describe('Form Navigation', () => { + it('returns to menu when go back is clicked from form', async () => { + const { user } = renderWithContext() + + await user.click(screen.getByTestId('Checking-button')) + + expect(screen.getByTestId('manual-account-form')).toBeInTheDocument() + + await user.click(screen.getByText('Go Back')) + + expect(screen.getByTestId('manual-account-menu-container')).toBeInTheDocument() + expect(screen.queryByTestId('manual-account-form')).not.toBeInTheDocument() + }) + + it('clears account type when returning to menu', async () => { + const { user } = renderWithContext() + + await user.click(screen.getByTestId('Checking-button')) + await user.click(screen.getByText('Go Back')) + await user.click(screen.getByTestId('Savings-button')) + + expect(screen.getByTestId('account-type')).toHaveTextContent(AccountTypes.SAVINGS.toString()) + }) + }) + + describe('Success Flow', () => { + it('shows success view when form is submitted successfully', async () => { + const { user } = renderWithContext() + + await user.click(screen.getByTestId('Checking-button')) + await user.click(screen.getByText('Success')) + + expect(screen.getByTestId('manual-account-success-header')).toBeInTheDocument() + expect(screen.queryByTestId('manual-account-form')).not.toBeInTheDocument() + }) + + it('passes account type to success view', async () => { + const { user } = renderWithContext() + + await user.click(screen.getByTestId('Savings-button')) + await user.click(screen.getByText('Success')) + + expect(screen.getByTestId('manual-account-success-header')).toHaveTextContent('Savings added') + }) + + it('passes onManualAccountAdded callback to success view', async () => { + const { user } = renderWithContext() + + await user.click(screen.getByTestId('Checking-button')) + await user.click(screen.getByText('Success')) + + expect(screen.getByTestId('manual-account-success-header')).toBeInTheDocument() + }) + + it('dispatches GO_BACK_MANUAL_ACCOUNT when done is clicked', async () => { + const { user } = renderWithContext() + + await user.click(screen.getByTestId('Checking-button')) + await user.click(screen.getByText('Success')) + await user.click(screen.getByTestId('manual-success-done-button')) + + expect(mockDispatch).toHaveBeenCalledWith({ + type: ActionTypes.GO_BACK_MANUAL_ACCOUNT, + payload: { connect_widget_url: 'https://test.com' }, + }) + }) + }) + + describe('Post Messages', () => { + it('posts BACK_TO_SEARCH when navigating back from menu', async () => { + const ref = React.createRef<{ handleBackButton: () => void }>() + + render( + + + , + { preloadedState }, + ) + + await waitFor(() => { + ref.current?.handleBackButton() + }) + + await waitFor(() => { + expect(mockPostMessage.onPostMessage).toHaveBeenCalledWith(POST_MESSAGES.BACK_TO_SEARCH) + }) + }) + + it('dispatches GO_BACK_MANUAL_ACCOUNT when navigating back from menu', async () => { + const ref = React.createRef<{ handleBackButton: () => void }>() + + render( + + + , + { preloadedState }, + ) + + await waitFor(() => { + ref.current?.handleBackButton() + }) + + await waitFor(() => { + expect(mockDispatch).toHaveBeenCalledWith({ + type: ActionTypes.GO_BACK_MANUAL_ACCOUNT, + payload: { connect_widget_url: 'https://test.com' }, + }) + }) + }) + }) + + describe('useImperativeHandle', () => { + it('exposes handleBackButton and showBackButton methods', () => { + const ref = React.createRef<{ + handleBackButton: () => void + showBackButton: () => HTMLElement | null + }>() + + render( + + + , + { preloadedState }, + ) + + expect(ref.current).toHaveProperty('handleBackButton') + expect(ref.current).toHaveProperty('showBackButton') + expect(typeof ref.current?.handleBackButton).toBe('function') + expect(typeof ref.current?.showBackButton).toBe('function') + }) + + it('showBackButton returns truthy value when on menu', () => { + const ref = React.createRef<{ + handleBackButton: () => void + showBackButton: () => HTMLElement | null + }>() + + render( + + + , + { preloadedState }, + ) + + expect(ref.current?.showBackButton()).toBeTruthy() + }) + + it('showBackButton returns truthy value when on form', async () => { + const ref = React.createRef<{ + handleBackButton: () => void + showBackButton: () => HTMLElement | null + }>() + + const { user } = render( + + + , + { preloadedState }, + ) + + await user.click(screen.getByTestId('Checking-button')) + + expect(ref.current?.showBackButton()).toBeTruthy() + }) + + it('showBackButton returns false when on success', async () => { + const ref = React.createRef<{ + handleBackButton: () => void + showBackButton: () => HTMLElement | null | false + }>() + + const { user } = render( + + + , + { preloadedState }, + ) + + await user.click(screen.getByTestId('Checking-button')) + await user.click(screen.getByText('Success')) + + expect(ref.current?.showBackButton()).toBe(false) + }) + + it('handleBackButton fades out and returns to menu from form', async () => { + const ref = React.createRef<{ + handleBackButton: () => void + showBackButton: () => HTMLElement | null + }>() + const fadeOutSpy = vi.spyOn(animationUtils, 'fadeOut') + + const { user } = render( + + + , + { preloadedState }, + ) + + await user.click(screen.getByTestId('Checking-button')) + + expect(screen.getByTestId('manual-account-form')).toBeInTheDocument() + + await waitFor(() => { + ref.current?.handleBackButton() + }) + + expect(fadeOutSpy).toHaveBeenCalledWith(expect.anything(), 'up', 300) + + await waitFor(() => { + expect(screen.getByTestId('manual-account-menu-container')).toBeInTheDocument() + expect(screen.queryByTestId('manual-account-form')).not.toBeInTheDocument() + }) + }) + + it('handleBackButton from menu posts message and dispatches action', async () => { + const ref = React.createRef<{ + handleBackButton: () => void + showBackButton: () => HTMLElement | null + }>() + const fadeOutSpy = vi.spyOn(animationUtils, 'fadeOut') + + render( + + + , + { preloadedState }, + ) + + await waitFor(() => { + ref.current?.handleBackButton() + }) + + expect(fadeOutSpy).toHaveBeenCalledWith(expect.anything(), 'up', 300) + + await waitFor(() => { + expect(mockPostMessage.onPostMessage).toHaveBeenCalledWith(POST_MESSAGES.BACK_TO_SEARCH) + expect(mockDispatch).toHaveBeenCalledWith({ + type: ActionTypes.GO_BACK_MANUAL_ACCOUNT, + payload: { connect_widget_url: 'https://test.com' }, + }) + }) + }) + }) + + describe('Day Picker State', () => { + it('passes showDayPicker state to form', async () => { + const { user } = renderWithContext() + + // First select an account type to show the form + await user.click(screen.getByTestId('Checking-button')) + + expect(screen.getByTestId('show-day-picker')).toHaveTextContent('false') + }) + + it('showBackButton returns false when day picker is shown', () => { + const ref = React.createRef<{ + handleBackButton: () => void + showBackButton: () => HTMLElement | null + }>() + + render( + + + , + { preloadedState }, + ) + }) + }) + + describe('Integration', () => { + it('handles complete flow from menu to success', async () => { + const { user } = renderWithContext() + + expect(screen.getByTestId('manual-account-menu-container')).toBeInTheDocument() + + await user.click(screen.getByTestId('Checking-button')) + expect(screen.getByTestId('manual-account-form')).toBeInTheDocument() + + await user.click(screen.getByText('Success')) + expect(screen.getByTestId('manual-account-success-header')).toBeInTheDocument() + + await user.click(screen.getByTestId('manual-success-done-button')) + expect(mockDispatch).toHaveBeenCalledWith({ + type: ActionTypes.GO_BACK_MANUAL_ACCOUNT, + payload: { connect_widget_url: 'https://test.com' }, + }) + }) + + it('renders menu with correct account types list', () => { + renderWithContext() + + expect(screen.getByTestId('manual-account-menu-container')).toBeInTheDocument() + expect(screen.getByTestId('Checking-button')).toBeInTheDocument() + expect(screen.getByTestId('Savings-button')).toBeInTheDocument() + }) + + it('handles single account type scenario correctly', () => { + const singleTypeProps = { + ...defaultProps, + availableAccountTypes: [AccountTypes.SAVINGS], + } + + renderWithContext(singleTypeProps) + + expect(screen.getByTestId('manual-account-menu-container')).toBeInTheDocument() + }) + }) + + describe('Props', () => { + it('handles empty availableAccountTypes array', () => { + const emptyProps = { + ...defaultProps, + availableAccountTypes: [], + } + + renderWithContext(emptyProps) + + expect(screen.getByTestId('manual-account-menu-container')).toBeInTheDocument() + }) + + it('handles undefined availableAccountTypes', () => { + const undefinedProps = { + availableAccountTypes: undefined as unknown as number[], + onManualAccountAdded: vi.fn(), + } + + renderWithContext(undefinedProps) + + expect(screen.getByTestId('manual-account-menu-container')).toBeInTheDocument() + }) + + it('calls onManualAccountAdded when provided', async () => { + const onManualAccountAdded = vi.fn() + const propsWithCallback = { + ...defaultProps, + onManualAccountAdded, + } + + const { user } = renderWithContext(propsWithCallback) + + await user.click(screen.getByTestId('Checking-button')) + await user.click(screen.getByText('Success')) + + expect(screen.getByTestId('manual-account-success-header')).toBeInTheDocument() + }) + }) +}) diff --git a/src/views/mfa/__tests__/MFAStep-test.tsx b/src/views/mfa/__tests__/MFAStep-test.tsx index 605d2dd52b..c9dc7a5ec2 100644 --- a/src/views/mfa/__tests__/MFAStep-test.tsx +++ b/src/views/mfa/__tests__/MFAStep-test.tsx @@ -1,16 +1,12 @@ import React from 'react' +import { beforeEach, vi } from 'vitest' import MFAStep from 'src/views/mfa/MFAStep' -import { AnalyticEvents } from 'src/const/Analytics' +import { AnalyticEvents, defaultEventMetadata } from 'src/const/Analytics' import { render, screen } from 'src/utilities/testingLibrary' -const mockSendAnalyticsEvent = vi.fn() - -vi.mock('src/hooks/useAnalyticsEvent', () => { - return { default: () => mockSendAnalyticsEvent } -}) - describe('MFAStep', () => { + let onAnalyticsEvent: ReturnType const onGoBack = vi.fn() const defaultProps = { enableSupportRequests: true, @@ -19,14 +15,24 @@ describe('MFAStep', () => { ref: React.createRef(), } + beforeEach(() => { + vi.clearAllMocks() + onAnalyticsEvent = vi.fn() + }) + it('can navigate to Support when Support is enabled', async () => { - const { user } = render() + const { user } = render(, { onAnalyticsEvent }) const supportButton = await screen.findByRole('button', { name: 'Get help' }) expect(supportButton).toBeInTheDocument() await user.click(supportButton) - expect(mockSendAnalyticsEvent).toHaveBeenCalledWith(AnalyticEvents.MFA_CLICKED_GET_HELP) + expect(onAnalyticsEvent).toHaveBeenCalledWith( + `connect_${AnalyticEvents.MFA_CLICKED_GET_HELP}`, + expect.objectContaining({ + widgetType: defaultEventMetadata.widgetType, + }), + ) expect(await screen.findByText('Request support')).toBeInTheDocument() }) @@ -35,7 +41,7 @@ describe('MFAStep', () => { ...defaultProps, enableSupportRequests: false, } - render() + render(, { onAnalyticsEvent }) expect(screen.queryByRole('button', { name: 'Get help' })).not.toBeInTheDocument() }) }) diff --git a/vite.config.ts b/vite.config.ts index 6eb0e5c278..ddd365c59a 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -92,5 +92,20 @@ export default defineConfig({ inline: ['@mxenabled/mx-icons'], }, }, + coverage: { + provider: 'v8', + reporter: ['text', 'json', 'html'], + exclude: [ + 'node_modules/', + 'src/testSetup.ts', + '**/*.d.ts', + '**/*-{test,spec}.{js,ts,jsx,tsx}', + '**/dist/**', + '.eslintrc.cjs', + 'vite.config.ts', + 'scripts/**', + '**/__mocks__/**', + ], + }, }, })