diff --git a/package-lock.json b/package-lock.json index 30b0727..33a1359 100644 --- a/package-lock.json +++ b/package-lock.json @@ -15,6 +15,7 @@ "@stryker-mutator/vitest-runner": "^10.0.0", "@vitest/coverage-v8": "^4.1.11", "eslint": "^10.8.0", + "jsdom": "^30.0.1", "prettier": "^3.9.6", "stylelint": "^17.14.1", "stylelint-config-standard-scss": "^17.0.0", @@ -25,6 +26,39 @@ "node": "^22.12.0 || >=24.0.0" } }, + "node_modules/@asamuzakjp/css-color": { + "version": "6.0.7", + "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-6.0.7.tgz", + "integrity": "sha512-vC/bk1Lz7Tn/EfU9/apOTBk80/8dyGyWMowPoV1tJ52muDGsDqt2HPT2klrFUiY60MQmQv9q8yIht15JnBgDGw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@csstools/css-calc": "^3.3.0", + "@csstools/css-color-parser": "^4.1.10", + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0", + "lru-cache": "^11.5.2" + }, + "engines": { + "node": "^22.13.0 || >=24.0.0" + } + }, + "node_modules/@asamuzakjp/dom-selector": { + "version": "8.3.2", + "resolved": "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-8.3.2.tgz", + "integrity": "sha512-93Z1N+BQNXysodoicpOIyNh2drHfz/CTf9nnT0FEx72GJcIiwgydD7tGAr78j41LsYn3hlRn+LdGPuBLn1Bl8Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "bidi-js": "^1.0.3", + "css-tree": "^3.2.1", + "is-potential-custom-element-name": "^1.0.1", + "lru-cache": "^11.5.2" + }, + "engines": { + "node": "^22.13.0 || >=24.0.0" + } + }, "node_modules/@axe-core/playwright": { "version": "4.13.0", "resolved": "https://registry.npmjs.org/@axe-core/playwright/-/playwright-4.13.0.tgz", @@ -641,6 +675,19 @@ "node": ">=18" } }, + "node_modules/@bramus/specificity": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/@bramus/specificity/-/specificity-2.4.2.tgz", + "integrity": "sha512-ctxtJ/eA+t+6q2++vj5j7FYX3nRu311q1wfYH3xjlLOsczhlhxAg2FWNUXhpGvAw3BWo1xBcvOV6/YLc2r5FJw==", + "dev": true, + "license": "MIT", + "dependencies": { + "css-tree": "^3.0.0" + }, + "bin": { + "specificity": "bin/cli.js" + } + }, "node_modules/@cacheable/memory": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/@cacheable/memory/-/memory-2.2.0.tgz", @@ -702,10 +749,30 @@ "@keyv/serialize": "^1.1.1" } }, + "node_modules/@csstools/color-helpers": { + "version": "6.1.1", + "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-6.1.1.tgz", + "integrity": "sha512-gLNsunvwf3mCi5u5o46/Z/JcJMnhbHSaZ69rkgPzNM3J4s8hWwpPUQB6/tt0EDFyCiWzxANlx+2LJwpYj4zS1w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "engines": { + "node": ">=20.19.0" + } + }, "node_modules/@csstools/css-calc": { - "version": "3.3.0", - "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-3.3.0.tgz", - "integrity": "sha512-c5ihYsPkdG6JCkU2zTMm4+k6r7RXuGxtWYhu5DHMIiF1FHzrfmHL5so11AoFpUv/tu61xfcmT4AmKoFfMPoqdQ==", + "version": "3.4.0", + "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-3.4.0.tgz", + "integrity": "sha512-XQKj5B7QiZcHiegCOCAzcAOJdhGgWOHbbu62h5e5mkHnn8lWcfiJhllkqWmxu5zWR9jucPHuo1iTB56P033hcg==", "dev": true, "funding": [ { @@ -726,6 +793,34 @@ "@csstools/css-tokenizer": "^4.0.0" } }, + "node_modules/@csstools/css-color-parser": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-4.2.3.tgz", + "integrity": "sha512-y4LpL+lmpuyKDiEFq2PnZUVFdAjsoB/qQJod79yLNokXyW7jewi+/WJ69EfItj8A2unWtxXnGjw6LYXgXu5ZjA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "dependencies": { + "@csstools/color-helpers": "^6.1.1", + "@csstools/css-calc": "^3.4.0" + }, + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" + } + }, "node_modules/@csstools/css-parser-algorithms": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-4.0.0.tgz", @@ -971,6 +1066,24 @@ "node": "^20.19.0 || ^22.13.0 || >=24" } }, + "node_modules/@exodus/bytes": { + "version": "1.15.1", + "resolved": "https://registry.npmjs.org/@exodus/bytes/-/bytes-1.15.1.tgz", + "integrity": "sha512-S6mL0yNB/Abt9Ei4tq8gDhcczc4S3+vQ4ra7vxnAf+YHC02srtqxKKZghx2Dq6p0e66THKwR6r8N6P95wEty7Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + }, + "peerDependencies": { + "@noble/hashes": "^1.8.0 || ^2.0.0" + }, + "peerDependenciesMeta": { + "@noble/hashes": { + "optional": true + } + } + }, "node_modules/@fontsource/ibm-plex-sans": { "version": "5.3.0", "resolved": "https://registry.npmjs.org/@fontsource/ibm-plex-sans/-/ibm-plex-sans-5.3.0.tgz", @@ -2276,6 +2389,16 @@ "node": ">=6.0.0" } }, + "node_modules/bidi-js": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.1.0.tgz", + "integrity": "sha512-fX1Onk0tdVPC7obPWB5EbJ1z7NVhLq4m2xZLq2YXBkxzMXIGRpNMU88n0EPgWseKl12J7zXs7qrDxPK4sRs2fg==", + "dev": true, + "license": "MIT", + "dependencies": { + "require-from-string": "^2.0.2" + } + }, "node_modules/brace-expansion": { "version": "5.0.9", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz", @@ -2585,6 +2708,35 @@ "node": ">=4" } }, + "node_modules/data-urls": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-7.0.0.tgz", + "integrity": "sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA==", + "dev": true, + "license": "MIT", + "dependencies": { + "whatwg-mimetype": "^5.0.0", + "whatwg-url": "^16.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/data-urls/node_modules/whatwg-url": { + "version": "16.0.1", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-16.0.1.tgz", + "integrity": "sha512-1to4zXBxmXHV3IiSSEInrreIlu02vUOvrhxJJH5vcxYTBDAx51cqZiKdyTxlecdKNSjj8EcxGBxNf6Vg+945gw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@exodus/bytes": "^1.11.0", + "tr46": "^6.0.0", + "webidl-conversions": "^8.0.1" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, "node_modules/debug": { "version": "4.4.3", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", @@ -2603,6 +2755,13 @@ } } }, + "node_modules/decimal.js": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz", + "integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==", + "dev": true, + "license": "MIT" + }, "node_modules/deep-is": { "version": "0.1.4", "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", @@ -2677,6 +2836,19 @@ "node": ">=14" } }, + "node_modules/entities": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-8.1.0.tgz", + "integrity": "sha512-kxL7msIffSuh9aaFAMD7rxAIuTRMAHMeBtgHW2yUdWw732ZNh4MehkF2gdjvtdmikkaIP9bFDDJOPlsvm7avrA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=20.19.0" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, "node_modules/env-paths": { "version": "2.2.1", "resolved": "https://registry.npmjs.org/env-paths/-/env-paths-2.2.1.tgz", @@ -3463,6 +3635,19 @@ "dev": true, "license": "MIT" }, + "node_modules/html-encoding-sniffer": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-6.0.0.tgz", + "integrity": "sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@exodus/bytes": "^1.6.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, "node_modules/html-escaper": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz", @@ -3658,6 +3843,13 @@ "node": ">=0.10.0" } }, + "node_modules/is-potential-custom-element-name": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", + "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==", + "dev": true, + "license": "MIT" + }, "node_modules/is-stream": { "version": "4.0.1", "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-4.0.1.tgz", @@ -3790,6 +3982,47 @@ "js-yaml": "bin/js-yaml.js" } }, + "node_modules/jsdom": { + "version": "30.0.1", + "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-30.0.1.tgz", + "integrity": "sha512-52v7mUVUfNQVYYqE1lcdaymWL0njO7lTLUog6ZvW2U5KsbiLk/GnZlVJ+qx0xfNJZ6Gn+KSpPNE52vurbxZwrA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/css-color": "^6.0.5", + "@asamuzakjp/dom-selector": "^8.3.0", + "@bramus/specificity": "^2.4.2", + "@csstools/css-syntax-patches-for-csstree": "^1.1.7", + "@exodus/bytes": "^1.15.1", + "css-tree": "^3.2.1", + "data-urls": "^7.0.0", + "decimal.js": "^10.6.0", + "html-encoding-sniffer": "^6.0.0", + "is-potential-custom-element-name": "^1.0.1", + "lru-cache": "^11.5.2", + "parse5": "^8.0.1", + "saxes": "^6.0.0", + "symbol-tree": "^3.2.4", + "tough-cookie": "^6.0.2", + "undici": "^8.9.0", + "w3c-xmlserializer": "^5.0.0", + "webidl-conversions": "^8.0.1", + "whatwg-mimetype": "^5.0.0", + "whatwg-url": "^17.1.0", + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": "^22.22.2 || ^24.15.0 || >=26.0.0" + }, + "peerDependencies": { + "canvas": "^3.2.3" + }, + "peerDependenciesMeta": { + "canvas": { + "optional": true + } + } + }, "node_modules/jsesc": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", @@ -4672,6 +4905,19 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/parse5": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-8.0.1.tgz", + "integrity": "sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw==", + "dev": true, + "license": "MIT", + "dependencies": { + "entities": "^8.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, "node_modules/path-exists": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", @@ -5094,6 +5340,19 @@ "dev": true, "license": "MIT" }, + "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.8.5", "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", @@ -5587,6 +5846,13 @@ "integrity": "sha512-ovssysQTa+luh7A5Weu3Rta6FJlFBBbInjOh722LIt6klpU2/HtdUbszju/G4devcvk8PGt7FCLv5wftu3THUA==", "dev": true }, + "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/table": { "version": "6.9.0", "resolved": "https://registry.npmjs.org/table/-/table-6.9.0.tgz", @@ -5724,6 +5990,26 @@ "node": ">=14.0.0" } }, + "node_modules/tldts": { + "version": "7.4.13", + "resolved": "https://registry.npmjs.org/tldts/-/tldts-7.4.13.tgz", + "integrity": "sha512-iHtaIWWIbMDkCeJdTBzZFGgbluE5J+oHlb2g7+oAz1S1gpuVpabRZdQyd471Vl8UUkcz2vXSL8xZH2kyCe8tfA==", + "dev": true, + "license": "MIT", + "dependencies": { + "tldts-core": "^7.4.13" + }, + "bin": { + "tldts": "bin/cli.js" + } + }, + "node_modules/tldts-core": { + "version": "7.4.13", + "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.4.13.tgz", + "integrity": "sha512-mbYsrih5FRtGxs3Usvl/PqwJsNpp+jsmrdFviiK02teHDG0/HebBG/pqCylje3kzgXYzuLoHJF/0mz9W53t8Xg==", + "dev": true, + "license": "MIT" + }, "node_modules/to-regex-range": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", @@ -5737,6 +6023,32 @@ "node": ">=8.0" } }, + "node_modules/tough-cookie": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-6.0.2.tgz", + "integrity": "sha512-exgYmnmL/sJpR3upZfXG5PoatXQii55xAiXGXzY+sROLZ/Y+SLcp9PgJNI9Vz37HpQ74WvDcLT8eqm+kV3FzrA==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "tldts": "^7.0.5" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/tr46": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-6.0.0.tgz", + "integrity": "sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==", + "dev": true, + "license": "MIT", + "dependencies": { + "punycode": "^2.3.1" + }, + "engines": { + "node": ">=20" + } + }, "node_modules/tree-kill": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/tree-kill/-/tree-kill-1.2.2.tgz", @@ -5811,6 +6123,16 @@ "dev": true, "license": "MIT" }, + "node_modules/undici": { + "version": "8.10.2", + "resolved": "https://registry.npmjs.org/undici/-/undici-8.10.2.tgz", + "integrity": "sha512-/y4/bH9YNU5hi9NIrpOuvGXFcxrj3CMrV+/AYpowAYTpHn8gX/XPFjNy766FPoYY0miQhdW977JFWKGNhBdwyQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=22.19.0" + } + }, "node_modules/unicorn-magic": { "version": "0.3.0", "resolved": "https://registry.npmjs.org/unicorn-magic/-/unicorn-magic-0.3.0.tgz", @@ -6081,6 +6403,19 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/w3c-xmlserializer": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz", + "integrity": "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==", + "dev": true, + "license": "MIT", + "dependencies": { + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/weapon-regex": { "version": "2.0.4", "resolved": "https://registry.npmjs.org/weapon-regex/-/weapon-regex-2.0.4.tgz", @@ -6088,6 +6423,41 @@ "dev": true, "license": "Apache-2.0" }, + "node_modules/webidl-conversions": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-8.0.1.tgz", + "integrity": "sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=20" + } + }, + "node_modules/whatwg-mimetype": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-5.0.0.tgz", + "integrity": "sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20" + } + }, + "node_modules/whatwg-url": { + "version": "17.1.1", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-17.1.1.tgz", + "integrity": "sha512-ohjk1mdUebJVadRt3bAhQhx8lSnISq+GDttK79LFl8EHQkAPvzwctoasC4hs8tBt6kLAncBWWyq1N52qEfKvDw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@exodus/bytes": "^1.15.1", + "tr46": "^6.0.0", + "webidl-conversions": "^8.0.1" + }, + "engines": { + "node": "^22.14.0 || >=24.0.0" + } + }, "node_modules/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", @@ -6144,6 +6514,23 @@ "node": "^20.17.0 || >=22.9.0" } }, + "node_modules/xml-name-validator": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz", + "integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/xmlchars": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz", + "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==", + "dev": true, + "license": "MIT" + }, "node_modules/yocto-queue": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", diff --git a/package.json b/package.json index fedac96..a8d64b5 100644 --- a/package.json +++ b/package.json @@ -44,6 +44,7 @@ "@stryker-mutator/vitest-runner": "^10.0.0", "@vitest/coverage-v8": "^4.1.11", "eslint": "^10.8.0", + "jsdom": "^30.0.1", "prettier": "^3.9.6", "stylelint": "^17.14.1", "stylelint-config-standard-scss": "^17.0.0", diff --git a/resources/assets/dist/js/debug.min.js b/resources/assets/dist/js/debug.min.js index b65ec7e..1b298df 100644 --- a/resources/assets/dist/js/debug.min.js +++ b/resources/assets/dist/js/debug.min.js @@ -1,2 +1,2 @@ const __vite__mapDeps=(i,m=__vite__mapDeps,d=(m.f||(m.f=["./db.min.js","./dom.min.js","./userswitch.min.js"])))=>i.map(i=>d[i]); -import{a as e}from"./focus.min.js";function t(e,t,n){if(t.tag){for(var r=e.querySelectorAll(`.yii-debug-nav-link[href], a.yii-debug-brand-chip-config[href]`),i=0;i=500?`5xx`:e>=400?`4xx`:e>=300?`3xx`:e>=200?`2xx`:`none`}function d(e){var t=(e||``).toUpperCase();return t===`GET`||t===`HEAD`?`get`:t===`POST`?`post`:t===`PUT`||t===`PATCH`?`put`:t===`DELETE`?`delete`:`other`}function f(){n.forEach(function(e,t){e.classList.toggle(`is-cursor`,t===o)});var a=l(n[o]);e.querySelectorAll(`[data-snapshot-field]`).forEach(function(e){var t=e.getAttribute(`data-snapshot-field`);t===`method`?(e.textContent=a.method,i.forEach(function(t){e.classList.remove(`yii-debug-verb-`+t)}),e.classList.add(`yii-debug-verb-`+d(a.method))):t===`url`?(e.textContent=a.url,a.fullUrl&&e.setAttribute(`title`,a.fullUrl)):t===`status`?(e.textContent=a.status?String(a.status):`–`,r.forEach(function(t){e.classList.remove(`yii-debug-status-`+t)}),e.classList.add(`yii-debug-status-`+u(a.status))):t===`time`?(e.textContent=a.time,e.hidden=a.time===``):t===`ajax`&&(e.hidden=!a.ajax)});var s=e.querySelector(`.yii-debug-history-card`);s&&s.setAttribute(`title`,(a.method+` `+(a.fullUrl||a.url)).trim()),t(document,a,window.location&&window.location.href);var c=e.querySelector(`[data-yii-debug-cursor="newest"]`),f=e.querySelector(`[data-yii-debug-cursor="newer"]`),p=e.querySelector(`[data-yii-debug-cursor="older"]`),m=e.querySelector(`[data-yii-debug-cursor="oldest"]`),h=o===0,g=o===n.length-1;c&&(c.disabled=h),f&&(f.disabled=h),p&&(p.disabled=g),m&&(m.disabled=g)}function p(e){var t=e.getBoundingClientRect(),n=window.innerHeight||document.documentElement.clientHeight;if(!(t.top>=0&&t.bottom<=n)){var r=window.scrollY+t.top-n/2+t.height/2;window.scrollTo({top:Math.max(0,r),behavior:`smooth`})}}function m(e){e<0||e>=n.length||e===o||(o=e,f(),p(n[o]))}e.addEventListener(`click`,function(t){var r=t.target.closest(`[data-yii-debug-cursor]`);if(r&&e.contains(r)){if(t.preventDefault(),r.disabled)return;var i=r.getAttribute(`data-yii-debug-cursor`);i===`newer`?m(o-1):i===`older`?m(o+1):i===`newest`?m(0):i===`oldest`&&m(n.length-1)}}),f(),o!==0&&p(n[o])})();var n=Object.freeze({error:{button:`Copy failed`,label:`Retry copy`,status:`Copy failed. Try again.`},success:{button:`Copied`,label:`Copied`,status:`Copied to clipboard.`},unavailable:{button:`Copy unavailable`,label:`Unavailable`,status:`Clipboard access is unavailable.`}}),r=0;function i(e){return(e.getAttribute(`aria-describedby`)||``).split(/\s+/).filter(Boolean)}function a(e,t){var n=i(e);n.indexOf(t)===-1&&(n.push(t),e.setAttribute(`aria-describedby`,n.join(` `)))}function o(e){var t=e.ownerDocument.createElement(`span`);return r+=1,t.id=`yii-debug-copy-status-`+r,t.className=`yii-debug-sr-only`,t.setAttribute(`aria-atomic`,`true`),t.setAttribute(`aria-live`,`polite`),t.setAttribute(`data-yii-debug-copy-status`,`true`),e.after(t),a(e,t.id),t}function s(e){for(var t=i(e),n=e.ownerDocument,r=0;r1?decodeURIComponent(t.slice(1)):``}catch{return``}}function p(e){return String(e||``).normalize(`NFKD`).replace(/[\u0300-\u036f]/g,``).toLowerCase().replace(/[^a-z0-9]+/g,`-`).replace(/^-|-$/g,``)||`section`}function m(e){return e.nodeType===3?e.textContent||``:e.nodeType!==1||e.matches&&e.matches(d)?``:e.childNodes?Array.from(e.childNodes).map(m).join(` `):``}function h(e){return((e.childNodes?Array.from(e.childNodes).map(m).join(` `):``)||e.textContent||``).replace(/\s+/g,` `).trim()}function g(e,t,n){for(var r=t,i=2,a=e.getElementById(r);a&&a!==n;)r=t+`-`+i,i+=1,a=e.getElementById(r);return r}function _(e){for(var t=e.querySelectorAll(`#yii-debug-main h2, #yii-debug-main h3`),n=0;n0){let e=document.getElementsByTagName(`link`),i=document.querySelector(`meta[property=csp-nonce]`),a=i?.nonce||i?.getAttribute(`nonce`);function o(e){return Promise.all(e.map(e=>Promise.resolve(e).then(e=>({status:`fulfilled`,value:e}),e=>({status:`rejected`,reason:e}))))}function s(e){return import.meta.resolve?import.meta.resolve(e):new URL(e,import.meta.url).href}r=o(t.map(t=>{if(t=w(t,n),t=s(t),t in T)return;T[t]=!0;let r=t.endsWith(`.css`);for(let n=e.length-1;n>=0;n--){let i=e[n];if(i.href===t&&(!r||i.rel===`stylesheet`))return}let i=document.createElement(`link`);if(i.rel=r?`stylesheet`:C,r||(i.as=`script`),i.crossOrigin=``,i.href=t,a&&i.setAttribute(`nonce`,a),document.head.appendChild(i),r)return new Promise((e,n)=>{i.addEventListener(`load`,e),i.addEventListener(`error`,()=>n(Error(`Unable to preload CSS for ${t}`)))})}))}function i(e){let t=new Event(`vite:preloadError`,{cancelable:!0});if(t.payload=e,window.dispatchEvent(t),!t.defaultPrevented)throw e}return r.then(t=>{for(let e of t||[])e.status===`rejected`&&i(e.reason);return e().catch(i)})},D=Object.freeze({db:`.yii-debug-db-explain-toggle, [data-yii-debug-n1-filter]`,phpinfo:`[data-yii-debug-phpinfo-search]`,userswitch:`#debug-userswitch__filter, #debug-userswitch__reset-identity-button`}),O={db:()=>E(()=>import(`./db.min.js`),__vite__mapDeps([0,1]),import.meta.url),phpinfo:()=>E(()=>import(`./phpinfo-search.min.js`),[],import.meta.url),userswitch:()=>E(()=>import(`./userswitch.min.js`),__vite__mapDeps([2,1]),import.meta.url)};function k(e,t=O){var n=[];return Object.keys(D).forEach(function(r){e.querySelector(D[r])&&n.push(Promise.resolve().then(t[r]))}),Promise.all(n)}var A=`[data-yii-debug-filter-row], tbody tr`,j=`data-yii-debug-filter-default-open`,M=`data-yii-debug-filter-previous-open`,N=`[data-yii-debug-filter-target]`;function P(e){var t=e.closest(`[data-yii-debug-filter-scope], .yii-debug-disclosure-body`);if(t){var n=t.querySelector(N);if(n)return n}for(var r=(e.closest(`header, .yii-debug-section-header, .yii-debug-mini-toolbar`)||e).nextElementSibling;r&&!r.matches(N);)r=r.nextElementSibling;return r}function F(e,t){for(var n=e.querySelectorAll(A),r=t.trim().toLowerCase(),i=0,a=0;a0),{total:n.length,unit:e.getAttribute(`data-yii-debug-filter-unit`)||`rows`,visible:i}}var I=[`ArrowLeft`,`ArrowRight`,`Home`,`End`];function L(e,t){return e&&e.nodeType!==1&&(e=e.parentElement),e&&typeof e.closest==`function`?e.closest(t):null}function R(e,t){var n=e.getAttribute(`aria-controls`);if(!n){var r=e.getAttribute(`href`)||``;n=r.charAt(0)===`#`?r.slice(1):``}return n&&t.getElementById?t.getElementById(n):null}function z(e,t){var n=R(e,t);if(!n)return!1;var r=L(e,`.yii-debug-tabs`),i=n.parentElement,a=r?r.querySelectorAll(`[data-yii-debug-toggle="tab"]`):[],o=i?i.children:[],s;for(s=0;s[`dark`,`night`,`black`].includes(e));return n===t.some(e=>[`light`,`day`,`white`].includes(e))?null:n?`dark`:`light`}function K(e){return G(e)===`dark`?`Switch to light theme`:`Switch to dark theme`}function q(e,t){if(!e)return;let n=e.querySelector(`.yii-debug-brand-icon`),r=t=>{let r=G(t)||`light`,i=K(r);e.setAttribute(`data-current-theme`,r),e.setAttribute(`aria-label`,i),e.setAttribute(`aria-pressed`,r===`dark`?`true`:`false`),e.setAttribute(`title`,i),n&&(n.innerHTML=r===`dark`?e.getAttribute(`data-icon-sun`):e.getAttribute(`data-icon-moon`))};r(G(document.documentElement.getAttribute(`data-yii-debug-theme`))),e.addEventListener(`click`,()=>{let e=G(document.documentElement.getAttribute(`data-yii-debug-theme`))===`dark`?`light`:`dark`;document.documentElement.setAttribute(`data-yii-debug-theme`,e),r(e),X(e),$(e),t&&t(e)})}function J(e=W){try{return window.localStorage?G(localStorage.getItem(e)):null}catch{return null}}function Y(){let e=`${W}=`,t=(document.cookie||``).split(`;`).map(e=>e.trim()).find(t=>t.startsWith(e));if(!t)return null;try{return G(decodeURIComponent(t.slice(e.length)))}catch{return null}}function X(e){let t=G(e);if(t){try{localStorage.setItem(W,t)}catch{}try{document.cookie=`${W}=${encodeURIComponent(t)};path=/;max-age=31536000;SameSite=Lax`}catch{}}}function Z(e){let t;try{t=new URL(e,window.location.href)}catch{return null}if(t.origin!==window.location.origin)return null;let n=t.searchParams.get(`r`)||``;return!t.pathname.includes(`/debug/`)&&!t.pathname.endsWith(`/debug`)&&!n.startsWith(`debug/`)&&!n.startsWith(`debug%2F`)?null:t}function Q(e,t){let n=G(t);if(!n)return e;let r=Z(e);return r?(r.searchParams.set(U,n),r.href):e}function $(e){let t=document.querySelectorAll(`a[href]`),n=document.querySelectorAll(`form[action]`),r;for(let n=0;n1);i.setAttribute(`aria-controls`,r.id),i.textContent=e[n].classList.contains(`is-open`)?`Show less`:`Show more`}}}function l(e){var t=P(e);if(t){var n=e.closest(`header, .yii-debug-section-header, .yii-debug-mini-toolbar`)||e.parentElement||e,r=F(t,e.value),i=n.querySelector(`[data-yii-debug-filter-status]`);if(!i){i=document.createElement(`span`),i.id=e.id?e.id+`-status`:`yii-debug-filter-status-`+(document.querySelectorAll(`[data-yii-debug-filter-status]`).length+1),i.className=`yii-debug-sr-only`,i.setAttribute(`aria-live`,`polite`),i.setAttribute(`aria-atomic`,`true`),i.setAttribute(`data-yii-debug-filter-status`,`true`),n.appendChild(i);var a=(e.getAttribute(`aria-describedby`)||``).split(/\s+/).filter(Boolean);a.indexOf(i.id)===-1&&(a.push(i.id),e.setAttribute(`aria-describedby`,a.join(` `)))}i.textContent=r.visible+` of `+r.total+` `+r.unit+` shown.`}}function d(e){for(var t=document.querySelectorAll(`.yii-debug-dropdown.is-open`),n=0;n0?a[0]:null;!o||o.tagName!==`INPUT`||(o.focus({preventScroll:!0}),typeof o.setSelectionRange==`function`&&o.setSelectionRange(n.start,n.end))}}}function s(e){if(r(e)){var t=new URL(window.location.href);e.value===``||e.value===null?t.searchParams.delete(e.name):t.searchParams.set(e.name,e.value),t.searchParams.delete(`page`),t.toString()!==window.location.href&&(a(e),document.documentElement.setAttribute(`aria-busy`,`true`),window.location.href=t.toString())}}function c(e){n&&clearTimeout(n.timeout),n={input:e,timeout:setTimeout(function(){var e=n;n=null,s(e.input)},650)}}function l(){if(!n)return!1;clearTimeout(n.timeout);var e=n.input;return n=null,s(e),!0}document.addEventListener(`change`,function(e){r(e.target)&&(n&&n.input===e.target&&(clearTimeout(n.timeout),n=null),(e.target.tagName===`SELECT`||e.target.tagName===`INPUT`)&&s(e.target))}),document.addEventListener(`input`,function(e){e.target.tagName===`INPUT`&&e.target.type!==`submit`&&r(e.target)&&c(e.target)}),document.addEventListener(`keydown`,function(e){e.key===`Enter`&&e.target.tagName===`INPUT`&&e.target.type!==`submit`&&r(e.target)&&(e.preventDefault(),l()||s(e.target))}),document.addEventListener(`focusout`,function(e){e.target.tagName===`INPUT`&&e.target.type!==`submit`&&r(e.target)&&n&&n.input===e.target&&l()}),o()})(),y(document,window),H(document,window),u(document,window.navigator?window.navigator.clipboard:null,window.location),k(document).catch(function(){document.documentElement.setAttribute(`data-yii-debug-feature-load-error`,`true`);var e=document.createElement(`div`);e.className=`yii-debug-callout yii-debug-callout-danger`,e.setAttribute(`role`,`alert`),e.textContent=`An interactive debugger feature failed to load. Reload the page and try again.`;var t=document.getElementById(`yii-debug-main`);t&&t.prepend(e)})})(); \ No newline at end of file +import{a as e}from"./focus.min.js";function t(e,t,n){if(t.tag){for(var r=e.querySelectorAll(`.yii-debug-nav-link[href], a.yii-debug-brand-chip-config[href]`),i=0;i=500?`5xx`:e>=400?`4xx`:e>=300?`3xx`:e>=200?`2xx`:`none`}function d(e){var t=(e||``).toUpperCase();return t===`GET`||t===`HEAD`?`get`:t===`POST`?`post`:t===`PUT`||t===`PATCH`?`put`:t===`DELETE`?`delete`:`other`}function f(){n.forEach(function(e,t){e.classList.toggle(`is-cursor`,t===o)});var a=l(n[o]);e.querySelectorAll(`[data-snapshot-field]`).forEach(function(e){var t=e.getAttribute(`data-snapshot-field`);t===`method`?(e.textContent=a.method,i.forEach(function(t){e.classList.remove(`yii-debug-verb-`+t)}),e.classList.add(`yii-debug-verb-`+d(a.method))):t===`url`?(e.textContent=a.url,a.fullUrl&&e.setAttribute(`title`,a.fullUrl)):t===`status`?(e.textContent=a.status?String(a.status):`–`,r.forEach(function(t){e.classList.remove(`yii-debug-status-`+t)}),e.classList.add(`yii-debug-status-`+u(a.status))):t===`time`?(e.textContent=a.time,e.hidden=a.time===``):t===`ajax`&&(e.hidden=!a.ajax)});var s=e.querySelector(`.yii-debug-history-card`);s&&s.setAttribute(`title`,(a.method+` `+(a.fullUrl||a.url)).trim()),t(document,a,window.location&&window.location.href);var c=e.querySelector(`[data-yii-debug-cursor="newest"]`),f=e.querySelector(`[data-yii-debug-cursor="newer"]`),p=e.querySelector(`[data-yii-debug-cursor="older"]`),m=e.querySelector(`[data-yii-debug-cursor="oldest"]`),h=o===0,g=o===n.length-1;c&&(c.disabled=h),f&&(f.disabled=h),p&&(p.disabled=g),m&&(m.disabled=g)}function p(e){var t=e.getBoundingClientRect(),n=window.innerHeight||document.documentElement.clientHeight;if(!(t.top>=0&&t.bottom<=n)){var r=window.scrollY+t.top-n/2+t.height/2;window.scrollTo({top:Math.max(0,r),behavior:`smooth`})}}function m(e){e<0||e>=n.length||e===o||(o=e,f(),p(n[o]))}e.addEventListener(`click`,function(t){var r=t.target.closest(`[data-yii-debug-cursor]`);if(r&&e.contains(r)){if(t.preventDefault(),r.disabled)return;var i=r.getAttribute(`data-yii-debug-cursor`);i===`newer`?m(o-1):i===`older`?m(o+1):i===`newest`?m(0):i===`oldest`&&m(n.length-1)}}),f(),o!==0&&p(n[o])})();var n=Object.freeze({error:{button:`Copy failed`,label:`Retry copy`,status:`Copy failed. Try again.`},success:{button:`Copied`,label:`Copied`,status:`Copied to clipboard.`},unavailable:{button:`Copy unavailable`,label:`Unavailable`,status:`Clipboard access is unavailable.`}}),r=0;function i(e){return(e.getAttribute(`aria-describedby`)||``).split(/\s+/).filter(Boolean)}function a(e,t){var n=i(e);n.indexOf(t)===-1&&(n.push(t),e.setAttribute(`aria-describedby`,n.join(` `)))}function o(e){var t=e.ownerDocument.createElement(`span`);return r+=1,t.id=`yii-debug-copy-status-`+r,t.className=`yii-debug-sr-only`,t.setAttribute(`aria-atomic`,`true`),t.setAttribute(`aria-live`,`polite`),t.setAttribute(`data-yii-debug-copy-status`,`true`),e.after(t),a(e,t.id),t}function s(e){for(var t=i(e),n=e.ownerDocument,r=0;r1?decodeURIComponent(t.slice(1)):``}catch{return``}}function p(e){return String(e||``).normalize(`NFKD`).replace(/[\u0300-\u036f]/g,``).toLowerCase().replace(/[^a-z0-9]+/g,`-`).replace(/^-|-$/g,``)||`section`}function m(e){return e.nodeType===3?e.textContent||``:e.nodeType!==1||e.matches&&e.matches(d)?``:e.childNodes?Array.from(e.childNodes).map(m).join(` `):``}function h(e){return((e.childNodes?Array.from(e.childNodes).map(m).join(` `):``)||e.textContent||``).replace(/\s+/g,` `).trim()}function g(e,t,n){for(var r=t,i=2,a=e.getElementById(r);a&&a!==n;)r=t+`-`+i,i+=1,a=e.getElementById(r);return r}function _(e){for(var t=e.querySelectorAll(`#yii-debug-main h2, #yii-debug-main h3`),n=0;n0){let e=document.getElementsByTagName(`link`),i=document.querySelector(`meta[property=csp-nonce]`),a=i?.nonce||i?.getAttribute(`nonce`);function o(e){return Promise.all(e.map(e=>Promise.resolve(e).then(e=>({status:`fulfilled`,value:e}),e=>({status:`rejected`,reason:e}))))}function s(e){return import.meta.resolve?import.meta.resolve(e):new URL(e,import.meta.url).href}r=o(t.map(t=>{if(t=w(t,n),t=s(t),t in T)return;T[t]=!0;let r=t.endsWith(`.css`);for(let n=e.length-1;n>=0;n--){let i=e[n];if(i.href===t&&(!r||i.rel===`stylesheet`))return}let i=document.createElement(`link`);if(i.rel=r?`stylesheet`:C,r||(i.as=`script`),i.crossOrigin=``,i.href=t,a&&i.setAttribute(`nonce`,a),document.head.appendChild(i),r)return new Promise((e,n)=>{i.addEventListener(`load`,e),i.addEventListener(`error`,()=>n(Error(`Unable to preload CSS for ${t}`)))})}))}function i(e){let t=new Event(`vite:preloadError`,{cancelable:!0});if(t.payload=e,window.dispatchEvent(t),!t.defaultPrevented)throw e}return r.then(t=>{for(let e of t||[])e.status===`rejected`&&i(e.reason);return e().catch(i)})},D=Object.freeze({db:`.yii-debug-db-explain-toggle, [data-yii-debug-n1-filter]`,phpinfo:`[data-yii-debug-phpinfo-search]`,userswitch:`#debug-userswitch__filter, #debug-userswitch__reset-identity-button`}),O={db:()=>E(()=>import(`./db.min.js`),__vite__mapDeps([0,1]),import.meta.url),phpinfo:()=>E(()=>import(`./phpinfo-search.min.js`),[],import.meta.url),userswitch:()=>E(()=>import(`./userswitch.min.js`),__vite__mapDeps([2,1]),import.meta.url)};function k(e,t=O){var n=[];return Object.keys(D).forEach(function(r){e.querySelector(D[r])&&n.push(Promise.resolve().then(t[r]))}),Promise.all(n)}var A=`[data-yii-debug-filter-row], tbody tr`,j=`data-yii-debug-filter-default-open`,M=`data-yii-debug-filter-previous-open`,N=`[data-yii-debug-filter-target]`;function P(e){var t=e.closest(`[data-yii-debug-filter-scope], .yii-debug-disclosure-body`);if(t){var n=t.querySelector(N);if(n)return n}for(var r=(e.closest(`header, .yii-debug-section-header, .yii-debug-mini-toolbar`)||e).nextElementSibling;r&&!r.matches(N);)r=r.nextElementSibling;return r}function F(e,t){for(var n=e.querySelectorAll(A),r=t.trim().toLowerCase(),i=0,a=0;a0),{total:n.length,unit:e.getAttribute(`data-yii-debug-filter-unit`)||`rows`,visible:i}}var I=[`ArrowLeft`,`ArrowRight`,`Home`,`End`];function L(e,t){return e&&e.nodeType!==1&&(e=e.parentElement),e&&typeof e.closest==`function`?e.closest(t):null}function R(e,t){var n=e.getAttribute(`aria-controls`);if(!n){var r=e.getAttribute(`href`)||``;n=r.charAt(0)===`#`?r.slice(1):``}return n&&t.getElementById?t.getElementById(n):null}function z(e,t){var n=R(e,t);if(!n)return!1;var r=L(e,`.yii-debug-tabs`),i=n.parentElement,a=r?r.querySelectorAll(`[data-yii-debug-toggle="tab"]`):[],o=i?i.children:[],s;for(s=0;s[`dark`,`night`,`black`].includes(e));return n===t.some(e=>[`light`,`day`,`white`].includes(e))?null:n?`dark`:`light`}function K(e){return G(e)===`dark`?`Switch to light theme`:`Switch to dark theme`}function q(e,t){if(!e)return;let n=e.querySelector(`.yii-debug-brand-icon`),r=t=>{let r=G(t)||`light`,i=K(r);e.setAttribute(`data-current-theme`,r),e.setAttribute(`aria-label`,i),e.setAttribute(`aria-pressed`,r===`dark`?`true`:`false`),e.setAttribute(`title`,i),n&&(n.innerHTML=r===`dark`?e.getAttribute(`data-icon-sun`):e.getAttribute(`data-icon-moon`))};r(G(document.documentElement.getAttribute(`data-yii-debug-theme`))),e.addEventListener(`click`,()=>{let e=G(document.documentElement.getAttribute(`data-yii-debug-theme`))===`dark`?`light`:`dark`;document.documentElement.setAttribute(`data-yii-debug-theme`,e),r(e),X(e),$(e),t&&t(e)})}function J(e=W){try{return window.localStorage?G(localStorage.getItem(e)):null}catch{return null}}function Y(){let e=`${W}=`,t=(document.cookie||``).split(`;`).map(e=>e.trim()).find(t=>t.startsWith(e));if(!t)return null;try{return G(decodeURIComponent(t.slice(e.length)))}catch{return null}}function X(e){let t=G(e);if(t){try{localStorage.setItem(W,t)}catch{}try{document.cookie=`${W}=${encodeURIComponent(t)};path=/;max-age=31536000;SameSite=Lax`}catch{}}}function Z(e){let t;try{t=new URL(e,window.location.href)}catch{return null}if(t.origin!==window.location.origin)return null;let n=t.searchParams.get(`r`)||``;return!t.pathname.includes(`/debug/`)&&!t.pathname.endsWith(`/debug`)&&!n.startsWith(`debug/`)&&!n.startsWith(`debug%2F`)?null:t}function Q(e,t){let n=G(t);if(!n)return e;let r=Z(e);return r?(r.searchParams.set(U,n),r.href):e}function $(e){let t=document.querySelectorAll(`a[href]`),n=document.querySelectorAll(`form[action]`),r;for(let n=0;n1);i.setAttribute(`aria-controls`,r.id),i.textContent=e[n].classList.contains(`is-open`)?`Show less`:`Show more`}}}function l(e){var t=P(e);if(t){var n=e.closest(`header, .yii-debug-section-header, .yii-debug-mini-toolbar`)||e.parentElement||e,r=F(t,e.value),i=n.querySelector(`[data-yii-debug-filter-status]`);if(!i){i=document.createElement(`span`),i.id=e.id?e.id+`-status`:`yii-debug-filter-status-`+(document.querySelectorAll(`[data-yii-debug-filter-status]`).length+1),i.className=`yii-debug-sr-only`,i.setAttribute(`aria-live`,`polite`),i.setAttribute(`aria-atomic`,`true`),i.setAttribute(`data-yii-debug-filter-status`,`true`),n.appendChild(i);var a=(e.getAttribute(`aria-describedby`)||``).split(/\s+/).filter(Boolean);a.indexOf(i.id)===-1&&(a.push(i.id),e.setAttribute(`aria-describedby`,a.join(` `)))}i.textContent=r.visible+` of `+r.total+` `+r.unit+` shown.`}}function d(e){for(var t=document.querySelectorAll(`.yii-debug-dropdown.is-open`),n=0;n0?a[0]:null;!o||o.tagName!==`INPUT`||(o.focus({preventScroll:!0}),typeof o.setSelectionRange==`function`&&o.setSelectionRange(n.start,n.end))}}}function s(e){if(r(e)){var t=new URL(window.location.href);e.value===``||e.value===null?t.searchParams.delete(e.name):t.searchParams.set(e.name,e.value),t.searchParams.delete(`page`),t.toString()!==window.location.href&&(a(e),document.documentElement.setAttribute(`aria-busy`,`true`),window.location.href=t.toString())}}function c(e){n&&clearTimeout(n.timeout),n={input:e,timeout:setTimeout(function(){var e=n;n=null,s(e.input)},650)}}function l(){if(!n)return!1;clearTimeout(n.timeout);var e=n.input;return n=null,s(e),!0}document.addEventListener(`change`,function(e){r(e.target)&&(n&&n.input===e.target&&(clearTimeout(n.timeout),n=null),(e.target.tagName===`SELECT`||e.target.tagName===`INPUT`)&&s(e.target))}),document.addEventListener(`input`,function(e){e.target.tagName===`INPUT`&&e.target.type!==`submit`&&r(e.target)&&c(e.target)}),document.addEventListener(`keydown`,function(e){e.key===`Enter`&&e.target.tagName===`INPUT`&&e.target.type!==`submit`&&r(e.target)&&(e.preventDefault(),l()||s(e.target))}),document.addEventListener(`focusout`,function(e){e.target.tagName===`INPUT`&&e.target.type!==`submit`&&r(e.target)&&n&&n.input===e.target&&l()}),o()})(),y(document,window),H(document,window),u(document,window.navigator?window.navigator.clipboard:null,window.location),k(document).catch(function(){document.documentElement.setAttribute(`data-yii-debug-feature-load-error`,`true`);var e=document.createElement(`div`);e.className=`yii-debug-callout yii-debug-callout-danger`,e.setAttribute(`role`,`alert`),e.textContent=`An interactive debugger feature failed to load. Reload the page and try again.`;var t=document.getElementById(`yii-debug-main`);t&&t.prepend(e)})})(); \ No newline at end of file diff --git a/resources/src/core/debug.js b/resources/src/core/debug.js index 5a49639..ff5690b 100644 --- a/resources/src/core/debug.js +++ b/resources/src/core/debug.js @@ -122,15 +122,13 @@ import { requestParentToolbarDrawerClose } from "../toolbar/focus.js"; } function dropdownItems(menu) { - return menu - ? Array.from( - menu.querySelectorAll( - 'a[href], button:not([disabled]), [role="menuitem"][tabindex]', - ), - ).filter(function (item) { - return !item.hidden && item.getAttribute("aria-hidden") !== "true"; - }) - : []; + return Array.from( + menu.querySelectorAll( + 'a[href], button:not([disabled]), [role="menuitem"][tabindex]', + ), + ).filter(function (item) { + return !item.hidden && item.getAttribute("aria-hidden") !== "true"; + }); } function prepareCellMoreControls() { diff --git a/resources/tests/debug-bootstrap.test.js b/resources/tests/debug-bootstrap.test.js new file mode 100644 index 0000000..dd27170 --- /dev/null +++ b/resources/tests/debug-bootstrap.test.js @@ -0,0 +1,233 @@ +// @vitest-environment jsdom +import assert from "node:assert/strict"; +import { test, vi } from "vitest"; + +import { bootDebugPage, fire, settle } from "./debug-page-harness.js"; + +/** + * The Database panel feature is the failure fixture: it is the only module the + * bootstrap loads on demand here, and its import must reject for the degraded + * callout to appear. No other scenario in this file ships a panel marker. + */ +vi.doMock("../src/panels/db.js", () => { + throw new Error("Chunk load failed."); +}); + +const CELL_MORE = + '
' + + '
Full value
' + + '' + + "
"; +const CELL_MORE_OPEN = + '
' + + '
Full value
' + + '' + + "
"; +const COPY_CONTROL = + '"; +const DB_MARKER = ''; +const TABS = + '
' + + 'One' + + 'Two
' + + '
One
' + + '
'; + +test("cell-more controls describe the body they disclose", async () => { + const page = await bootDebugPage({ body: CELL_MORE + CELL_MORE_OPEN }); + const controls = page.document.querySelectorAll( + '[data-yii-debug-toggle="cell-more"]', + ); + const bodies = page.document.querySelectorAll(".yii-debug-cell-more-body"); + + assert.equal( + controls[0].getAttribute("aria-controls"), + bodies[0].id, + "Control must point at its own body.", + ); + assert.equal(controls[0].textContent, "Show more", "Closed box invites."); + assert.equal(controls[1].textContent, "Show less", "Open box retracts."); + assert.notEqual(bodies[0].id, bodies[1].id, "Generated ids must be unique."); +}); + +test("a cell-more box missing its body or its control is left alone", async () => { + const page = await bootDebugPage({ + body: + '
' + + '' + + "
" + + '
' + + '
Orphan
', + }); + const control = page.document.querySelector( + '[data-yii-debug-toggle="cell-more"]', + ); + + assert.equal(control.textContent, "Raw", "Half-rendered box must be left."); + assert.equal( + control.getAttribute("aria-controls"), + null, + "No body means nothing to reference.", + ); + assert.equal( + page.document.querySelector(".yii-debug-cell-more-body").id, + "", + "A body without a control needs no id.", + ); +}); + +test("a server-rendered body id is kept", async () => { + const page = await bootDebugPage({ + body: + '
' + + '
Full value
' + + '' + + "
", + }); + + assert.equal( + page.document + .querySelector('[data-yii-debug-toggle="cell-more"]') + .getAttribute("aria-controls"), + "db-row-7", + "Server id must be reused.", + ); +}); + +test("generated body ids skip ids already taken on the page", async () => { + const page = await bootDebugPage({ + body: '' + CELL_MORE, + }); + + assert.equal( + page.document.querySelector(".yii-debug-cell-more-body").id, + "yii-debug-cell-more-2", + "Collision must be resolved by the next sequence number.", + ); +}); + +test("panel headings receive a permalink", async () => { + const page = await bootDebugPage({ + body: '

