diff --git a/package-lock.json b/package-lock.json
index f6c57856c3..d830c8407c 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -140,6 +140,7 @@
"@types/moment-duration-format": "2.2.6",
"@types/offscreencanvas": "2019.7.2",
"@types/pixelmatch": "5.2.5",
+ "@types/pretty": "2.0.3",
"@types/punycode": "2.1.0",
"@types/react": "17.0.14",
"@types/react-dom": "17.0.14",
@@ -176,6 +177,7 @@
"jsonwebtoken": "9.0.2",
"metro-react-native-babel-preset": "0.77.0",
"patch-package": "6.4.7",
+ "pretty": "2.0.0",
"process": "0.11.10",
"sass": "1.26.8",
"style-loader": "3.3.1",
@@ -4661,6 +4663,12 @@
"node": ">= 8"
}
},
+ "node_modules/@one-ini/wasm": {
+ "version": "0.1.1",
+ "resolved": "https://registry.npmjs.org/@one-ini/wasm/-/wasm-0.1.1.tgz",
+ "integrity": "sha512-XuySG1E38YScSJoMlqovLru4KTUNSjgVTIjyh7qMX6aNN5HY5Ct5LhRJdxO79JtTzKfzV/bnWpz+zquYrISsvw==",
+ "dev": true
+ },
"node_modules/@pkgjs/parseargs": {
"version": "0.11.0",
"resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz",
@@ -7219,6 +7227,12 @@
"@types/node": "*"
}
},
+ "node_modules/@types/pretty": {
+ "version": "2.0.3",
+ "resolved": "https://registry.npmjs.org/@types/pretty/-/pretty-2.0.3.tgz",
+ "integrity": "sha512-xR96pShNlrxLd3gZqzCnbaAmbYhiRYjW51CDFjektZemqpBZBAAkMwxm4gBraJP/xSgKcsQhLXdlXOwDNWo4VQ==",
+ "dev": true
+ },
"node_modules/@types/prop-types": {
"version": "15.7.5",
"resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.5.tgz",
@@ -8659,6 +8673,15 @@
"integrity": "sha512-j2afSsaIENvHZN2B8GOpF566vZ5WVk5opAiMTvWgaQT8DkbOqsTfvNAvHoRGU2zzP8cPoqys+xHTRDWW8L+/BA==",
"deprecated": "Use your platform's native atob() and btoa() methods instead"
},
+ "node_modules/abbrev": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/abbrev/-/abbrev-2.0.0.tgz",
+ "integrity": "sha512-6/mh1E2u2YgEsCHdY0Yx5oW+61gZU+1vXaoiHHrpKeuRNNgFvS+/jrwHiQhB5apAf5oB7UB7E19ol2R2LKH8hQ==",
+ "dev": true,
+ "engines": {
+ "node": "^14.17.0 || ^16.13.0 || >=18.0.0"
+ }
+ },
"node_modules/abort-controller": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/abort-controller/-/abort-controller-3.0.0.tgz",
@@ -10624,6 +10647,42 @@
"resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz",
"integrity": "sha1-2Klr13/Wjfd5OnMDajug1UBdR3s="
},
+ "node_modules/condense-newlines": {
+ "version": "0.2.1",
+ "resolved": "https://registry.npmjs.org/condense-newlines/-/condense-newlines-0.2.1.tgz",
+ "integrity": "sha512-P7X+QL9Hb9B/c8HI5BFFKmjgBu2XpQuF98WZ9XkO+dBGgk5XgwiQz7o1SmpglNWId3581UcS0SFAWfoIhMHPfg==",
+ "dev": true,
+ "dependencies": {
+ "extend-shallow": "^2.0.1",
+ "is-whitespace": "^0.3.0",
+ "kind-of": "^3.0.2"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/condense-newlines/node_modules/kind-of": {
+ "version": "3.2.2",
+ "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-3.2.2.tgz",
+ "integrity": "sha512-NOW9QQXMoZGg/oqnVNoNTTIFEIid1627WCffUBJEdMxYApq7mNE7CpzucIPc+ZQg25Phej7IJSmX3hO+oblOtQ==",
+ "dev": true,
+ "dependencies": {
+ "is-buffer": "^1.1.5"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/config-chain": {
+ "version": "1.1.13",
+ "resolved": "https://registry.npmjs.org/config-chain/-/config-chain-1.1.13.tgz",
+ "integrity": "sha512-qj+f8APARXHrM0hraqXYb2/bOVSV4PvJQlNZ/DVj0QrmNM2q2euizkeuVckQ57J+W0mRH6Hvi+k50M4Jul2VRQ==",
+ "dev": true,
+ "dependencies": {
+ "ini": "^1.3.4",
+ "proto-list": "~1.2.1"
+ }
+ },
"node_modules/connect": {
"version": "3.7.0",
"resolved": "https://registry.npmjs.org/connect/-/connect-3.7.0.tgz",
@@ -11573,6 +11632,57 @@
"node": "^16.13.0 || >=18.0.0"
}
},
+ "node_modules/editorconfig": {
+ "version": "1.0.4",
+ "resolved": "https://registry.npmjs.org/editorconfig/-/editorconfig-1.0.4.tgz",
+ "integrity": "sha512-L9Qe08KWTlqYMVvMcTIvMAdl1cDUubzRNYL+WfA4bLDMHe4nemKkpmYzkznE1FwLKu0EEmy6obgQKzMJrg4x9Q==",
+ "dev": true,
+ "dependencies": {
+ "@one-ini/wasm": "0.1.1",
+ "commander": "^10.0.0",
+ "minimatch": "9.0.1",
+ "semver": "^7.5.3"
+ },
+ "bin": {
+ "editorconfig": "bin/editorconfig"
+ },
+ "engines": {
+ "node": ">=14"
+ }
+ },
+ "node_modules/editorconfig/node_modules/brace-expansion": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz",
+ "integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==",
+ "dev": true,
+ "dependencies": {
+ "balanced-match": "^1.0.0"
+ }
+ },
+ "node_modules/editorconfig/node_modules/commander": {
+ "version": "10.0.1",
+ "resolved": "https://registry.npmjs.org/commander/-/commander-10.0.1.tgz",
+ "integrity": "sha512-y4Mg2tXshplEbSGzx7amzPwKKOCGuoSRP/CjEdwwk0FOGlUbq6lKuoyDZTNZkmxHdJtp54hdfY/JUrdL7Xfdug==",
+ "dev": true,
+ "engines": {
+ "node": ">=14"
+ }
+ },
+ "node_modules/editorconfig/node_modules/minimatch": {
+ "version": "9.0.1",
+ "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.1.tgz",
+ "integrity": "sha512-0jWhJpD/MdhPXwPuiRkCbfYfSKp2qnn2eOc279qI7f+osl/l+prKSrvhg157zSYvx/1nmgn2NqdT6k2Z7zSH9w==",
+ "dev": true,
+ "dependencies": {
+ "brace-expansion": "^2.0.1"
+ },
+ "engines": {
+ "node": ">=16 || 14 >=14.17"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/isaacs"
+ }
+ },
"node_modules/ee-first": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz",
@@ -12922,6 +13032,18 @@
"resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz",
"integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g=="
},
+ "node_modules/extend-shallow": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/extend-shallow/-/extend-shallow-2.0.1.tgz",
+ "integrity": "sha512-zCnTtlxNoAiDc3gqY2aYAWFx7XWWiasuF2K8Me5WbN8otHKTUKBwjPtNpRs/rbUZm7KxWAaNj7P1a/p52GbVug==",
+ "dev": true,
+ "dependencies": {
+ "is-extendable": "^0.1.0"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
"node_modules/external-editor": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/external-editor/-/external-editor-3.1.0.tgz",
@@ -14451,6 +14573,12 @@
"resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="
},
+ "node_modules/ini": {
+ "version": "1.3.8",
+ "resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz",
+ "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==",
+ "dev": true
+ },
"node_modules/inline-style-prefixer": {
"version": "6.0.4",
"resolved": "https://registry.npmjs.org/inline-style-prefixer/-/inline-style-prefixer-6.0.4.tgz",
@@ -14708,6 +14836,15 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
+ "node_modules/is-extendable": {
+ "version": "0.1.1",
+ "resolved": "https://registry.npmjs.org/is-extendable/-/is-extendable-0.1.1.tgz",
+ "integrity": "sha512-5BMULNob1vgFX6EjQw5izWDxrecWK9AM72rugNr0TFldMOi0fj6Jk+zeKIt0xGj4cEfQIJth4w3OKWOJ4f+AFw==",
+ "dev": true,
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
"node_modules/is-extglob": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz",
@@ -14987,6 +15124,15 @@
"url": "https://github.com/sponsors/ljharb"
}
},
+ "node_modules/is-whitespace": {
+ "version": "0.3.0",
+ "resolved": "https://registry.npmjs.org/is-whitespace/-/is-whitespace-0.3.0.tgz",
+ "integrity": "sha512-RydPhl4S6JwAyj0JJjshWJEFG6hNye3pZFBRZaTUfZFwGHxzppNaNOVgQuS/E/SlhrApuMXrpnK1EEIXfdo3Dg==",
+ "dev": true,
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
"node_modules/is-wsl": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/is-wsl/-/is-wsl-2.2.0.tgz",
@@ -15754,6 +15900,80 @@
"resolved": "https://registry.npmjs.org/jquery-i18next/-/jquery-i18next-1.2.1.tgz",
"integrity": "sha512-UNcw3rgxoKjGEg4w23FEn2h3OlPJU7rPzsgDuXDBZktIzeiVbJohs9Cv9hj8oP8KNfBRKOoErL/OVxg2FaAR4g=="
},
+ "node_modules/js-beautify": {
+ "version": "1.15.1",
+ "resolved": "https://registry.npmjs.org/js-beautify/-/js-beautify-1.15.1.tgz",
+ "integrity": "sha512-ESjNzSlt/sWE8sciZH8kBF8BPlwXPwhR6pWKAw8bw4Bwj+iZcnKW6ONWUutJ7eObuBZQpiIb8S7OYspWrKt7rA==",
+ "dev": true,
+ "dependencies": {
+ "config-chain": "^1.1.13",
+ "editorconfig": "^1.0.4",
+ "glob": "^10.3.3",
+ "js-cookie": "^3.0.5",
+ "nopt": "^7.2.0"
+ },
+ "bin": {
+ "css-beautify": "js/bin/css-beautify.js",
+ "html-beautify": "js/bin/html-beautify.js",
+ "js-beautify": "js/bin/js-beautify.js"
+ },
+ "engines": {
+ "node": ">=14"
+ }
+ },
+ "node_modules/js-beautify/node_modules/brace-expansion": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz",
+ "integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==",
+ "dev": true,
+ "dependencies": {
+ "balanced-match": "^1.0.0"
+ }
+ },
+ "node_modules/js-beautify/node_modules/glob": {
+ "version": "10.4.5",
+ "resolved": "https://registry.npmjs.org/glob/-/glob-10.4.5.tgz",
+ "integrity": "sha512-7Bv8RF0k6xjo7d4A/PxYLbUCfb6c+Vpd2/mB2yRDlew7Jb5hEXiCD9ibfO7wpk8i4sevK6DFny9h7EYbM3/sHg==",
+ "dev": true,
+ "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/js-beautify/node_modules/js-cookie": {
+ "version": "3.0.5",
+ "resolved": "https://registry.npmjs.org/js-cookie/-/js-cookie-3.0.5.tgz",
+ "integrity": "sha512-cEiJEAEoIbWfCZYKWhVwFuvPX1gETRYPw6LlaTKoxD3s2AkXzkCjnp6h0V77ozyqj0jakteJ4YqDJT830+lVGw==",
+ "dev": true,
+ "engines": {
+ "node": ">=14"
+ }
+ },
+ "node_modules/js-beautify/node_modules/minimatch": {
+ "version": "9.0.5",
+ "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz",
+ "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==",
+ "dev": true,
+ "dependencies": {
+ "brace-expansion": "^2.0.1"
+ },
+ "engines": {
+ "node": ">=16 || 14 >=14.17"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/isaacs"
+ }
+ },
"node_modules/js-cookie": {
"version": "2.2.1",
"resolved": "https://registry.npmjs.org/js-cookie/-/js-cookie-2.2.1.tgz",
@@ -17945,6 +18165,21 @@
"url": "https://github.com/sponsors/antelle"
}
},
+ "node_modules/nopt": {
+ "version": "7.2.1",
+ "resolved": "https://registry.npmjs.org/nopt/-/nopt-7.2.1.tgz",
+ "integrity": "sha512-taM24ViiimT/XntxbPyJQzCG+p4EKOpgD3mxFwW38mGjVUrfERQOeY4EDHjdnptttfHuHQXFx+lTP08Q+mLa/w==",
+ "dev": true,
+ "dependencies": {
+ "abbrev": "^2.0.0"
+ },
+ "bin": {
+ "nopt": "bin/nopt.js"
+ },
+ "engines": {
+ "node": "^14.17.0 || ^16.13.0 || >=18.0.0"
+ }
+ },
"node_modules/normalize-package-data": {
"version": "6.0.2",
"resolved": "https://registry.npmjs.org/normalize-package-data/-/normalize-package-data-6.0.2.tgz",
@@ -18909,6 +19144,20 @@
"node": ">= 0.8.0"
}
},
+ "node_modules/pretty": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/pretty/-/pretty-2.0.0.tgz",
+ "integrity": "sha512-G9xUchgTEiNpormdYBl+Pha50gOUovT18IvAe7EYMZ1/f9W/WWMPRn+xI68yXNMUk3QXHDwo/1wV/4NejVNe1w==",
+ "dev": true,
+ "dependencies": {
+ "condense-newlines": "^0.2.1",
+ "extend-shallow": "^2.0.1",
+ "js-beautify": "^1.6.12"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
"node_modules/pretty-format": {
"version": "29.7.0",
"resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-29.7.0.tgz",
@@ -19040,6 +19289,12 @@
"node": ">=0.10"
}
},
+ "node_modules/proto-list": {
+ "version": "1.2.4",
+ "resolved": "https://registry.npmjs.org/proto-list/-/proto-list-1.2.4.tgz",
+ "integrity": "sha512-vtK/94akxsTMhe0/cbfpR+syPuszcuwhqVjJq26CuNDgFGj682oRBXOP5MJpv2r7JtE8MsiepGIqvvOTBwn2vA==",
+ "dev": true
+ },
"node_modules/proxy-addr": {
"version": "2.0.7",
"resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz",
@@ -27445,6 +27700,12 @@
"fastq": "^1.6.0"
}
},
+ "@one-ini/wasm": {
+ "version": "0.1.1",
+ "resolved": "https://registry.npmjs.org/@one-ini/wasm/-/wasm-0.1.1.tgz",
+ "integrity": "sha512-XuySG1E38YScSJoMlqovLru4KTUNSjgVTIjyh7qMX6aNN5HY5Ct5LhRJdxO79JtTzKfzV/bnWpz+zquYrISsvw==",
+ "dev": true
+ },
"@pkgjs/parseargs": {
"version": "0.11.0",
"resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz",
@@ -29247,6 +29508,12 @@
"@types/node": "*"
}
},
+ "@types/pretty": {
+ "version": "2.0.3",
+ "resolved": "https://registry.npmjs.org/@types/pretty/-/pretty-2.0.3.tgz",
+ "integrity": "sha512-xR96pShNlrxLd3gZqzCnbaAmbYhiRYjW51CDFjektZemqpBZBAAkMwxm4gBraJP/xSgKcsQhLXdlXOwDNWo4VQ==",
+ "dev": true
+ },
"@types/prop-types": {
"version": "15.7.5",
"resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.5.tgz",
@@ -30332,6 +30599,12 @@
"resolved": "https://registry.npmjs.org/abab/-/abab-2.0.6.tgz",
"integrity": "sha512-j2afSsaIENvHZN2B8GOpF566vZ5WVk5opAiMTvWgaQT8DkbOqsTfvNAvHoRGU2zzP8cPoqys+xHTRDWW8L+/BA=="
},
+ "abbrev": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/abbrev/-/abbrev-2.0.0.tgz",
+ "integrity": "sha512-6/mh1E2u2YgEsCHdY0Yx5oW+61gZU+1vXaoiHHrpKeuRNNgFvS+/jrwHiQhB5apAf5oB7UB7E19ol2R2LKH8hQ==",
+ "dev": true
+ },
"abort-controller": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/abort-controller/-/abort-controller-3.0.0.tgz",
@@ -31769,6 +32042,38 @@
"resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz",
"integrity": "sha1-2Klr13/Wjfd5OnMDajug1UBdR3s="
},
+ "condense-newlines": {
+ "version": "0.2.1",
+ "resolved": "https://registry.npmjs.org/condense-newlines/-/condense-newlines-0.2.1.tgz",
+ "integrity": "sha512-P7X+QL9Hb9B/c8HI5BFFKmjgBu2XpQuF98WZ9XkO+dBGgk5XgwiQz7o1SmpglNWId3581UcS0SFAWfoIhMHPfg==",
+ "dev": true,
+ "requires": {
+ "extend-shallow": "^2.0.1",
+ "is-whitespace": "^0.3.0",
+ "kind-of": "^3.0.2"
+ },
+ "dependencies": {
+ "kind-of": {
+ "version": "3.2.2",
+ "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-3.2.2.tgz",
+ "integrity": "sha512-NOW9QQXMoZGg/oqnVNoNTTIFEIid1627WCffUBJEdMxYApq7mNE7CpzucIPc+ZQg25Phej7IJSmX3hO+oblOtQ==",
+ "dev": true,
+ "requires": {
+ "is-buffer": "^1.1.5"
+ }
+ }
+ }
+ },
+ "config-chain": {
+ "version": "1.1.13",
+ "resolved": "https://registry.npmjs.org/config-chain/-/config-chain-1.1.13.tgz",
+ "integrity": "sha512-qj+f8APARXHrM0hraqXYb2/bOVSV4PvJQlNZ/DVj0QrmNM2q2euizkeuVckQ57J+W0mRH6Hvi+k50M4Jul2VRQ==",
+ "dev": true,
+ "requires": {
+ "ini": "^1.3.4",
+ "proto-list": "~1.2.1"
+ }
+ },
"connect": {
"version": "3.7.0",
"resolved": "https://registry.npmjs.org/connect/-/connect-3.7.0.tgz",
@@ -32449,6 +32754,44 @@
}
}
},
+ "editorconfig": {
+ "version": "1.0.4",
+ "resolved": "https://registry.npmjs.org/editorconfig/-/editorconfig-1.0.4.tgz",
+ "integrity": "sha512-L9Qe08KWTlqYMVvMcTIvMAdl1cDUubzRNYL+WfA4bLDMHe4nemKkpmYzkznE1FwLKu0EEmy6obgQKzMJrg4x9Q==",
+ "dev": true,
+ "requires": {
+ "@one-ini/wasm": "0.1.1",
+ "commander": "^10.0.0",
+ "minimatch": "9.0.1",
+ "semver": "^7.5.3"
+ },
+ "dependencies": {
+ "brace-expansion": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz",
+ "integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==",
+ "dev": true,
+ "requires": {
+ "balanced-match": "^1.0.0"
+ }
+ },
+ "commander": {
+ "version": "10.0.1",
+ "resolved": "https://registry.npmjs.org/commander/-/commander-10.0.1.tgz",
+ "integrity": "sha512-y4Mg2tXshplEbSGzx7amzPwKKOCGuoSRP/CjEdwwk0FOGlUbq6lKuoyDZTNZkmxHdJtp54hdfY/JUrdL7Xfdug==",
+ "dev": true
+ },
+ "minimatch": {
+ "version": "9.0.1",
+ "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.1.tgz",
+ "integrity": "sha512-0jWhJpD/MdhPXwPuiRkCbfYfSKp2qnn2eOc279qI7f+osl/l+prKSrvhg157zSYvx/1nmgn2NqdT6k2Z7zSH9w==",
+ "dev": true,
+ "requires": {
+ "brace-expansion": "^2.0.1"
+ }
+ }
+ }
+ },
"ee-first": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz",
@@ -33435,6 +33778,15 @@
"resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz",
"integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g=="
},
+ "extend-shallow": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/extend-shallow/-/extend-shallow-2.0.1.tgz",
+ "integrity": "sha512-zCnTtlxNoAiDc3gqY2aYAWFx7XWWiasuF2K8Me5WbN8otHKTUKBwjPtNpRs/rbUZm7KxWAaNj7P1a/p52GbVug==",
+ "dev": true,
+ "requires": {
+ "is-extendable": "^0.1.0"
+ }
+ },
"external-editor": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/external-editor/-/external-editor-3.1.0.tgz",
@@ -34547,6 +34899,12 @@
"resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="
},
+ "ini": {
+ "version": "1.3.8",
+ "resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz",
+ "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==",
+ "dev": true
+ },
"inline-style-prefixer": {
"version": "6.0.4",
"resolved": "https://registry.npmjs.org/inline-style-prefixer/-/inline-style-prefixer-6.0.4.tgz",
@@ -34733,6 +35091,12 @@
"resolved": "https://registry.npmjs.org/is-docker/-/is-docker-2.2.1.tgz",
"integrity": "sha512-F+i2BKsFrH66iaUFc0woD8sLy8getkwTwtOBjvs56Cx4CgJDeKQeqfz8wAYiSb8JOprWhHH5p77PbmYCvvUuXQ=="
},
+ "is-extendable": {
+ "version": "0.1.1",
+ "resolved": "https://registry.npmjs.org/is-extendable/-/is-extendable-0.1.1.tgz",
+ "integrity": "sha512-5BMULNob1vgFX6EjQw5izWDxrecWK9AM72rugNr0TFldMOi0fj6Jk+zeKIt0xGj4cEfQIJth4w3OKWOJ4f+AFw==",
+ "dev": true
+ },
"is-extglob": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz",
@@ -34897,6 +35261,12 @@
"call-bind": "^1.0.2"
}
},
+ "is-whitespace": {
+ "version": "0.3.0",
+ "resolved": "https://registry.npmjs.org/is-whitespace/-/is-whitespace-0.3.0.tgz",
+ "integrity": "sha512-RydPhl4S6JwAyj0JJjshWJEFG6hNye3pZFBRZaTUfZFwGHxzppNaNOVgQuS/E/SlhrApuMXrpnK1EEIXfdo3Dg==",
+ "dev": true
+ },
"is-wsl": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/is-wsl/-/is-wsl-2.2.0.tgz",
@@ -35454,6 +35824,59 @@
"resolved": "https://registry.npmjs.org/jquery-i18next/-/jquery-i18next-1.2.1.tgz",
"integrity": "sha512-UNcw3rgxoKjGEg4w23FEn2h3OlPJU7rPzsgDuXDBZktIzeiVbJohs9Cv9hj8oP8KNfBRKOoErL/OVxg2FaAR4g=="
},
+ "js-beautify": {
+ "version": "1.15.1",
+ "resolved": "https://registry.npmjs.org/js-beautify/-/js-beautify-1.15.1.tgz",
+ "integrity": "sha512-ESjNzSlt/sWE8sciZH8kBF8BPlwXPwhR6pWKAw8bw4Bwj+iZcnKW6ONWUutJ7eObuBZQpiIb8S7OYspWrKt7rA==",
+ "dev": true,
+ "requires": {
+ "config-chain": "^1.1.13",
+ "editorconfig": "^1.0.4",
+ "glob": "^10.3.3",
+ "js-cookie": "^3.0.5",
+ "nopt": "^7.2.0"
+ },
+ "dependencies": {
+ "brace-expansion": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz",
+ "integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==",
+ "dev": true,
+ "requires": {
+ "balanced-match": "^1.0.0"
+ }
+ },
+ "glob": {
+ "version": "10.4.5",
+ "resolved": "https://registry.npmjs.org/glob/-/glob-10.4.5.tgz",
+ "integrity": "sha512-7Bv8RF0k6xjo7d4A/PxYLbUCfb6c+Vpd2/mB2yRDlew7Jb5hEXiCD9ibfO7wpk8i4sevK6DFny9h7EYbM3/sHg==",
+ "dev": true,
+ "requires": {
+ "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"
+ }
+ },
+ "js-cookie": {
+ "version": "3.0.5",
+ "resolved": "https://registry.npmjs.org/js-cookie/-/js-cookie-3.0.5.tgz",
+ "integrity": "sha512-cEiJEAEoIbWfCZYKWhVwFuvPX1gETRYPw6LlaTKoxD3s2AkXzkCjnp6h0V77ozyqj0jakteJ4YqDJT830+lVGw==",
+ "dev": true
+ },
+ "minimatch": {
+ "version": "9.0.5",
+ "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz",
+ "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==",
+ "dev": true,
+ "requires": {
+ "brace-expansion": "^2.0.1"
+ }
+ }
+ }
+ },
"js-cookie": {
"version": "2.2.1",
"resolved": "https://registry.npmjs.org/js-cookie/-/js-cookie-2.2.1.tgz",
@@ -37117,6 +37540,15 @@
"resolved": "https://registry.npmjs.org/node-stream-zip/-/node-stream-zip-1.15.0.tgz",
"integrity": "sha512-LN4fydt9TqhZhThkZIVQnF9cwjU3qmUH9h78Mx/K7d3VvfRqqwthLwJEUOEL0QPZ0XQmNN7be5Ggit5+4dq3Bw=="
},
+ "nopt": {
+ "version": "7.2.1",
+ "resolved": "https://registry.npmjs.org/nopt/-/nopt-7.2.1.tgz",
+ "integrity": "sha512-taM24ViiimT/XntxbPyJQzCG+p4EKOpgD3mxFwW38mGjVUrfERQOeY4EDHjdnptttfHuHQXFx+lTP08Q+mLa/w==",
+ "dev": true,
+ "requires": {
+ "abbrev": "^2.0.0"
+ }
+ },
"normalize-package-data": {
"version": "6.0.2",
"resolved": "https://registry.npmjs.org/normalize-package-data/-/normalize-package-data-6.0.2.tgz",
@@ -37784,6 +38216,17 @@
"integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==",
"dev": true
},
+ "pretty": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/pretty/-/pretty-2.0.0.tgz",
+ "integrity": "sha512-G9xUchgTEiNpormdYBl+Pha50gOUovT18IvAe7EYMZ1/f9W/WWMPRn+xI68yXNMUk3QXHDwo/1wV/4NejVNe1w==",
+ "dev": true,
+ "requires": {
+ "condense-newlines": "^0.2.1",
+ "extend-shallow": "^2.0.1",
+ "js-beautify": "^1.6.12"
+ }
+ },
"pretty-format": {
"version": "29.7.0",
"resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-29.7.0.tgz",
@@ -37885,6 +38328,12 @@
"integrity": "sha512-qYNxyMj1JeW54i/EWEFsM1cVwxJbtgPp8+0Wg9XjNaK6VE/c4oRi6PNu5p7w1mNXEIQIjV5Wwn8v8Gz82/QzdQ==",
"dev": true
},
+ "proto-list": {
+ "version": "1.2.4",
+ "resolved": "https://registry.npmjs.org/proto-list/-/proto-list-1.2.4.tgz",
+ "integrity": "sha512-vtK/94akxsTMhe0/cbfpR+syPuszcuwhqVjJq26CuNDgFGj682oRBXOP5MJpv2r7JtE8MsiepGIqvvOTBwn2vA==",
+ "dev": true
+ },
"proxy-addr": {
"version": "2.0.7",
"resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz",
diff --git a/package.json b/package.json
index a80f4a28e9..758708054d 100644
--- a/package.json
+++ b/package.json
@@ -146,6 +146,7 @@
"@types/moment-duration-format": "2.2.6",
"@types/offscreencanvas": "2019.7.2",
"@types/pixelmatch": "5.2.5",
+ "@types/pretty": "2.0.3",
"@types/punycode": "2.1.0",
"@types/react": "17.0.14",
"@types/react-dom": "17.0.14",
@@ -182,6 +183,7 @@
"jsonwebtoken": "9.0.2",
"metro-react-native-babel-preset": "0.77.0",
"patch-package": "6.4.7",
+ "pretty": "2.0.0",
"process": "0.11.10",
"sass": "1.26.8",
"style-loader": "3.3.1",
diff --git a/react/features/display-name/components/web/StageParticipantNameLabel.tsx b/react/features/display-name/components/web/StageParticipantNameLabel.tsx
index c8d3adfc37..1eb9242890 100644
--- a/react/features/display-name/components/web/StageParticipantNameLabel.tsx
+++ b/react/features/display-name/components/web/StageParticipantNameLabel.tsx
@@ -106,7 +106,8 @@ const StageParticipantNameLabel = () => {
classes.badgeContainer,
toolboxVisible && classes.containerElevated,
_isScreenShareParticipant && classes.screenSharing
- ) }>
+ ) }
+ data-testid = 'stage-display-name' >
);
diff --git a/tests/helpers/Participant.ts b/tests/helpers/Participant.ts
index d527578d8f..263f08848f 100644
--- a/tests/helpers/Participant.ts
+++ b/tests/helpers/Participant.ts
@@ -7,11 +7,12 @@ import { urlObjectToString } from '../../react/features/base/util/uri';
import Filmstrip from '../pageobjects/Filmstrip';
import IframeAPI from '../pageobjects/IframeAPI';
import ParticipantsPane from '../pageobjects/ParticipantsPane';
+import SettingsDialog from '../pageobjects/SettingsDialog';
import Toolbar from '../pageobjects/Toolbar';
import VideoQualityDialog from '../pageobjects/VideoQualityDialog';
import { LOG_PREFIX, logInfo } from './browserLogger';
-import { IContext } from './types';
+import { IContext, IJoinOptions } from './types';
/**
* Participant.
@@ -112,21 +113,25 @@ export class Participant {
* Joins conference.
*
* @param {IContext} context - The context.
- * @param {boolean} skipInMeetingChecks - Whether to skip in meeting checks.
+ * @param {IJoinOptions} options - Options for joining.
* @returns {Promise}
*/
- async joinConference(context: IContext, skipInMeetingChecks = false): Promise {
+ async joinConference(context: IContext, options: IJoinOptions = {}): Promise {
const config = {
room: context.roomName,
configOverwrite: this.config,
interfaceConfigOverwrite: {
SHOW_CHROME_EXTENSION_BANNER: false
- },
- userInfo: {
- displayName: this._name
}
};
+ if (!options.skipDisplayName) {
+ // @ts-ignore
+ config.userInfo = {
+ displayName: this._name
+ };
+ }
+
if (context.iframeAPI) {
config.room = 'iframeAPITest.html';
}
@@ -168,7 +173,7 @@ export class Participant {
await this.waitToJoinMUC();
- await this.postLoadProcess(skipInMeetingChecks);
+ await this.postLoadProcess(options.skipInMeetingChecks);
}
/**
@@ -178,7 +183,7 @@ export class Participant {
* @returns {Promise}
* @private
*/
- private async postLoadProcess(skipInMeetingChecks: boolean): Promise {
+ private async postLoadProcess(skipInMeetingChecks = false): Promise {
const driver = this.driver;
const parallel = [];
@@ -227,7 +232,7 @@ export class Participant {
*/
async waitForPageToLoad(): Promise {
return this.driver.waitUntil(
- () => this.driver.execute(() => document.readyState === 'complete'),
+ async () => await this.driver.execute(() => document.readyState === 'complete'),
{
timeout: 30_000, // 30 seconds
timeoutMsg: 'Timeout waiting for Page Load Request to complete.'
@@ -238,8 +243,8 @@ export class Participant {
/**
* Checks if the participant is in the meeting.
*/
- isInMuc() {
- return this.driver.execute(() => APP.conference.isJoined());
+ async isInMuc() {
+ return await this.driver.execute(() => typeof APP !== 'undefined' && APP.conference?.isJoined());
}
/**
@@ -266,7 +271,7 @@ export class Participant {
const driver = this.driver;
return driver.waitUntil(async () =>
- driver.execute(() => APP.conference.getConnectionState() === 'connected'), {
+ await driver.execute(() => APP.conference.getConnectionState() === 'connected'), {
timeout: 15_000,
timeoutMsg: 'expected ICE to be connected for 15s'
});
@@ -281,7 +286,7 @@ export class Participant {
const driver = this.driver;
return driver.waitUntil(async () =>
- driver.execute(() => {
+ await driver.execute(() => {
const stats = APP.conference.getStats();
const bitrateMap = stats?.bitrate || {};
const rtpStats = {
@@ -306,7 +311,7 @@ export class Participant {
const driver = this.driver;
return driver.waitUntil(async () =>
- driver.execute(count => APP.conference.getNumberOfParticipantsWithTracks() >= count, number), {
+ await driver.execute(count => APP.conference.getNumberOfParticipantsWithTracks() >= count, number), {
timeout: 15_000,
timeoutMsg: 'expected remote streams in 15s'
});
@@ -348,6 +353,15 @@ export class Participant {
return new VideoQualityDialog(this);
}
+ /**
+ * Returns the settings Dialog.
+ *
+ * @returns {SettingsDialog}
+ */
+ getSettingsDialog(): SettingsDialog {
+ return new SettingsDialog(this);
+ }
+
/**
* Switches to the iframe API context
*/
@@ -371,6 +385,13 @@ export class Participant {
return new IframeAPI(this);
}
+ /**
+ * Hangups the participant by leaving the page. base.html is an empty page on all deployments.
+ */
+ async hangup() {
+ await this.driver.url('/base.html');
+ }
+
/**
* Returns the local display name.
*/
@@ -383,4 +404,92 @@ export class Participant {
return await localDisplayName.getText();
}
+
+ /**
+ * Gets avatar SRC attribute for the one displayed on local video thumbnail.
+ */
+ async getLocalVideoAvatar() {
+ const avatar
+ = this.driver.$('//span[@id="localVideoContainer"]//img[contains(@class,"userAvatar")]');
+
+ return await avatar.isExisting() ? await avatar.getAttribute('src') : null;
+ }
+
+ /**
+ * Gets avatar SRC attribute for the one displayed on large video.
+ */
+ async getLargeVideoAvatar() {
+ const avatar = this.driver.$('//img[@id="dominantSpeakerAvatar"]');
+
+ return await avatar.isExisting() ? await avatar.getAttribute('src') : null;
+ }
+
+ /**
+ * Returns resource part of the JID of the user who is currently displayed in the large video area.
+ */
+ async getLargeVideoResource() {
+ return await this.driver.execute(() => APP.UI.getLargeVideoID());
+ }
+
+ /**
+ * Makes sure that the avatar is displayed in the local thumbnail and that the video is not displayed.
+ * There are 3 options for avatar:
+ * - defaultAvatar: true - the default avatar (with grey figure) is used
+ * - image: true - the avatar is an image set in the settings
+ * - defaultAvatar: false, image: false - the avatar is produced from the initials of the display name
+ */
+ async assertThumbnailShowsAvatar(
+ participant: Participant, reverse = false, defaultAvatar = false, image = false): Promise {
+ const id = participant === this
+ ? 'localVideoContainer' : `participant_${await participant.getEndpointId()}`;
+
+ const xpath = defaultAvatar
+ ? `//span[@id='${id}']//div[contains(@class,'userAvatar') and contains(@class, 'defaultAvatar')]`
+ : `//span[@id="${id}"]//${image ? 'img' : 'div'}[contains(@class,"userAvatar")]`;
+
+ await this.driver.$(xpath).waitForDisplayed({
+ reverse,
+ timeout: 2000,
+ timeoutMsg: `Avatar is ${reverse ? '' : 'not'} displayed in the local thumbnail for ${participant.name}`
+ });
+
+ await this.driver.$(`//span[@id="${id}"]//video`).waitForDisplayed({
+ reverse: !reverse,
+ timeout: 2000,
+ timeoutMsg: `Video is ${reverse ? 'not' : ''} displayed in the local thumbnail for ${participant.name}`
+ });
+ }
+
+ /**
+ * Makes sure that the default avatar is used.
+ */
+ async assertDefaultAvatarExist(participant: Participant): Promise {
+ const id = participant === this
+ ? 'localVideoContainer' : `participant_${await participant.getEndpointId()}`;
+
+ await this.driver.$(
+ `//span[@id='${id}']//div[contains(@class,'userAvatar') and contains(@class, 'defaultAvatar')]`)
+ .waitForExist({
+ timeout: 2000,
+ timeoutMsg: `Default avatar does not exist for ${participant.name}`
+ });
+ }
+
+ /**
+ * Makes sure that the local video is displayed in the local thumbnail and that the avatar is not displayed.
+ */
+ async asserLocalThumbnailShowsVideo(): Promise {
+ await this.assertThumbnailShowsAvatar(this, true);
+ }
+
+ /**
+ * Make sure a display name is visible on the stage.
+ * @param value
+ */
+ async assertDisplayNameVisibleOnStage(value: string) {
+ const displayNameEl = this.driver.$('div[data-testid="stage-display-name"]');
+
+ expect(await displayNameEl.isDisplayed()).toBeTrue();
+ expect(await displayNameEl.getText()).toBe(value);
+ }
}
diff --git a/tests/helpers/participants.ts b/tests/helpers/participants.ts
index 1b5d78151b..d411d6c4f3 100644
--- a/tests/helpers/participants.ts
+++ b/tests/helpers/participants.ts
@@ -5,7 +5,7 @@ import { v4 as uuidv4 } from 'uuid';
import { Participant } from './Participant';
import WebhookProxy from './WebhookProxy';
-import { IContext } from './types';
+import { IContext, IJoinOptions } from './types';
/**
* Generate a random room name.
@@ -31,16 +31,20 @@ function generateRandomRoomName(): string {
* Ensure that there is on participant.
*
* @param {IContext} context - The context.
+ * @param {IJoinOptions} options - The options to use when joining the participant.
* @returns {Promise}
*/
-export async function ensureOneParticipant(context: IContext): Promise {
+export async function ensureOneParticipant(context: IContext, options?: IJoinOptions): Promise {
if (!context.roomName) {
context.roomName = generateRandomRoomName();
}
context.p1 = new Participant('participant1');
- await context.p1.joinConference(context, true);
+ await context.p1.joinConference(context, {
+ ...options,
+ skipInMeetingChecks: true
+ });
}
/**
@@ -80,9 +84,10 @@ export async function ensureThreeParticipants(context: IContext): Promise
* Ensure that there are two participants.
*
* @param {Object} context - The context.
+ * @param {IJoinOptions} options - The options to join.
* @returns {Promise}
*/
-export async function ensureTwoParticipants(context: IContext): Promise {
+export async function ensureTwoParticipants(context: IContext, options?: IJoinOptions): Promise {
if (!context.roomName) {
context.roomName = generateRandomRoomName();
}
@@ -98,12 +103,15 @@ export async function ensureTwoParticipants(context: IContext): Promise {
// make sure the first participant is moderator, if supported by deployment
await _joinParticipant(p1DisplayName, context.p1, p => {
context.p1 = p;
- }, true, token);
+ }, {
+ ...options,
+ skipInMeetingChecks: true
+ }, token);
await Promise.all([
_joinParticipant('participant2', context.p2, p => {
context.p2 = p;
- }),
+ }, options),
context.p1.waitForRemoteStreams(1),
context.p2.waitForRemoteStreams(1)
]);
@@ -114,24 +122,28 @@ export async function ensureTwoParticipants(context: IContext): Promise {
* @param name - The name of the participant.
* @param p - The participant instance to prepare or undefined if new one is needed.
* @param setter - The setter to use for setting the new participant instance into the context if needed.
- * @param {boolean} skipInMeetingChecks - Whether to skip in meeting checks.
+ * @param {boolean} options - Join options.
* @param {string?} jwtToken - The token to use if any.
*/
async function _joinParticipant( // eslint-disable-line max-params
name: string,
p: Participant,
setter: (p: Participant) => void,
- skipInMeetingChecks = false,
+ options: IJoinOptions = {},
jwtToken?: string) {
if (p) {
- await p.switchInPage();
+ if (context.iframeAPI) {
+ await p.switchInPage();
+ }
if (await p.isInMuc()) {
return;
}
- // when loading url make sure we are on the top page context or strange errors may occur
- await p.switchToAPI();
+ if (context.iframeAPI) {
+ // when loading url make sure we are on the top page context or strange errors may occur
+ await p.switchToAPI();
+ }
// Change the page so we can reload same url if we need to, base.html is supposed to be empty or close to empty
await p.driver.url('/base.html');
@@ -144,7 +156,7 @@ async function _joinParticipant( // eslint-disable-line max-params
// set the new participant instance, pass it to setter
setter(newParticipant);
- return newParticipant.joinConference(context, skipInMeetingChecks);
+ await newParticipant.joinConference(context, options);
}
/**
@@ -157,13 +169,25 @@ async function _joinParticipant( // eslint-disable-line max-params
* the mute state of {@code testee}.
* @returns {Promise}
*/
-export async function toggleMuteAndCheck(testee: Participant, observer: Participant): Promise {
+export async function muteAudioAndCheck(testee: Participant, observer: Participant): Promise {
await testee.getToolbar().clickAudioMuteButton();
await observer.getFilmstrip().assertAudioMuteIconIsDisplayed(testee);
await testee.getFilmstrip().assertAudioMuteIconIsDisplayed(testee);
}
+/**
+ * Starts the video on testee and check on observer.
+ * @param testee
+ * @param observer
+ */
+export async function unMuteVideoAndCheck(testee: Participant, observer: Participant): Promise {
+ await testee.getToolbar().clickVideoUnmuteButton();
+
+ await observer.getParticipantsPane().assertVideoMuteIconIsDisplayed(testee, true);
+ await testee.getParticipantsPane().assertVideoMuteIconIsDisplayed(testee, true);
+}
+
/**
* Get a JWT token for a moderator.
*/
diff --git a/tests/helpers/types.ts b/tests/helpers/types.ts
index e75c3c9e91..4f292f72d2 100644
--- a/tests/helpers/types.ts
+++ b/tests/helpers/types.ts
@@ -13,3 +13,16 @@ export type IContext = {
roomName: string;
webhooksProxy: WebhookProxy;
};
+
+export type IJoinOptions = {
+
+ /**
+ * Whether to skip setting display name.
+ */
+ skipDisplayName?: boolean;
+
+ /**
+ * Whether to skip in meeting checks like ice connected and send receive data. For single in meeting participant.
+ */
+ skipInMeetingChecks?: boolean;
+};
diff --git a/tests/pageobjects/BaseDialog.ts b/tests/pageobjects/BaseDialog.ts
index bab64b9618..603a16d967 100644
--- a/tests/pageobjects/BaseDialog.ts
+++ b/tests/pageobjects/BaseDialog.ts
@@ -1,6 +1,7 @@
import { Participant } from '../helpers/Participant';
const CLOSE_BUTTON = 'modal-header-close-button';
+const OK_BUTTON = 'modal-dialog-ok-button';
/**
* Base class for all dialogs.
@@ -23,4 +24,11 @@ export default class BaseDialog {
async clickCloseButton(): Promise {
await this.participant.driver.$(`#${CLOSE_BUTTON}`).click();
}
+
+ /**
+ * Clicks on the ok button.
+ */
+ async clickOkButton(): Promise {
+ await this.participant.driver.$(`#${OK_BUTTON}`).click();
+ }
}
diff --git a/tests/pageobjects/Filmstrip.ts b/tests/pageobjects/Filmstrip.ts
index e9cc14919b..04f5ab5151 100644
--- a/tests/pageobjects/Filmstrip.ts
+++ b/tests/pageobjects/Filmstrip.ts
@@ -44,37 +44,6 @@ export default class Filmstrip {
});
}
- /**
- * Asserts that {@code participant} shows or doesn't show the video mute icon for the conference participant
- * identified by {@code testee}.
- *
- * @param {Participant} testee - The {@code Participant} for whom we're checking the status of audio muted icon.
- * @param {boolean} reverse - If {@code true}, the method will assert the absence of the "mute" icon;
- * otherwise, it will assert its presence.
- * @returns {Promise}
- */
- async assertVideoMuteIconIsDisplayed(testee: Participant, reverse = false): Promise {
- const isOpen = await this.participant.getParticipantsPane().isOpen();
-
- if (!isOpen) {
- await this.participant.getParticipantsPane().open();
- }
-
- const id = `participant-item-${await testee.getEndpointId()}`;
- const mutedIconXPath
- = `//div[@id='${id}']//div[contains(@class, 'indicators')]//*[local-name()='svg' and @id='videoMuted']`;
-
- await this.participant.driver.$(mutedIconXPath).waitForDisplayed({
- reverse,
- timeout: 2000,
- timeoutMsg: `Video mute icon is ${reverse ? '' : 'not'} displayed for ${testee.name}`
- });
-
- if (!isOpen) {
- await this.participant.getParticipantsPane().close();
- }
- }
-
/**
* Returns the remote display name for an endpoint.
* @param endpointId The endpoint id.
@@ -86,4 +55,26 @@ export default class Filmstrip {
return await remoteDisplayName.getText();
}
+
+ /**
+ * Pins a participant by clicking on their thumbnail.
+ * @param participant The participant.
+ */
+ async pinParticipant(participant: Participant) {
+ const id = participant === this.participant
+ ? 'localVideoContainer' : `participant_${await participant.getEndpointId()}`;
+
+ await this.participant.driver.$(`//span[@id="${id}"]`).click();
+ }
+
+ /**
+ * Gets avatar SRC attribute for the one displayed on small video thumbnail.
+ * @param endpointId
+ */
+ async getAvatar(endpointId: string) {
+ const elem = this.participant.driver.$(
+ `//span[@id='participant_${endpointId}']//img[contains(@class,'userAvatar')]`);
+
+ return await elem.isExisting() ? elem.getAttribute('src') : null;
+ }
}
diff --git a/tests/pageobjects/IframeAPI.ts b/tests/pageobjects/IframeAPI.ts
index 12f25f21fd..11a4458a90 100644
--- a/tests/pageobjects/IframeAPI.ts
+++ b/tests/pageobjects/IframeAPI.ts
@@ -20,7 +20,7 @@ export default class IframeAPI {
* @param event
*/
async getEventResult(event: string): Promise {
- return this.participant.driver.execute(
+ return await this.participant.driver.execute(
eventName => {
const result = window.jitsiAPI.test[eventName];
@@ -37,28 +37,29 @@ export default class IframeAPI {
* @param eventName The event name.
*/
async addEventListener(eventName: string) {
- return this.participant.driver.executeAsync((event, prefix, done) => {
- console.log(`${new Date().toISOString()} ${prefix} Adding listener for event: ${event}`);
- window.jitsiAPI.addListener(event, evt => {
- console.log(`${new Date().toISOString()} ${prefix} Received ${event} event: ${JSON.stringify(evt)}`);
- window.jitsiAPI.test[event] = evt;
- });
- done();
- }, eventName, LOG_PREFIX);
+ return await this.participant.driver.execute(
+ (event, prefix) => {
+ console.log(`${new Date().toISOString()} ${prefix} Adding listener for event: ${event}`);
+ window.jitsiAPI.addListener(event, evt => {
+ console.log(
+ `${new Date().toISOString()} ${prefix} Received ${event} event: ${JSON.stringify(evt)}`);
+ window.jitsiAPI.test[event] = evt;
+ });
+ }, eventName, LOG_PREFIX);
}
/**
* Returns an array of available rooms and details of it.
*/
async getRoomsInfo() {
- return this.participant.driver.execute(() => window.jitsiAPI.getRoomsInfo());
+ return await this.participant.driver.execute(() => window.jitsiAPI.getRoomsInfo());
}
/**
* Returns the number of participants in the conference.
*/
async getNumberOfParticipants() {
- return this.participant.driver.execute(() => window.jitsiAPI.getNumberOfParticipants());
+ return await this.participant.driver.execute(() => window.jitsiAPI.getNumberOfParticipants());
}
/**
@@ -67,7 +68,7 @@ export default class IframeAPI {
* @param args The arguments.
*/
async executeCommand(command: string, ...args: any[]) {
- return this.participant.driver.execute(
+ return await this.participant.driver.execute(
(commandName, commandArgs) =>
window.jitsiAPI.executeCommand(commandName, ...commandArgs)
, command, args);
@@ -77,14 +78,14 @@ export default class IframeAPI {
* Returns the current state of the participant's pane.
*/
async isParticipantsPaneOpen() {
- return this.participant.driver.execute(() => window.jitsiAPI.isParticipantsPaneOpen());
+ return await this.participant.driver.execute(() => window.jitsiAPI.isParticipantsPaneOpen());
}
/**
* Removes the embedded Jitsi Meet conference.
*/
async dispose() {
- return this.participant.driver.execute(() => window.jitsiAPI.dispose());
+ return await this.participant.driver.execute(() => window.jitsiAPI.dispose());
}
}
diff --git a/tests/pageobjects/ParticipantsPane.ts b/tests/pageobjects/ParticipantsPane.ts
index a792eac9f6..64bd73fe8e 100644
--- a/tests/pageobjects/ParticipantsPane.ts
+++ b/tests/pageobjects/ParticipantsPane.ts
@@ -44,4 +44,35 @@ export default class ParticipantsPane {
await this.participant.driver.$(`.${PARTICIPANTS_PANE}`).waitForDisplayed({ reverse: true });
}
+
+ /**
+ * Asserts that {@code participant} shows or doesn't show the video mute icon for the conference participant
+ * identified by {@code testee}.
+ *
+ * @param {Participant} testee - The {@code Participant} for whom we're checking the status of audio muted icon.
+ * @param {boolean} reverse - If {@code true}, the method will assert the absence of the "mute" icon;
+ * otherwise, it will assert its presence.
+ * @returns {Promise}
+ */
+ async assertVideoMuteIconIsDisplayed(testee: Participant, reverse = false): Promise {
+ const isOpen = await this.isOpen();
+
+ if (!isOpen) {
+ await this.open();
+ }
+
+ const id = `participant-item-${await testee.getEndpointId()}`;
+ const mutedIconXPath
+ = `//div[@id='${id}']//div[contains(@class, 'indicators')]//*[local-name()='svg' and @id='videoMuted']`;
+
+ await this.participant.driver.$(mutedIconXPath).waitForDisplayed({
+ reverse,
+ timeout: 2000,
+ timeoutMsg: `Video mute icon is ${reverse ? '' : 'not'} displayed for ${testee.name}`
+ });
+
+ if (!isOpen) {
+ await this.close();
+ }
+ }
}
diff --git a/tests/pageobjects/SettingsDialog.ts b/tests/pageobjects/SettingsDialog.ts
new file mode 100644
index 0000000000..e38ad03311
--- /dev/null
+++ b/tests/pageobjects/SettingsDialog.ts
@@ -0,0 +1,62 @@
+import BaseDialog from './BaseDialog';
+
+const EMAIL_FIELD = '#setEmail';
+const SETTINGS_DIALOG_CONTENT = '.settings-pane';
+const X_PATH_PROFILE_TAB = '//div[contains(@class, "settings-dialog")]//*[text()="Profile"]';
+
+/**
+ * The settings dialog.
+ */
+export default class SettingsDialog extends BaseDialog {
+ /**
+ * Waits for the settings dialog to be visible.
+ */
+ async waitForDisplay() {
+ await this.participant.driver.$(SETTINGS_DIALOG_CONTENT).waitForDisplayed();
+ }
+
+ /**
+ * Displays a specific tab in the settings dialog.
+ * @param xpath
+ * @private
+ */
+ private async openTab(xpath: string) {
+ const elem = this.participant.driver.$(xpath);
+
+ await elem.waitForClickable();
+ await elem.click();
+ }
+
+ /**
+ * Selects the Profile tab to be displayed.
+ */
+ async openProfileTab() {
+ await this.openTab(X_PATH_PROFILE_TAB);
+ }
+
+ /**
+ * Enters the passed in email into the email field.
+ * @param email
+ */
+ async setEmail(email: string) {
+ await this.openProfileTab();
+
+ await this.participant.driver.$(EMAIL_FIELD).setValue(email);
+ }
+
+ /**
+ * Returns the participant's email displayed in the settings dialog.
+ */
+ async getEmail() {
+ await this.openProfileTab();
+
+ return await this.participant.driver.$(EMAIL_FIELD).getValue();
+ }
+
+ /**
+ * Clicks the OK button on the settings dialog to close the dialog and save any changes made.
+ */
+ async submit() {
+ await this.clickOkButton();
+ }
+}
diff --git a/tests/pageobjects/Toolbar.ts b/tests/pageobjects/Toolbar.ts
index 6ae797deb1..11d85051fd 100644
--- a/tests/pageobjects/Toolbar.ts
+++ b/tests/pageobjects/Toolbar.ts
@@ -7,6 +7,7 @@ const CLOSE_PARTICIPANTS_PANE = 'Close participants pane';
const OVERFLOW_MENU = 'More actions menu';
const OVERFLOW = 'More actions';
const PARTICIPANTS = 'Open participants pane';
+const PROFILE = 'Edit your profile';
const VIDEO_QUALITY = 'Manage video quality';
const VIDEO_MUTE = 'Stop camera';
const VIDEO_UNMUTE = 'Start camera';
@@ -134,6 +135,13 @@ export default class Toolbar {
return this.clickButtonInOverflowMenu(VIDEO_QUALITY);
}
+ /**
+ * Clicks on the profile toolbar button which opens or closes the profile panel.
+ */
+ async clickProfileButton(): Promise {
+ return this.clickButtonInOverflowMenu(PROFILE);
+ }
+
/**
* Ensure the overflow menu is open and clicks on a specified button.
* @param accessibilityLabel The accessibility label of the button to be clicked.
@@ -203,4 +211,15 @@ export default class Toolbar {
timeoutMsg: `Overflow menu is not ${visible ? 'visible' : 'hidden'}`
});
}
+
+ /**
+ * Gets the participant's avatar image element located in the toolbar.
+ */
+ async getProfileImage() {
+ await this.openOverflowMenu();
+
+ const elem = this.participant.driver.$(`[aria-label^="${PROFILE}"] img`);
+
+ return await elem.isExisting() ? await elem.getAttribute('src') : null;
+ }
}
diff --git a/tests/specs/2way/audioOnly.spec.ts b/tests/specs/2way/audioOnly.spec.ts
index d0e778b7b9..17344cd020 100644
--- a/tests/specs/2way/audioOnly.spec.ts
+++ b/tests/specs/2way/audioOnly.spec.ts
@@ -19,9 +19,7 @@ describe('Audio only - ', () => {
await context.p1.driver.$('//div[@id="dominantSpeaker"]').waitForDisplayed();
// Makes sure that the avatar is displayed in the local thumbnail and that the video is not displayed.
- await context.p1.driver.$('//span[@id="localVideoContainer"]//div[contains(@class,"userAvatar")]')
- .waitForDisplayed();
- await context.p1.driver.$('//span[@id="localVideoWrapper"]//video').waitForDisplayed({ reverse: true });
+ await context.p1.assertThumbnailShowsAvatar(context.p1);
});
/**
@@ -51,10 +49,10 @@ describe('Audio only - ', () => {
*/
async function verifyVideoMute(muted: boolean) {
// Verify the observer sees the testee in the desired muted state.
- await context.p2.getFilmstrip().assertVideoMuteIconIsDisplayed(context.p1, !muted);
+ await context.p2.getParticipantsPane().assertVideoMuteIconIsDisplayed(context.p1, !muted);
// Verify the testee sees itself in the desired muted state.
- await context.p1.getFilmstrip().assertVideoMuteIconIsDisplayed(context.p1, !muted);
+ await context.p1.getParticipantsPane().assertVideoMuteIconIsDisplayed(context.p1, !muted);
}
/**
diff --git a/tests/specs/3way/activeSpeaker.spec.ts b/tests/specs/3way/activeSpeaker.spec.ts
index 0e637307ab..180531b276 100644
--- a/tests/specs/3way/activeSpeaker.spec.ts
+++ b/tests/specs/3way/activeSpeaker.spec.ts
@@ -1,14 +1,14 @@
/* global APP */
import type { Participant } from '../../helpers/Participant';
-import { ensureThreeParticipants, toggleMuteAndCheck } from '../../helpers/participants';
+import { ensureThreeParticipants, muteAudioAndCheck } from '../../helpers/participants';
describe('ActiveSpeaker ', () => {
it('testActiveSpeaker', async () => {
await ensureThreeParticipants(context);
- await toggleMuteAndCheck(context.p1, context.p2);
- await toggleMuteAndCheck(context.p2, context.p1);
- await toggleMuteAndCheck(context.p3, context.p1);
+ await muteAudioAndCheck(context.p1, context.p2);
+ await muteAudioAndCheck(context.p2, context.p1);
+ await muteAudioAndCheck(context.p3, context.p1);
// participant1 becomes active speaker - check from participant2's perspective
await testActiveSpeaker(context.p1, context.p2, context.p3);
@@ -60,7 +60,8 @@ async function testActiveSpeaker(
const otherParticipant1Driver = otherParticipant1.driver;
await otherParticipant1Driver.waitUntil(
- () => otherParticipant1Driver.execute((id: string) => APP.UI.getLargeVideoID() === id, speakerEndpoint),
+ async () => await otherParticipant1Driver.execute(
+ id => APP.UI.getLargeVideoID() === id, speakerEndpoint),
{
timeout: 30_000, // 30 seconds
timeoutMsg: 'Active speaker not displayed on large video.'
diff --git a/tests/specs/3way/avatarTest.spec.ts b/tests/specs/3way/avatarTest.spec.ts
new file mode 100644
index 0000000000..47607449fa
--- /dev/null
+++ b/tests/specs/3way/avatarTest.spec.ts
@@ -0,0 +1,189 @@
+import {
+ ensureThreeParticipants,
+ ensureTwoParticipants,
+ unMuteVideoAndCheck
+} from '../../helpers/participants';
+
+const EMAIL = 'support@jitsi.org';
+const HASH = '38f014e4b7dde0f64f8157d26a8c812e';
+
+describe('Avatar - ', () => {
+ it('setup the meeting', async () => {
+ // Start p1
+ await ensureTwoParticipants(context, {
+ skipDisplayName: true
+ });
+ });
+
+ it('change and check', async () => {
+ // check default avatar for p1 on p2
+ await context.p2.assertDefaultAvatarExist(context.p1);
+
+ await context.p1.getToolbar().clickProfileButton();
+
+ const settings = context.p1.getSettingsDialog();
+
+ await settings.waitForDisplay();
+ await settings.setEmail(EMAIL);
+ await settings.submit();
+
+ // check if the local avatar in the toolbar menu has changed
+ await context.p1.driver.waitUntil(
+ async () => (await context.p1.getToolbar().getProfileImage())?.includes(HASH), {
+ timeout: 3000, // give more time for the initial download of the image
+ timeoutMsg: 'Avatar has not changed for p1'
+ });
+
+ // check if the avatar in the local thumbnail has changed
+ expect(await context.p1.getLocalVideoAvatar()).toContain(HASH);
+
+ const p1EndpointId = await context.p1.getEndpointId();
+
+ await context.p2.driver.waitUntil(
+ async () => (await context.p2.getFilmstrip().getAvatar(p1EndpointId))?.includes(HASH), {
+ timeout: 5000,
+ timeoutMsg: 'Avatar has not changed for p1 on p2'
+ });
+
+ // check if the avatar in the large video has changed
+ expect(await context.p2.getLargeVideoAvatar()).toContain(HASH);
+
+ // we check whether the default avatar of participant2 is displayed on both sides
+ await context.p1.assertDefaultAvatarExist(context.p2);
+ await context.p2.assertDefaultAvatarExist(context.p2);
+
+ // the problem on FF where we can send keys to the input field,
+ // and the m from the text can mute the call, check whether we are muted
+ await context.p2.getFilmstrip().assertAudioMuteIconIsDisplayed(context.p1, true);
+ });
+
+ it('when video muted', async () => {
+ await context.p2.hangup();
+
+ // Mute p1's video
+ await context.p1.getToolbar().clickVideoMuteButton();
+
+ await context.p1.getParticipantsPane().assertVideoMuteIconIsDisplayed(context.p1);
+
+ await context.p1.driver.waitUntil(
+ async () => (await context.p1.getLargeVideoAvatar())?.includes(HASH), {
+ timeout: 2000,
+ timeoutMsg: 'Avatar on large video did not change'
+ });
+
+ const p1LargeSrc = await context.p1.getLargeVideoAvatar();
+ const p1ThumbSrc = await context.p1.getLocalVideoAvatar();
+
+ // Check if avatar on large video is the same as on local thumbnail
+ expect(p1ThumbSrc).toBe(p1LargeSrc);
+
+ // Join p2
+ await ensureTwoParticipants(context, {
+ skipDisplayName: true
+ });
+
+ // Verify that p1 is muted from the perspective of p2
+ await context.p2.getParticipantsPane().assertVideoMuteIconIsDisplayed(context.p1);
+
+ await context.p2.getFilmstrip().pinParticipant(context.p1);
+
+ // Check if p1's avatar is on large video now
+ await context.p2.driver.waitUntil(
+ async () => await context.p2.getLargeVideoAvatar() === p1LargeSrc, {
+ timeout: 2000,
+ timeoutMsg: 'Avatar on large video did not change'
+ });
+
+ // p1 pins p2's video
+ await context.p1.getFilmstrip().pinParticipant(context.p2);
+
+ // Check if avatar is displayed on p1's local video thumbnail
+ await context.p1.assertThumbnailShowsAvatar(context.p1, false, false, true);
+
+ // Unmute - now local avatar should be hidden and local video displayed
+ await unMuteVideoAndCheck(context.p1, context.p2);
+
+ await context.p1.asserLocalThumbnailShowsVideo();
+
+ // Now both p1 and p2 have video muted
+ await context.p1.getToolbar().clickVideoMuteButton();
+ await context.p1.getParticipantsPane().assertVideoMuteIconIsDisplayed(context.p1);
+ await context.p2.getParticipantsPane().assertVideoMuteIconIsDisplayed(context.p1);
+
+ await context.p2.getToolbar().clickVideoMuteButton();
+ await context.p2.getParticipantsPane().assertVideoMuteIconIsDisplayed(context.p2);
+ await context.p1.getParticipantsPane().assertVideoMuteIconIsDisplayed(context.p2);
+
+ // Start the third participant
+ await ensureThreeParticipants(context);
+
+ // Pin local video and verify avatars are displayed
+ await context.p3.getFilmstrip().pinParticipant(context.p3);
+
+ await context.p3.assertThumbnailShowsAvatar(context.p1, false, false, true);
+ await context.p3.assertThumbnailShowsAvatar(context.p2, false, true);
+
+ const p1EndpointId = await context.p1.getEndpointId();
+ const p2EndpointId = await context.p2.getEndpointId();
+
+ expect(await context.p3.getFilmstrip().getAvatar(p1EndpointId)).toBe(p1ThumbSrc);
+
+ // Click on p1's video
+ await context.p3.getFilmstrip().pinParticipant(context.p1);
+
+ // The avatar should be on large video and display name instead of an avatar, local video displayed
+ await context.p3.driver.waitUntil(
+ async () => await context.p3.getLargeVideoResource() === p1EndpointId, {
+ timeout: 2000,
+ timeoutMsg: `Large video did not switch to ${context.p1.name}`
+ });
+
+ await context.p3.assertDisplayNameVisibleOnStage(
+ await context.p3.getFilmstrip().getRemoteDisplayName(p1EndpointId));
+
+ // p2 has the default avatar
+ await context.p3.assertThumbnailShowsAvatar(context.p2, false, true);
+ await context.p3.assertThumbnailShowsAvatar(context.p3, true);
+
+ // Click on p2's video
+ await context.p3.getFilmstrip().pinParticipant(context.p2);
+
+ // The avatar should be on large video and display name instead of an avatar, local video displayed
+ await context.p3.driver.waitUntil(
+ async () => await context.p3.getLargeVideoResource() === p2EndpointId, {
+ timeout: 2000,
+ timeoutMsg: `Large video did not switch to ${context.p2.name}`
+ });
+
+ await context.p3.assertDisplayNameVisibleOnStage(
+ await context.p3.getFilmstrip().getRemoteDisplayName(p2EndpointId)
+ );
+
+ await context.p3.assertThumbnailShowsAvatar(context.p1, false, false, true);
+ await context.p3.assertThumbnailShowsAvatar(context.p3, true);
+
+ await context.p3.hangup();
+
+ // Unmute p1's and p2's videos
+ await context.p1.getToolbar().clickVideoUnmuteButton();
+
+ await context.p2.getParticipantsPane().assertVideoMuteIconIsDisplayed(context.p1, true);
+ await context.p1.getParticipantsPane().assertVideoMuteIconIsDisplayed(context.p1, true);
+ });
+
+ it('email persistence', async () => {
+ await context.p1.getToolbar().clickProfileButton();
+
+ expect(await context.p1.getSettingsDialog().getEmail()).toBe(EMAIL);
+
+ await context.p1.hangup();
+
+ await ensureTwoParticipants(context, {
+ skipDisplayName: true
+ });
+
+ await context.p1.getToolbar().clickProfileButton();
+
+ expect(await context.p1.getSettingsDialog().getEmail()).toBe(EMAIL);
+ });
+});
diff --git a/tests/wdio.conf.ts b/tests/wdio.conf.ts
index 22967dd4a1..8a02fb71c0 100644
--- a/tests/wdio.conf.ts
+++ b/tests/wdio.conf.ts
@@ -3,6 +3,7 @@ import { multiremotebrowser } from '@wdio/globals';
import { Buffer } from 'buffer';
import path from 'node:path';
import process from 'node:process';
+import pretty from 'pretty';
import { getLogs, initLogger, logInfo } from './helpers/browserLogger';
import { IContext } from './helpers/types';
@@ -58,7 +59,7 @@ export const config: WebdriverIO.MultiremoteConfig = {
],
maxInstances: 1,
- baseUrl: process.env.BASE_URL || 'https://alpha.jitsi.net/torture',
+ baseUrl: process.env.BASE_URL || 'https://alpha.jitsi.net/torture/',
tsConfigPath: './tsconfig.json',
// Default timeout for all waitForXXX commands.
@@ -249,7 +250,7 @@ export const config: WebdriverIO.MultiremoteConfig = {
AllureReporter.addAttachment(`console-logs-${instance}`, getLogs(bInstance) || '', 'text/plain');
allProcessing.push(bInstance.getPageSource().then(source => {
- AllureReporter.addAttachment(`html-source-${instance}`, source, 'text/plain');
+ AllureReporter.addAttachment(`html-source-${instance}`, pretty(source), 'text/plain');
}));
});