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:
1 parent
b83baedd21
commit
43f3b38f12
4 files changed
+726
-2
No files matched your search
Generated
+493
-1
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
@@ -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',
|
||||
},
|
||||
});
|
||||
Reference in new issue
Block a user