Database queries

', + }); + const heading = page.document.querySelector("h2"); + + assert.equal( + heading.id, + "yii-debug-section-database-queries", + "Heading must get a stable fragment id.", + ); + assert.equal( + heading.querySelector(".yii-debug-heading-permalink").getAttribute("href"), + "#yii-debug-section-database-queries", + "Permalink must target the heading.", + ); +}); + +test("the tab named by the fragment is revealed on load", async () => { + const page = await bootDebugPage({ + body: TABS, + url: "https://debug.test/debug/index#panel-two", + }); + + assert.equal( + page.document.getElementById("panel-two").hidden, + false, + "Fragment panel must be shown.", + ); + assert.equal( + page.document.getElementById("panel-one").hidden, + true, + "Sibling panel must be hidden.", + ); +}); + +test("copy controls hand their value to the clipboard", async () => { + const copied = []; + const page = await bootDebugPage({ + body: COPY_CONTROL, + clipboard: { + writeText(text) { + copied.push(text); + + return Promise.resolve(); + }, + }, + }); + const control = page.document.querySelector("[data-yii-debug-copy]"); + + fire(control, "click"); + + await settle(page.window); + + assert.deepEqual(copied, ["SELECT 1"], "Literal value must be copied."); + assert.equal( + control.getAttribute("aria-label"), + "Copied", + "Control must confirm the copy.", + ); +}); + +test("a host without a navigator reports the clipboard as unavailable", async () => { + const page = await bootDebugPage({ + body: COPY_CONTROL, + navigator: "none", + }); + const control = page.document.querySelector("[data-yii-debug-copy]"); + + fire(control, "click"); + + await settle(page.window); + + assert.equal( + control.getAttribute("aria-label"), + "Copy unavailable", + "Missing navigator must degrade, not throw.", + ); +}); + +test("a panel feature that fails to load raises a callout", async () => { + const page = await bootDebugPage({ + body: '

