diff --git a/package-lock.json b/package-lock.json index 3113d49..7595c62 100644 --- a/package-lock.json +++ b/package-lock.json @@ -23,6 +23,9 @@ "sax": "^1.4.3", "xml2js": "^0.6.2" }, + "devDependencies": { + "jsdom": "^22.1.0" + }, "engines": { "node": ">=18.0.0" }, @@ -175,6 +178,16 @@ "win32" ] }, + "node_modules/@tootallnate/once": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/@tootallnate/once/-/once-2.0.1.tgz", + "integrity": "sha512-HqmEUIGRJ5fSXchkVgR5F7qn48bDBzv0kWj/Kfu5e6uci4UlEeng4331LnBkWffb++Ei3FOVLxo8JJWMFBDMeQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 10" + } + }, "node_modules/@types/node": { "version": "10.17.60", "resolved": "https://registry.npmjs.org/@types/node/-/node-10.17.60.tgz", @@ -182,6 +195,14 @@ "license": "MIT", "optional": true }, + "node_modules/abab": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/abab/-/abab-2.0.6.tgz", + "integrity": "sha512-j2afSsaIENvHZN2B8GOpF566vZ5WVk5opAiMTvWgaQT8DkbOqsTfvNAvHoRGU2zzP8cPoqys+xHTRDWW8L+/BA==", + "deprecated": "Use your platform's native atob() and btoa() methods instead", + "dev": true, + "license": "BSD-3-Clause" + }, "node_modules/accepts": { "version": "1.3.8", "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz", @@ -199,8 +220,8 @@ "version": "6.0.2", "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz", "integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==", + "devOptional": true, "license": "MIT", - "optional": true, "dependencies": { "debug": "4" }, @@ -212,8 +233,8 @@ "version": "4.4.3", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "devOptional": true, "license": "MIT", - "optional": true, "dependencies": { "ms": "^2.1.3" }, @@ -230,8 +251,8 @@ "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", - "license": "MIT", - "optional": true + "devOptional": true, + "license": "MIT" }, "node_modules/array-flatten": { "version": "1.1.1", @@ -239,6 +260,13 @@ "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==", "license": "MIT" }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "dev": true, + "license": "MIT" + }, "node_modules/base64-js": { "version": "1.5.1", "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", @@ -414,6 +442,19 @@ "integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==", "license": "ISC" }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "dev": true, + "license": "MIT", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, "node_modules/concat-stream": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/concat-stream/-/concat-stream-2.0.0.tgz", @@ -466,6 +507,34 @@ "integrity": "sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==", "license": "MIT" }, + "node_modules/cssstyle": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/cssstyle/-/cssstyle-3.0.0.tgz", + "integrity": "sha512-N4u2ABATi3Qplzf0hWbVCdjenim8F3ojEXpBDF5hBpjzW182MjNGLqfmQ0SkSPeQ+V86ZXgeH8aXj6kayd4jgg==", + "dev": true, + "license": "MIT", + "dependencies": { + "rrweb-cssom": "^0.6.0" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/data-urls": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-4.0.0.tgz", + "integrity": "sha512-/mMTei/JXPqvFqQtfyTowxmJVwr2PVAeCcDxyFf6LhoOu/09TX2OX3kb2wzi4DMXcfj4OItwDOnhl5oziPnT6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "abab": "^2.0.6", + "whatwg-mimetype": "^3.0.0", + "whatwg-url": "^12.0.0" + }, + "engines": { + "node": ">=14" + } + }, "node_modules/debug": { "version": "2.6.9", "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", @@ -475,6 +544,13 @@ "ms": "2.0.0" } }, + "node_modules/decimal.js": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz", + "integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==", + "dev": true, + "license": "MIT" + }, "node_modules/decompress-response": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz", @@ -499,6 +575,16 @@ "node": ">=4.0.0" } }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, "node_modules/depd": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", @@ -527,6 +613,20 @@ "node": ">=8" } }, + "node_modules/domexception": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/domexception/-/domexception-4.0.0.tgz", + "integrity": "sha512-A2is4PLG+eeSfoTMA95/s4pvAoSo2mKtiM5jlHkAVewmiO8ISFTFKZjH7UAM1Atli/OT/7JHOrJRJiMKUZKYBw==", + "deprecated": "Use your platform's native DOMException instead", + "dev": true, + "license": "MIT", + "dependencies": { + "webidl-conversions": "^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, "node_modules/dotenv": { "version": "17.2.3", "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.2.3.tgz", @@ -586,6 +686,19 @@ "once": "^1.4.0" } }, + "node_modules/entities": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz", + "integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, "node_modules/env-paths": { "version": "2.2.1", "resolved": "https://registry.npmjs.org/env-paths/-/env-paths-2.2.1.tgz", @@ -626,6 +739,22 @@ "node": ">= 0.4" } }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/escape-html": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", @@ -756,6 +885,23 @@ "node": ">= 0.8" } }, + "node_modules/form-data": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.6.tgz", + "integrity": "sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.4", + "mime-types": "^2.1.35" + }, + "engines": { + "node": ">= 6" + } + }, "node_modules/forwarded": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", @@ -856,10 +1002,26 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/hasown": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", - "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", + "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", "license": "MIT", "dependencies": { "function-bind": "^1.1.2" @@ -868,6 +1030,19 @@ "node": ">= 0.4" } }, + "node_modules/html-encoding-sniffer": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-3.0.0.tgz", + "integrity": "sha512-oWv4T4yJ52iKrufjnyZPkrN0CH3QnrUqdB6In1g5Fe1mia8GmF36gnfNySxoZtxD5+NmYw1EElVXiBk93UeskA==", + "dev": true, + "license": "MIT", + "dependencies": { + "whatwg-encoding": "^2.0.0" + }, + "engines": { + "node": ">=12" + } + }, "node_modules/http-errors": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", @@ -888,6 +1063,46 @@ "url": "https://opencollective.com/express" } }, + "node_modules/http-proxy-agent": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-5.0.0.tgz", + "integrity": "sha512-n2hY8YdoRE1i7r6M0w9DIw5GgZN0G25P8zLCRQ8rjXtTU3vsNFBI/vWK/UIeE6g5MUUz6avwAPXmL6Fy9D/90w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@tootallnate/once": "2", + "agent-base": "6", + "debug": "4" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/http-proxy-agent/node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/http-proxy-agent/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, "node_modules/http-response-object": { "version": "3.0.2", "resolved": "https://registry.npmjs.org/http-response-object/-/http-response-object-3.0.2.tgz", @@ -902,8 +1117,8 @@ "version": "5.0.1", "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz", "integrity": "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==", + "devOptional": true, "license": "MIT", - "optional": true, "dependencies": { "agent-base": "6", "debug": "4" @@ -916,8 +1131,8 @@ "version": "4.4.3", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "devOptional": true, "license": "MIT", - "optional": true, "dependencies": { "ms": "^2.1.3" }, @@ -934,8 +1149,8 @@ "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", - "license": "MIT", - "optional": true + "devOptional": true, + "license": "MIT" }, "node_modules/iconv-lite": { "version": "0.4.24", @@ -990,6 +1205,56 @@ "node": ">= 0.10" } }, + "node_modules/is-potential-custom-element-name": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", + "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/jsdom": { + "version": "22.1.0", + "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-22.1.0.tgz", + "integrity": "sha512-/9AVW7xNbsBv6GfWho4TTNjEo9fe6Zhf9O7s0Fhhr3u+awPwAJMKwAMXnkk5vBxflqLW9hTHX/0cs+P3gW+cQw==", + "dev": true, + "license": "MIT", + "dependencies": { + "abab": "^2.0.6", + "cssstyle": "^3.0.0", + "data-urls": "^4.0.0", + "decimal.js": "^10.4.3", + "domexception": "^4.0.0", + "form-data": "^4.0.0", + "html-encoding-sniffer": "^3.0.0", + "http-proxy-agent": "^5.0.0", + "https-proxy-agent": "^5.0.1", + "is-potential-custom-element-name": "^1.0.1", + "nwsapi": "^2.2.4", + "parse5": "^7.1.2", + "rrweb-cssom": "^0.6.0", + "saxes": "^6.0.0", + "symbol-tree": "^3.2.4", + "tough-cookie": "^4.1.2", + "w3c-xmlserializer": "^4.0.0", + "webidl-conversions": "^7.0.0", + "whatwg-encoding": "^2.0.0", + "whatwg-mimetype": "^3.0.0", + "whatwg-url": "^12.0.1", + "ws": "^8.13.0", + "xml-name-validator": "^4.0.0" + }, + "engines": { + "node": ">=16" + }, + "peerDependencies": { + "canvas": "^2.5.0" + }, + "peerDependenciesMeta": { + "canvas": { + "optional": true + } + } + }, "node_modules/jsonwebtoken": { "version": "9.0.3", "resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.3.tgz", @@ -1210,6 +1475,13 @@ "node": ">=10" } }, + "node_modules/nwsapi": { + "version": "2.2.28", + "resolved": "https://registry.npmjs.org/nwsapi/-/nwsapi-2.2.28.tgz", + "integrity": "sha512-IlVB7OS7qrOsVYlpnFIkETjMwT9jwvmocJmmM+GZU/PAB3uGi9Ezd7vcWhWBUnSc0ya4ppmQITOyP1ez9gg8cg==", + "dev": true, + "license": "MIT" + }, "node_modules/oauth": { "version": "0.10.2", "resolved": "https://registry.npmjs.org/oauth/-/oauth-0.10.2.tgz", @@ -1264,6 +1536,19 @@ "integrity": "sha512-60zvsJReQPX5/QP0Kzfd/VrpjScIQ7SHBW6bFCYfEP+fp0Eppr1SHhIO5nd1PjZtvclzSzES9D/p5nFJurwfWg==", "optional": true }, + "node_modules/parse5": { + "version": "7.3.0", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-7.3.0.tgz", + "integrity": "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "entities": "^6.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, "node_modules/parseurl": { "version": "1.3.3", "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", @@ -1397,6 +1682,19 @@ "node": ">= 0.10" } }, + "node_modules/psl": { + "version": "1.15.0", + "resolved": "https://registry.npmjs.org/psl/-/psl-1.15.0.tgz", + "integrity": "sha512-JZd3gMVBAVQkSs6HdNZo9Sdo0LNcQeMNP3CozBJb3JYC/QUYZTnKxP+f8oWRX4rHP5EurWxqAHTSwUCjlNKa1w==", + "dev": true, + "license": "MIT", + "dependencies": { + "punycode": "^2.3.1" + }, + "funding": { + "url": "https://github.com/sponsors/lupomontero" + } + }, "node_modules/pump": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.3.tgz", @@ -1407,6 +1705,16 @@ "once": "^1.3.1" } }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/qs": { "version": "6.14.0", "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.0.tgz", @@ -1422,6 +1730,13 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/querystringify": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/querystringify/-/querystringify-2.2.0.tgz", + "integrity": "sha512-FIqgj2EUvTa7R50u0rGsyTftzjYmv/a3hO345bZNrqabNqjtgiDMgmo4mkUjd+nzU5oF3dClKqFIPUKybUyqoQ==", + "dev": true, + "license": "MIT" + }, "node_modules/random-bytes": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/random-bytes/-/random-bytes-1.0.0.tgz", @@ -1484,6 +1799,20 @@ "node": ">= 6" } }, + "node_modules/requires-port": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/requires-port/-/requires-port-1.0.0.tgz", + "integrity": "sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/rrweb-cssom": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/rrweb-cssom/-/rrweb-cssom-0.6.0.tgz", + "integrity": "sha512-APM0Gt1KoXBz0iIkkdB/kfvGOwC4UuJFeG/c+yV7wSc7q96cG/kJ0HiYCnzivD9SB53cLV1MlHFNfOuPaadYSw==", + "dev": true, + "license": "MIT" + }, "node_modules/safe-buffer": { "version": "5.2.1", "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", @@ -1516,6 +1845,19 @@ "integrity": "sha512-yqYn1JhPczigF94DMS+shiDMjDowYO6y9+wB/4WgO0Y19jWYk0lQ4tuG5KI7kj4FTp1wxPj5IFfcrz/s1c3jjQ==", "license": "BlueOak-1.0.0" }, + "node_modules/saxes": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz", + "integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==", + "dev": true, + "license": "ISC", + "dependencies": { + "xmlchars": "^2.2.0" + }, + "engines": { + "node": ">=v12.22.7" + } + }, "node_modules/semver": { "version": "7.7.3", "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz", @@ -1723,6 +2065,13 @@ "node": ">=0.10.0" } }, + "node_modules/symbol-tree": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz", + "integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==", + "dev": true, + "license": "MIT" + }, "node_modules/tar-fs": { "version": "2.1.4", "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.4.tgz", @@ -1760,6 +2109,35 @@ "node": ">=0.6" } }, + "node_modules/tough-cookie": { + "version": "4.1.4", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-4.1.4.tgz", + "integrity": "sha512-Loo5UUvLD9ScZ6jh8beX1T6sO1w2/MpCRpEP7V280GKMVUQ0Jzar2U3UJPsrdbziLEMMhu3Ujnq//rhiFuIeag==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "psl": "^1.1.33", + "punycode": "^2.1.1", + "universalify": "^0.2.0", + "url-parse": "^1.5.3" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/tr46": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-4.1.1.tgz", + "integrity": "sha512-2lv/66T7e5yNyhAAC4NaKe5nVavzuGJQVVtRYLyQ2OI8tsJ61PMLlelehb0wi2Hx6+hT/OJUWZcw8MjlSRnxvw==", + "dev": true, + "license": "MIT", + "dependencies": { + "punycode": "^2.3.0" + }, + "engines": { + "node": ">=14" + } + }, "node_modules/tunnel-agent": { "version": "0.6.0", "resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz", @@ -1804,6 +2182,16 @@ "node": ">= 0.8" } }, + "node_modules/universalify": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-0.2.0.tgz", + "integrity": "sha512-CJ1QgKmNg3CwvAv/kOFmtnEN05f0D/cn9QntgNOQlQF9dgvVTHj3t+8JPdjqawCHk7V/KA+fbUqzZ9XWhcqPUg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4.0.0" + } + }, "node_modules/unpipe": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", @@ -1813,6 +2201,17 @@ "node": ">= 0.8" } }, + "node_modules/url-parse": { + "version": "1.5.10", + "resolved": "https://registry.npmjs.org/url-parse/-/url-parse-1.5.10.tgz", + "integrity": "sha512-WypcfiRhfeUP9vvF0j6rw0J3hrWrw6iZv3+22h6iRMJ/8z1Tj6XfLP4DsUix5MhMPnXpiHDoKyoZ/bdCkwBCiQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "querystringify": "^2.1.1", + "requires-port": "^1.0.0" + } + }, "node_modules/util-deprecate": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", @@ -1837,12 +2236,118 @@ "node": ">= 0.8" } }, + "node_modules/w3c-xmlserializer": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-4.0.0.tgz", + "integrity": "sha512-d+BFHzbiCx6zGfz0HyQ6Rg69w9k19nviJspaj4yNscGjrHu94sVP+aRm75yEbCh+r2/yR+7q6hux9LVtbuTGBw==", + "dev": true, + "license": "MIT", + "dependencies": { + "xml-name-validator": "^4.0.0" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/webidl-conversions": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-7.0.0.tgz", + "integrity": "sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + } + }, + "node_modules/whatwg-encoding": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/whatwg-encoding/-/whatwg-encoding-2.0.0.tgz", + "integrity": "sha512-p41ogyeMUrw3jWclHWTQg1k05DSVXPLcVxRTYsXUk+ZooOCZLcoYgPZ/HL/D/N+uQPOtcp1me1WhBEaX02mhWg==", + "deprecated": "Use @exodus/bytes instead for a more spec-conformant and faster implementation", + "dev": true, + "license": "MIT", + "dependencies": { + "iconv-lite": "0.6.3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/whatwg-encoding/node_modules/iconv-lite": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", + "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/whatwg-mimetype": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-3.0.0.tgz", + "integrity": "sha512-nt+N2dzIutVRxARx1nghPKGv1xHikU7HKdfafKkLNLindmPU/ch3U31NOCGGA/dmPcmb1VlofO0vnKAcsm0o/Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "node_modules/whatwg-url": { + "version": "12.0.1", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-12.0.1.tgz", + "integrity": "sha512-Ed/LrqB8EPlGxjS+TrsXcpUond1mhccS3pchLhzSgPCnTimUCKj3IZE75pAs5m6heB2U2TMerKFUXheyHY+VDQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "tr46": "^4.1.1", + "webidl-conversions": "^7.0.0" + }, + "engines": { + "node": ">=14" + } + }, "node_modules/wrappy": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", "license": "ISC" }, + "node_modules/ws": { + "version": "8.22.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.22.0.tgz", + "integrity": "sha512-Ydggc987+RO0AnWtZ/7Wq9FtNvcrL1b/RO0ud9mWjUPgDrsAAwQSF51sm2hm1XofbU/4jkpGEsLFsZZxU+1DOg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/xml-name-validator": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-4.0.0.tgz", + "integrity": "sha512-ICP2e+jsHvAj2E2lIHxa5tjXRlKDJo4IdvPvCXbXQGdzSfmSpNVyIKMvoZHjDY9DP0zV17iI85o90vRFXNccRw==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12" + } + }, "node_modules/xml2js": { "version": "0.6.2", "resolved": "https://registry.npmjs.org/xml2js/-/xml2js-0.6.2.tgz", @@ -1864,6 +2369,13 @@ "engines": { "node": ">=4.0" } + }, + "node_modules/xmlchars": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz", + "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==", + "dev": true, + "license": "MIT" } } } diff --git a/package.json b/package.json index 504f49c..b5bd8f5 100644 --- a/package.json +++ b/package.json @@ -29,5 +29,8 @@ }, "engines": { "node": ">=18.0.0" + }, + "devDependencies": { + "jsdom": "^22.1.0" } } diff --git a/public/js/components/ChannelList.js b/public/js/components/ChannelList.js index 6e90171..5af913d 100644 --- a/public/js/components/ChannelList.js +++ b/public/js/components/ChannelList.js @@ -906,6 +906,13 @@ class ChannelList { this.isLoading = true; this.currentRenderId = null; // Reset render tracking + const previousState = { + channels: this.channels, + groups: this.groups, + visibleFavorites: this.visibleFavorites, + hiddenItems: this.hiddenItems + }; + const sourceValue = this.sourceSelect.value; const self = this; @@ -943,7 +950,15 @@ class ChannelList { void this.refreshLayaLanguages(); } catch (err) { console.error('Error loading channels:', err); - this.container.innerHTML = `

