From c24fd035eb7f720dcf4ed2f1ccf25f61129a8d65 Mon Sep 17 00:00:00 2001 From: Ken Date: Tue, 26 May 2026 19:13:46 +0000 Subject: [PATCH] feat: API client and hooks for auth, sessions, chat SSE, artifacts --- frontend/package-lock.json | 1215 +++++++++++++++++++++++- frontend/package.json | 11 +- frontend/src/hooks/useArtifacts.ts | 22 + frontend/src/hooks/useAuth.ts | 27 + frontend/src/hooks/useChat.ts | 195 ++++ frontend/src/hooks/useSessions.ts | 44 + frontend/src/lib/api.ts | 54 ++ frontend/src/test/api.test.ts | 180 ++++ frontend/src/test/setup.ts | 3 + frontend/src/test/useArtifacts.test.ts | 85 ++ frontend/src/test/useAuth.test.ts | 98 ++ frontend/src/test/useChat.test.ts | 198 ++++ frontend/src/test/useSessions.test.ts | 132 +++ frontend/vite.config.ts | 6 + 14 files changed, 2267 insertions(+), 3 deletions(-) create mode 100644 frontend/src/hooks/useArtifacts.ts create mode 100644 frontend/src/hooks/useAuth.ts create mode 100644 frontend/src/hooks/useChat.ts create mode 100644 frontend/src/hooks/useSessions.ts create mode 100644 frontend/src/lib/api.ts create mode 100644 frontend/src/test/api.test.ts create mode 100644 frontend/src/test/setup.ts create mode 100644 frontend/src/test/useArtifacts.test.ts create mode 100644 frontend/src/test/useAuth.test.ts create mode 100644 frontend/src/test/useChat.test.ts create mode 100644 frontend/src/test/useSessions.test.ts diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 525a149..3f3ab55 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -18,17 +18,22 @@ }, "devDependencies": { "@eslint/js": "^10.0.1", + "@testing-library/react": "^16.3.2", + "@testing-library/user-event": "^14.6.1", "@types/node": "^24.12.3", "@types/react": "^19.2.15", "@types/react-dom": "^19.2.3", "@vitejs/plugin-react": "^6.0.1", + "@vitest/coverage-v8": "^4.1.7", "eslint": "^10.3.0", "eslint-plugin-react-hooks": "^7.1.1", "eslint-plugin-react-refresh": "^0.5.2", "globals": "^17.6.0", + "jsdom": "^29.1.1", "typescript": "~6.0.2", "typescript-eslint": "^8.59.2", - "vite": "^8.0.12" + "vite": "^8.0.12", + "vitest": "^4.1.7" } }, "node_modules/@0no-co/graphql.web": { @@ -141,6 +146,57 @@ "url": "https://github.com/sponsors/antfu" } }, + "node_modules/@asamuzakjp/css-color": { + "version": "5.1.11", + "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-5.1.11.tgz", + "integrity": "sha512-KVw6qIiCTUQhByfTd78h2yD1/00waTmm9uy/R7Ck/ctUyAPj+AEDLkQIdJW0T8+qGgj3j5bpNKK7Q3G+LedJWg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/generational-cache": "^1.0.1", + "@csstools/css-calc": "^3.2.0", + "@csstools/css-color-parser": "^4.1.0", + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/@asamuzakjp/dom-selector": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-7.1.1.tgz", + "integrity": "sha512-67RZDnYRc8H/8MLDgQCDE//zoqVFwajkepHZgmXrbwybzXOEwOWGPYGmALYl9J2DOLfFPPs6kKCqmbzV895hTQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/generational-cache": "^1.0.1", + "@asamuzakjp/nwsapi": "^2.3.9", + "bidi-js": "^1.0.3", + "css-tree": "^3.2.1", + "is-potential-custom-element-name": "^1.0.1" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/@asamuzakjp/generational-cache": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@asamuzakjp/generational-cache/-/generational-cache-1.0.1.tgz", + "integrity": "sha512-wajfB8KqzMCN2KGNFdLkReeHncd0AslUSrvHVvvYWuU8ghncRJoA50kT3zP9MVL0+9g4/67H+cdvBskj9THPzg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/@asamuzakjp/nwsapi": { + "version": "2.3.9", + "resolved": "https://registry.npmjs.org/@asamuzakjp/nwsapi/-/nwsapi-2.3.9.tgz", + "integrity": "sha512-n8GuYSrI9bF7FFZ/SjhwevlHc8xaVlb/7HmHelnc/PZXBD2ZR49NnN9sMMuDdEGPeeRQ5d0hqlSlEpgCX3Wl0Q==", + "dev": true, + "license": "MIT" + }, "node_modules/@babel/code-frame": { "version": "7.29.7", "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", @@ -390,12 +446,35 @@ "node": ">=6.9.0" } }, + "node_modules/@bcoe/v8-coverage": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@bcoe/v8-coverage/-/v8-coverage-1.0.2.tgz", + "integrity": "sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/@braintree/sanitize-url": { "version": "7.1.2", "resolved": "https://registry.npmjs.org/@braintree/sanitize-url/-/sanitize-url-7.1.2.tgz", "integrity": "sha512-jigsZK+sMF/cuiB7sERuo9V7N9jx+dhmHHnQyDSVdpZwVutaBu7WvNYqMDLSgFgfB30n452TP3vjDAvFC973mA==", "license": "MIT" }, + "node_modules/@bramus/specificity": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/@bramus/specificity/-/specificity-2.4.2.tgz", + "integrity": "sha512-ctxtJ/eA+t+6q2++vj5j7FYX3nRu311q1wfYH3xjlLOsczhlhxAg2FWNUXhpGvAw3BWo1xBcvOV6/YLc2r5FJw==", + "dev": true, + "license": "MIT", + "dependencies": { + "css-tree": "^3.0.0" + }, + "bin": { + "specificity": "bin/cli.js" + } + }, "node_modules/@bufbuild/protobuf": { "version": "2.12.0", "resolved": "https://registry.npmjs.org/@bufbuild/protobuf/-/protobuf-2.12.0.tgz", @@ -1310,6 +1389,146 @@ "node": ">=18" } }, + "node_modules/@csstools/color-helpers": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-6.0.2.tgz", + "integrity": "sha512-LMGQLS9EuADloEFkcTBR3BwV/CGHV7zyDxVRtVDTwdI2Ca4it0CCVTT9wCkxSgokjE5Ho41hEPgb8OEUwoXr6Q==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "engines": { + "node": ">=20.19.0" + } + }, + "node_modules/@csstools/css-calc": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-3.2.1.tgz", + "integrity": "sha512-DtdHlgXh5ZkA43cwBcAm+huzgJiwx3ZTWVjBs94kwz2xKqSimDA3lBgCjphYgwgVUMWatSM0pDd8TILB1yrVVg==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-color-parser": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-4.1.1.tgz", + "integrity": "sha512-eZ5XOtyhK+mggRafYUWzA0tvaYOFgdY8AkgQiCJF9qNAePnUo/zmsqqYubBBb3sQ8uNUaSKTY9s9klfRaAXL0g==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "dependencies": { + "@csstools/color-helpers": "^6.0.2", + "@csstools/css-calc": "^3.2.1" + }, + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-parser-algorithms": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-4.0.0.tgz", + "integrity": "sha512-+B87qS7fIG3L5h3qwJ/IFbjoVoOe/bpOdh9hAjXbvx0o8ImEmUsGXN0inFOnk2ChCFgqkkGFQ+TpM5rbhkKe4w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-syntax-patches-for-csstree": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.1.4.tgz", + "integrity": "sha512-wgsqt92b7C7tQhIdPNxj0n9zuUbQlvAuI1exyzeNrOKOi62SD7ren8zqszmpVREjAOqg8cD2FqYhQfAuKjk4sw==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "peerDependencies": { + "css-tree": "^3.2.1" + }, + "peerDependenciesMeta": { + "css-tree": { + "optional": true + } + } + }, + "node_modules/@csstools/css-tokenizer": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-4.0.0.tgz", + "integrity": "sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + } + }, "node_modules/@emnapi/core": { "version": "1.10.0", "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz", @@ -1472,6 +1691,24 @@ "node": "^20.19.0 || ^22.13.0 || >=24" } }, + "node_modules/@exodus/bytes": { + "version": "1.15.1", + "resolved": "https://registry.npmjs.org/@exodus/bytes/-/bytes-1.15.1.tgz", + "integrity": "sha512-S6mL0yNB/Abt9Ei4tq8gDhcczc4S3+vQ4ra7vxnAf+YHC02srtqxKKZghx2Dq6p0e66THKwR6r8N6P95wEty7Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + }, + "peerDependencies": { + "@noble/hashes": "^1.8.0 || ^2.0.0" + }, + "peerDependenciesMeta": { + "@noble/hashes": { + "optional": true + } + } + }, "node_modules/@floating-ui/core": { "version": "1.7.5", "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.7.5.tgz", @@ -3055,6 +3292,69 @@ "url": "https://github.com/sponsors/tannerlinsley" } }, + "node_modules/@testing-library/dom": { + "version": "10.4.1", + "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz", + "integrity": "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==", + "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", + "dom-accessibility-api": "^0.5.9", + "lz-string": "^1.5.0", + "picocolors": "1.1.1", + "pretty-format": "^27.0.2" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@testing-library/react": { + "version": "16.3.2", + "resolved": "https://registry.npmjs.org/@testing-library/react/-/react-16.3.2.tgz", + "integrity": "sha512-XU5/SytQM+ykqMnAnvB2umaJNIOsLF3PVv//1Ew4CTcpz0/BRyy/af40qqrt7SjKpDdT1saBMc42CUok5gaw+g==", + "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/@tybys/wasm-util": { "version": "0.10.2", "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.2.tgz", @@ -3066,6 +3366,25 @@ "tslib": "^2.4.0" } }, + "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/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, "node_modules/@types/d3": { "version": "7.4.3", "resolved": "https://registry.npmjs.org/@types/d3/-/d3-7.4.3.tgz", @@ -3328,6 +3647,13 @@ "@types/ms": "*" } }, + "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/esrecurse": { "version": "4.3.1", "resolved": "https://registry.npmjs.org/@types/esrecurse/-/esrecurse-4.3.1.tgz", @@ -3741,6 +4067,150 @@ } } }, + "node_modules/@vitest/coverage-v8": { + "version": "4.1.7", + "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-4.1.7.tgz", + "integrity": "sha512-qsYPeXc5Q9dFLd1i8Ap+Bx8sQgcp+rFVQo4R0dDsWNBzl26ldVF1qOO+RL24K7FDrR6pA+50XedRLSoSG24bVQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@bcoe/v8-coverage": "^1.0.2", + "@vitest/utils": "4.1.7", + "ast-v8-to-istanbul": "^1.0.0", + "istanbul-lib-coverage": "^3.2.2", + "istanbul-lib-report": "^3.0.1", + "istanbul-reports": "^3.2.0", + "magicast": "^0.5.2", + "obug": "^2.1.1", + "std-env": "^4.0.0-rc.1", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@vitest/browser": "4.1.7", + "vitest": "4.1.7" + }, + "peerDependenciesMeta": { + "@vitest/browser": { + "optional": true + } + } + }, + "node_modules/@vitest/expect": { + "version": "4.1.7", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.7.tgz", + "integrity": "sha512-1R+tw0ortHEbZDGMymm+pN7/AFQ/RkFFdtd7EN+VBpynKmLbP8A3rpEXdshBJ7+8hQ9zBJh/i1s0yKNtxAnU7w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.1.0", + "@types/chai": "^5.2.2", + "@vitest/spy": "4.1.7", + "@vitest/utils": "4.1.7", + "chai": "^6.2.2", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "4.1.7", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.7.tgz", + "integrity": "sha512-vY7nuamKgfvpA1Koa3oYIw/k7D6kZnpGyNMZW8loow2bsBYla1TFdqTaXncWdRn4pgwNs+90RhnXhJScDwQeJA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "4.1.7", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.21" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/pretty-format": { + "version": "4.1.7", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.7.tgz", + "integrity": "sha512-umgCarTOYQWIaDMvGDRZij+6b9oVeLIyJzfN+AS88e0ZOU3QTgNNSTtjQOpcvWr3np1N0j4WgZj+sb3oYBDscw==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "4.1.7", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.7.tgz", + "integrity": "sha512-BapjmAQ2aI78WdMEfeUWivnfVzB+VPGwWRQcJE0OUq7qEeEcBsCSf+0T5iREBNE5nBb4wA5Ya0W6IA+sghdEFw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "4.1.7", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "4.1.7", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.7.tgz", + "integrity": "sha512-ZacLzja+TmJeZ1h14xW2FB/WpeimUD3haBXQPyJqxvo8jQTmfeA8zv58mtjN2C7EHXZDYVcVYdYmAxjkWVvKCw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.7", + "@vitest/utils": "4.1.7", + "magic-string": "^0.30.21", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "4.1.7", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.7.tgz", + "integrity": "sha512-kbkI5LMWakyuTIvs6fUJ5qdIVb1XVKsYJAT4OJ938cHMROYMSfmoQdZy0aaAnjbbc8F61vkoTqz/Az+/HiIu5Q==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "4.1.7", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.7.tgz", + "integrity": "sha512-T532WBu791cBxJlCl6SO+J14l81DQx6uQHm1bQbmCDY7nqlEIgkza/UFnSBNaUtSf41unldDFjdOBYEQC4b5Hw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.7", + "convert-source-map": "^2.0.0", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, "node_modules/acorn": { "version": "8.16.0", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", @@ -3781,6 +4251,17 @@ "url": "https://github.com/sponsors/epoberezkin" } }, + "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", @@ -3808,6 +4289,46 @@ "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", + "peer": true, + "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/ast-v8-to-istanbul": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/ast-v8-to-istanbul/-/ast-v8-to-istanbul-1.0.2.tgz", + "integrity": "sha512-dKmJxJsGItLmc5CYZKuEjuG6GnBs6PG4gohMhyFOWKaNQoYCuRZJDECaBlHmcG0lv2wc2E0uU8lESmBEumC3DQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/trace-mapping": "^0.3.31", + "estree-walker": "^3.0.3", + "js-tokens": "^10.0.0" + } + }, + "node_modules/ast-v8-to-istanbul/node_modules/js-tokens": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-10.0.0.tgz", + "integrity": "sha512-lM/UBzQmfJRo9ABXbPWemivdCW8V2G8FHaHdypQaIy523snUjog0W71ayWXTjiR+ixeMyVHN2XcpnTd/liPg/Q==", + "dev": true, + "license": "MIT" + }, "node_modules/bail": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/bail/-/bail-2.0.2.tgz", @@ -3861,6 +4382,16 @@ "node": ">=6.0.0" } }, + "node_modules/bidi-js": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.0.3.tgz", + "integrity": "sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==", + "dev": true, + "license": "MIT", + "dependencies": { + "require-from-string": "^2.0.2" + } + }, "node_modules/bootstrap": { "version": "5.3.7", "resolved": "https://registry.npmjs.org/bootstrap/-/bootstrap-5.3.7.tgz", @@ -3982,6 +4513,16 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/chai": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", + "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/chalk": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", @@ -4133,6 +4674,20 @@ "node": ">= 8" } }, + "node_modules/css-tree": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.2.1.tgz", + "integrity": "sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==", + "dev": true, + "license": "MIT", + "dependencies": { + "mdn-data": "2.27.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0" + } + }, "node_modules/csstype": { "version": "3.2.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", @@ -4647,6 +5202,58 @@ "lodash-es": "^4.17.21" } }, + "node_modules/data-urls": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-7.0.0.tgz", + "integrity": "sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA==", + "dev": true, + "license": "MIT", + "dependencies": { + "whatwg-mimetype": "^5.0.0", + "whatwg-url": "^16.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/data-urls/node_modules/tr46": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-6.0.0.tgz", + "integrity": "sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==", + "dev": true, + "license": "MIT", + "dependencies": { + "punycode": "^2.3.1" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/data-urls/node_modules/webidl-conversions": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-8.0.1.tgz", + "integrity": "sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=20" + } + }, + "node_modules/data-urls/node_modules/whatwg-url": { + "version": "16.0.1", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-16.0.1.tgz", + "integrity": "sha512-1to4zXBxmXHV3IiSSEInrreIlu02vUOvrhxJJH5vcxYTBDAx51cqZiKdyTxlecdKNSjj8EcxGBxNf6Vg+945gw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@exodus/bytes": "^1.11.0", + "tr46": "^6.0.0", + "webidl-conversions": "^8.0.1" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, "node_modules/date-fns": { "version": "4.3.0", "resolved": "https://registry.npmjs.org/date-fns/-/date-fns-4.3.0.tgz", @@ -4680,6 +5287,13 @@ } } }, + "node_modules/decimal.js": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz", + "integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==", + "dev": true, + "license": "MIT" + }, "node_modules/decode-named-character-reference": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/decode-named-character-reference/-/decode-named-character-reference-1.3.0.tgz", @@ -4756,6 +5370,14 @@ "node": ">=0.3.1" } }, + "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/dompurify": { "version": "3.4.6", "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.6.tgz", @@ -4793,6 +5415,13 @@ "url": "https://github.com/fb55/entities?sponsor=1" } }, + "node_modules/es-module-lexer": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.1.0.tgz", + "integrity": "sha512-n27zTYMjYu1aj4MjCWzSP7G9r75utsaoc8m61weK+W8JMBGGQybd43GstCXZ3WNmSFtGT9wi59qQTW6mhTR5LQ==", + "dev": true, + "license": "MIT" + }, "node_modules/es-toolkit": { "version": "1.47.0", "resolved": "https://registry.npmjs.org/es-toolkit/-/es-toolkit-1.47.0.tgz", @@ -5008,6 +5637,16 @@ "url": "https://opencollective.com/unified" } }, + "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/esutils": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", @@ -5018,6 +5657,16 @@ "node": ">=0.10.0" } }, + "node_modules/expect-type": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz", + "integrity": "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/extend": { "version": "3.0.2", "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz", @@ -5631,6 +6280,26 @@ "integrity": "sha512-PDEfEF102G23vHmPhLyPboFCD+BkMGu+GuJe2d9/eH4FsCwvgBpnc9n0pGE+ffKdph38s6foEZiEjdgHdzp+IA==", "license": "CC0-1.0" }, + "node_modules/html-encoding-sniffer": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-6.0.0.tgz", + "integrity": "sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@exodus/bytes": "^1.6.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/html-escaper": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz", + "integrity": "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==", + "dev": true, + "license": "MIT" + }, "node_modules/html-url-attributes": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/html-url-attributes/-/html-url-attributes-3.0.1.tgz", @@ -5830,6 +6499,13 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/is-potential-custom-element-name": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", + "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==", + "dev": true, + "license": "MIT" + }, "node_modules/isexe": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", @@ -5837,6 +6513,45 @@ "dev": true, "license": "ISC" }, + "node_modules/istanbul-lib-coverage": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.2.tgz", + "integrity": "sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=8" + } + }, + "node_modules/istanbul-lib-report": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/istanbul-lib-report/-/istanbul-lib-report-3.0.1.tgz", + "integrity": "sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "istanbul-lib-coverage": "^3.0.0", + "make-dir": "^4.0.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/istanbul-reports": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/istanbul-reports/-/istanbul-reports-3.2.0.tgz", + "integrity": "sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "html-escaper": "^2.0.0", + "istanbul-lib-report": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/jose": { "version": "5.10.0", "resolved": "https://registry.npmjs.org/jose/-/jose-5.10.0.tgz", @@ -5852,6 +6567,121 @@ "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", "license": "MIT" }, + "node_modules/jsdom": { + "version": "29.1.1", + "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-29.1.1.tgz", + "integrity": "sha512-ECi4Fi2f7BdJtUKTflYRTiaMxIB0O6zfR1fX0GXpUrf6flp8QIYn1UT20YQqdSOfk2dfkCwS8LAFoJDEppNK5Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/css-color": "^5.1.11", + "@asamuzakjp/dom-selector": "^7.1.1", + "@bramus/specificity": "^2.4.2", + "@csstools/css-syntax-patches-for-csstree": "^1.1.3", + "@exodus/bytes": "^1.15.0", + "css-tree": "^3.2.1", + "data-urls": "^7.0.0", + "decimal.js": "^10.6.0", + "html-encoding-sniffer": "^6.0.0", + "is-potential-custom-element-name": "^1.0.1", + "lru-cache": "^11.3.5", + "parse5": "^8.0.1", + "saxes": "^6.0.0", + "symbol-tree": "^3.2.4", + "tough-cookie": "^6.0.1", + "undici": "^7.25.0", + "w3c-xmlserializer": "^5.0.0", + "webidl-conversions": "^8.0.1", + "whatwg-mimetype": "^5.0.0", + "whatwg-url": "^16.0.1", + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24.0.0" + }, + "peerDependencies": { + "canvas": "^3.0.0" + }, + "peerDependenciesMeta": { + "canvas": { + "optional": true + } + } + }, + "node_modules/jsdom/node_modules/entities": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-8.0.0.tgz", + "integrity": "sha512-zwfzJecQ/Uej6tusMqwAqU/6KL2XaB2VZ2Jg54Je6ahNBGNH6Ek6g3jjNCF0fG9EWQKGZNddNjU5F1ZQn/sBnA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=20.19.0" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/jsdom/node_modules/lru-cache": { + "version": "11.5.0", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.0.tgz", + "integrity": "sha512-5YgH9UJd7wVb9hIouI2adWpgqrrICkt070Dnj8EUY1+B4B2P9eRLPAkAAo6NICA7CEhOIeBHl46u9zSNpNu7zA==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/jsdom/node_modules/parse5": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-8.0.1.tgz", + "integrity": "sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw==", + "dev": true, + "license": "MIT", + "dependencies": { + "entities": "^8.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, + "node_modules/jsdom/node_modules/tr46": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-6.0.0.tgz", + "integrity": "sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==", + "dev": true, + "license": "MIT", + "dependencies": { + "punycode": "^2.3.1" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/jsdom/node_modules/webidl-conversions": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-8.0.1.tgz", + "integrity": "sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=20" + } + }, + "node_modules/jsdom/node_modules/whatwg-url": { + "version": "16.0.1", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-16.0.1.tgz", + "integrity": "sha512-1to4zXBxmXHV3IiSSEInrreIlu02vUOvrhxJJH5vcxYTBDAx51cqZiKdyTxlecdKNSjj8EcxGBxNf6Vg+945gw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@exodus/bytes": "^1.11.0", + "tr46": "^6.0.0", + "webidl-conversions": "^8.0.1" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, "node_modules/jsesc": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", @@ -6346,6 +7176,68 @@ "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.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/magicast": { + "version": "0.5.3", + "resolved": "https://registry.npmjs.org/magicast/-/magicast-0.5.3.tgz", + "integrity": "sha512-pVKE4UdSQ7DvHzivsCIFx2BJn1mHG6KsyrFcaxFx6tONdneEuThrDx0Cj3AMg58KyN4pzYT+LHOotxDQDjNvkw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.3", + "@babel/types": "^7.29.0", + "source-map-js": "^1.2.1" + } + }, + "node_modules/make-dir": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-4.0.0.tgz", + "integrity": "sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==", + "dev": true, + "license": "MIT", + "dependencies": { + "semver": "^7.5.3" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/make-dir/node_modules/semver": { + "version": "7.8.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.1.tgz", + "integrity": "sha512-rkVq3IXh+4FDGch+KwzX3aV9W3kO54GyEgpvBzSyctDA6Xtd7RJQV1xmXbeQp5v7+VzLOfVqiutSE6GICgPFvg==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/markdown-table": { "version": "3.0.4", "resolved": "https://registry.npmjs.org/markdown-table/-/markdown-table-3.0.4.tgz", @@ -6741,6 +7633,13 @@ "url": "https://opencollective.com/unified" } }, + "node_modules/mdn-data": { + "version": "2.27.1", + "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.27.1.tgz", + "integrity": "sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==", + "dev": true, + "license": "CC0-1.0" + }, "node_modules/mermaid": { "version": "11.15.0", "resolved": "https://registry.npmjs.org/mermaid/-/mermaid-11.15.0.tgz", @@ -7531,6 +8430,17 @@ "node": ">=0.10.0" } }, + "node_modules/obug": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.1.tgz", + "integrity": "sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==", + "dev": true, + "funding": [ + "https://github.com/sponsors/sxzz", + "https://opencollective.com/debug" + ], + "license": "MIT" + }, "node_modules/oniguruma-parser": { "version": "0.12.2", "resolved": "https://registry.npmjs.org/oniguruma-parser/-/oniguruma-parser-0.12.2.tgz", @@ -7673,6 +8583,13 @@ "node": ">=8" } }, + "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/phoenix": { "version": "1.8.7", "resolved": "https://registry.npmjs.org/phoenix/-/phoenix-1.8.7.tgz", @@ -7754,6 +8671,44 @@ "node": ">= 0.8.0" } }, + "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/pretty-format/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/prismjs": { "version": "1.30.0", "resolved": "https://registry.npmjs.org/prismjs/-/prismjs-1.30.0.tgz", @@ -8305,6 +9260,16 @@ "integrity": "sha512-152puVH0qMoRJQFnaMG+rVDdf01Jq/CaED+MBuXExurJgdbkLp0c3TIe4R12o28Klx8uyGsjvFNG05aFG69G9w==", "license": "Apache-2.0" }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/robust-predicates": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/robust-predicates/-/robust-predicates-3.0.3.tgz", @@ -8390,6 +9355,19 @@ "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", "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.27.0", "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", @@ -8445,6 +9423,13 @@ "@types/hast": "^3.0.4" } }, + "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/source-map-js": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", @@ -8465,6 +9450,20 @@ "url": "https://github.com/sponsors/wooorm" } }, + "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": "4.1.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.1.0.tgz", + "integrity": "sha512-Rq7ybcX2RuC55r9oaPVEW7/xu3tj8u4GeBYHBWCychFtzMIr86A7e3PPEBPT37sHStKX3+TiX/Fr/ACmJLVlLQ==", + "dev": true, + "license": "MIT" + }, "node_modules/streamdown": { "version": "1.6.11", "resolved": "https://registry.npmjs.org/streamdown/-/streamdown-1.6.11.tgz", @@ -8570,6 +9569,13 @@ "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/tabbable": { "version": "6.4.0", "resolved": "https://registry.npmjs.org/tabbable/-/tabbable-6.4.0.tgz", @@ -8586,6 +9592,13 @@ "url": "https://github.com/sponsors/dcastil" } }, + "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": "1.2.2", "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.2.2.tgz", @@ -8612,6 +9625,49 @@ "url": "https://github.com/sponsors/SuperchupuDev" } }, + "node_modules/tinyrainbow": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.0.tgz", + "integrity": "sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tldts": { + "version": "7.4.0", + "resolved": "https://registry.npmjs.org/tldts/-/tldts-7.4.0.tgz", + "integrity": "sha512-yHBe+zVfzNZ3QfTPW/Z6KK1G2t340gFjMHqI/4KKSt/abzYydzuCnpqdaF5gCCABby+9Yfbj59oR5F2Fd5CBzg==", + "dev": true, + "license": "MIT", + "dependencies": { + "tldts-core": "^7.4.0" + }, + "bin": { + "tldts": "bin/cli.js" + } + }, + "node_modules/tldts-core": { + "version": "7.4.0", + "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.4.0.tgz", + "integrity": "sha512-/mb9kRld+x1sIMXxWNOAp5m6C+D4GrAORWlJkOJ5dElvxdN1eutz/o7qHLp9gFvDF4Y3/L2xeScoxz6AbEo8rQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/tough-cookie": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-6.0.1.tgz", + "integrity": "sha512-LktZQb3IeoUWB9lqR5EWTHgW/VTITCXg4D21M+lvybRVdylLrRMnqaIONLVb5mav8vM19m44HIcGq4qASeu2Qw==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "tldts": "^7.0.5" + }, + "engines": { + "node": ">=16" + } + }, "node_modules/tr46": { "version": "0.0.3", "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", @@ -8726,6 +9782,16 @@ "typescript": ">=4.8.4 <6.1.0" } }, + "node_modules/undici": { + "version": "7.26.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-7.26.0.tgz", + "integrity": "sha512-3O9Tf67pGhgOv9jM35AbhkXAKi13f3oy3aE4CSgr+TckGeY+/iu97ZXN+J7DpHPzLbVApFd1IFhcnBjREYXYcg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20.18.1" + } + }, "node_modules/undici-types": { "version": "7.16.0", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz", @@ -9131,6 +10197,109 @@ } } }, + "node_modules/vitest": { + "version": "4.1.7", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.7.tgz", + "integrity": "sha512-flYyaFd2CgoCoU+0UKt3pxksgC+S02iTDN0n3LtqaMeXsI9SBcdNujc2k0DeFLzUn/0k538yNjOSdwgCqcrwJA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/expect": "4.1.7", + "@vitest/mocker": "4.1.7", + "@vitest/pretty-format": "4.1.7", + "@vitest/runner": "4.1.7", + "@vitest/snapshot": "4.1.7", + "@vitest/spy": "4.1.7", + "@vitest/utils": "4.1.7", + "es-module-lexer": "^2.0.0", + "expect-type": "^1.3.0", + "magic-string": "^0.30.21", + "obug": "^2.1.1", + "pathe": "^2.0.3", + "picomatch": "^4.0.3", + "std-env": "^4.0.0-rc.1", + "tinybench": "^2.9.0", + "tinyexec": "^1.0.2", + "tinyglobby": "^0.2.15", + "tinyrainbow": "^3.1.0", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@opentelemetry/api": "^1.9.0", + "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", + "@vitest/browser-playwright": "4.1.7", + "@vitest/browser-preview": "4.1.7", + "@vitest/browser-webdriverio": "4.1.7", + "@vitest/coverage-istanbul": "4.1.7", + "@vitest/coverage-v8": "4.1.7", + "@vitest/ui": "4.1.7", + "happy-dom": "*", + "jsdom": "*", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@opentelemetry/api": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser-playwright": { + "optional": true + }, + "@vitest/browser-preview": { + "optional": true + }, + "@vitest/browser-webdriverio": { + "optional": true + }, + "@vitest/coverage-istanbul": { + "optional": true + }, + "@vitest/coverage-v8": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + }, + "vite": { + "optional": false + } + } + }, + "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/web-namespaces": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/web-namespaces/-/web-namespaces-2.0.1.tgz", @@ -9147,6 +10316,16 @@ "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==", "license": "BSD-2-Clause" }, + "node_modules/whatwg-mimetype": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-5.0.0.tgz", + "integrity": "sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20" + } + }, "node_modules/whatwg-url": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", @@ -9173,6 +10352,23 @@ "node": ">= 8" } }, + "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/wonka": { "version": "6.3.6", "resolved": "https://registry.npmjs.org/wonka/-/wonka-6.3.6.tgz", @@ -9189,6 +10385,23 @@ "node": ">=0.10.0" } }, + "node_modules/xml-name-validator": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz", + "integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/xmlchars": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz", + "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==", + "dev": true, + "license": "MIT" + }, "node_modules/xtend": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", diff --git a/frontend/package.json b/frontend/package.json index 3a38eca..3907604 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -7,7 +7,9 @@ "dev": "vite", "build": "tsc -b && vite build", "lint": "eslint .", - "preview": "vite preview" + "preview": "vite preview", + "test": "vitest run", + "test:watch": "vitest" }, "dependencies": { "@copilotkit/react-core": "^1.58.0", @@ -20,16 +22,21 @@ }, "devDependencies": { "@eslint/js": "^10.0.1", + "@testing-library/react": "^16.3.2", + "@testing-library/user-event": "^14.6.1", "@types/node": "^24.12.3", "@types/react": "^19.2.15", "@types/react-dom": "^19.2.3", "@vitejs/plugin-react": "^6.0.1", + "@vitest/coverage-v8": "^4.1.7", "eslint": "^10.3.0", "eslint-plugin-react-hooks": "^7.1.1", "eslint-plugin-react-refresh": "^0.5.2", "globals": "^17.6.0", + "jsdom": "^29.1.1", "typescript": "~6.0.2", "typescript-eslint": "^8.59.2", - "vite": "^8.0.12" + "vite": "^8.0.12", + "vitest": "^4.1.7" } } diff --git a/frontend/src/hooks/useArtifacts.ts b/frontend/src/hooks/useArtifacts.ts new file mode 100644 index 0000000..10f8fcf --- /dev/null +++ b/frontend/src/hooks/useArtifacts.ts @@ -0,0 +1,22 @@ +import { useState } from 'react' +import type { Artifact, ArtifactContent } from '../lib/types' +import * as api from '../lib/api' + +export function useArtifacts(sessionId: string | null) { + const [artifacts, setArtifacts] = useState([]) + const [activeArtifact, setActiveArtifact] = useState(null) + + async function refresh() { + if (!sessionId) return + const data = await api.listArtifacts(sessionId) + setArtifacts(data) + } + + async function loadArtifact(name: string) { + if (!sessionId) return + const data = await api.getArtifact(sessionId, name) + setActiveArtifact(data) + } + + return { artifacts, activeArtifact, refresh, loadArtifact } +} diff --git a/frontend/src/hooks/useAuth.ts b/frontend/src/hooks/useAuth.ts new file mode 100644 index 0000000..c320a59 --- /dev/null +++ b/frontend/src/hooks/useAuth.ts @@ -0,0 +1,27 @@ +import { useState, useEffect } from 'react' +import type { AuthUser } from '../lib/types' +import * as api from '../lib/api' + +export function useAuth() { + const [user, setUser] = useState(null) + const [loading, setLoading] = useState(true) + + useEffect(() => { + api.getMe() + .then(u => setUser(u)) + .catch(() => setUser(null)) + .finally(() => setLoading(false)) + }, []) + + async function doLogin(email: string, password: string) { + const resp = await api.login(email, password) + setUser({ email: resp.email }) + } + + async function doLogout() { + await api.logout() + setUser(null) + } + + return { user, loading, login: doLogin, logout: doLogout } +} diff --git a/frontend/src/hooks/useChat.ts b/frontend/src/hooks/useChat.ts new file mode 100644 index 0000000..2ac2d5a --- /dev/null +++ b/frontend/src/hooks/useChat.ts @@ -0,0 +1,195 @@ +import { useState, useRef } from 'react' +import type { ToolCall } from '../lib/types' + +export interface ChatMessage { + id: string + role: 'user' | 'assistant' | 'tool' + content: string + toolCalls?: ToolCall[] +} + +export function useChat(sessionId: string | null) { + const [messages, setMessages] = useState([]) + const [isStreaming, setIsStreaming] = useState(false) + const abortRef = useRef(null) + + async function sendMessage(content: string) { + if (!sessionId || !content.trim()) return + + const trimmed = content.trim() + const userMsg: ChatMessage = { id: `user-${Date.now()}`, role: 'user', content: trimmed } + + setMessages(prev => [...prev, userMsg]) + setIsStreaming(true) + + const allMessages = [...messages, userMsg].map(m => ({ role: m.role, content: m.content })) + + const controller = new AbortController() + abortRef.current = controller + + try { + const res = await fetch('/api/copilotkit', { + method: 'POST', + credentials: 'include', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + threadId: sessionId, + runId: `run-${Date.now()}`, + messages: allMessages, + tools: [], + context: [], + }), + signal: controller.signal, + }) + + if (!res.ok || !res.body) { + throw new Error(`Request failed: ${res.status}`) + } + + const reader = res.body.getReader() + const decoder = new TextDecoder() + let buffer = '' + + let assistantId: string | null = null + let assistantContent = '' + const toolCalls: Record = {} + + while (true) { + const { done, value } = await reader.read() + if (done) break + + buffer += decoder.decode(value, { stream: true }) + + const chunks = buffer.split('\n\n') + buffer = chunks.pop() ?? '' + + for (const chunk of chunks) { + if (!chunk.startsWith('data: ')) continue + const jsonStr = chunk.slice('data: '.length).trim() + if (!jsonStr) continue + + let event: Record + try { + event = JSON.parse(jsonStr) as Record + } catch { + continue + } + + switch (event.type) { + case 'TEXT_MESSAGE_START': { + assistantId = event.messageId as string + assistantContent = '' + break + } + case 'TEXT_MESSAGE_CONTENT': { + assistantContent += event.delta as string + setMessages(prev => { + const existing = prev.find(m => m.id === assistantId) + if (existing) { + return prev.map(m => + m.id === assistantId ? { ...m, content: assistantContent } : m + ) + } + return [...prev, { id: assistantId!, role: 'assistant', content: assistantContent }] + }) + break + } + case 'TEXT_MESSAGE_END': { + // no-op + break + } + case 'TOOL_CALL_START': { + const tcId = event.toolCallId as string + toolCalls[tcId] = { + id: tcId, + name: event.toolCallName as string, + args: {}, + status: 'running', + } + break + } + case 'TOOL_CALL_ARGS': { + const tcId = event.toolCallId as string + if (toolCalls[tcId]) { + toolCalls[tcId] = { + ...toolCalls[tcId], + args: { ...toolCalls[tcId].args, _raw: ((toolCalls[tcId].args._raw as string) ?? '') + (event.delta as string) }, + } + } + break + } + case 'TOOL_CALL_END': { + const tcId = event.toolCallId as string + if (toolCalls[tcId]) { + toolCalls[tcId] = { ...toolCalls[tcId], status: 'complete' } + const tcList = Object.values(toolCalls) + setMessages(prev => { + if (assistantId) { + const existing = prev.find(m => m.id === assistantId) + if (existing) { + return prev.map(m => + m.id === assistantId ? { ...m, toolCalls: tcList } : m + ) + } + } + return prev + }) + } + break + } + case 'TOOL_CALL_RESULT': { + const tcId = event.toolCallId as string + if (toolCalls[tcId]) { + const meta = event._meta as { ui?: { resourceUri: string } } | undefined + const structuredContent = event.structuredContent as Record | undefined + toolCalls[tcId] = { + ...toolCalls[tcId], + result: event.result, + ...(meta?.ui?.resourceUri ? { _meta: meta as ToolCall['_meta'] } : {}), + ...(structuredContent ? { structuredContent } : {}), + } + const tcList = Object.values(toolCalls) + setMessages(prev => { + if (assistantId) { + const existing = prev.find(m => m.id === assistantId) + if (existing) { + return prev.map(m => + m.id === assistantId ? { ...m, toolCalls: tcList } : m + ) + } + } + return prev + }) + } + break + } + case 'RUN_FINISHED': { + // no-op + break + } + default: + break + } + } + } + } catch (err) { + if (err instanceof Error && err.name === 'AbortError') { + // silently ignore abort + } else { + console.error('Chat stream error:', err) + } + } finally { + setIsStreaming(false) + } + } + + function cancel() { + abortRef.current?.abort() + } + + function clearMessages() { + setMessages([]) + } + + return { messages, isStreaming, sendMessage, cancel, clearMessages } +} diff --git a/frontend/src/hooks/useSessions.ts b/frontend/src/hooks/useSessions.ts new file mode 100644 index 0000000..e97eacb --- /dev/null +++ b/frontend/src/hooks/useSessions.ts @@ -0,0 +1,44 @@ +import { useState, useEffect } from 'react' +import type { Session, SessionListResponse } from '../lib/types' +import * as api from '../lib/api' + +export function useSessions() { + const [sessions, setSessions] = useState([]) + const [activeSessionId, setActiveSessionId] = useState(null) + const [loading, setLoading] = useState(true) + + async function refresh() { + try { + const data: SessionListResponse = await api.listSessions() + setSessions(data.sessions) + } catch (err) { + console.error('Failed to list sessions:', err) + } finally { + setLoading(false) + } + } + + useEffect(() => { + void refresh() + }, []) + + async function createNew(): Promise { + try { + const data = await api.createSession() + await refresh() + setActiveSessionId(data.session_id) + return data.session_id + } catch (err) { + console.error('Failed to create session:', err) + return null + } + } + + function switchTo(sessionId: string) { + setActiveSessionId(sessionId) + } + + const activeSession = sessions.find(s => s.session_id === activeSessionId) + + return { sessions, activeSession, activeSessionId, loading, createNew, switchTo, refresh } +} diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts new file mode 100644 index 0000000..3e4adc1 --- /dev/null +++ b/frontend/src/lib/api.ts @@ -0,0 +1,54 @@ +import type { AuthUser, LoginResponse, SessionListResponse, Artifact, ArtifactContent } from './types' + +async function request(method: string, path: string, body?: unknown): Promise { + const options: RequestInit = { + method, + credentials: 'include', + headers: { 'Content-Type': 'application/json' }, + } + if (body !== undefined) { + options.body = JSON.stringify(body) + } + const res = await fetch(path, options) + if (!res.ok) { + const text = await res.text() + throw new Error(`${res.status}: ${text}`) + } + return res.json() as Promise +} + +export function login(email: string, password: string): Promise { + return request('POST', '/api/auth/login', { email, password }) +} + +export function getMe(): Promise { + return request('GET', '/api/auth/me') +} + +export function logout(): Promise { + return request('POST', '/api/auth/logout') +} + +export function listSessions(): Promise { + return request('GET', '/api/sessions') +} + +export function createSession(name?: string): Promise<{ session_id: string }> { + return request<{ session_id: string }>('POST', '/api/sessions', { name }) +} + +export function deleteSession(sessionId: string): Promise { + return request('DELETE', `/api/sessions/${sessionId}`) +} + +export function getTranscript(sessionId: string): Promise<{ messages: { role: string; content: string }[] }> { + return request<{ messages: { role: string; content: string }[] }>('GET', `/api/sessions/${sessionId}/transcript`) +} + +export function listArtifacts(sessionId: string): Promise { + return request('GET', `/api/artifacts/${sessionId}`) +} + +export function getArtifact(sessionId: string, name: string): Promise { + return request('GET', `/api/artifacts/${sessionId}/${name}`) +} diff --git a/frontend/src/test/api.test.ts b/frontend/src/test/api.test.ts new file mode 100644 index 0000000..b4a455f --- /dev/null +++ b/frontend/src/test/api.test.ts @@ -0,0 +1,180 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' + +// Mock fetch globally +const mockFetch = vi.fn() +vi.stubGlobal('fetch', mockFetch) + +// Import after stubbing fetch +import * as api from '../lib/api' + +function makeResponse(status: number, body: unknown, headers?: Record) { + return { + ok: status >= 200 && status < 300, + status, + text: vi.fn().mockResolvedValue(typeof body === 'string' ? body : JSON.stringify(body)), + json: vi.fn().mockResolvedValue(body), + body: null, + headers: { + get: (key: string) => (headers ?? {})[key] ?? null, + }, + } +} + +describe('api module', () => { + beforeEach(() => { + mockFetch.mockReset() + }) + + afterEach(() => { + vi.clearAllMocks() + }) + + describe('login', () => { + it('POSTs to /api/auth/login with credentials and returns LoginResponse', async () => { + const responseData = { token: 'abc123', email: 'test@example.com' } + mockFetch.mockResolvedValue(makeResponse(200, responseData)) + + const result = await api.login('test@example.com', 'password123') + + expect(mockFetch).toHaveBeenCalledWith('/api/auth/login', expect.objectContaining({ + method: 'POST', + credentials: 'include', + headers: expect.objectContaining({ 'Content-Type': 'application/json' }), + body: JSON.stringify({ email: 'test@example.com', password: 'password123' }), + })) + expect(result).toEqual(responseData) + }) + + it('throws on non-OK response', async () => { + mockFetch.mockResolvedValue(makeResponse(401, 'Unauthorized')) + + await expect(api.login('bad@example.com', 'wrong')).rejects.toThrow('401: Unauthorized') + }) + }) + + describe('getMe', () => { + it('GETs /api/auth/me with credentials', async () => { + const userData = { email: 'user@example.com' } + mockFetch.mockResolvedValue(makeResponse(200, userData)) + + const result = await api.getMe() + + expect(mockFetch).toHaveBeenCalledWith('/api/auth/me', expect.objectContaining({ + method: 'GET', + credentials: 'include', + })) + expect(result).toEqual(userData) + }) + }) + + describe('logout', () => { + it('POSTs to /api/auth/logout with credentials', async () => { + mockFetch.mockResolvedValue(makeResponse(200, {})) + + await api.logout() + + expect(mockFetch).toHaveBeenCalledWith('/api/auth/logout', expect.objectContaining({ + method: 'POST', + credentials: 'include', + })) + }) + }) + + describe('listSessions', () => { + it('GETs /api/sessions and returns SessionListResponse', async () => { + const sessionsData = { sessions: [], total: 0 } + mockFetch.mockResolvedValue(makeResponse(200, sessionsData)) + + const result = await api.listSessions() + + expect(mockFetch).toHaveBeenCalledWith('/api/sessions', expect.objectContaining({ + method: 'GET', + credentials: 'include', + })) + expect(result).toEqual(sessionsData) + }) + }) + + describe('createSession', () => { + it('POSTs /api/sessions with name body', async () => { + const responseData = { session_id: 'sess-123' } + mockFetch.mockResolvedValue(makeResponse(200, responseData)) + + const result = await api.createSession('My Session') + + expect(mockFetch).toHaveBeenCalledWith('/api/sessions', expect.objectContaining({ + method: 'POST', + credentials: 'include', + body: JSON.stringify({ name: 'My Session' }), + })) + expect(result).toEqual(responseData) + }) + + it('POSTs /api/sessions with undefined name when not provided', async () => { + const responseData = { session_id: 'sess-456' } + mockFetch.mockResolvedValue(makeResponse(200, responseData)) + + const result = await api.createSession() + + expect(result).toEqual(responseData) + }) + }) + + describe('deleteSession', () => { + it('DELETEs /api/sessions/{id}', async () => { + mockFetch.mockResolvedValue(makeResponse(200, {})) + + await api.deleteSession('sess-123') + + expect(mockFetch).toHaveBeenCalledWith('/api/sessions/sess-123', expect.objectContaining({ + method: 'DELETE', + credentials: 'include', + })) + }) + }) + + describe('getTranscript', () => { + it('GETs /api/sessions/{id}/transcript', async () => { + const transcript = { messages: [{ role: 'user', content: 'hello' }] } + mockFetch.mockResolvedValue(makeResponse(200, transcript)) + + const result = await api.getTranscript('sess-123') + + expect(mockFetch).toHaveBeenCalledWith('/api/sessions/sess-123/transcript', expect.objectContaining({ + method: 'GET', + credentials: 'include', + })) + expect(result).toEqual(transcript) + }) + }) + + describe('listArtifacts', () => { + it('GETs /api/artifacts/{id}', async () => { + const artifacts = [{ name: 'report.md', session_id: 'sess-123', size: 100 }] + mockFetch.mockResolvedValue(makeResponse(200, artifacts)) + + const result = await api.listArtifacts('sess-123') + + expect(mockFetch).toHaveBeenCalledWith('/api/artifacts/sess-123', expect.objectContaining({ + method: 'GET', + credentials: 'include', + })) + expect(result).toEqual(artifacts) + }) + }) + + describe('getArtifact', () => { + it('GETs /api/artifacts/{id}/{name}', async () => { + const artifact = { name: 'report.md', session_id: 'sess-123', content: '# Report' } + mockFetch.mockResolvedValue(makeResponse(200, artifact)) + + const result = await api.getArtifact('sess-123', 'report.md') + + expect(mockFetch).toHaveBeenCalledWith('/api/artifacts/sess-123/report.md', expect.objectContaining({ + method: 'GET', + credentials: 'include', + })) + expect(result).toEqual(artifact) + }) + }) +}) diff --git a/frontend/src/test/setup.ts b/frontend/src/test/setup.ts new file mode 100644 index 0000000..b59b868 --- /dev/null +++ b/frontend/src/test/setup.ts @@ -0,0 +1,3 @@ +import '@testing-library/react' + +// Minimal setup for vitest + jsdom diff --git a/frontend/src/test/useArtifacts.test.ts b/frontend/src/test/useArtifacts.test.ts new file mode 100644 index 0000000..17d8052 --- /dev/null +++ b/frontend/src/test/useArtifacts.test.ts @@ -0,0 +1,85 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { renderHook, act } from '@testing-library/react' + +vi.mock('../lib/api', () => ({ + listArtifacts: vi.fn(), + getArtifact: vi.fn(), +})) + +import { useArtifacts } from '../hooks/useArtifacts' +import * as api from '../lib/api' + +const mockListArtifacts = vi.mocked(api.listArtifacts) +const mockGetArtifact = vi.mocked(api.getArtifact) + +describe('useArtifacts', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('starts with empty artifacts and null activeArtifact', () => { + const { result } = renderHook(() => useArtifacts('sess-123')) + + expect(result.current.artifacts).toEqual([]) + expect(result.current.activeArtifact).toBeNull() + }) + + it('refresh calls listArtifacts and updates state', async () => { + const artifacts = [{ name: 'report.md', session_id: 'sess-123', size: 100 }] + mockListArtifacts.mockResolvedValue(artifacts) + + const { result } = renderHook(() => useArtifacts('sess-123')) + + await act(async () => { + await result.current.refresh() + }) + + expect(mockListArtifacts).toHaveBeenCalledWith('sess-123') + expect(result.current.artifacts).toEqual(artifacts) + }) + + it('refresh is guarded by sessionId', async () => { + const { result } = renderHook(() => useArtifacts(null)) + + await act(async () => { + await result.current.refresh() + }) + + expect(mockListArtifacts).not.toHaveBeenCalled() + }) + + it('loadArtifact calls getArtifact and sets activeArtifact', async () => { + const artifactContent = { name: 'report.md', session_id: 'sess-123', content: '# Report' } + mockGetArtifact.mockResolvedValue(artifactContent) + + const { result } = renderHook(() => useArtifacts('sess-123')) + + await act(async () => { + await result.current.loadArtifact('report.md') + }) + + expect(mockGetArtifact).toHaveBeenCalledWith('sess-123', 'report.md') + expect(result.current.activeArtifact).toEqual(artifactContent) + }) + + it('loadArtifact is guarded by sessionId', async () => { + const { result } = renderHook(() => useArtifacts(null)) + + await act(async () => { + await result.current.loadArtifact('report.md') + }) + + expect(mockGetArtifact).not.toHaveBeenCalled() + expect(result.current.activeArtifact).toBeNull() + }) + + it('returns expected shape', () => { + mockListArtifacts.mockResolvedValue([]) + + const { result } = renderHook(() => useArtifacts('sess-123')) + + expect(typeof result.current.refresh).toBe('function') + expect(typeof result.current.loadArtifact).toBe('function') + expect(Array.isArray(result.current.artifacts)).toBe(true) + }) +}) diff --git a/frontend/src/test/useAuth.test.ts b/frontend/src/test/useAuth.test.ts new file mode 100644 index 0000000..ccb6731 --- /dev/null +++ b/frontend/src/test/useAuth.test.ts @@ -0,0 +1,98 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { renderHook, act } from '@testing-library/react' + +// Mock the api module +vi.mock('../lib/api', () => ({ + getMe: vi.fn(), + login: vi.fn(), + logout: vi.fn(), +})) + +import { useAuth } from '../hooks/useAuth' +import * as api from '../lib/api' + +const mockGetMe = vi.mocked(api.getMe) +const mockLogin = vi.mocked(api.login) +const mockLogout = vi.mocked(api.logout) + +describe('useAuth', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('starts with loading=true and user=null', () => { + mockGetMe.mockResolvedValue({ email: 'user@example.com' }) + + const { result } = renderHook(() => useAuth()) + + expect(result.current.loading).toBe(true) + expect(result.current.user).toBeNull() + }) + + it('sets user on successful getMe and loading=false', async () => { + mockGetMe.mockResolvedValue({ email: 'user@example.com' }) + + const { result } = renderHook(() => useAuth()) + + await act(async () => { + await new Promise(resolve => setTimeout(resolve, 0)) + }) + + expect(result.current.loading).toBe(false) + expect(result.current.user).toEqual({ email: 'user@example.com' }) + }) + + it('sets user=null when getMe fails and loading=false', async () => { + mockGetMe.mockRejectedValue(new Error('Unauthorized')) + + const { result } = renderHook(() => useAuth()) + + await act(async () => { + await new Promise(resolve => setTimeout(resolve, 0)) + }) + + expect(result.current.loading).toBe(false) + expect(result.current.user).toBeNull() + }) + + it('login calls api.login and sets user', async () => { + mockGetMe.mockResolvedValue({ email: 'user@example.com' }) + mockLogin.mockResolvedValue({ token: 'tok', email: 'new@example.com' }) + + const { result } = renderHook(() => useAuth()) + + await act(async () => { + await result.current.login('new@example.com', 'password') + }) + + expect(mockLogin).toHaveBeenCalledWith('new@example.com', 'password') + expect(result.current.user).toEqual({ email: 'new@example.com' }) + }) + + it('logout calls api.logout and sets user=null', async () => { + mockGetMe.mockResolvedValue({ email: 'user@example.com' }) + mockLogout.mockResolvedValue(undefined) + + const { result } = renderHook(() => useAuth()) + + await act(async () => { + await new Promise(resolve => setTimeout(resolve, 0)) + }) + + await act(async () => { + await result.current.logout() + }) + + expect(mockLogout).toHaveBeenCalled() + expect(result.current.user).toBeNull() + }) + + it('exports login and logout functions', () => { + mockGetMe.mockResolvedValue({ email: 'user@example.com' }) + + const { result } = renderHook(() => useAuth()) + + expect(typeof result.current.login).toBe('function') + expect(typeof result.current.logout).toBe('function') + }) +}) diff --git a/frontend/src/test/useChat.test.ts b/frontend/src/test/useChat.test.ts new file mode 100644 index 0000000..13bfff7 --- /dev/null +++ b/frontend/src/test/useChat.test.ts @@ -0,0 +1,198 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { renderHook, act } from '@testing-library/react' + +import { useChat } from '../hooks/useChat' +import type { ChatMessage } from '../hooks/useChat' + +// Helper to create a ReadableStream from SSE events +function createSSEStream(events: string[]): ReadableStream { + const encoder = new TextEncoder() + return new ReadableStream({ + start(controller) { + for (const event of events) { + controller.enqueue(encoder.encode(event)) + } + controller.close() + }, + }) +} + +const mockFetch = vi.fn() +vi.stubGlobal('fetch', mockFetch) + +describe('useChat', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('returns initial state', () => { + const { result } = renderHook(() => useChat(null)) + + expect(result.current.messages).toEqual([]) + expect(result.current.isStreaming).toBe(false) + expect(typeof result.current.sendMessage).toBe('function') + expect(typeof result.current.cancel).toBe('function') + expect(typeof result.current.clearMessages).toBe('function') + }) + + it('does not fetch when sessionId is null', async () => { + const { result } = renderHook(() => useChat(null)) + + await act(async () => { + await result.current.sendMessage('hello') + }) + + expect(mockFetch).not.toHaveBeenCalled() + }) + + it('does not fetch when content is empty/whitespace', async () => { + const { result } = renderHook(() => useChat('sess-123')) + + await act(async () => { + await result.current.sendMessage(' ') + }) + + expect(mockFetch).not.toHaveBeenCalled() + }) + + it('sends message and sets isStreaming', async () => { + const sseStream = createSSEStream([ + 'data: {"type":"RUN_FINISHED"}\n\n', + ]) + + mockFetch.mockResolvedValue({ + ok: true, + body: sseStream, + }) + + const { result } = renderHook(() => useChat('sess-123')) + + await act(async () => { + await result.current.sendMessage('hello') + }) + + expect(mockFetch).toHaveBeenCalledWith( + '/api/copilotkit', + expect.objectContaining({ + method: 'POST', + credentials: 'include', + }) + ) + }) + + it('appends user message with trimmed content', async () => { + const sseStream = createSSEStream([ + 'data: {"type":"RUN_FINISHED"}\n\n', + ]) + + mockFetch.mockResolvedValue({ + ok: true, + body: sseStream, + }) + + const { result } = renderHook(() => useChat('sess-123')) + + await act(async () => { + await result.current.sendMessage(' hello world ') + }) + + expect(result.current.messages.length).toBeGreaterThanOrEqual(1) + const userMsg = result.current.messages.find(m => m.role === 'user') + expect(userMsg?.content).toBe('hello world') + expect(userMsg?.id).toMatch(/^user-/) + }) + + it('handles TEXT_MESSAGE events to build assistant message', async () => { + const msgId = 'msg-abc' + const sseStream = createSSEStream([ + `data: {"type":"TEXT_MESSAGE_START","messageId":"${msgId}"}\n\n`, + `data: {"type":"TEXT_MESSAGE_CONTENT","messageId":"${msgId}","delta":"Hello"}\n\n`, + `data: {"type":"TEXT_MESSAGE_CONTENT","messageId":"${msgId}","delta":" World"}\n\n`, + `data: {"type":"TEXT_MESSAGE_END","messageId":"${msgId}"}\n\n`, + 'data: {"type":"RUN_FINISHED"}\n\n', + ]) + + mockFetch.mockResolvedValue({ + ok: true, + body: sseStream, + }) + + const { result } = renderHook(() => useChat('sess-123')) + + await act(async () => { + await result.current.sendMessage('hi') + }) + + const assistantMsg = result.current.messages.find(m => m.role === 'assistant') + expect(assistantMsg).toBeDefined() + expect(assistantMsg?.content).toBe('Hello World') + }) + + it('clearMessages resets messages to empty', async () => { + const sseStream = createSSEStream([ + 'data: {"type":"RUN_FINISHED"}\n\n', + ]) + + mockFetch.mockResolvedValue({ + ok: true, + body: sseStream, + }) + + const { result } = renderHook(() => useChat('sess-123')) + + await act(async () => { + await result.current.sendMessage('hello') + }) + + expect(result.current.messages.length).toBeGreaterThan(0) + + act(() => { + result.current.clearMessages() + }) + + expect(result.current.messages).toEqual([]) + }) + + it('cancel calls abort on abortRef', async () => { + // Create a stream that never finishes + let streamController: ReadableStreamDefaultController + const sseStream = new ReadableStream({ + start(controller) { + streamController = controller + }, + }) + + mockFetch.mockResolvedValue({ + ok: true, + body: sseStream, + }) + + const { result } = renderHook(() => useChat('sess-123')) + + act(() => { + void result.current.sendMessage('hello') + }) + + await act(async () => { + result.current.cancel() + // Give time for abort to propagate + await new Promise(resolve => setTimeout(resolve, 10)) + // Close stream to avoid hanging + streamController!.close() + }) + + // After cancel, isStreaming should be false (eventually) + // This verifies cancel() doesn't throw + expect(result.current.cancel).toBeDefined() + }) + + it('exports ChatMessage interface compatible shape', () => { + const msg: ChatMessage = { + id: 'test-1', + role: 'user', + content: 'hello', + } + expect(msg.id).toBe('test-1') + expect(msg.role).toBe('user') + }) +}) diff --git a/frontend/src/test/useSessions.test.ts b/frontend/src/test/useSessions.test.ts new file mode 100644 index 0000000..b1a9004 --- /dev/null +++ b/frontend/src/test/useSessions.test.ts @@ -0,0 +1,132 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { renderHook, act } from '@testing-library/react' + +vi.mock('../lib/api', () => ({ + listSessions: vi.fn(), + createSession: vi.fn(), +})) + +import { useSessions } from '../hooks/useSessions' +import * as api from '../lib/api' + +const mockListSessions = vi.mocked(api.listSessions) +const mockCreateSession = vi.mocked(api.createSession) + +const sampleSessions = [ + { session_id: 'sess-1', status: 'active', bundle: 'default', created_at: '2024-01-01', last_activity: '2024-01-01', total_messages: 5, tool_invocations: 2, stale: false }, + { session_id: 'sess-2', status: 'active', bundle: 'default', created_at: '2024-01-02', last_activity: '2024-01-02', total_messages: 3, tool_invocations: 1, stale: false }, +] + +describe('useSessions', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('starts with empty sessions and loading=true', () => { + mockListSessions.mockResolvedValue({ sessions: [], total: 0 }) + + const { result } = renderHook(() => useSessions()) + + expect(result.current.sessions).toEqual([]) + expect(result.current.loading).toBe(true) + }) + + it('loads sessions on mount', async () => { + mockListSessions.mockResolvedValue({ sessions: sampleSessions, total: 2 }) + + const { result } = renderHook(() => useSessions()) + + await act(async () => { + await new Promise(resolve => setTimeout(resolve, 0)) + }) + + expect(result.current.sessions).toEqual(sampleSessions) + expect(result.current.loading).toBe(false) + }) + + it('refresh calls listSessions and updates sessions', async () => { + mockListSessions.mockResolvedValue({ sessions: sampleSessions, total: 2 }) + + const { result } = renderHook(() => useSessions()) + + await act(async () => { + await result.current.refresh() + }) + + expect(mockListSessions).toHaveBeenCalled() + expect(result.current.sessions).toEqual(sampleSessions) + }) + + it('createNew calls createSession, refreshes, sets activeSessionId, returns session_id', async () => { + mockListSessions.mockResolvedValue({ sessions: sampleSessions, total: 2 }) + mockCreateSession.mockResolvedValue({ session_id: 'sess-new' }) + + const { result } = renderHook(() => useSessions()) + + let sessionId: string | null = null + await act(async () => { + sessionId = await result.current.createNew() + }) + + expect(mockCreateSession).toHaveBeenCalled() + expect(sessionId).toBe('sess-new') + expect(result.current.activeSessionId).toBe('sess-new') + }) + + it('createNew returns null on error', async () => { + mockListSessions.mockResolvedValue({ sessions: [], total: 0 }) + mockCreateSession.mockRejectedValue(new Error('Server error')) + + const { result } = renderHook(() => useSessions()) + + let sessionId: string | null | undefined = undefined + await act(async () => { + sessionId = await result.current.createNew() + }) + + expect(sessionId).toBeNull() + }) + + it('switchTo sets activeSessionId', async () => { + mockListSessions.mockResolvedValue({ sessions: sampleSessions, total: 2 }) + + const { result } = renderHook(() => useSessions()) + + await act(async () => { + await new Promise(resolve => setTimeout(resolve, 0)) + }) + + act(() => { + result.current.switchTo('sess-2') + }) + + expect(result.current.activeSessionId).toBe('sess-2') + }) + + it('activeSession is found via Array.find', async () => { + mockListSessions.mockResolvedValue({ sessions: sampleSessions, total: 2 }) + + const { result } = renderHook(() => useSessions()) + + await act(async () => { + await new Promise(resolve => setTimeout(resolve, 0)) + }) + + act(() => { + result.current.switchTo('sess-1') + }) + + expect(result.current.activeSession).toEqual(sampleSessions[0]) + }) + + it('returns expected shape', () => { + mockListSessions.mockResolvedValue({ sessions: [], total: 0 }) + + const { result } = renderHook(() => useSessions()) + + expect(typeof result.current.refresh).toBe('function') + expect(typeof result.current.createNew).toBe('function') + expect(typeof result.current.switchTo).toBe('function') + expect(result.current.activeSession).toBeUndefined() + }) +}) diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts index 686fa6f..450add8 100644 --- a/frontend/vite.config.ts +++ b/frontend/vite.config.ts @@ -1,3 +1,4 @@ +/// import { defineConfig } from 'vite' import react from '@vitejs/plugin-react' @@ -17,4 +18,9 @@ export default defineConfig({ outDir: 'dist', sourcemap: true, }, + test: { + globals: true, + environment: 'jsdom', + setupFiles: ['./src/test/setup.ts'], + }, })