Queries

' + DB_MARKER, + }); + + await settle(page.window); + + const alert = page.document.querySelector('[role="alert"]'); + + assert.equal( + page.document.documentElement.getAttribute( + "data-yii-debug-feature-load-error", + ), + "true", + "Failure must be flagged on the document.", + ); + assert.equal( + alert.parentElement.id, + "yii-debug-main", + "Callout must open the main region.", + ); + assert.match(alert.textContent, /Reload the page/, "Callout must advise."); +}); + +test("a panel feature failure without a main region only flags the page", async () => { + const page = await bootDebugPage({ body: DB_MARKER }); + + await settle(page.window); + + assert.equal( + page.document.documentElement.getAttribute( + "data-yii-debug-feature-load-error", + ), + "true", + "Failure must be flagged on the document.", + ); + assert.equal( + page.document.querySelector('[role="alert"]'), + null, + "No region means no callout.", + ); +}); diff --git a/resources/tests/debug-disclosure.test.js b/resources/tests/debug-disclosure.test.js new file mode 100644 index 0000000..3879f2e --- /dev/null +++ b/resources/tests/debug-disclosure.test.js @@ -0,0 +1,179 @@ +// @vitest-environment jsdom +import assert from "node:assert/strict"; +import { test } from "vitest"; + +import { bootDebugPage, fire } from "./debug-page-harness.js"; + +const CELL_MORE = + '
' + + '
Full value
' + + '
'; + +test("a cell-more control expands and collapses its box", async () => { + const page = await bootDebugPage({ body: CELL_MORE }); + const box = page.document.querySelector(".yii-debug-cell-more"); + const control = box.querySelector('[data-yii-debug-toggle="cell-more"]'); + + fire(control, "click"); + + assert.equal(box.classList.contains("is-open"), true, "Box must open."); + assert.equal(control.getAttribute("aria-expanded"), "true", "State is up."); + assert.equal(control.textContent, "Show less", "Label must retract."); + + fire(control, "click"); + + assert.equal(box.classList.contains("is-open"), false, "Box must close."); + assert.equal(control.getAttribute("aria-expanded"), "false", "State down."); + assert.equal(control.textContent, "Show more", "Label must invite."); +}); + +test("a cell-more control outside a box is inert", async () => { + const page = await bootDebugPage({ + body: '', + }); + const control = page.document.querySelector( + '[data-yii-debug-toggle="cell-more"]', + ); + const event = fire(control, "click"); + + assert.equal(event.defaultPrevented, true, "Navigation must be suppressed."); + assert.equal(control.textContent, "More", "Orphan control must not change."); +}); + +test("a collapse control toggles the region named by data-target", async () => { + const page = await bootDebugPage({ + body: + '' + + '
frames
', + }); + const control = page.document.querySelector( + '[data-yii-debug-toggle="collapse"]', + ); + const target = page.document.getElementById("trace"); + + fire(control.querySelector("span"), "click"); + + assert.equal(target.classList.contains("is-open"), false, "Must collapse."); + assert.equal(control.getAttribute("aria-expanded"), "false", "State down."); + + fire(control, "click"); + + assert.equal(target.classList.contains("is-open"), true, "Must expand."); + assert.equal(control.getAttribute("aria-expanded"), "true", "State is up."); +}); + +test("a collapse control falls back to its href", async () => { + const page = await bootDebugPage({ + body: + 'Trace' + + '
frames
', + }); + + fire(page.document.querySelector("a"), "click"); + + assert.equal( + page.document.getElementById("trace").classList.contains("is-open"), + true, + "Fragment href must name the region.", + ); +}); + +test("a collapse control without a target is inert", async () => { + const page = await bootDebugPage({ + body: '', + }); + const control = page.document.querySelector( + '[data-yii-debug-toggle="collapse"]', + ); + const event = fire(control, "click"); + + assert.equal(event.defaultPrevented, true, "Navigation must be suppressed."); + assert.equal( + control.getAttribute("aria-expanded"), + null, + "Nothing to expand means no state.", + ); +}); + +test("a collapse control pointing at a missing region is inert", async () => { + const page = await bootDebugPage({ + body: + '', + }); + const control = page.document.querySelector( + '[data-yii-debug-toggle="collapse"]', + ); + + fire(control, "click"); + + assert.equal( + control.getAttribute("aria-expanded"), + null, + "A dangling target must leave the control alone.", + ); +}); + +test("a reveal control flips its pressed state and its label", async () => { + const page = await bootDebugPage({ + body: + '', + }); + const control = page.document.querySelector("[data-yii-debug-reveal]"); + + fire(control, "click"); + + assert.equal( + control.classList.contains("is-revealed"), + true, + "Value must be revealed.", + ); + assert.equal(control.getAttribute("aria-pressed"), "true", "State is up."); + assert.equal( + control.getAttribute("aria-label"), + "Hide access token", + "Label must offer the reverse action.", + ); + + fire(control, "click"); + + assert.equal( + control.classList.contains("is-revealed"), + false, + "Value must be masked again.", + ); + assert.equal(control.getAttribute("aria-pressed"), "false", "State down."); + assert.equal( + control.getAttribute("aria-label"), + "Reveal access token", + "Label must offer the reverse action.", + ); +}); + +test("a reveal control without a label describes a generic value", async () => { + const page = await bootDebugPage({ + body: '', + }); + const control = page.document.querySelector("[data-yii-debug-reveal]"); + + fire(control, "click"); + + assert.equal( + control.getAttribute("aria-label"), + "Hide value", + "Unlabelled fields fall back to a generic noun.", + ); +}); + +test("a click away from any control leaves the page alone", async () => { + const page = await bootDebugPage({ body: "