Error loading channels

${err.message}

`; + if (previousState.channels.length > 0) { + this.channels = previousState.channels; + this.groups = previousState.groups; + this.visibleFavorites = previousState.visibleFavorites; + this.hiddenItems = previousState.hiddenItems; + this.render(); + } else { + this.container.innerHTML = `

Error loading channels

${err.message}

`; + } } finally { this.isLoading = false; } @@ -953,6 +968,12 @@ class ChannelList { * Load channels from all enabled sources */ async loadAllChannels() { + const previousState = { + channels: this.channels, + groups: this.groups, + visibleFavorites: this.visibleFavorites, + hiddenItems: this.hiddenItems + }; this.channels = []; this.groups = []; @@ -985,6 +1006,15 @@ class ChannelList { void this.refreshLayaLanguages(); } catch (err) { console.error('Error loading all channels:', err); + if (previousState.channels.length > 0) { + this.channels = previousState.channels; + this.groups = previousState.groups; + this.visibleFavorites = previousState.visibleFavorites; + this.hiddenItems = previousState.hiddenItems; + this.render(); + } else { + this.container.innerHTML = `

Error loading channels

${err.message}

`; + } } } diff --git a/public/js/services/liveChannelProjection.js b/public/js/services/liveChannelProjection.js index f2a1e9f..a213a1b 100644 --- a/public/js/services/liveChannelProjection.js +++ b/public/js/services/liveChannelProjection.js @@ -43,7 +43,17 @@ } function canUse(response) { - return Boolean(response && Array.isArray(response.channels) && !response.projection?.stale); + if (!response || !Array.isArray(response.channels) || response.projection?.stale) return false; + + const projection = response.projection || {}; + const expectedChannelCount = [ + projection.total, + projection.processed, + projection.catalogCount, + projection.sourceChannelCount + ].some(count => Number(count) > 0); + + return response.channels.length > 0 || !expectedChannelCount; } return { canUse, mapResponse }; diff --git a/server/routes/live.js b/server/routes/live.js index 46a1f74..9c61b09 100644 --- a/server/routes/live.js +++ b/server/routes/live.js @@ -5,9 +5,10 @@ const liveProjection = require('../services/liveProjectionService'); const router = express.Router(); function serializeState(state) { + const emptyProjectionWithLiveInput = state.total_count > 0 && state.projected_count === 0; return { status: state.status, - stale: state.status !== 'success', + stale: state.status !== 'success' || emptyProjectionWithLiveInput, startedAt: state.started_at, completedAt: state.completed_at, inputHash: state.input_hash, diff --git a/test/liveAnalysisUserFlowDom.test.js b/test/liveAnalysisUserFlowDom.test.js new file mode 100644 index 0000000..d2c8b04 --- /dev/null +++ b/test/liveAnalysisUserFlowDom.test.js @@ -0,0 +1,257 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); +const { JSDOM } = require('jsdom'); + +const root = path.resolve(__dirname, '..'); + +function readPublicScript(relativePath, exposeGlobal = '') { + const source = fs.readFileSync(path.join(root, 'public', relativePath), 'utf8'); + return exposeGlobal ? `${source}\nwindow.${exposeGlobal} = ${exposeGlobal};` : source; +} + +function createDom() { + const dom = new JSDOM(` + + + + +
+
+ + +
+ +
+ + + `, { + url: 'http://nodecast.test/', + runScripts: 'outside-only', + pretendToBeVisual: true + }); + const { window } = dom; + window.clearInterval = window.clearInterval.bind(window); + window.IntersectionObserver = class { + observe() {} + disconnect() {} + }; + window.console = console; + window.eval(readPublicScript('js/icons.js')); + window.eval(readPublicScript('js/services/channelLanguageDetection.js')); + window.eval(readPublicScript('js/services/channelLanguageFusion.js')); + window.eval(readPublicScript('js/services/channelVariantGrouping.js')); + window.eval(readPublicScript('js/services/liveChannelProjection.js')); + window.eval(readPublicScript('js/services/layaStatusPresentation.js')); + window.eval(readPublicScript('js/services/liveAnalysisPresentation.js')); + window.eval(readPublicScript('js/components/ChannelList.js', 'ChannelList')); + window.eval(readPublicScript('js/pages/Settings.js', 'SettingsPage')); + return { + window + }; +} + +function projectedChannels() { + return { + projection: { status: 'success', stale: false }, + channels: [{ + id: 'logical:sports-one', + fingerprint: 'sports one', + displayName: 'Sports One', + variants: [ + { + id: 'fr-sd', + sourceId: 1, + name: '|FR| Sports One SD', + groupTitle: 'Sports France', + categoryName: 'Sports France', + streamIcon: 'fr.png', + streamUrl: 'https://streams.test/fr-sd.m3u8', + tvgId: 'sports.one.fr', + isFavorite: true, + isHidden: false + }, + { + id: 'en-4k', + sourceId: 1, + name: '|UK| Sports One 4K', + groupTitle: 'UK Sports', + categoryName: 'UK Sports', + streamIcon: 'en.png', + streamUrl: 'https://streams.test/en-4k.m3u8', + tvgId: 'sports.one.uk', + isFavorite: false, + isHidden: false + } + ] + }] + }; +} + +test('user analysis click keeps visible channels after Local AI language failure', async () => { + const { window } = createDom(); + let rebuildClicked = 0; + let layaBatchCalls = 0; + let channelLoads = 0; + const consoleWarnings = []; + window.console = { + ...console, + warn: (...args) => { + consoleWarnings.push(args.join(' ')); + console.warn(...args); + } + }; + window.API = { + live: { + getChannels: async () => { + channelLoads += 1; + return projectedChannels(); + }, + rebuildProjection: async () => { + rebuildClicked += 1; + return { projection: { status: 'building', processed: 0, total: 2 } }; + }, + getProjectionStatus: async () => ({ status: 'success', processed: 2, total: 2, projectedCount: 1 }) + }, + laya: { + getStatus: async () => ({ modelPresent: true, runtimeAvailable: true }), + detectLanguages: async () => { + layaBatchCalls += 1; + throw new Error('simulated local AI language failure'); + } + } + }; + + const app = { + player: { + settings: { liveChannelListMode: 'grouped', languageDetectionMode: 'algo' }, + saveSettings() {} + }, + epgGuide: null, + navigateTo() {}, + pages: { settings: { switchTab() {} } } + }; + window.app = app; + const channelList = new window.ChannelList(); + channelList.sources = [{ id: 1, type: 'm3u', name: 'Fixture Provider' }]; + app.channelList = channelList; + + await channelList.loadChannels(); + channelList.expandAll(); + const beforeNames = [...window.document.querySelectorAll('.channel-name')].map(item => item.textContent); + assert.deepEqual(beforeNames, ['Sports One', 'Sports One']); + assert.equal(window.document.querySelector('.empty-state'), null); + + app.player.settings.languageDetectionMode = 'hybrid'; + const settingsPage = Object.create(window.SettingsPage.prototype); + settingsPage.app = app; + settingsPage.initLiveAnalysis(); + await Promise.resolve(); + + window.document.getElementById('live-analysis-button').click(); + assert.equal(rebuildClicked, 1); + await new Promise(resolve => setTimeout(resolve, 1100)); + channelList.expandAll(); + window.clearInterval(channelList._epgRefreshTimer); + window.clearInterval(settingsPage.liveAnalysisPollTimer); + + const notice = window.document.querySelector('.laya-language-notice'); + const afterNames = [...window.document.querySelectorAll('.channel-name')].map(item => item.textContent); + const languageBadges = [...window.document.querySelectorAll('.variant-language')].map(item => item.textContent); + const groupNames = [...window.document.querySelectorAll('.group-name')].map(item => item.textContent); + + assert.equal(channelLoads, 2); + assert.equal(layaBatchCalls, 1); + assert.ok(notice); + assert.match(notice.textContent, /Local AI language detection failed/); + assert.deepEqual(afterNames, ['Sports One', 'Sports One']); + assert.deepEqual([...new Set(languageBadges)].sort(), ['EN 100%', 'FR 100%']); + assert.deepEqual(groupNames, ['Favorites', 'Sports France']); + assert.equal(window.document.querySelector('.empty-state'), null); + assert.equal(window.document.querySelector('#channel-list > .loading'), null); + assert.equal(channelList.languageDetectionFallback, true); + assert.equal(channelList.logicalChannels[0].variants.length, 2); + assert.ok(consoleWarnings.some(line => line.includes('Local AI language detection unavailable'))); +}); + +test('analysis click does not replace visible channels with an empty successful projection', async () => { + const { window } = createDom(); + let rebuildClicked = 0; + let channelLoads = 0; + window.API = { + live: { + getChannels: async () => { + channelLoads += 1; + if (channelLoads === 1) return projectedChannels(); + return { + projection: { + status: 'success', + stale: false, + projectedCount: 0, + processed: 3, + total: 3 + }, + channels: [] + }; + }, + rebuildProjection: async () => { + rebuildClicked += 1; + return { projection: { status: 'building', processed: 0, total: 3 } }; + }, + getProjectionStatus: async () => ({ status: 'success', processed: 3, total: 3, projectedCount: 0 }) + }, + laya: { + getStatus: async () => ({ modelPresent: true, runtimeAvailable: true }), + detectLanguages: async () => { + throw new Error('simulated local AI language failure'); + } + } + }; + + const app = { + player: { + settings: { liveChannelListMode: 'grouped', languageDetectionMode: 'hybrid' }, + saveSettings() {} + }, + epgGuide: null, + navigateTo() {}, + pages: { settings: { switchTab() {} } } + }; + window.app = app; + const channelList = new window.ChannelList(); + channelList.sources = [{ id: 1, type: 'm3u', name: 'Fixture Provider', enabled: true }]; + app.channelList = channelList; + + await channelList.loadChannels(); + channelList.expandAll(); + const beforeNames = [...window.document.querySelectorAll('.channel-name')].map(item => item.textContent); + assert.deepEqual(beforeNames, ['Sports One', 'Sports One']); + assert.equal(window.document.querySelector('.empty-state'), null); + assert.equal(channelLoads, 1); + + const settingsPage = Object.create(window.SettingsPage.prototype); + settingsPage.app = app; + settingsPage.initLiveAnalysis(); + await Promise.resolve(); + + window.document.getElementById('live-analysis-button').click(); + assert.equal(rebuildClicked, 1); + await new Promise(resolve => setTimeout(resolve, 1100)); + channelList.expandAll(); + window.clearInterval(channelList._epgRefreshTimer); + window.clearInterval(settingsPage.liveAnalysisPollTimer); + + const afterNames = [...window.document.querySelectorAll('.channel-name')].map(item => item.textContent); + const emptyState = window.document.querySelector('.empty-state'); + const loading = window.document.querySelector('#channel-list > .loading'); + + assert.equal(channelLoads, 2); + assert.deepEqual(afterNames, ['Sports One', 'Sports One']); + assert.equal(emptyState, null); + assert.equal(loading, null); + assert.equal(channelList.channels.length, 2); +}); diff --git a/test/liveDetectionFunctionalChain.test.js b/test/liveDetectionFunctionalChain.test.js new file mode 100644 index 0000000..5583419 --- /dev/null +++ b/test/liveDetectionFunctionalChain.test.js @@ -0,0 +1,166 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); +const Database = require('better-sqlite3'); +const { + ensureColumns, + LIVE_PROJECTION_COLUMNS, + LIVE_PROJECTION_SCHEMA_SQL +} = require('../server/db/liveProjectionSchema'); +const liveProjection = require('../server/services/liveProjectionService'); +const frontendProjection = require('../public/js/services/liveChannelProjection.js'); +const grouping = require('../public/js/services/channelVariantGrouping.js'); +const { channelListWarning } = require('../public/js/services/layaStatusPresentation.js'); + +function createFunctionalDb() { + const db = new Database(':memory:'); + db.exec(` + CREATE TABLE categories ( + id TEXT PRIMARY KEY, + source_id INTEGER NOT NULL, + category_id TEXT NOT NULL, + type TEXT NOT NULL, + name TEXT NOT NULL, + parent_id TEXT, + is_hidden INTEGER DEFAULT 0, + data JSON + ); + CREATE TABLE playlist_items ( + id TEXT PRIMARY KEY, + source_id INTEGER NOT NULL, + item_id TEXT NOT NULL, + type TEXT NOT NULL, + name TEXT NOT NULL, + category_id TEXT, + parent_id TEXT, + stream_icon TEXT, + stream_url TEXT, + container_extension TEXT, + rating REAL, + year TEXT, + added_at TEXT, + is_hidden INTEGER DEFAULT 0, + is_favorite INTEGER DEFAULT 0, + data JSON + ); + CREATE TABLE favorites ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL, + source_id INTEGER NOT NULL, + item_id TEXT NOT NULL, + item_type TEXT NOT NULL, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + UNIQUE(user_id, source_id, item_id, item_type) + ); + `); + ensureColumns(db, 'playlist_items', LIVE_PROJECTION_COLUMNS); + db.exec(LIVE_PROJECTION_SCHEMA_SQL); + return db; +} + +function seedCategory(db, categoryId, name, extra = {}) { + db.prepare(` + INSERT INTO categories (id, source_id, category_id, type, name, is_hidden, data) + VALUES (?, 1, ?, 'live', ?, ?, '{}') + `).run(`1:${categoryId}`, categoryId, name, extra.isHidden ? 1 : 0); +} + +function seedChannel(db, itemId, name, categoryId, extra = {}) { + db.prepare(` + INSERT INTO playlist_items + (id, source_id, item_id, type, name, category_id, stream_icon, stream_url, is_hidden, data) + VALUES (?, 1, ?, 'live', ?, ?, ?, ?, ?, ?) + `).run( + `1:${itemId}`, + itemId, + name, + categoryId, + `${itemId}.png`, + `https://streams.test/${itemId}.m3u8`, + extra.isHidden ? 1 : 0, + JSON.stringify({ + tvgId: extra.tvgId || null, + groupTitle: extra.groupTitle || undefined + }) + ); +} + +test('functional live detection chain keeps categories and language selection correct after Laya fallback', async () => { + const db = createFunctionalDb(); + seedCategory(db, 'sports-fr', 'Sports France'); + seedCategory(db, 'sports-uk', 'UK Sports'); + seedCategory(db, 'events', 'Sports Events'); + seedChannel(db, 'fr-sd', '|FR| Sports One SD', 'sports-fr', { tvgId: 'sports.one.fr' }); + seedChannel(db, 'fr-4k-hidden', '|FR| Sports One 4K', 'sports-fr', { isHidden: true, tvgId: 'sports.one.fr.4k' }); + seedChannel(db, 'en-4k', '|UK| Sports One 4K', 'sports-uk', { tvgId: 'sports.one.uk' }); + seedChannel(db, 'bein-main', 'beIN Sports 1 HD', 'events'); + seedChannel(db, 'bein-event', 'Partner beIN Sports 1 Event', 'events'); + db.prepare(` + INSERT INTO favorites (user_id, source_id, item_id, item_type) + VALUES (7, 1, 'fr-sd', 'channel') + `).run(); + + let layaRequests = 0; + const state = await liveProjection.rebuildWithSignals(db, { + mode: 'hybrid', + layaClient: { + identityHintBatch: async () => { + layaRequests += 1; + throw new Error('simulated local AI failure'); + } + } + }); + + assert.equal(layaRequests, 1); + assert.equal(state.status, 'success'); + assert.equal(state.error, null); + + const rawRows = db.prepare(` + SELECT item_id, logical_channel_id, grouping_fingerprint + FROM playlist_items + WHERE item_id IN ('fr-sd', 'fr-4k-hidden', 'en-4k') + ORDER BY item_id + `).all(); + assert.deepEqual(rawRows.map(row => row.item_id), ['en-4k', 'fr-4k-hidden', 'fr-sd']); + assert.equal(new Set(rawRows.map(row => row.logical_channel_id)).size, 1); + assert.deepEqual([...new Set(rawRows.map(row => row.grouping_fingerprint))], ['sports one']); + + const apiChannels = liveProjection.getChannels({ includeHidden: true, userId: 7 }, db); + const sportsOne = apiChannels.find(channel => channel.fingerprint === 'sports one'); + assert.ok(sportsOne); + assert.deepEqual( + sportsOne.variants + .map(variant => [variant.id, variant.categoryName, variant.isHidden, variant.isFavorite]) + .sort((left, right) => left[0].localeCompare(right[0])), + [ + ['en-4k', 'UK Sports', false, false], + ['fr-4k-hidden', 'Sports France', true, false], + ['fr-sd', 'Sports France', false, true] + ] + ); + + const mapped = frontendProjection.mapResponse({ + projection: { status: 'success', stale: false }, + channels: apiChannels + }, [{ id: 1, type: 'm3u', name: 'Fixture Provider France' }]); + const warning = channelListWarning('hybrid', { modelPresent: true, runtimeAvailable: true }, true); + const logical = grouping.groupChannels(mapped.channels, { + languageMode: warning ? 'algo' : 'hybrid', + preferredLanguage: 'fr', + isFavorite: variant => mapped.favorites.has(`${variant.sourceId}:${variant.id}`), + isHidden: variant => mapped.hiddenItems.has(`channel:${variant.sourceId}:${variant.streamId || variant.id}`) + }); + const frontendSportsOne = logical.find(channel => channel.fingerprint === 'sports one'); + + assert.equal( + warning, + 'Local AI language detection failed. Channels were grouped with Standard matching and remain available.' + ); + assert.ok(frontendSportsOne); + assert.equal(frontendSportsOne.variants.length, 3); + assert.deepEqual(frontendSportsOne.availableLanguages.map(language => language.code).sort(), ['en', 'fr']); + assert.equal(frontendSportsOne.preferredVariant.id, 'fr-sd'); + assert.equal(frontendSportsOne.preferredVariant.groupTitle, 'Sports France'); + assert.equal(frontendSportsOne.isFavorite, true); + assert.equal(frontendSportsOne.isHidden, false); + assert.equal(grouping.orderFlatChannels(logical).length, logical.length); +}); diff --git a/test/liveProjectionRoutes.test.js b/test/liveProjectionRoutes.test.js index e6eb85a..2832dce 100644 --- a/test/liveProjectionRoutes.test.js +++ b/test/liveProjectionRoutes.test.js @@ -3,6 +3,7 @@ const assert = require('node:assert/strict'); const fs = require('fs'); const path = require('path'); const { isAdmin } = require('../server/authz'); +const { serializeState } = require('../server/routes/live'); const routeSource = fs.readFileSync(path.join(__dirname, '..', 'server', 'routes', 'live.js'), 'utf8'); @@ -19,3 +20,19 @@ test('projection authorization distinguishes viewer and admin roles', () => { assert.equal(isAdmin({ role: 'admin' }), true); assert.equal(isAdmin(null), false); }); + +test('projection success with live input but zero projected channels is exposed as stale', () => { + const state = serializeState({ + status: 'success', + started_at: 100, + completed_at: 200, + input_hash: 'hash', + projected_count: 0, + processed_count: 3, + total_count: 3, + error: null + }); + assert.equal(state.stale, true); + assert.equal(state.projectedCount, 0); + assert.equal(state.total, 3); +});