From 4334b40fa9adfd9433e9d097cc97721b4278a270 Mon Sep 17 00:00:00 2001 From: LordMathis Date: Sat, 26 Jul 2025 19:20:09 +0200 Subject: [PATCH] Implement basic tests for webui --- webui/package-lock.json | 1384 ++++++++++++++++- webui/package.json | 13 +- webui/src/__tests__/App.test.tsx | 186 +++ webui/src/components/Header.tsx | 3 +- webui/src/components/InstanceCard.tsx | 5 + webui/src/components/InstanceList.tsx | 2 +- webui/src/components/InstanceModal.tsx | 179 ++- .../__tests__/InstanceCard.test.tsx | 339 ++++ .../__tests__/InstanceList.test.tsx | 246 +++ .../__tests__/InstanceModal.test.tsx | 382 +++++ .../__tests__/InstancesContext.test.tsx | 395 +++++ webui/src/lib/__tests__/api.test.ts | 60 + webui/src/lib/api.ts | 132 +- webui/src/test/setup.ts | 10 + webui/vite.config.ts | 10 +- 15 files changed, 3188 insertions(+), 158 deletions(-) create mode 100644 webui/src/__tests__/App.test.tsx create mode 100644 webui/src/components/__tests__/InstanceCard.test.tsx create mode 100644 webui/src/components/__tests__/InstanceList.test.tsx create mode 100644 webui/src/components/__tests__/InstanceModal.test.tsx create mode 100644 webui/src/contexts/__tests__/InstancesContext.test.tsx create mode 100644 webui/src/lib/__tests__/api.test.ts create mode 100644 webui/src/test/setup.ts diff --git a/webui/package-lock.json b/webui/package-lock.json index c295122..e1afe7f 100644 --- a/webui/package-lock.json +++ b/webui/package-lock.json @@ -24,15 +24,28 @@ "zod": "^4.0.5" }, "devDependencies": { + "@testing-library/jest-dom": "^6.6.3", + "@testing-library/react": "^16.3.0", + "@testing-library/user-event": "^14.6.1", "@types/node": "^24.0.15", "@types/react": "^19.1.8", "@types/react-dom": "^19.1.6", "@vitejs/plugin-react": "^4.7.0", + "@vitest/ui": "^3.2.4", + "jsdom": "^26.1.0", "tw-animate-css": "^1.3.5", "typescript": "^5.8.3", - "vite": "^7.0.5" + "vite": "^7.0.5", + "vitest": "^3.2.4" } }, + "node_modules/@adobe/css-tools": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/@adobe/css-tools/-/css-tools-4.4.3.tgz", + "integrity": "sha512-VQKMkwriZbaOgVCby1UDY/LDk5fIjhQicCvVPFqfe+69fWaPWydbWJ3wRt59/YzIwda1I81loas3oCoHxnqvdA==", + "dev": true, + "license": "MIT" + }, "node_modules/@ampproject/remapping": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/@ampproject/remapping/-/remapping-2.3.0.tgz", @@ -46,6 +59,27 @@ "node": ">=6.0.0" } }, + "node_modules/@asamuzakjp/css-color": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-3.2.0.tgz", + "integrity": "sha512-K1A6z8tS3XsmCMM86xoWdn7Fkdn9m6RSVtocUrJYIwZnFVkng/PvkEoWtOWmP+Scc6saYWHWZYbndEEXxl24jw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@csstools/css-calc": "^2.1.3", + "@csstools/css-color-parser": "^3.0.9", + "@csstools/css-parser-algorithms": "^3.0.4", + "@csstools/css-tokenizer": "^3.0.3", + "lru-cache": "^10.4.3" + } + }, + "node_modules/@asamuzakjp/css-color/node_modules/lru-cache": { + "version": "10.4.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", + "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", + "dev": true, + "license": "ISC" + }, "node_modules/@babel/code-frame": { "version": "7.27.1", "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.27.1.tgz", @@ -280,6 +314,16 @@ "@babel/core": "^7.0.0-0" } }, + "node_modules/@babel/runtime": { + "version": "7.28.2", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.28.2.tgz", + "integrity": "sha512-KHp2IflsnGywDjBWDkR9iEqiWSpc8GIi0lgTT3mOElT0PP1tG26P4tmFI2YvAdzgq9RGyoHZQEIEdZy6Ec5xCA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, "node_modules/@babel/template": { "version": "7.27.2", "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.27.2.tgz", @@ -328,6 +372,121 @@ "node": ">=6.9.0" } }, + "node_modules/@csstools/color-helpers": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-5.0.2.tgz", + "integrity": "sha512-JqWH1vsgdGcw2RR6VliXXdA0/59LttzlU8UlRT/iUUsEeWfYq8I+K0yhihEUTTHLRm1EXvpsCx3083EU15ecsA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "engines": { + "node": ">=18" + } + }, + "node_modules/@csstools/css-calc": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-2.1.4.tgz", + "integrity": "sha512-3N8oaj+0juUw/1H3YwmDDJXCgTB1gKU6Hc/bB502u9zR0q2vd786XJH9QfrKIEgFlZmhZiq6epXl4rHqhzsIgQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^3.0.5", + "@csstools/css-tokenizer": "^3.0.4" + } + }, + "node_modules/@csstools/css-color-parser": { + "version": "3.0.10", + "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-3.0.10.tgz", + "integrity": "sha512-TiJ5Ajr6WRd1r8HSiwJvZBiJOqtH86aHpUjq5aEKWHiII2Qfjqd/HCWKPOW8EP4vcspXbHnXrwIDlu5savQipg==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "dependencies": { + "@csstools/color-helpers": "^5.0.2", + "@csstools/css-calc": "^2.1.4" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^3.0.5", + "@csstools/css-tokenizer": "^3.0.4" + } + }, + "node_modules/@csstools/css-parser-algorithms": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-3.0.5.tgz", + "integrity": "sha512-DaDeUkXZKjdGhgYaHNJTV9pV7Y9B3b644jCLs9Upc3VeNGg6LWARAT6O+Q+/COo+2gg/bM5rhpMAtf70WqfBdQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@csstools/css-tokenizer": "^3.0.4" + } + }, + "node_modules/@csstools/css-tokenizer": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-3.0.4.tgz", + "integrity": "sha512-Vd/9EVDiu6PPJt9yAh6roZP6El1xHrdvIVGjyBsHR0RYwNHgL7FJPyIIW4fANJNG6FtyZfvlRPpFI4ZM/lubvw==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/@esbuild/aix-ppc64": { "version": "0.25.8", "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.8.tgz", @@ -791,6 +950,13 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "node_modules/@polka/url": { + "version": "1.0.0-next.29", + "resolved": "https://registry.npmjs.org/@polka/url/-/url-1.0.0-next.29.tgz", + "integrity": "sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww==", + "dev": true, + "license": "MIT" + }, "node_modules/@radix-ui/primitive": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.1.2.tgz", @@ -1737,6 +1903,119 @@ "vite": "^5.2.0 || ^6 || ^7" } }, + "node_modules/@testing-library/dom": { + "version": "10.4.0", + "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.0.tgz", + "integrity": "sha512-pemlzrSESWbdAloYml3bAJMEfNh1Z7EduzqPKprCH5S341frlpYnUEW0H72dLxa6IsYr+mPno20GiSm+h9dEdQ==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/code-frame": "^7.10.4", + "@babel/runtime": "^7.12.5", + "@types/aria-query": "^5.0.1", + "aria-query": "5.3.0", + "chalk": "^4.1.0", + "dom-accessibility-api": "^0.5.9", + "lz-string": "^1.5.0", + "pretty-format": "^27.0.2" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@testing-library/jest-dom": { + "version": "6.6.3", + "resolved": "https://registry.npmjs.org/@testing-library/jest-dom/-/jest-dom-6.6.3.tgz", + "integrity": "sha512-IteBhl4XqYNkM54f4ejhLRJiZNqcSCoXUOG2CPK7qbD322KjQozM4kHQOfkG2oln9b9HTYqs+Sae8vBATubxxA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@adobe/css-tools": "^4.4.0", + "aria-query": "^5.0.0", + "chalk": "^3.0.0", + "css.escape": "^1.5.1", + "dom-accessibility-api": "^0.6.3", + "lodash": "^4.17.21", + "redent": "^3.0.0" + }, + "engines": { + "node": ">=14", + "npm": ">=6", + "yarn": ">=1" + } + }, + "node_modules/@testing-library/jest-dom/node_modules/chalk": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-3.0.0.tgz", + "integrity": "sha512-4D3B6Wf41KOYRFdszmDqMCGq5VV/uMAB273JILmO+3jAlh8X4qDtdtgCR3fxtbLEMzSx22QdhnDcJvu2u1fVwg==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/@testing-library/jest-dom/node_modules/dom-accessibility-api": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.6.3.tgz", + "integrity": "sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@testing-library/react": { + "version": "16.3.0", + "resolved": "https://registry.npmjs.org/@testing-library/react/-/react-16.3.0.tgz", + "integrity": "sha512-kFSyxiEDwv1WLl2fgsq6pPBbw5aWKrsY2/noi1Id0TK0UParSF62oFQFGHXIyaG4pp2tEub/Zlel+fjjZILDsw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.12.5" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@testing-library/dom": "^10.0.0", + "@types/react": "^18.0.0 || ^19.0.0", + "@types/react-dom": "^18.0.0 || ^19.0.0", + "react": "^18.0.0 || ^19.0.0", + "react-dom": "^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@testing-library/user-event": { + "version": "14.6.1", + "resolved": "https://registry.npmjs.org/@testing-library/user-event/-/user-event-14.6.1.tgz", + "integrity": "sha512-vq7fv0rnt+QTXgPxr5Hjc210p6YKq2kmdziLgnsZGgLJ9e6VAShx1pACLuRjd/AS/sr7phAR58OIIpf0LlmQNw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12", + "npm": ">=6" + }, + "peerDependencies": { + "@testing-library/dom": ">=7.21.4" + } + }, + "node_modules/@types/aria-query": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz", + "integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==", + "dev": true, + "license": "MIT", + "peer": true + }, "node_modules/@types/babel__core": { "version": "7.20.5", "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", @@ -1782,6 +2061,23 @@ "@babel/types": "^7.20.7" } }, + "node_modules/@types/chai": { + "version": "5.2.2", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.2.tgz", + "integrity": "sha512-8kB30R7Hwqf40JPiKhVzodJs2Qc1ZJ5zuT3uzw5Hq/dhNCl3G3l83jfpdI1e20BP348+fV7VIL/+FxaXkqBmWg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*" + } + }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/estree": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", @@ -1839,6 +2135,180 @@ "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" } }, + "node_modules/@vitest/expect": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-3.2.4.tgz", + "integrity": "sha512-Io0yyORnB6sikFlt8QW5K7slY4OjqNX9jmJQ02QDda8lyM6B5oNgVWoSoKPac8/kgnCUzuHQKrSLtu/uOqqrig==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/chai": "^5.2.2", + "@vitest/spy": "3.2.4", + "@vitest/utils": "3.2.4", + "chai": "^5.2.0", + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-3.2.4.tgz", + "integrity": "sha512-46ryTE9RZO/rfDd7pEqFl7etuyzekzEhUbTW3BvmeO/BcCMEgq59BKhek3dXDWgAj4oMK6OZi+vRr1wPW6qjEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "3.2.4", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.17" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/pretty-format": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-3.2.4.tgz", + "integrity": "sha512-IVNZik8IVRJRTr9fxlitMKeJeXFFFN0JaB9PHPGQ8NKQbGpfjlTx9zO4RefN8gp7eqjNy8nyK3NZmBzOPeIxtA==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-3.2.4.tgz", + "integrity": "sha512-oukfKT9Mk41LreEW09vt45f8wx7DordoWUZMYdY/cyAk7w5TWkTRCNZYF7sX7n2wB7jyGAl74OxgwhPgKaqDMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "3.2.4", + "pathe": "^2.0.3", + "strip-literal": "^3.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-3.2.4.tgz", + "integrity": "sha512-dEYtS7qQP2CjU27QBC5oUOxLE/v5eLkGqPE0ZKEIDGMs4vKWe7IjgLOeauHsR0D5YuuycGRO5oSRXnwnmA78fQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "3.2.4", + "magic-string": "^0.30.17", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-3.2.4.tgz", + "integrity": "sha512-vAfasCOe6AIK70iP5UD11Ac4siNUNJ9i/9PZ3NKx07sG6sUxeag1LWdNrMWeKKYBLlzuK+Gn65Yd5nyL6ds+nw==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyspy": "^4.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/ui": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/ui/-/ui-3.2.4.tgz", + "integrity": "sha512-hGISOaP18plkzbWEcP/QvtRW1xDXF2+96HbEX6byqQhAUbiS5oH6/9JwW+QsQCIYON2bI6QZBF+2PvOmrRZ9wA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "3.2.4", + "fflate": "^0.8.2", + "flatted": "^3.3.3", + "pathe": "^2.0.3", + "sirv": "^3.0.1", + "tinyglobby": "^0.2.14", + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "vitest": "3.2.4" + } + }, + "node_modules/@vitest/utils": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-3.2.4.tgz", + "integrity": "sha512-fB2V0JFrQSMsCo9HiSq3Ezpdv4iYaXRG1Sx8edX3MwxfyNn83mKiGzOcH+Fkxt4MHxr3y42fQi1oeAInqgX2QA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "3.2.4", + "loupe": "^3.1.4", + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/agent-base": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", + "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, + "node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, "node_modules/aria-hidden": { "version": "1.2.6", "resolved": "https://registry.npmjs.org/aria-hidden/-/aria-hidden-1.2.6.tgz", @@ -1851,6 +2321,26 @@ "node": ">=10" } }, + "node_modules/aria-query": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.0.tgz", + "integrity": "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "dequal": "^2.0.3" + } + }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, "node_modules/browserslist": { "version": "4.25.1", "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.25.1.tgz", @@ -1884,6 +2374,16 @@ "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" } }, + "node_modules/cac": { + "version": "6.7.14", + "resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz", + "integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/caniuse-lite": { "version": "1.0.30001727", "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001727.tgz", @@ -1905,6 +2405,51 @@ ], "license": "CC-BY-4.0" }, + "node_modules/chai": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/chai/-/chai-5.2.1.tgz", + "integrity": "sha512-5nFxhUrX0PqtyogoYOA8IPswy5sZFTOsBFl/9bNsmDLgsxYTzSZQJDPppDnZPTQbzSEm0hqGjWPzRemQCYbD6A==", + "dev": true, + "license": "MIT", + "dependencies": { + "assertion-error": "^2.0.1", + "check-error": "^2.1.1", + "deep-eql": "^5.0.1", + "loupe": "^3.1.0", + "pathval": "^2.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/check-error": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/check-error/-/check-error-2.1.1.tgz", + "integrity": "sha512-OAlb+T7V4Op9OwdkjmguYRqncdlx5JiofwOAUkmTF+jNdHwzTaTs4sRAGpzLF3oOz5xAyDGrPgeIDFQmDOTiJw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 16" + } + }, "node_modules/chownr": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/chownr/-/chownr-3.0.0.tgz", @@ -1935,6 +2480,26 @@ "node": ">=6" } }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true, + "license": "MIT" + }, "node_modules/convert-source-map": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", @@ -1942,6 +2507,27 @@ "dev": true, "license": "MIT" }, + "node_modules/css.escape": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/css.escape/-/css.escape-1.5.1.tgz", + "integrity": "sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg==", + "dev": true, + "license": "MIT" + }, + "node_modules/cssstyle": { + "version": "4.6.0", + "resolved": "https://registry.npmjs.org/cssstyle/-/cssstyle-4.6.0.tgz", + "integrity": "sha512-2z+rWdzbbSZv6/rhtvzvqeZQHrBaqgogqt85sqFNbabZOuFbCVFb8kPeEtZjiKkbrm395irpNKiYeFeLiQnFPg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/css-color": "^3.2.0", + "rrweb-cssom": "^0.8.0" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/csstype": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz", @@ -1949,6 +2535,20 @@ "devOptional": true, "license": "MIT" }, + "node_modules/data-urls": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-5.0.0.tgz", + "integrity": "sha512-ZYP5VBHshaDAiVZxjbRVcFJpc+4xGgT0bK3vzy1HLN8jTO975HEbuYzZJcHoQEY5K1a0z8YayJkyVETa08eNTg==", + "dev": true, + "license": "MIT", + "dependencies": { + "whatwg-mimetype": "^4.0.0", + "whatwg-url": "^14.0.0" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/debug": { "version": "4.4.1", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.1.tgz", @@ -1967,6 +2567,33 @@ } } }, + "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-eql": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.2.tgz", + "integrity": "sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/dequal": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", + "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/detect-libc": { "version": "2.0.4", "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.0.4.tgz", @@ -1982,6 +2609,14 @@ "integrity": "sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ==", "license": "MIT" }, + "node_modules/dom-accessibility-api": { + "version": "0.5.16", + "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz", + "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==", + "dev": true, + "license": "MIT", + "peer": true + }, "node_modules/electron-to-chromium": { "version": "1.5.187", "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.187.tgz", @@ -2002,6 +2637,26 @@ "node": ">=10.13.0" } }, + "node_modules/entities": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz", + "integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/es-module-lexer": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz", + "integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==", + "dev": true, + "license": "MIT" + }, "node_modules/esbuild": { "version": "0.25.8", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.8.tgz", @@ -2053,6 +2708,26 @@ "node": ">=6" } }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/expect-type": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.2.2.tgz", + "integrity": "sha512-JhFGDVJ7tmDJItKhYgJCGLOWjuK9vPxiXoUFLwLDc99NlmklilbiQJwoctZtt13+xMw91MCk/REan6MWHqDjyA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/fdir": { "version": "6.4.6", "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.4.6.tgz", @@ -2067,6 +2742,20 @@ } } }, + "node_modules/fflate": { + "version": "0.8.2", + "resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.2.tgz", + "integrity": "sha512-cPJU47OaAoCbg0pBvzsgpTPhmhqI5eJjh/JIu8tPj5q+T7iLvW/JAYUqmE7KOB4R1ZyEhzBaIQpQpardBF5z8A==", + "dev": true, + "license": "MIT" + }, + "node_modules/flatted": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.3.3.tgz", + "integrity": "sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==", + "dev": true, + "license": "ISC" + }, "node_modules/fsevents": { "version": "2.3.3", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", @@ -2106,6 +2795,87 @@ "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", "license": "ISC" }, + "node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/html-encoding-sniffer": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-4.0.0.tgz", + "integrity": "sha512-Y22oTqIU4uuPgEemfz7NDJz6OeKf12Lsu+QC+s3BVpda64lTiMYCyGwg5ki4vFxkMwQdeZDl2adZoqUgdFuTgQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "whatwg-encoding": "^3.1.1" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/http-proxy-agent": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", + "integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.0", + "debug": "^4.3.4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/https-proxy-agent": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", + "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/iconv-lite": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", + "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/indent-string": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/indent-string/-/indent-string-4.0.0.tgz", + "integrity": "sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "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/jiti": { "version": "2.4.2", "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.4.2.tgz", @@ -2122,6 +2892,46 @@ "dev": true, "license": "MIT" }, + "node_modules/jsdom": { + "version": "26.1.0", + "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-26.1.0.tgz", + "integrity": "sha512-Cvc9WUhxSMEo4McES3P7oK3QaXldCfNWp7pl2NNeiIFlCoLr3kfq9kb1fxftiwk1FLV7CvpvDfonxtzUDeSOPg==", + "dev": true, + "license": "MIT", + "dependencies": { + "cssstyle": "^4.2.1", + "data-urls": "^5.0.0", + "decimal.js": "^10.5.0", + "html-encoding-sniffer": "^4.0.0", + "http-proxy-agent": "^7.0.2", + "https-proxy-agent": "^7.0.6", + "is-potential-custom-element-name": "^1.0.1", + "nwsapi": "^2.2.16", + "parse5": "^7.2.1", + "rrweb-cssom": "^0.8.0", + "saxes": "^6.0.0", + "symbol-tree": "^3.2.4", + "tough-cookie": "^5.1.1", + "w3c-xmlserializer": "^5.0.0", + "webidl-conversions": "^7.0.0", + "whatwg-encoding": "^3.1.1", + "whatwg-mimetype": "^4.0.0", + "whatwg-url": "^14.1.1", + "ws": "^8.18.0", + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "canvas": "^3.0.0" + }, + "peerDependenciesMeta": { + "canvas": { + "optional": true + } + } + }, "node_modules/jsesc": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", @@ -2376,6 +3186,20 @@ "url": "https://opencollective.com/parcel" } }, + "node_modules/lodash": { + "version": "4.17.21", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", + "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==", + "dev": true, + "license": "MIT" + }, + "node_modules/loupe": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/loupe/-/loupe-3.2.0.tgz", + "integrity": "sha512-2NCfZcT5VGVNX9mSZIxLRkEAegDGBpuQZBy13desuHeVORmBDyAET4TkJr4SjqQy3A8JDofMN6LpkK8Xcm/dlw==", + "dev": true, + "license": "MIT" + }, "node_modules/lru-cache": { "version": "5.1.1", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", @@ -2395,6 +3219,17 @@ "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, + "node_modules/lz-string": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz", + "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==", + "dev": true, + "license": "MIT", + "peer": true, + "bin": { + "lz-string": "bin/bin.js" + } + }, "node_modules/magic-string": { "version": "0.30.17", "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.17.tgz", @@ -2404,6 +3239,16 @@ "@jridgewell/sourcemap-codec": "^1.5.0" } }, + "node_modules/min-indent": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/min-indent/-/min-indent-1.0.1.tgz", + "integrity": "sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, "node_modules/minipass": { "version": "7.1.2", "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.2.tgz", @@ -2440,6 +3285,16 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/mrmime": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/mrmime/-/mrmime-2.0.1.tgz", + "integrity": "sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + } + }, "node_modules/ms": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", @@ -2472,6 +3327,43 @@ "dev": true, "license": "MIT" }, + "node_modules/nwsapi": { + "version": "2.2.21", + "resolved": "https://registry.npmjs.org/nwsapi/-/nwsapi-2.2.21.tgz", + "integrity": "sha512-o6nIY3qwiSXl7/LuOU0Dmuctd34Yay0yeuZRLFmDPrrdHpXKFndPj3hM+YEPVHYC5fx2otBx4Ilc/gyYSAUaIA==", + "dev": true, + "license": "MIT" + }, + "node_modules/parse5": { + "version": "7.3.0", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-7.3.0.tgz", + "integrity": "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "entities": "^6.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, + "node_modules/pathval": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/pathval/-/pathval-2.0.1.tgz", + "integrity": "sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14.16" + } + }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", @@ -2518,6 +3410,46 @@ "node": "^10 || ^12 || >=14" } }, + "node_modules/pretty-format": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.5.1.tgz", + "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "ansi-regex": "^5.0.1", + "ansi-styles": "^5.0.0", + "react-is": "^17.0.1" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + } + }, + "node_modules/pretty-format/node_modules/ansi-styles": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", + "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/react": { "version": "19.1.0", "resolved": "https://registry.npmjs.org/react/-/react-19.1.0.tgz", @@ -2539,6 +3471,14 @@ "react": "^19.1.0" } }, + "node_modules/react-is": { + "version": "17.0.2", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", + "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==", + "dev": true, + "license": "MIT", + "peer": true + }, "node_modules/react-refresh": { "version": "0.17.0", "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz", @@ -2618,6 +3558,20 @@ } } }, + "node_modules/redent": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/redent/-/redent-3.0.0.tgz", + "integrity": "sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==", + "dev": true, + "license": "MIT", + "dependencies": { + "indent-string": "^4.0.0", + "strip-indent": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/rollup": { "version": "4.45.1", "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.45.1.tgz", @@ -2657,6 +3611,33 @@ "fsevents": "~2.3.2" } }, + "node_modules/rrweb-cssom": { + "version": "0.8.0", + "resolved": "https://registry.npmjs.org/rrweb-cssom/-/rrweb-cssom-0.8.0.tgz", + "integrity": "sha512-guoltQEx+9aMf2gDZ0s62EcV8lsXR+0w8915TC3ITdn2YueuNjdAYh/levpU9nFaoChh9RUS5ZdQMrKfVEN9tw==", + "dev": true, + "license": "MIT" + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "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/scheduler": { "version": "0.26.0", "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.26.0.tgz", @@ -2673,6 +3654,28 @@ "semver": "bin/semver.js" } }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, + "node_modules/sirv": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/sirv/-/sirv-3.0.1.tgz", + "integrity": "sha512-FoqMu0NCGBLCcAkS1qA+XJIQTR6/JHfQXl+uGteNCQ76T91DMUjPa9xfmeqMY3z80nLSg9yQmNjK0Px6RWsH/A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@polka/url": "^1.0.0-next.24", + "mrmime": "^2.0.0", + "totalist": "^3.0.0" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/source-map-js": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", @@ -2682,6 +3685,73 @@ "node": ">=0.10.0" } }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/std-env": { + "version": "3.9.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.9.0.tgz", + "integrity": "sha512-UGvjygr6F6tpH7o2qyqR6QYpwraIjKSdtzyBdyytFOHmPZY917kwdwLG0RbOjWOnKmnm3PeHjaoLLMie7kPLQw==", + "dev": true, + "license": "MIT" + }, + "node_modules/strip-indent": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/strip-indent/-/strip-indent-3.0.0.tgz", + "integrity": "sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "min-indent": "^1.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-literal": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/strip-literal/-/strip-literal-3.0.0.tgz", + "integrity": "sha512-TcccoMhJOM3OebGhSBEmp3UZ2SfDMZUEBdRA/9ynfLi8yYajyWX3JiXArcJt4Umh4vISpspkQIY8ZZoCqjbviA==", + "dev": true, + "license": "MIT", + "dependencies": { + "js-tokens": "^9.0.1" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/strip-literal/node_modules/js-tokens": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-9.0.1.tgz", + "integrity": "sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "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/tailwind-merge": { "version": "3.3.1", "resolved": "https://registry.npmjs.org/tailwind-merge/-/tailwind-merge-3.3.1.tgz", @@ -2733,6 +3803,20 @@ "node": ">=18" } }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-0.3.2.tgz", + "integrity": "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==", + "dev": true, + "license": "MIT" + }, "node_modules/tinyglobby": { "version": "0.2.14", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.14.tgz", @@ -2749,6 +3833,92 @@ "url": "https://github.com/sponsors/SuperchupuDev" } }, + "node_modules/tinypool": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/tinypool/-/tinypool-1.1.1.tgz", + "integrity": "sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.0.0 || >=20.0.0" + } + }, + "node_modules/tinyrainbow": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-2.0.0.tgz", + "integrity": "sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tinyspy": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/tinyspy/-/tinyspy-4.0.3.tgz", + "integrity": "sha512-t2T/WLB2WRgZ9EpE4jgPJ9w+i66UZfDc8wHh0xrwiRNN+UwH98GIJkTeZqX9rg0i0ptwzqW+uYeIF0T4F8LR7A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tldts": { + "version": "6.1.86", + "resolved": "https://registry.npmjs.org/tldts/-/tldts-6.1.86.tgz", + "integrity": "sha512-WMi/OQ2axVTf/ykqCQgXiIct+mSQDFdH2fkwhPwgEwvJ1kSzZRiinb0zF2Xb8u4+OqPChmyI6MEu4EezNJz+FQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "tldts-core": "^6.1.86" + }, + "bin": { + "tldts": "bin/cli.js" + } + }, + "node_modules/tldts-core": { + "version": "6.1.86", + "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-6.1.86.tgz", + "integrity": "sha512-Je6p7pkk+KMzMv2XXKmAE3McmolOQFdxkKw0R8EYNr7sELW46JqnNeTX8ybPiQgvg1ymCoF8LXs5fzFaZvJPTA==", + "dev": true, + "license": "MIT" + }, + "node_modules/totalist": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/totalist/-/totalist-3.0.1.tgz", + "integrity": "sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/tough-cookie": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-5.1.2.tgz", + "integrity": "sha512-FVDYdxtnj0G6Qm/DhNPSb8Ju59ULcup3tuJxkFb5K8Bv2pUXILbf0xZWU8PX8Ov19OXljbUyveOFwRMwkXzO+A==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "tldts": "^6.1.32" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/tr46": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-5.1.1.tgz", + "integrity": "sha512-hdF5ZgjTqgAntKkklYw0R03MG2x/bSzTtkxmIRw/sTNV8YXsCJ1tfLAX23lhxhHJlEf3CRCOCGGWw3vI3GaSPw==", + "dev": true, + "license": "MIT", + "dependencies": { + "punycode": "^2.3.1" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/tslib": { "version": "2.8.1", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", @@ -2934,6 +4104,218 @@ } } }, + "node_modules/vite-node": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-3.2.4.tgz", + "integrity": "sha512-EbKSKh+bh1E1IFxeO0pg1n4dvoOTt0UDiXMd/qn++r98+jPO1xtJilvXldeuQ8giIB5IkpjCgMleHMNEsGH6pg==", + "dev": true, + "license": "MIT", + "dependencies": { + "cac": "^6.7.14", + "debug": "^4.4.1", + "es-module-lexer": "^1.7.0", + "pathe": "^2.0.3", + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0" + }, + "bin": { + "vite-node": "vite-node.mjs" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/vitest": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-3.2.4.tgz", + "integrity": "sha512-LUCP5ev3GURDysTWiP47wRRUpLKMOfPh+yKTx3kVIEiu5KOMeqzpnYNsKyOoVrULivR8tLcks4+lga33Whn90A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/chai": "^5.2.2", + "@vitest/expect": "3.2.4", + "@vitest/mocker": "3.2.4", + "@vitest/pretty-format": "^3.2.4", + "@vitest/runner": "3.2.4", + "@vitest/snapshot": "3.2.4", + "@vitest/spy": "3.2.4", + "@vitest/utils": "3.2.4", + "chai": "^5.2.0", + "debug": "^4.4.1", + "expect-type": "^1.2.1", + "magic-string": "^0.30.17", + "pathe": "^2.0.3", + "picomatch": "^4.0.2", + "std-env": "^3.9.0", + "tinybench": "^2.9.0", + "tinyexec": "^0.3.2", + "tinyglobby": "^0.2.14", + "tinypool": "^1.1.1", + "tinyrainbow": "^2.0.0", + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0", + "vite-node": "3.2.4", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@types/debug": "^4.1.12", + "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", + "@vitest/browser": "3.2.4", + "@vitest/ui": "3.2.4", + "happy-dom": "*", + "jsdom": "*" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@types/debug": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + } + } + }, + "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": "7.0.0", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-7.0.0.tgz", + "integrity": "sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + } + }, + "node_modules/whatwg-encoding": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/whatwg-encoding/-/whatwg-encoding-3.1.1.tgz", + "integrity": "sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "iconv-lite": "0.6.3" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/whatwg-mimetype": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-4.0.0.tgz", + "integrity": "sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/whatwg-url": { + "version": "14.2.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-14.2.0.tgz", + "integrity": "sha512-De72GdQZzNTUBBChsXueQUnPKDkg/5A5zp7pFDuQAj5UFoENpiACU0wlCvzpAGnTkj++ihpKwKyYewn/XNUbKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "tr46": "^5.1.0", + "webidl-conversions": "^7.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/ws": { + "version": "8.18.3", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.18.3.tgz", + "integrity": "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/xml-name-validator": { + "version": "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", diff --git a/webui/package.json b/webui/package.json index 015be9b..8b17164 100644 --- a/webui/package.json +++ b/webui/package.json @@ -9,7 +9,10 @@ "scripts": { "dev": "vite", "build": "vite build", - "preview": "vite preview" + "preview": "vite preview", + "test": "vitest", + "test:ui": "vitest --ui", + "test:run": "vitest run" }, "dependencies": { "@radix-ui/react-checkbox": "^1.3.2", @@ -27,12 +30,18 @@ "zod": "^4.0.5" }, "devDependencies": { + "@testing-library/jest-dom": "^6.6.3", + "@testing-library/react": "^16.3.0", + "@testing-library/user-event": "^14.6.1", "@types/node": "^24.0.15", "@types/react": "^19.1.8", "@types/react-dom": "^19.1.6", "@vitejs/plugin-react": "^4.7.0", + "@vitest/ui": "^3.2.4", + "jsdom": "^26.1.0", "tw-animate-css": "^1.3.5", "typescript": "^5.8.3", - "vite": "^7.0.5" + "vite": "^7.0.5", + "vitest": "^3.2.4" } } diff --git a/webui/src/__tests__/App.test.tsx b/webui/src/__tests__/App.test.tsx new file mode 100644 index 0000000..bbd67d9 --- /dev/null +++ b/webui/src/__tests__/App.test.tsx @@ -0,0 +1,186 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { render, screen, waitFor } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import App from '@/App' +import { InstancesProvider } from '@/contexts/InstancesContext' +import { instancesApi } from '@/lib/api' +import { Instance } from '@/types/instance' + +// Mock the API +vi.mock('@/lib/api', () => ({ + instancesApi: { + list: vi.fn(), + create: vi.fn(), + update: vi.fn(), + start: vi.fn(), + stop: vi.fn(), + restart: vi.fn(), + delete: vi.fn(), + }, + serverApi: { + getHelp: vi.fn(), + getVersion: vi.fn(), + getDevices: vi.fn(), + } +})) + +// Mock health service to avoid real network calls +vi.mock('@/lib/healthService', () => ({ + healthService: { + subscribe: vi.fn(() => () => {}), + checkHealth: vi.fn(), + }, + checkHealth: vi.fn(), +})) + +function renderApp() { + return render( + + + + ) +} + +describe('App Component - Critical Business Logic Only', () => { + const mockInstances: Instance[] = [ + { name: 'test-instance-1', running: false, options: { model: 'model1.gguf' } }, + { name: 'test-instance-2', running: true, options: { model: 'model2.gguf' } } + ] + + beforeEach(() => { + vi.clearAllMocks() + vi.mocked(instancesApi.list).mockResolvedValue(mockInstances) + }) + + describe('End-to-End Instance Management', () => { + it('creates new instance with correct API call and updates UI', async () => { + const user = userEvent.setup() + const newInstance: Instance = { + name: 'new-test-instance', + running: false, + options: { model: 'new-model.gguf' } + } + vi.mocked(instancesApi.create).mockResolvedValue(newInstance) + + renderApp() + + // Wait for app to load + await waitFor(() => { + expect(screen.getByText('test-instance-1')).toBeInTheDocument() + }) + + // Complete create flow: button → form → API call → UI update + await user.click(screen.getByText('Create Instance')) + + const nameInput = screen.getByLabelText(/Instance Name/) + await user.type(nameInput, 'new-test-instance') + + await user.click(screen.getByTestId('modal-save-button')) + + // Verify correct API call + await waitFor(() => { + expect(instancesApi.create).toHaveBeenCalledWith('new-test-instance', { + auto_restart: true, // Default value + }) + }) + + // Verify UI updates with new instance + await waitFor(() => { + expect(screen.getByText('new-test-instance')).toBeInTheDocument() + }) + }) + + it('updates existing instance with correct API call', async () => { + const user = userEvent.setup() + const updatedInstance: Instance = { + name: 'test-instance-1', + running: false, + options: { model: 'updated-model.gguf' } + } + vi.mocked(instancesApi.update).mockResolvedValue(updatedInstance) + + renderApp() + + await waitFor(() => { + expect(screen.getByText('test-instance-1')).toBeInTheDocument() + }) + + // Complete edit flow: edit button → form → API call + const editButtons = screen.getAllByTitle('Edit instance') + await user.click(editButtons[0]) + + await user.click(screen.getByTestId('modal-save-button')) + + // Verify correct API call with existing instance data + await waitFor(() => { + expect(instancesApi.update).toHaveBeenCalledWith('test-instance-1', { + model: "model1.gguf", // Pre-filled from existing instance + }) + }) + }) + + it('renders instances and provides working interface', async () => { + renderApp() + + // Verify the app loads instances and renders them + await waitFor(() => { + expect(screen.getByText('test-instance-1')).toBeInTheDocument() + expect(screen.getByText('test-instance-2')).toBeInTheDocument() + expect(screen.getByText('Instances (2)')).toBeInTheDocument() + }) + + // Verify action buttons are present (testing integration, not specific actions) + expect(screen.getAllByTitle('Start instance').length).toBeGreaterThan(0) + expect(screen.getAllByTitle('Stop instance').length).toBeGreaterThan(0) + expect(screen.getAllByTitle('Edit instance').length).toBe(2) + expect(screen.getAllByTitle('Delete instance').length).toBeGreaterThan(0) + }) + + it('delete confirmation calls correct API', async () => { + const user = userEvent.setup() + const confirmSpy = vi.spyOn(window, 'confirm').mockReturnValue(true) + vi.mocked(instancesApi.delete).mockResolvedValue(undefined) + + renderApp() + + await waitFor(() => { + expect(screen.getByText('test-instance-1')).toBeInTheDocument() + }) + + const deleteButtons = screen.getAllByTitle('Delete instance') + await user.click(deleteButtons[0]) + + // Verify confirmation and API call + expect(confirmSpy).toHaveBeenCalledWith('Are you sure you want to delete instance "test-instance-1"?') + await waitFor(() => { + expect(instancesApi.delete).toHaveBeenCalledWith('test-instance-1') + }) + + confirmSpy.mockRestore() + }) + }) + + describe('Error Handling', () => { + it('handles instance loading errors gracefully', async () => { + vi.mocked(instancesApi.list).mockRejectedValue(new Error('Failed to load instances')) + + renderApp() + + // App should still render and show error + expect(screen.getByText('LlamaCtl Dashboard')).toBeInTheDocument() + await waitFor(() => { + expect(screen.getByText('Error loading instances')).toBeInTheDocument() + }) + }) + + it('shows empty state when no instances exist', async () => { + vi.mocked(instancesApi.list).mockResolvedValue([]) + + renderApp() + + await waitFor(() => { + expect(screen.getByText('No instances found')).toBeInTheDocument() + }) + }) + }) +}) \ No newline at end of file diff --git a/webui/src/components/Header.tsx b/webui/src/components/Header.tsx index c74d294..f2ac259 100644 --- a/webui/src/components/Header.tsx +++ b/webui/src/components/Header.tsx @@ -16,12 +16,13 @@ function Header({ onCreateInstance, onShowSystemInfo }: HeaderProps) {
- + @@ -78,6 +79,7 @@ function InstanceCard({ onClick={handleStop} disabled={!instance.running} title="Stop instance" + data-testid="stop-instance-button" > @@ -87,6 +89,7 @@ function InstanceCard({ variant="outline" onClick={handleEdit} title="Edit instance" + data-testid="edit-instance-button" > @@ -96,6 +99,7 @@ function InstanceCard({ variant="outline" onClick={handleLogs} title="View logs" + data-testid="view-logs-button" > @@ -106,6 +110,7 @@ function InstanceCard({ onClick={handleDelete} disabled={instance.running} title="Delete instance" + data-testid="delete-instance-button" > diff --git a/webui/src/components/InstanceList.tsx b/webui/src/components/InstanceList.tsx index 1e2571b..b2fce60 100644 --- a/webui/src/components/InstanceList.tsx +++ b/webui/src/components/InstanceList.tsx @@ -16,7 +16,7 @@ function InstanceList({ editInstance }: InstanceListProps) { if (loading) { return ( -
+

Loading instances...

diff --git a/webui/src/components/InstanceModal.tsx b/webui/src/components/InstanceModal.tsx index f213ddc..2e8434f 100644 --- a/webui/src/components/InstanceModal.tsx +++ b/webui/src/components/InstanceModal.tsx @@ -1,7 +1,7 @@ -import React, { useState, useEffect } from 'react' -import { Button } from '@/components/ui/button' -import { Input } from '@/components/ui/input' -import { Label } from '@/components/ui/label' +import React, { useState, useEffect } from "react"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; import { Dialog, DialogContent, @@ -9,123 +9,125 @@ import { DialogFooter, DialogHeader, DialogTitle, -} from '@/components/ui/dialog' -import { CreateInstanceOptions, Instance } from '@/types/instance' -import { getBasicFields, getAdvancedFields } from '@/lib/zodFormUtils' -import { ChevronDown, ChevronRight } from 'lucide-react' -import ZodFormField from '@/components/ZodFormField' +} from "@/components/ui/dialog"; +import { CreateInstanceOptions, Instance } from "@/types/instance"; +import { getBasicFields, getAdvancedFields } from "@/lib/zodFormUtils"; +import { ChevronDown, ChevronRight } from "lucide-react"; +import ZodFormField from "@/components/ZodFormField"; interface InstanceModalProps { - open: boolean - onOpenChange: (open: boolean) => void - onSave: (name: string, options: CreateInstanceOptions) => void - instance?: Instance // For editing existing instance + open: boolean; + onOpenChange: (open: boolean) => void; + onSave: (name: string, options: CreateInstanceOptions) => void; + instance?: Instance; // For editing existing instance } const InstanceModal: React.FC = ({ open, onOpenChange, onSave, - instance + instance, }) => { - const isEditing = !!instance - const isRunning = instance?.running || true // Assume running if instance exists - - const [instanceName, setInstanceName] = useState('') - const [formData, setFormData] = useState({}) - const [showAdvanced, setShowAdvanced] = useState(false) - const [nameError, setNameError] = useState('') + const isEditing = !!instance; + const isRunning = instance?.running || true; // Assume running if instance exists + + const [instanceName, setInstanceName] = useState(""); + const [formData, setFormData] = useState({}); + const [showAdvanced, setShowAdvanced] = useState(false); + const [nameError, setNameError] = useState(""); // Get field lists dynamically from the type - const basicFields = getBasicFields() - const advancedFields = getAdvancedFields() + const basicFields = getBasicFields(); + const advancedFields = getAdvancedFields(); // Reset form when modal opens/closes or when instance changes useEffect(() => { if (open) { if (instance) { // Populate form with existing instance data - setInstanceName(instance.name) - setFormData(instance.options || {}) + setInstanceName(instance.name); + setFormData(instance.options || {}); } else { // Reset form for new instance - setInstanceName('') + setInstanceName(""); setFormData({ auto_restart: true, // Default value - }) + }); } - setShowAdvanced(false) // Always start with basic view - setNameError('') // Reset any name errors + setShowAdvanced(false); // Always start with basic view + setNameError(""); // Reset any name errors } - }, [open, instance]) + }, [open, instance]); const handleFieldChange = (key: keyof CreateInstanceOptions, value: any) => { - setFormData(prev => ({ + setFormData((prev) => ({ ...prev, - [key]: value - })) - } + [key]: value, + })); + }; const handleNameChange = (name: string) => { - setInstanceName(name) + setInstanceName(name); // Validate instance name if (!name.trim()) { - setNameError('Instance name is required') + setNameError("Instance name is required"); } else if (!/^[a-zA-Z0-9-_]+$/.test(name)) { - setNameError('Instance name can only contain letters, numbers, hyphens, and underscores') + setNameError( + "Instance name can only contain letters, numbers, hyphens, and underscores" + ); } else { - setNameError('') + setNameError(""); } - } + }; const handleSave = () => { // Validate instance name before saving if (!instanceName.trim()) { - setNameError('Instance name is required') - return + setNameError("Instance name is required"); + return; } // Clean up undefined values to avoid sending empty fields - const cleanOptions: CreateInstanceOptions = {} + const cleanOptions: CreateInstanceOptions = {}; Object.entries(formData).forEach(([key, value]) => { - if (value !== undefined && value !== '' && value !== null) { + if (value !== undefined && value !== "" && value !== null) { // Handle arrays - don't include empty arrays if (Array.isArray(value) && value.length === 0) { - return + return; } - ;(cleanOptions as any)[key] = value + (cleanOptions as any)[key] = value; } - }) + }); - onSave(instanceName, cleanOptions) - onOpenChange(false) - } + onSave(instanceName, cleanOptions); + onOpenChange(false); + }; const handleCancel = () => { - onOpenChange(false) - } + onOpenChange(false); + }; const toggleAdvanced = () => { - setShowAdvanced(!showAdvanced) - } + setShowAdvanced(!showAdvanced); + }; // Check if auto_restart is enabled - const isAutoRestartEnabled = formData.auto_restart === true + const isAutoRestartEnabled = formData.auto_restart === true; return ( - {isEditing ? 'Edit Instance' : 'Create New Instance'} + {isEditing ? "Edit Instance" : "Create New Instance"} - {isEditing - ? 'Modify the instance configuration below.' - : 'Configure your new llama-server instance below.'} + {isEditing + ? "Modify the instance configuration below." + : "Configure your new llama-server instance below."} - +
{/* Instance Name - Special handling since it's not in CreateInstanceOptions */} @@ -139,11 +141,9 @@ const InstanceModal: React.FC = ({ onChange={(e) => handleNameChange(e.target.value)} placeholder="my-instance" disabled={isEditing} // Don't allow name changes when editing - className={nameError ? 'border-red-500' : ''} + className={nameError ? "border-red-500" : ""} /> - {nameError && ( -

{nameError}

- )} + {nameError &&

{nameError}

}

Unique identifier for the instance

@@ -151,8 +151,10 @@ const InstanceModal: React.FC = ({ {/* Auto Restart Configuration Section */}
-

Auto Restart Configuration

- +

+ Auto Restart Configuration +

+ {/* Auto Restart Toggle */} = ({

Basic Configuration

{basicFields - .filter(fieldKey => fieldKey !== 'auto_restart') // Exclude auto_restart as it's handled above + .filter( + (fieldKey) => + fieldKey !== "auto_restart" && + fieldKey !== "max_restarts" && + fieldKey !== "restart_delay" + ) // Exclude auto_restart, max_restarts, and restart_delay as they're handled above .map((fieldKey) => ( = ({ )} Advanced Configuration - ({advancedFields.filter(f => !['max_restarts', 'restart_delay'].includes(f as string)).length} options) + ( + { + advancedFields.filter( + (f) => + !["max_restarts", "restart_delay"].includes(f as string) + ).length + }{" "} + options)
@@ -216,7 +230,12 @@ const InstanceModal: React.FC = ({
{advancedFields - .filter(fieldKey => !['max_restarts', 'restart_delay'].includes(fieldKey as string)) // Exclude restart options as they're handled above + .filter( + (fieldKey) => + !["max_restarts", "restart_delay"].includes( + fieldKey as string + ) + ) // Exclude restart options as they're handled above .sort() .map((fieldKey) => ( = ({
- -
- ) -} + ); +}; -export default InstanceModal \ No newline at end of file +export default InstanceModal; diff --git a/webui/src/components/__tests__/InstanceCard.test.tsx b/webui/src/components/__tests__/InstanceCard.test.tsx new file mode 100644 index 0000000..266e7de --- /dev/null +++ b/webui/src/components/__tests__/InstanceCard.test.tsx @@ -0,0 +1,339 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { render, screen } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import InstanceCard from '@/components/InstanceCard' +import { Instance } from '@/types/instance' + +// Mock the health hook since we're not testing health logic here +vi.mock('@/hooks/useInstanceHealth', () => ({ + useInstanceHealth: vi.fn(() => ({ status: 'ok', lastChecked: new Date() })) +})) + +describe('InstanceCard - Instance Actions and State', () => { + const mockStartInstance = vi.fn() + const mockStopInstance = vi.fn() + const mockDeleteInstance = vi.fn() + const mockEditInstance = vi.fn() + + const stoppedInstance: Instance = { + name: 'test-instance', + running: false, + options: { model: 'test-model.gguf' } + } + + const runningInstance: Instance = { + name: 'running-instance', + running: true, + options: { model: 'running-model.gguf' } + } + + beforeEach(() => { + vi.clearAllMocks() + }) + + describe('Instance Action Buttons', () => { + it('calls startInstance when start button clicked on stopped instance', async () => { + const user = userEvent.setup() + + render( + + ) + + const startButton = screen.getByTitle('Start instance') + expect(startButton).not.toBeDisabled() + + await user.click(startButton) + + expect(mockStartInstance).toHaveBeenCalledWith('test-instance') + }) + + it('calls stopInstance when stop button clicked on running instance', async () => { + const user = userEvent.setup() + + render( + + ) + + const stopButton = screen.getByTitle('Stop instance') + expect(stopButton).not.toBeDisabled() + + await user.click(stopButton) + + expect(mockStopInstance).toHaveBeenCalledWith('running-instance') + }) + + it('calls editInstance when edit button clicked', async () => { + const user = userEvent.setup() + + render( + + ) + + const editButton = screen.getByTitle('Edit instance') + await user.click(editButton) + + expect(mockEditInstance).toHaveBeenCalledWith(stoppedInstance) + }) + + it('opens logs modal when logs button clicked', async () => { + const user = userEvent.setup() + + render( + + ) + + const logsButton = screen.getByTitle('View logs') + await user.click(logsButton) + + // Should open logs modal (we can verify this by checking if modal title appears) + expect(screen.getByText(`Logs: ${stoppedInstance.name}`)).toBeInTheDocument() + }) + }) + + describe('Delete Confirmation Logic', () => { + it('shows confirmation dialog and calls deleteInstance when confirmed', async () => { + const user = userEvent.setup() + const confirmSpy = vi.spyOn(window, 'confirm').mockReturnValue(true) + + render( + + ) + + const deleteButton = screen.getByTitle('Delete instance') + await user.click(deleteButton) + + expect(confirmSpy).toHaveBeenCalledWith('Are you sure you want to delete instance "test-instance"?') + expect(mockDeleteInstance).toHaveBeenCalledWith('test-instance') + + confirmSpy.mockRestore() + }) + + it('does not call deleteInstance when confirmation cancelled', async () => { + const user = userEvent.setup() + const confirmSpy = vi.spyOn(window, 'confirm').mockReturnValue(false) + + render( + + ) + + const deleteButton = screen.getByTitle('Delete instance') + await user.click(deleteButton) + + expect(confirmSpy).toHaveBeenCalled() + expect(mockDeleteInstance).not.toHaveBeenCalled() + + confirmSpy.mockRestore() + }) + }) + + describe('Button State Based on Instance Status', () => { + it('disables start button and enables stop button for running instance', () => { + render( + + ) + + expect(screen.getByTitle('Start instance')).toBeDisabled() + expect(screen.getByTitle('Stop instance')).not.toBeDisabled() + expect(screen.getByTitle('Delete instance')).toBeDisabled() // Can't delete running instance + }) + + it('enables start button and disables stop button for stopped instance', () => { + render( + + ) + + expect(screen.getByTitle('Start instance')).not.toBeDisabled() + expect(screen.getByTitle('Stop instance')).toBeDisabled() + expect(screen.getByTitle('Delete instance')).not.toBeDisabled() // Can delete stopped instance + }) + + it('edit and logs buttons are always enabled', () => { + render( + + ) + + expect(screen.getByTitle('Edit instance')).not.toBeDisabled() + expect(screen.getByTitle('View logs')).not.toBeDisabled() + }) + }) + + describe('Instance Information Display', () => { + it('displays instance name correctly', () => { + render( + + ) + + expect(screen.getByText('test-instance')).toBeInTheDocument() + }) + + it('shows health badge for running instances', () => { + render( + + ) + + // Health badge should be present for running instances + // The exact text depends on the health status from the mock + expect(screen.getByText('Ready')).toBeInTheDocument() + }) + + it('does not show health badge for stopped instances', () => { + render( + + ) + + // Health badge should not be present for stopped instances + expect(screen.queryByText('Ready')).not.toBeInTheDocument() + }) + }) + + describe('Integration with LogsModal', () => { + it('passes correct props to LogsModal', async () => { + const user = userEvent.setup() + + render( + + ) + + // Open logs modal + await user.click(screen.getByTitle('View logs')) + + // Verify modal opened with correct instance data + expect(screen.getByText('Logs: running-instance')).toBeInTheDocument() + + // Close modal to test close functionality + const closeButtons = screen.getAllByText('Close') + const modalCloseButton = closeButtons.find(button => + button.closest('[data-slot="dialog-content"]') + ) + expect(modalCloseButton).toBeTruthy() + await user.click(modalCloseButton!) + + // Modal should close + expect(screen.queryByText('Logs: running-instance')).not.toBeInTheDocument() + }) + }) + + describe('Error Edge Cases', () => { + it('handles instance with minimal data', () => { + const minimalInstance: Instance = { + name: 'minimal', + running: false, + options: {} + } + + render( + + ) + + // Should still render basic structure + expect(screen.getByText('minimal')).toBeInTheDocument() + expect(screen.getByTitle('Start instance')).toBeInTheDocument() + }) + + it('handles instance with undefined options', () => { + const instanceWithoutOptions: Instance = { + name: 'no-options', + running: true, + options: undefined + } + + render( + + ) + + // Should still work + expect(screen.getByText('no-options')).toBeInTheDocument() + expect(screen.getByTitle('Stop instance')).not.toBeDisabled() + }) + }) +}) \ No newline at end of file diff --git a/webui/src/components/__tests__/InstanceList.test.tsx b/webui/src/components/__tests__/InstanceList.test.tsx new file mode 100644 index 0000000..cf2ab92 --- /dev/null +++ b/webui/src/components/__tests__/InstanceList.test.tsx @@ -0,0 +1,246 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { render, screen } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import InstanceList from '@/components/InstanceList' +import { InstancesProvider } from '@/contexts/InstancesContext' +import { instancesApi } from '@/lib/api' +import { Instance } from '@/types/instance' + +// Mock the API +vi.mock('@/lib/api', () => ({ + instancesApi: { + list: vi.fn(), + create: vi.fn(), + update: vi.fn(), + start: vi.fn(), + stop: vi.fn(), + restart: vi.fn(), + delete: vi.fn(), + } +})) + +// Mock health service +vi.mock('@/lib/healthService', () => ({ + healthService: { + subscribe: vi.fn(() => () => {}), + checkHealth: vi.fn(), + }, + checkHealth: vi.fn(), +})) + +function renderInstanceList(editInstance = vi.fn()) { + return render( + + + + ) +} + +describe('InstanceList - State Management and UI Logic', () => { + const mockEditInstance = vi.fn() + + const mockInstances: Instance[] = [ + { name: 'instance-1', running: false, options: { model: 'model1.gguf' } }, + { name: 'instance-2', running: true, options: { model: 'model2.gguf' } }, + { name: 'instance-3', running: false, options: { model: 'model3.gguf' } } + ] + + beforeEach(() => { + vi.clearAllMocks() + }) + + describe('Loading State', () => { + it('shows loading spinner while instances are being fetched', async () => { + // Mock a delayed response to test loading state + vi.mocked(instancesApi.list).mockImplementation(() => + new Promise(resolve => setTimeout(() => resolve(mockInstances), 100)) + ) + + renderInstanceList(mockEditInstance) + + // Should show loading state immediately + expect(screen.getByText('Loading instances...')).toBeInTheDocument() + expect(screen.getByLabelText('Loading')).toBeInTheDocument() + }) + }) + + describe('Error State', () => { + it('displays error message when instance loading fails', async () => { + const errorMessage = 'Failed to connect to server' + vi.mocked(instancesApi.list).mockRejectedValue(new Error(errorMessage)) + + renderInstanceList(mockEditInstance) + + // Wait for error to appear + expect(await screen.findByText('Error loading instances')).toBeInTheDocument() + expect(screen.getByText(errorMessage)).toBeInTheDocument() + }) + + it('does not show instances or loading when in error state', async () => { + vi.mocked(instancesApi.list).mockRejectedValue(new Error('Network error')) + + renderInstanceList(mockEditInstance) + + await screen.findByText('Error loading instances') + + // Should not show loading or instance elements + expect(screen.queryByText('Loading instances...')).not.toBeInTheDocument() + expect(screen.queryByText('Instances (')).not.toBeInTheDocument() + }) + }) + + describe('Empty State', () => { + it('shows empty state message when no instances exist', async () => { + vi.mocked(instancesApi.list).mockResolvedValue([]) + + renderInstanceList(mockEditInstance) + + expect(await screen.findByText('No instances found')).toBeInTheDocument() + expect(screen.getByText('Create your first instance to get started')).toBeInTheDocument() + }) + + it('does not show instances header when empty', async () => { + vi.mocked(instancesApi.list).mockResolvedValue([]) + + renderInstanceList(mockEditInstance) + + await screen.findByText('No instances found') + + expect(screen.queryByText(/Instances \(/)).not.toBeInTheDocument() + }) + }) + + describe('Instances Display', () => { + it('displays all instances with correct count', async () => { + vi.mocked(instancesApi.list).mockResolvedValue(mockInstances) + + renderInstanceList(mockEditInstance) + + // Wait for instances to load + expect(await screen.findByText('Instances (3)')).toBeInTheDocument() + + // All instances should be displayed + expect(screen.getByText('instance-1')).toBeInTheDocument() + expect(screen.getByText('instance-2')).toBeInTheDocument() + expect(screen.getByText('instance-3')).toBeInTheDocument() + }) + + it('displays correct count based on instances received', async () => { + // Test with different numbers of instances + const twoInstances = mockInstances.slice(0, 2) + vi.mocked(instancesApi.list).mockResolvedValue(twoInstances) + + renderInstanceList(mockEditInstance) + + expect(await screen.findByText('Instances (2)')).toBeInTheDocument() + expect(screen.getByText('instance-1')).toBeInTheDocument() + expect(screen.getByText('instance-2')).toBeInTheDocument() + expect(screen.queryByText('instance-3')).not.toBeInTheDocument() + }) + }) + + describe('Instance Card Integration', () => { + it('passes editInstance function to each instance card', async () => { + vi.mocked(instancesApi.list).mockResolvedValue(mockInstances) + + renderInstanceList(mockEditInstance) + + await screen.findByText('Instances (3)') + + // Find edit buttons and click one + const editButtons = screen.getAllByTitle('Edit instance') + expect(editButtons).toHaveLength(3) + + // Click the first edit button + await userEvent.setup().click(editButtons[0]) + + // Should call editInstance with the correct instance + expect(mockEditInstance).toHaveBeenCalledWith(mockInstances[0]) + }) + + it('instance actions work through context integration', async () => { + vi.mocked(instancesApi.list).mockResolvedValue(mockInstances) + vi.mocked(instancesApi.start).mockResolvedValue({} as Instance) + + renderInstanceList(mockEditInstance) + + await screen.findByText('Instances (3)') + + // Find start buttons (should be available for stopped instances) + const startButtons = screen.getAllByTitle('Start instance') + expect(startButtons.length).toBeGreaterThan(0) + + // Click a start button + await userEvent.setup().click(startButtons[0]) + + // Should call the API (testing integration with context) + expect(instancesApi.start).toHaveBeenCalled() + }) + }) + + describe('Performance Optimization', () => { + it('uses memoized instance cards to prevent unnecessary re-renders', async () => { + vi.mocked(instancesApi.list).mockResolvedValue(mockInstances) + + renderInstanceList(mockEditInstance) + + await screen.findByText('Instances (3)') + + // This is more of a structural test - we're verifying that the component + // uses MemoizedInstanceCard (as mentioned in the source code comment) + // The actual memoization effect would need more complex testing setup + expect(screen.getAllByTitle('Edit instance')).toHaveLength(3) + }) + }) + + describe('Grid Layout', () => { + it('renders instances in a grid layout', async () => { + vi.mocked(instancesApi.list).mockResolvedValue(mockInstances) + + renderInstanceList(mockEditInstance) + + await screen.findByText('Instances (3)') + + // Check that instances are rendered in the expected container structure + const instanceGrid = screen.getByText('instance-1').closest('.grid') + expect(instanceGrid).toBeInTheDocument() + }) + }) + + describe('State Transitions', () => { + it('transitions from loading to loaded state correctly', async () => { + vi.mocked(instancesApi.list).mockResolvedValue(mockInstances) + + renderInstanceList(mockEditInstance) + + // Should start with loading + expect(screen.getByText('Loading instances...')).toBeInTheDocument() + + // Should transition to loaded state + expect(await screen.findByText('Instances (3)')).toBeInTheDocument() + expect(screen.queryByText('Loading instances...')).not.toBeInTheDocument() + }) + + it('handles transition from error back to loaded state', async () => { + // Start with error + vi.mocked(instancesApi.list).mockRejectedValue(new Error('Network error')) + + const { rerender } = renderInstanceList(mockEditInstance) + + expect(await screen.findByText('Error loading instances')).toBeInTheDocument() + + // Simulate recovery (e.g., retry after network recovery) + vi.mocked(instancesApi.list).mockResolvedValue(mockInstances) + + rerender( + + + + ) + + // Should eventually show instances + // Note: This test is somewhat artificial since the context handles retries + expect(screen.getByText('Error loading instances')).toBeInTheDocument() + }) + }) +}) \ No newline at end of file diff --git a/webui/src/components/__tests__/InstanceModal.test.tsx b/webui/src/components/__tests__/InstanceModal.test.tsx new file mode 100644 index 0000000..38033e6 --- /dev/null +++ b/webui/src/components/__tests__/InstanceModal.test.tsx @@ -0,0 +1,382 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { render, screen, waitFor } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import InstanceModal from '@/components/InstanceModal' +import { Instance } from '@/types/instance' + +describe('InstanceModal - Form Logic and Validation', () => { + const mockOnSave = vi.fn() + const mockOnOpenChange = vi.fn() + + beforeEach(() => { + vi.clearAllMocks() + }) + + describe('Create Mode', () => { + it('validates instance name is required', async () => { + const user = userEvent.setup() + + render( + + ) + + // Try to submit without name + const saveButton = screen.getByTestId('modal-save-button') + expect(saveButton).toBeDisabled() + + // Add name, button should be enabled + const nameInput = screen.getByLabelText(/Instance Name/) + await user.type(nameInput, 'test-instance') + + await waitFor(() => { + expect(saveButton).not.toBeDisabled() + }) + }) + + it('validates instance name format', async () => { + const user = userEvent.setup() + + render( + + ) + + const nameInput = screen.getByLabelText(/Instance Name/) + + // Test invalid characters + await user.type(nameInput, 'test instance!') + + expect(screen.getByText(/can only contain letters, numbers, hyphens, and underscores/)).toBeInTheDocument() + expect(screen.getByTestId('modal-save-button')).toBeDisabled() + + // Clear and test valid name + await user.clear(nameInput) + await user.type(nameInput, 'test-instance-123') + + await waitFor(() => { + expect(screen.queryByText(/can only contain letters, numbers, hyphens, and underscores/)).not.toBeInTheDocument() + expect(screen.getByTestId('modal-save-button')).not.toBeDisabled() + }) + }) + + it('submits form with correct data structure', async () => { + const user = userEvent.setup() + + render( + + ) + + // Fill required name + await user.type(screen.getByLabelText(/Instance Name/), 'my-instance') + + // Submit form + await user.click(screen.getByTestId('modal-save-button')) + + expect(mockOnSave).toHaveBeenCalledWith('my-instance', { + auto_restart: true, // Default value + }) + }) + + it('form resets when modal reopens', async () => { + const { rerender } = render( + + ) + + // Fill form + const nameInput = screen.getByLabelText(/Instance Name/) + await userEvent.setup().type(nameInput, 'temp-name') + + // Close modal + rerender( + + ) + + // Reopen modal + rerender( + + ) + + // Form should be reset + const newNameInput = screen.getByLabelText(/Instance Name/) + expect(newNameInput).toHaveValue('') + }) + }) + + describe('Edit Mode', () => { + const mockInstance: Instance = { + name: 'existing-instance', + running: false, + options: { + model: 'test-model.gguf', + gpu_layers: 10, + auto_restart: false + } + } + + it('pre-fills form with existing instance data', async () => { + render( + + ) + + // Name should be pre-filled and disabled + const nameInput = screen.getByDisplayValue('existing-instance') + expect(nameInput).toBeDisabled() + + // Other fields should be pre-filled (where visible) + // Note: Not all fields are easily testable without more complex setup + expect(screen.getByText('Edit Instance')).toBeInTheDocument() + }) + + it('submits update with existing data when no changes made', async () => { + const user = userEvent.setup() + + render( + + ) + + // Submit without changes + await user.click(screen.getByTestId('modal-save-button')) + + expect(mockOnSave).toHaveBeenCalledWith('existing-instance', { + model: 'test-model.gguf', + gpu_layers: 10, + auto_restart: false + }) + }) + + it('shows correct button text for running vs stopped instances', async () => { + const runningInstance: Instance = { ...mockInstance, running: true } + + const { rerender } = render( + + ) + + expect(screen.getByTestId('modal-save-button')).toBeInTheDocument() + + rerender( + + ) + + expect(screen.getByText('Update & Restart Instance')).toBeInTheDocument() + }) + }) + + describe('Auto Restart Configuration', () => { + it('shows restart options when auto restart is enabled', async () => { + render( + + ) + + // Auto restart should be enabled by default + const autoRestartCheckbox = screen.getByLabelText(/Auto Restart/) + expect(autoRestartCheckbox).toBeChecked() + + // Restart options should be visible + expect(screen.getByLabelText(/Max Restarts/)).toBeInTheDocument() + expect(screen.getByLabelText(/Restart Delay/)).toBeInTheDocument() + }) + + it('hides restart options when auto restart is disabled', async () => { + const user = userEvent.setup() + + render( + + ) + + // Disable auto restart + const autoRestartCheckbox = screen.getByLabelText(/Auto Restart/) + await user.click(autoRestartCheckbox) + + // Restart options should be hidden + expect(screen.queryByLabelText(/Max Restarts/)).not.toBeInTheDocument() + expect(screen.queryByLabelText(/Restart Delay/)).not.toBeInTheDocument() + }) + + it('includes restart options in form submission when enabled', async () => { + const user = userEvent.setup() + + render( + + ) + + // Fill form + await user.type(screen.getByLabelText(/Instance Name/), 'test-instance') + + // Set restart options + await user.type(screen.getByLabelText(/Max Restarts/), '5') + await user.type(screen.getByLabelText(/Restart Delay/), '10') + + await user.click(screen.getByTestId('modal-save-button')) + + expect(mockOnSave).toHaveBeenCalledWith('test-instance', { + auto_restart: true, + max_restarts: 5, + restart_delay: 10 + }) + }) + }) + + describe('Advanced Fields Toggle', () => { + it('shows advanced fields when toggle clicked', async () => { + const user = userEvent.setup() + + render( + + ) + + // Advanced fields should be hidden initially + expect(screen.queryByText(/Advanced Configuration/)).toBeInTheDocument() + + // Click to expand + await user.click(screen.getByText(/Advanced Configuration/)) + + // Should show more configuration options + // Note: Specific fields depend on zodFormUtils configuration + // We're testing the toggle behavior, not specific fields + }) + }) + + describe('Form Data Handling', () => { + it('cleans up undefined values before submission', async () => { + const user = userEvent.setup() + + render( + + ) + + // Fill only required field + await user.type(screen.getByLabelText(/Instance Name/), 'clean-instance') + + await user.click(screen.getByTestId('modal-save-button')) + + // Should only include non-empty values + expect(mockOnSave).toHaveBeenCalledWith('clean-instance', { + auto_restart: true, // Only this default value should be included + }) + }) + + it('handles numeric fields correctly', async () => { + const user = userEvent.setup() + + render( + + ) + + await user.type(screen.getByLabelText(/Instance Name/), 'numeric-test') + + // Test GPU layers field (numeric) + const gpuLayersInput = screen.getByLabelText(/GPU Layers/) + await user.type(gpuLayersInput, '15') + + await user.click(screen.getByTestId('modal-save-button')) + + expect(mockOnSave).toHaveBeenCalledWith('numeric-test', { + auto_restart: true, + gpu_layers: 15, // Should be number, not string + }) + }) + }) + + describe('Modal Controls', () => { + it('calls onOpenChange when cancel button clicked', async () => { + const user = userEvent.setup() + + render( + + ) + + await user.click(screen.getByTestId('modal-cancel-button')) + + expect(mockOnOpenChange).toHaveBeenCalledWith(false) + }) + + it('calls onOpenChange after successful save', async () => { + const user = userEvent.setup() + + render( + + ) + + await user.type(screen.getByLabelText(/Instance Name/), 'test') + await user.click(screen.getByTestId('modal-save-button')) + + expect(mockOnSave).toHaveBeenCalled() + expect(mockOnOpenChange).toHaveBeenCalledWith(false) + }) + }) +}) \ No newline at end of file diff --git a/webui/src/contexts/__tests__/InstancesContext.test.tsx b/webui/src/contexts/__tests__/InstancesContext.test.tsx new file mode 100644 index 0000000..b1437ab --- /dev/null +++ b/webui/src/contexts/__tests__/InstancesContext.test.tsx @@ -0,0 +1,395 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' +import { render, screen, waitFor } from '@testing-library/react' +import { ReactNode } from 'react' +import { InstancesProvider, useInstances } from '@/contexts/InstancesContext' +import { instancesApi } from '@/lib/api' +import { Instance } from '@/types/instance' + +// Mock the API module +vi.mock('@/lib/api', () => ({ + instancesApi: { + list: vi.fn(), + create: vi.fn(), + update: vi.fn(), + start: vi.fn(), + stop: vi.fn(), + restart: vi.fn(), + delete: vi.fn(), + } +})) + +// Test component to access context +function TestComponent() { + const { + instances, + loading, + error, + createInstance, + updateInstance, + startInstance, + stopInstance, + restartInstance, + deleteInstance, + clearError + } = useInstances() + + return ( +
+
{loading.toString()}
+
{error || 'no-error'}
+
{instances.length}
+ {instances.map(instance => ( +
+ {instance.name}:{instance.running.toString()} +
+ ))} + + {/* Action buttons for testing with specific instances */} + + + + + + + +
+ ) +} + +function renderWithProvider(children: ReactNode) { + return render( + + {children} + + ) +} + +describe('InstancesContext', () => { + const mockInstances: Instance[] = [ + { name: 'instance1', running: true, options: { model: 'model1.gguf' } }, + { name: 'instance2', running: false, options: { model: 'model2.gguf' } } + ] + + beforeEach(() => { + vi.clearAllMocks() + // Default successful API responses + vi.mocked(instancesApi.list).mockResolvedValue(mockInstances) + }) + + afterEach(() => { + vi.clearAllMocks() + }) + + describe('Initial Loading', () => { + it('loads instances on mount', async () => { + renderWithProvider() + + // Should start loading + expect(screen.getByTestId('loading')).toHaveTextContent('true') + + // Should fetch instances + await waitFor(() => { + expect(instancesApi.list).toHaveBeenCalledOnce() + }) + + // Should display loaded instances + await waitFor(() => { + expect(screen.getByTestId('loading')).toHaveTextContent('false') + expect(screen.getByTestId('instances-count')).toHaveTextContent('2') + expect(screen.getByTestId('instance-instance1')).toHaveTextContent('instance1:true') + expect(screen.getByTestId('instance-instance2')).toHaveTextContent('instance2:false') + }) + }) + + it('handles API error during initial load', async () => { + const errorMessage = 'Network error' + vi.mocked(instancesApi.list).mockRejectedValue(new Error(errorMessage)) + + renderWithProvider() + + await waitFor(() => { + expect(screen.getByTestId('loading')).toHaveTextContent('false') + expect(screen.getByTestId('error')).toHaveTextContent(errorMessage) + expect(screen.getByTestId('instances-count')).toHaveTextContent('0') + }) + }) + }) + + describe('Create Instance', () => { + it('creates instance and adds it to state', async () => { + const newInstance: Instance = { + name: 'new-instance', + running: false, + options: { model: 'test.gguf' } + } + vi.mocked(instancesApi.create).mockResolvedValue(newInstance) + + renderWithProvider() + + await waitFor(() => { + expect(screen.getByTestId('loading')).toHaveTextContent('false') + expect(screen.getByTestId('instances-count')).toHaveTextContent('2') + }) + + screen.getByTestId('create-instance').click() + + await waitFor(() => { + expect(instancesApi.create).toHaveBeenCalledWith('new-instance', { model: 'test.gguf' }) + }) + + await waitFor(() => { + expect(screen.getByTestId('instances-count')).toHaveTextContent('3') + expect(screen.getByTestId('instance-new-instance')).toHaveTextContent('new-instance:false') + }) + }) + + it('handles create instance error without changing state', async () => { + const errorMessage = 'Instance already exists' + vi.mocked(instancesApi.create).mockRejectedValue(new Error(errorMessage)) + + renderWithProvider() + + await waitFor(() => { + expect(screen.getByTestId('loading')).toHaveTextContent('false') + expect(screen.getByTestId('instances-count')).toHaveTextContent('2') + }) + + screen.getByTestId('create-instance').click() + + await waitFor(() => { + expect(screen.getByTestId('error')).toHaveTextContent(errorMessage) + }) + + expect(screen.getByTestId('instances-count')).toHaveTextContent('2') + expect(screen.queryByTestId('instance-new-instance')).not.toBeInTheDocument() + }) + }) + + describe('Update Instance', () => { + it('updates instance and maintains it in state', async () => { + const updatedInstance: Instance = { + name: 'instance1', + running: true, + options: { model: 'updated.gguf' } + } + vi.mocked(instancesApi.update).mockResolvedValue(updatedInstance) + + renderWithProvider() + + await waitFor(() => { + expect(screen.getByTestId('loading')).toHaveTextContent('false') + expect(screen.getByTestId('instances-count')).toHaveTextContent('2') + }) + + screen.getByTestId('update-instance').click() + + await waitFor(() => { + expect(instancesApi.update).toHaveBeenCalledWith('instance1', { model: 'updated.gguf' }) + }) + + await waitFor(() => { + expect(screen.getByTestId('instances-count')).toHaveTextContent('2') + expect(screen.getByTestId('instance-instance1')).toBeInTheDocument() + }) + }) + }) + + describe('Start/Stop Instance', () => { + it('starts existing instance and updates its running state', async () => { + vi.mocked(instancesApi.start).mockResolvedValue({} as Instance) + + renderWithProvider() + + await waitFor(() => { + expect(screen.getByTestId('loading')).toHaveTextContent('false') + // instance2 starts as not running + expect(screen.getByTestId('instance-instance2')).toHaveTextContent('instance2:false') + }) + + // Start instance2 (button already configured to start instance2) + screen.getByTestId('start-instance').click() + + await waitFor(() => { + expect(instancesApi.start).toHaveBeenCalledWith('instance2') + // The running state should be updated to true + expect(screen.getByTestId('instance-instance2')).toHaveTextContent('instance2:true') + }) + }) + + it('stops instance and updates running state to false', async () => { + vi.mocked(instancesApi.stop).mockResolvedValue({} as Instance) + + renderWithProvider() + + await waitFor(() => { + expect(screen.getByTestId('loading')).toHaveTextContent('false') + // instance1 starts as running + expect(screen.getByTestId('instance-instance1')).toHaveTextContent('instance1:true') + }) + + // Stop instance1 (button already configured to stop instance1) + screen.getByTestId('stop-instance').click() + + await waitFor(() => { + expect(instancesApi.stop).toHaveBeenCalledWith('instance1') + // The running state should be updated to false + expect(screen.getByTestId('instance-instance1')).toHaveTextContent('instance1:false') + }) + }) + + it('handles start instance error', async () => { + const errorMessage = 'Failed to start instance' + vi.mocked(instancesApi.start).mockRejectedValue(new Error(errorMessage)) + + renderWithProvider() + + await waitFor(() => { + expect(screen.getByTestId('loading')).toHaveTextContent('false') + }) + + screen.getByTestId('start-instance').click() + + await waitFor(() => { + expect(screen.getByTestId('error')).toHaveTextContent(errorMessage) + }) + }) + }) + + describe('Delete Instance', () => { + it('deletes instance and removes it from state', async () => { + vi.mocked(instancesApi.delete).mockResolvedValue(undefined) + + renderWithProvider() + + await waitFor(() => { + expect(screen.getByTestId('loading')).toHaveTextContent('false') + expect(screen.getByTestId('instances-count')).toHaveTextContent('2') + expect(screen.getByTestId('instance-instance2')).toBeInTheDocument() + }) + + screen.getByTestId('delete-instance').click() + + await waitFor(() => { + expect(instancesApi.delete).toHaveBeenCalledWith('instance2') + }) + + await waitFor(() => { + expect(screen.getByTestId('instances-count')).toHaveTextContent('1') + expect(screen.queryByTestId('instance-instance2')).not.toBeInTheDocument() + expect(screen.getByTestId('instance-instance1')).toBeInTheDocument() // instance1 should still exist + }) + }) + + it('handles delete instance error without changing state', async () => { + const errorMessage = 'Instance is running' + vi.mocked(instancesApi.delete).mockRejectedValue(new Error(errorMessage)) + + renderWithProvider() + + await waitFor(() => { + expect(screen.getByTestId('loading')).toHaveTextContent('false') + expect(screen.getByTestId('instances-count')).toHaveTextContent('2') + }) + + screen.getByTestId('delete-instance').click() + + await waitFor(() => { + expect(screen.getByTestId('error')).toHaveTextContent(errorMessage) + }) + + expect(screen.getByTestId('instances-count')).toHaveTextContent('2') + expect(screen.getByTestId('instance-instance2')).toBeInTheDocument() + }) + }) + + describe('Error Management', () => { + it('clears error when clearError is called', async () => { + const errorMessage = 'Test error' + vi.mocked(instancesApi.list).mockRejectedValue(new Error(errorMessage)) + + renderWithProvider() + + await waitFor(() => { + expect(screen.getByTestId('error')).toHaveTextContent(errorMessage) + }) + + screen.getByTestId('clear-error').click() + + await waitFor(() => { + expect(screen.getByTestId('error')).toHaveTextContent('no-error') + }) + }) + }) + + describe('State Consistency', () => { + it('maintains consistent state during multiple operations', async () => { + // Test that operations don't interfere with each other + const newInstance: Instance = { + name: 'new-instance', + running: false, + options: {} + } + vi.mocked(instancesApi.create).mockResolvedValue(newInstance) + vi.mocked(instancesApi.start).mockResolvedValue({} as Instance) + + renderWithProvider() + + await waitFor(() => { + expect(screen.getByTestId('loading')).toHaveTextContent('false') + expect(screen.getByTestId('instances-count')).toHaveTextContent('2') + }) + + // Create new instance + screen.getByTestId('create-instance').click() + + await waitFor(() => { + expect(screen.getByTestId('instances-count')).toHaveTextContent('3') + }) + + // Start an instance (this should not affect the count) + screen.getByTestId('start-instance').click() + + await waitFor(() => { + expect(instancesApi.start).toHaveBeenCalled() + expect(screen.getByTestId('instances-count')).toHaveTextContent('3') // Still 3 + // But the running state should change + expect(screen.getByTestId('instance-instance2')).toHaveTextContent('instance2:true') + }) + }) + }) +}) \ No newline at end of file diff --git a/webui/src/lib/__tests__/api.test.ts b/webui/src/lib/__tests__/api.test.ts new file mode 100644 index 0000000..d7881bc --- /dev/null +++ b/webui/src/lib/__tests__/api.test.ts @@ -0,0 +1,60 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { instancesApi } from '@/lib/api' + +// Mock fetch globally +const mockFetch = vi.fn() +global.fetch = mockFetch + +describe('API Error Handling', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('converts HTTP errors to meaningful messages', async () => { + mockFetch.mockResolvedValue({ + ok: false, + status: 409, + text: () => Promise.resolve('Instance already exists') + }) + + await expect(instancesApi.create('existing', {})) + .rejects + .toThrow('HTTP 409: Instance already exists') + }) + + it('handles empty error responses gracefully', async () => { + mockFetch.mockResolvedValue({ + ok: false, + status: 500, + text: () => Promise.resolve('') + }) + + await expect(instancesApi.list()) + .rejects + .toThrow('HTTP 500') + }) + + it('handles 204 No Content responses', async () => { + mockFetch.mockResolvedValue({ + ok: true, + status: 204 + }) + + const result = await instancesApi.delete('test-instance') + expect(result).toBeUndefined() + }) + + it('builds query parameters correctly for logs', async () => { + mockFetch.mockResolvedValue({ + ok: true, + text: () => Promise.resolve('logs') + }) + + await instancesApi.getLogs('test-instance', 100) + + expect(mockFetch).toHaveBeenCalledWith( + '/api/v1/instances/test-instance/logs?lines=100', + expect.any(Object) + ) + }) +}) \ No newline at end of file diff --git a/webui/src/lib/api.ts b/webui/src/lib/api.ts index 1e827bb..2d2333e 100644 --- a/webui/src/lib/api.ts +++ b/webui/src/lib/api.ts @@ -1,146 +1,124 @@ -import { CreateInstanceOptions, Instance } from "@/types/instance" +import { CreateInstanceOptions, Instance } from "@/types/instance"; -const API_BASE = '/api/v1' - -// Configuration for API calls -// interface ApiConfig { -// apiKey?: string -// } - -// Global config - can be updated when auth is added -// let apiConfig: ApiConfig = {} - -// export const setApiConfig = (config: ApiConfig) => { -// apiConfig = config -// } +const API_BASE = "/api/v1"; // Base API call function with error handling async function apiCall( endpoint: string, - options: RequestInit = {} + options: RequestInit = {}, + responseType: "json" | "text" = "json" ): Promise { - const url = `${API_BASE}${endpoint}` - + const url = `${API_BASE}${endpoint}`; + // Prepare headers const headers: HeadersInit = { - 'Content-Type': 'application/json', + "Content-Type": "application/json", ...options.headers, - } - - // Add API key - not supported yet - // if (apiConfig.apiKey) { - // headers['Authorization'] = `Bearer ${apiConfig.apiKey}` - // } - + }; + try { const response = await fetch(url, { ...options, headers, - }) - + }); + if (!response.ok) { // Try to get error message from response - let errorMessage = `HTTP ${response.status}` + let errorMessage = `HTTP ${response.status}`; try { - const errorText = await response.text() + const errorText = await response.text(); if (errorText) { - errorMessage += `: ${errorText}` + errorMessage += `: ${errorText}`; } } catch { // If we can't read the error, just use status } - - throw new Error(errorMessage) + + throw new Error(errorMessage); } - + // Handle empty responses (like DELETE) if (response.status === 204) { - return undefined as T + return undefined as T; + } + + // Parse response based on type + if (responseType === "text") { + const text = await response.text(); + return text as T; + } else { + const data = await response.json(); + return data; } - - const data = await response.json() - return data } catch (error) { if (error instanceof Error) { - throw error + throw error; } - throw new Error('Network error occurred') + throw new Error("Network error occurred"); } } -// Server API functions +// Server API functions export const serverApi = { // GET /server/help - getHelp: async (): Promise => { - const response = await fetch(`${API_BASE}/server/help`) - if (!response.ok) throw new Error(`HTTP ${response.status}`) - return response.text() - }, - - // GET /server/version - getVersion: async (): Promise => { - const response = await fetch(`${API_BASE}/server/version`) - if (!response.ok) throw new Error(`HTTP ${response.status}`) - return response.text() - }, - + getHelp: () => apiCall("/server/help", {}, "text"), + + // GET /server/version + getVersion: () => apiCall("/server/version", {}, "text"), + // GET /server/devices - getDevices: async (): Promise => { - const response = await fetch(`${API_BASE}/server/devices`) - if (!response.ok) throw new Error(`HTTP ${response.status}`) - return response.text() - }, -} + getDevices: () => apiCall("/server/devices", {}, "text"), +}; // Instance API functions export const instancesApi = { // GET /instances - list: () => apiCall('/instances'), - + list: () => apiCall("/instances"), + // GET /instances/{name} get: (name: string) => apiCall(`/instances/${name}`), - + // POST /instances/{name} create: (name: string, options: CreateInstanceOptions) => apiCall(`/instances/${name}`, { - method: 'POST', + method: "POST", body: JSON.stringify(options), }), - + // PUT /instances/{name} update: (name: string, options: CreateInstanceOptions) => apiCall(`/instances/${name}`, { - method: 'PUT', + method: "PUT", body: JSON.stringify(options), }), - + // DELETE /instances/{name} delete: (name: string) => apiCall(`/instances/${name}`, { - method: 'DELETE', + method: "DELETE", }), - + // POST /instances/{name}/start start: (name: string) => apiCall(`/instances/${name}/start`, { - method: 'POST', + method: "POST", }), - + // POST /instances/{name}/stop stop: (name: string) => apiCall(`/instances/${name}/stop`, { - method: 'POST', + method: "POST", }), - + // POST /instances/{name}/restart restart: (name: string) => apiCall(`/instances/${name}/restart`, { - method: 'POST', + method: "POST", }), - + // GET /instances/{name}/logs getLogs: (name: string, lines?: number) => { - const params = lines ? `?lines=${lines}` : '' - return apiCall(`/instances/${name}/logs${params}`) + const params = lines ? `?lines=${lines}` : ""; + return apiCall(`/instances/${name}/logs${params}`, {}, "text"); }, -} \ No newline at end of file +}; diff --git a/webui/src/test/setup.ts b/webui/src/test/setup.ts new file mode 100644 index 0000000..0e7e331 --- /dev/null +++ b/webui/src/test/setup.ts @@ -0,0 +1,10 @@ +import '@testing-library/jest-dom' +import { afterEach, vi } from 'vitest' + +// Mock fetch globally since your app uses fetch +global.fetch = vi.fn() + +// Clean up after each test +afterEach(() => { + vi.clearAllMocks() +}) \ No newline at end of file diff --git a/webui/vite.config.ts b/webui/vite.config.ts index c25cdc3..2d06ca4 100644 --- a/webui/vite.config.ts +++ b/webui/vite.config.ts @@ -1,4 +1,4 @@ -import { defineConfig } from 'vite' +import { defineConfig } from 'vitest/config' import react from '@vitejs/plugin-react' import path from "path" import tailwindcss from "@tailwindcss/vite" @@ -14,5 +14,11 @@ export default defineConfig({ proxy: { '/api': 'http://localhost:8080' } - } + }, + test: { + globals: true, + environment: 'jsdom', + setupFiles: ['./src/test/setup.ts'], + css: true, + }, }) \ No newline at end of file