Nothing to toggle

" }); + const event = fire(page.document.querySelector("p"), "click"); + + assert.equal( + event.defaultPrevented, + false, + "Plain content clicks must stay live.", + ); +}); diff --git a/resources/tests/debug-dropdown.test.js b/resources/tests/debug-dropdown.test.js new file mode 100644 index 0000000..70fcef9 --- /dev/null +++ b/resources/tests/debug-dropdown.test.js @@ -0,0 +1,295 @@ +// @vitest-environment jsdom +import assert from "node:assert/strict"; +import { test } from "vitest"; + +import { bootDebugPage, fire, press, settle } from "./debug-page-harness.js"; + +const DEFAULT_ITEMS = + 'Database'; + +/** Renders one server-side dropdown, optionally degraded or already open. */ +function dropdown(options) { + const settings = options || {}; + const expanded = settings.open === true ? "true" : "false"; + const items = settings.items === undefined ? DEFAULT_ITEMS : settings.items; + + return ( + '
' + + (settings.trigger === false + ? "" + : '') + + (settings.menu === false + ? "" + : '
' + items + "
") + + "
" + ); +} + +function trigger(page, id) { + return page.document + .getElementById(id || "filters") + .querySelector('[data-yii-debug-toggle="dropdown"]'); +} + +function isOpen(page, id) { + return page.document.getElementById(id).classList.contains("is-open"); +} + +test("opening a dropdown closes the one already open", async () => { + const page = await bootDebugPage({ + body: dropdown({ id: "sort", open: true }) + dropdown({ id: "filters" }), + }); + + fire(trigger(page, "filters"), "click"); + + assert.equal(isOpen(page, "filters"), true, "Clicked menu must open."); + assert.equal(isOpen(page, "sort"), false, "Only one menu may stay open."); + assert.equal( + trigger(page, "sort").getAttribute("aria-expanded"), + "false", + "Closed trigger must report its state.", + ); + assert.equal( + trigger(page, "filters").getAttribute("aria-expanded"), + "true", + "Open trigger must report its state.", + ); +}); + +test("clicking an open trigger closes its own menu", async () => { + const page = await bootDebugPage({ body: dropdown({ open: true }) }); + + fire(trigger(page), "click"); + + assert.equal(isOpen(page, "filters"), false, "Second click must close."); + assert.equal( + trigger(page).getAttribute("aria-expanded"), + "false", + "Trigger must report the collapsed state.", + ); +}); + +test("a dropdown trigger outside a wrapper is inert", async () => { + const page = await bootDebugPage({ + body: '', + }); + const control = page.document.querySelector( + '[data-yii-debug-toggle="dropdown"]', + ); + const event = fire(control, "click"); + + assert.equal(event.defaultPrevented, true, "Navigation must be suppressed."); + assert.equal( + control.getAttribute("aria-expanded"), + null, + "An orphan trigger controls nothing.", + ); +}); + +test("a dropdown without a menu is inert", async () => { + const page = await bootDebugPage({ body: dropdown({ menu: false }) }); + + fire(trigger(page), "click"); + + assert.equal(isOpen(page, "filters"), false, "Nothing to reveal."); + assert.equal( + trigger(page).getAttribute("aria-expanded"), + "false", + "Trigger state must not change.", + ); +}); + +test("a click elsewhere closes a dropdown left open without a trigger", async () => { + const page = await bootDebugPage({ + body: dropdown({ open: true, trigger: false }) + "

