From 2ef78957cb264f57bbd6b94b7f08c65d9959b83c Mon Sep 17 00:00:00 2001 From: LeeJJang1 Date: Thu, 8 Oct 2026 23:01:10 +0900 Subject: [PATCH 1/2] fix: bound auth retries and add broadcast regression checks --- .github/workflows/check.yml | 35 + docs/BASELINE.md | 10 + docs/DEVELOPMENT.md | 8 +- docs/TROUBLESHOOTING.md | 8 + swproject/package-lock.json | 1029 ++++++++++++++++- swproject/package.json | 21 +- .../hooks/useTerminateBroadcast.test.ts | 62 + swproject/src/shared/lib/axios.test.ts | 99 ++ swproject/src/shared/lib/axios.ts | 8 +- swproject/src/test/setup.ts | 18 + swproject/vitest.config.ts | 8 + 11 files changed, 1297 insertions(+), 9 deletions(-) create mode 100644 .github/workflows/check.yml create mode 100644 swproject/src/features/broadcast/hooks/useTerminateBroadcast.test.ts create mode 100644 swproject/src/shared/lib/axios.test.ts create mode 100644 swproject/src/test/setup.ts create mode 100644 swproject/vitest.config.ts diff --git a/.github/workflows/check.yml b/.github/workflows/check.yml new file mode 100644 index 0000000..ee988b3 --- /dev/null +++ b/.github/workflows/check.yml @@ -0,0 +1,35 @@ +name: Frontend checks + +on: + pull_request: + push: + branches: [main] + +permissions: + contents: read + +concurrency: + group: frontend-checks-${{ github.ref }} + cancel-in-progress: true + +jobs: + check: + runs-on: ubuntu-latest + timeout-minutes: 15 + defaults: + run: + working-directory: swproject + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: '20' + cache: npm + cache-dependency-path: swproject/package-lock.json + - run: npm ci + env: + ELECTRON_SKIP_BINARY_DOWNLOAD: '1' + - run: npm run lint + - run: npm test + - run: npm run build + - run: npm run electron:build-main diff --git a/docs/BASELINE.md b/docs/BASELINE.md index bc63a68..825d447 100644 --- a/docs/BASELINE.md +++ b/docs/BASELINE.md @@ -35,3 +35,13 @@ - PostCSS 설정의 모듈 형식 경고와 Browserslist 데이터 갱신 안내가 있습니다. 현재 코드가 실행 가능한지에 대한 전체 판정은 아직 하지 않았습니다. 다음 단계는 핵심 통합 시나리오를 실행하고 재현 가능한 테스트를 추가하는 것입니다. 문서 정리 PR에서는 동작 코드·의존성·배포 설정을 변경하지 않습니다. + +## 후속 검증: 인증·방송 회귀 테스트 + +2026-10-08, macOS / Node 25.9.0 / npm 11.12.1에서 확인했습니다. + +- Vitest 9개 테스트 통과: 동시 인증 만료, 갱신 실패, 큐 요청의 재시도 제한, 로그인 실패, refresh token 부재, 방송 종료 성공·404·500, 종료 훅 언마운트 시 세션 유지. +- 기존 구현에서 인증 테스트 2개가 실패하는 것을 확인하고 재시도 표시와 공개 인증 요청 제외 조건을 수정했습니다. +- 프론트 빌드·Electron 컴파일 성공, 린트 오류 0 / 기존 경고 7. +- 이 테스트는 전송 경계를 대체한 단위·훅 테스트입니다. WebSocket·TTS 자원 해제와 실제 OBS·서버 연결, 전체 페이지 이동 통합은 미검증입니다. +- PR 자동 검사 워크플로를 추가했습니다. 위의 최초 기준선은 과거 기록이며 현재 테스트 명령은 `npm test`입니다. diff --git a/docs/DEVELOPMENT.md b/docs/DEVELOPMENT.md index 84c9854..d3adc7a 100644 --- a/docs/DEVELOPMENT.md +++ b/docs/DEVELOPMENT.md @@ -55,7 +55,7 @@ npm run electron:preview # STT 준비 + 빌드 후 로컬 실행 2. 백엔드 담당자와 요청·응답 타입, 오류 코드, WebSocket 이벤트를 합의합니다. 이전 API 계획서를 현재 계약으로 사용하지 않습니다. 3. 기존 `features/<기능>/api`, `hooks`, `components` 경계를 먼저 활용합니다. 단순한 UI에 새 전역 store를 만들 필요는 없습니다. 4. 화면 전용 상태는 컴포넌트에 두고, 공유가 필요한 상태만 store로 올립니다. 저장할 설정과 방송 종료 시 정리할 데이터를 구분합니다. -5. 중요한 동작의 검증을 추가하고 위 빌드·린트 명령을 실행합니다. 테스트 도입 전에는 아래 수동 시나리오도 기록합니다. +5. 중요한 동작의 검증을 추가하고 위 빌드·린트 명령을 실행합니다. 아래 수동 통합 시나리오도 별도로 기록합니다. 6. 기능 범위가 바뀌면 README·아키텍처를 갱신하고, PR에 변경 이유·확인 결과·남은 제약을 적습니다. ## 수동 통합 확인 @@ -82,7 +82,7 @@ npm run electron:preview # STT 준비 + 빌드 후 로컬 실행 | 5 | 결과물 공개 | 실제 화면 캡처와 시연, 필요 시 명시적인 샘플 데이터 웹 모드 | | 6 | 기능 확장 | 한 기능씩 계약·검증·문서까지 완결 | -코드 이동과 기능 변경을 같은 PR에서 크게 섞지 않습니다. 7개 기존 Hook 경고의 실제 의존성을 확인한 뒤 처리하고, 경고를 일괄 비활성화하지 않습니다. 자동 테스트 명령은 아직 없습니다. +코드 이동과 기능 변경을 같은 PR에서 크게 섞지 않습니다. 7개 기존 Hook 경고의 실제 의존성을 확인한 뒤 처리하고, 경고를 일괄 비활성화하지 않습니다. 자동 테스트는 `npm test`, 개발 중 반복 실행은 `npm run test:watch`를 사용합니다. 인증 실패와 방송 종료 훅을 격리해 검증하며 실제 서버·오디오·OBS 통합 테스트를 대체하지 않습니다. ## 개발 규칙 @@ -99,3 +99,7 @@ npm run electron:preview # STT 준비 + 빌드 후 로컬 실행 ## 개인 개발 도구 설정 개인 도구의 안내 파일은 공개 문서와 별도로 로컬에서 관리합니다. 이 체크아웃에서만 추적을 막으려면 `.git/info/exclude`를 사용합니다. 이미 Git이 추적하는 파일은 제외 규칙만 추가해도 제거되지 않으므로, 먼저 삭제 변경을 커밋해야 합니다. + +## PR 자동 검사 + +`Frontend checks`는 PR과 main push에서 Node 20 환경의 설치·린트·테스트·프론트 빌드·Electron 컴파일을 실행합니다. 기존 린트 경고 7개는 오류로 승격하지 않았습니다. 실제 데스크톱 실행과 STT 모델 패키징은 배포·통합 확인에서 다룹니다. diff --git a/docs/TROUBLESHOOTING.md b/docs/TROUBLESHOOTING.md index d4ee9e7..0553079 100644 --- a/docs/TROUBLESHOOTING.md +++ b/docs/TROUBLESHOOTING.md @@ -37,3 +37,11 @@ Python 패키지·모델·VC++ 런타임 등 개발 환경과 설치본의 차 ## 다음 진단 대상 [검증 기준선](BASELINE.md)에 Hook 경고 7개와 번들 경고를 기록했습니다. 경고 제거 전에 상태 변화·effect 재실행 의도를 확인하고, 변경 후 방송·캐릭터 시나리오를 검증합니다. + +## 인증 재시도 회귀 테스트에서 수정한 사례 + +동시 401 처리에서 큐에 들어간 요청에는 재시도 표시가 없어, 갱신 후 다시 401을 받으면 추가 갱신이 발생했습니다. 큐 진입 전에 재시도 표시를 적용해 각 요청의 재시도를 한 번으로 제한했습니다. + +로그인 실패에도 저장된 세션의 refresh 요청이 발생하던 경로는 공개 인증 엔드포인트를 갱신 대상에서 제외해 수정했습니다. 두 경우 모두 수정 전 실패·수정 후 통과하는 테스트를 추가했습니다. + +`npm test`는 Axios 전송 경계와 방송 API를 대체해 동작을 재현합니다. 네트워크·브라우저 저장소·방송 장치의 실제 동작을 검증하는 테스트는 아닙니다. diff --git a/swproject/package-lock.json b/swproject/package-lock.json index 4127896..ebdd25b 100644 --- a/swproject/package-lock.json +++ b/swproject/package-lock.json @@ -30,6 +30,7 @@ "devDependencies": { "@eslint/js": "^9.39.4", "@tailwindcss/postcss": "^4.2.3", + "@testing-library/react": "^16.3.3", "@types/node": "^20", "@types/react": "^19", "@types/react-dom": "^19", @@ -45,12 +46,14 @@ "eslint-plugin-react-hooks": "^7.1.1", "eslint-plugin-react-refresh": "^0.5.2", "globals": "^17.6.0", + "jsdom": "^29.1.1", "postcss": "^8.5.10", "tailwindcss": "^3.4.19", "tsx": "^4.21.0", "typescript": "^5", "typescript-eslint": "^8.59.2", "vite": "^8.0.9", + "vitest": "^4.1.11", "wait-on": "^8.0.2" } }, @@ -66,6 +69,57 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "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.0", "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz", @@ -258,6 +312,16 @@ "node": ">=6.0.0" } }, + "node_modules/@babel/runtime": { + "version": "7.29.10", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.10.tgz", + "integrity": "sha512-NpugTzbKgGrNe45Ui32cN8vCFFMxVtP/9rtfwv0nP5FRDAjvYBxJcLVpYA9mr1jxP6cnIT7x9LUzuDTbWYYNRA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, "node_modules/@babel/template": { "version": "7.28.6", "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz", @@ -306,6 +370,159 @@ "node": ">=6.9.0" } }, + "node_modules/@bramus/specificity": { + "version": "2.4.3", + "resolved": "https://registry.npmjs.org/@bramus/specificity/-/specificity-2.4.3.tgz", + "integrity": "sha512-0GEz8qJWokkUVYQYRukmkSHIFNoBH8QTreeHrAFbvz1XFYc3Y+puhMs8PQ5x8GNugiC2l5jvDBXKLbBIByeSBw==", + "dev": true, + "license": "MIT", + "dependencies": { + "css-tree": "^3.2.1" + }, + "bin": { + "specificity": "bin/cli.js" + } + }, + "node_modules/@csstools/color-helpers": { + "version": "6.1.2", + "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-6.1.2.tgz", + "integrity": "sha512-grhRy3OKmniaAEKXMjua5z/EODX0MSqBGjunw8+j/3HQjOnahs2AGhvEOIYVUWcU6ScApbhLhVrQTX8XqrMrow==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "engines": { + "node": ">=20.19.0" + } + }, + "node_modules/@csstools/css-calc": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-3.4.3.tgz", + "integrity": "sha512-iex20d8CHVkyvg6B7UKV7uHnI2Bqo9g+EFfT9E0y+GvTvhZ/DwONJ+9aKb1dlqm0ZiGsL5RXjp0fCoJYnkeDjA==", + "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.2", + "@csstools/css-tokenizer": "^4.0.2" + } + }, + "node_modules/@csstools/css-color-parser": { + "version": "4.2.6", + "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-4.2.6.tgz", + "integrity": "sha512-iiPQ3iRWwnJkeEn6RIu6SJPr7hYrLz6XZ9s/QZl+2/LI5KQVjpl2fdmDSZKuD4xP6GMmMPgHFFXg6k1Wkz0Trg==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "dependencies": { + "@csstools/color-helpers": "^6.1.2", + "@csstools/css-calc": "^3.4.3" + }, + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^4.0.2", + "@csstools/css-tokenizer": "^4.0.2" + } + }, + "node_modules/@csstools/css-parser-algorithms": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-4.0.2.tgz", + "integrity": "sha512-40cSKyMvK+tq4qz6Awrlye2WGuOKt3FwPgtGg6KTfbHOWNw+Rk1rzbAtZnZ6IBhsY491HLRnDXwoyBAijmmILA==", + "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.2" + } + }, + "node_modules/@csstools/css-syntax-patches-for-csstree": { + "version": "1.1.15", + "resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.1.15.tgz", + "integrity": "sha512-J0u7HkVl2nzSlhsiTOp4AmwcUQ3D+mGEEKfBy/7To5/y7F2OHwyLrXfrhR0SMgr4p5Lo+eaMVSeai24zUcBIxA==", + "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.2", + "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-4.0.2.tgz", + "integrity": "sha512-OoKoR0f76dCY666JlcbhmVTs2drYj1GUXZTYTcbUgJjh9Nv41aFfZ21bPQTERm5+L5cBDo466NltB2lplS5GBw==", + "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/@develar/schema-utils": { "version": "2.6.5", "resolved": "https://registry.npmjs.org/@develar/schema-utils/-/schema-utils-2.6.5.tgz", @@ -1268,6 +1485,24 @@ "node": "^18.18.0 || ^20.9.0 || >=21.1.0" } }, + "node_modules/@exodus/bytes": { + "version": "1.16.0", + "resolved": "https://registry.npmjs.org/@exodus/bytes/-/bytes-1.16.0.tgz", + "integrity": "sha512-IcpW84uEn3N7ETtNZMlxKhfl6Pec8rUNGOTBtWbK1FKhJxIFAptZyVrvVRVBimAJxJCgc3PxepxkdWWG4DVzfA==", + "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/@gar/promisify": { "version": "1.1.3", "resolved": "https://registry.npmjs.org/@gar/promisify/-/promisify-1.1.3.tgz", @@ -2502,6 +2737,55 @@ "dev": true, "license": "MIT" }, + "node_modules/@testing-library/dom": { + "version": "10.4.2", + "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.2.tgz", + "integrity": "sha512-yzr2S9HyAIdhz2/6qHgbs665Q7PKVcDF05vsOlHPxG1mo36gKVesdYVeDLnXgfjJ03CrKRk08knc6+E/9m8v2Q==", + "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.3", + "resolved": "https://registry.npmjs.org/@testing-library/react/-/react-16.3.3.tgz", + "integrity": "sha512-Uo193NgQbPMz6lrrhtRQQFcMC6Re/ELLFbbuVL30WDlZxlpZf9/lMHTAVxPRLw1q1iu9OJmR1c2BLiENRstdBg==", + "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/@tootallnate/once": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/@tootallnate/once/-/once-2.0.0.tgz", @@ -2530,6 +2814,14 @@ "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/cacheable-request": { "version": "6.0.3", "resolved": "https://registry.npmjs.org/@types/cacheable-request/-/cacheable-request-6.0.3.tgz", @@ -2543,6 +2835,17 @@ "@types/responselike": "^1.0.0" } }, + "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/debug": { "version": "4.1.13", "resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.13.tgz", @@ -2553,6 +2856,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/estree": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", @@ -3032,6 +3342,119 @@ } } }, + "node_modules/@vitest/expect": { + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.11.tgz", + "integrity": "sha512-VX2x5vNJXET47KAFzwERI+KRMtTTCSWTfSMKsW7JsUsXV4psq++e3DvZpuTDOpHcxytiDs6p2nhVb2tVDiiUYw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.1.0", + "@types/chai": "^5.2.2", + "@vitest/spy": "4.1.11", + "@vitest/utils": "4.1.11", + "chai": "^6.2.2", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.11.tgz", + "integrity": "sha512-2XJVD55d1o5AZous5CCGKS74g/riOj9odEt2bQpCVZeblHyHdnMeFl4jl0XjU21stf4mbjUkew2eXQZt65g5CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "4.1.11", + "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.11", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.11.tgz", + "integrity": "sha512-yiZzPbGTS9Sr/JpFl8zHrcIkAofNbFV6k21vIgQN/cY/oxZeXhJv5sc/MBJ5jFKWmWs+oJHw0UXLZjmf931+Vw==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.11.tgz", + "integrity": "sha512-LztvUgdwMNJMIkj3hQnnxiC2Xy1zNxq928W/xhjCLaNCzqTZOudjwbQf6v9IntZGPw132i2Lq2rgTRZHD3JHNw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "4.1.11", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.11.tgz", + "integrity": "sha512-pN7ikn1ON7h8ee4gIAp4AzyK+zBtJPzVbqOgu5LCEh4VaJVbPQcgYQYJIMGQPXVeJJq1fnfazis7a5pFNPahog==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.11", + "@vitest/utils": "4.1.11", + "magic-string": "^0.30.21", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.11.tgz", + "integrity": "sha512-apNa/prQy2qCeywhnixOHPRCgGNhvg7T4Dapfl1GahLp/R+uhBm5cPyFoNVyqsNd2h1nJxL6BqqdIjiABL60YA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.11.tgz", + "integrity": "sha512-zTCVGpyFsGWBhllOyKlTw/vnr6D9qxsfSDyfbyZmTyjHw5N/VuvzHpHoQjm2ZJzn4RJgx5w4r7V0er69CmLgPQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.11", + "convert-source-map": "^2.0.0", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, "node_modules/@xmldom/xmldom": { "version": "0.8.13", "resolved": "https://registry.npmjs.org/@xmldom/xmldom/-/xmldom-0.8.13.tgz", @@ -3467,6 +3890,17 @@ "dev": true, "license": "Python-2.0" }, + "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/assert-plus": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/assert-plus/-/assert-plus-1.0.0.tgz", @@ -3478,6 +3912,16 @@ "node": ">=0.8" } }, + "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/astral-regex": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/astral-regex/-/astral-regex-2.0.0.tgz", @@ -3611,6 +4055,16 @@ "node": ">=6.0.0" } }, + "node_modules/bidi-js": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.1.0.tgz", + "integrity": "sha512-fX1Onk0tdVPC7obPWB5EbJ1z7NVhLq4m2xZLq2YXBkxzMXIGRpNMU88n0EPgWseKl12J7zXs7qrDxPK4sRs2fg==", + "dev": true, + "license": "MIT", + "dependencies": { + "require-from-string": "^2.0.2" + } + }, "node_modules/binary-extensions": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", @@ -4003,6 +4457,16 @@ ], "license": "CC-BY-4.0" }, + "node_modules/chai": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/chai/-/chai-6.3.0.tgz", + "integrity": "sha512-XWAtwJ6OHO+tj0EKCs0Y2UamnyOxseZWltU4x2U2wh8g4AigdjwvtUjvLP2tqkA/avxHEtzxNaqGq/YGNwckKg==", + "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", @@ -4506,6 +4970,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/cssesc": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz", @@ -4525,6 +5003,20 @@ "devOptional": true, "license": "MIT" }, + "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/debug": { "version": "4.4.3", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", @@ -4543,6 +5035,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/decompress-response": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz", @@ -4656,6 +5155,17 @@ "dev": true, "license": "MIT" }, + "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", + "peer": true, + "engines": { + "node": ">=6" + } + }, "node_modules/detect-libc": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", @@ -4780,6 +5290,14 @@ "node": ">=8" } }, + "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/dotenv": { "version": "16.6.1", "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz", @@ -5087,6 +5605,19 @@ "node": ">=10.13.0" } }, + "node_modules/entities": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-8.1.0.tgz", + "integrity": "sha512-kxL7msIffSuh9aaFAMD7rxAIuTRMAHMeBtgHW2yUdWw732ZNh4MehkF2gdjvtdmikkaIP9bFDDJOPlsvm7avrA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=20.19.0" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, "node_modules/env-paths": { "version": "2.2.1", "resolved": "https://registry.npmjs.org/env-paths/-/env-paths-2.2.1.tgz", @@ -5122,6 +5653,13 @@ "node": ">= 0.4" } }, + "node_modules/es-module-lexer": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.3.2.tgz", + "integrity": "sha512-poHGpORABojJJucnV9KbOavETW8lBVnphkW77ER5/BQ5Fz7oXSoCNek7IH3vR5nRjdsEz926ibFYX8KtLQmdyw==", + "dev": true, + "license": "MIT" + }, "node_modules/es-object-atoms": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", @@ -5396,14 +5934,34 @@ "node": ">=4.0" } }, + "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", "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", "dev": true, - "license": "BSD-2-Clause", + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/expect-type": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz", + "integrity": "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==", + "dev": true, + "license": "Apache-2.0", "engines": { - "node": ">=0.10.0" + "node": ">=12.0.0" } }, "node_modules/exponential-backoff": { @@ -6149,6 +6707,19 @@ "dev": true, "license": "ISC" }, + "node_modules/html-encoding-sniffer": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-6.0.0.tgz", + "integrity": "sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@exodus/bytes": "^1.6.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, "node_modules/http-cache-semantics": { "version": "4.2.0", "resolved": "https://registry.npmjs.org/http-cache-semantics/-/http-cache-semantics-4.2.0.tgz", @@ -6440,6 +7011,13 @@ "node": ">=0.12.0" } }, + "node_modules/is-potential-custom-element-name": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", + "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==", + "dev": true, + "license": "MIT" + }, "node_modules/is-unicode-supported": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/is-unicode-supported/-/is-unicode-supported-0.1.0.tgz", @@ -6556,6 +7134,57 @@ "js-yaml": "bin/js-yaml.js" } }, + "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/lru-cache": { + "version": "11.5.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.3.tgz", + "integrity": "sha512-U4N8FgzmWxc8k1VH8Kr6lQg18U7Fjvby6wXHVRX/ZZ7IwWbRMgrRbP0Wrb5q5NVinryp4SQampHKdvtecItxUg==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, "node_modules/jsesc": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", @@ -7117,6 +7746,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.21", "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", @@ -7230,6 +7870,13 @@ "node": ">= 0.4" } }, + "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/merge2": { "version": "1.4.1", "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", @@ -7732,6 +8379,20 @@ "node": ">= 0.4" } }, + "node_modules/obug": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/obug/-/obug-2.2.1.tgz", + "integrity": "sha512-XrsrhT5sybtKI6wakr2SPOlGZWWYbUXZ7a0jT8/QOeAPau+1X/bSegNe5YR75oJmEZQbKningirmGOEJCIk61Q==", + "dev": true, + "funding": [ + "https://github.com/sponsors/sxzz", + "https://opencollective.com/debug" + ], + "license": "MIT", + "engines": { + "node": ">=12.20.0" + } + }, "node_modules/once": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", @@ -7878,6 +8539,19 @@ "node": ">=6" } }, + "node_modules/parse5": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-8.0.1.tgz", + "integrity": "sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw==", + "dev": true, + "license": "MIT", + "dependencies": { + "entities": "^8.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, "node_modules/path-exists": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", @@ -7948,6 +8622,13 @@ "node": ">=16 || 14 >=14.17" } }, + "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/pe-library": { "version": "0.4.1", "resolved": "https://registry.npmjs.org/pe-library/-/pe-library-0.4.1.tgz", @@ -8187,6 +8868,36 @@ "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/process-nextick-args": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz", @@ -8336,6 +9047,14 @@ "react": "^16.8.0 || ^17 || ^18 || ^19" } }, + "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-router": { "version": "7.14.1", "resolved": "https://registry.npmjs.org/react-router/-/react-router-7.14.1.tgz", @@ -8481,6 +9200,16 @@ "node": ">=0.10.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/resedit": { "version": "1.7.2", "resolved": "https://registry.npmjs.org/resedit/-/resedit-1.7.2.tgz", @@ -8752,6 +9481,19 @@ "node": ">=11.0.0" } }, + "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", @@ -8842,6 +9584,13 @@ "url": "https://github.com/sponsors/ljharb" } }, + "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/signal-exit": { "version": "3.0.7", "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", @@ -8996,6 +9745,13 @@ "node": "^12.13.0 || ^14.15.0 || >=16.0.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/stat-mode": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/stat-mode/-/stat-mode-1.0.0.tgz", @@ -9006,6 +9762,13 @@ "node": ">= 6" } }, + "node_modules/std-env": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.3.0.tgz", + "integrity": "sha512-OtU/EgQ1kIm5KwqQpBC6ZEMXrZRui11w8zgfTWp8cdO9B8OaPsbA8bTHO2P+HNo1VlUTGMVBwPhydu6poeXiag==", + "dev": true, + "license": "MIT" + }, "node_modules/string_decoder": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", @@ -9170,6 +9933,13 @@ "url": "https://github.com/sponsors/ljharb" } }, + "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.5.0", "resolved": "https://registry.npmjs.org/tailwind-merge/-/tailwind-merge-3.5.0.tgz", @@ -9370,6 +10140,23 @@ "integrity": "sha512-wtTRjG92pM5eUg/KuUnHsqSAlPM296brTOcLgMRqEeylYTh/CdtvKUvCyyCQTzFuStieWxvZb8mVTMvdPyUpxg==", "license": "MIT" }, + "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.3.1", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.3.1.tgz", + "integrity": "sha512-GCvB3aoys96IuDFBMcTB46JOR6mdMtAToqwiW8JlWhsoh1mhHi/xn9ss/Dg7N555GiJyEt2qzoG/NHCwM6h1EA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/tinyglobby": { "version": "0.2.16", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.16.tgz", @@ -9386,6 +10173,36 @@ "url": "https://github.com/sponsors/SuperchupuDev" } }, + "node_modules/tinyrainbow": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.2.0.tgz", + "integrity": "sha512-LgO3D9yZJjApUiuUfl9iFAwrtaX4+lok3wJIqttGoKCHlWUqHqbQpnxCf82L8FjgKsh4iGo78hqJwgL8F6To2A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tldts": { + "version": "7.4.18", + "resolved": "https://registry.npmjs.org/tldts/-/tldts-7.4.18.tgz", + "integrity": "sha512-somxA8xKAUzlAxl0TX/jMSYIbRNFIVGnI96pVGJPeMc5BsHmfVXn6mOJT6U++ELnl04eGfzpfcEuZe8WiASkXg==", + "dev": true, + "license": "MIT", + "dependencies": { + "tldts-core": "^7.4.18" + }, + "bin": { + "tldts": "bin/cli.js" + } + }, + "node_modules/tldts-core": { + "version": "7.4.18", + "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.4.18.tgz", + "integrity": "sha512-KI2PKvCmvQI/xD6ypPqkU0i7oxNXR/O58RLRaHR4svsgVUQ2JE/XC8BtMbG6PSehD27I8sZJRcC1ASnCy6lp3g==", + "dev": true, + "license": "MIT" + }, "node_modules/tmp": { "version": "0.2.5", "resolved": "https://registry.npmjs.org/tmp/-/tmp-0.2.5.tgz", @@ -9418,6 +10235,32 @@ "node": ">=8.0" } }, + "node_modules/tough-cookie": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-6.0.2.tgz", + "integrity": "sha512-exgYmnmL/sJpR3upZfXG5PoatXQii55xAiXGXzY+sROLZ/Y+SLcp9PgJNI9Vz37HpQ74WvDcLT8eqm+kV3FzrA==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "tldts": "^7.0.5" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/tr46": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-6.0.0.tgz", + "integrity": "sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==", + "dev": true, + "license": "MIT", + "dependencies": { + "punycode": "^2.3.1" + }, + "engines": { + "node": ">=20" + } + }, "node_modules/tree-kill": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/tree-kill/-/tree-kill-1.2.2.tgz", @@ -9562,6 +10405,16 @@ "node": ">= 16" } }, + "node_modules/undici": { + "version": "7.30.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-7.30.0.tgz", + "integrity": "sha512-dkrQXeHSaoamnItlYbmzG0wFYrM0ZwDxCIg0A7aKjTyyhh9svRzCNFEzV+Vm05/yehjCzjDZ31KXfGEjYSztDQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20.18.1" + } + }, "node_modules/undici-types": { "version": "6.21.0", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", @@ -9753,6 +10606,109 @@ } } }, + "node_modules/vitest": { + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.11.tgz", + "integrity": "sha512-fhACrNXUidIbGSBr5FlbuBkO7VWC1ZyLl0DO4CU2DrQoAPxX84Ysxs+HeGQpii5lZWV1Q4gBZTTu49mF+A6Edw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/expect": "4.1.11", + "@vitest/mocker": "4.1.11", + "@vitest/pretty-format": "4.1.11", + "@vitest/runner": "4.1.11", + "@vitest/snapshot": "4.1.11", + "@vitest/spy": "4.1.11", + "@vitest/utils": "4.1.11", + "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.11", + "@vitest/browser-preview": "4.1.11", + "@vitest/browser-webdriverio": "4.1.11", + "@vitest/coverage-istanbul": "4.1.11", + "@vitest/coverage-v8": "4.1.11", + "@vitest/ui": "4.1.11", + "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/wait-on": { "version": "8.0.5", "resolved": "https://registry.npmjs.org/wait-on/-/wait-on-8.0.5.tgz", @@ -9783,6 +10739,41 @@ "defaults": "^1.0.3" } }, + "node_modules/webidl-conversions": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-8.0.1.tgz", + "integrity": "sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=20" + } + }, + "node_modules/whatwg-mimetype": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-5.0.0.tgz", + "integrity": "sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20" + } + }, + "node_modules/whatwg-url": { + "version": "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/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", @@ -9799,6 +10790,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/wide-align": { "version": "1.1.5", "resolved": "https://registry.npmjs.org/wide-align/-/wide-align-1.1.5.tgz", @@ -9884,6 +10892,16 @@ } } }, + "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/xmlbuilder": { "version": "15.1.1", "resolved": "https://registry.npmjs.org/xmlbuilder/-/xmlbuilder-15.1.1.tgz", @@ -9894,6 +10912,13 @@ "node": ">=8.0" } }, + "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/y18n": { "version": "5.0.8", "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", diff --git a/swproject/package.json b/swproject/package.json index c6c5efc..31a8f70 100644 --- a/swproject/package.json +++ b/swproject/package.json @@ -12,7 +12,9 @@ "electron:build-main": "tsc --project tsconfig.electron.json", "electron:dev": "concurrently \"npm run dev\" \"wait-on http://localhost:5173 && npm run electron:build-main && cross-env NODE_ENV=development electron dist-electron/main.js\"", "electron:build": "npm run stt:build && npm run electron:build-main && npm run build && electron-builder --publish never", - "electron:preview": "npm run stt:build && npm run electron:build-main && npm run build && cross-env NODE_ENV=production electron dist-electron/main.js" + "electron:preview": "npm run stt:build && npm run electron:build-main && npm run build && cross-env NODE_ENV=production electron dist-electron/main.js", + "test": "vitest run", + "test:watch": "vitest" }, "dependencies": { "@hookform/resolvers": "^5.2.2", @@ -37,6 +39,7 @@ "devDependencies": { "@eslint/js": "^9.39.4", "@tailwindcss/postcss": "^4.2.3", + "@testing-library/react": "^16.3.3", "@types/node": "^20", "@types/react": "^19", "@types/react-dom": "^19", @@ -52,12 +55,14 @@ "eslint-plugin-react-hooks": "^7.1.1", "eslint-plugin-react-refresh": "^0.5.2", "globals": "^17.6.0", + "jsdom": "^29.1.1", "postcss": "^8.5.10", "tailwindcss": "^3.4.19", "tsx": "^4.21.0", "typescript": "^5", "typescript-eslint": "^8.59.2", "vite": "^8.0.9", + "vitest": "^4.1.11", "wait-on": "^8.0.2" }, "build": { @@ -93,7 +98,12 @@ }, "mac": { "target": [ - { "target": "dmg", "arch": ["arm64"] } + { + "target": "dmg", + "arch": [ + "arm64" + ] + } ], "category": "public.app-category.utilities", "icon": "build/icon.icns", @@ -105,7 +115,12 @@ }, "win": { "target": [ - { "target": "nsis", "arch": ["x64"] } + { + "target": "nsis", + "arch": [ + "x64" + ] + } ], "icon": "build/icon.png", "artifactName": "LiveBuddy-win-${arch}.${ext}" diff --git a/swproject/src/features/broadcast/hooks/useTerminateBroadcast.test.ts b/swproject/src/features/broadcast/hooks/useTerminateBroadcast.test.ts new file mode 100644 index 0000000..cda5b9e --- /dev/null +++ b/swproject/src/features/broadcast/hooks/useTerminateBroadcast.test.ts @@ -0,0 +1,62 @@ +/** @file Broadcast termination behavior across server success, absence and failure. */ +import { afterEach, beforeEach, expect, it, vi } from 'vitest'; +import { act, cleanup, renderHook } from '@testing-library/react'; +import { useTerminateBroadcast } from './useTerminateBroadcast'; +import { terminateBroadcast } from '@/features/broadcast/api/broadcastApi'; +import { useAIModeStore } from '@/shared/stores/aiModeStore'; +import { useAlarmStore } from '@/shared/stores/alarmStore'; + +vi.mock('@/features/broadcast/api/broadcastApi', () => ({ terminateBroadcast: vi.fn() })); +afterEach(cleanup); +beforeEach(() => { + vi.mocked(terminateBroadcast).mockReset(); + useAIModeStore.getState().resetToDefaults(); + useAlarmStore.getState().clearAll(); + useAIModeStore.getState().setBroadcast('stream-1', '2026-10-08T10:00:00Z'); + useAIModeStore.getState().setCurrentTranscript('Previous character'); + useAIModeStore.getState().setEmotion('HAPPY'); + useAIModeStore.getState().upsertDialogues([{ + id: '1', cursorId: 1, speaker: 'ai', subject: 'AI_CHARACTER', text: 'Old reply', + emotion: 'HAPPY', timestamp: '2026-10-08T10:01:00Z', + }], 1, true); +}); + +it('clears the broadcast and previous character dialogue after successful termination', async () => { + vi.mocked(terminateBroadcast).mockResolvedValue({ + terminatedBroadcastStreamId: 'stream-1', broadcastStatus: 'TERMINATED', broadcastTerminatedAt: '2026-10-08T11:00:00Z', + }); + const { result } = renderHook(useTerminateBroadcast); + await act(async () => { await result.current.terminate(); }); + expect(useAIModeStore.getState()).toMatchObject({ + mode: 'idle', broadcastStreamId: null, broadcastStartedAt: null, + dialogues: [], currentTranscript: '', currentEmotion: 'DEFAULT', nextCursor: null, hasNextDialogues: false, + }); + expect(useAlarmStore.getState().entries[0].key).toBe('broadcast.ended'); + expect(result.current).toMatchObject({ isPending: false, error: null }); +}); + +it('treats an already missing broadcast (404) as locally terminated', async () => { + vi.mocked(terminateBroadcast).mockRejectedValue({ response: { status: 404 } }); + const { result } = renderHook(useTerminateBroadcast); + await act(async () => { await result.current.terminate(); }); + expect(useAIModeStore.getState()).toMatchObject({ mode: 'idle', broadcastStreamId: null, dialogues: [], currentTranscript: '' }); + expect(result.current).toMatchObject({ isPending: false, error: null }); +}); + +it('retains the active session and exposes a server failure so termination can be retried', async () => { + vi.mocked(terminateBroadcast).mockRejectedValue({ response: { status: 500 } }); + const { result } = renderHook(useTerminateBroadcast); + await act(async () => { await result.current.terminate(); }); + expect(useAIModeStore.getState().broadcastStreamId).toBe('stream-1'); + expect(useAIModeStore.getState().dialogues).toHaveLength(1); + expect(result.current.error).toBe('서버 오류로 방송을 종료하지 못했습니다.'); + expect(result.current.isPending).toBe(false); + expect(useAlarmStore.getState().entries).toHaveLength(0); +}); + +it('does not terminate the broadcast when its view unmounts', () => { + const { unmount } = renderHook(useTerminateBroadcast); + unmount(); + expect(terminateBroadcast).not.toHaveBeenCalled(); + expect(useAIModeStore.getState().broadcastStreamId).toBe('stream-1'); +}); diff --git a/swproject/src/shared/lib/axios.test.ts b/swproject/src/shared/lib/axios.test.ts new file mode 100644 index 0000000..356603b --- /dev/null +++ b/swproject/src/shared/lib/axios.test.ts @@ -0,0 +1,99 @@ +/** @file Regression tests for concurrent authentication failures; no live server. */ +import { beforeEach, expect, it, vi } from 'vitest'; +import { AxiosError, type InternalAxiosRequestConfig } from 'axios'; +import apiClient, { bareClient } from './axios'; +import { useAuthStore } from '@/shared/stores/authStore'; + +function reply(config: InternalAxiosRequestConfig, data: unknown, status = 200) { + return { config, data, status, statusText: String(status), headers: {} }; +} +function unauthorized(config: InternalAxiosRequestConfig) { + return Promise.reject(new AxiosError('Unauthorized', 'ERR_BAD_REQUEST', config, undefined, reply(config, {}, 401))); +} +function deferred() { + let resolve!: (value: T) => void; + let reject!: (reason: unknown) => void; + const promise = new Promise((yes, no) => { resolve = yes; reject = no; }); + return { promise, resolve, reject }; +} + +beforeEach(() => { + localStorage.clear(); + useAuthStore.getState().setTokens('old-access', 'old-refresh'); + window.location.hash = '#/dashboard'; +}); + +it('shares one refresh across simultaneous 401s and retries each with the new token', async () => { + const gate = deferred<{ data: { accessToken: string; refreshToken: string } }>(); + const refresh = vi.spyOn(bareClient, 'post').mockReturnValue(gate.promise); + const requests: string[] = []; + apiClient.defaults.adapter = async config => { + requests.push(String(config.headers.Authorization)); + if (config.headers.Authorization === 'Bearer old-access') return unauthorized(config); + return reply(config, { success: true, data: { ok: true } }); + }; + const pending = Promise.all([apiClient.get('/one'), apiClient.get('/two')]); + await vi.waitFor(() => expect(requests).toHaveLength(2)); + await vi.waitFor(() => expect(refresh).toHaveBeenCalledTimes(1)); + gate.resolve({ data: { accessToken: 'new-access', refreshToken: 'new-refresh' } }); + expect((await pending).map(r => r.data)).toEqual([{ ok: true }, { ok: true }]); + expect(requests).toEqual(['Bearer old-access', 'Bearer old-access', 'Bearer new-access', 'Bearer new-access']); + expect(refresh).toHaveBeenCalledWith('/api/v1/auth/refresh', { refreshToken: 'old-refresh' }); +}); + +it('rejects every waiting request and clears authentication when refresh fails', async () => { + const gate = deferred(); + const refresh = vi.spyOn(bareClient, 'post').mockReturnValue(gate.promise); + let attempts = 0; + apiClient.defaults.adapter = config => { attempts++; return unauthorized(config); }; + const pending = Promise.allSettled([apiClient.get('/one'), apiClient.get('/two')]); + await vi.waitFor(() => expect(attempts).toBe(2)); + await vi.waitFor(() => expect(refresh).toHaveBeenCalledTimes(1)); + gate.reject(new Error('Refresh expired')); + expect((await pending).map(r => r.status)).toEqual(['rejected', 'rejected']); + expect(useAuthStore.getState().accessToken).toBeNull(); + expect(useAuthStore.getState().refreshToken).toBeNull(); + expect(window.location.hash).toBe('#/login'); +}); + +it('does not start another refresh when a queued request is still unauthorized after retry', async () => { + const gate = deferred<{ data: { accessToken: string; refreshToken: string } }>(); + const refresh = vi.spyOn(bareClient, 'post') + .mockResolvedValue({ data: { accessToken: 'third-access', refreshToken: 'third-refresh' } }) + .mockReturnValueOnce(gate.promise); + const attempts: Record = {}; + apiClient.defaults.adapter = async config => { + const url = config.url!; + attempts[url] = (attempts[url] ?? 0) + 1; + if (attempts[url] === 1 || (url === '/two' && attempts[url] === 2)) return unauthorized(config); + return reply(config, { ok: true }); + }; + const pending = Promise.allSettled([apiClient.get('/one'), apiClient.get('/two')]); + await vi.waitFor(() => expect(attempts).toEqual({ '/one': 1, '/two': 1 })); + await vi.waitFor(() => expect(refresh).toHaveBeenCalledTimes(1)); + gate.resolve({ data: { accessToken: 'new-access', refreshToken: 'new-refresh' } }); + const results = await pending; + expect(refresh).toHaveBeenCalledTimes(1); + expect(results.map(r => r.status)).toEqual(['fulfilled', 'rejected']); + expect(attempts['/two']).toBe(2); +}); + +it('returns a rejected login without refreshing an unrelated stored session', async () => { + const refresh = vi.spyOn(bareClient, 'post').mockRejectedValue(new Error('Should not refresh')); + const headers: unknown[] = []; + apiClient.defaults.adapter = config => { headers.push(config.headers.Authorization); return unauthorized(config); }; + await expect(apiClient.post('/api/v1/auth/login/email', { email: 'wrong@example.com' })).rejects.toBeDefined(); + expect(headers).toEqual([undefined]); + expect(refresh).not.toHaveBeenCalled(); + expect(useAuthStore.getState().accessToken).toBe('old-access'); +}); + +it('clears an expired session with no refresh token without making a refresh request', async () => { + useAuthStore.setState({ refreshToken: null }); + const refresh = vi.spyOn(bareClient, 'post'); + apiClient.defaults.adapter = unauthorized; + await expect(apiClient.get('/one')).rejects.toBeDefined(); + expect(refresh).not.toHaveBeenCalled(); + expect(useAuthStore.getState().accessToken).toBeNull(); + expect(window.location.hash).toBe('#/login'); +}); diff --git a/swproject/src/shared/lib/axios.ts b/swproject/src/shared/lib/axios.ts index 59d0f23..4c70fc9 100644 --- a/swproject/src/shared/lib/axios.ts +++ b/swproject/src/shared/lib/axios.ts @@ -157,7 +157,12 @@ apiClient.interceptors.response.use( }; // 401 에러이고 아직 재시도하지 않은 요청만 처리 - if (error.response?.status === 401 && originalRequest && !originalRequest._retry) { + const isPublicAuth = AUTH_PUBLIC_PATHS.some((path) => + (originalRequest?.url ?? '').startsWith(path) + ); + if (error.response?.status === 401 && originalRequest && !originalRequest._retry && !isPublicAuth) { + // 대기 큐에 들어가는 요청도 재시도 횟수를 제한한다. + originalRequest._retry = true; // 이미 토큰 재발급 중이면 큐에 대기 if (isRefreshing) { return new Promise((resolve, reject) => { @@ -171,7 +176,6 @@ apiClient.interceptors.response.use( } // 토큰 재발급 시작 (동시 요청 차단) - originalRequest._retry = true; isRefreshing = true; // 리프레시 토큰이 없으면 재발급 불가 → 인증 정리 + 로그인 페이지로 diff --git a/swproject/src/test/setup.ts b/swproject/src/test/setup.ts new file mode 100644 index 0000000..a3fbb5a --- /dev/null +++ b/swproject/src/test/setup.ts @@ -0,0 +1,18 @@ +/** @file Deterministic in-memory browser storage for store and hook tests. */ +import { vi } from 'vitest'; + +function createStorage(): Storage { + const values = new Map(); + return { + get length() { return values.size; }, + key: index => [...values.keys()][index] ?? null, + getItem: key => values.get(String(key)) ?? null, + setItem: (key, value) => { values.set(String(key), String(value)); }, + removeItem: key => { values.delete(String(key)); }, + clear: () => { values.clear(); }, + }; +} + +// Node 25 also exposes Web Storage; do not depend on its process-level backing file. +vi.stubGlobal('localStorage', createStorage()); +vi.stubGlobal('sessionStorage', createStorage()); diff --git a/swproject/vitest.config.ts b/swproject/vitest.config.ts new file mode 100644 index 0000000..fe29e73 --- /dev/null +++ b/swproject/vitest.config.ts @@ -0,0 +1,8 @@ +/** @file Isolated browser-like tests for API clients and React hooks. */ +import { defineConfig } from 'vitest/config'; +import { fileURLToPath } from 'node:url'; + +export default defineConfig({ + resolve: { alias: { '@': fileURLToPath(new URL('./src', import.meta.url)) } }, + test: { environment: 'jsdom', setupFiles: ['./src/test/setup.ts'], include: ['src/**/*.test.ts'], restoreMocks: true }, +}); From 4cae376a2955a151c50281085701a1c6ec6a5374 Mon Sep 17 00:00:00 2001 From: LeeJJang1 Date: Thu, 8 Oct 2026 23:21:05 +0900 Subject: [PATCH 2/2] docs: record browser authentication screen checks --- docs/BASELINE.md | 5 +++++ docs/BROWSER_QA.md | 41 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 46 insertions(+) create mode 100644 docs/BROWSER_QA.md diff --git a/docs/BASELINE.md b/docs/BASELINE.md index 825d447..b9f58ef 100644 --- a/docs/BASELINE.md +++ b/docs/BASELINE.md @@ -45,3 +45,8 @@ - 프론트 빌드·Electron 컴파일 성공, 린트 오류 0 / 기존 경고 7. - 이 테스트는 전송 경계를 대체한 단위·훅 테스트입니다. WebSocket·TTS 자원 해제와 실제 OBS·서버 연결, 전체 페이지 이동 통합은 미검증입니다. - PR 자동 검사 워크플로를 추가했습니다. 위의 최초 기준선은 과거 기록이며 현재 테스트 명령은 `npm test`입니다. + + +## 브라우저 UI 확인 (2026-10-08) + +로그인 전 입력 검증, 회원가입 이동, 미로그인 접근 가드 및 새로고침을 로컬 브라우저에서 확인했습니다. 실제 서버 인증과 Electron 장치 연동은 미검증입니다. 재현 절차와 범위는 [BROWSER_QA.md](BROWSER_QA.md)를 참고하세요. diff --git a/docs/BROWSER_QA.md b/docs/BROWSER_QA.md new file mode 100644 index 0000000..f025eac --- /dev/null +++ b/docs/BROWSER_QA.md @@ -0,0 +1,41 @@ +# 브라우저 화면 검증 결과 + +- 검증일: 2026-10-08 +- 대상: Live Buddy 0.1.20, `test/auth-broadcast-regressions` 브랜치의 2ef7895 코드 +- 환경: macOS, Vite 개발 서버, Codex 내장 브라우저 +- 주소: `http://127.0.0.1:5173` +- 범위: 로그인 전 화면. 실제 서버 로그인·회원가입·방송은 수행하지 않았습니다. + +## 확인 결과 + +| 시나리오 | 관찰 결과 | +| --- | --- | +| 첫 진입 | 로그인 화면 표시, 이메일 필드 자동 포커스 | +| 빈 로그인 폼 제출 | 이메일 형식 및 비밀번호 8자 이상 안내 표시 | +| 비밀번호 보기 | 버튼이 숨기기 상태로 전환되고 입력 유지 | +| 회원가입 링크 | 회원가입 화면 이동, 이름 필드 자동 포커스 | +| 비밀번호 확인 불일치 | 불일치 안내 표시, 회원가입 화면 유지 | +| 미로그인 대시보드 직접 접근 | 로그인 화면으로 이동 | +| 로그인 화면 새로고침 | 로그인 화면 정상 표시 | +| 브라우저 오류 로그 확인 | 수집된 error 로그 없음 | + +이 범위에서 재현된 기능 오류는 없었습니다. Google 로그인·회원가입 버튼은 준비 중으로 비활성화되어 있었으며, 해당 기능의 완료를 의미하지 않습니다. 비밀번호 표시는 상태 전환을 확인했으며 보안 기능 전반을 감사한 것은 아닙니다. + +## 재현 순서 + +1. `swproject`에서 `npm run dev -- --host 127.0.0.1` 실행. +2. 저장된 로그인 정보가 없는 브라우저에서 위 주소 열기. +3. 빈 상태에서 로그인 클릭 후 두 입력 안내 확인. +4. 테스트용 비밀번호를 입력하고 보기 버튼 전환 확인. +5. 회원가입 링크 클릭 후 테스트 이름·이메일과 서로 다른 비밀번호 입력. +6. 가입하기 클릭 후 비밀번호 불일치 안내 확인. 실제 계정은 생성하지 않음. +7. `/#/dashboard`로 직접 이동하여 `/#/login` 복귀 확인. +8. 새로고침 후 화면 및 오류 로그 확인. + +## 다음 통합 검증에 필요한 조건 + +- 사용 가능한 개발 백엔드와 테스트 계정: 정상 로그인, 캐릭터 조회·수정, 로그인 이후 페이지 이동 +- Electron 실행과 마이크 권한: 음성 입력, 방송 종료 후 장치·연결 정리 +- OBS 테스트 환경: 캐릭터·오버레이 동기화 + +이번 결과는 브라우저 UI 확인이며 Electron 설치 파일, 실서버, 마이크, OBS 동작을 보장하지 않습니다. 테스트 계정이나 운영 계정의 비밀번호는 문서에 기록하지 않습니다.