test(web): render the real UI against the real server in jsdom

jsdom (dev only): the only way to exercise Preact components here without a browser. fetch is routed to Fastify inject,
EventSource is a stub the test drives. Covers the queue flow (note validation, proceed with executor, refused decision),
live refresh, runs/apps views, starting a run.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
homelab-updaterandClaude Sonnet 5 committed 2026-09-18 23:51:19 +02:00
1 parent b83baedd21
commit 43f3b38f12
4 files changed
+726 -2

No files matched your search

+493 -1
View File
@@ -18,7 +18,8 @@
"@preact/preset-vite": "^2.10.6",
"@types/node": "^24.13.5",
"eslint": "^10.11.0",
"typescript": "^6.0.3",
"jsdom": "^30.1.0",
"typescript": "~6.0.3",
"typescript-eslint": "^8.70.0",
"vite": "^8.3.0",
"vitest": "^5.0.1"
@@ -27,6 +28,39 @@
"node": ">=24"
}
},
"node_modules/@asamuzakjp/css-color": {
"version": "7.0.0",
"resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-7.0.0.tgz",
"integrity": "sha512-IxddtGnnoidVSO6O/aiTueptbTThHvi2qGHu9/AWuD1J7i86Ox+jl+Y5SXcDH1RixzQlP2cjH/PjnlImZiAurQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@csstools/css-calc": "^3.3.0",
"@csstools/css-color-parser": "^4.2.0",
"@csstools/css-parser-algorithms": "^4.0.0",
"@csstools/css-tokenizer": "^4.0.0",
"lru-cache": "^11.5.2"
},
"engines": {
"node": "^22.22.2 || ^24.15.0 || >=26.0.0"
}
},
"node_modules/@asamuzakjp/dom-selector": {
"version": "9.2.0",
"resolved": "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-9.2.0.tgz",
"integrity": "sha512-qKvLetaSd/OTWif9JT0eu1SHBg3H7Nvp9SoMwMxqT5tmYyrxea/P1A5pIfGbnIJlOtm3yVeqw+ORxDOkjN1aEw==",
"dev": true,
"license": "MIT",
"dependencies": {
"bidi-js": "^1.1.0",
"css-tree": "^3.2.1",
"is-potential-custom-element-name": "^1.0.1",
"lru-cache": "^11.5.2"
},
"engines": {
"node": "^22.22.2 || ^24.15.0 || >=26.0.0"
}
},
"node_modules/@babel/code-frame": {
"version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz",
@@ -372,6 +406,19 @@
"node": ">=6.9.0"
}
},
"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",
@@ -396,6 +443,146 @@
"keyv": "^5.6.0"
}
},
"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.4.0",
"resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-3.4.0.tgz",
"integrity": "sha512-XQKj5B7QiZcHiegCOCAzcAOJdhGgWOHbbu62h5e5mkHnn8lWcfiJhllkqWmxu5zWR9jucPHuo1iTB56P033hcg==",
"dev": true,
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/csstools"
},
{
"type": "opencollective",
"url": "https://opencollective.com/csstools"
}
],
"license": "MIT",
"engines": {
"node": ">=20.19.0"
},
"peerDependencies": {
"@csstools/css-parser-algorithms": "^4.0.0",
"@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",
"integrity": "sha512-+B87qS7fIG3L5h3qwJ/IFbjoVoOe/bpOdh9hAjXbvx0o8ImEmUsGXN0inFOnk2ChCFgqkkGFQ+TpM5rbhkKe4w==",
"dev": true,
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/csstools"
},
{
"type": "opencollective",
"url": "https://opencollective.com/csstools"
}
],
"license": "MIT",
"engines": {
"node": ">=20.19.0"
},
"peerDependencies": {
"@csstools/css-tokenizer": "^4.0.0"
}
},
"node_modules/@csstools/css-syntax-patches-for-csstree": {
"version": "1.1.14",
"resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.1.14.tgz",
"integrity": "sha512-HpbVXyrofRXpHpgkNIjU/3EWR4WJvOkO3emNK/L6X/mTJU7bGUI3AkkpoTNXznQLp0KRjLHELTGeKI5dIkI9JQ==",
"dev": true,
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/csstools"
},
{
"type": "opencollective",
"url": "https://opencollective.com/csstools"
}
],
"license": "MIT-0",
"peerDependencies": {
"css-tree": "^3.2.1"
},
"peerDependenciesMeta": {
"css-tree": {
"optional": true
}
}
},
"node_modules/@csstools/css-tokenizer": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-4.0.1.tgz",
"integrity": "sha512-bPlN9S9O1A0euCpEWE4qnvB5YDuyYVsUTrxSgmAM1Is0j4tICHoVyOVAXfWMP/kS9ZrjvyIXWV2PmomiAXXqOw==",
"dev": true,
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/csstools"
},
{
"type": "opencollective",
"url": "https://opencollective.com/csstools"
}
],
"license": "MIT",
"engines": {
"node": ">=20.19.0"
}
},
"node_modules/@eslint-community/eslint-utils": {
"version": "4.10.1",
"resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.10.1.tgz",
@@ -524,6 +711,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/@fastify/accept-negotiator": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/@fastify/accept-negotiator/-/accept-negotiator-2.1.0.tgz",
@@ -1748,6 +1953,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/boolbase": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/boolbase/-/boolbase-1.0.0.tgz",
@@ -1911,6 +2126,20 @@
"url": "https://github.com/sponsors/fb55"
}
},
"node_modules/css-tree": {
"version": "3.2.1",
"resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.2.1.tgz",
"integrity": "sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==",
"dev": true,
"license": "MIT",
"dependencies": {
"mdn-data": "2.27.1",
"source-map-js": "^1.2.1"
},
"engines": {
"node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0"
}
},
"node_modules/css-what": {
"version": "6.2.2",
"resolved": "https://registry.npmjs.org/css-what/-/css-what-6.2.2.tgz",
@@ -1924,6 +2153,35 @@
"url": "https://github.com/sponsors/fb55"
}
},
"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",
@@ -1942,6 +2200,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",
@@ -2584,6 +2849,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/http-errors": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz",
@@ -2662,6 +2940,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/isexe": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz",
@@ -2676,6 +2961,46 @@
"dev": true,
"license": "MIT"
},
"node_modules/jsdom": {
"version": "30.1.0",
"resolved": "https://registry.npmjs.org/jsdom/-/jsdom-30.1.0.tgz",
"integrity": "sha512-h/Q+H+KmlGf49ITw3TrykSVbg04S75S2O7ZtJc9gMGOWBrQRZmx5AJIKLtJ01e6Kszl+/tpFthCVqxNifxhoqg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@asamuzakjp/css-color": "^7.0.0",
"@asamuzakjp/dom-selector": "^9.1.2",
"@bramus/specificity": "^2.4.2",
"@csstools/css-syntax-patches-for-csstree": "^1.1.13",
"@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",
"tough-cookie": "^6.0.2",
"undici": "^8.10.2",
"w3c-xmlserializer": "^5.0.0",
"webidl-conversions": "^8.0.1",
"whatwg-mimetype": "^5.0.0",
"whatwg-url": "^17.1.1",
"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",
@@ -3110,6 +3435,13 @@
"@jridgewell/sourcemap-codec": "^1.5.5"
}
},
"node_modules/mdn-data": {
"version": "2.27.1",
"resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.27.1.tgz",
"integrity": "sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==",
"dev": true,
"license": "CC0-1.0"
},
"node_modules/mime": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/mime/-/mime-3.0.0.tgz",
@@ -3286,6 +3618,32 @@
"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/parse5/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/path-exists": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz",
@@ -3596,6 +3954,19 @@
"node": ">=10"
}
},
"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/secure-json-parse": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/secure-json-parse/-/secure-json-parse-4.1.0.tgz",
@@ -3802,6 +4173,26 @@
"url": "https://github.com/sponsors/SuperchupuDev"
}
},
"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/toad-cache": {
"version": "3.7.4",
"resolved": "https://registry.npmjs.org/toad-cache/-/toad-cache-3.7.4.tgz",
@@ -3820,6 +4211,32 @@
"node": ">=0.6"
}
},
"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/ts-api-utils": {
"version": "2.5.0",
"resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.5.0.tgz",
@@ -3884,6 +4301,16 @@
"typescript": ">=4.8.4 <6.1.0"
}
},
"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/undici-types": {
"version": "7.18.2",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz",
@@ -4121,6 +4548,54 @@
"@jridgewell/sourcemap-codec": "^1.6.0"
}
},
"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/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",
@@ -4164,6 +4639,23 @@
"node": ">=0.10.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/yallist": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz",
+1
View File
@@ -30,6 +30,7 @@
"@preact/preset-vite": "^2.10.6",
"@types/node": "^24.13.5",
"eslint": "^10.11.0",
"jsdom": "^30.1.0",
"typescript": "~6.0.3",
"typescript-eslint": "^8.70.0",
"vite": "^8.3.0",
+231
View File
@@ -0,0 +1,231 @@
// @vitest-environment jsdom
import { render } from 'preact';
import type { FastifyInstance } from 'fastify';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { createDemoHandlers } from '../app/demo/handlers.ts';
import { createDemoWorld, seedDemoConfig } from '../app/demo/world.ts';
import { createDriver } from '../app/driver.ts';
import { startManualRun } from '../app/use-cases.ts';
import { createChangeHub } from '../http/change-hub.ts';
import type { ChangeHub } from '../http/change-hub.ts';
import { buildServer } from '../http/server.ts';
import { FakeClock } from '../testing/fake-clock.ts';
import { RecordingNotifier } from '../testing/fakes.ts';
import { createTestDatabase } from '../testing/test-db.ts';
import { App } from './app.tsx';
// The real UI on the real server: `fetch` is routed to Fastify's inject, EventSource is a stub the test drives.
class FakeEventSource {
static instances: FakeEventSource[] = [];
onopen: (() => void) | null = null;
onerror: (() => void) | null = null;
listeners = new Map<string, () => void>();
url: string;
constructor(url: string) {
this.url = url;
FakeEventSource.instances.push(this);
}
addEventListener(type: string, listener: () => void) {
this.listeners.set(type, listener);
}
close() {
this.listeners.clear();
}
emit(type: string) {
this.listeners.get(type)?.();
}
}
let server: FastifyInstance;
let hub: ChangeHub;
let requests: { method: string; url: string; body?: unknown }[];
let container: HTMLElement;
beforeEach(async () => {
FakeEventSource.instances = [];
requests = [];
const { store, appConfig } = createTestDatabase();
seedDemoConfig(appConfig);
const clock = new FakeClock();
hub = createChangeHub();
server = await buildServer({ store, appConfig, clock, hub, pollChangesMs: 0 });
const driver = createDriver({ store, clock, notifier: new RecordingNotifier(), handlers: createDemoHandlers({ appConfig, world: createDemoWorld() }) });
startManualRun(store, clock);
await driver.drain(new AbortController().signal);
vi.stubGlobal('EventSource', FakeEventSource);
vi.stubGlobal('fetch', async (input: string, init?: RequestInit) => {
const method = init?.method ?? 'GET';
const body: unknown = typeof init?.body === 'string' ? JSON.parse(init.body) : undefined;
requests.push({ method, url: input, ...(body === undefined ? {} : { body }) });
const response = await server.inject({ method: method as 'GET' | 'POST', url: input, ...(body === undefined ? {} : { payload: body as object }) });
return new Response(response.body, { status: response.statusCode, headers: { 'content-type': 'application/json' } });
});
window.location.hash = '';
container = document.createElement('div');
document.body.replaceChildren(container);
render(<App />, container);
});
afterEach(async () => {
render(null, container);
vi.unstubAllGlobals();
await server.close();
});
/** Polls until the assertion holds: the UI updates asynchronously after fetches resolve. */
async function eventually<T>(check: () => T, timeoutMs = 2000): Promise<T> {
const deadline = Date.now() + timeoutMs;
for (;;) {
try {
return check();
} catch (cause) {
if (Date.now() > deadline) throw cause;
await new Promise((resolve) => setTimeout(resolve, 10));
}
}
}
const text = () => container.textContent;
const cards = () => [...container.querySelectorAll('article.card')];
const button = (root: ParentNode, label: string) => {
const found = [...root.querySelectorAll('button')].find((candidate) => candidate.textContent === label);
if (found === undefined) throw new Error(`no button "${label}"`);
return found;
};
const nextTask = () => new Promise((resolve) => setTimeout(resolve, 0));
/** Like a person: type, and let the UI render before the next click. */
async function type(input: HTMLInputElement, value: string) {
input.value = value;
input.dispatchEvent(new Event('input', { bubbles: true }));
await nextTask();
}
async function choose(select: HTMLSelectElement, value: string) {
select.value = value;
select.dispatchEvent(new Event('change', { bubbles: true }));
await nextTask();
}
describe('decision queue', () => {
it('shows what is waiting for the owner, with reason and detail, and a count in the navigation', async () => {
await eventually(() => expect(cards()).toHaveLength(3));
const bazarr = cards().find((card) => card.textContent.includes('bazarr'))!;
expect(bazarr.textContent).toContain('version above auto');
expect(bazarr.textContent).toContain('1.5.0 is a minor update, auto allows patch');
expect(container.querySelector('nav .count')?.textContent).toBe('3');
expect(text()).not.toContain('demo-secret-value');
});
it('offers only the decisions that make sense for the step', async () => {
await eventually(() => expect(cards()).toHaveLength(3));
const labels = (name: string) => [...cards().find((card) => card.textContent.includes(name))!.querySelectorAll('button')].map((b) => b.textContent);
expect(labels('bazarr')).toEqual(['Proceed', 'Retry', 'Roll back', 'Skip', 'Just note']);
expect(labels('paperless')).toEqual(['Retry', 'Roll back', 'Skip', 'Just note']); // it proposes nothing to proceed to
});
it('asks for a note before sending anything', async () => {
await eventually(() => expect(cards()).toHaveLength(3));
button(cards()[0]!, 'Proceed').click();
await eventually(() => expect(text()).toContain('a short note is required'));
expect(requests.some((r) => r.method === 'POST')).toBe(false);
});
it('rejects a note longer than ten words in the browser', async () => {
await eventually(() => expect(cards()).toHaveLength(3));
await type(cards()[0]!.querySelector('input')!, 'one two three four five six seven eight nine ten eleven');
button(cards()[0]!, 'Skip').click();
await eventually(() => expect(text()).toContain('at most 10 words'));
expect(requests.some((r) => r.method === 'POST')).toBe(false);
});
it('proceed sends the decision with the chosen executor and the answered card leaves the queue', async () => {
await eventually(() => expect(cards()).toHaveLength(3));
const bazarr = cards().find((card) => card.textContent.includes('bazarr'))!;
await type(bazarr.querySelector('input')!, 'minor bump is fine');
await choose(bazarr.querySelector('select')!, 'opus');
button(bazarr, 'Proceed').click();
await eventually(() => expect(cards()).toHaveLength(2));
const post = requests.find((r) => r.method === 'POST')!;
expect(post.url).toMatch(/^\/api\/steps\/\d+\/resolve$/);
expect(post.body).toEqual({ action: 'proceed', resolution: 'minor bump is fine', executor: 'opus' });
expect(text()).not.toContain('version above auto');
expect(container.querySelector('nav .count')?.textContent).toBe('2');
});
it('shows the server’s message when a decision is refused', async () => {
await eventually(() => expect(cards()).toHaveLength(3));
const paperless = cards().find((card) => card.textContent.includes('paperless'))!;
// decided elsewhere between page load and click
const [step] = (await server.inject('/api/steps?app=paperless&status=error')).json<{ id: number }[]>();
await server.inject({ method: 'POST', url: `/api/steps/${String(step!.id)}/resolve`, payload: { action: 'skip', resolution: 'done elsewhere' } });
await type(paperless.querySelector('input')!, 'retry it');
button(paperless, 'Retry').click();
await eventually(() => expect(text()).toContain('step is not waiting for a decision'));
});
});
describe('live updates', () => {
it('shows the connection state', async () => {
await eventually(() => expect(FakeEventSource.instances).toHaveLength(1));
expect(text()).toContain('connecting');
FakeEventSource.instances[0]!.onopen?.();
await eventually(() => expect(text()).toContain('live'));
FakeEventSource.instances[0]!.onerror?.();
await eventually(() => expect(text()).toContain('offline'));
});
it('refreshes the view when the server announces a change, without any click', async () => {
await eventually(() => expect(cards()).toHaveLength(3));
const [step] = (await server.inject('/api/steps?app=grafana&status=stop')).json<{ id: number }[]>();
await server.inject({ method: 'POST', url: `/api/steps/${String(step!.id)}/resolve`, payload: { action: 'skip', resolution: 'not now' } });
expect(cards()).toHaveLength(3); // nothing told the page yet
FakeEventSource.instances[0]!.emit('changed');
await eventually(() => expect(cards()).toHaveLength(2));
});
});
describe('other views', () => {
it('lists runs with counts and opens a run with its steps', async () => {
window.location.hash = '#/runs';
await eventually(() => expect(container.querySelector('h1')?.textContent).toBe('Runs'));
const link = await eventually(() => {
const found = container.querySelector<HTMLAnchorElement>('tbody a');
if (found === null) throw new Error('no run yet');
return found;
});
expect(container.querySelector('tbody')?.textContent).toContain('stop');
window.location.hash = new URL(link.href).hash;
await eventually(() => expect(container.querySelectorAll('tbody tr').length).toBeGreaterThan(20));
expect(text()).toContain('image_tags');
expect(text()).toContain('$0.0');
});
it('shows every app with its latest step, and an app’s history', async () => {
window.location.hash = '#/apps';
await eventually(() => expect(container.querySelectorAll('tbody tr')).toHaveLength(4));
const row = [...container.querySelectorAll('tbody tr')].find((r) => r.textContent.includes('grafana'))!;
expect(row.textContent).toContain('verify failed');
window.location.hash = '#/apps/grafana';
await eventually(() => expect(container.querySelector('h1')?.textContent).toBe('grafana'));
await eventually(() => expect(container.querySelectorAll('tbody tr').length).toBeGreaterThan(5));
});
it('starts a manual run from the header and opens it', async () => {
button(container, 'Start manual run').click();
await eventually(() => expect(window.location.hash).toMatch(/^#\/runs\/manual-/));
await eventually(() => expect(text()).toContain('plan'));
});
it('says so for an unknown page', async () => {
window.location.hash = '#/nowhere';
await eventually(() => expect(text()).toContain('Page not found'));
});
});
+1 -1
View File
@@ -2,7 +2,7 @@ import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
include: ['src/**/*.test.ts', 'tools/**/*.test.ts'],
include: ['src/**/*.test.{ts,tsx}', 'tools/**/*.test.ts'],
environment: 'node',
},
});