Elsewhere

", + }); + + fire(page.document.querySelector("p"), "click"); + + assert.equal(isOpen(page, "filters"), false, "Outside click must close."); +}); + +test("ArrowDown from the trigger opens the menu on its first item", async () => { + const page = await bootDebugPage({ body: dropdown() }); + const event = press(trigger(page), "ArrowDown"); + + assert.equal(event.defaultPrevented, true, "Scrolling must be suppressed."); + assert.equal(isOpen(page, "filters"), true, "Navigation must open the menu."); + assert.equal( + page.document.activeElement.textContent, + "Database", + "Focus must land on the first item.", + ); +}); + +test("ArrowUp from the trigger opens the menu on its last item", async () => { + const page = await bootDebugPage({ body: dropdown() }); + + press(trigger(page), "ArrowUp"); + + assert.equal( + page.document.activeElement.textContent, + "Clear", + "Focus must land on the last item.", + ); +}); + +test("Home and End jump to the ends of the menu", async () => { + const page = await bootDebugPage({ body: dropdown({ open: true }) }); + const items = page.document.querySelectorAll(".yii-debug-dropdown-menu > *"); + + press(items[0], "End"); + + assert.equal(page.document.activeElement, items[1], "End must go last."); + + press(items[1], "Home"); + + assert.equal(page.document.activeElement, items[0], "Home must go first."); +}); + +test("ArrowDown from an item moves to the next item", async () => { + const page = await bootDebugPage({ body: dropdown({ open: true }) }); + const items = page.document.querySelectorAll(".yii-debug-dropdown-menu > *"); + + press(items[0], "ArrowDown"); + + assert.equal(page.document.activeElement, items[1], "Focus must advance."); +}); + +test("hidden menu items are skipped by keyboard navigation", async () => { + const page = await bootDebugPage({ + body: dropdown({ + items: + 'Database' + + '' + + '' + + '' + + 'Reset', + }), + }); + + press(trigger(page), "End"); + + assert.equal( + page.document.activeElement.textContent, + "Reset", + "Only perceivable, enabled items may take focus.", + ); +}); + +test("an empty menu stays closed under keyboard navigation", async () => { + const page = await bootDebugPage({ body: dropdown({ items: "" }) }); + const event = press(trigger(page), "ArrowDown"); + + assert.equal(isOpen(page, "filters"), false, "Nothing to navigate."); + assert.equal(event.defaultPrevented, false, "The key must stay available."); +}); + +test("a dropdown without a trigger ignores arrow keys", async () => { + const page = await bootDebugPage({ body: dropdown({ trigger: false }) }); + const item = page.document.querySelector(".yii-debug-dropdown-menu > *"); + + press(item, "ArrowDown"); + + assert.equal(isOpen(page, "filters"), false, "No trigger, no navigation."); +}); + +test("a dropdown without a menu ignores arrow keys", async () => { + const page = await bootDebugPage({ body: dropdown({ menu: false }) }); + + press(trigger(page), "ArrowDown"); + + assert.equal(isOpen(page, "filters"), false, "No menu, no navigation."); +}); + +test("Escape closes the dropdown and returns focus to its trigger", async () => { + const page = await bootDebugPage({ + body: dropdown({ open: true }), + parent: "frame", + }); + + press(page.document.querySelector(".yii-debug-dropdown-menu > *"), "Escape"); + + await settle(page.window); + + assert.equal(isOpen(page, "filters"), false, "Escape must close the menu."); + assert.equal( + page.document.activeElement, + trigger(page), + "Focus must return to the trigger.", + ); + assert.equal( + page.posts.length, + 0, + "Closing a menu must not also close the drawer.", + ); +}); + +test("Escape closes a triggerless dropdown without moving focus", async () => { + const page = await bootDebugPage({ + body: dropdown({ open: true, trigger: false }), + }); + const item = page.document.querySelector(".yii-debug-dropdown-menu > *"); + + press(item, "Escape"); + + await settle(page.window); + + assert.equal(isOpen(page, "filters"), false, "Escape must close the menu."); + assert.equal( + page.document.activeElement, + page.document.body, + "There is nothing to focus.", + ); +}); + +test("Escape with no menu open asks the parent toolbar to close the drawer", async () => { + const page = await bootDebugPage({ + body: "

Panel

", + parent: "frame", + }); + + press(page.document.querySelector("p"), "Escape"); + + await settle(page.window); + + assert.deepEqual( + page.posts, + [ + { + data: { source: "yii-debug-toolbar", type: "close-drawer" }, + origin: "https://debug.test", + }, + ], + "Host toolbar must be asked to close.", + ); +}); + +test("Escape on a standalone page posts nothing", async () => { + const page = await bootDebugPage({ body: "

Panel

" }); + + press(page.document.querySelector("p"), "Escape"); + + await settle(page.window); + + assert.equal(page.posts.length, 0, "There is no drawer to close."); +}); + +test("a keydown on the document itself is ignored", async () => { + const page = await bootDebugPage({ body: dropdown() }); + + fire(page.document, "keydown", { key: "ArrowDown" }); + + assert.equal( + isOpen(page, "filters"), + false, + "A targetless key does nothing.", + ); +}); diff --git a/resources/tests/debug-grid-url.test.js b/resources/tests/debug-grid-url.test.js new file mode 100644 index 0000000..2ebed26 --- /dev/null +++ b/resources/tests/debug-grid-url.test.js @@ -0,0 +1,579 @@ +// @vitest-environment jsdom +import assert from "node:assert/strict"; +import { test, vi } from "vitest"; + +import { + bootDebugPage, + FILTER_FOCUS_KEY, + fire, + press, +} from "./debug-page-harness.js"; + +const LISTED = "https://debug.test/debug/index?page=3&sort=-time"; +const PAGE_SIZE = + "'; + +function change(element, value) { + element.value = value; + fire(element, "change"); +} + +test("choosing a page size rewrites per-page and returns to page one", async () => { + const page = await bootDebugPage({ body: PAGE_SIZE, url: LISTED }); + + change(page.document.querySelector("select"), "50"); + + assert.deepEqual( + page.navigations, + ["https://debug.test/debug/index?sort=-time&per-page=50"], + "Size must replace the page cursor, not the sort.", + ); +}); + +test("choosing the default page size drops per-page", async () => { + const page = await bootDebugPage({ + body: PAGE_SIZE, + url: "https://debug.test/debug/index?per-page=50&page=3", + }); + + change(page.document.querySelector("select"), ""); + + assert.deepEqual( + page.navigations, + ["https://debug.test/debug/index"], + "The default size must leave no trace in the URL.", + ); +}); + +test("choosing the unlimited page size drops per-page", async () => { + const page = await bootDebugPage({ + body: PAGE_SIZE, + url: "https://debug.test/debug/index?per-page=50", + }); + + change(page.document.querySelector("select"), "0"); + + assert.deepEqual( + page.navigations, + ["https://debug.test/debug/index"], + "Unlimited must be expressed by absence.", + ); +}); + +test("a change from an unrelated control is ignored", async () => { + const page = await bootDebugPage({ + body: '', + url: LISTED, + }); + + change(page.document.getElementById("sort"), "time"); + + assert.equal(page.navigations.length, 0, "Only marked selects reload."); +}); + +test("a GridView select filter applies as soon as it changes", async () => { + const page = await bootDebugPage({ + body: + '', + url: LISTED, + }); + + change(page.document.querySelector("select"), "GET"); + + assert.deepEqual( + page.navigations, + ["https://debug.test/debug/index?sort=-time&Debug%5Bmethod%5D=GET"], + "Filter must join the query without the page cursor.", + ); + assert.equal( + page.document.documentElement.getAttribute("aria-busy"), + "true", + "Page must announce the pending reload.", + ); +}); + +test("Enter applies a typed GridView filter immediately", async () => { + const page = await bootDebugPage({ + body: '', + url: LISTED, + }); + const event = press(page.document.querySelector("input"), "Enter"); + + assert.equal(event.defaultPrevented, true, "Form submission is suppressed."); + assert.deepEqual( + page.navigations, + [ + "https://debug.test/debug/index?sort=-time&Debug%5Burl%5D=%2Fsite%2Findex", + ], + "Typed value must reach the query.", + ); +}); + +test("clearing a GridView filter removes its query parameter", async () => { + const page = await bootDebugPage({ + body: '', + url: "https://debug.test/debug/index?Debug%5Burl%5D=%2Fsite&page=2", + }); + + press(page.document.querySelector("input"), "Enter"); + + assert.deepEqual( + page.navigations, + ["https://debug.test/debug/index"], + "An empty filter must leave no parameter behind.", + ); +}); + +test("re-applying the value already in the URL does not reload", async () => { + const page = await bootDebugPage({ + body: '', + url: "https://debug.test/debug/index?Debug%5Bmethod%5D=GET", + }); + + press(page.document.querySelector("input"), "Enter"); + + assert.equal( + page.navigations.length, + 0, + "An unchanged URL is not worth a trip.", + ); + assert.equal( + page.document.documentElement.getAttribute("aria-busy"), + null, + "No reload means no busy state.", + ); +}); + +test("typing applies the filter once the field goes idle", async () => { + const page = await bootDebugPage({ + body: '', + url: LISTED, + }); + + vi.useFakeTimers(); + + try { + fire(page.document.querySelector("input"), "input"); + + assert.equal( + page.navigations.length, + 0, + "Every keystroke must not reload.", + ); + + vi.advanceTimersByTime(650); + + assert.deepEqual( + page.navigations, + ["https://debug.test/debug/index?sort=-time&Debug%5Burl%5D=%2Fsite"], + "The idle field must reach the query.", + ); + } finally { + vi.useRealTimers(); + } +}); + +test("every keystroke restarts the idle wait", async () => { + const page = await bootDebugPage({ + body: '', + url: LISTED, + }); + const input = page.document.querySelector("input"); + + vi.useFakeTimers(); + + try { + fire(input, "input"); + vi.advanceTimersByTime(400); + + input.value = "/site/index"; + fire(input, "input"); + vi.advanceTimersByTime(400); + + assert.equal(page.navigations.length, 0, "The first wait must be dropped."); + + vi.advanceTimersByTime(250); + + assert.deepEqual( + page.navigations, + [ + "https://debug.test/debug/index?sort=-time&Debug%5Burl%5D=%2Fsite%2Findex", + ], + "Only the last value may reach the query.", + ); + } finally { + vi.useRealTimers(); + } +}); + +test("a filter renamed while the wait runs is dropped", async () => { + const page = await bootDebugPage({ + body: '', + url: LISTED, + }); + const input = page.document.querySelector("input"); + + vi.useFakeTimers(); + + try { + fire(input, "input"); + input.removeAttribute("name"); + vi.advanceTimersByTime(650); + + assert.equal( + page.navigations.length, + 0, + "A field that stopped being a filter must not reload.", + ); + } finally { + vi.useRealTimers(); + } +}); + +test("leaving the field flushes what was typed", async () => { + const page = await bootDebugPage({ + body: '', + url: LISTED, + }); + const input = page.document.querySelector("input"); + + vi.useFakeTimers(); + + try { + fire(input, "input"); + fire(input, "focusout"); + + assert.equal(page.navigations.length, 1, "Tabbing out must not lose text."); + + vi.advanceTimersByTime(650); + + assert.equal(page.navigations.length, 1, "The flush must cancel the wait."); + } finally { + vi.useRealTimers(); + } +}); + +test("Enter flushes the pending edit instead of applying it twice", async () => { + const page = await bootDebugPage({ + body: '', + url: LISTED, + }); + const input = page.document.querySelector("input"); + + vi.useFakeTimers(); + + try { + fire(input, "input"); + press(input, "Enter"); + + vi.advanceTimersByTime(650); + + assert.equal(page.navigations.length, 1, "Only one reload may happen."); + } finally { + vi.useRealTimers(); + } +}); + +test("a change cancels the wait started by the same field", async () => { + const page = await bootDebugPage({ + body: '', + url: LISTED, + }); + const input = page.document.querySelector("input"); + + vi.useFakeTimers(); + + try { + fire(input, "input"); + fire(input, "change"); + + assert.equal(page.navigations.length, 1, "The change must apply at once."); + + vi.advanceTimersByTime(650); + + assert.equal(page.navigations.length, 1, "The wait must be cancelled."); + } finally { + vi.useRealTimers(); + } +}); + +test("a change on another field leaves the first one waiting", async () => { + const page = await bootDebugPage({ + body: + '' + + '', + url: LISTED, + }); + const inputs = page.document.querySelectorAll("input"); + + vi.useFakeTimers(); + + try { + fire(inputs[0], "input"); + fire(inputs[1], "change"); + + assert.equal(page.navigations.length, 1, "The change must apply at once."); + + vi.advanceTimersByTime(650); + + assert.equal(page.navigations.length, 2, "The waiting field must follow."); + } finally { + vi.useRealTimers(); + } +}); + +test("a field whose name is not a Yii form input is ignored", async () => { + const page = await bootDebugPage({ + body: '', + url: LISTED, + }); + const input = page.document.querySelector("input"); + + press(input, "Enter"); + fire(input, "input"); + fire(input, "change"); + fire(input, "focusout"); + + assert.equal( + page.navigations.length, + 0, + "Foreign fields must be left alone.", + ); +}); + +test("a field without a name is ignored", async () => { + const page = await bootDebugPage({ + body: '', + url: LISTED, + }); + + press(page.document.querySelector("input"), "Enter"); + + assert.equal(page.navigations.length, 0, "A nameless field drives nothing."); +}); + +test("a submit button is never treated as a filter", async () => { + const page = await bootDebugPage({ + body: '', + url: LISTED, + }); + const button = page.document.querySelector("input"); + + press(button, "Enter"); + fire(button, "input"); + fire(button, "focusout"); + + assert.equal(page.navigations.length, 0, "Submitting is not filtering."); +}); + +test("a named control that is not an input or a select is ignored", async () => { + const page = await bootDebugPage({ + body: '', + url: LISTED, + }); + const field = page.document.querySelector("textarea"); + + press(field, "Enter"); + fire(field, "input"); + fire(field, "focusout"); + fire(field, "change"); + + assert.equal(page.navigations.length, 0, "Only fields drive the query."); +}); + +test("a key other than Enter leaves the field alone", async () => { + const page = await bootDebugPage({ + body: '', + url: LISTED, + }); + + press(page.document.querySelector("input"), "a"); + + assert.equal(page.navigations.length, 0, "Typing must not reload."); +}); + +test("leaving an untouched field does not reload", async () => { + const page = await bootDebugPage({ + body: '', + url: LISTED, + }); + + fire(page.document.querySelector("input"), "focusout"); + + assert.equal(page.navigations.length, 0, "Nothing was typed, nothing to do."); +}); + +test("the caret position is recorded before the reload", async () => { + const page = await bootDebugPage({ + body: '', + url: LISTED, + }); + const input = page.document.querySelector("input"); + + input.setSelectionRange(2, 5); + press(input, "Enter"); + + assert.deepEqual( + JSON.parse(page.session.items.get(FILTER_FOCUS_KEY)), + { end: 5, name: "Debug[url]", start: 2 }, + "Caret must survive the round trip.", + ); +}); + +test("the recorded caret is restored on the next page", async () => { + const page = await bootDebugPage({ + body: '', + sessionData: { + [FILTER_FOCUS_KEY]: JSON.stringify({ + end: 5, + name: "Debug[url]", + start: 2, + }), + }, + }); + const input = page.document.querySelector("input"); + + assert.equal(page.document.activeElement, input, "Field must regain focus."); + assert.equal(input.selectionStart, 2, "Caret must start where it was."); + assert.equal(input.selectionEnd, 5, "Caret must end where it was."); + assert.equal( + page.session.items.has(FILTER_FOCUS_KEY), + false, + "A restored position must be consumed.", + ); +}); + +test("a field that cannot place a caret is only refocused", async () => { + const page = await bootDebugPage({ + body: '', + prepare(documentValue) { + /** + * Older engines expose no selection API on some input types; the source + * must still hand the field back its focus. + */ + documentValue.querySelector("input").setSelectionRange = undefined; + }, + sessionData: { + [FILTER_FOCUS_KEY]: JSON.stringify({ + end: 5, + name: "Debug[url]", + start: 2, + }), + }, + }); + + assert.equal( + page.document.activeElement, + page.document.querySelector("input"), + "Field must regain focus.", + ); +}); + +test("a recorded position for a field that is gone is dropped", async () => { + const page = await bootDebugPage({ + body: '', + sessionData: { + [FILTER_FOCUS_KEY]: JSON.stringify({ end: 5, name: "Db[url]", start: 2 }), + }, + }); + + assert.equal( + page.document.activeElement, + page.document.body, + "Nothing to focus.", + ); + assert.equal( + page.session.items.has(FILTER_FOCUS_KEY), + false, + "A stale position must be consumed.", + ); +}); + +test("a recorded position naming a control that is not an input is dropped", async () => { + const page = await bootDebugPage({ + body: '', + sessionData: { + [FILTER_FOCUS_KEY]: JSON.stringify({ + end: 5, + name: "Debug[url]", + start: 2, + }), + }, + }); + + assert.equal( + page.document.activeElement, + page.document.body, + "Only a field may take the caret back.", + ); +}); + +test("a recorded position that is not a filter name is dropped", async () => { + const page = await bootDebugPage({ + body: '', + sessionData: { [FILTER_FOCUS_KEY]: JSON.stringify({ name: "q" }) }, + }); + + assert.equal( + page.document.activeElement, + page.document.body, + "Foreign names must be refused.", + ); +}); + +test("an unreadable recorded position is ignored", async () => { + const page = await bootDebugPage({ + body: '', + sessionData: { [FILTER_FOCUS_KEY]: "{not json" }, + }); + + assert.equal( + page.document.activeElement, + page.document.body, + "A corrupt entry must not throw.", + ); +}); + +test("a page without session storage still filters", async () => { + const page = await bootDebugPage({ + body: '', + session: "throws", + url: LISTED, + }); + + press(page.document.querySelector("input"), "Enter"); + + assert.deepEqual( + page.navigations, + ["https://debug.test/debug/index?sort=-time&Debug%5Burl%5D=%2Fsite"], + "Blocked storage must not block the reload.", + ); +}); + +test("a session storage that refuses writes still filters", async () => { + const page = await bootDebugPage({ + body: '', + session: { + getItem() { + return null; + }, + removeItem() {}, + setItem() { + throw new Error("Quota exceeded."); + }, + }, + url: LISTED, + }); + + press(page.document.querySelector("input"), "Enter"); + + assert.deepEqual( + page.navigations, + ["https://debug.test/debug/index?sort=-time&Debug%5Burl%5D=%2Fsite"], + "A full quota must not block the reload.", + ); +}); diff --git a/resources/tests/debug-live-filter.test.js b/resources/tests/debug-live-filter.test.js new file mode 100644 index 0000000..4e50b60 --- /dev/null +++ b/resources/tests/debug-live-filter.test.js @@ -0,0 +1,241 @@ +// @vitest-environment jsdom +import assert from "node:assert/strict"; +import { test } from "vitest"; + +import { bootDebugPage, fire, press } from "./debug-page-harness.js"; + +const ROWS = + "SELECT 1UPDATE users"; +const TARGET = + '' + + ROWS + + "
"; + +function filtered(page, value) { + const input = page.document.querySelector("[data-yii-debug-filter]"); + + input.value = value; + fire(input, "input"); + + return input; +} + +function status(page) { + return page.document.querySelector("[data-yii-debug-filter-status]"); +} + +test("typing hides the rows that do not match and announces the count", async () => { + const page = await bootDebugPage({ + body: + '
' + + '
' + + TARGET, + }); + const input = filtered(page, "select"); + const rows = page.document.querySelectorAll("tbody tr"); + + assert.equal(rows[0].hidden, false, "Matching row must stay."); + assert.equal(rows[1].hidden, true, "Other rows must go."); + assert.equal( + status(page).textContent, + "1 of 2 queries shown.", + "Count must name the filtered unit.", + ); + assert.equal( + status(page).id, + "db-filter-status", + "Status id must derive from the field.", + ); + assert.equal( + input.getAttribute("aria-describedby"), + "db-filter-status", + "Field must point at its own status.", + ); + assert.equal( + status(page).parentElement.className, + "yii-debug-section-header", + "Status must live in the section header.", + ); +}); + +test("the status region is reused on every keystroke", async () => { + const page = await bootDebugPage({ + body: + '
' + + '
' + + TARGET, + }); + + filtered(page, "select"); + filtered(page, ""); + + assert.equal( + page.document.querySelectorAll("[data-yii-debug-filter-status]").length, + 1, + "Only one live region may exist.", + ); + assert.equal( + status(page).textContent, + "2 of 2 queries shown.", + "Clearing must announce the full set.", + ); +}); + +test("an unnamed filter gets a numbered status region", async () => { + const page = await bootDebugPage({ + body: + '
' + + "
" + + TARGET, + }); + + filtered(page, "select"); + + assert.equal( + status(page).id, + "yii-debug-filter-status-1", + "Status id must fall back to a sequence.", + ); +}); + +test("a filter already describing its status is not described twice", async () => { + const page = await bootDebugPage({ + body: + '
' + + '' + + "
" + + TARGET, + }); + const input = filtered(page, "select"); + + assert.equal( + input.getAttribute("aria-describedby"), + "yii-debug-filter-status-1", + "Description list must stay free of duplicates.", + ); +}); + +test("a filter with no target is ignored", async () => { + const page = await bootDebugPage({ + body: "", + }); + + filtered(page, "select"); + + assert.equal(status(page), null, "Nothing to filter, nothing to announce."); +}); + +test("input from a plain field is ignored", async () => { + const page = await bootDebugPage({ + body: '' + TARGET, + }); + const input = page.document.getElementById("search"); + + input.value = "select"; + fire(input, "input"); + + assert.equal(status(page), null, "Only marked filters drive the rows."); + assert.equal( + page.document.querySelector("tbody tr").hidden, + false, + "Unmarked fields must not touch the target.", + ); +}); + +test("a scoped filter anchors its status next to itself", async () => { + const page = await bootDebugPage({ + body: + "
" + + TARGET + + "
", + }); + + filtered(page, "select"); + + assert.equal( + status(page).parentElement.getAttribute("data-yii-debug-filter-scope"), + "", + "Status must join the scope that owns the filter.", + ); +}); + +test("Escape empties a live filter and keeps the caret in it", async () => { + const page = await bootDebugPage({ + body: + '
' + + '
' + + TARGET, + }); + const input = filtered(page, "select"); + const event = press(input, "Escape"); + + assert.equal(event.defaultPrevented, true, "Escape must be consumed."); + assert.equal(input.value, "", "Field must be emptied."); + assert.equal(page.document.activeElement, input, "Caret must stay put."); + assert.equal( + status(page).textContent, + "2 of 2 queries shown.", + "Full set must be announced again.", + ); + assert.equal( + page.document.querySelectorAll("tbody tr")[1].hidden, + false, + "Every row must come back.", + ); +}); + +test("Escape on an empty live filter is left to the page", async () => { + const page = await bootDebugPage({ + body: + '
' + + '
' + + TARGET, + }); + const event = press( + page.document.querySelector("[data-yii-debug-filter]"), + "Escape", + ); + + assert.equal(event.defaultPrevented, false, "Nothing to clear."); +}); + +test("Escape empties a GridView filter cell", async () => { + const page = await bootDebugPage({ + body: + '
' + + '
', + }); + const input = page.document.querySelector(".yii-debug-grid .filters input"); + const event = press(input, "Escape"); + + assert.equal(event.defaultPrevented, true, "Escape must be consumed."); + assert.equal(input.value, "", "Cell must be emptied."); + assert.equal( + page.navigations.length, + 0, + "Clearing an unapplied filter must not reload.", + ); +}); + +test("a filter control without a parent anchors its status on itself", async () => { + const page = await bootDebugPage({ + html: + "" + + "" + + TARGET + + "", + }); + + press(page.document.documentElement, "Escape"); + + assert.equal( + status(page).parentElement, + page.document.documentElement, + "Status must fall back to the control itself.", + ); + assert.equal( + status(page).textContent, + "2 of 2 queries shown.", + "Cleared filter must announce the full set.", + ); +}); diff --git a/resources/tests/debug-page-harness.js b/resources/tests/debug-page-harness.js new file mode 100644 index 0000000..d91f41e --- /dev/null +++ b/resources/tests/debug-page-harness.js @@ -0,0 +1,313 @@ +/** + * Shared jsdom scaffolding for the debugger page bootstrap (`core/debug.js`). + * + * `debug.js` is a side-effect module: importing it runs the whole page + * bootstrap against the ambient `window` and `document` and leaves its + * listeners on that document forever. Every scenario therefore needs a pristine + * page, which `bootDebugPage()` builds from a fresh jsdom instance, publishes as + * the ambient globals and then re-imports the module against. + * + * Facilities the source assumes but jsdom does not provide are installed from + * here — never from the source — through a window facade that inherits from the + * real jsdom window: `matchMedia`, a `location` that records assignments instead + * of refusing to navigate, `parent`/`frameElement` for the drawer iframe, and a + * `navigator.clipboard`. Node's experimental `localStorage` accessor shadows the + * jsdom one, so an in-memory store is redefined over both globals. + */ +import { JSDOM } from "jsdom"; +import { vi } from "vitest"; + +export const FILTER_FOCUS_KEY = "yii-debug-grid-filter-focus"; + +const DEFAULT_URL = "https://debug.test/debug/index"; +const LOCATION_KEYS = [ + "hash", + "host", + "hostname", + "href", + "origin", + "pathname", + "port", + "protocol", + "search", +]; + +var openDom = null; + +function define(target, name, value) { + Object.defineProperty(target, name, { + configurable: true, + enumerable: true, + value: value, + writable: true, + }); +} + +/** Mirrors the real location for reads and records every assignment. */ +function createLocation(win, navigations, href) { + var location = {}; + + LOCATION_KEYS.forEach(function (key) { + Object.defineProperty(location, key, { + configurable: true, + enumerable: true, + get: function () { + return key === "href" && typeof href === "string" + ? href + : win.location[key]; + }, + set: function (value) { + navigations.push(String(value)); + }, + }); + }); + + return location; +} + +function createStorage(initial) { + var items = new Map(Object.entries(initial || {})); + + return { + items: items, + getItem(key) { + return items.has(key) ? items.get(key) : null; + }, + removeItem(key) { + items.delete(key); + }, + setItem(key, value) { + items.set(key, String(value)); + }, + }; +} + +function installSessionStorage(facade, options) { + if (options.session === "throws") { + Object.defineProperty(facade, "sessionStorage", { + configurable: true, + enumerable: true, + get() { + throw new Error("Session storage is blocked."); + }, + }); + + return null; + } + + var storage = options.session || createStorage(options.sessionData); + + define(facade, "sessionStorage", storage); + + return storage; +} + +function installMatchMedia(facade, preference) { + if (preference === undefined) { + return; + } + + define(facade, "matchMedia", function (media) { + return { matches: preference === "dark", media: media }; + }); +} + +function installFrameElement(facade, documentValue, frameElement) { + if (frameElement === "throws") { + Object.defineProperty(facade, "frameElement", { + configurable: true, + enumerable: true, + get() { + throw new Error("Cross-origin frame access is denied."); + }, + }); + + return; + } + + define( + facade, + "frameElement", + typeof frameElement === "function" ? frameElement(documentValue) : null, + ); +} + +/** + * Builds the drawer iframe the toolbar renders: a frame living inside a shadow + * root whose host carries the toolbar theme. + */ +export function shadowHostFrame(theme) { + return function (documentValue) { + var host = documentValue.createElement("div"); + var frame = documentValue.createElement("iframe"); + + if (theme !== undefined) { + host.setAttribute("data-theme", theme); + } + + documentValue.body.appendChild(host); + host.attachShadow({ mode: "open" }).appendChild(frame); + + return frame; + }; +} + +/** Builds a frame element that reports no root node, as a detached one would. */ +export function rootlessFrame() { + return function () { + return {}; + }; +} + +/** Builds a frame whose root node is a plain document, so it has no host. */ +export function hostlessFrame() { + return function (documentValue) { + var frame = documentValue.createElement("iframe"); + + documentValue.body.appendChild(frame); + + return frame; + }; +} + +/** + * Renders a page and runs the `core/debug.js` bootstrap against it. + * + * Every option describes the browser state the bootstrap reads: `body` (or the + * whole `html` document) is the server-rendered markup, `url` the address bar, + * `cookie`/`storage` the client theme memory, and `frameElement`/`parent` + * whether the page is the toolbar drawer or a standalone tab. + */ +export async function bootDebugPage(options) { + var settings = options || {}; + var navigations = []; + var posts = []; + var dom = new JSDOM( + settings.html || + "" + + (settings.body || "") + + "", + { url: settings.url || DEFAULT_URL }, + ); + var win = dom.window; + var documentValue = win.document; + var facade = Object.create(win); + var storage = createStorage(settings.storage); + + if (openDom) { + openDom.window.close(); + } + + openDom = dom; + + if (settings.documentTheme !== undefined) { + documentValue.documentElement.setAttribute( + "data-yii-debug-theme", + settings.documentTheme, + ); + } + + if (settings.cookie) { + documentValue.cookie = settings.cookie; + } + + if (settings.prepare) { + settings.prepare(documentValue); + } + + /** + * jsdom's EventTarget methods reject an inheriting receiver, so the facade + * forwards them to the window they were taken from. + */ + ["addEventListener", "dispatchEvent", "removeEventListener"].forEach( + function (name) { + define(facade, name, win[name].bind(win)); + }, + ); + + define(facade, "localStorage", storage); + define(facade, "location", createLocation(win, navigations, settings.href)); + define( + facade, + "parent", + settings.parent === "none" + ? undefined + : settings.parent === "frame" + ? { + postMessage(data, origin) { + posts.push({ data: data, origin: origin }); + }, + } + : facade, + ); + + if (settings.clipboard !== undefined) { + define(facade, "navigator", { clipboard: settings.clipboard }); + } else if (settings.navigator === "none") { + define(facade, "navigator", undefined); + } + + var session = installSessionStorage(facade, settings); + + installMatchMedia(facade, settings.matchMedia); + installFrameElement(facade, documentValue, settings.frameElement); + + Object.defineProperty(globalThis, "window", { + configurable: true, + value: facade, + writable: true, + }); + Object.defineProperty(globalThis, "document", { + configurable: true, + value: documentValue, + writable: true, + }); + Object.defineProperty(globalThis, "localStorage", { + configurable: true, + value: storage, + writable: true, + }); + + vi.resetModules(); + + await import("../src/core/debug.js"); + + return { + document: documentValue, + navigations: navigations, + posts: posts, + session: session, + storage: storage, + window: facade, + realWindow: win, + }; +} + +/** Dispatches a bubbling UI event from `element`, as the browser would. */ +export function fire(element, type, init) { + var documentValue = element.ownerDocument || element; + var view = documentValue.defaultView; + var event = + type === "keydown" + ? new view.KeyboardEvent(type, Object.assign({ bubbles: true }, init)) + : new view.Event( + type, + Object.assign({ bubbles: true, cancelable: true }, init), + ); + + element.dispatchEvent(event); + + return event; +} + +/** Presses a key on `element` and reports the dispatched event. */ +export function press(element, key) { + return fire(element, "keydown", { cancelable: true, key: key }); +} + +/** Lets pending zero-delay timers and microtasks run. */ +export function settle(windowValue) { + return new Promise(function (resolve) { + windowValue.setTimeout(resolve, 0); + }); +} diff --git a/resources/tests/debug-theme.test.js b/resources/tests/debug-theme.test.js new file mode 100644 index 0000000..28949c2 --- /dev/null +++ b/resources/tests/debug-theme.test.js @@ -0,0 +1,225 @@ +// @vitest-environment jsdom +import assert from "node:assert/strict"; +import { test } from "vitest"; + +import { + bootDebugPage, + fire, + hostlessFrame, + rootlessFrame, + shadowHostFrame, +} from "./debug-page-harness.js"; + +const TOGGLE = + ''; + +function theme(page) { + return page.document.documentElement.getAttribute("data-yii-debug-theme"); +} + +test("the parent toolbar outranks every stored preference", async () => { + const page = await bootDebugPage({ + cookie: "yii-debug-toolbar-theme=light", + documentTheme: "light", + frameElement: shadowHostFrame("dark"), + matchMedia: "light", + storage: { "yii-debug-toolbar-theme": "light" }, + url: "https://debug.test/debug/index?yii_debug_theme=light", + }); + + assert.equal(theme(page), "dark", "Drawer must follow its host toolbar."); +}); + +test("the cookie wins for a page that is not framed", async () => { + const page = await bootDebugPage({ + cookie: "yii-debug-toolbar-theme=dark", + documentTheme: "light", + storage: { "yii-debug-toolbar-theme": "light" }, + url: "https://debug.test/debug/index?yii_debug_theme=light", + }); + + assert.equal(theme(page), "dark", "Cookie is the last client write."); +}); + +test("the stored theme is used when the cookie is missing", async () => { + const page = await bootDebugPage({ + documentTheme: "light", + storage: { "yii-debug-toolbar-theme": "dark" }, + url: "https://debug.test/debug/index?yii_debug_theme=light", + }); + + assert.equal(theme(page), "dark", "Storage covers blocked cookies."); +}); + +test("the query parameter is used when no client choice is stored", async () => { + const page = await bootDebugPage({ + documentTheme: "light", + url: "https://debug.test/debug/index?yii_debug_theme=dark", + }); + + assert.equal(theme(page), "dark", "Deep link must seed a fresh client."); +}); + +test("the server-rendered attribute is used when the query has no theme", async () => { + const page = await bootDebugPage({ + documentTheme: "dark", + matchMedia: "light", + }); + + assert.equal(theme(page), "dark", "Server render outranks the system."); +}); + +test("a dark system preference is the last resort", async () => { + const page = await bootDebugPage({ matchMedia: "dark" }); + + assert.equal(theme(page), "dark", "System preference must apply."); +}); + +test("a light system preference resolves to the light theme", async () => { + const page = await bootDebugPage({ matchMedia: "light" }); + + assert.equal(theme(page), "light", "System preference must apply."); +}); + +test("a host without matchMedia resolves to the light theme", async () => { + const page = await bootDebugPage({}); + + assert.equal(theme(page), "light", "Missing query support means light."); +}); + +test("an unreadable frame element falls through to the next source", async () => { + const page = await bootDebugPage({ + cookie: "yii-debug-toolbar-theme=dark", + frameElement: "throws", + }); + + assert.equal(theme(page), "dark", "Cross-origin denial must not throw."); +}); + +test("a frame element without a root node falls through", async () => { + const page = await bootDebugPage({ + cookie: "yii-debug-toolbar-theme=dark", + frameElement: rootlessFrame(), + }); + + assert.equal(theme(page), "dark", "No root node means no host theme."); +}); + +test("a frame outside a shadow root falls through", async () => { + const page = await bootDebugPage({ + cookie: "yii-debug-toolbar-theme=dark", + frameElement: hostlessFrame(), + }); + + assert.equal(theme(page), "dark", "A document root carries no host."); +}); + +test("a shadow host without a usable theme falls through", async () => { + const page = await bootDebugPage({ + cookie: "yii-debug-toolbar-theme=dark", + frameElement: shadowHostFrame("mauve"), + }); + + assert.equal(theme(page), "dark", "Unknown host theme must be ignored."); +}); + +test("an unparsable address drops the query source and leaves links alone", async () => { + const page = await bootDebugPage({ + body: 'Database', + documentTheme: "dark", + href: "not a url", + }); + + assert.equal(theme(page), "dark", "Server render must still apply."); + assert.equal( + page.document.querySelector("a").getAttribute("href"), + "/debug/db", + "Link must stay untouched.", + ); +}); + +test("the resolved theme is persisted and carried by debug links and forms", async () => { + const page = await bootDebugPage({ + body: + 'DatabaseTop' + + '
', + matchMedia: "dark", + }); + + const links = page.document.querySelectorAll("a"); + + assert.equal( + page.storage.items.get("yii-debug-toolbar-theme"), + "dark", + "Storage must record the resolved theme.", + ); + assert.match( + page.document.cookie, + /yii-debug-toolbar-theme=dark/, + "Cookie must record the resolved theme.", + ); + assert.equal( + links[0].getAttribute("href"), + "https://debug.test/debug/db?yii_debug_theme=dark", + "Debug links must carry the theme.", + ); + assert.equal( + links[1].getAttribute("href"), + "#top", + "Fragment links must stay untouched.", + ); + assert.equal( + page.document.querySelector('form input[name="yii_debug_theme"]').value, + "dark", + "GET forms must resubmit the theme.", + ); +}); + +test("the toggle tells the parent toolbar about the new theme", async () => { + const page = await bootDebugPage({ + body: TOGGLE, + matchMedia: "light", + parent: "frame", + }); + + fire(page.document.querySelector("[data-yii-debug-theme-toggle]"), "click"); + + assert.equal(theme(page), "dark", "Toggle must flip the page theme."); + assert.deepEqual( + page.posts, + [ + { + data: { + source: "yii-debug-toolbar", + theme: "dark", + type: "theme", + }, + origin: "https://debug.test", + }, + ], + "Host toolbar must receive the new theme.", + ); +}); + +test("the toggle stays silent on a standalone page", async () => { + const page = await bootDebugPage({ body: TOGGLE, matchMedia: "light" }); + + fire(page.document.querySelector("[data-yii-debug-theme-toggle]"), "click"); + + assert.equal(theme(page), "dark", "Toggle must flip the page theme."); + assert.equal(page.posts.length, 0, "Nothing must be posted to itself."); +}); + +test("the toggle stays silent when the window has no parent", async () => { + const page = await bootDebugPage({ + body: TOGGLE, + matchMedia: "light", + parent: "none", + }); + + fire(page.document.querySelector("[data-yii-debug-theme-toggle]"), "click"); + + assert.equal(theme(page), "dark", "Toggle must flip the page theme."); + assert.equal(page.posts.length, 0, "A parentless window must be skipped."); +}); diff --git a/resources/tests/toolbar-element-ajax.test.js b/resources/tests/toolbar-element-ajax.test.js new file mode 100644 index 0000000..4e8958c --- /dev/null +++ b/resources/tests/toolbar-element-ajax.test.js @@ -0,0 +1,395 @@ +// @vitest-environment jsdom +import assert from "node:assert/strict"; +import { afterEach, test } from "vitest"; + +import { + createToolbar, + installLocalStorage, + installMatchMedia, + renderToolbar, + teardownToolbars, + toolbarPayload, +} from "./toolbar-element-harness.js"; + +var storage = installLocalStorage({ "yii-debug-toolbar-expanded": "1" }); + +installMatchMedia(); + +/** + * Detaches the fixtures and restores the expanded flag unconditionally: a + * thrown assertion must not hand the next test a collapsed bar. + */ +afterEach(() => { + teardownToolbars(); + storage.set("yii-debug-toolbar-expanded", "1"); +}); + +function request(overrides) { + return Object.assign( + { + duration: 12, + error: false, + loading: false, + method: "GET", + statusCode: 200, + url: "/api/items", + }, + overrides || {}, + ); +} + +function withRequests(requests) { + var element = renderToolbar(toolbarPayload()); + + element.ajaxRequests = requests; + element.render(); + + return element; +} + +function badgeClass(element) { + return element.shadowRoot.querySelector(".ajax-panel .metric-value") + .className; +} + +function rows(element) { + return element.shadowRoot.querySelectorAll(".ajax-popover tbody tr"); +} + +test("the AJAX chip counts every request but lists only the last twenty", () => { + var stack = []; + + for (var i = 0; i < 22; i++) { + stack.push(request({ url: "/api/items/" + i })); + } + + var element = withRequests(stack); + + assert.equal( + element.shadowRoot.querySelector(".ajax-panel .metric-value").textContent, + "22", + "Counter must reflect the whole stack.", + ); + assert.equal( + rows(element).length, + 20, + "Popover must show the newest twenty.", + ); + assert.equal( + rows(element)[0].querySelector(".ajax-url").textContent, + "/api/items/2", + "Window must start after the dropped entries.", + ); + assert.equal( + badgeClass(element), + "metric-value badge-success", + "Badge: success.", + ); + + element.remove(); +}); + +test("an in-flight request switches the chip to the loading badge", () => { + var element = withRequests([ + request(), + request({ loading: true, statusCode: undefined }), + ]); + + assert.equal( + badgeClass(element), + "metric-value badge-loading", + "Badge: loading.", + ); + assert.equal( + rows(element)[1].querySelector(".badge").className, + "badge badge-loading", + "In-flight row must carry the loading badge.", + ); + + element.remove(); +}); + +test("a recent failure turns the chip red while an older one does not", () => { + var element = withRequests([ + request({ error: true, statusCode: 500 }), + request(), + request(), + request(), + request(), + ]); + + assert.equal( + badgeClass(element), + "metric-value badge-success", + "A failure outside the recency window must not raise the badge.", + ); + + element.ajaxRequests = [ + request(), + request({ error: true, statusCode: 500 }), + request(), + ]; + element.render(); + + assert.equal( + badgeClass(element), + "metric-value badge-danger", + "Badge: danger.", + ); + + element.remove(); +}); + +test("an untracked stack renders the placeholder row", () => { + var element = withRequests([]); + + assert.equal( + rows(element)[0].querySelector(".empty").textContent, + "No AJAX requests tracked yet.", + "Placeholder must explain the empty popover.", + ); + + element.ajaxRequests = null; + element.render(); + + assert.equal( + rows(element).length, + 1, + "A missing stack must behave as empty.", + ); + assert.equal( + element.shadowRoot.querySelector(".ajax-panel").getAttribute("aria-label"), + "AJAX requests: 0", + "Label must announce an empty stack.", + ); + + element.remove(); +}); + +test("status codes map onto the badge palette", () => { + var element = withRequests([ + request({ statusCode: 204 }), + request({ statusCode: 302 }), + request({ statusCode: 404 }), + request({ statusCode: 500 }), + request({ statusCode: "n/a" }), + ]); + var badges = Array.prototype.map.call( + element.shadowRoot.querySelectorAll(".ajax-popover .badge"), + function (node) { + return node.className; + }, + ); + + assert.deepEqual( + badges, + [ + "badge badge-status-2xx", + "badge badge-status-3xx", + "badge badge-status-4xx", + "badge badge-status-5xx", + "badge badge-danger", + ], + "Palette: 2xx, 3xx, 4xx, 5xx, unparsable.", + ); + + element.remove(); +}); + +test("HTTP verbs map onto the verb palette", () => { + var element = withRequests([ + request({ method: "GET" }), + request({ method: "head" }), + request({ method: "POST" }), + request({ method: "PUT" }), + request({ method: "patch" }), + request({ method: "DELETE" }), + request({ method: "OPTIONS" }), + request({ method: undefined }), + ]); + var verbs = Array.prototype.map.call( + element.shadowRoot.querySelectorAll( + ".ajax-popover tbody tr td:first-child span", + ), + function (node) { + return node.className + ":" + node.textContent; + }, + ); + + assert.deepEqual( + verbs, + [ + "verb-get:GET", + "verb-get:head", + "verb-post:POST", + "verb-put:PUT", + "verb-put:patch", + "verb-delete:DELETE", + "verb-other:OPTIONS", + "verb-other:GET", + ], + "Palette: get, post, put, delete, other; a method-less row keeps the neutral class.", + ); + + element.remove(); +}); + +test("a profiled request links to its snapshot and an unprofiled one does not", () => { + var element = withRequests([ + request({ profile: "tag-1", profilerUrl: "/debug/view?tag=tag-1" }), + request({ profile: "tag-2" }), + ]); + var cells = element.shadowRoot.querySelectorAll( + ".ajax-popover tbody tr td:last-child", + ); + + assert.equal( + cells[0].querySelector(".ajax-link").getAttribute("href"), + "http://localhost:3000/debug/view?tag=tag-1&yii_debug_theme=light", + "Snapshot link must carry the stamped theme.", + ); + assert.equal( + cells[0].querySelector(".ajax-link").getAttribute("data-debug-url"), + "/debug/view?tag=tag-1", + "Snapshot link must double as a drawer trigger.", + ); + assert.equal( + cells[1].textContent, + "n/a", + "Unprofiled requests must read `n/a`.", + ); + + element.remove(); +}); + +test("missing status, duration and URL fall back to placeholders", () => { + var element = withRequests([ + request({ duration: undefined, statusCode: undefined, url: "