From 6874632178e007713ce0bdc16479fe1d19034831 Mon Sep 17 00:00:00 2001 From: Shawn Kang <77564014+i-meant-to-be@users.noreply.github.com> Date: Mon, 18 May 2026 13:43:29 +0900 Subject: [PATCH 1/7] =?UTF-8?q?[FEAT]=20STOMP=20=EC=9B=B9=20=EC=86=8C?= =?UTF-8?q?=EC=BC=93=20=EC=9A=94=EC=B2=AD=20=ED=95=A8=EC=88=98=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80=20(#448)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * docs: Gemini + Codex 멀티 에이전트 개발을 위한 부트스트랩 추가 * feat: 화면 공유를 위한 임시 액세스 토큰 발급 기능 구현 * feat: 소켓 훅 디렉토리 변경 및 테스트 코드 추가 * refactor: SocketManager가 URL을 매개변수로 받도록 수정 * feat: 사회자 및 청중을 위한 소켓 훅 추가 * feat: 공유 아이콘 추가 * chore: 기타 변경 사항 * docs: fix spelling error * fix: apply CodeRabbit's reviews * test: GitHub Actions 러너 환경에서의 테스트 오류 해결 * refactor: 에러 메시지 구체화 * fix: 재구독 또는 새 연결 수립 시 기존 데이터가 사라지지 않는 문제 수정 * fix: 린팅 오류 해결 * test: 테스트 로직 보강 * refactor: ResolvedSocketOption 타입의 가독성 개선 * feat: 웹 소켓 연결 종료 시 TanStack Query의 임시 액세스 토큰 캐시 비우도록 수정 * chore: 린팅 경고 수정 --- .agent/codex/evaluate.md | 420 +++ .agent/codex/how-to-use.md | 408 +++ .agent/codex/implement.md | 529 ++++ .agent/gemini/analyze.md | 30 + .agent/gemini/clarify.md | 38 + .agent/gemini/evaluate.md | 47 + .agent/gemini/feature-request-template.md | 62 + .agent/gemini/how-to-use.md | 96 + .agent/gemini/plan.md | 41 + .agent/gemini/specify.md | 53 + .agent/gemini/tasks.md | 30 + .specify/memory/constitution.md | 8 +- package-lock.json | 2260 +++++++++-------- package.json | 2 +- public/mockServiceWorker.js | 2 +- src/apis/apis/share.ts | 12 + src/apis/endpoints.ts | 1 + src/apis/responses/share.ts | 3 + src/apis/sockets/SocketManager.test.ts | 22 + src/apis/sockets/SocketManager.ts | 43 +- src/apis/sockets/util.ts | 24 + src/components/icons/Icon.stories.tsx | 13 + src/components/icons/ShareLive.tsx | 44 + src/hooks/query/useGetChairmanToken.ts | 20 + src/hooks/sockets/useAudienceSocket.test.ts | 196 ++ src/hooks/sockets/useAudienceSocket.ts | 106 + src/hooks/sockets/useChairmanSocket.test.ts | 276 ++ src/hooks/sockets/useChairmanSocket.ts | 155 ++ src/hooks/sockets/useSocket.test.ts | 176 ++ src/hooks/{ => sockets}/useSocket.ts | 23 +- src/hooks/usePageTracking.test.tsx | 6 +- src/hooks/usePageTracking.ts | 10 +- .../TableCompositionPage.test.tsx | 30 +- .../TableSharingPage/TableSharingPage.tsx | 5 +- .../TimerPage/components/NormalTimer.test.tsx | 4 +- 35 files changed, 4104 insertions(+), 1091 deletions(-) create mode 100644 .agent/codex/evaluate.md create mode 100644 .agent/codex/how-to-use.md create mode 100644 .agent/codex/implement.md create mode 100644 .agent/gemini/analyze.md create mode 100644 .agent/gemini/clarify.md create mode 100644 .agent/gemini/evaluate.md create mode 100644 .agent/gemini/feature-request-template.md create mode 100644 .agent/gemini/how-to-use.md create mode 100644 .agent/gemini/plan.md create mode 100644 .agent/gemini/specify.md create mode 100644 .agent/gemini/tasks.md create mode 100644 src/apis/apis/share.ts create mode 100644 src/apis/responses/share.ts create mode 100644 src/apis/sockets/util.ts create mode 100644 src/components/icons/ShareLive.tsx create mode 100644 src/hooks/query/useGetChairmanToken.ts create mode 100644 src/hooks/sockets/useAudienceSocket.test.ts create mode 100644 src/hooks/sockets/useAudienceSocket.ts create mode 100644 src/hooks/sockets/useChairmanSocket.test.ts create mode 100644 src/hooks/sockets/useChairmanSocket.ts create mode 100644 src/hooks/sockets/useSocket.test.ts rename src/hooks/{ => sockets}/useSocket.ts (91%) diff --git a/.agent/codex/evaluate.md b/.agent/codex/evaluate.md new file mode 100644 index 00000000..93d267bf --- /dev/null +++ b/.agent/codex/evaluate.md @@ -0,0 +1,420 @@ +# Codex Implementation Evaluation Bootstrap + +This document is the reusable evaluation bootstrap for completed Codex implementation work. Use it after implementation has already been performed to decide whether the result is acceptable, plan-aligned, repository-compliant, validated, and safe to hand off. + +This bootstrap is for evaluation only. Do not implement features, fix defects, refactor code, or edit implementation files while using it. + +## Role + +Codex is the implementation evaluator. Act as an auditor, not as an implementer. + +Gemini is the upstream planner and specifier. Gemini planning artifacts define the intended implementation direction unless they conflict with higher-priority repository rules or user-approved decisions. + +The implementation Codex session is the producer of code changes, validation results, implementation reports, unresolved issue notes, and handoff summaries. + +The user is the final approver for ambiguity, contradiction, risk, user-approved deviations, and final acceptance. + +## Project Constants + +Edit only this section when adapting the bootstrap to a different project. + +- `STACK`: React + TypeScript + Vite + Vitest +- `IMPLEMENT_BOOTSTRAP_PATH`: `./.agent/codex/implement.md` +- `README_PATH`: `./README.md` +- `CONSTITUTION_PATH`: `./.specify/memory/constitution.md` +- `SPEC_PATH`: `./specs/spec.md` +- `PLAN_PATH`: `./specs/plan.md` +- `TASKS_DIR`: `./specs/tasks/` +- `TASK_OVERVIEW_PATH`: `./specs/tasks/00-project-overview.md` +- `TARGET_TASK_PATTERN`: `./specs/tasks/task-NN-task_name.md` +- `DEFAULT_EVALUATION_REPORT_PATH`: `./specs/codex-implementation-eval.md` +- `VALIDATION_COMMANDS`: + - `npm run lint` + - `npm run test` + +For this repository, evaluate implementation work against React, TypeScript, Vite, Vitest, Testing Library, MSW, TanStack Query, Axios, Tailwind CSS, i18next, and the local architecture described by repository files. + +## Required Inputs + +Future Codex evaluation sessions should receive: + +- Evaluation scope: `single-task`, `task-set`, or `all-tasks`. +- Target task path for `single-task`. +- Explicit target task path list for `task-set`. +- Confirmation to evaluate the completed implementation across `TASK_OVERVIEW_PATH` and relevant `task-*.md` files under `TASKS_DIR` for `all-tasks`. +- The implementation report produced by the implementation Codex session, including the executed scope. +- The list of changed files, if not already included in the report. +- Validation results for: + - `npm run lint` + - `npm run test` +- Any known unresolved issues. +- Any user-approved deviations from the original plan. +- Any relevant handoff summary. + +If any required input is missing, ambiguous, contradictory, or lacks evidence, stop and ask the user for clarification before evaluating. + +Do not infer missing implementation details. Do not accept unsupported claims in the implementation report as proof. Treat ambiguity clarification as mandatory. + +## Evaluation Scope + +Future Codex evaluation sessions must identify the evaluation scope before evaluating. + +Supported scopes: + +- `single-task`: Evaluate exactly one specified task file. Requires one target task path matching `TARGET_TASK_PATTERN`. +- `task-set`: Evaluate multiple user-specified task files. Requires the user to provide the exact target task paths. Codex must not infer the task set if the user does not provide it. +- `all-tasks`: Evaluate the completed implementation across the full task set described by `TASK_OVERVIEW_PATH` and relevant `task-*.md` files under `TASKS_DIR`. + +If the user does not specify the scope, stop and ask whether to evaluate `single-task`, `task-set`, or `all-tasks`. + +For `task-set` and `all-tasks`, evaluate task ordering, dependency handling, and cross-task consistency. + +For `all-tasks`, inspect the task overview and relevant task files to determine the expected full task set, sequence, and dependencies. + +Stop and ask the user if the evaluated scope, task inclusion, task order, or dependency relationship is ambiguous. + +The evaluation report should be one report for the evaluated scope. Do not add task-specific report paths unless the user explicitly requests them. + +## Strict Mode + +Apply strict mode throughout the evaluation: + +- Do not guess. +- Do not infer missing validation results. +- Do not assume a file was changed correctly without inspecting it. +- Do not assume tests passed unless validation output or the implementation report proves it. +- Do not silently accept deviations from the plan. +- Do not treat unsupported implementation-report claims as evidence. +- Do not silently choose one interpretation when requirements, results, scope, ordering, dependencies, or task inclusion are ambiguous. +- Stop and ask the user if ambiguity, missing information, contradiction, or unverifiable claims affect the evaluation. + +Ask precise clarification questions and wait for the user's answer before continuing. + +## Evaluation Workflow + +### Phase 1: Context Ingestion + +Read the following inputs in order: + +1. `IMPLEMENT_BOOTSTRAP_PATH` +2. `README_PATH` +3. `CONSTITUTION_PATH` +4. `SPEC_PATH` +5. `PLAN_PATH` +6. `TASK_OVERVIEW_PATH` +7. Scope-specific task files: + - For `single-task`: the one target task file + - For `task-set`: each user-specified target task file + - For `all-tasks`: all relevant `task-*.md` files under `TASKS_DIR` +8. Implementation report or handoff summary +9. Changed files +10. Validation output, if available + +If a required document is unavailable and the absence affects evaluation, stop and ask the user how to proceed. + +### Phase 2: Implementation Evidence Review + +Verify: + +- What execution scope was implemented. +- What target tasks were implemented or claimed. +- What files changed. +- Why those files changed. +- How changes map to the selected evaluation scope. +- Whether changes are limited to the selected scope. +- Whether task order and dependencies were respected for `task-set` and `all-tasks`. +- Whether cross-task behavior is consistent for `task-set` and `all-tasks`. +- Whether tests were added or updated where expected. +- Whether validation was run. +- Whether validation failures were disclosed. +- Whether unresolved issues were reported. +- Whether deviations from the plan were approved by the user. + +Use direct file inspection, planning artifacts, implementation reports, validation output, and user-provided context as evidence. + +### Phase 3: Criteria-Based Evaluation + +Evaluate the completed implementation against the strict Boolean criteria in `Evaluation Criteria`. + +Each criterion must be marked exactly `[Yes]` or `[No]`. Do not use partial credit in criterion status. + +### Phase 4: Report Generation + +Write one evaluation report for the evaluated scope to `DEFAULT_EVALUATION_REPORT_PATH`, unless the user provides another path. + +The report must follow the `Report Format` section exactly enough to preserve all required headings, criteria, verdict, and score. + +### Phase 5: Final Response + +Summarize: + +- Final score. +- Final verdict. +- Report path. +- Whether the implementation should be accepted, revised, or halted. + +Do not include fixes or implementation changes in the final response. + +## Evaluation Criteria + +Evaluate each criterion as `[Yes]` or `[No]`. + +1. **Plan Alignment** + - Does the implementation match the selected scope, target Gemini tasks, and project overview without unauthorized redesign? + - For `task-set` and `all-tasks`, does it respect planned ordering, dependencies, and cross-task expectations? + +2. **Repository Rule Compliance** + - Does the implementation comply with README, constitution, naming conventions, architecture, i18n rules, testing rules, and local patterns? + +3. **Ambiguity Handling** + - Were ambiguities, contradictions, missing acceptance criteria, scope uncertainties, task ordering uncertainties, and dependency uncertainties clarified with the user instead of guessed through? + - Mark `[Yes]` when material ambiguity existed and was explicitly clarified with the user before implementation. + - Mark `[Yes]` when no material ambiguity is detectable after reviewing the selected scope, planning artifacts, implementation report, and changed files. + - Mark `[No]` when ambiguity existed but Codex guessed, silently chose one interpretation, or implemented behavior not clearly supported by the plan or user instruction. + +4. **Scope Control** + - Were changes limited to the selected scope without unrelated behavior changes, unrelated files, or broad refactoring? + - Did the implementation avoid adding tasks or behavior outside the selected `single-task`, `task-set`, or `all-tasks` scope? + +5. **Implementation Quality** + - Is the implementation technically coherent, maintainable, idiomatic for the stack, and consistent with nearby code? + - For `task-set` and `all-tasks`, are shared changes coherent across tasks and free of avoidable duplication or ordering defects? + +6. **Test Coverage and TDD Alignment** + - Were relevant tests added or updated according to the selected scope and plan, and does the work respect TDD expectations where required? + +7. **Validation Integrity** + - Were required validation commands run, and were results reported honestly? + - Required commands: + - `npm run lint` + - `npm run test` + +8. **Report Quality** + - Does the implementation report clearly include executed scope, target tasks, changed files, rationale, plan alignment, validation results, and unresolved issues? + +9. **Handoff Quality** + - For the selected scope, if work remains, does the handoff clearly identify remaining work, decisions made, validation status, and open questions? + - Mark `[Yes]` when remaining work exists and the handoff clearly documents remaining work, decisions, validation status, and open questions. + - Mark `[Yes]` when no remaining work exists for the selected scope and the implementation report clearly states that no follow-up is required. + - Mark `[No]` when work remains but the handoff omits remaining work, decisions, validation status, or open questions. + +## Evidence Rules + +Support every criterion with evidence. + +Acceptable evidence includes: + +- Direct inspection of changed files. +- Direct comparison with selected scope and target task requirements. +- Direct comparison with README and constitution rules. +- Validation command output. +- Implementation report contents. +- User-approved deviations. +- Nearby source and test patterns. +- Task overview, task order, dependency notes, and cross-task relationships for `task-set` and `all-tasks`. + +Unacceptable evidence includes: + +- Assumptions. +- Unsupported claims in the implementation report. +- General best practices not grounded in the repository. +- Guesses about test behavior without validation output. +- Guesses about task inclusion, ordering, or dependencies. +- Claims that cannot be verified from available files or user-provided context. + +If changed files are available, inspect them. Do not treat the implementation report as sufficient evidence by itself. + +Raw validation output, terminal logs, CI output, or direct local rerun results are stronger evidence than a plain implementation-report claim. + +If the implementation report merely says validation passed but no output is available, do not treat that claim as strong evidence. Ask the user for validation output or clearly mark the residual risk in the evaluation report. + +If the evaluator can safely rerun validation without modifying files, it may do so when the user permits or when the evaluation request explicitly asks for it. + +## Verdict Rules + +Use these verdict categories: + +- `ACCEPT` + - The implementation satisfies all or nearly all criteria for the selected scope. + - No blocking issue remains. + - Validation passed, or any skipped validation is justified and low-risk. + +- `REVISE` + - The implementation is directionally correct but has fixable issues. + - Some criteria fail, but there is no fundamental design, dependency, or scope violation. + +- `HALT` + - The implementation is unsafe to continue without user decision. + - Examples include plan contradiction, unauthorized broad refactor, major validation failure, unapproved public behavior change, missing critical evidence, unresolved ambiguity affecting correctness, or unclear task inclusion/order/dependencies. + +Map score to verdict as follows: + +- `9/9`: `ACCEPT` +- `7/9` or `8/9`: usually `REVISE`, unless all failed criteria are explicitly non-blocking +- `0/9` to `6/9`: usually `HALT` or `REVISE`, depending on severity +- Any critical stop condition may force `HALT` regardless of score + +Explain any verdict that differs from the default score mapping. + +## Report Format + +Write the generated evaluation report to: + +`./specs/codex-implementation-eval.md` + +unless the user provides a different path. + +Use this report format: + +```markdown +# Codex Implementation Evaluation + +Evaluation scope: + +- + +Target tasks: + +- + +Implementation report reviewed: + +- + +Changed files reviewed: + +- + +Validation reviewed: + +- `npm run lint`: +- `npm run test`: + +## Criteria Evaluation + +**Plan Alignment: [Yes/No]** +<3 to 5 lines of evidence/reasoning> + +**Repository Rule Compliance: [Yes/No]** +<3 to 5 lines of evidence/reasoning> + +**Ambiguity Handling: [Yes/No]** +<3 to 5 lines of evidence/reasoning> + +**Scope Control: [Yes/No]** +<3 to 5 lines of evidence/reasoning> + +**Implementation Quality: [Yes/No]** +<3 to 5 lines of evidence/reasoning> + +**Test Coverage and TDD Alignment: [Yes/No]** +<3 to 5 lines of evidence/reasoning> + +**Validation Integrity: [Yes/No]** +<3 to 5 lines of evidence/reasoning> + +**Report Quality: [Yes/No]** +<3 to 5 lines of evidence/reasoning> + +**Handoff Quality: [Yes/No]** +<3 to 5 lines of evidence/reasoning> + +## Blocking Issues + +- + +## Non-Blocking Issues + +- + +## Required Follow-Up + +- + +## Final Verdict + + + +## Score + +**Score: n/9** +``` + +## Stop Conditions + +Stop and ask the user before writing a final evaluation report when: + +- The evaluation scope is missing or unclear. +- The target task path is missing or unclear for `single-task`. +- The explicit target task list is missing or unclear for `task-set`. +- Task inclusion, task order, or dependency relationships are unclear for `task-set` or `all-tasks`. +- The implementation report is missing. +- The implementation report does not identify the executed scope and the scope cannot be verified from other evidence. +- Changed files cannot be identified. +- Validation status is missing and materially affects the verdict. +- User-approved deviations are mentioned but not provided. +- Planning artifacts conflict with each other. +- Implementation appears to deviate from the selected scope or plan but the reason is unclear. +- Repository rules conflict with the selected scope. +- There is insufficient evidence to judge a critical criterion. +- Any ambiguity remains about requirements, scope, validation, changed files, user intent, acceptance criteria, task inclusion, task order, or dependencies. + +## Prohibited Behaviors + +Do not: + +- Implement fixes during evaluation. +- Edit implementation files. +- Modify `IMPLEMENT_BOOTSTRAP_PATH`. +- Modify Gemini-side evaluation bootstrap files. +- Silently fix problems found during evaluation. +- Invent missing validation results. +- Infer `task-set` membership when the user did not provide exact task paths. +- Guess task inclusion, order, or dependency relationships. +- Mark a criterion `[Yes]` without evidence. +- Treat the implementation report as sufficient evidence when changed files are available for inspection. +- Ignore failed validation. +- Ignore unapproved scope expansion. +- Downgrade serious architectural or public-behavior risks to minor issues. +- Continue past ambiguity without user clarification. +- Reward unsupported claims in the implementation report. +- Evaluate an actual implementation result when the user only requested creation or update of this bootstrap. + +## First Message Template for Future Codex Evaluation Sessions + +Use this template when asking Codex to evaluate a completed implementation: + +```markdown +Use `.agent/codex/evaluate.md` as the implementation evaluation bootstrap. + +Evaluation scope: + +- `` + +Target tasks: + +- `` + +Implementation report: + +- `` + +Changed files: + +- `` + +Validation results: + +- `npm run lint`: `` +- `npm run test`: `` + +User-approved deviations: + +- `` + +Handoff summary: + +- `` + +Please identify the evaluation scope, inspect the repository, read the implementation bootstrap, read the README and constitution, read the planning artifacts for the selected scope, review the implementation evidence, evaluate the result using strict Boolean criteria, and write one evaluation report for the selected scope. +``` diff --git a/.agent/codex/how-to-use.md b/.agent/codex/how-to-use.md new file mode 100644 index 00000000..05bc54b1 --- /dev/null +++ b/.agent/codex/how-to-use.md @@ -0,0 +1,408 @@ +# Codex 부트스트랩 사용법 + +## 1. 개요 + +이 문서는 Codex 작업을 안전하게 운영하기 위한 사용 가이드입니다. + +이 저장소에는 두 가지 Codex 부트스트랩이 있습니다. + +| 파일 | 용도 | 사용 시점 | +| --- | --- | --- | +| `./.agent/codex/implement.md` | Gemini가 만든 계획 문서를 기준으로 Codex가 구현을 수행하는 절차 정의 | 구현 전과 구현 중 | +| `./.agent/codex/evaluate.md` | Codex가 이미 완료한 구현 결과를 감사하고 평가하는 절차 정의 | 구현 완료 후 | + +핵심 원칙은 간단합니다. + +- 구현 세션은 코드를 바꾸는 세션입니다. +- 평가 세션은 구현 결과를 검토하는 세션입니다. +- 평가 세션에서는 구현 파일을 수정하면 안 됩니다. +- 파일 수정과 터미널 명령 실행은 명시적 사용자 승인이 필요합니다. + +## 2. 파일 구성 + +`implement.md`는 Codex가 구현 작업을 시작할 때 따라야 하는 규칙입니다. + +- README, constitution, spec, plan, task 문서를 읽는 순서 +- scope 선택 방식 +- 모호성 확인 방식 +- 파일 수정 전 승인 방식 +- 명령 실행 전 승인 방식 +- 구현 단위 분해 방식 +- 검증 명령어 처리 방식 +- 구현 결과 보고 형식 + +`evaluate.md`는 Codex가 완료된 구현 결과를 평가할 때 따라야 하는 규칙입니다. + +- 구현 report와 변경 파일 검토 방식 +- 계획 문서와 실제 구현의 정합성 확인 방식 +- 검증 결과의 증거 수준 판단 방식 +- 9개 Boolean 평가 기준 +- `ACCEPT`, `REVISE`, `HALT` verdict 산정 방식 +- 평가 report 작성 형식 + +## 3. 구현 부트스트랩 사용 시점 + +`./.agent/codex/implement.md`는 Codex에게 구현을 맡기기 전에 사용합니다. + +다음 상황에서 사용합니다. + +- Gemini가 `spec.md`, `plan.md`, `tasks/` 문서를 만든 뒤 Codex가 실제 구현을 해야 할 때 +- 특정 task 하나를 구현해야 할 때 +- 여러 task를 순서대로 구현해야 할 때 +- 전체 task set을 구현해야 할 때 +- 구현 전에 scope, task 순서, 변경 파일, 실행할 명령어를 명확히 하고 싶을 때 + +구현 세션에서는 Codex가 파일을 만들거나 수정할 수 있으므로 승인 정책을 반드시 적용해야 합니다. + +## 4. 평가 부트스트랩 사용 시점 + +`./.agent/codex/evaluate.md`는 구현이 이미 끝난 뒤 사용합니다. + +다음 상황에서 사용합니다. + +- Codex 구현 결과가 Gemini 계획과 맞는지 확인하고 싶을 때 +- 변경 파일이 scope 밖으로 벗어나지 않았는지 확인하고 싶을 때 +- validation 결과가 충분한 증거를 갖는지 확인하고 싶을 때 +- 구현 report와 handoff가 충분한지 확인하고 싶을 때 +- 최종적으로 `ACCEPT`, `REVISE`, `HALT` verdict를 받고 싶을 때 + +평가 세션은 감사 세션입니다. 구현 파일을 수정하거나 문제를 바로 고치면 안 됩니다. + +## 5. Scope 선택법 + +Codex 작업을 시작할 때는 먼저 scope를 정해야 합니다. + +| Scope | 의미 | 필요한 입력 | +| --- | --- | --- | +| `single-task` | 정확히 하나의 task 파일만 구현 또는 평가 | task 파일 1개 | +| `task-set` | 사용자가 지정한 여러 task 파일을 구현 또는 평가 | 정확한 task 파일 목록 | +| `all-tasks` | `TASK_OVERVIEW_PATH`와 `TASKS_DIR`의 관련 task 전체를 구현 또는 평가 | 전체 task set 사용 의사 | + +scope가 불명확하면 Codex는 진행하지 않고 사용자에게 질문해야 합니다. + +### 5.1 single-task + +`single-task`는 정확히 하나의 task 파일만 다룹니다. + +사용 예: + +- 작은 기능 하나를 독립적으로 구현할 때 +- 특정 task의 구현 결과만 평가할 때 +- dependency가 거의 없거나 이미 해결된 task를 처리할 때 + +사용자는 task path를 하나만 제공해야 합니다. + +예: + +```md +./specs/tasks/task-03-add-login-modal.md +``` + +### 5.2 task-set + +`task-set`은 사용자가 명시한 여러 task 파일만 다룹니다. + +Codex가 task set을 추측하면 안 됩니다. 사용자가 정확한 task path 목록을 제공해야 합니다. + +사용 예: + +- 서로 연관된 2~3개 task를 한 번에 구현할 때 +- API, hook, component처럼 dependency가 있는 task 묶음을 처리할 때 +- 구현 결과를 여러 task 범위로 함께 평가할 때 + +`task-set`에서는 Codex가 task 순서와 dependency를 확인하고 구현 또는 평가 순서를 제안해야 합니다. + +### 5.3 all-tasks + +`all-tasks`는 전체 task set을 다룹니다. + +Codex는 다음 문서를 기준으로 전체 범위를 파악합니다. + +- `TASK_OVERVIEW_PATH`: `./specs/tasks/00-project-overview.md` +- `TASKS_DIR`: `./specs/tasks/` +- 관련 `task-*.md` 파일 + +사용 예: + +- Gemini가 만든 전체 계획을 Codex가 끝까지 구현해야 할 때 +- 완료된 전체 구현 결과를 한 번에 평가해야 할 때 + +`all-tasks`에서는 task 순서, dependency, 포함 범위가 모호하면 반드시 사용자에게 확인해야 합니다. + +## 6. 권장 실행 흐름 + +권장 흐름은 다음과 같습니다. + +1. Gemini가 specification, plan, tasks를 작성합니다. +2. 사용자가 Codex에게 `implement.md`를 사용해 구현을 요청합니다. +3. Codex가 scope를 확인합니다. +4. Codex가 필요한 문서와 task를 읽고 구현 순서를 제안합니다. +5. 사용자가 구현 단위, 예상 변경 파일, 실행 명령을 승인합니다. +6. Codex가 승인된 범위 안에서만 구현합니다. +7. Codex가 validation 명령 실행 승인을 요청합니다. +8. 사용자가 승인하면 Codex가 `npm run lint`, `npm run test`를 실행합니다. +9. Codex가 선택된 scope에 대한 evaluation-ready 구현 report 작성을 준비하고, `./specs/codex-implementation-report.md` 생성 또는 수정 승인을 요청합니다. +10. 사용자가 Codex에게 `evaluate.md`를 사용해 구현 결과 평가를 요청합니다. +11. Codex가 구현 report, 변경 파일, validation output, planning artifacts를 근거로 평가합니다. +12. Codex가 `./specs/codex-implementation-eval.md`에 평가 report를 작성합니다. + +## 7. 승인 정책 + +`implement.md` 기준으로, future Codex 구현 세션은 파일 수정 전에 명시적 승인을 받아야 합니다. + +승인이 필요한 파일 작업: + +- 파일 생성 +- 파일 편집 +- 파일 삭제 +- 파일 이동 또는 이름 변경 +- 파일 포맷팅 +- patch 적용 +- repository 파일에 코드 생성 + +터미널 또는 shell 명령 실행도 명시적 승인이 필요합니다. + +승인이 필요한 명령 예: + +- `npm run lint` +- `npm run test` +- build 명령 +- package-manager 명령 +- git 명령 +- migration 명령 +- generator 명령 +- formatting 명령 +- `ls`, `find`, `grep`, `cat`, `rg`, `Get-Content` 같은 read-only shell 명령 + +Codex는 승인 요청 전에 다음을 제시해야 합니다. + +- 수행할 inspection 또는 implementation 단위 +- 수정할 것으로 예상되는 파일 +- 실행하려는 명령 +- 각 변경 또는 명령의 목적 +- 예상 위험 또는 side effect + +승인 하나가 이후의 모든 작업을 승인하는 것은 아닙니다. 승인된 batch 밖의 작업은 다시 승인을 받아야 합니다. + +단, reasoning, planning, clarification question, 사용자가 직접 제공한 prompt text 읽기는 별도 승인이 필요하지 않습니다. + +## 8. 검증 명령어 처리 방식 + +기본 validation 명령은 고정되어 있습니다. + +```bash +npm run lint +npm run test +``` + +이 명령들은 기본적으로 필요하지만, 실행 전에는 반드시 사용자 승인이 필요합니다. + +Codex는 validation 실행 전에 다음을 알려야 합니다. + +- 실행할 정확한 명령 +- 각 명령을 실행하는 이유 +- 예상 runtime 또는 side effect +- cache, coverage, snapshot 등 생성 파일 가능성 + +평가 세션에서는 validation 결과를 증거로 봅니다. raw terminal output, CI output, local rerun output이 단순 report 문장보다 강한 증거입니다. + +구현 report에 “validation passed”라고만 되어 있고 실제 output이 없으면, 평가 Codex는 사용자에게 output을 요청하거나 evaluation report에 residual risk를 명시해야 합니다. + +## 9. 구현 호출 프롬프트 예시 + +### 9.1 single-task 구현 예시 + +```md +`.agent/codex/implement.md`를 구현 부트스트랩으로 사용해 주세요. + +Execution scope: +- `single-task` + +Target tasks: +- `./specs/tasks/task-03-example.md` + +추가 제약: +- 계획 문서와 repository convention을 우선해 주세요. +- scope 밖 파일은 수정하지 마세요. + +승인 지시: +- shell 명령은 실행 전에 먼저 요청해 주세요. +- 파일 수정은 예상 변경 파일과 목적을 설명한 뒤 승인받고 진행해 주세요. +- 승인된 batch 밖의 작업은 다시 승인받아 주세요. + +Validation: +- 구현 후 `npm run lint`, `npm run test` 실행 승인을 요청해 주세요. + +모호성 처리: +- task 요구사항, 파일 소유권, acceptance criteria, validation 기대값이 모호하면 추측하지 말고 질문한 뒤 대기해 주세요. +``` + +### 9.2 task-set 구현 예시 + +```md +`.agent/codex/implement.md`를 구현 부트스트랩으로 사용해 주세요. + +Execution scope: +- `task-set` + +Target tasks: +- `./specs/tasks/task-02-api-types.md` +- `./specs/tasks/task-03-query-hook.md` +- `./specs/tasks/task-04-page-integration.md` + +추가 제약: +- 위에 명시한 task만 구현해 주세요. +- Codex가 task set을 임의로 추가하거나 추측하지 마세요. +- task 간 dependency와 구현 순서를 먼저 제안해 주세요. + +승인 지시: +- repository inspection에 shell 명령이 필요하면 먼저 승인 요청해 주세요. +- 파일 수정 전 예상 변경 파일, 변경 목적, 위험을 설명해 주세요. +- 승인된 구현 batch만 수행해 주세요. + +Validation: +- 구현 후 `npm run lint`, `npm run test` 실행 승인을 요청해 주세요. + +모호성 처리: +- task 순서, dependency, 포함 범위, scope 밖 변경 여부가 모호하면 질문한 뒤 대기해 주세요. +``` + +### 9.3 all-tasks 구현 예시 + +```md +`.agent/codex/implement.md`를 구현 부트스트랩으로 사용해 주세요. + +Execution scope: +- `all-tasks` + +Target tasks: +- `TASK_OVERVIEW_PATH`와 `TASKS_DIR` 아래의 관련 `task-*.md` 전체 + +추가 제약: +- 전체 task set을 한 번의 실행 scope로 다뤄 주세요. +- task overview를 기준으로 task 포함 범위, 순서, dependency를 먼저 정리해 주세요. +- dependent task를 prerequisite보다 먼저 구현하지 마세요. 필요한 경우 먼저 승인 요청해 주세요. + +승인 지시: +- shell 명령 실행 전 승인 요청해 주세요. +- 파일 수정 전 승인 요청해 주세요. +- 각 phase별로 승인된 batch만 진행해 주세요. + +Validation: +- 구현 후 `npm run lint`, `npm run test` 실행 승인을 요청해 주세요. + +모호성 처리: +- 전체 task 범위, task ordering, dependency, acceptance criteria가 모호하면 추측하지 말고 질문해 주세요. +``` + +## 10. 평가 호출 프롬프트 예시 + +### 10.1 single-task 평가 예시 + +```md +Use `.agent/codex/evaluate.md` as the implementation evaluation bootstrap. + +Please answer in English for evaluation accuracy. + +Evaluation scope: +- `single-task` + +Target tasks: +- `./specs/tasks/task-03-example.md` + +Implementation report: +- `./specs/codex-implementation-report.md` + +Instructions: +- Do not modify implementation files during evaluation. +- Do not implement fixes during evaluation. +- If shell commands are needed, ask for approval before running them. +- If the implementation report is missing, incomplete, or inconsistent with changed files or validation evidence, stop and ask a precise clarification question. +``` + +### 10.2 task-set 평가 예시 + +```md +Use `.agent/codex/evaluate.md` as the implementation evaluation bootstrap. + +Please answer in English for evaluation accuracy. + +Evaluation scope: +- `task-set` + +Target tasks: +- `./specs/tasks/task-02-api-types.md` +- `./specs/tasks/task-03-query-hook.md` +- `./specs/tasks/task-04-page-integration.md` + +Implementation report: +- `./specs/codex-implementation-report.md` + +Instructions: +- Do not modify implementation files during evaluation. +- Do not implement fixes during evaluation. +- If shell commands are needed, ask for approval before running them. +- If the implementation report is missing, incomplete, or inconsistent with changed files or validation evidence, stop and ask a precise clarification question. +``` + +### 10.3 all-tasks 평가 예시 + +```md +Use `.agent/codex/evaluate.md` as the implementation evaluation bootstrap. + +Please answer in English for evaluation accuracy. + +Evaluation scope: +- `all-tasks` + +Target tasks: +- `TASK_OVERVIEW_PATH`와 `TASKS_DIR` 아래의 관련 `task-*.md` 전체 + +Implementation report: +- `./specs/codex-implementation-report.md` + +Instructions: +- Do not modify implementation files during evaluation. +- Do not implement fixes during evaluation. +- If shell commands are needed, ask for approval before running them. +- If the implementation report is missing, incomplete, or inconsistent with changed files or validation evidence, stop and ask a precise clarification question. +``` + +구현 report 파일을 사용할 수 없거나 내용이 불완전하면, 사용자는 changed files, validation output, user-approved deviations, handoff summary를 평가 요청에 직접 붙여 넣을 수 있습니다. + +## 11. 운영상 주의사항 + +- `implement.md`는 구현용이고, `evaluate.md`는 평가용입니다. +- 평가 세션에서 구현 파일을 수정하면 안 됩니다. +- `single-task`는 정확히 하나의 task 파일입니다. +- `task-set`은 사용자가 명시한 task 파일 목록입니다. Codex가 추측하면 안 됩니다. +- `all-tasks`는 task overview와 tasks directory의 관련 task 전체입니다. +- `task-set`과 `all-tasks`에서는 task 순서와 dependency를 반드시 확인해야 합니다. +- 파일 수정은 명시적 승인 후에만 가능합니다. +- shell 또는 terminal 명령 실행도 명시적 승인 후에만 가능합니다. +- validation 명령은 기본적으로 필요하지만 실행 전 승인이 필요합니다. +- 기본 구현 report 경로는 `./specs/codex-implementation-report.md`입니다. +- 구현 report 파일 생성 또는 수정도 명시적 승인 후에만 가능합니다. +- 구현 report는 선택된 execution scope에 대해 하나로 작성합니다. 사용자가 요청하지 않는 한 task별 report를 따로 만들지 않습니다. +- 평가 report도 선택된 evaluation scope에 대해 하나로 작성합니다. +- 기본 평가 report 경로는 `./specs/codex-implementation-eval.md`입니다. +- sanitized example 파일 같은 오래된 예시 파일 참조를 workflow에 포함하지 마세요. + +## 12. 빠른 사용 요약 + +| 단계 | 사용 파일 | 사용자 입력 | Codex 결과 | +| --- | --- | --- | --- | +| 구현 요청 | `implement.md` | scope, target tasks, approval policy, validation 지시 | 승인된 범위의 구현과 evaluation-ready 구현 report | +| 구현 후 검증 | `implement.md` | `npm run lint`, `npm run test` 실행 승인 | validation 결과 보고 | +| 평가 요청 | `evaluate.md` | scope, target tasks, `./specs/codex-implementation-report.md` | 평가 report | +| 평가 report | `evaluate.md` | 필요 시 clarification 답변 | `./specs/codex-implementation-eval.md` | + +가장 짧게 운영하려면 다음 순서를 사용하세요. + +1. Gemini planning artifacts를 준비합니다. +2. `implement.md`로 Codex 구현 세션을 시작합니다. +3. scope와 target tasks를 명확히 지정합니다. +4. 파일 수정과 명령 실행을 batch 단위로 승인합니다. +5. `./specs/codex-implementation-report.md` 작성을 승인하거나, 승인하지 않을 경우 final response에 출력된 report 내용을 확보합니다. +6. `evaluate.md`로 Codex 평가 세션을 시작합니다. +7. 평가 report에서 `ACCEPT`, `REVISE`, `HALT` verdict를 확인합니다. diff --git a/.agent/codex/implement.md b/.agent/codex/implement.md new file mode 100644 index 00000000..53e4752b --- /dev/null +++ b/.agent/codex/implement.md @@ -0,0 +1,529 @@ +# Codex Implementation Bootstrap + +## Role + +Codex is the implementation agent. Use Codex to inspect the repository, interpret approved planning artifacts, clarify ambiguity, divide work into scoped implementation units, request approval for commands and file changes, implement approved changes, request approval for validation, and report the result. + +Gemini is the upstream planner and specifier. Treat Gemini planning documents as the intended design and implementation direction unless they conflict with higher-priority repository rules, are internally contradictory, are risky, or are underspecified. + +The user is the final approver for ambiguity, contradiction, risk, scope changes, command execution, file modification, and any departure from the approved plan. Stop and ask the user before proceeding when requirements are ambiguous or when any stop condition in this document is triggered. + +## Project Constants + +Edit only this section when adapting the bootstrap to a different project. + +- `STACK`: React + TypeScript + Vite + Vitest +- `README_PATH`: `./README.md` +- `CONSTITUTION_PATH`: `./.specify/memory/constitution.md` +- `SPEC_PATH`: `./specs/spec.md` +- `PLAN_PATH`: `./specs/plan.md` +- `TASKS_DIR`: `./specs/tasks/` +- `TASK_OVERVIEW_PATH`: `./specs/tasks/00-project-overview.md` +- `DEFAULT_IMPLEMENTATION_REPORT_PATH`: `./specs/codex-implementation-report.md` +- `TARGET_TASK_PATTERN`: `./specs/tasks/task-NN-task_name.md` +- `VALIDATION_COMMANDS`: + - `npm run lint` + - `npm run test` + +## Required Inputs + +Future Codex implementation sessions should receive: + +- Execution scope: `single-task`, `task-set`, or `all-tasks`. +- Target task path for `single-task`, normally matching `TARGET_TASK_PATTERN`. +- Explicit target task path list for `task-set`. +- Confirmation to use `TASK_OVERVIEW_PATH` and relevant `task-*.md` files under `TASKS_DIR` for `all-tasks`. +- Additional user constraints, such as allowed files, preferred sequencing, files to avoid, or approved deviations. +- Whether approval is granted for any initial read-only inspection actions or commands. +- Whether approval is granted for any implementation batch. +- Whether validation should be requested after implementation. If unspecified, validation is required by default but still requires command approval before execution. + +If the execution scope is missing, stop and ask whether to use `single-task`, `task-set`, or `all-tasks`. + +If required target tasks are missing, ambiguous, unavailable, or do not match the planning artifact structure, ask the user for clarification before proceeding. + +## Execution Scope + +Future Codex implementation sessions must identify the execution scope before implementation. + +Supported scopes: + +- `single-task`: Implement exactly one specified task file. Requires one target task path matching `TARGET_TASK_PATTERN`. +- `task-set`: Implement multiple user-specified task files. Requires the user to provide the exact target task paths. Codex must not infer the task set if the user does not provide it. +- `all-tasks`: Implement the full task set described by `TASK_OVERVIEW_PATH` and the relevant `task-*.md` files under `TASKS_DIR`. + +For `task-set` and `all-tasks`, inspect task dependencies and propose an implementation order before coding. + +For `all-tasks`, inspect the task overview and relevant task files, identify task order and dependencies, then present the proposed implementation sequence to the user. + +Stop and ask the user if task ordering, task inclusion, or dependency relationships are ambiguous. + +Do not implement dependent tasks before prerequisite tasks unless the user explicitly approves the deviation. + +Even in `task-set` or `all-tasks` mode, divide the work into task-level and implementation-unit-level steps before coding. + +Obtain user approval before each state-changing phase according to the `Execution Approval Policy`. + +## Execution Approval Policy + +Future Codex implementation sessions must obtain explicit user approval before modifying any file. + +File modification includes: + +- Creating files. +- Editing files. +- Deleting files. +- Moving or renaming files. +- Formatting files. +- Applying patches. +- Generating code into repository files. + +Future Codex implementation sessions must obtain explicit user approval before running any shell or terminal command. + +Shell or terminal commands include: + +- Validation commands. +- Test commands. +- Lint commands. +- Build commands. +- Package-manager commands. +- Git commands. +- Migration commands. +- Generator commands. +- Formatting commands. +- Read-only shell commands such as `ls`, `find`, `grep`, `cat`, `rg`, `Get-Content`, or similar commands when executed through the shell. + +Before requesting approval, Codex must present: + +- The implementation or inspection unit. +- The files it expects to modify, if any. +- The commands it wants to run, if any. +- The purpose of each proposed file change or command. +- Expected risks or side effects. + +Codex must not proceed until the user explicitly approves. + +Approval for one action does not imply approval for unrelated future actions. + +If the user grants approval for a batch of actions, Codex may perform only the approved batch and must ask again before any additional file modification or command execution outside that batch. + +Reasoning, planning, and asking clarification questions do not require approval. + +Reading user-provided prompt text does not require approval. + +Inspecting repository files through non-shell read-only file access may be treated as read-only inspection, but any shell command still requires approval. + +## Ambiguity Clarification Protocol + +Treat ambiguity clarification as a required pre-implementation step, not an optional courtesy. + +Do not guess through ambiguity. Do not silently choose one interpretation. Ask the user a precise clarification question and wait for the user's answer before coding. + +Clarify with the user before implementation when there is uncertainty about: + +- Execution scope. +- Target task files. +- Task ordering or dependencies. +- Requirements or acceptance criteria. +- User intent. +- Planning document contradictions. +- Underspecified public behavior. +- File ownership or module boundaries. +- Validation expectations. +- Whether a proposed change is in scope. +- Any other ambiguity that could affect implementation direction, public behavior, tests, or architecture. + +## Pre-Implementation Repository Inspection + +Before implementation, inspect the repository structure. Do not infer project rules from memory when repository documents provide explicit rules. + +Inspection must respect the `Execution Approval Policy`. Request approval before running any shell command used for inspection. Non-shell read-only file access may be used as read-only inspection when available. + +Inspect at least: + +- Repository layout and top-level configuration files. +- `README_PATH`. +- `CONSTITUTION_PATH`. +- Package metadata and scripts. +- Source directories. +- Test directories and existing test naming patterns. +- Existing architectural conventions around pages, components, hooks, APIs, utilities, repositories, mocks, routes, styling, i18n, and tests. +- Relevant local coding patterns near the files that the selected scope expects to change. + +For this project, expect a React 18 + TypeScript + Vite codebase with Vitest, Testing Library, MSW, TanStack Query, Axios, Tailwind CSS, and i18next. Confirm current details from repository files before using them. + +## Repository Document Reading Order + +Read repository governance and implementation context in this order: + +1. `./README.md` +2. Repository constitution document at `./.specify/memory/constitution.md` +3. Package metadata and scripts, especially `package.json` +4. Relevant source and test directories + +Use the README and constitution as governing project documents. When they define explicit rules, follow them over assumptions from prior experience. + +## Planning Artifact Reading Order + +After repository inspection, read planning artifacts according to the execution scope: + +1. `./specs/spec.md` +2. `./specs/plan.md` +3. `./specs/tasks/00-project-overview.md` +4. For `single-task`: the one target task file under `TASKS_DIR` +5. For `task-set`: each user-specified target task file under `TASKS_DIR` +6. For `all-tasks`: all relevant `task-*.md` files under `TASKS_DIR` + +Do not treat example files, non-target task files, or unrelated planning files as active implementation targets. + +For `task-set` and `all-tasks`, compare target tasks with the task overview and plan to identify prerequisites, dependencies, and ordering constraints before proposing implementation units. + +## Authority and Conflict Resolution + +Apply this authority order: + +1. Explicit user instruction in the current session +2. Repository constitution +3. README and repository conventions +4. Gemini planning documents +5. Existing implementation patterns + +If higher-priority and lower-priority sources conflict, stop and ask the user before proceeding. Do not silently choose one source or rewrite the plan. + +If Gemini planning documents conflict with each other, stop and ask the user to resolve the contradiction. + +If the README, constitution, and Gemini planning documents conflict, stop and ask the user to identify the governing decision. + +## Consistency and Risk Checks + +Before coding, check for: + +- Missing or ambiguous execution scope. +- Missing or ambiguous target tasks for the selected scope. +- Ambiguous task inclusion, ordering, or dependency relationships. +- Contradictions between Gemini planning documents. +- Contradictions between README, constitution, and planning documents. +- Missing acceptance criteria. +- Risky design choices that could break existing behavior, architecture, routing, API contracts, state management, styling, i18n, or tests. +- Underspecified user-visible behavior. +- Unclear user intent. +- Uncertain file ownership or module boundaries. +- Unclear validation expectations. +- Uncertainty about whether a change is in scope. +- A mismatch between the plan and the actual repository structure. +- Broad refactoring required by the selected scope. +- Public behavior changes that are not clearly requested. +- Validation expectations that cannot be met in the local environment. + +Stop and ask the user before proceeding when: + +- Gemini planning documents contradict each other. +- The README, constitution, and Gemini planning documents conflict. +- The design appears risky or underspecified. +- The plan conflicts with the repository structure or existing architecture. +- The selected scope requires broad refactoring without clear justification from the plan or user. +- The selected scope requires changing public behavior outside the stated scope. +- Any ambiguity remains after repository and planning artifact inspection. + +## Implementation Protocol + +Follow this protocol for future implementation work: + +1. Confirm the execution scope and required inputs. +2. If scope is missing or ambiguous, ask the user to choose `single-task`, `task-set`, or `all-tasks`. +3. Request approval before running shell commands for repository or planning inspection. +4. Inspect the repository structure and governing documents. +5. Read the planning artifacts for the selected scope in the required order. +6. For `task-set` and `all-tasks`, identify dependencies and propose task order. +7. Identify the intended behavior, files likely to change, tests likely to change, commands likely to run, and validation commands. +8. Resolve ambiguity through the Ambiguity Clarification Protocol before coding. +9. Divide the work into task-level and implementation-unit-level steps before coding. +10. Present the proposed implementation units, expected file changes, commands, purposes, and risks to the user. +11. Obtain explicit user approval for the implementation batch before modifying files or running commands. +12. Implement only the approved batch with minimal, plan-aligned changes. +13. Preserve the existing architecture, naming, folder boundaries, and testing style. +14. Follow TDD where the constitution or plan requires it. +15. Keep user-facing text in i18n resources and use `useTranslation()` where applicable. +16. Avoid redesigning Gemini's plan unless a stop condition is triggered and the user approves a change. +17. Avoid broad refactors unless clearly required by the selected scope or explicitly approved by the user. +18. Do not change unrelated files. +19. Re-check the changed areas for consistency with the selected scope and repository rules. +20. Request approval before running validation commands. +21. Run only approved validation commands and report results. +22. After implementation and validation reporting, prepare the Evaluation-Ready Implementation Report. +23. Request explicit user approval before creating or updating the report file at `DEFAULT_IMPLEMENTATION_REPORT_PATH`. +24. If the user does not approve writing the report file, include the same report content in the final response. +25. Include the existing final response summary even when the evaluation-ready report is written. + +For React + TypeScript + Vite + Vitest work in this project, prefer existing local patterns for components, hooks, API primitives, route definitions, MSW handlers, and co-located tests. + +## Testing and Validation + +Use these validation commands unless the user explicitly overrides them: + +```bash +npm run lint +npm run test +``` + +These validation commands are required by default after implementation, but Codex must request explicit user approval before executing them. + +Before requesting approval for validation, state: + +- The exact validation commands. +- Why each command is needed. +- Expected runtime or side effects, if known. +- Whether the commands may write caches, coverage, snapshots, or other generated files. + +Report whether each approved command passed or failed. If a command fails, summarize the relevant failure and whether it appears related to the current changes. + +If validation is not approved, cannot be run, or is skipped by user instruction, explain why and identify the residual risk. + +Include validation results and a brief validation output summary in the Evaluation-Ready Implementation Report. Keep the fixed validation command names exactly as: + +- `npm run lint` +- `npm run test` + +## Evaluation-Ready Implementation Report + +After implementation and validation reporting, future Codex implementation sessions must prepare an evaluation-ready Markdown report for the selected execution scope. + +The default report path is `DEFAULT_IMPLEMENTATION_REPORT_PATH`. + +Codex must request explicit user approval before creating or updating this report file, because file creation and file modification are covered by the `Execution Approval Policy`. + +If this path conflicts with existing repository conventions, stop and ask the user before choosing another path. + +If the user does not approve writing the report file, Codex must still print the same report content in the final response so the user can copy it manually. + +The report must contain all information needed to invoke `./.agent/codex/evaluate.md` without reconstructing the previous implementation session from memory. + +Keep one implementation report for the selected execution scope unless the user explicitly requests per-task reports. + +Use this report structure: + +```markdown +# Codex Implementation Report + +Execution scope: +- + +Target tasks: +- + +Planning overview: +- + +Documents read: +- +- +- +- +- +- + +Changed files: +- + +Implemented changes: +- + +Task-by-task summary: +- : + +User-approved implementation decisions: +- + +User-approved deviations: +- + +Validation results: +- `npm run lint`: +- `npm run test`: + +Validation output summary: +- + +Commands run: +- + +Approvals received: +- + +Unresolved issues: +- + +Remaining work: +- + +Open questions: +- + +Handoff summary: +- + +Evaluation invocation data: +- Evaluation scope: +- Target tasks: +- Implementation report path: +- Changed files: +- Validation results: + - `npm run lint`: + - `npm run test`: +- User-approved deviations: +- Handoff summary: +``` + +## Change Reporting Format + +Use this concise format in the final response after implementation. Do not remove this final response summary when the Evaluation-Ready Implementation Report is written. Produce one implementation report for the executed scope unless the user explicitly requests per-task reports. + +```markdown +Summary: + +Execution scope: + +- + +Target tasks: + +- + +Changed files: + +- + +Rationale: + +- + +Plan alignment: + +- + +Validation: + +- `npm run lint`: +- `npm run test`: + +Implementation report: + +- + +Unresolved issues: + +- +``` + +## Handoff Summary Format + +Use this copy-pasteable handoff when work must continue later: + +```markdown +Handoff Summary + +Execution scope: + +- + +Target tasks: + +- + +Documents read: + +- +- +- +- +- +- + +Decisions made: + +- + +Files changed: + +- : + +Validation results: + +- `npm run lint`: +- `npm run test`: + +Implementation report: + +- + +Remaining work: + +- + +Open questions: + +- +``` + +## Prohibited Behaviors + +Do not: + +- Implement without identifying the execution scope. +- Infer `task-set` membership when the user did not provide exact task paths. +- Implement dependent tasks before prerequisite tasks unless the user explicitly approves the deviation. +- Implement without reading the relevant plan for the selected scope. +- Implement without reading the README and constitution. +- Modify files without explicit user approval. +- Create or update the implementation report file without explicit user approval. +- Run shell commands without explicit user approval. +- Treat approval for one action as approval for unrelated future actions. +- Run validation commands without explicit user approval. +- Invent requirements that are not in the user request, repository documents, or planning artifacts. +- Silently resolve contradictions. +- Perform broad refactoring without approval. +- Change public behavior outside the selected scope. +- Skip validation without explanation. +- Skip the final response summary because an implementation report was written. +- Treat current planning files as active implementation work when the user only requested bootstrap creation. +- Treat example files, non-target task files, or unrelated planning files as active implementation targets. +- Redesign, simplify, or reinterpret Gemini's implementation plan without a clear contradiction, risk, repository mismatch, or conflict with the README or constitution. +- Guess through ambiguity or silently choose one interpretation. +- Start coding before asking the user a precise clarification question and receiving an answer when ambiguity exists. +- Modify unrelated files. +- Produce per-task implementation reports unless the user explicitly requests them. +- Evaluate the actual implementation result during an implementation session. +- Continue past ambiguity, contradiction, risky design, underspecified behavior, or repository-plan mismatch without user approval. + +## First Message Template for Future Codex Sessions + +Use this template when asking Codex to implement a selected scope: + +```markdown +Use `.agent/codex/implement.md` as the implementation bootstrap. + +Execution scope: + +- `` + +Target tasks: + +- `` + +Additional user constraints: + +- `` + +Approval: + +- Inspection commands: `` +- File modifications: `` +- Validation commands: `` +- Implementation report file: `` + +Validation: + +- `` + +Please identify the execution scope, inspect the repository and planning artifacts only after any required command approval, divide the work into task-level and implementation-unit-level steps, present expected file changes and commands with risks, wait for explicit approval before modifying files or running commands, request approval before validation, and prepare an evaluation-ready implementation report at `./specs/codex-implementation-report.md` after implementation. Ask for approval before creating or updating that report file; if report writing is not approved, print the same report content in the final response. +``` diff --git a/.agent/gemini/analyze.md b/.agent/gemini/analyze.md new file mode 100644 index 00000000..dcf9d521 --- /dev/null +++ b/.agent/gemini/analyze.md @@ -0,0 +1,30 @@ +--- +description: Finalize the design-to-implementation handoff by generating a project overview and specific task documents. +--- + +### Strict Mode +If any ambiguity, missing information, or unverified technical detail is encountered, the agent MUST immediately stop execution and ask the user for clarification. Do not guess, do not assume, and do not proceed with uncertainty. + +### Phase 1: Research & Discovery +**Goal:** Ingest all design and planning artifacts. +1. **Initialize Topic:** Call `update_topic` to begin the final analysis and handoff generation. +2. **Read Artifacts:** Use `read_file` (parallelized) to load `./specs/spec.md`, `./specs/plan.md`, and `./specs/tasks.md`. + +### Phase 2: Strategy & Planning +**Goal:** Structure the handoff documentation. +1. **Draft Overview:** Synthesize a "bird's-eye view" of the system architecture and goals based on the loaded artifacts. +2. **Map Individual Tasks:** Prepare the precise instructions, context, and requirements for each task listed in `tasks.md`. +3. **Strategy Update:** Call `update_topic` detailing the handoff structure to be generated. + +### Phase 3: Execution & Implementation +**Goal:** Generate the handoff files. +1. **Execution Update:** Call `update_topic` before writing the handoff files. +2. **Create Overview Document:** Use `write_file` to create `./specs/tasks/00-project-overview.md`. This document must summarize the entire system architecture, goals, and technical decisions. +3. **Create Task Documents:** Use `write_file` (or delegate to a sub-agent using `invoke_agent` if there are many) to create an individual file for each task (e.g., `./specs/tasks/task-01-[name].md`, `./specs/tasks/task-02-[name].md`). Each file MUST contain precise instructions, context, and requirements for the next agent to implement. + +### Phase 4: Validation & Finalization +**Goal:** Verify the integrity of the final handoff directory. +1. **Verify Structure:** Use `list_directory` on `./specs/tasks/` to confirm the presence of `00-project-overview.md` and all individual task files. +2. **Consistency Check:** Spot check a task file (using `read_file`) to ensure it logically aligns with the `00-project-overview.md` and previous specs. +3. **Validate Handoff Readiness:** Ask the critical question: "Would a new developer be able to implement this without asking a single question?" If not, identify the remaining ambiguities and halt execution to ask the user for clarification before finalizing. +4. **Final Update:** Call `update_topic` to declare the design phase complete and ready for implementation. diff --git a/.agent/gemini/clarify.md b/.agent/gemini/clarify.md new file mode 100644 index 00000000..1fa0638b --- /dev/null +++ b/.agent/gemini/clarify.md @@ -0,0 +1,38 @@ +--- +description: Identify underspecified areas in the feature specification and resolve them interactively. +--- + +### Strict Mode + +If any ambiguity, missing information, or unverified technical detail is encountered, the agent MUST immediately stop execution and ask the user for clarification. Do not guess, do not assume, and do not proceed with uncertainty. + +### Phase 1: Research & Discovery + +**Goal:** Analyze the current specification for ambiguities and load foundational constraints. + +1. **Initialize Topic:** Call `update_topic` with the goal of clarifying the specification. +2. **Read Specification:** Use `read_file` to load `./specs/spec.md`. All artifacts MUST reside in the `./specs/` directory. +3. **Analyze Content:** Scan the specification for `[NEEDS CLARIFICATION]` markers or areas lacking detail (e.g., edge cases, error handling). + +### Phase 2: Strategy & Planning + +**Goal:** Formulate targeted questions for the user. + +1. **Draft Questions:** Generate up to 10 targeted, multiple-choice or short-answer questions to resolve the ambiguities. +2. **Strategy Update:** Call `update_topic` to inform the user that questions are ready. + +### Phase 3: Execution & Implementation + +**Goal:** Ask questions and update the specification. + +1. **Interactive Session:** Use the `ask_user` tool to present the questions one by one or in a single form. +2. **Update Specification:** Use `replace` or `write_file` to apply the answers to `./specs/spec.md`. Ensure clarifications replace vague placeholders and resolve contradictions, remaining aligned with the Project Constitution. All changes MUST be saved within the `./specs/` directory. +3. **Execution Update:** Call `update_topic` as updates are applied. + +### Phase 4: Validation & Finalization + +**Goal:** Confirm the specification is unambiguous. + +1. **Verify Updates:** Check `./specs/spec.md` to ensure no `[NEEDS CLARIFICATION]` markers remain. +2. **Final Update:** Call `update_topic` summarizing the resolved areas. +3. **Next Steps:** Suggest proceeding to the Plan workflow. diff --git a/.agent/gemini/evaluate.md b/.agent/gemini/evaluate.md new file mode 100644 index 00000000..23ab0e3b --- /dev/null +++ b/.agent/gemini/evaluate.md @@ -0,0 +1,47 @@ +--- +description: Evaluate design and planning artifacts to determine readiness for the implementation phase. +--- + +### Strict Mode + +If any ambiguity, missing information, or unverified technical detail is encountered, the agent MUST immediately stop execution and ask the user for clarification. Do not guess, do not assume, and do not proceed with uncertainty. + +### Phase 1: Context & Discovery + +**Goal:** Ingest all relevant design and planning artifacts for comprehensive evaluation. + +1. **Initialize Topic:** Call `update_topic` to indicate the start of the rigorous evaluation phase. +2. **Explore Context:** Use `read_file` to read the `README.md` for project overview and domain context. +3. **Read Constitution:** Use `read_file` to read the 'Project Constitution' at `./.specify/memory/constitution.md`. All subsequent stages must be strictly governed by the principles found in this Constitution. +4. **Ingest Specs Directory:** Use `list_directory` and `read_file` (parallelized) to read all summary and design documents located in the `./specs/` directory (e.g., `spec.md`, `plan.md`, `tasks.md`). +5. **Ingest Tasks Directory:** Use `list_directory` and `read_file` (parallelized) to read all handoff documents located in the `./specs/tasks/` directory (e.g., `00-project-overview.md` and all individual `task-*.md` files). + +### Phase 2: Rigorous Evaluation + +**Goal:** Critically evaluate the artifacts against 7 strict Boolean criteria. Act as an Auditor: be highly critical and focused on minimizing errors for the upcoming implementation phase. Do not assume completeness without concrete evidence in the documentation. + +1. **Evaluate:** For each of the following criteria, determine a strict `[Yes/No]` status based on your analysis: + - **Requirement Traceability:** Are all initial requirements from the 'Specify' stage fully reflected in the final tasks? + - **Task Atomicity:** Are individual tasks broken down into atomic units that can be implemented in a single session? + - **Contextual Grounding:** Is every design decision based on provided project context rather than hallucination or arbitrary assumptions? + - **Unambiguity:** Is the documentation free of vague adjectives (e.g., 'appropriately', 'efficiently') and providing clear technical instructions? + - **Directory Discipline:** Are all artifacts correctly stored in `./specs/` or `./specs/tasks/` according to the naming convention? + - **Technical Feasibility:** Is the design realistically implementable within the current tech stack (React/TypeScript)? + - **Handoff Completeness:** Can a new implementation agent start working immediately with only the provided task documents? + +### Phase 3: Reporting & Verdict + +**Goal:** Generate the final evaluation report. + +1. **Execution Update:** Call `update_topic` to indicate the evaluation is complete and the report is being drafted. +2. **Write Report:** Use `write_file` to create exactly `./specs/non-deterministic-eval.md`. +3. **Report Format Constraints:** The generated file MUST strictly adhere to this format: + - For each of the 7 criteria, write: `**[Criterion Name]: [Yes/No]**` followed by a paragraph containing exactly **3 to 5 lines of evidence/reasoning** justifying the status. + - **Final Verdict:** Provide a concluding statement clearly stating whether the team should proceed to the 'Implement' stage or halt to revise the design. + - **Score:** On a separate line, display the final score in the exact format: `**Score: n/7**` (where 'n' is the number of 'Yes' criteria out of 7 total). + +### Phase 4: Finalization + +**Goal:** Conclude the audit workflow. + +1. **Final Update:** Call `update_topic` to report the final Score (n/7), the Verdict, and confirm that `./specs/non-deterministic-eval.md` has been successfully written. diff --git a/.agent/gemini/feature-request-template.md b/.agent/gemini/feature-request-template.md new file mode 100644 index 00000000..f3aaba00 --- /dev/null +++ b/.agent/gemini/feature-request-template.md @@ -0,0 +1,62 @@ +# 기능 요구사항 명세서 (Feature Request Template) + + + +## 1. 기능 명칭 (Feature Name) + +- 기능명: + +## 2. 배경 및 목적 (Background) + +- 배경: + +## 3. 사용자 스토리 (User Stories) + +- [ ] + +## 4. 데이터 스키마 (Data Schema) + +- + +## 5. 프로토콜 상세 (Protocol Details) + +- 통신 방식: +- 엔드포인트 / 토픽: + +## 6. 예외 및 엣지 케이스 (Edge Cases) + +- + +## 7. UI/UX 요구사항 (UI/UX Requirements) + +- diff --git a/.agent/gemini/how-to-use.md b/.agent/gemini/how-to-use.md new file mode 100644 index 00000000..d2cae4ed --- /dev/null +++ b/.agent/gemini/how-to-use.md @@ -0,0 +1,96 @@ +# Gemini CLI 워크플로우 사용 가이드: 설계부터 평가까지 + +본 가이드는 `.agent/gemini/` 디렉토리에 정의된 6개의 Gemini CLI 전용 워크플로우를 사용하여 요구사항 정의부터 최종 구현 핸드오프, 그리고 평가(Auditor)까지의 파이프라인을 실행하는 방법을 안내합니다. 본 가이드는 현재 작성된 워크플로우 파일의 내용만을 기반으로 하며, 임의로 워크플로우를 수정하여 사용하는 것을 권장하지 않습니다. + +### **엄격 모드 (Strict Mode)** + +만약 모호함, 누락된 정보, 또는 검증되지 않은 기술적 세부 사항이 발견되면, 에이전트는 즉시 실행을 중지하고 사용자에게 명확한 설명을 요청해야 합니다. 추측하거나, 가정하거나, 불확실한 상태로 진행하지 마십시오. 모든 워크플로우는 이 제로 모호성(Zero-ambiguity) 원칙을 영구적으로 준수해야 합니다. + +--- + +## 1. 파이프라인 실행 순서 (Sequence) + +전체 프로세스는 논리적인 흐름에 따라 다음 6단계로 진행됩니다. 각 단계는 이전 단계의 산출물을 기반으로 동작합니다. **특히 마지막 6단계(Evaluate)는 객관성을 유지하기 위해 반드시 기존 세션을 종료하고 새로운 독립 세션(새 프롬프트 창)에서 실행해야 합니다.** + +### 1단계: Specify (요구사항 정의) - Template-First Process + +- **목적:** 사용자가 무엇을 필요로 하고 그 이유가 무엇인지에 초점을 맞춘 초기 기능 명세서(Feature Specification)를 구체적이고 표준화된 형식으로 초안합니다. +- **실행:** Gemini CLI에게 "Specify 워크플로우를 실행해줘"라고 요청합니다. +- **동작:** + - **초기화 및 템플릿 제공:** 에이전트는 먼저 `./.agent/gemini/feature-request-template.md` 파일을 읽어 `./specs/feature-request.md` 경로에 요구사항 작성용 템플릿을 복사해 줍니다. 그리고 사용자에게 작성을 요청한 뒤 실행을 일시 중지(STOP)합니다. + - **사용자 작성 (Human-in-the-Loop):** 사용자는 `./specs/feature-request.md` 파일 내의 가이드(주석)에 따라 요구사항을 상세히 작성한 후, 에이전트를 다시 호출("다 작성했어" 등)합니다. + - **공식 명세서 생성 (Formalization):** 에이전트가 작성된 `feature-request.md`와 `README.md`, 'Project Constitution'을 읽고 분석하여 최종적인 공식 명세서인 `./specs/spec.md`를 생성합니다. 여전히 불명확한 부분은 `[NEEDS CLARIFICATION]` 태그로 표시됩니다. + +### 2단계: Clarify (요구사항 구체화) + +- **목적:** 명세서 내의 모호한 부분이나 누락된 디테일(엣지 케이스 등)을 식별하고 해결합니다. +- **실행:** Gemini CLI에게 "Clarify 워크플로우를 실행해줘"라고 요청합니다. +- **동작:** 먼저 `./.specify/memory/constitution.md`에 위치한 'Project Constitution'을 읽어 이후 단계의 기반 원칙으로 삼습니다. `spec.md` 파일 내의 `[NEEDS CLARIFICATION]` 태그를 찾아 사용자에게 최대 10개의 객관식 또는 단답형 질문을 던집니다. 사용자의 답변을 바탕으로 `spec.md` 파일을 즉시 업데이트하여 모호성을 제거합니다. + +### 3단계: Plan (설계 및 기술 계획) + +- **목적:** 확정된 명세서를 바탕으로 기술적 접근 방식과 아키텍처를 설계합니다. +- **실행:** Gemini CLI에게 "Plan 워크플로우를 실행해줘"라고 요청합니다. +- **동작:** 기술 스택, 데이터 모델, 컴포넌트 계층 구조 등을 기획하고, 이 내용을 `./specs/plan.md` 파일로 작성합니다. 기획 시 모든 파일 경로는 `tree` 명령어 등으로 실제 존재하는지 검증되어야 하며, 모든 기획은 명세서의 요구사항과 직접적으로 매핑되어야 합니다. 필요에 따라 `./specs/data-model.md`와 같은 보조 파일도 함께 생성합니다. + +### 4단계: Tasks (작업 분할) + +- **목적:** 기획된 계획을 실행 가능한 의존성 기반의 작업 목록으로 쪼갭니다. +- **실행:** Gemini CLI에게 "Tasks 워크플로우를 실행해줘"라고 요청합니다. +- **동작:** `spec.md`와 `plan.md`를 읽고 작업을 세팅, 파운데이션, 기능 구현, 폴리싱 단계로 나눕니다. 모든 작업은 명세서의 엣지 케이스 및 요구사항과 추적 가능하도록 명시적으로 매핑되어야 하며, 실제 파일 경로가 존재하는지 다시 한 번 검증해야 합니다. 결과물은 개별 작업 ID와 설명이 포함된 `./specs/tasks.md` 파일로 저장됩니다. + +### 5단계: Analyze (최종 핸드오프 준비) + +- **목적:** 설계 단계를 마무리하고 다음 구현 에이전트가 즉각적으로 작업에 착수할 수 있도록 최종 문서를 생성합니다. +- **실행:** Gemini CLI에게 "Analyze 워크플로우를 실행해줘"라고 요청합니다. +- **동작:** 이전까지 생성된 모든 산출물을 종합하여 다음 단계(구현)를 위한 완벽한 지침서 세트를 생성합니다 (`./specs/tasks/` 디렉토리에 요약 및 개별 태스크 문서 생성). 최종적으로 "새로운 개발자가 질문 하나 없이 이것을 구현할 수 있는가?"를 스스로 검증하며, 불가능하다면 남은 모호성을 식별하여 사용자에게 묻습니다. + +### 6단계: Evaluate (Auditor 세션 - 평가 및 검증) + +- **목적:** 이전 5단계에서 생성된 설계 아티팩트가 구현 단계로 넘어가기에 충분히 완벽하고 오류가 없는지 객관적으로 검사(Audit)합니다. +- **실행:** **반드시 새로운 프롬프트 창을 열어 신규 세션에서** "Evaluate 워크플로우를 실행해줘"라고 요청합니다. +- **동작:** 에이전트가 감사자(Auditor) 역할을 수행하여 7가지 엄격한 Boolean 기준에 대해 검증을 진행하고 평가 보고서를 생성합니다. + +--- + +## 2. 디렉토리 제약 사항 (Constraints) + +**매우 중요:** 이 파이프라인의 모든 워크플로우는 **반드시 `./specs/` 디렉토리 내에만** 산출물(중간 및 최종 설계 아티팩트, 다이어그램, 기획서 등)을 저장하도록 엄격하게 설계되었습니다. + +- `spec.md`, `plan.md`, `tasks.md`, `data-model.md` 등 파이프라인 진행 중 생성되는 모든 마크다운 파일은 `./specs/` 루트에 위치해야 합니다. +- 에이전트는 구조적 무결성을 유지하기 위해 작업 전후로 해당 디렉토리의 파일 존재 여부를 검증(`list_directory`, `read_file` 도구 사용)합니다. + +--- + +## 3. 최종 핸드오프 프로세스 (Handoff Process) + +**Analyze 워크플로우**는 단순한 분석을 넘어 '구현을 위한 문서화(Handoff)'를 담당합니다. 이 워크플로우가 실행되면 `./specs/tasks/` 디렉토리 내에 다음 두 가지 형태의 문서가 생성됩니다. + +1. **프로젝트 개요 (Summary Document):** + - **파일명:** `./specs/tasks/00-project-overview.md` + - **내용:** 전체 시스템 아키텍처, 기술적 결정 사항, 그리고 전체 목표를 조망할 수 있는 요약 문서입니다. +2. **개별 작업 지침서 (Task-Specific Documents):** + - **파일명:** `./specs/tasks/task-01-xxx.md`, `./specs/tasks/task-02-yyy.md` 등 (`tasks.md`에 정의된 단위별로 생성) + - **내용:** 다음 구현 에이전트가 읽고 바로 코드를 작성할 수 있도록 돕는 구체적인 지침, 컨텍스트, 요구사항이 담겨 있습니다. 작업량이 많을 경우 서브 에이전트(`invoke_agent`)를 활용하여 일괄 생성될 수 있습니다. + +--- + +## 4. 평가 보고서 및 점수 산정 (Report & Scoring) + +6단계 **Evaluate 워크플로우**가 완료되면 `./specs/non-deterministic-eval.md` 파일에 최종 평가 보고서가 저장됩니다. 보고서는 다음의 구조와 해석 기준을 따릅니다: + +- **7가지 검증 기준 (Boolean-based):** + 각 기준(Requirement Traceability, Task Atomicity, Contextual Grounding, Unambiguity, Directory Discipline, Technical Feasibility, Handoff Completeness)에 대해 엄격한 `[Yes/No]` 상태가 판별되며, 뒤이어 3~5줄의 명확한 증거와 추론이 기재됩니다. +- **최종 점수 (Score):** + 보고서 하단에는 `**Score: n/7**` 형식으로 점수가 표시됩니다. 이는 7개의 기준 중 `Yes`를 받은 항목의 개수를 의미합니다. +- **핸드오프 논리 (Handoff Logic):** + 다음 구현(Implement) 단계로 넘어갈 수 있는지의 최종 결정은 보고서 하단의 **'Final Verdict(최종 판정)'**에 의해 결정됩니다. 점수가 낮거나 Handoff에 치명적인 `No` 판정이 있는 경우, 팀은 설계를 보완하기 위해 이전 단계로 돌아가야 합니다. 긍정적인 최종 판정을 받았을 때만 비로소 새로운 구현 에이전트가 코딩 작업을 시작할 수 있습니다. + +--- + +## 5. 워크플로우 무결성 (Integrity) + +본 가이드는 현재 `.agent/gemini/` 디렉토리에 정의된 6개의 마크다운 파일 내용만을 기반으로 작성되었습니다. + +- 사용자는 각 워크플로우 파일에 명시된 `description`과 `Phase` 목표에 맞게 Gemini CLI에게 자연어로 요청하기만 하면 됩니다. +- 워크플로우 파일 내부의 로직(예: 툴 호출 순서, 저장 경로, `update_topic` 호출 등)은 변경하지 않고 그대로 사용하는 것이 가장 안전하고 효율적입니다. diff --git a/.agent/gemini/plan.md b/.agent/gemini/plan.md new file mode 100644 index 00000000..10573239 --- /dev/null +++ b/.agent/gemini/plan.md @@ -0,0 +1,41 @@ +--- +description: Generate an implementation plan and design artifacts based on the specification. +--- + +### Strict Mode + +If any ambiguity, missing information, or unverified technical detail is encountered, the agent MUST immediately stop execution and ask the user for clarification. Do not guess, do not assume, and do not proceed with uncertainty. + +### Phase 1: Research & Discovery + +**Goal:** Gather all necessary requirements for planning. + +1. **Initialize Topic:** Call `update_topic` to indicate the start of the planning phase. +2. **Read Specification:** Use `read_file` to read `./specs/spec.md`. +3. **Explore Codebase:** Use `glob` or `grep_search` to understand existing architectural patterns in `./src/`. + +### Phase 2: Strategy & Planning + +**Goal:** Draft the technical approach. + +1. **Draft Architecture:** Define the tech stack, data models, and component hierarchy based on the specification. +2. **Check Constraints:** Ensure the plan adheres to any project constitutions or architectural guidelines. +3. **Ensure Traceability:** Ensure every planned implementation explicitly maps back to an edge case or requirement in `spec.md`. +4. **Strategy Update:** Call `update_topic` to share a summary of the technical strategy. + +### Phase 3: Execution & Implementation + +**Goal:** Create the planning documents. + +1. **Execution Update:** Call `update_topic` before writing files. +2. **Verify Structure:** Verify every file path against the actual project structure (e.g., via `run_shell_command` with `tree` output or `list_directory`) to ensure the plan references valid paths. +3. **Write Plan File:** Use `write_file` to create `./specs/plan.md`. This artifact MUST be stored in the `./specs/` directory. +4. **Write Auxiliary Files:** Use `write_file` to generate any necessary diagrams, data models (e.g., `./specs/data-model.md`), or API contracts. These MUST ALSO reside strictly within the `./specs/` directory. + +### Phase 4: Validation & Finalization + +**Goal:** Verify completeness of the planning artifacts. + +1. **Verify Structure:** Use `list_directory` on `./specs/` to ensure `plan.md` and related files exist. +2. **Final Update:** Call `update_topic` to recap the generated planning artifacts. +3. **Next Steps:** Suggest running the Tasks workflow to break down the plan. diff --git a/.agent/gemini/specify.md b/.agent/gemini/specify.md new file mode 100644 index 00000000..2b99b7b4 --- /dev/null +++ b/.agent/gemini/specify.md @@ -0,0 +1,53 @@ +--- +description: Create or update a feature specification focusing on what users need and why using a template-first approach. +--- + +### Strict Mode + +If any ambiguity, missing information, or unverified technical detail is encountered, the agent MUST immediately stop execution and ask the user for clarification. Do not guess, do not assume, and do not proceed with uncertainty. + +### .gitignore Hygiene + +The agent must ensure that any temporary files, logs, or sensitive information generated during the specification process are not tracked by git, strictly adhering to `.gitignore` rules. + +### Phase 1: Initialization & Human-in-the-Loop + +**Goal:** Provide the user with the requirement template and wait for input. + +1. **Initialize Topic:** Call `update_topic` to start the specify workflow. +2. **Check Existing Template:** Use `list_directory` or `read_file` to check if `./specs/feature-request.md` already exists and is filled out. +3. **Step 1 (Initialization):** If `./specs/feature-request.md` does not exist or is empty, the agent MUST copy `./.agent/gemini/feature-request-template.md` to `./specs/feature-request.md`. (You can use `read_file` on the template and `write_file` to the destination). +4. **Step 2 (Human-in-the-Loop):** Once the template is copied, the agent MUST STOP execution and instruct the user in Korean: "요구사항 템플릿이 `./specs/feature-request.md`에 생성되었습니다. 내용을 채워 넣은 후 다시 호출해 주세요." Do not proceed further until the user returns. + +### Phase 2: Research & Discovery + +**Goal:** Read user requirements and project context. + +1. **Explore Context:** Once the user confirms the template is filled, use `read_file` to read the `README.md` for project overview and domain context. +2. **Read Filled Template:** Use `read_file` to read the contents of `./specs/feature-request.md`. +3. **Read Constitution:** Use `read_file` to read the 'Project Constitution' at `./.specify/memory/constitution.md`. All subsequent stages must be strictly governed by the principles found in this Constitution. + +### Phase 3: Formalization & Strategy + +**Goal:** Define the feature scope and finalize the formal specification. + +1. **Silent Self-Correction:** Review the loaded Constitution one more time silently. Ensure your planned implementation for the feature request does not violate any architectural principles or prohibitions before writing any code. +2. **Analyze Requirements:** Analyze the filled `feature-request.md` against the Project Constitution. +3. **Draft Formal Spec:** Structure the formal specification (`spec.md`) focusing on Functional Scope, Data Model, UX Flow, Edge Cases, Non-Functional, and External Integration based on the provided template data. +4. **Strategy Update:** Call `update_topic` to inform the user of the planned specification structure. + +### Phase 4: Execution & Implementation + +**Goal:** Draft the formal feature specification. + +1. **Execution Update:** Call `update_topic` to indicate the specification drafting has begun. +2. **Step 3 (Formalization):** Use `write_file` or `replace` to create or update `./specs/spec.md`. All intermediate and final design artifacts MUST be stored strictly in the `./specs/` directory. +3. **Highlight Ambiguities:** Use `[NEEDS CLARIFICATION]` markers for unclear aspects that were missing or ambiguous in the `feature-request.md`. + +### Phase 5: Validation & Finalization + +**Goal:** Ensure completeness and trigger the next step. + +1. **Verify Structure:** Read the written `./specs/spec.md` to ensure all sections are present. +2. **Final Update:** Call `update_topic` to recap the completion of the specification. +3. **Next Steps:** Suggest running the Clarify workflow to resolve any `[NEEDS CLARIFICATION]` markers. diff --git a/.agent/gemini/tasks.md b/.agent/gemini/tasks.md new file mode 100644 index 00000000..088bd863 --- /dev/null +++ b/.agent/gemini/tasks.md @@ -0,0 +1,30 @@ +--- +description: Generate an actionable, dependency-ordered task list based on the specification and plan. +--- + +### Strict Mode +If any ambiguity, missing information, or unverified technical detail is encountered, the agent MUST immediately stop execution and ask the user for clarification. Do not guess, do not assume, and do not proceed with uncertainty. + +### Phase 1: Research & Discovery +**Goal:** Load the specification and plan artifacts. +1. **Initialize Topic:** Call `update_topic` to begin task breakdown. +2. **Read Artifacts:** Use `read_file` (parallelized) to read `./specs/spec.md` and `./specs/plan.md`. +3. **Verify Structure:** Verify every file path against the actual project structure (e.g., via `run_shell_command` with `tree` output or `list_directory`) to ensure tasks reference existing architecture correctly. + +### Phase 2: Strategy & Planning +**Goal:** Deconstruct the plan into a logical sequence of tasks. +1. **Identify Tasks:** Break the work down into actionable items mapped to user stories and technical phases. +2. **Determine Dependencies:** Sequence the tasks (Setup -> Foundational -> Feature -> Polish) and identify parallelization opportunities. +3. **Ensure Traceability:** Ensure every task explicitly maps back to an edge case or requirement in `spec.md`. +4. **Strategy Update:** Call `update_topic` to summarize the planned task phases. + +### Phase 3: Execution & Implementation +**Goal:** Write the consolidated task list. +1. **Execution Update:** Call `update_topic` before writing. +2. **Write Tasks File:** Use `write_file` to create `./specs/tasks.md`. This MUST reside strictly in `./specs/`. Ensure each task has a clear ID, description, related file paths, and a direct reference to the originating requirement or edge case in `spec.md`. + +### Phase 4: Validation & Finalization +**Goal:** Validate task coverage against requirements. +1. **Verify Coverage:** Ensure every user story and edge case in `spec.md` has corresponding tasks in `tasks.md`. +2. **Final Update:** Call `update_topic` to report the total number of tasks generated. +3. **Next Steps:** Suggest running the Analyze workflow for the final handoff generation. diff --git a/.specify/memory/constitution.md b/.specify/memory/constitution.md index 9405b27f..1956f9a7 100644 --- a/.specify/memory/constitution.md +++ b/.specify/memory/constitution.md @@ -9,8 +9,8 @@ - **page/**: 페이지 단위 컴포넌트. 각 페이지는 로컬 `components/`, `hooks/` 하위 디렉토리를 가진다. - **components/**: 페이지 간 재사용 가능한 UI 컴포넌트. 폴더 단위로 관리. - **hooks/**: 공유 커스텀 훅. - - `query/` — TanStack Query 래핑 (useGet*) - - `mutations/` — TanStack Mutation 래핑 (usePost*, usePatch*, useDelete*) + - `query/` — TanStack Query 래핑 (useGet\*) + - `mutations/` — TanStack Mutation 래핑 (usePost*, usePatch*, useDelete\*) - 루트 — 유틸리티 훅 (useModal, useMobile 등) - **apis/**: API 통신 레이어. - `apis/` — Axios 기반 API 함수 @@ -36,6 +36,7 @@ - **Boolean**: `is`/`has`/`should` 접두사 (e.g., `isLoading`, `hasError`). - **이벤트 핸들러**: `handle` 접두사 (e.g., `handleSubmit`). - **커스텀 훅**: `use` 접두사 (e.g., `useTimerPageState`). +- **요청 및 응답 형식**: `RequestType` 또는 `ResponseType` 접두사 (e.g., `GetDebateTableResponseType`). - **파일명**: 컴포넌트 PascalCase, 훅 camelCase(`use` prefix), 유틸 camelCase. ### III. TDD (Test-Driven Development) @@ -100,5 +101,6 @@ - 이 Constitution은 프로젝트의 모든 코드 작성 및 리뷰의 기준이 된다. - 아키텍처 결정이 불명확할 경우, 코드 작성 전 팀원과 논의한다. - Constitution 변경 시 팀 합의 및 문서 업데이트가 필요하다. +- 작업 중 생성되는 설계서 등의 임시 문서의 생명 주기는 이 기능 개발 완료 시점까지이므로 Git에 포함할 필요가 없다. 따라서 `specs/` 디렉터리를 `.gitignore`에서 제외해서는 안 되며, 어떤 상황에서도 `specs/`는 원격 저장소에 푸시하지 않는다. -**Version**: 2.0.0 | **Ratified**: 2026-02-13 | **Last Amended**: 2026-03-31 +**Version**: 2.0.0 | **Ratified**: 2026-02-13 | **Last Amended**: 2026-05-13 diff --git a/package-lock.json b/package-lock.json index 2cdbd723..00a3fe92 100644 --- a/package-lock.json +++ b/package-lock.json @@ -70,7 +70,7 @@ "eslint-plugin-react-hooks": "^5.1.0", "eslint-plugin-react-refresh": "^0.4.14", "eslint-plugin-storybook": "^0.11.1", - "eslint-plugin-tailwindcss": "^3.17.5", + "eslint-plugin-tailwindcss": "3.18.2", "glob": "^13.0.0", "globals": "^17.3.0", "jsdom": "^28.0.0", @@ -119,18 +119,19 @@ } }, "node_modules/@amplitude/analytics-browser": { - "version": "2.39.2", - "resolved": "https://registry.npmjs.org/@amplitude/analytics-browser/-/analytics-browser-2.39.2.tgz", - "integrity": "sha512-kSSAnnKK+2jSxBBrz7PGpeO1U+o1LPmmV4Jx4/S9kNZmPHQnxLZGwgtsX5YJYTvopJGRf2LFD83HArA2PrWuDw==", - "license": "MIT", - "dependencies": { - "@amplitude/analytics-core": "2.45.0", - "@amplitude/plugin-autocapture-browser": "1.25.2", - "@amplitude/plugin-custom-enrichment-browser": "0.1.4", - "@amplitude/plugin-network-capture-browser": "1.9.13", - "@amplitude/plugin-page-url-enrichment-browser": "0.7.5", - "@amplitude/plugin-page-view-tracking-browser": "2.9.6", - "@amplitude/plugin-web-vitals-browser": "1.1.28", + "version": "2.42.3", + "resolved": "https://registry.npmjs.org/@amplitude/analytics-browser/-/analytics-browser-2.42.3.tgz", + "integrity": "sha512-zej80Yb7G56xjNeURhyFDXgluIroIdcCQVg6bcTQebJOp2HkjleUL7YMa/891sdmKuqJ8TrZgJoCE7wMz2fqcg==", + "license": "MIT", + "dependencies": { + "@amplitude/analytics-core": "2.48.1", + "@amplitude/plugin-autocapture-browser": "1.27.1", + "@amplitude/plugin-custom-enrichment-browser": "0.1.8", + "@amplitude/plugin-event-property-attribution-browser": "0.2.0", + "@amplitude/plugin-network-capture-browser": "1.10.0", + "@amplitude/plugin-page-url-enrichment-browser": "0.7.10", + "@amplitude/plugin-page-view-tracking-browser": "2.11.0", + "@amplitude/plugin-web-vitals-browser": "1.1.32", "tslib": "^2.4.1" } }, @@ -141,9 +142,9 @@ "license": "MIT" }, "node_modules/@amplitude/analytics-core": { - "version": "2.45.0", - "resolved": "https://registry.npmjs.org/@amplitude/analytics-core/-/analytics-core-2.45.0.tgz", - "integrity": "sha512-vWRYbXu2Grs1GM+WHo03RPtbaPs5sJm21YQcAow9JASvtoY4xNqItIeRydCJQWtFHhbbxY41n+CVW6mzDP6aBA==", + "version": "2.48.1", + "resolved": "https://registry.npmjs.org/@amplitude/analytics-core/-/analytics-core-2.48.1.tgz", + "integrity": "sha512-6ltecomnZc9z1AUE59orcoLYii0gPoVBBoI4qoMGXw2lsxFh35zx32LPrXO3ezFkTE71CS44AzwaBFsSxQWRuw==", "license": "MIT", "dependencies": { "@amplitude/analytics-connector": "^1.6.4", @@ -154,93 +155,158 @@ } }, "node_modules/@amplitude/plugin-autocapture-browser": { - "version": "1.25.2", - "resolved": "https://registry.npmjs.org/@amplitude/plugin-autocapture-browser/-/plugin-autocapture-browser-1.25.2.tgz", - "integrity": "sha512-AWzIX0uit60Q742rH/96/n88e+3BaVZa4+7Xs+BeuuIOyrljOZlQKzH23Lxzkl0DgbNb5+MMqWds0pov3DV5TA==", + "version": "1.27.1", + "resolved": "https://registry.npmjs.org/@amplitude/plugin-autocapture-browser/-/plugin-autocapture-browser-1.27.1.tgz", + "integrity": "sha512-vJxwfExfREBIMbVydAimO/diYsRfNBD/nqo+DABURBsdmb+gliUPd7J6g22ys7al59+ixRwJSQMUrk0bnqn76Q==", "license": "MIT", "dependencies": { - "@amplitude/analytics-core": "2.45.0", + "@amplitude/analytics-core": "2.48.1", "tslib": "^2.4.1" } }, "node_modules/@amplitude/plugin-custom-enrichment-browser": { - "version": "0.1.4", - "resolved": "https://registry.npmjs.org/@amplitude/plugin-custom-enrichment-browser/-/plugin-custom-enrichment-browser-0.1.4.tgz", - "integrity": "sha512-vxuQocn8YGE2wMLZUmotRG8c6RijoaQAsHKDQEO56CNk3WhSecgSGMnlHcUcOYIzwfXKFj4MxRJS386kdDHV+Q==", + "version": "0.1.8", + "resolved": "https://registry.npmjs.org/@amplitude/plugin-custom-enrichment-browser/-/plugin-custom-enrichment-browser-0.1.8.tgz", + "integrity": "sha512-PVg56GfQID/UKLZx7imbg6Tmlj2AX/euyG7nnouKpowgGJ7jz/t4o2u3csSgrKbLSrTjxdbXVdPyz/+CecJ4Zg==", "license": "MIT", "dependencies": { - "@amplitude/analytics-core": "2.45.0", + "@amplitude/analytics-core": "2.48.0", "tslib": "^2.4.1" } }, + "node_modules/@amplitude/plugin-custom-enrichment-browser/node_modules/@amplitude/analytics-core": { + "version": "2.48.0", + "resolved": "https://registry.npmjs.org/@amplitude/analytics-core/-/analytics-core-2.48.0.tgz", + "integrity": "sha512-6ckWWL60LiJJEQQ5V3Veviq0Gl5Lcvy1dFaRDKA/SwLT3cgiBrEFZXZPcri4wDGjchPmJXFCYcE55nr7rP6Wjg==", + "license": "MIT", + "dependencies": { + "@amplitude/analytics-connector": "^1.6.4", + "@types/zen-observable": "0.8.3", + "safe-json-stringify": "1.2.0", + "tslib": "^2.4.1", + "zen-observable": "0.10.0" + } + }, + "node_modules/@amplitude/plugin-event-property-attribution-browser": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/@amplitude/plugin-event-property-attribution-browser/-/plugin-event-property-attribution-browser-0.2.0.tgz", + "integrity": "sha512-7GmAvpOf7CbdIL++9PrdNB8GeyUvL5/yRrv1hnVC7rv19MKumbYK9Oq1utOUr6nddPfQU3w3sz9YK5A8cUVrlQ==", + "license": "MIT", + "dependencies": { + "@amplitude/analytics-core": "2.48.0", + "tslib": "^2.4.1" + } + }, + "node_modules/@amplitude/plugin-event-property-attribution-browser/node_modules/@amplitude/analytics-core": { + "version": "2.48.0", + "resolved": "https://registry.npmjs.org/@amplitude/analytics-core/-/analytics-core-2.48.0.tgz", + "integrity": "sha512-6ckWWL60LiJJEQQ5V3Veviq0Gl5Lcvy1dFaRDKA/SwLT3cgiBrEFZXZPcri4wDGjchPmJXFCYcE55nr7rP6Wjg==", + "license": "MIT", + "dependencies": { + "@amplitude/analytics-connector": "^1.6.4", + "@types/zen-observable": "0.8.3", + "safe-json-stringify": "1.2.0", + "tslib": "^2.4.1", + "zen-observable": "0.10.0" + } + }, "node_modules/@amplitude/plugin-network-capture-browser": { - "version": "1.9.13", - "resolved": "https://registry.npmjs.org/@amplitude/plugin-network-capture-browser/-/plugin-network-capture-browser-1.9.13.tgz", - "integrity": "sha512-8uzTQFbP+dvqJX+S39KqKw+EheJW8JCWT/xlXT55vtTU/ZTFeF074QnHFEKUPewpYXpwKXgJky8PDoMk0b46Qw==", + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/@amplitude/plugin-network-capture-browser/-/plugin-network-capture-browser-1.10.0.tgz", + "integrity": "sha512-wDTxpeDCst+pKyfRnVH1RYV93ctQtfuAQuCaUHqSO/TYcHnidGFWLl/UVbMvJ6EkrPoDXOCCTk5gTSPVMKSp0w==", "license": "MIT", "dependencies": { - "@amplitude/analytics-core": "2.45.0", + "@amplitude/analytics-core": "2.48.0", "tslib": "^2.4.1" } }, + "node_modules/@amplitude/plugin-network-capture-browser/node_modules/@amplitude/analytics-core": { + "version": "2.48.0", + "resolved": "https://registry.npmjs.org/@amplitude/analytics-core/-/analytics-core-2.48.0.tgz", + "integrity": "sha512-6ckWWL60LiJJEQQ5V3Veviq0Gl5Lcvy1dFaRDKA/SwLT3cgiBrEFZXZPcri4wDGjchPmJXFCYcE55nr7rP6Wjg==", + "license": "MIT", + "dependencies": { + "@amplitude/analytics-connector": "^1.6.4", + "@types/zen-observable": "0.8.3", + "safe-json-stringify": "1.2.0", + "tslib": "^2.4.1", + "zen-observable": "0.10.0" + } + }, "node_modules/@amplitude/plugin-page-url-enrichment-browser": { - "version": "0.7.5", - "resolved": "https://registry.npmjs.org/@amplitude/plugin-page-url-enrichment-browser/-/plugin-page-url-enrichment-browser-0.7.5.tgz", - "integrity": "sha512-0Q7P5vsue/s92i3zevVDVJf9AiHkbxGdwkB8iV2oWgkXtglzWugwr//qN+muHmXdi1ZWxRjm93CW+jQJVripgw==", + "version": "0.7.10", + "resolved": "https://registry.npmjs.org/@amplitude/plugin-page-url-enrichment-browser/-/plugin-page-url-enrichment-browser-0.7.10.tgz", + "integrity": "sha512-ILehefdpqUk1iK4G8kTlRxmf7OogiG2Y4lRph2cvsw25a9zVUpPXeZ5H4us9uEWrsUI0Y7w6FJlt3MMBJRMiBA==", "license": "MIT", "dependencies": { - "@amplitude/analytics-core": "2.45.0", + "@amplitude/analytics-core": "2.48.1", "tslib": "^2.4.1" } }, "node_modules/@amplitude/plugin-page-view-tracking-browser": { - "version": "2.9.6", - "resolved": "https://registry.npmjs.org/@amplitude/plugin-page-view-tracking-browser/-/plugin-page-view-tracking-browser-2.9.6.tgz", - "integrity": "sha512-/4lG2lXIB6qbQNf1VYQ5fDOnvInPEtYuOgvmyLfuZ6PvHVFUu4NZtoOVdAcy0R9x76rNyCpRXxdL78p9Ra1ANA==", + "version": "2.11.0", + "resolved": "https://registry.npmjs.org/@amplitude/plugin-page-view-tracking-browser/-/plugin-page-view-tracking-browser-2.11.0.tgz", + "integrity": "sha512-ZI/1kTQID0yXileGjvseMoFZ9zUbLG5r+MsznmT0Br+x91LdCrPHXdpU7lq53UAwIhgREXk9S/o/AwUZfFSgzg==", "license": "MIT", "dependencies": { - "@amplitude/analytics-core": "2.45.0", + "@amplitude/analytics-core": "2.48.0", "tslib": "^2.4.1" } }, + "node_modules/@amplitude/plugin-page-view-tracking-browser/node_modules/@amplitude/analytics-core": { + "version": "2.48.0", + "resolved": "https://registry.npmjs.org/@amplitude/analytics-core/-/analytics-core-2.48.0.tgz", + "integrity": "sha512-6ckWWL60LiJJEQQ5V3Veviq0Gl5Lcvy1dFaRDKA/SwLT3cgiBrEFZXZPcri4wDGjchPmJXFCYcE55nr7rP6Wjg==", + "license": "MIT", + "dependencies": { + "@amplitude/analytics-connector": "^1.6.4", + "@types/zen-observable": "0.8.3", + "safe-json-stringify": "1.2.0", + "tslib": "^2.4.1", + "zen-observable": "0.10.0" + } + }, "node_modules/@amplitude/plugin-web-vitals-browser": { - "version": "1.1.28", - "resolved": "https://registry.npmjs.org/@amplitude/plugin-web-vitals-browser/-/plugin-web-vitals-browser-1.1.28.tgz", - "integrity": "sha512-gs4Y1eOuVUEDwYEJF82f/GmgQ7iM4Y/eZTkftJKjFsBNbrPro2CuLymfdAcC+QuVfyrp3qAiWcSGnjDXA6ZbQg==", + "version": "1.1.32", + "resolved": "https://registry.npmjs.org/@amplitude/plugin-web-vitals-browser/-/plugin-web-vitals-browser-1.1.32.tgz", + "integrity": "sha512-cf/MR5WTJ5iwCjxdy9f7vK8zy2nD1iXPwu8eKHiRxWR7Eoqx7bT30n9dar8kWDV8kraV0sglA5pVrP3b33m/pw==", "license": "MIT", "dependencies": { - "@amplitude/analytics-core": "2.45.0", + "@amplitude/analytics-core": "2.48.0", "tslib": "^2.4.1", "web-vitals": "5.1.0" } }, + "node_modules/@amplitude/plugin-web-vitals-browser/node_modules/@amplitude/analytics-core": { + "version": "2.48.0", + "resolved": "https://registry.npmjs.org/@amplitude/analytics-core/-/analytics-core-2.48.0.tgz", + "integrity": "sha512-6ckWWL60LiJJEQQ5V3Veviq0Gl5Lcvy1dFaRDKA/SwLT3cgiBrEFZXZPcri4wDGjchPmJXFCYcE55nr7rP6Wjg==", + "license": "MIT", + "dependencies": { + "@amplitude/analytics-connector": "^1.6.4", + "@types/zen-observable": "0.8.3", + "safe-json-stringify": "1.2.0", + "tslib": "^2.4.1", + "zen-observable": "0.10.0" + } + }, "node_modules/@asamuzakjp/css-color": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-5.0.1.tgz", - "integrity": "sha512-2SZFvqMyvboVV1d15lMf7XiI3m7SDqXUuKaTymJYLN6dSGadqp+fVojqJlVoMlbZnlTmu3S0TLwLTJpvBMO1Aw==", + "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": { - "@csstools/css-calc": "^3.1.1", - "@csstools/css-color-parser": "^4.0.2", + "@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", - "lru-cache": "^11.2.6" + "@csstools/css-tokenizer": "^4.0.0" }, "engines": { "node": "^20.19.0 || ^22.12.0 || >=24.0.0" } }, - "node_modules/@asamuzakjp/css-color/node_modules/lru-cache": { - "version": "11.2.7", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.7.tgz", - "integrity": "sha512-aY/R+aEsRelme17KGQa/1ZSIpLpNYYrhcrepKTZgE+W3WM16YMCaPwOHLHsmopZHELU0Ojin1lPVxKR0MihncA==", - "dev": true, - "license": "BlueOak-1.0.0", - "engines": { - "node": "20 || >=22" - } - }, "node_modules/@asamuzakjp/dom-selector": { "version": "6.8.1", "resolved": "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-6.8.1.tgz", @@ -256,15 +322,25 @@ } }, "node_modules/@asamuzakjp/dom-selector/node_modules/lru-cache": { - "version": "11.2.7", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.7.tgz", - "integrity": "sha512-aY/R+aEsRelme17KGQa/1ZSIpLpNYYrhcrepKTZgE+W3WM16YMCaPwOHLHsmopZHELU0Ojin1lPVxKR0MihncA==", + "version": "11.3.6", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.3.6.tgz", + "integrity": "sha512-Gf/KoL3C/MlI7Bt0PGI9I+TeTC/I6r/csU58N4BSNc4lppLBeKsOdFYkK+dX0ABDUMJNfCHTyPpzwwO21Awd3A==", "dev": true, "license": "BlueOak-1.0.0", "engines": { "node": "20 || >=22" } }, + "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", @@ -288,9 +364,9 @@ } }, "node_modules/@babel/compat-data": { - "version": "7.29.0", - "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.0.tgz", - "integrity": "sha512-T1NCJqT/j9+cn8fvkt7jtwbLBfLC/1y1c7NtCeXFRgzGTsafi68MRv8yzkYSapBnFA6L3U2VSc02ciDzoAJhJg==", + "version": "7.29.3", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.3.tgz", + "integrity": "sha512-LIVqM46zQWZhj17qA8wb4nW/ixr2y1Nw+r1etiAWgRM6U1IqP+LNhL1yg440jYZR72jCWcWbLWzIosH+uP1fqg==", "dev": true, "license": "MIT", "engines": { @@ -328,16 +404,6 @@ "url": "https://opencollective.com/babel" } }, - "node_modules/@babel/core/node_modules/semver": { - "version": "6.3.1", - "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", - "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", - "dev": true, - "license": "ISC", - "bin": { - "semver": "bin/semver.js" - } - }, "node_modules/@babel/generator": { "version": "7.29.1", "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.1.tgz", @@ -372,16 +438,6 @@ "node": ">=6.9.0" } }, - "node_modules/@babel/helper-compilation-targets/node_modules/semver": { - "version": "6.3.1", - "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", - "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", - "dev": true, - "license": "ISC", - "bin": { - "semver": "bin/semver.js" - } - }, "node_modules/@babel/helper-globals": { "version": "7.28.0", "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.28.0.tgz", @@ -479,9 +535,9 @@ } }, "node_modules/@babel/parser": { - "version": "7.29.2", - "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.2.tgz", - "integrity": "sha512-4GgRzy/+fsBa72/RZVJmGKPmZu9Byn8o4MoLpmNe1m8ZfYnz5emHLQz3U4gLud6Zwl0RZIcgiLD7Uq7ySFuDLA==", + "version": "7.29.3", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.3.tgz", + "integrity": "sha512-b3ctpQwp+PROvU/cttc4OYl4MzfJUWy6FZg+PMXfzmt/+39iHVF0sDfqay8TQM3JA2EUOyKcFZt75jWriQijsA==", "dev": true, "license": "MIT", "dependencies": { @@ -699,9 +755,9 @@ } }, "node_modules/@csstools/css-calc": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-3.1.1.tgz", - "integrity": "sha512-HJ26Z/vmsZQqs/o3a6bgKslXGFAungXGbinULZO3eMsOyNJHeBBZfup5FiZInOghgoM4Hwnmw+OgbJCNg1wwUQ==", + "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": [ { @@ -723,9 +779,9 @@ } }, "node_modules/@csstools/css-color-parser": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-4.0.2.tgz", - "integrity": "sha512-0GEfbBLmTFf0dJlpsNU7zwxRIH0/BGEMuXLTCvFYxuL1tNhqzTbtnFICyJLTNK4a+RechKP75e7w42ClXSnJQw==", + "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": [ { @@ -740,7 +796,7 @@ "license": "MIT", "dependencies": { "@csstools/color-helpers": "^6.0.2", - "@csstools/css-calc": "^3.1.1" + "@csstools/css-calc": "^3.2.1" }, "engines": { "node": ">=20.19.0" @@ -774,9 +830,9 @@ } }, "node_modules/@csstools/css-syntax-patches-for-csstree": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.1.2.tgz", - "integrity": "sha512-5GkLzz4prTIpoyeUiIu3iV6CSG3Plo7xRVOFPKI7FVEJ3mZ0A8SwK0XU3Gl7xAkiQ+mDyam+NNp875/C5y+jSA==", + "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": [ { @@ -1342,9 +1398,9 @@ "license": "MIT" }, "node_modules/@eslint/config-array/node_modules/brace-expansion": { - "version": "1.1.13", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.13.tgz", - "integrity": "sha512-9ZLprWS6EENmhEOpjCYW2c8VkmOvckIJZfkr7rBW6dObmfgJ/L1GpSYW5Hpo9lDz4D1+n0Ckz8rU7FwHDQiG/w==", + "version": "1.1.14", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.14.tgz", + "integrity": "sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g==", "license": "MIT", "dependencies": { "balanced-match": "^1.0.0", @@ -1417,9 +1473,9 @@ "license": "MIT" }, "node_modules/@eslint/eslintrc/node_modules/brace-expansion": { - "version": "1.1.13", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.13.tgz", - "integrity": "sha512-9ZLprWS6EENmhEOpjCYW2c8VkmOvckIJZfkr7rBW6dObmfgJ/L1GpSYW5Hpo9lDz4D1+n0Ckz8rU7FwHDQiG/w==", + "version": "1.1.14", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.14.tgz", + "integrity": "sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g==", "license": "MIT", "dependencies": { "balanced-match": "^1.0.0", @@ -1503,27 +1559,40 @@ } }, "node_modules/@humanfs/core": { - "version": "0.19.1", - "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz", - "integrity": "sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==", + "version": "0.19.2", + "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.2.tgz", + "integrity": "sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==", "license": "Apache-2.0", + "dependencies": { + "@humanfs/types": "^0.15.0" + }, "engines": { "node": ">=18.18.0" } }, "node_modules/@humanfs/node": { - "version": "0.16.7", - "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.7.tgz", - "integrity": "sha512-/zUx+yOsIrG4Y43Eh2peDeKCxlRt/gET6aHfaKpuq267qXdYDFViVHfMaLyygZOnl0kGWxFIgsBy8QFuTLUXEQ==", + "version": "0.16.8", + "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.8.tgz", + "integrity": "sha512-gE1eQNZ3R++kTzFUpdGlpmy8kDZD/MLyHqDwqjkVQI0JMdI1D51sy1H958PNXYkM2rAac7e5/CnIKZrHtPh3BQ==", "license": "Apache-2.0", "dependencies": { - "@humanfs/core": "^0.19.1", + "@humanfs/core": "^0.19.2", + "@humanfs/types": "^0.15.0", "@humanwhocodes/retry": "^0.4.0" }, "engines": { "node": ">=18.18.0" } }, + "node_modules/@humanfs/types": { + "version": "0.15.0", + "resolved": "https://registry.npmjs.org/@humanfs/types/-/types-0.15.0.tgz", + "integrity": "sha512-ZZ1w0aoQkwuUuC7Yf+7sdeaNfqQiiLcSRbfI08oAxqLtpXQr9AIVX7Ay7HLDuiLYAaFPu8oBYNq/QIi9URHJ3Q==", + "license": "Apache-2.0", + "engines": { + "node": ">=18.18.0" + } + }, "node_modules/@humanwhocodes/module-importer": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", @@ -1551,27 +1620,27 @@ } }, "node_modules/@inquirer/ansi": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/@inquirer/ansi/-/ansi-1.0.2.tgz", - "integrity": "sha512-S8qNSZiYzFd0wAcyG5AXCvUHC5Sr7xpZ9wZ2py9XR88jUz8wooStVx5M6dRzczbBWjic9NP7+rY0Xi7qqK/aMQ==", + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@inquirer/ansi/-/ansi-2.0.5.tgz", + "integrity": "sha512-doc2sWgJpbFQ64UflSVd17ibMGDuxO1yKgOgLMwavzESnXjFWJqUeG8saYosqKpHp4kWiM5x1nXvEjbpx90gzw==", "dev": true, "license": "MIT", "engines": { - "node": ">=18" + "node": ">=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0" } }, "node_modules/@inquirer/confirm": { - "version": "5.1.21", - "resolved": "https://registry.npmjs.org/@inquirer/confirm/-/confirm-5.1.21.tgz", - "integrity": "sha512-KR8edRkIsUayMXV+o3Gv+q4jlhENF9nMYUZs9PA2HzrXeHI8M5uDag70U7RJn9yyiMZSbtF5/UexBtAVtZGSbQ==", + "version": "6.0.13", + "resolved": "https://registry.npmjs.org/@inquirer/confirm/-/confirm-6.0.13.tgz", + "integrity": "sha512-wkGPC7yJ5WJk1DJ5SX7fzk+gfj4BM8cf5dDDi71B/551xHrdsZVRJOC0WyikXd0pEsb/9cLniuE4atbsMqmFkw==", "dev": true, "license": "MIT", "dependencies": { - "@inquirer/core": "^10.3.2", - "@inquirer/type": "^3.0.10" + "@inquirer/core": "^11.1.10", + "@inquirer/type": "^4.0.5" }, "engines": { - "node": ">=18" + "node": ">=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0" }, "peerDependencies": { "@types/node": ">=18" @@ -1583,23 +1652,22 @@ } }, "node_modules/@inquirer/core": { - "version": "10.3.2", - "resolved": "https://registry.npmjs.org/@inquirer/core/-/core-10.3.2.tgz", - "integrity": "sha512-43RTuEbfP8MbKzedNqBrlhhNKVwoK//vUFNW3Q3vZ88BLcrs4kYpGg+B2mm5p2K/HfygoCxuKwJJiv8PbGmE0A==", + "version": "11.1.10", + "resolved": "https://registry.npmjs.org/@inquirer/core/-/core-11.1.10.tgz", + "integrity": "sha512-a4Q5BXHQAHa9eO202sTaFCHFYVB3x5fauDuThEAdZ9gfn76pSxiKU7wWcEH0N1O0XmQvNfQNU6QXpiRxmYQx+A==", "dev": true, "license": "MIT", "dependencies": { - "@inquirer/ansi": "^1.0.2", - "@inquirer/figures": "^1.0.15", - "@inquirer/type": "^3.0.10", + "@inquirer/ansi": "^2.0.5", + "@inquirer/figures": "^2.0.5", + "@inquirer/type": "^4.0.5", "cli-width": "^4.1.0", - "mute-stream": "^2.0.0", - "signal-exit": "^4.1.0", - "wrap-ansi": "^6.2.0", - "yoctocolors-cjs": "^2.1.3" + "fast-wrap-ansi": "^0.2.0", + "mute-stream": "^3.0.0", + "signal-exit": "^4.1.0" }, "engines": { - "node": ">=18" + "node": ">=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0" }, "peerDependencies": { "@types/node": ">=18" @@ -1611,23 +1679,23 @@ } }, "node_modules/@inquirer/figures": { - "version": "1.0.15", - "resolved": "https://registry.npmjs.org/@inquirer/figures/-/figures-1.0.15.tgz", - "integrity": "sha512-t2IEY+unGHOzAaVM5Xx6DEWKeXlDDcNPeDyUpsRc6CUhBfU3VQOEl+Vssh7VNp1dR8MdUJBWhuObjXCsVpjN5g==", + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@inquirer/figures/-/figures-2.0.5.tgz", + "integrity": "sha512-NsSs4kzfm12lNetHwAn3GEuH317IzpwrMCbOuMIVytpjnJ90YYHNwdRgYGuKmVxwuIqSgqk3M5qqQt1cDk0tGQ==", "dev": true, "license": "MIT", "engines": { - "node": ">=18" + "node": ">=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0" } }, "node_modules/@inquirer/type": { - "version": "3.0.10", - "resolved": "https://registry.npmjs.org/@inquirer/type/-/type-3.0.10.tgz", - "integrity": "sha512-BvziSRxfz5Ov8ch0z/n3oijRSEcEsHnhggm4xFZe93DHcUCTlutlq9Ox4SVENAfcRD22UQq7T/atg9Wr3k09eA==", + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/@inquirer/type/-/type-4.0.5.tgz", + "integrity": "sha512-aetVUNeKNc/VriqXlw1NRSW0zhMBB0W4bNbWRJgzRl/3d0QNDQFfk0GO5SDdtjMZVg6o8ZKEiadd7SCCzoOn5Q==", "dev": true, "license": "MIT", "engines": { - "node": ">=18" + "node": ">=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0" }, "peerDependencies": { "@types/node": ">=18" @@ -1706,9 +1774,9 @@ } }, "node_modules/@jest/diff-sequences": { - "version": "30.3.0", - "resolved": "https://registry.npmjs.org/@jest/diff-sequences/-/diff-sequences-30.3.0.tgz", - "integrity": "sha512-cG51MVnLq1ecVUaQ3fr6YuuAOitHK1S4WUJHnsPFE/quQr33ADUx1FfrTCpMCRxvy0Yr9BThKpDjSlcTi91tMA==", + "version": "30.4.0", + "resolved": "https://registry.npmjs.org/@jest/diff-sequences/-/diff-sequences-30.4.0.tgz", + "integrity": "sha512-zOpzlfUs45l6u7jm39qr87JCHUDsaeCtvL+kQe/Vn9jSnRB4/5IPXISm0h9I1vZW/o00Kn4UTJ2MOlhnUGwv3g==", "dev": true, "license": "MIT", "engines": { @@ -1716,9 +1784,9 @@ } }, "node_modules/@jest/expect-utils": { - "version": "30.3.0", - "resolved": "https://registry.npmjs.org/@jest/expect-utils/-/expect-utils-30.3.0.tgz", - "integrity": "sha512-j0+W5iQQ8hBh7tHZkTQv3q2Fh/M7Je72cIsYqC4OaktgtO7v1So9UTjp6uPBHIaB6beoF/RRsCgMJKvti0wADA==", + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/@jest/expect-utils/-/expect-utils-30.4.1.tgz", + "integrity": "sha512-ZBn5CglH8fBsQsvs4VWNzD4aWfUYks+IdOOQU3MEK71ol/BcVm+P+rtb1KpiFBpSWSCE27uOahyyf1vfqOVbcQ==", "dev": true, "license": "MIT", "dependencies": { @@ -1739,23 +1807,23 @@ } }, "node_modules/@jest/pattern": { - "version": "30.0.1", - "resolved": "https://registry.npmjs.org/@jest/pattern/-/pattern-30.0.1.tgz", - "integrity": "sha512-gWp7NfQW27LaBQz3TITS8L7ZCQ0TLvtmI//4OwlQRx4rnWxcPNIYjxZpDcN4+UlGxgm3jS5QPz8IPTCkb59wZA==", + "version": "30.4.0", + "resolved": "https://registry.npmjs.org/@jest/pattern/-/pattern-30.4.0.tgz", + "integrity": "sha512-RAWn3+f9u8BsHijKJ71uHcFp6vmyEt6VvoWXkl6hKF3qVIuWNmudVjg12DlBPGup/frIl5UcUlH5HfEuvHpEXg==", "dev": true, "license": "MIT", "dependencies": { "@types/node": "*", - "jest-regex-util": "30.0.1" + "jest-regex-util": "30.4.0" }, "engines": { "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, "node_modules/@jest/schemas": { - "version": "30.0.5", - "resolved": "https://registry.npmjs.org/@jest/schemas/-/schemas-30.0.5.tgz", - "integrity": "sha512-DmdYgtezMkh3cpU8/1uyXakv3tJRcmcXxBOcO0tbaozPwpmh4YMsnWrQm9ZmZMfa5ocbxzbFk6O4bDPEc/iAnA==", + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/@jest/schemas/-/schemas-30.4.1.tgz", + "integrity": "sha512-i6b4qw5qnP8c5FEeBJg/uZQ4ddrkN6Ca8qISJh0pr7a5hfn3h3v5x60BEbOC7OYAGZNMs1LfFLwnW2CuK8F57Q==", "dev": true, "license": "MIT", "dependencies": { @@ -1766,14 +1834,14 @@ } }, "node_modules/@jest/types": { - "version": "30.3.0", - "resolved": "https://registry.npmjs.org/@jest/types/-/types-30.3.0.tgz", - "integrity": "sha512-JHm87k7bA33hpBngtU8h6UBub/fqqA9uXfw+21j5Hmk7ooPHlboRNxHq0JcMtC+n8VJGP1mcfnD3Mk+XKe1oSw==", + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/@jest/types/-/types-30.4.1.tgz", + "integrity": "sha512-f1x/vJXIfjOlEmejYpbkbgw1gOqpPECwMvMEtBqe47j7H2Hg8h8w3o3ikhSXq3MI15kg+oQ0exWO0uCtTNJLoQ==", "dev": true, "license": "MIT", "dependencies": { - "@jest/pattern": "30.0.1", - "@jest/schemas": "30.0.5", + "@jest/pattern": "30.4.0", + "@jest/schemas": "30.4.1", "@types/istanbul-lib-coverage": "^2.0.6", "@types/istanbul-reports": "^3.0.4", "@types/node": "*", @@ -1813,9 +1881,9 @@ "license": "MIT" }, "node_modules/@joshwooding/vite-plugin-react-docgen-typescript/node_modules/brace-expansion": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.3.tgz", - "integrity": "sha512-MCV/fYJEbqx68aE58kv2cA/kiky1G8vux3OR6/jbS+jIMe/6fJWa0DTzJU7dqijOWYwHi1t29FlfYI9uytqlpA==", + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.0.tgz", + "integrity": "sha512-TN1kCZAgdgweJhWWpgKYrQaMNHcDULHkWwQIspdtjV4Y5aurRdZpjAqn6yX3FPqTA9ngHCc4hJxMAMgGfve85w==", "dev": true, "license": "MIT", "dependencies": { @@ -1973,9 +2041,9 @@ } }, "node_modules/@mswjs/interceptors": { - "version": "0.41.3", - "resolved": "https://registry.npmjs.org/@mswjs/interceptors/-/interceptors-0.41.3.tgz", - "integrity": "sha512-cXu86tF4VQVfwz8W1SPbhoRyHJkti6mjH/XJIxp40jhO4j2k1m4KYrEykxqWPkFF3vrK4rgQppBh//AwyGSXPA==", + "version": "0.41.9", + "resolved": "https://registry.npmjs.org/@mswjs/interceptors/-/interceptors-0.41.9.tgz", + "integrity": "sha512-VVPPgHyQ6ShqnrmDWuxjmUIsO9gWyOZFmuOfLd9LfBGQJwZfy0gvv9pbHSJuoFNIYC7ZDX9aoFwowjcdSC4E8w==", "dev": true, "license": "MIT", "dependencies": { @@ -1990,6 +2058,13 @@ "node": ">=18" } }, + "node_modules/@mswjs/interceptors/node_modules/@open-draft/deferred-promise": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@open-draft/deferred-promise/-/deferred-promise-2.2.0.tgz", + "integrity": "sha512-CecwLWx3rhxVQF6V4bAgPS5t+So2sTbPgAzafKkVizyi7tlwpcFpdFqq+wqF2OwNBmqFuu6tOyouTuxgpMfzmA==", + "dev": true, + "license": "MIT" + }, "node_modules/@nodelib/fs.scandir": { "version": "2.1.5", "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", @@ -2029,9 +2104,9 @@ } }, "node_modules/@open-draft/deferred-promise": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/@open-draft/deferred-promise/-/deferred-promise-2.2.0.tgz", - "integrity": "sha512-CecwLWx3rhxVQF6V4bAgPS5t+So2sTbPgAzafKkVizyi7tlwpcFpdFqq+wqF2OwNBmqFuu6tOyouTuxgpMfzmA==", + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@open-draft/deferred-promise/-/deferred-promise-3.0.0.tgz", + "integrity": "sha512-XW375UK8/9SqUVNVa6M0yEy8+iTi4QN5VZ7aZuRFQmy76LRwI9wy5F4YIBU6T+eTe2/DNDo8tqu8RHlwLHM6RA==", "dev": true, "license": "MIT" }, @@ -2108,9 +2183,9 @@ } }, "node_modules/@rollup/rollup-android-arm-eabi": { - "version": "4.60.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.60.0.tgz", - "integrity": "sha512-WOhNW9K8bR3kf4zLxbfg6Pxu2ybOUbB2AjMDHSQx86LIF4rH4Ft7vmMwNt0loO0eonglSNy4cpD3MKXXKQu0/A==", + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.60.3.tgz", + "integrity": "sha512-x35CNW/ANXG3hE/EZpRU8MXX1JDN86hBb2wMGAtltkz7pc6cxgjpy1OMMfDosOQ+2hWqIkag/fGok1Yady9nGw==", "cpu": [ "arm" ], @@ -2121,9 +2196,9 @@ ] }, "node_modules/@rollup/rollup-android-arm64": { - "version": "4.60.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.60.0.tgz", - "integrity": "sha512-u6JHLll5QKRvjciE78bQXDmqRqNs5M/3GVqZeMwvmjaNODJih/WIrJlFVEihvV0MiYFmd+ZyPr9wxOVbPAG2Iw==", + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.60.3.tgz", + "integrity": "sha512-xw3xtkDApIOGayehp2+Rz4zimfkaX65r4t47iy+ymQB2G4iJCBBfj0ogVg5jpvjpn8UWn/+q9tprxleYeNp3Hw==", "cpu": [ "arm64" ], @@ -2134,9 +2209,9 @@ ] }, "node_modules/@rollup/rollup-darwin-arm64": { - "version": "4.60.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.60.0.tgz", - "integrity": "sha512-qEF7CsKKzSRc20Ciu2Zw1wRrBz4g56F7r/vRwY430UPp/nt1x21Q/fpJ9N5l47WWvJlkNCPJz3QRVw008fi7yA==", + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.60.3.tgz", + "integrity": "sha512-vo6Y5Qfpx7/5EaamIwi0WqW2+zfiusVihKatLvtN1VFVy3D13uERk/6gZLU1UiHRL6fDXqj/ELIeVRGnvcTE1g==", "cpu": [ "arm64" ], @@ -2147,9 +2222,9 @@ ] }, "node_modules/@rollup/rollup-darwin-x64": { - "version": "4.60.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.60.0.tgz", - "integrity": "sha512-WADYozJ4QCnXCH4wPB+3FuGmDPoFseVCUrANmA5LWwGmC6FL14BWC7pcq+FstOZv3baGX65tZ378uT6WG8ynTw==", + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.60.3.tgz", + "integrity": "sha512-D+0QGcZhBzTN82weOnsSlY7V7+RMmPuF1CkbxyMAGE8+ZHeUjyb76ZiWmBlCu//AQQONvxcqRbwZTajZKqjuOw==", "cpu": [ "x64" ], @@ -2160,9 +2235,9 @@ ] }, "node_modules/@rollup/rollup-freebsd-arm64": { - "version": "4.60.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.60.0.tgz", - "integrity": "sha512-6b8wGHJlDrGeSE3aH5mGNHBjA0TTkxdoNHik5EkvPHCt351XnigA4pS7Wsj/Eo9Y8RBU6f35cjN9SYmCFBtzxw==", + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.60.3.tgz", + "integrity": "sha512-6HnvHCT7fDyj6R0Ph7A6x8dQS/S38MClRWeDLqc0MdfWkxjiu1HSDYrdPhqSILzjTIC/pnXbbJbo+ft+gy/9hQ==", "cpu": [ "arm64" ], @@ -2173,9 +2248,9 @@ ] }, "node_modules/@rollup/rollup-freebsd-x64": { - "version": "4.60.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.60.0.tgz", - "integrity": "sha512-h25Ga0t4jaylMB8M/JKAyrvvfxGRjnPQIR8lnCayyzEjEOx2EJIlIiMbhpWxDRKGKF8jbNH01NnN663dH638mA==", + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.60.3.tgz", + "integrity": "sha512-KHLgC3WKlUYW3ShFKnnosZDOJ0xjg9zp7au3sIm2bs/tGBeC2ipmvRh/N7JKi0t9Ue20C0dpEshi8WUubg+cnA==", "cpu": [ "x64" ], @@ -2186,12 +2261,15 @@ ] }, "node_modules/@rollup/rollup-linux-arm-gnueabihf": { - "version": "4.60.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.60.0.tgz", - "integrity": "sha512-RzeBwv0B3qtVBWtcuABtSuCzToo2IEAIQrcyB/b2zMvBWVbjo8bZDjACUpnaafaxhTw2W+imQbP2BD1usasK4g==", + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.60.3.tgz", + "integrity": "sha512-DV6fJoxEYWJOvaZIsok7KrYl0tPvga5OZ2yvKHNNYyk/2roMLqQAbGhr78EQ5YhHpnhLKJD3S1WFusAkmUuV5g==", "cpu": [ "arm" ], + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -2199,12 +2277,15 @@ ] }, "node_modules/@rollup/rollup-linux-arm-musleabihf": { - "version": "4.60.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.60.0.tgz", - "integrity": "sha512-Sf7zusNI2CIU1HLzuu9Tc5YGAHEZs5Lu7N1ssJG4Tkw6e0MEsN7NdjUDDfGNHy2IU+ENyWT+L2obgWiguWibWQ==", + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.60.3.tgz", + "integrity": "sha512-mQKoJAzvuOs6F+TZybQO4GOTSMUu7v0WdxEk24krQ/uUxXoPTtHjuaUuPmFhtBcM4K0ons8nrE3JyhTuCFtT/w==", "cpu": [ "arm" ], + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ @@ -2212,12 +2293,15 @@ ] }, "node_modules/@rollup/rollup-linux-arm64-gnu": { - "version": "4.60.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.60.0.tgz", - "integrity": "sha512-DX2x7CMcrJzsE91q7/O02IJQ5/aLkVtYFryqCjduJhUfGKG6yJV8hxaw8pZa93lLEpPTP/ohdN4wFz7yp/ry9A==", + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.60.3.tgz", + "integrity": "sha512-Whjj2qoiJ6+OOJMGptTYazaJvjOJm+iKHpXQM1P3LzGjt7Ff++Tp7nH4N8J/BUA7R9IHfDyx4DJIflifwnbmIA==", "cpu": [ "arm64" ], + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -2225,12 +2309,15 @@ ] }, "node_modules/@rollup/rollup-linux-arm64-musl": { - "version": "4.60.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.60.0.tgz", - "integrity": "sha512-09EL+yFVbJZlhcQfShpswwRZ0Rg+z/CsSELFCnPt3iK+iqwGsI4zht3secj5vLEs957QvFFXnzAT0FFPIxSrkQ==", + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.60.3.tgz", + "integrity": "sha512-4YTNHKqGng5+yiZt3mg77nmyuCfmNfX4fPmyUapBcIk+BdwSwmCWGXOUxhXbBEkFHtoN5boLj/5NON+u5QC9tg==", "cpu": [ "arm64" ], + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ @@ -2238,12 +2325,15 @@ ] }, "node_modules/@rollup/rollup-linux-loong64-gnu": { - "version": "4.60.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.60.0.tgz", - "integrity": "sha512-i9IcCMPr3EXm8EQg5jnja0Zyc1iFxJjZWlb4wr7U2Wx/GrddOuEafxRdMPRYVaXjgbhvqalp6np07hN1w9kAKw==", + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.60.3.tgz", + "integrity": "sha512-SU3kNlhkpI4UqlUc2VXPGK9o886ZsSeGfMAX2ba2b8DKmMXq4AL7KUrkSWVbb7koVqx41Yczx6dx5PNargIrEA==", "cpu": [ "loong64" ], + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -2251,12 +2341,15 @@ ] }, "node_modules/@rollup/rollup-linux-loong64-musl": { - "version": "4.60.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.60.0.tgz", - "integrity": "sha512-DGzdJK9kyJ+B78MCkWeGnpXJ91tK/iKA6HwHxF4TAlPIY7GXEvMe8hBFRgdrR9Ly4qebR/7gfUs9y2IoaVEyog==", + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.60.3.tgz", + "integrity": "sha512-6lDLl5h4TXpB1mTf2rQWnAk/LcXrx9vBfu/DT5TIPhvMhRWaZ5MxkIc8u4lJAmBo6klTe1ywXIUHFjylW505sg==", "cpu": [ "loong64" ], + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ @@ -2264,12 +2357,15 @@ ] }, "node_modules/@rollup/rollup-linux-ppc64-gnu": { - "version": "4.60.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.60.0.tgz", - "integrity": "sha512-RwpnLsqC8qbS8z1H1AxBA1H6qknR4YpPR9w2XX0vo2Sz10miu57PkNcnHVaZkbqyw/kUWfKMI73jhmfi9BRMUQ==", + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.60.3.tgz", + "integrity": "sha512-BMo8bOw8evlup/8G+cj5xWtPyp93xPdyoSN16Zy90Q2QZ0ZYRhCt6ZJSwbrRzG9HApFabjwj2p25TUPDWrhzqQ==", "cpu": [ "ppc64" ], + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -2277,12 +2373,15 @@ ] }, "node_modules/@rollup/rollup-linux-ppc64-musl": { - "version": "4.60.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.60.0.tgz", - "integrity": "sha512-Z8pPf54Ly3aqtdWC3G4rFigZgNvd+qJlOE52fmko3KST9SoGfAdSRCwyoyG05q1HrrAblLbk1/PSIV+80/pxLg==", + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.60.3.tgz", + "integrity": "sha512-E0L8X1dZN1/Rph+5VPF6Xj2G7JJvMACVXtamTJIDrVI44Y3K+G8gQaMEAavbqCGTa16InptiVrX6eM6pmJ+7qA==", "cpu": [ "ppc64" ], + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ @@ -2290,12 +2389,15 @@ ] }, "node_modules/@rollup/rollup-linux-riscv64-gnu": { - "version": "4.60.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.60.0.tgz", - "integrity": "sha512-3a3qQustp3COCGvnP4SvrMHnPQ9d1vzCakQVRTliaz8cIp/wULGjiGpbcqrkv0WrHTEp8bQD/B3HBjzujVWLOA==", + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.60.3.tgz", + "integrity": "sha512-oZJ/WHaVfHUiRAtmTAeo3DcevNsVvH8mbvodjZy7D5QKvCefO371SiKRpxoDcCxB3PTRTLayWBkvmDQKTcX/sw==", "cpu": [ "riscv64" ], + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -2303,12 +2405,15 @@ ] }, "node_modules/@rollup/rollup-linux-riscv64-musl": { - "version": "4.60.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.60.0.tgz", - "integrity": "sha512-pjZDsVH/1VsghMJ2/kAaxt6dL0psT6ZexQVrijczOf+PeP2BUqTHYejk3l6TlPRydggINOeNRhvpLa0AYpCWSQ==", + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.60.3.tgz", + "integrity": "sha512-Dhbyh7j9FybM3YaTgaHmVALwA8AkUwTPccyCQ79TG9AJUsMQqgN1DDEZNr4+QUfwiWvLDumW5vdwzoeUF+TNxQ==", "cpu": [ "riscv64" ], + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ @@ -2316,12 +2421,15 @@ ] }, "node_modules/@rollup/rollup-linux-s390x-gnu": { - "version": "4.60.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.60.0.tgz", - "integrity": "sha512-3ObQs0BhvPgiUVZrN7gqCSvmFuMWvWvsjG5ayJ3Lraqv+2KhOsp+pUbigqbeWqueGIsnn+09HBw27rJ+gYK4VQ==", + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.60.3.tgz", + "integrity": "sha512-cJd1X5XhHHlltkaypz1UcWLA8AcoIi1aWhsvaWDskD1oz2eKCypnqvTQ8ykMNI0RSmm7NkTdSqSSD7zM0xa6Ig==", "cpu": [ "s390x" ], + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -2329,12 +2437,15 @@ ] }, "node_modules/@rollup/rollup-linux-x64-gnu": { - "version": "4.60.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.60.0.tgz", - "integrity": "sha512-EtylprDtQPdS5rXvAayrNDYoJhIz1/vzN2fEubo3yLE7tfAw+948dO0g4M0vkTVFhKojnF+n6C8bDNe+gDRdTg==", + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.60.3.tgz", + "integrity": "sha512-DAZDBHQfG2oQuhY7mc6I3/qB4LU2fQCjRvxbDwd/Jdvb9fypP4IJ4qmtu6lNjes6B531AI8cg1aKC2di97bUxA==", "cpu": [ "x64" ], + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -2342,12 +2453,15 @@ ] }, "node_modules/@rollup/rollup-linux-x64-musl": { - "version": "4.60.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.60.0.tgz", - "integrity": "sha512-k09oiRCi/bHU9UVFqD17r3eJR9bn03TyKraCrlz5ULFJGdJGi7VOmm9jl44vOJvRJ6P7WuBi/s2A97LxxHGIdw==", + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.60.3.tgz", + "integrity": "sha512-cRxsE8c13mZOh3vP+wLDxpQBRrOHDIGOWyDL93Sy0Ga8y515fBcC2pjUfFwUe5T7tqvTvWbCpg1URM/AXdWIXA==", "cpu": [ "x64" ], + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ @@ -2355,9 +2469,9 @@ ] }, "node_modules/@rollup/rollup-openbsd-x64": { - "version": "4.60.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.60.0.tgz", - "integrity": "sha512-1o/0/pIhozoSaDJoDcec+IVLbnRtQmHwPV730+AOD29lHEEo4F5BEUB24H0OBdhbBBDwIOSuf7vgg0Ywxdfiiw==", + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.60.3.tgz", + "integrity": "sha512-QaWcIgRxqEdQdhJqW4DJctsH6HCmo5vHxY0krHSX4jMtOqfzC+dqDGuHM87bu4H8JBeibWx7jFz+h6/4C8wA5Q==", "cpu": [ "x64" ], @@ -2368,9 +2482,9 @@ ] }, "node_modules/@rollup/rollup-openharmony-arm64": { - "version": "4.60.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.60.0.tgz", - "integrity": "sha512-pESDkos/PDzYwtyzB5p/UoNU/8fJo68vcXM9ZW2V0kjYayj1KaaUfi1NmTUTUpMn4UhU4gTuK8gIaFO4UGuMbA==", + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.60.3.tgz", + "integrity": "sha512-AaXwSvUi3QIPtroAUw1t5yHGIyqKEXwH54WUocFolZhpGDruJcs8c+xPNDRn4XiQsS7MEwnYsHW2l0MBLDMkWg==", "cpu": [ "arm64" ], @@ -2381,9 +2495,9 @@ ] }, "node_modules/@rollup/rollup-win32-arm64-msvc": { - "version": "4.60.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.60.0.tgz", - "integrity": "sha512-hj1wFStD7B1YBeYmvY+lWXZ7ey73YGPcViMShYikqKT1GtstIKQAtfUI6yrzPjAy/O7pO0VLXGmUVWXQMaYgTQ==", + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.60.3.tgz", + "integrity": "sha512-65LAKM/bAWDqKNEelHlcHvm2V+Vfb8C6INFxQXRHCvaVN1rJfwr4NvdP4FyzUaLqWfaCGaadf6UbTm8xJeYfEg==", "cpu": [ "arm64" ], @@ -2394,9 +2508,9 @@ ] }, "node_modules/@rollup/rollup-win32-ia32-msvc": { - "version": "4.60.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.60.0.tgz", - "integrity": "sha512-SyaIPFoxmUPlNDq5EHkTbiKzmSEmq/gOYFI/3HHJ8iS/v1mbugVa7dXUzcJGQfoytp9DJFLhHH4U3/eTy2Bq4w==", + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.60.3.tgz", + "integrity": "sha512-EEM2gyhBF5MFnI6vMKdX1LAosE627RGBzIoGMdLloPZkXrUN0Ckqgr2Qi8+J3zip/8NVVro3/FjB+tjhZUgUHA==", "cpu": [ "ia32" ], @@ -2407,9 +2521,9 @@ ] }, "node_modules/@rollup/rollup-win32-x64-gnu": { - "version": "4.60.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.60.0.tgz", - "integrity": "sha512-RdcryEfzZr+lAr5kRm2ucN9aVlCCa2QNq4hXelZxb8GG0NJSazq44Z3PCCc8wISRuCVnGs0lQJVX5Vp6fKA+IA==", + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.60.3.tgz", + "integrity": "sha512-E5Eb5H/DpxaoXH++Qkv28RcUJboMopmdDUALBczvHMf7hNIxaDZqwY5lK12UK1BHacSmvupoEWGu+n993Z0y1A==", "cpu": [ "x64" ], @@ -2420,9 +2534,9 @@ ] }, "node_modules/@rollup/rollup-win32-x64-msvc": { - "version": "4.60.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.60.0.tgz", - "integrity": "sha512-PrsWNQ8BuE00O3Xsx3ALh2Df8fAj9+cvvX9AIA6o4KpATR98c9mud4XtDWVvsEuyia5U4tVSTKygawyJkjm60w==", + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.60.3.tgz", + "integrity": "sha512-hPt/bgL5cE+Qp+/TPHBqptcAgPzgj46mPcg/16zNUmbQk0j+mOEQV/+Lqu8QRtDV3Ek95Q6FeFITpuhl6OTsAA==", "cpu": [ "x64" ], @@ -2440,59 +2554,59 @@ "license": "MIT" }, "node_modules/@sentry-internal/browser-utils": { - "version": "10.50.0", - "resolved": "https://registry.npmjs.org/@sentry-internal/browser-utils/-/browser-utils-10.50.0.tgz", - "integrity": "sha512-42bxyRTxnCmYlWnvz4CxikuQNanw8UNma2WJrtxJ0f1MAJV2GhQGSHDLnA+lvFlmiz6qct3pfen/NXGyOTegTA==", + "version": "10.53.1", + "resolved": "https://registry.npmjs.org/@sentry-internal/browser-utils/-/browser-utils-10.53.1.tgz", + "integrity": "sha512-X4d6y8sBMjmNhcDW4eMBU3ASsNIMz8dqaFkhyIMN/dkYr/yZKnbRZPaVuVUGvHKjnlficPpIH0/HK9KBjrYxPw==", "license": "MIT", "dependencies": { - "@sentry/core": "10.50.0" + "@sentry/core": "10.53.1" }, "engines": { "node": ">=18" } }, "node_modules/@sentry-internal/feedback": { - "version": "10.50.0", - "resolved": "https://registry.npmjs.org/@sentry-internal/feedback/-/feedback-10.50.0.tgz", - "integrity": "sha512-0k9XZF0wn86f77mIO2U3gNNyDZooy139CnEanRzHinrN106vVzvBZ6TUEQoHtoO1fqQxr+nWWVrqV/PXUqk47w==", + "version": "10.53.1", + "resolved": "https://registry.npmjs.org/@sentry-internal/feedback/-/feedback-10.53.1.tgz", + "integrity": "sha512-vVpTI/aEYN5d9IgZeYJWMqVaN0+iFgidSrYNAsZTh1US5sJUzF/wrl+68KdpmCtFROrN3jiAn1oPSwL5CKvEJA==", "license": "MIT", "dependencies": { - "@sentry/core": "10.50.0" + "@sentry/core": "10.53.1" }, "engines": { "node": ">=18" } }, "node_modules/@sentry-internal/replay": { - "version": "10.50.0", - "resolved": "https://registry.npmjs.org/@sentry-internal/replay/-/replay-10.50.0.tgz", - "integrity": "sha512-51FYNfnvVLAWw1rrEWPFfwHuMRb9mkVCFGA4J9/un7SpeGBsQDziGB0Di4fsCxI7+EdSBpfLHPF0csKtCCw0oQ==", + "version": "10.53.1", + "resolved": "https://registry.npmjs.org/@sentry-internal/replay/-/replay-10.53.1.tgz", + "integrity": "sha512-wZNzTBYkgGUPWMuUQv7L64+OJmoCnz7GQNiTrTFK6EVAjJXFBCSsPp/nhif0bLhbk8+0g4xz633uOhpXuQbFdw==", "license": "MIT", "dependencies": { - "@sentry-internal/browser-utils": "10.50.0", - "@sentry/core": "10.50.0" + "@sentry-internal/browser-utils": "10.53.1", + "@sentry/core": "10.53.1" }, "engines": { "node": ">=18" } }, "node_modules/@sentry-internal/replay-canvas": { - "version": "10.50.0", - "resolved": "https://registry.npmjs.org/@sentry-internal/replay-canvas/-/replay-canvas-10.50.0.tgz", - "integrity": "sha512-jx6RKBmcJSWdI92qDGS/sBv1w+7Cww879Z/moX7bw7ipHa/Ts3iDcB3rgZwvhmi17U+mvYsbJeL2DXkPo3TjPw==", + "version": "10.53.1", + "resolved": "https://registry.npmjs.org/@sentry-internal/replay-canvas/-/replay-canvas-10.53.1.tgz", + "integrity": "sha512-aueLaf/2prExwA76BGU5/bOXCKWqtt6jQXWA6WJQNrmKpPEtZJB4ypnpsou0McXQCF8tur2Y8U0TEkwQP13yJQ==", "license": "MIT", "dependencies": { - "@sentry-internal/replay": "10.50.0", - "@sentry/core": "10.50.0" + "@sentry-internal/replay": "10.53.1", + "@sentry/core": "10.53.1" }, "engines": { "node": ">=18" } }, "node_modules/@sentry/babel-plugin-component-annotate": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/@sentry/babel-plugin-component-annotate/-/babel-plugin-component-annotate-5.2.0.tgz", - "integrity": "sha512-8LbOI5Kzb5F0+7LVQPi2+zGz1iPiRRFhM+7uZ/ZQ33L9BmDOYNIy3xWxCfMw2JCuMXXaxF47XCjGmR22/B0WPg==", + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/@sentry/babel-plugin-component-annotate/-/babel-plugin-component-annotate-5.3.0.tgz", + "integrity": "sha512-p4q8gn8wcFqZGP/s2MnJCAAd8fTikaU6A0mM97RDHQgStcrYiaS0Sc5zUNfb1V+UOLPuvdEdL6MwyxfzjYJQTA==", "dev": true, "license": "MIT", "engines": { @@ -2500,30 +2614,30 @@ } }, "node_modules/@sentry/browser": { - "version": "10.50.0", - "resolved": "https://registry.npmjs.org/@sentry/browser/-/browser-10.50.0.tgz", - "integrity": "sha512-1f6rAvET6myiTaSeYqvaaBwvq1LfxqWjAPIoAW/NVC9bPMkeEcuvgDajHrnZMrBeWoJ81NMyoLkyX+iOc7MoFA==", + "version": "10.53.1", + "resolved": "https://registry.npmjs.org/@sentry/browser/-/browser-10.53.1.tgz", + "integrity": "sha512-zXF373hzUOGzUOrqd8xb1U3LQi5uYC3mwv+z5OMKUUinQlu30tTWBs7ypy6YTchtix9QlYaHWlayUF8vBZ5UjA==", "license": "MIT", "dependencies": { - "@sentry-internal/browser-utils": "10.50.0", - "@sentry-internal/feedback": "10.50.0", - "@sentry-internal/replay": "10.50.0", - "@sentry-internal/replay-canvas": "10.50.0", - "@sentry/core": "10.50.0" + "@sentry-internal/browser-utils": "10.53.1", + "@sentry-internal/feedback": "10.53.1", + "@sentry-internal/replay": "10.53.1", + "@sentry-internal/replay-canvas": "10.53.1", + "@sentry/core": "10.53.1" }, "engines": { "node": ">=18" } }, "node_modules/@sentry/bundler-plugin-core": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/@sentry/bundler-plugin-core/-/bundler-plugin-core-5.2.0.tgz", - "integrity": "sha512-+C0x4gEIJRgoMwyRFGx+TFiJ1Po2BZlT1v61+PnouiaprKL5qtZG8n5PXx/5LPLDsVjSIcXjnDrTz9aSm8SJ3w==", + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/@sentry/bundler-plugin-core/-/bundler-plugin-core-5.3.0.tgz", + "integrity": "sha512-L5T60sWdAI3qWwdg3Ptwek/0TY59PERrxyqp4XMUkroayQvGd9r5dIW9Q1kSeXX9iJ442nXbFZKAOyCKV4Z13Q==", "dev": true, "license": "MIT", "dependencies": { "@babel/core": "^7.18.5", - "@sentry/babel-plugin-component-annotate": "5.2.0", + "@sentry/babel-plugin-component-annotate": "5.3.0", "@sentry/cli": "^2.58.5", "dotenv": "^16.3.1", "find-up": "^5.0.0", @@ -2708,57 +2822,23 @@ "node": ">=10" } }, - "node_modules/@sentry/cli/node_modules/agent-base": { - "version": "6.0.2", - "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz", - "integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "debug": "4" - }, - "engines": { - "node": ">= 6.0.0" - } - }, - "node_modules/@sentry/cli/node_modules/https-proxy-agent": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz", - "integrity": "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==", - "dev": true, - "license": "MIT", - "dependencies": { - "agent-base": "6", - "debug": "4" - }, - "engines": { - "node": ">= 6" - } - }, - "node_modules/@sentry/cli/node_modules/proxy-from-env": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", - "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==", - "dev": true, - "license": "MIT" - }, "node_modules/@sentry/core": { - "version": "10.50.0", - "resolved": "https://registry.npmjs.org/@sentry/core/-/core-10.50.0.tgz", - "integrity": "sha512-J4A+vzUO3adl0TkFCjaN1+4miamrjHiEIYuLHiuu1lmAjq5WIVw32ObvAh4yMwNtxyaEMosTrrh5M6f12XSJFg==", + "version": "10.53.1", + "resolved": "https://registry.npmjs.org/@sentry/core/-/core-10.53.1.tgz", + "integrity": "sha512-XG4ezlkyuAPjBC5+9kXC94rXXuqYTw9NRhfaDHssbTFaGnqBR8vQX2UUgZfY7ucbeelRDGfBu1sywoU+mB04uA==", "license": "MIT", "engines": { "node": ">=18" } }, "node_modules/@sentry/react": { - "version": "10.50.0", - "resolved": "https://registry.npmjs.org/@sentry/react/-/react-10.50.0.tgz", - "integrity": "sha512-MZHYjEZAtFIa4zPrWS4oXlo+gMppRvfETqUqF920Sj2jN2U7WjboU03lDmjfDqEcH7QiwjQyl13jHd2nwAyrrw==", + "version": "10.53.1", + "resolved": "https://registry.npmjs.org/@sentry/react/-/react-10.53.1.tgz", + "integrity": "sha512-lrwNq5T/zW84l60894TpKHPcvFuc1I/Hnohecc0TfYVpIcYYuw2orCHoU4v4wgkFaJUpegVetbgdOphViyLVjA==", "license": "MIT", "dependencies": { - "@sentry/browser": "10.50.0", - "@sentry/core": "10.50.0" + "@sentry/browser": "10.53.1", + "@sentry/core": "10.53.1" }, "engines": { "node": ">=18" @@ -2768,13 +2848,13 @@ } }, "node_modules/@sentry/rollup-plugin": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/@sentry/rollup-plugin/-/rollup-plugin-5.2.0.tgz", - "integrity": "sha512-a8LfpvcYMFtFSroro5MpCcOoS528LeLfUHzxWURnpofOnY+Aso9Si4y4dFlna+RKqxCXjmFbn6CLnfI+YrHysQ==", + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/@sentry/rollup-plugin/-/rollup-plugin-5.3.0.tgz", + "integrity": "sha512-hgPGPYdQJ/G1cGYOxAb7d4z3V+/k/E5/P/5TFPEEBLuIbFFk+JG0CISUDJdzXJjO382Lb99PBJuXGbueBmO79w==", "dev": true, "license": "MIT", "dependencies": { - "@sentry/bundler-plugin-core": "5.2.0", + "@sentry/bundler-plugin-core": "5.3.0", "magic-string": "~0.30.8" }, "engines": { @@ -2790,23 +2870,23 @@ } }, "node_modules/@sentry/vite-plugin": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/@sentry/vite-plugin/-/vite-plugin-5.2.0.tgz", - "integrity": "sha512-4Jo3ixBspso5HY81PDvZdRXkH9wYGVmcw/0a2IX9ejbyKBdHqkYg4IhAtNqGUAyGuHwwRS9Y1S+sCMvrXv6htw==", + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/@sentry/vite-plugin/-/vite-plugin-5.3.0.tgz", + "integrity": "sha512-qcoSzo4n2MulVQ70UUPLq6dTleb2a2HwL2wuwvAgWhPChrYTuk6A6mDg6aQb9fairPAwFPiU9PzOANpoDJcz1A==", "dev": true, "license": "MIT", "dependencies": { - "@sentry/bundler-plugin-core": "5.2.0", - "@sentry/rollup-plugin": "5.2.0" + "@sentry/bundler-plugin-core": "5.3.0", + "@sentry/rollup-plugin": "5.3.0" }, "engines": { "node": ">= 18" } }, "node_modules/@sinclair/typebox": { - "version": "0.34.48", - "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.34.48.tgz", - "integrity": "sha512-kKJTNuK3AQOrgjjotVxMrCn1sUJwM76wMszfq1kdU4uYVJjvEWuFQ6HgvLt4Xz3fSmZlTOxJ/Ie13KnIcWQXFA==", + "version": "0.34.49", + "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.34.49.tgz", + "integrity": "sha512-brySQQs7Jtn0joV8Xh9ZV/hZb9Ozb0pmazDIASBkYKCjXrXU3mpcFahmK/z4YDhGkQvP9mWJbVyahdtU5wQA+A==", "dev": true, "license": "MIT" }, @@ -3144,6 +3224,19 @@ } } }, + "node_modules/@storybook/core/node_modules/semver": { + "version": "7.8.0", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.0.tgz", + "integrity": "sha512-AcM7dV/5ul4EekoQ29Agm5vri8JNqRyj39o0qpX6vDF2GZrtutZl5RwgD1XnZjiTAfncsJhMI48QQH3sN87YNA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/@storybook/csf": { "version": "0.1.13", "resolved": "https://registry.npmjs.org/@storybook/csf/-/csf-0.1.13.tgz", @@ -3442,20 +3535,20 @@ } }, "node_modules/@tanstack/eslint-plugin-query": { - "version": "5.95.2", - "resolved": "https://registry.npmjs.org/@tanstack/eslint-plugin-query/-/eslint-plugin-query-5.95.2.tgz", - "integrity": "sha512-EYUFRaqjBep4EHMPpZR12sXP7Kr5qv9iDIlq93NfbhHwhITaW6Txu3ROO6dLFz5r84T8p+oZXBG77pa2Wuok7A==", + "version": "5.100.10", + "resolved": "https://registry.npmjs.org/@tanstack/eslint-plugin-query/-/eslint-plugin-query-5.100.10.tgz", + "integrity": "sha512-Ddou3agTWv5rvHSBby4yHlugUFHVh0nyo2fyoZ81qSxaTBIwNCoPgpiJhjo5QkThrH1wGC7k548BcMTszZCkBw==", "license": "MIT", "dependencies": { - "@typescript-eslint/utils": "^8.48.0" + "@typescript-eslint/utils": "^8.58.1" }, "funding": { "type": "github", "url": "https://github.com/sponsors/tannerlinsley" }, "peerDependencies": { - "eslint": "^8.57.0 || ^9.0.0", - "typescript": "^5.4.0" + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": "^5.4.0 || ^6.0.0" }, "peerDependenciesMeta": { "typescript": { @@ -3464,9 +3557,9 @@ } }, "node_modules/@tanstack/query-core": { - "version": "5.95.2", - "resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.95.2.tgz", - "integrity": "sha512-o4T8vZHZET4Bib3jZ/tCW9/7080urD4c+0/AUaYVpIqOsr7y0reBc1oX3ttNaSW5mYyvZHctiQ/UOP2PfdmFEQ==", + "version": "5.100.10", + "resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.100.10.tgz", + "integrity": "sha512-8UR0yJR+GiQ40m3lPhUr0xbfAupe6GSQiksSBSa9SM2NjezFyxXCIA69/lz8cSoNKZLrw1/PktIyQBJcVeMi3w==", "license": "MIT", "funding": { "type": "github", @@ -3474,12 +3567,12 @@ } }, "node_modules/@tanstack/react-query": { - "version": "5.95.2", - "resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.95.2.tgz", - "integrity": "sha512-/wGkvLj/st5Ud1Q76KF1uFxScV7WeqN1slQx5280ycwAyYkIPGaRZAEgHxe3bjirSd5Zpwkj6zNcR4cqYni/ZA==", + "version": "5.100.10", + "resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.100.10.tgz", + "integrity": "sha512-FLaZf2RCrA/Zgp4aiu5tG3TyasTRO7aZ99skxQpr3Hg/zXOhu6yq5FZCYQ/tRaJtM9ylnoK8tFK7PolXQadv6Q==", "license": "MIT", "dependencies": { - "@tanstack/query-core": "5.95.2" + "@tanstack/query-core": "5.100.10" }, "funding": { "type": "github", @@ -3656,9 +3749,9 @@ "license": "MIT" }, "node_modules/@types/estree": { - "version": "1.0.8", - "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", - "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", "license": "MIT" }, "node_modules/@types/glob": { @@ -3724,27 +3817,21 @@ } }, "node_modules/@types/jest/node_modules/pretty-format": { - "version": "30.3.0", - "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-30.3.0.tgz", - "integrity": "sha512-oG4T3wCbfeuvljnyAzhBvpN45E8iOTXCU/TD3zXW80HA3dQ4ahdqMkWGiPWZvjpQwlbyHrPTWUAqUzGzv4l1JQ==", + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-30.4.1.tgz", + "integrity": "sha512-K6KiKMHTL4jjX4u3Kir2EW07nRfcqVTXIImx50wbjHQTcZPgg+gjVeNTIT3l3L1Rd4UefxfogquC9J37SoFyyw==", "dev": true, "license": "MIT", "dependencies": { - "@jest/schemas": "30.0.5", + "@jest/schemas": "30.4.1", "ansi-styles": "^5.2.0", - "react-is": "^18.3.1" + "react-is-18": "npm:react-is@^18.3.1", + "react-is-19": "npm:react-is@^19.2.5" }, "engines": { "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, - "node_modules/@types/jest/node_modules/react-is": { - "version": "18.3.1", - "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz", - "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==", - "dev": true, - "license": "MIT" - }, "node_modules/@types/json-schema": { "version": "7.0.15", "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", @@ -3773,13 +3860,13 @@ "license": "MIT" }, "node_modules/@types/node": { - "version": "25.5.0", - "resolved": "https://registry.npmjs.org/@types/node/-/node-25.5.0.tgz", - "integrity": "sha512-jp2P3tQMSxWugkCUKLRPVUpGaL5MVFwF8RDuSRztfwgN1wmqJeMSbKlnEtQqU8UrhTmzEmZdu2I6v2dpp7XIxw==", + "version": "25.7.0", + "resolved": "https://registry.npmjs.org/@types/node/-/node-25.7.0.tgz", + "integrity": "sha512-z+pdZyxE+RTQE9AcboAZCb4otwcrvgHD+GlBpPgn0emDVt0ohrTMhAwlr2Wd9nZ+nihhYFxO2pThz3C5qSu2Eg==", "dev": true, "license": "MIT", "dependencies": { - "undici-types": "~7.18.0" + "undici-types": "~7.21.0" } }, "node_modules/@types/pako": { @@ -3824,6 +3911,16 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/set-cookie-parser": { + "version": "2.4.10", + "resolved": "https://registry.npmjs.org/@types/set-cookie-parser/-/set-cookie-parser-2.4.10.tgz", + "integrity": "sha512-GGmQVGpQWUe5qglJozEjZV/5dyxbOOZ0LHe/lqyWssB88Y4svNfst0uqBVscdDeIKl5Jy5+aPSvy7mI9tYRguw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/sockjs-client": { "version": "1.5.4", "resolved": "https://registry.npmjs.org/@types/sockjs-client/-/sockjs-client-1.5.4.tgz", @@ -3876,20 +3973,20 @@ "license": "MIT" }, "node_modules/@typescript-eslint/eslint-plugin": { - "version": "8.57.2", - "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.57.2.tgz", - "integrity": "sha512-NZZgp0Fm2IkD+La5PR81sd+g+8oS6JwJje+aRWsDocxHkjyRw0J5L5ZTlN3LI1LlOcGL7ph3eaIUmTXMIjLk0w==", + "version": "8.59.3", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.59.3.tgz", + "integrity": "sha512-PwFvSKsXGShKGW6n5bZOhGHEcCZXM8HofLK9fNsEwZXzFRjoY+XT1Vsf1zgyXdwTr0ZYz1/2tkZ0DBTT9jZjhw==", "dev": true, "license": "MIT", "dependencies": { "@eslint-community/regexpp": "^4.12.2", - "@typescript-eslint/scope-manager": "8.57.2", - "@typescript-eslint/type-utils": "8.57.2", - "@typescript-eslint/utils": "8.57.2", - "@typescript-eslint/visitor-keys": "8.57.2", + "@typescript-eslint/scope-manager": "8.59.3", + "@typescript-eslint/type-utils": "8.59.3", + "@typescript-eslint/utils": "8.59.3", + "@typescript-eslint/visitor-keys": "8.59.3", "ignore": "^7.0.5", "natural-compare": "^1.4.0", - "ts-api-utils": "^2.4.0" + "ts-api-utils": "^2.5.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -3899,9 +3996,9 @@ "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "@typescript-eslint/parser": "^8.57.2", + "@typescript-eslint/parser": "^8.59.3", "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", - "typescript": ">=4.8.4 <6.0.0" + "typescript": ">=4.8.4 <6.1.0" } }, "node_modules/@typescript-eslint/eslint-plugin/node_modules/ignore": { @@ -3915,16 +4012,16 @@ } }, "node_modules/@typescript-eslint/parser": { - "version": "8.57.2", - "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.57.2.tgz", - "integrity": "sha512-30ScMRHIAD33JJQkgfGW1t8CURZtjc2JpTrq5n2HFhOefbAhb7ucc7xJwdWcrEtqUIYJ73Nybpsggii6GtAHjA==", + "version": "8.59.3", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.59.3.tgz", + "integrity": "sha512-HPwA+hVkfcriajbNvTmZv4VRauibay+cWArYUYq7u7W7PmGShMxbPxLvrwDme55a6d5alG3nrYfhyJ/G28XlLg==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/scope-manager": "8.57.2", - "@typescript-eslint/types": "8.57.2", - "@typescript-eslint/typescript-estree": "8.57.2", - "@typescript-eslint/visitor-keys": "8.57.2", + "@typescript-eslint/scope-manager": "8.59.3", + "@typescript-eslint/types": "8.59.3", + "@typescript-eslint/typescript-estree": "8.59.3", + "@typescript-eslint/visitor-keys": "8.59.3", "debug": "^4.4.3" }, "engines": { @@ -3936,17 +4033,17 @@ }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", - "typescript": ">=4.8.4 <6.0.0" + "typescript": ">=4.8.4 <6.1.0" } }, "node_modules/@typescript-eslint/project-service": { - "version": "8.57.2", - "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.57.2.tgz", - "integrity": "sha512-FuH0wipFywXRTHf+bTTjNyuNQQsQC3qh/dYzaM4I4W0jrCqjCVuUh99+xd9KamUfmCGPvbO8NDngo/vsnNVqgw==", + "version": "8.59.3", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.59.3.tgz", + "integrity": "sha512-ECiUWa/KYRGDFUqTNehaRgzDshnJfkTABJxVemHk4ko22gcr0ukloKjWvyQ64g8YCV/UI47kN1dbmjf/GaQYng==", "license": "MIT", "dependencies": { - "@typescript-eslint/tsconfig-utils": "^8.57.2", - "@typescript-eslint/types": "^8.57.2", + "@typescript-eslint/tsconfig-utils": "^8.59.3", + "@typescript-eslint/types": "^8.59.3", "debug": "^4.4.3" }, "engines": { @@ -3957,17 +4054,17 @@ "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "typescript": ">=4.8.4 <6.0.0" + "typescript": ">=4.8.4 <6.1.0" } }, "node_modules/@typescript-eslint/scope-manager": { - "version": "8.57.2", - "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.57.2.tgz", - "integrity": "sha512-snZKH+W4WbWkrBqj4gUNRIGb/jipDW3qMqVJ4C9rzdFc+wLwruxk+2a5D+uoFcKPAqyqEnSb4l2ULuZf95eSkw==", + "version": "8.59.3", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.59.3.tgz", + "integrity": "sha512-t2LvZnoEfzKtnPjgeEu41xw5gxq9mQVfYy4OoZ4Vlt0sk3JwxmhCca/AR7DwOiHrjWgjAj6as4AhRLKSDfvZIA==", "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.57.2", - "@typescript-eslint/visitor-keys": "8.57.2" + "@typescript-eslint/types": "8.59.3", + "@typescript-eslint/visitor-keys": "8.59.3" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -3978,9 +4075,9 @@ } }, "node_modules/@typescript-eslint/tsconfig-utils": { - "version": "8.57.2", - "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.57.2.tgz", - "integrity": "sha512-3Lm5DSM+DCowsUOJC+YqHHnKEfFh5CoGkj5Z31NQSNF4l5wdOwqGn99wmwN/LImhfY3KJnmordBq/4+VDe2eKw==", + "version": "8.59.3", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.59.3.tgz", + "integrity": "sha512-PcIJHjmaREXLgIAIzLnSY9VucEzz8FKXsRgFa1DmdGCK/5tJpW03TKJF01Q6VZd1lLdz2sIKPWaDUZN9dp//dw==", "license": "MIT", "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -3990,21 +4087,21 @@ "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "typescript": ">=4.8.4 <6.0.0" + "typescript": ">=4.8.4 <6.1.0" } }, "node_modules/@typescript-eslint/type-utils": { - "version": "8.57.2", - "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.57.2.tgz", - "integrity": "sha512-Co6ZCShm6kIbAM/s+oYVpKFfW7LBc6FXoPXjTRQ449PPNBY8U0KZXuevz5IFuuUj2H9ss40atTaf9dlGLzbWZg==", + "version": "8.59.3", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.59.3.tgz", + "integrity": "sha512-g71d8QD8UaiHGvrJwyIS1hCX5r63w6Jll+4VEYhEAHXTDIqX1JgxhTAbEHtKntL9kuc4jRo7/GWw5xfCepSccQ==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.57.2", - "@typescript-eslint/typescript-estree": "8.57.2", - "@typescript-eslint/utils": "8.57.2", + "@typescript-eslint/types": "8.59.3", + "@typescript-eslint/typescript-estree": "8.59.3", + "@typescript-eslint/utils": "8.59.3", "debug": "^4.4.3", - "ts-api-utils": "^2.4.0" + "ts-api-utils": "^2.5.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -4015,13 +4112,13 @@ }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", - "typescript": ">=4.8.4 <6.0.0" + "typescript": ">=4.8.4 <6.1.0" } }, "node_modules/@typescript-eslint/types": { - "version": "8.57.2", - "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.57.2.tgz", - "integrity": "sha512-/iZM6FnM4tnx9csuTxspMW4BOSegshwX5oBDznJ7S4WggL7Vczz5d2W11ecc4vRrQMQHXRSxzrCsyG5EsPPTbA==", + "version": "8.59.3", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.59.3.tgz", + "integrity": "sha512-ePFoH0g4ludssdRFqqDxQePCxU4WQyRa9+XVwjm7yLn0FKhMeoetC+qBEEI1Eyb1pGSDveTIT09Bvw2WhlGayg==", "license": "MIT", "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -4032,20 +4129,20 @@ } }, "node_modules/@typescript-eslint/typescript-estree": { - "version": "8.57.2", - "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.57.2.tgz", - "integrity": "sha512-2MKM+I6g8tJxfSmFKOnHv2t8Sk3T6rF20A1Puk0svLK+uVapDZB/4pfAeB7nE83uAZrU6OxW+HmOd5wHVdXwXA==", + "version": "8.59.3", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.59.3.tgz", + "integrity": "sha512-CbRjVRAf7Lr9Kr8RopKcbY45p2VfmmHrm0ygOCYFi7oU8q19m0Fs/6iHS7kNOmwpp+ob07ZVcAqlxUod9lYdmg==", "license": "MIT", "dependencies": { - "@typescript-eslint/project-service": "8.57.2", - "@typescript-eslint/tsconfig-utils": "8.57.2", - "@typescript-eslint/types": "8.57.2", - "@typescript-eslint/visitor-keys": "8.57.2", + "@typescript-eslint/project-service": "8.59.3", + "@typescript-eslint/tsconfig-utils": "8.59.3", + "@typescript-eslint/types": "8.59.3", + "@typescript-eslint/visitor-keys": "8.59.3", "debug": "^4.4.3", "minimatch": "^10.2.2", "semver": "^7.7.3", "tinyglobby": "^0.2.15", - "ts-api-utils": "^2.4.0" + "ts-api-utils": "^2.5.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -4055,19 +4152,31 @@ "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "typescript": ">=4.8.4 <6.0.0" + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/semver": { + "version": "7.8.0", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.0.tgz", + "integrity": "sha512-AcM7dV/5ul4EekoQ29Agm5vri8JNqRyj39o0qpX6vDF2GZrtutZl5RwgD1XnZjiTAfncsJhMI48QQH3sN87YNA==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" } }, "node_modules/@typescript-eslint/utils": { - "version": "8.57.2", - "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.57.2.tgz", - "integrity": "sha512-krRIbvPK1ju1WBKIefiX+bngPs+odIQUtR7kymzPfo1POVw3jlF+nLkmexdSSd4UCbDcQn+wMBATOOmpBbqgKg==", + "version": "8.59.3", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.59.3.tgz", + "integrity": "sha512-JAvT14goBzRzzzZyqq3P9BLArIxTtQURUtFgQ/V7FO+eU+Gg6ES+5ymOPP1wRxXcxAYeivCk4uS3jCKWI1K8Zg==", "license": "MIT", "dependencies": { "@eslint-community/eslint-utils": "^4.9.1", - "@typescript-eslint/scope-manager": "8.57.2", - "@typescript-eslint/types": "8.57.2", - "@typescript-eslint/typescript-estree": "8.57.2" + "@typescript-eslint/scope-manager": "8.59.3", + "@typescript-eslint/types": "8.59.3", + "@typescript-eslint/typescript-estree": "8.59.3" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -4078,16 +4187,16 @@ }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", - "typescript": ">=4.8.4 <6.0.0" + "typescript": ">=4.8.4 <6.1.0" } }, "node_modules/@typescript-eslint/visitor-keys": { - "version": "8.57.2", - "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.57.2.tgz", - "integrity": "sha512-zhahknjobV2FiD6Ee9iLbS7OV9zi10rG26odsQdfBO/hjSzUQbkIYgda+iNKK1zNiW2ey+Lf8MU5btN17V3dUw==", + "version": "8.59.3", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.59.3.tgz", + "integrity": "sha512-f1UQF7ggd42YiwI5wGrRaPsa+P0CINBlrkLPmGfpq/u/I/oVtecoEIfFR9ag/oa1sLOsRNZ6xehf6qMZhQGBDg==", "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.57.2", + "@typescript-eslint/types": "8.59.3", "eslint-visitor-keys": "^5.0.0" }, "engines": { @@ -4187,13 +4296,13 @@ } }, "node_modules/@vitest/mocker": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.2.tgz", - "integrity": "sha512-Ize4iQtEALHDttPRCmN+FKqOl2vxTiNUhzobQFFt/BM1lRUTG7zRCLOykG/6Vo4E4hnUdfVLo5/eqKPukcWW7Q==", + "version": "4.1.6", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.6.tgz", + "integrity": "sha512-MCFc63czMjEInOlcY2cpQCvCN+KgbAn+60xu9cMgP4sKaLC5JNAKw7JH8QdAnoAC88hW1IiSNZ+GgVXlN1UcMQ==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/spy": "4.1.2", + "@vitest/spy": "4.1.6", "estree-walker": "^3.0.3", "magic-string": "^0.30.21" }, @@ -4214,9 +4323,9 @@ } }, "node_modules/@vitest/mocker/node_modules/@vitest/spy": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.2.tgz", - "integrity": "sha512-DU4fBnbVCJGNBwVA6xSToNXrkZNSiw59H8tcuUspVMsBDBST4nfvsPsEHDHGtWRRnqBERBQu7TrTKskmjqTXKA==", + "version": "4.1.6", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.6.tgz", + "integrity": "sha512-JFKxMx6udhwKh/Ldo270e17QX710vgunMkuPAvXjHSvC6oqLWAHhVhjg/I71q0u0CBSErIODV1Kjv0FQNSWjdg==", "dev": true, "license": "MIT", "funding": { @@ -4247,13 +4356,13 @@ } }, "node_modules/@vitest/runner": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.2.tgz", - "integrity": "sha512-Gr+FQan34CdiYAwpGJmQG8PgkyFVmARK8/xSijia3eTFgVfpcpztWLuP6FttGNfPLJhaZVP/euvujeNYar36OQ==", + "version": "4.1.6", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.6.tgz", + "integrity": "sha512-nOPCmn2+yD0ZNmKdsXGv/UxMMWbMuKeD6GyYncNwdkYDxpQvrPSKYj2rWuDjC2Y4b6w6hjip5dBKFzEUuZe3vA==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/utils": "4.1.2", + "@vitest/utils": "4.1.6", "pathe": "^2.0.3" }, "funding": { @@ -4261,9 +4370,9 @@ } }, "node_modules/@vitest/runner/node_modules/@vitest/pretty-format": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.2.tgz", - "integrity": "sha512-dwQga8aejqeuB+TvXCMzSQemvV9hNEtDDpgUKDzOmNQayl2OG241PSWeJwKRH3CiC+sESrmoFd49rfnq7T4RnA==", + "version": "4.1.6", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.6.tgz", + "integrity": "sha512-h5SxD/IzNhZYnrSZRsUZQIC+vD0GY8cUvq0iwsmkFKixRCKLLWqCXa/FIQ4S1R+sI+PGoojkHsdNrbZiM9Qpgw==", "dev": true, "license": "MIT", "dependencies": { @@ -4274,13 +4383,13 @@ } }, "node_modules/@vitest/runner/node_modules/@vitest/utils": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.2.tgz", - "integrity": "sha512-xw2/TiX82lQHA06cgbqRKFb5lCAy3axQ4H4SoUFhUsg+wztiet+co86IAMDtF6Vm1hc7J6j09oh/rgDn+JdKIQ==", + "version": "4.1.6", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.6.tgz", + "integrity": "sha512-FxIY+U81R3LGKCxaHHFRQ5+g6/iRgGLmeHWdp2Amj4ljQRrEIWHmZyDfDYBRZlpyqA7qKxtS9DD1dhk8RnRIVQ==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/pretty-format": "4.1.2", + "@vitest/pretty-format": "4.1.6", "convert-source-map": "^2.0.0", "tinyrainbow": "^3.1.0" }, @@ -4299,14 +4408,14 @@ } }, "node_modules/@vitest/snapshot": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.2.tgz", - "integrity": "sha512-g7yfUmxYS4mNxk31qbOYsSt2F4m1E02LFqO53Xpzg3zKMhLAPZAjjfyl9e6z7HrW6LvUdTwAQR3HHfLjpko16A==", + "version": "4.1.6", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.6.tgz", + "integrity": "sha512-YhsdE6xAVfTDmzjxL2ZDUvjj+ZsgyOKe+TdQzqkD72wIOmHka8NuGQ6NpTNZv9D2Z63fbwWKJPeVpEw4EQgYxw==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/pretty-format": "4.1.2", - "@vitest/utils": "4.1.2", + "@vitest/pretty-format": "4.1.6", + "@vitest/utils": "4.1.6", "magic-string": "^0.30.21", "pathe": "^2.0.3" }, @@ -4315,9 +4424,9 @@ } }, "node_modules/@vitest/snapshot/node_modules/@vitest/pretty-format": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.2.tgz", - "integrity": "sha512-dwQga8aejqeuB+TvXCMzSQemvV9hNEtDDpgUKDzOmNQayl2OG241PSWeJwKRH3CiC+sESrmoFd49rfnq7T4RnA==", + "version": "4.1.6", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.6.tgz", + "integrity": "sha512-h5SxD/IzNhZYnrSZRsUZQIC+vD0GY8cUvq0iwsmkFKixRCKLLWqCXa/FIQ4S1R+sI+PGoojkHsdNrbZiM9Qpgw==", "dev": true, "license": "MIT", "dependencies": { @@ -4328,13 +4437,13 @@ } }, "node_modules/@vitest/snapshot/node_modules/@vitest/utils": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.2.tgz", - "integrity": "sha512-xw2/TiX82lQHA06cgbqRKFb5lCAy3axQ4H4SoUFhUsg+wztiet+co86IAMDtF6Vm1hc7J6j09oh/rgDn+JdKIQ==", + "version": "4.1.6", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.6.tgz", + "integrity": "sha512-FxIY+U81R3LGKCxaHHFRQ5+g6/iRgGLmeHWdp2Amj4ljQRrEIWHmZyDfDYBRZlpyqA7qKxtS9DD1dhk8RnRIVQ==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/pretty-format": "4.1.2", + "@vitest/pretty-format": "4.1.6", "convert-source-map": "^2.0.0", "tinyrainbow": "^3.1.0" }, @@ -4402,19 +4511,21 @@ } }, "node_modules/agent-base": { - "version": "7.1.4", - "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", - "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", - "dev": true, + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz", + "integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==", "license": "MIT", + "dependencies": { + "debug": "4" + }, "engines": { - "node": ">= 14" + "node": ">= 6.0.0" } }, "node_modules/ajv": { - "version": "6.14.0", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.14.0.tgz", - "integrity": "sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw==", + "version": "6.15.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz", + "integrity": "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==", "license": "MIT", "dependencies": { "fast-deep-equal": "^3.1.1", @@ -4735,9 +4846,9 @@ "license": "MIT" }, "node_modules/autoprefixer": { - "version": "10.4.27", - "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.4.27.tgz", - "integrity": "sha512-NP9APE+tO+LuJGn7/9+cohklunJsXWiaWEfV3si4Gi/XHDwVNgkwr1J3RQYFIvPy76GmJ9/bW8vyoU1LcxwKHA==", + "version": "10.5.0", + "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.5.0.tgz", + "integrity": "sha512-FMhOoZV4+qR6aTUALKX2rEqGG+oyATvwBt9IIzVR5rMa2HRWPkxf+P+PAJLD1I/H5/II+HuZcBJYEFBpq39ong==", "dev": true, "funding": [ { @@ -4755,8 +4866,8 @@ ], "license": "MIT", "dependencies": { - "browserslist": "^4.28.1", - "caniuse-lite": "^1.0.30001774", + "browserslist": "^4.28.2", + "caniuse-lite": "^1.0.30001787", "fraction.js": "^5.3.4", "picocolors": "^1.1.1", "postcss-value-parser": "^4.2.0" @@ -4788,9 +4899,9 @@ } }, "node_modules/axe-core": { - "version": "4.11.1", - "resolved": "https://registry.npmjs.org/axe-core/-/axe-core-4.11.1.tgz", - "integrity": "sha512-BASOg+YwO2C+346x3LZOeoovTIoTrRqEsqMa6fmfAV0P+U9mFr9NsyOEpiYvFjbc64NMrSswhV50WdXzdb/Z5A==", + "version": "4.11.4", + "resolved": "https://registry.npmjs.org/axe-core/-/axe-core-4.11.4.tgz", + "integrity": "sha512-KunSNx+TVpkAw/6ULfhnx+HWRecjqZGTOyquAoWHYLRSdK1tB5Ihce1ZW+UY3fj33bYAFWPu7W/GRSmmrCGuxA==", "dev": true, "license": "MPL-2.0", "engines": { @@ -4798,16 +4909,26 @@ } }, "node_modules/axios": { - "version": "1.14.0", - "resolved": "https://registry.npmjs.org/axios/-/axios-1.14.0.tgz", - "integrity": "sha512-3Y8yrqLSwjuzpXuZ0oIYZ/XGgLwUIBU3uLvbcpb0pidD9ctpShJd43KSlEEkVQg6DS0G9NKyzOvBfUtDKEyHvQ==", + "version": "1.16.1", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.16.1.tgz", + "integrity": "sha512-caYkukvroVPO8KrzuJEb50Hm07KwfBZPEC3VeFHTsqWHvKTsy54hjJz9BS/cdaypROE2rH6xvm9mHX4fgWkr3A==", "license": "MIT", "dependencies": { - "follow-redirects": "^1.15.11", + "follow-redirects": "^1.16.0", "form-data": "^4.0.5", + "https-proxy-agent": "^5.0.1", "proxy-from-env": "^2.1.0" } }, + "node_modules/axios/node_modules/proxy-from-env": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-2.1.0.tgz", + "integrity": "sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==", + "license": "MIT", + "engines": { + "node": ">=10" + } + }, "node_modules/axobject-query": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-4.1.0.tgz", @@ -4828,9 +4949,9 @@ } }, "node_modules/baseline-browser-mapping": { - "version": "2.10.11", - "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.11.tgz", - "integrity": "sha512-DAKrHphkJyiGuau/cFieRYhcTFeK/lBuD++C7cZ6KZHbMhBrisoi+EvhQ5RZrIfV5qwsW8kgQ07JIC+MDJRAhg==", + "version": "2.10.29", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.29.tgz", + "integrity": "sha512-Asa2krT+XTPZINCS+2QcyS8WTkObE77RwkydwF7h6DmnKqbvlalz93m/dnphUyCa6SWSP51VgtEUf2FN+gelFQ==", "dev": true, "license": "Apache-2.0", "bin": { @@ -4877,9 +4998,9 @@ } }, "node_modules/brace-expansion": { - "version": "5.0.5", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.5.tgz", - "integrity": "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==", + "version": "5.0.6", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.6.tgz", + "integrity": "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==", "license": "MIT", "dependencies": { "balanced-match": "^4.0.2" @@ -4908,9 +5029,9 @@ "dev": true }, "node_modules/browserslist": { - "version": "4.28.1", - "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.1.tgz", - "integrity": "sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA==", + "version": "4.28.2", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.2.tgz", + "integrity": "sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg==", "dev": true, "funding": [ { @@ -4928,11 +5049,11 @@ ], "license": "MIT", "dependencies": { - "baseline-browser-mapping": "^2.9.0", - "caniuse-lite": "^1.0.30001759", - "electron-to-chromium": "^1.5.263", - "node-releases": "^2.0.27", - "update-browserslist-db": "^1.2.0" + "baseline-browser-mapping": "^2.10.12", + "caniuse-lite": "^1.0.30001782", + "electron-to-chromium": "^1.5.328", + "node-releases": "^2.0.36", + "update-browserslist-db": "^1.2.3" }, "bin": { "browserslist": "cli.js" @@ -4951,17 +5072,17 @@ } }, "node_modules/cacheable": { - "version": "2.3.4", - "resolved": "https://registry.npmjs.org/cacheable/-/cacheable-2.3.4.tgz", - "integrity": "sha512-djgxybDbw9fL/ZWMI3+CE8ZilNxcwFkVtDc1gJ+IlOSSWkSMPQabhV/XCHTQ6pwwN6aivXPZ43omTooZiX06Ew==", + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/cacheable/-/cacheable-2.3.5.tgz", + "integrity": "sha512-EQfaKe09tl615iNvq/TBRWTFf1AKJNXYQSsMx0Z3EI0nA+pVsVPS8wJhnRlkbdacKPh1d0qVIhwTc2zsQNFEEg==", "dev": true, "license": "MIT", "dependencies": { "@cacheable/memory": "^2.0.8", - "@cacheable/utils": "^2.4.0", + "@cacheable/utils": "^2.4.1", "hookified": "^1.15.0", "keyv": "^5.6.0", - "qified": "^0.9.0" + "qified": "^0.10.1" } }, "node_modules/cacheable/node_modules/keyv": { @@ -4975,15 +5096,15 @@ } }, "node_modules/call-bind": { - "version": "1.0.8", - "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.8.tgz", - "integrity": "sha512-oKlSFMcMwpUg2ednkhQ454wfWiU/ul3CkJe/PEHcTKuiX6RpbehUiFMXu13HalGZxfUwCQzZG747YXBn1im9ww==", + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.9.tgz", + "integrity": "sha512-a/hy+pNsFUTR+Iz8TCJvXudKVLAnz/DyeSUo10I5yvFDQJBFU2s9uqQpoSrJlroHUKoKqzg+epxyP9lqFdzfBQ==", "dev": true, "license": "MIT", "dependencies": { - "call-bind-apply-helpers": "^1.0.0", - "es-define-property": "^1.0.0", - "get-intrinsic": "^1.2.4", + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "get-intrinsic": "^1.3.0", "set-function-length": "^1.2.2" }, "engines": { @@ -5043,9 +5164,9 @@ } }, "node_modules/caniuse-lite": { - "version": "1.0.30001781", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001781.tgz", - "integrity": "sha512-RdwNCyMsNBftLjW6w01z8bKEvT6e/5tpPVEgtn22TiLGlstHOVecsX2KHFkD5e/vRnIE4EGzpuIODb3mtswtkw==", + "version": "1.0.30001792", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001792.tgz", + "integrity": "sha512-hVLMUZFgR4JJ6ACt1uEESvQN1/dBVqPAKY0hgrV70eN3391K6juAfTjKZLKvOMsx8PxA7gsY1/tLMMTcfFLLpw==", "dev": true, "funding": [ { @@ -5220,23 +5341,6 @@ "node": ">=8" } }, - "node_modules/cliui/node_modules/wrap-ansi": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", - "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", - "license": "MIT", - "dependencies": { - "ansi-styles": "^4.0.0", - "string-width": "^4.1.0", - "strip-ansi": "^6.0.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/wrap-ansi?sponsor=1" - } - }, "node_modules/clsx": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", @@ -5347,12 +5451,12 @@ } }, "node_modules/cross-fetch": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/cross-fetch/-/cross-fetch-4.0.0.tgz", - "integrity": "sha512-e4a5N8lVvuLgAWgnCrLr2PP0YyDOTHa9H/Rj54dirp61qXnNq46m82bRhNqIA5VccJtWBvPTFRV3TtvHUKPB1g==", + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/cross-fetch/-/cross-fetch-4.1.0.tgz", + "integrity": "sha512-uKm5PU+MHTootlWEY+mZ4vvXoCn4fLQxT9dSc1sXVMSFkINTJVN8cAQROpwcKm8bJ/c7rgZVIBWzH5T78sNZZw==", "license": "MIT", "dependencies": { - "node-fetch": "^2.6.12" + "node-fetch": "^2.7.0" } }, "node_modules/cross-spawn": { @@ -5430,9 +5534,9 @@ } }, "node_modules/cssstyle/node_modules/lru-cache": { - "version": "11.2.7", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.7.tgz", - "integrity": "sha512-aY/R+aEsRelme17KGQa/1ZSIpLpNYYrhcrepKTZgE+W3WM16YMCaPwOHLHsmopZHELU0Ojin1lPVxKR0MihncA==", + "version": "11.3.6", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.3.6.tgz", + "integrity": "sha512-Gf/KoL3C/MlI7Bt0PGI9I+TeTC/I6r/csU58N4BSNc4lppLBeKsOdFYkK+dX0ABDUMJNfCHTyPpzwwO21Awd3A==", "dev": true, "license": "BlueOak-1.0.0", "engines": { @@ -5707,9 +5811,9 @@ "license": "MIT" }, "node_modules/electron-to-chromium": { - "version": "1.5.328", - "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.328.tgz", - "integrity": "sha512-QNQ5l45DzYytThO21403XN3FvK0hOkWDG8viNf6jqS42msJ8I4tGDSpBCgvDRRPnkffafiwAym2X2eHeGD2V0w==", + "version": "1.5.355", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.355.tgz", + "integrity": "sha512-LUPZhKzZPYSPme1jEYohpkA+ybYCJztr1quAdBd7E7h3+VOBVcKkwwtBJu41nrjawrRzfb8mtMfzWozoaK0ZIQ==", "dev": true, "license": "ISC" }, @@ -5721,13 +5825,13 @@ "license": "MIT" }, "node_modules/entities": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz", - "integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==", + "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": ">=0.12" + "node": ">=20.19.0" }, "funding": { "url": "https://github.com/fb55/entities?sponsor=1" @@ -5754,9 +5858,9 @@ } }, "node_modules/es-abstract": { - "version": "1.24.1", - "resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.24.1.tgz", - "integrity": "sha512-zHXBLhP+QehSSbsS9Pt23Gg964240DPd6QCf8WpkqEXxQ7fhdZzYsocOr5u7apWonsS5EjZDmTF+/slGMyasvw==", + "version": "1.24.2", + "resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.24.2.tgz", + "integrity": "sha512-2FpH9Q5i2RRwyEP1AylXe6nYLR5OhaJTZwmlcP0dL/+JCbgg7yyEo/sEK6HeGZRf3dFpWwThaRHVApXSkW3xeg==", "dev": true, "license": "MIT", "dependencies": { @@ -5841,16 +5945,16 @@ } }, "node_modules/es-iterator-helpers": { - "version": "1.3.1", - "resolved": "https://registry.npmjs.org/es-iterator-helpers/-/es-iterator-helpers-1.3.1.tgz", - "integrity": "sha512-zWwRvqWiuBPr0muUG/78cW3aHROFCNIQ3zpmYDpwdbnt2m+xlNyRWpHBpa2lJjSBit7BQ+RXA1iwbSmu5yJ/EQ==", + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/es-iterator-helpers/-/es-iterator-helpers-1.3.2.tgz", + "integrity": "sha512-HVLACW1TppGYjJ8H6/jqH/pqOtKRw6wMlrB23xfExmFWxFquAIWCmwoLsOyN96K4a5KbmOf5At9ZUO3GZbetAw==", "dev": true, "license": "MIT", "dependencies": { - "call-bind": "^1.0.8", + "call-bind": "^1.0.9", "call-bound": "^1.0.4", "define-properties": "^1.2.1", - "es-abstract": "^1.24.1", + "es-abstract": "^1.24.2", "es-errors": "^1.3.0", "es-set-tostringtag": "^2.1.0", "function-bind": "^1.1.2", @@ -5862,17 +5966,16 @@ "has-symbols": "^1.1.0", "internal-slot": "^1.1.0", "iterator.prototype": "^1.1.5", - "math-intrinsics": "^1.1.0", - "safe-array-concat": "^1.1.3" + "math-intrinsics": "^1.1.0" }, "engines": { "node": ">= 0.4" } }, "node_modules/es-module-lexer": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.0.0.tgz", - "integrity": "sha512-5POEcUuZybH7IdmGsD8wlf0AI55wMecM9rVBTI/qEAy2c1kTOm3DjFYjrBdI2K3BaJjJYfYFeRtM0t9ssnRuxw==", + "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" }, @@ -6083,15 +6186,15 @@ } }, "node_modules/eslint-import-resolver-node": { - "version": "0.3.9", - "resolved": "https://registry.npmjs.org/eslint-import-resolver-node/-/eslint-import-resolver-node-0.3.9.tgz", - "integrity": "sha512-WFj2isz22JahUv+B788TlO3N6zL3nNJGU8CcZbPZvVEkBPaJdCV4vy5wyghty5ROFbCRnm132v8BScu5/1BQ8g==", + "version": "0.3.10", + "resolved": "https://registry.npmjs.org/eslint-import-resolver-node/-/eslint-import-resolver-node-0.3.10.tgz", + "integrity": "sha512-tRrKqFyCaKict5hOd244sL6EQFNycnMQnBe+j8uqGNXYzsImGbGUU4ibtoaBmv5FLwJwcFJNeg1GeVjQfbMrDQ==", "dev": true, "license": "MIT", "dependencies": { "debug": "^3.2.7", - "is-core-module": "^2.13.0", - "resolve": "^1.22.4" + "is-core-module": "^2.16.1", + "resolve": "^2.0.0-next.6" } }, "node_modules/eslint-import-resolver-node/node_modules/debug": { @@ -6104,6 +6207,30 @@ "ms": "^2.1.1" } }, + "node_modules/eslint-import-resolver-node/node_modules/resolve": { + "version": "2.0.0-next.6", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-2.0.0-next.6.tgz", + "integrity": "sha512-3JmVl5hMGtJ3kMmB3zi3DL25KfkCEyy3Tw7Gmw7z5w8M9WlwoPFnIvwChzu1+cF3iaK3sp18hhPz8ANeimdJfA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "is-core-module": "^2.16.1", + "node-exports-info": "^1.6.0", + "object-keys": "^1.1.1", + "path-parse": "^1.0.7", + "supports-preserve-symlinks-flag": "^1.0.0" + }, + "bin": { + "resolve": "bin/resolve" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/eslint-module-utils": { "version": "2.12.1", "resolved": "https://registry.npmjs.org/eslint-module-utils/-/eslint-module-utils-2.12.1.tgz", @@ -6174,9 +6301,9 @@ "license": "MIT" }, "node_modules/eslint-plugin-import/node_modules/brace-expansion": { - "version": "1.1.13", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.13.tgz", - "integrity": "sha512-9ZLprWS6EENmhEOpjCYW2c8VkmOvckIJZfkr7rBW6dObmfgJ/L1GpSYW5Hpo9lDz4D1+n0Ckz8rU7FwHDQiG/w==", + "version": "1.1.14", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.14.tgz", + "integrity": "sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g==", "dev": true, "license": "MIT", "dependencies": { @@ -6220,16 +6347,6 @@ "node": "*" } }, - "node_modules/eslint-plugin-import/node_modules/semver": { - "version": "6.3.1", - "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", - "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", - "dev": true, - "license": "ISC", - "bin": { - "semver": "bin/semver.js" - } - }, "node_modules/eslint-plugin-import/node_modules/tsconfig-paths": { "version": "3.15.0", "resolved": "https://registry.npmjs.org/tsconfig-paths/-/tsconfig-paths-3.15.0.tgz", @@ -6291,9 +6408,9 @@ "license": "MIT" }, "node_modules/eslint-plugin-jsx-a11y/node_modules/brace-expansion": { - "version": "1.1.13", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.13.tgz", - "integrity": "sha512-9ZLprWS6EENmhEOpjCYW2c8VkmOvckIJZfkr7rBW6dObmfgJ/L1GpSYW5Hpo9lDz4D1+n0Ckz8rU7FwHDQiG/w==", + "version": "1.1.14", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.14.tgz", + "integrity": "sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g==", "dev": true, "license": "MIT", "dependencies": { @@ -6409,9 +6526,9 @@ "license": "MIT" }, "node_modules/eslint-plugin-react/node_modules/brace-expansion": { - "version": "1.1.13", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.13.tgz", - "integrity": "sha512-9ZLprWS6EENmhEOpjCYW2c8VkmOvckIJZfkr7rBW6dObmfgJ/L1GpSYW5Hpo9lDz4D1+n0Ckz8rU7FwHDQiG/w==", + "version": "1.1.14", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.14.tgz", + "integrity": "sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g==", "dev": true, "license": "MIT", "dependencies": { @@ -6456,16 +6573,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/eslint-plugin-react/node_modules/semver": { - "version": "6.3.1", - "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", - "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", - "dev": true, - "license": "ISC", - "bin": { - "semver": "bin/semver.js" - } - }, "node_modules/eslint-plugin-storybook": { "version": "0.11.6", "resolved": "https://registry.npmjs.org/eslint-plugin-storybook/-/eslint-plugin-storybook-0.11.6.tgz", @@ -6536,9 +6643,9 @@ "license": "MIT" }, "node_modules/eslint/node_modules/brace-expansion": { - "version": "1.1.13", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.13.tgz", - "integrity": "sha512-9ZLprWS6EENmhEOpjCYW2c8VkmOvckIJZfkr7rBW6dObmfgJ/L1GpSYW5Hpo9lDz4D1+n0Ckz8rU7FwHDQiG/w==", + "version": "1.1.14", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.14.tgz", + "integrity": "sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g==", "license": "MIT", "dependencies": { "balanced-match": "^1.0.0", @@ -6671,18 +6778,18 @@ } }, "node_modules/expect": { - "version": "30.3.0", - "resolved": "https://registry.npmjs.org/expect/-/expect-30.3.0.tgz", - "integrity": "sha512-1zQrciTiQfRdo7qJM1uG4navm8DayFa2TgCSRlzUyNkhcJ6XUZF3hjnpkyr3VhAqPH7i/9GkG7Tv5abz6fqz0Q==", + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/expect/-/expect-30.4.1.tgz", + "integrity": "sha512-PMARsyh/JtqC20HoGqlFcIlQAyqUtW4PlI1rup1uhYJtKuwAjbvWi3GQMAn+STdHum/dk8xrKfUM1+5SAwpolA==", "dev": true, "license": "MIT", "dependencies": { - "@jest/expect-utils": "30.3.0", + "@jest/expect-utils": "30.4.1", "@jest/get-type": "30.1.0", - "jest-matcher-utils": "30.3.0", - "jest-message-util": "30.3.0", - "jest-mock": "30.3.0", - "jest-util": "30.3.0" + "jest-matcher-utils": "30.4.1", + "jest-message-util": "30.4.1", + "jest-mock": "30.4.1", + "jest-util": "30.4.1" }, "engines": { "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" @@ -6753,10 +6860,27 @@ "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", "license": "MIT" }, + "node_modules/fast-string-truncated-width": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/fast-string-truncated-width/-/fast-string-truncated-width-3.0.3.tgz", + "integrity": "sha512-0jjjIEL6+0jag3l2XWWizO64/aZVtpiGE3t0Zgqxv0DPuxiMjvB3M24fCyhZUO4KomJQPj3LTSUnDP3GpdwC0g==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-string-width": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/fast-string-width/-/fast-string-width-3.0.2.tgz", + "integrity": "sha512-gX8LrtNEI5hq8DVUfRQMbr5lpaS4nMIWV+7XEbXk2b8kiQIizgnlr12B4dA3ZEx3308ze0O4Q1R+cHts8kyUJg==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-string-truncated-width": "^3.0.2" + } + }, "node_modules/fast-uri": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.0.tgz", - "integrity": "sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==", + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.2.tgz", + "integrity": "sha512-rVjf7ArG3LTk+FS6Yw81V1DLuZl1bRbNrev6Tmd/9RaroeeRRJhAt7jg/6YFxbvAQXUCavSoZhPPj6oOx+5KjQ==", "dev": true, "funding": [ { @@ -6770,6 +6894,16 @@ ], "license": "BSD-3-Clause" }, + "node_modules/fast-wrap-ansi": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/fast-wrap-ansi/-/fast-wrap-ansi-0.2.0.tgz", + "integrity": "sha512-rLV8JHxTyhVmFYhBJuMujcrHqOT2cnO5Zxj37qROj23CP39GXubJRBUFF0z8KFK77Uc0SukZUf7JZhsVEQ6n8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-string-width": "^3.0.2" + } + }, "node_modules/fastest-levenshtein": { "version": "1.0.16", "resolved": "https://registry.npmjs.org/fastest-levenshtein/-/fastest-levenshtein-1.0.16.tgz", @@ -6890,9 +7024,9 @@ "license": "ISC" }, "node_modules/follow-redirects": { - "version": "1.15.11", - "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.11.tgz", - "integrity": "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==", + "version": "1.16.0", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.0.tgz", + "integrity": "sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==", "funding": [ { "type": "individual", @@ -7137,19 +7271,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/get-tsconfig": { - "version": "4.13.7", - "resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.13.7.tgz", - "integrity": "sha512-7tN6rFgBlMgpBML5j8typ92BKFi2sFQvIdpAqLA2beia5avZDrMs0FLZiM5etShWq5irVyGcGMEA1jcDaK7A/Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "resolve-pkg-maps": "^1.0.0" - }, - "funding": { - "url": "https://github.com/privatenumber/get-tsconfig?sponsor=1" - } - }, "node_modules/glob": { "version": "13.0.6", "resolved": "https://registry.npmjs.org/glob/-/glob-13.0.6.tgz", @@ -7222,9 +7343,9 @@ } }, "node_modules/globals": { - "version": "17.4.0", - "resolved": "https://registry.npmjs.org/globals/-/globals-17.4.0.tgz", - "integrity": "sha512-hjrNztw/VajQwOLsMNT1cbJiH2muO3OROCHnbehc8eY5JyD2gqz4AcMHPqgaOR59DjgUjYAYLeH699g/eWi2jw==", + "version": "17.6.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-17.6.0.tgz", + "integrity": "sha512-sepffkT8stwnIYbsMBpoCHJuJM5l98FUF2AnE07hfvE0m/qp3R586hw4jF4uadbhvg1ooIdzuu7CsfD2jzCaNA==", "dev": true, "license": "MIT", "engines": { @@ -7299,9 +7420,9 @@ "license": "ISC" }, "node_modules/graphql": { - "version": "16.13.2", - "resolved": "https://registry.npmjs.org/graphql/-/graphql-16.13.2.tgz", - "integrity": "sha512-5bJ+nf/UCpAjHM8i06fl7eLyVC9iuNAjm9qzkiu2ZGhM0VscSvS6WDPfAwkdkBuoXGM9FJSbKl6wylMwP9Ktig==", + "version": "16.14.0", + "resolved": "https://registry.npmjs.org/graphql/-/graphql-16.14.0.tgz", + "integrity": "sha512-BBvQ/406p+4CZbTpCbVPSxfzrZrbnuWSP1ELYgyS6B+hNeKzgrdB4JczCa5VZUBQrDa9hUngm0KnexY6pJRN5Q==", "dev": true, "license": "MIT", "engines": { @@ -7400,9 +7521,9 @@ } }, "node_modules/hasown": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", - "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.3.tgz", + "integrity": "sha512-ej4AhfhfL2Q2zpMmLo7U1Uv9+PyhIZpgQLGT1F9miIGmiCJIoCgSmczFdrc97mWT4kVY72KA+WnnhJ5pghSvSg==", "license": "MIT", "dependencies": { "function-bind": "^1.1.2" @@ -7412,11 +7533,15 @@ } }, "node_modules/headers-polyfill": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/headers-polyfill/-/headers-polyfill-4.0.3.tgz", - "integrity": "sha512-IScLbePpkvO846sIwOtOTDjutRMWdXdJmXdMvk6gCBHxFO8d+QKOQedyZSxFTTFYRSmlgSTDtXqqq4pcenBXLQ==", + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/headers-polyfill/-/headers-polyfill-5.0.1.tgz", + "integrity": "sha512-1TJ6Fih/b8h5TIcv+1+Hw0PDQWJTKDKzFZzcKOiW1wJza3XoAQlkCuXLbymPYB8+ZQyw8mHvdw560e8zVFIWyA==", "dev": true, - "license": "MIT" + "license": "MIT", + "dependencies": { + "@types/set-cookie-parser": "^2.4.10", + "set-cookie-parser": "^3.0.1" + } }, "node_modules/hookified": { "version": "1.15.1", @@ -7480,18 +7605,27 @@ "node": ">= 14" } }, - "node_modules/https-proxy-agent": { - "version": "7.0.6", - "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", - "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", + "node_modules/http-proxy-agent/node_modules/agent-base": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", + "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", "dev": true, "license": "MIT", + "engines": { + "node": ">= 14" + } + }, + "node_modules/https-proxy-agent": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz", + "integrity": "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==", + "license": "MIT", "dependencies": { - "agent-base": "^7.1.2", + "agent-base": "6", "debug": "4" }, "engines": { - "node": ">= 14" + "node": ">= 6" } }, "node_modules/i18next": { @@ -7535,12 +7669,12 @@ } }, "node_modules/i18next-http-backend": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/i18next-http-backend/-/i18next-http-backend-3.0.2.tgz", - "integrity": "sha512-PdlvPnvIp4E1sYi46Ik4tBYh/v/NbYfFFgTjkwFl0is8A18s7/bx9aXqsrOax9WUbeNS6mD2oix7Z0yGGf6m5g==", + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/i18next-http-backend/-/i18next-http-backend-3.0.6.tgz", + "integrity": "sha512-mBOqy8993jtqAoj6XaI1XeC/8/9v6EPS+681ziegrPvTB0DoaCY7PpTS0SpY56qLMoS4OI1TZEM2Zf59zNh05w==", "license": "MIT", "dependencies": { - "cross-fetch": "4.0.0" + "cross-fetch": "4.1.0" } }, "node_modules/ignore": { @@ -7760,13 +7894,13 @@ } }, "node_modules/is-core-module": { - "version": "2.16.1", - "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.1.tgz", - "integrity": "sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==", + "version": "2.16.2", + "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.2.tgz", + "integrity": "sha512-evOr8xfXKxE6qSR0hSXL2r3sd7ALj8+7jQEUvPYcm5sgZFdJ+AYzT6yNmJenvIYQBgIGwfwz08sL8zoL7yq2BA==", "dev": true, "license": "MIT", "dependencies": { - "hasown": "^2.0.2" + "hasown": "^2.0.3" }, "engines": { "node": ">= 0.4" @@ -8173,16 +8307,16 @@ } }, "node_modules/jest-diff": { - "version": "30.3.0", - "resolved": "https://registry.npmjs.org/jest-diff/-/jest-diff-30.3.0.tgz", - "integrity": "sha512-n3q4PDQjS4LrKxfWB3Z5KNk1XjXtZTBwQp71OP0Jo03Z6V60x++K5L8k6ZrW8MY8pOFylZvHM0zsjS1RqlHJZQ==", + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/jest-diff/-/jest-diff-30.4.1.tgz", + "integrity": "sha512-CRpFK0RtLriVDGcPPAnR6HMVI8bSR2jnUIgralhauzYQZIb4RH9AtEInTuQr65LmmGggGcRT6HIASxwqsVsmlA==", "dev": true, "license": "MIT", "dependencies": { - "@jest/diff-sequences": "30.3.0", + "@jest/diff-sequences": "30.4.0", "@jest/get-type": "30.1.0", "chalk": "^4.1.2", - "pretty-format": "30.3.0" + "pretty-format": "30.4.1" }, "engines": { "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" @@ -8202,38 +8336,32 @@ } }, "node_modules/jest-diff/node_modules/pretty-format": { - "version": "30.3.0", - "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-30.3.0.tgz", - "integrity": "sha512-oG4T3wCbfeuvljnyAzhBvpN45E8iOTXCU/TD3zXW80HA3dQ4ahdqMkWGiPWZvjpQwlbyHrPTWUAqUzGzv4l1JQ==", + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-30.4.1.tgz", + "integrity": "sha512-K6KiKMHTL4jjX4u3Kir2EW07nRfcqVTXIImx50wbjHQTcZPgg+gjVeNTIT3l3L1Rd4UefxfogquC9J37SoFyyw==", "dev": true, "license": "MIT", "dependencies": { - "@jest/schemas": "30.0.5", + "@jest/schemas": "30.4.1", "ansi-styles": "^5.2.0", - "react-is": "^18.3.1" + "react-is-18": "npm:react-is@^18.3.1", + "react-is-19": "npm:react-is@^19.2.5" }, "engines": { "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, - "node_modules/jest-diff/node_modules/react-is": { - "version": "18.3.1", - "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz", - "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==", - "dev": true, - "license": "MIT" - }, "node_modules/jest-matcher-utils": { - "version": "30.3.0", - "resolved": "https://registry.npmjs.org/jest-matcher-utils/-/jest-matcher-utils-30.3.0.tgz", - "integrity": "sha512-HEtc9uFQgaUHkC7nLSlQL3Tph4Pjxt/yiPvkIrrDCt9jhoLIgxaubo1G+CFOnmHYMxHwwdaSN7mkIFs6ZK8OhA==", + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/jest-matcher-utils/-/jest-matcher-utils-30.4.1.tgz", + "integrity": "sha512-zvYfX5CaeEkFrrLS9suWe9rvJrm9J1Iv3ua8kIBv9GEPzcnsfBf0bob37la7s67fs0nlBC3EuvkOLnXQKxtx4A==", "dev": true, "license": "MIT", "dependencies": { "@jest/get-type": "30.1.0", "chalk": "^4.1.2", - "jest-diff": "30.3.0", - "pretty-format": "30.3.0" + "jest-diff": "30.4.1", + "pretty-format": "30.4.1" }, "engines": { "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" @@ -8253,41 +8381,36 @@ } }, "node_modules/jest-matcher-utils/node_modules/pretty-format": { - "version": "30.3.0", - "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-30.3.0.tgz", - "integrity": "sha512-oG4T3wCbfeuvljnyAzhBvpN45E8iOTXCU/TD3zXW80HA3dQ4ahdqMkWGiPWZvjpQwlbyHrPTWUAqUzGzv4l1JQ==", + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-30.4.1.tgz", + "integrity": "sha512-K6KiKMHTL4jjX4u3Kir2EW07nRfcqVTXIImx50wbjHQTcZPgg+gjVeNTIT3l3L1Rd4UefxfogquC9J37SoFyyw==", "dev": true, "license": "MIT", "dependencies": { - "@jest/schemas": "30.0.5", + "@jest/schemas": "30.4.1", "ansi-styles": "^5.2.0", - "react-is": "^18.3.1" + "react-is-18": "npm:react-is@^18.3.1", + "react-is-19": "npm:react-is@^19.2.5" }, "engines": { "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, - "node_modules/jest-matcher-utils/node_modules/react-is": { - "version": "18.3.1", - "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz", - "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==", - "dev": true, - "license": "MIT" - }, "node_modules/jest-message-util": { - "version": "30.3.0", - "resolved": "https://registry.npmjs.org/jest-message-util/-/jest-message-util-30.3.0.tgz", - "integrity": "sha512-Z/j4Bo+4ySJ+JPJN3b2Qbl9hDq3VrXmnjjGEWD/x0BCXeOXPTV1iZYYzl2X8c1MaCOL+ewMyNBcm88sboE6YWw==", + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/jest-message-util/-/jest-message-util-30.4.1.tgz", + "integrity": "sha512-kwCKIvq0MCW1HzLoGola9Te6JUdzgV0loyKJ3Qghrkz9i5/RRIHsL95BMQc2HBBhlBKC4j22K9p11TGHH8RBpQ==", "dev": true, "license": "MIT", "dependencies": { "@babel/code-frame": "^7.27.1", - "@jest/types": "30.3.0", + "@jest/types": "30.4.1", "@types/stack-utils": "^2.0.3", "chalk": "^4.1.2", "graceful-fs": "^4.2.11", + "jest-util": "30.4.1", "picomatch": "^4.0.3", - "pretty-format": "30.3.0", + "pretty-format": "30.4.1", "slash": "^3.0.0", "stack-utils": "^2.0.6" }, @@ -8309,46 +8432,40 @@ } }, "node_modules/jest-message-util/node_modules/pretty-format": { - "version": "30.3.0", - "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-30.3.0.tgz", - "integrity": "sha512-oG4T3wCbfeuvljnyAzhBvpN45E8iOTXCU/TD3zXW80HA3dQ4ahdqMkWGiPWZvjpQwlbyHrPTWUAqUzGzv4l1JQ==", + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-30.4.1.tgz", + "integrity": "sha512-K6KiKMHTL4jjX4u3Kir2EW07nRfcqVTXIImx50wbjHQTcZPgg+gjVeNTIT3l3L1Rd4UefxfogquC9J37SoFyyw==", "dev": true, "license": "MIT", "dependencies": { - "@jest/schemas": "30.0.5", + "@jest/schemas": "30.4.1", "ansi-styles": "^5.2.0", - "react-is": "^18.3.1" + "react-is-18": "npm:react-is@^18.3.1", + "react-is-19": "npm:react-is@^19.2.5" }, "engines": { "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, - "node_modules/jest-message-util/node_modules/react-is": { - "version": "18.3.1", - "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz", - "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==", - "dev": true, - "license": "MIT" - }, "node_modules/jest-mock": { - "version": "30.3.0", - "resolved": "https://registry.npmjs.org/jest-mock/-/jest-mock-30.3.0.tgz", - "integrity": "sha512-OTzICK8CpE+t4ndhKrwlIdbM6Pn8j00lvmSmq5ejiO+KxukbLjgOflKWMn3KE34EZdQm5RqTuKj+5RIEniYhog==", + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/jest-mock/-/jest-mock-30.4.1.tgz", + "integrity": "sha512-/i8SVb8/NSB7RfNi8gfqu8gxLV23KaL5EpAttyb9iz8qWRIqXRLflycz/32wXsYkOnaUlx8NAKnJYtpsmXUmfw==", "dev": true, "license": "MIT", "dependencies": { - "@jest/types": "30.3.0", + "@jest/types": "30.4.1", "@types/node": "*", - "jest-util": "30.3.0" + "jest-util": "30.4.1" }, "engines": { "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, "node_modules/jest-regex-util": { - "version": "30.0.1", - "resolved": "https://registry.npmjs.org/jest-regex-util/-/jest-regex-util-30.0.1.tgz", - "integrity": "sha512-jHEQgBXAgc+Gh4g0p3bCevgRCVRkB4VB70zhoAE48gxeSr1hfUOsM/C2WoJgVL7Eyg//hudYENbm3Ne+/dRVVA==", + "version": "30.4.0", + "resolved": "https://registry.npmjs.org/jest-regex-util/-/jest-regex-util-30.4.0.tgz", + "integrity": "sha512-mWlvLviKIgIQ8VCuM1xRdD0TWp3zlzionlmDBjuXVBs+VkmXq6FgW9T4Emr7oGz/Rk6feDCGyiugolcQEyp3mg==", "dev": true, "license": "MIT", "engines": { @@ -8356,13 +8473,13 @@ } }, "node_modules/jest-util": { - "version": "30.3.0", - "resolved": "https://registry.npmjs.org/jest-util/-/jest-util-30.3.0.tgz", - "integrity": "sha512-/jZDa00a3Sz7rdyu55NLrQCIrbyIkbBxareejQI315f/i8HjYN+ZWsDLLpoQSiUIEIyZF/R8fDg3BmB8AtHttg==", + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/jest-util/-/jest-util-30.4.1.tgz", + "integrity": "sha512-vjQb1sACEiv13DKJMDToJpzVW0joCsIQrmbg0fi7CyOOt+g9jTuQl2A216pWRBYhOVt53XbL/2LbMKg1BECWOw==", "dev": true, "license": "MIT", "dependencies": { - "@jest/types": "30.3.0", + "@jest/types": "30.4.1", "@types/node": "*", "chalk": "^4.1.2", "ci-info": "^4.2.0", @@ -8373,16 +8490,6 @@ "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, - "node_modules/jiti": { - "version": "1.21.7", - "resolved": "https://registry.npmjs.org/jiti/-/jiti-1.21.7.tgz", - "integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==", - "devOptional": true, - "license": "MIT", - "bin": { - "jiti": "bin/jiti.js" - } - }, "node_modules/js-tokens": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", @@ -8452,6 +8559,30 @@ } } }, + "node_modules/jsdom/node_modules/agent-base": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", + "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, + "node_modules/jsdom/node_modules/https-proxy-agent": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", + "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "4" + }, + "engines": { + "node": ">= 14" + } + }, "node_modules/jsesc": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", @@ -8504,9 +8635,9 @@ } }, "node_modules/jsonfile": { - "version": "6.2.0", - "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.0.tgz", - "integrity": "sha512-FGuPw30AdOIUTRMC2OMRtQV+jkVj2cfPqSeWXv1NEAJ1qZ5zb1X6z1mFhbfOB/iy3ssJCD+3KuZ8r8C3uVFlAg==", + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.1.tgz", + "integrity": "sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q==", "dev": true, "license": "MIT", "dependencies": { @@ -8627,9 +8758,9 @@ } }, "node_modules/lodash": { - "version": "4.17.23", - "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.23.tgz", - "integrity": "sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==", + "version": "4.18.1", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz", + "integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==", "dev": true, "license": "MIT" }, @@ -8821,12 +8952,12 @@ } }, "node_modules/minimatch": { - "version": "10.2.4", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.4.tgz", - "integrity": "sha512-oRjTw/97aTBN0RHbYCdtF1MQfvusSIBQM0IZEgzl6426+8jSC0nF1a/GmnVLpfB9yyr6g6FTqWqiZVbxrtaCIg==", + "version": "10.2.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", + "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==", "license": "BlueOak-1.0.0", "dependencies": { - "brace-expansion": "^5.0.2" + "brace-expansion": "^5.0.5" }, "engines": { "node": "18 || 20 || >=22" @@ -8877,29 +9008,29 @@ "license": "MIT" }, "node_modules/msw": { - "version": "2.12.14", - "resolved": "https://registry.npmjs.org/msw/-/msw-2.12.14.tgz", - "integrity": "sha512-4KXa4nVBIBjbDbd7vfQNuQ25eFxug0aropCQFoI0JdOBuJWamkT1yLVIWReFI8SiTRc+H1hKzaNk+cLk2N9rtQ==", + "version": "2.14.6", + "resolved": "https://registry.npmjs.org/msw/-/msw-2.14.6.tgz", + "integrity": "sha512-ALe+N10S72cyx94cMcy3Zs4HhXCj35sgeAL4c+WTvKi0zWnbd8/h0lcFqv0mb2P+aSgAdD7p9HzvA0DiUPxsyg==", "dev": true, "hasInstallScript": true, "license": "MIT", "dependencies": { - "@inquirer/confirm": "^5.0.0", - "@mswjs/interceptors": "^0.41.2", - "@open-draft/deferred-promise": "^2.2.0", + "@inquirer/confirm": "^6.0.11", + "@mswjs/interceptors": "^0.41.3", + "@open-draft/deferred-promise": "^3.0.0", "@types/statuses": "^2.0.6", - "cookie": "^1.0.2", - "graphql": "^16.12.0", - "headers-polyfill": "^4.0.2", + "cookie": "^1.1.1", + "graphql": "^16.13.2", + "headers-polyfill": "^5.0.1", "is-node-process": "^1.2.0", "outvariant": "^1.4.3", "path-to-regexp": "^6.3.0", "picocolors": "^1.1.1", - "rettime": "^0.10.1", + "rettime": "^0.11.11", "statuses": "^2.0.2", "strict-event-emitter": "^0.5.1", - "tough-cookie": "^6.0.0", - "type-fest": "^5.2.0", + "tough-cookie": "^6.0.1", + "type-fest": "^5.5.0", "until-async": "^3.0.2", "yargs": "^17.7.2" }, @@ -8922,9 +9053,9 @@ } }, "node_modules/msw-storybook-addon": { - "version": "2.0.6", - "resolved": "https://registry.npmjs.org/msw-storybook-addon/-/msw-storybook-addon-2.0.6.tgz", - "integrity": "sha512-ExCwDbcJoM2V3iQU+fZNp+axVfNc7DWMRh4lyTXebDO8IbpUNYKGFUrA8UqaeWiRGKVuS7+fU+KXEa9b0OP6uA==", + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/msw-storybook-addon/-/msw-storybook-addon-2.0.7.tgz", + "integrity": "sha512-TGmlxXy2TsaB6QcClVKRxqvay5f93xoLguHOihRFQ+gIEIyiyvcoQjkEeuOe7Y9qvddzGB1LyFomzPo9/EpnuQ==", "dev": true, "license": "MIT", "dependencies": { @@ -8935,9 +9066,9 @@ } }, "node_modules/msw/node_modules/type-fest": { - "version": "5.5.0", - "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-5.5.0.tgz", - "integrity": "sha512-PlBfpQwiUvGViBNX84Yxwjsdhd1TUlXr6zjX7eoirtCPIr08NAmxwa+fcYBTeRQxHo9YC9wwF3m9i700sHma8g==", + "version": "5.6.0", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-5.6.0.tgz", + "integrity": "sha512-8ZiHFm91orbSAe2PSAiSVBVko18pbhbiB3U9GglSzF/zCGkR+rxpHx6sEMCUm4kxY4LjDIUGgCfUMtwfZfjfUA==", "dev": true, "license": "(MIT OR CC0-1.0)", "dependencies": { @@ -8951,13 +9082,13 @@ } }, "node_modules/mute-stream": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/mute-stream/-/mute-stream-2.0.0.tgz", - "integrity": "sha512-WWdIxpyjEn+FhQJQQv9aQAYlHoNVdzIzUySNV1gHUPDSdZJ3yZn7pAAbQcV7B56Mvu881q9FZV+0Vx2xC44VWA==", + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/mute-stream/-/mute-stream-3.0.0.tgz", + "integrity": "sha512-dkEJPVvun4FryqBmZ5KhDo0K9iDXAwn08tMLDinNdRBNPcYEDiWYysLcc6k3mjTMlbP9KyylvRpd4wFtwrT9rw==", "dev": true, "license": "ISC", "engines": { - "node": "^18.17.0 || >=20.5.0" + "node": "^20.17.0 || >=22.9.0" } }, "node_modules/mz": { @@ -8973,9 +9104,9 @@ } }, "node_modules/nanoid": { - "version": "3.3.11", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", - "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "version": "3.3.12", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz", + "integrity": "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==", "dev": true, "funding": [ { @@ -9016,16 +9147,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/node-exports-info/node_modules/semver": { - "version": "6.3.1", - "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", - "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", - "dev": true, - "license": "ISC", - "bin": { - "semver": "bin/semver.js" - } - }, "node_modules/node-fetch": { "version": "2.7.0", "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz", @@ -9069,9 +9190,9 @@ } }, "node_modules/node-releases": { - "version": "2.0.36", - "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.36.tgz", - "integrity": "sha512-TdC8FSgHz8Mwtw9g5L4gR/Sh9XhSP/0DEkQxfEFXOpiul5IiHgHan2VhYYb6agDSfp4KuvltmGApc8HMgUrIkA==", + "version": "2.0.44", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.44.tgz", + "integrity": "sha512-5WUyunoPMsvvEhS8AxHtRzP+oA8UCkJ7YRxatWKjngndhDGLiqEVAQKWjFAiAiuL8zMRGzGSJxFnLetoa43qGQ==", "dev": true, "license": "MIT" }, @@ -9363,13 +9484,13 @@ } }, "node_modules/parse5": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/parse5/-/parse5-8.0.0.tgz", - "integrity": "sha512-9m4m5GSgXjL4AjumKzq1Fgfp3Z8rsvjRNbnkVwfu2ImRqE5D0LnY2QfDen18FSY9C573YU5XxSapdHZTZ2WolA==", + "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": "^6.0.0" + "entities": "^8.0.0" }, "funding": { "url": "https://github.com/inikulin/parse5?sponsor=1" @@ -9418,9 +9539,9 @@ } }, "node_modules/path-scurry/node_modules/lru-cache": { - "version": "11.2.7", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.7.tgz", - "integrity": "sha512-aY/R+aEsRelme17KGQa/1ZSIpLpNYYrhcrepKTZgE+W3WM16YMCaPwOHLHsmopZHELU0Ojin1lPVxKR0MihncA==", + "version": "11.3.6", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.3.6.tgz", + "integrity": "sha512-Gf/KoL3C/MlI7Bt0PGI9I+TeTC/I6r/csU58N4BSNc4lppLBeKsOdFYkK+dX0ABDUMJNfCHTyPpzwwO21Awd3A==", "dev": true, "license": "BlueOak-1.0.0", "engines": { @@ -9524,9 +9645,9 @@ } }, "node_modules/postcss": { - "version": "8.5.8", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.8.tgz", - "integrity": "sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg==", + "version": "8.5.14", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.14.tgz", + "integrity": "sha512-SoSL4+OSEtR99LHFZQiJLkT59C5B1amGO1NzTwj7TT1qCUgUO6hxOvzkOYxD+vMrXBM3XJIKzokoERdqQq/Zmg==", "dev": true, "funding": [ { @@ -9744,9 +9865,9 @@ } }, "node_modules/prettier": { - "version": "3.8.1", - "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.8.1.tgz", - "integrity": "sha512-UOnG6LftzbdaHZcKoPFtOcCKztrQ57WkHDeRD9t/PTQtmT0NHSeWWepj6pS0z/N7+08BHFDQVUrfmfMRcZwbMg==", + "version": "3.8.3", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.8.3.tgz", + "integrity": "sha512-7igPTM53cGHMW8xWuVTydi2KO233VFiTNyF5hLJqpilHfmn8C8gPf+PS7dUT64YcXFbiMGZxS9pCSxL/Dxm/Jw==", "dev": true, "license": "MIT", "bin": { @@ -9927,13 +10048,11 @@ "license": "MIT" }, "node_modules/proxy-from-env": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-2.1.0.tgz", - "integrity": "sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==", - "license": "MIT", - "engines": { - "node": ">=10" - } + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", + "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==", + "dev": true, + "license": "MIT" }, "node_modules/punycode": { "version": "2.3.1", @@ -9945,22 +10064,22 @@ } }, "node_modules/qified": { - "version": "0.9.0", - "resolved": "https://registry.npmjs.org/qified/-/qified-0.9.0.tgz", - "integrity": "sha512-4q61YgkHbY6gmwkqm0BsxyLDO3UYdrdiJTJ7JiaZb3xpW1duxn135SB7KqUEkCiuu5O4W+TtwEWP2VjmSRanvA==", + "version": "0.10.1", + "resolved": "https://registry.npmjs.org/qified/-/qified-0.10.1.tgz", + "integrity": "sha512-+Owyggi9IxT1ePKGafcI87ubSmxol6smwJ+RAHDQlx9+9cPwFWDiKFFCPuWhr9ignlGpZ9vDQLw67N4dcTVFEA==", "dev": true, "license": "MIT", "dependencies": { - "hookified": "^2.1.0" + "hookified": "^2.1.1" }, "engines": { "node": ">=20" } }, "node_modules/qified/node_modules/hookified": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/hookified/-/hookified-2.1.0.tgz", - "integrity": "sha512-ootKng4eaxNxa7rx6FJv2YKef3DuhqbEj3l70oGXwddPQEEnISm50TEZQclqiLTAtilT2nu7TErtCO523hHkyg==", + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/hookified/-/hookified-2.2.0.tgz", + "integrity": "sha512-p/LgFzRN5FeoD3DLS6bkUapeye6E4SI6yJs6KetENd18S+FBthqYq2amJUWpt5z0EQwwHemidjY5OqJGEKm5uA==", "dev": true, "license": "MIT" }, @@ -10135,6 +10254,22 @@ "dev": true, "license": "MIT" }, + "node_modules/react-is-18": { + "name": "react-is", + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz", + "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==", + "dev": true, + "license": "MIT" + }, + "node_modules/react-is-19": { + "name": "react-is", + "version": "19.2.6", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-19.2.6.tgz", + "integrity": "sha512-XjBR15BhXuylgWGuslhDKqlSayuqvqBX91BP8pauG8kd1zY8kotkNWbXksTCNRarse4kuGbe2kIY05ARtwNIvw==", + "dev": true, + "license": "MIT" + }, "node_modules/react-refresh": { "version": "0.18.0", "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.18.0.tgz", @@ -10146,9 +10281,9 @@ } }, "node_modules/react-router": { - "version": "7.13.2", - "resolved": "https://registry.npmjs.org/react-router/-/react-router-7.13.2.tgz", - "integrity": "sha512-tX1Aee+ArlKQP+NIUd7SE6Li+CiGKwQtbS+FfRxPX6Pe4vHOo6nr9d++u5cwg+Z8K/x8tP+7qLmujDtfrAoUJA==", + "version": "7.15.0", + "resolved": "https://registry.npmjs.org/react-router/-/react-router-7.15.0.tgz", + "integrity": "sha512-HW9vYwuM8f4yx66Izy8xfrzCM+SBJluoZcCbww9A1TySax11S5Vgw6fi3ZjMONw9J4gQwngL7PzkyIpJJpJ7RQ==", "license": "MIT", "dependencies": { "cookie": "^1.0.1", @@ -10168,12 +10303,12 @@ } }, "node_modules/react-router-dom": { - "version": "7.13.2", - "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.13.2.tgz", - "integrity": "sha512-aR7SUORwTqAW0JDeiWF07e9SBE9qGpByR9I8kJT5h/FrBKxPMS6TiC7rmVO+gC0q52Bx7JnjWe8Z1sR9faN4YA==", + "version": "7.15.0", + "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.15.0.tgz", + "integrity": "sha512-VcrVg64Fo8nwBvDscajG8gRTLIuTC6N50nb22l2HOOV4PTOHgoGp8mUjy9wLiHYoYTSYI36tUnXZgasSRFZorQ==", "license": "MIT", "dependencies": { - "react-router": "7.13.2" + "react-router": "7.15.0" }, "engines": { "node": ">=20.0.0" @@ -10183,6 +10318,12 @@ "react-dom": ">=18" } }, + "node_modules/react-router/node_modules/set-cookie-parser": { + "version": "2.7.2", + "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz", + "integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==", + "license": "MIT" + }, "node_modules/read-cache": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz", @@ -10333,12 +10474,13 @@ "license": "MIT" }, "node_modules/resolve": { - "version": "1.22.11", - "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz", - "integrity": "sha512-RfqAvLnMl313r7c9oclB1HhUEAezcpLjz95wFH4LVuhk9JF/r22qmVP9AMmOU4vMX7Q8pN8jwNg/CSpdFnMjTQ==", + "version": "1.22.12", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.12.tgz", + "integrity": "sha512-TyeJ1zif53BPfHootBGwPRYT1RUt6oGWsaQr8UyZW/eAm9bKoijtvruSDEmZHm92CwS9nj7/fWttqPCgzep8CA==", "dev": true, "license": "MIT", "dependencies": { + "es-errors": "^1.3.0", "is-core-module": "^2.16.1", "path-parse": "^1.0.7", "supports-preserve-symlinks-flag": "^1.0.0" @@ -10362,20 +10504,10 @@ "node": ">=4" } }, - "node_modules/resolve-pkg-maps": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz", - "integrity": "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==", - "dev": true, - "license": "MIT", - "funding": { - "url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1" - } - }, "node_modules/rettime": { - "version": "0.10.1", - "resolved": "https://registry.npmjs.org/rettime/-/rettime-0.10.1.tgz", - "integrity": "sha512-uyDrIlUEH37cinabq0AX4QbgV4HbFZ/gqoiunWQ1UqBtRvTTytwhNYjE++pO/MjPTZL5KQCf2bEoJ/BJNVQ5Kw==", + "version": "0.11.11", + "resolved": "https://registry.npmjs.org/rettime/-/rettime-0.11.11.tgz", + "integrity": "sha512-ILJRqVWBCTlg9r42fFgwVZx1gnFAcQF8mRoMkbgQfIrjEDf9nbBFDFx00oloOa+Q869FUtaYDXZvEfnecQSCoQ==", "dev": true, "license": "MIT" }, @@ -10391,9 +10523,9 @@ } }, "node_modules/rollup": { - "version": "4.60.0", - "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.60.0.tgz", - "integrity": "sha512-yqjxruMGBQJ2gG4HtjZtAfXArHomazDHoFwFFmZZl0r7Pdo7qCIXKqKHZc8yeoMgzJJ+pO6pEEHa+V7uzWlrAQ==", + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.60.3.tgz", + "integrity": "sha512-pAQK9HalE84QSm4Po3EmWIZPd3FnjkShVkiMlz1iligWYkWQ7wHYd1PF/T7QZ5TVSD6uSTon5gBVMSM4JfBV+A==", "devOptional": true, "license": "MIT", "dependencies": { @@ -10407,31 +10539,31 @@ "npm": ">=8.0.0" }, "optionalDependencies": { - "@rollup/rollup-android-arm-eabi": "4.60.0", - "@rollup/rollup-android-arm64": "4.60.0", - "@rollup/rollup-darwin-arm64": "4.60.0", - "@rollup/rollup-darwin-x64": "4.60.0", - "@rollup/rollup-freebsd-arm64": "4.60.0", - "@rollup/rollup-freebsd-x64": "4.60.0", - "@rollup/rollup-linux-arm-gnueabihf": "4.60.0", - "@rollup/rollup-linux-arm-musleabihf": "4.60.0", - "@rollup/rollup-linux-arm64-gnu": "4.60.0", - "@rollup/rollup-linux-arm64-musl": "4.60.0", - "@rollup/rollup-linux-loong64-gnu": "4.60.0", - "@rollup/rollup-linux-loong64-musl": "4.60.0", - "@rollup/rollup-linux-ppc64-gnu": "4.60.0", - "@rollup/rollup-linux-ppc64-musl": "4.60.0", - "@rollup/rollup-linux-riscv64-gnu": "4.60.0", - "@rollup/rollup-linux-riscv64-musl": "4.60.0", - "@rollup/rollup-linux-s390x-gnu": "4.60.0", - "@rollup/rollup-linux-x64-gnu": "4.60.0", - "@rollup/rollup-linux-x64-musl": "4.60.0", - "@rollup/rollup-openbsd-x64": "4.60.0", - "@rollup/rollup-openharmony-arm64": "4.60.0", - "@rollup/rollup-win32-arm64-msvc": "4.60.0", - "@rollup/rollup-win32-ia32-msvc": "4.60.0", - "@rollup/rollup-win32-x64-gnu": "4.60.0", - "@rollup/rollup-win32-x64-msvc": "4.60.0", + "@rollup/rollup-android-arm-eabi": "4.60.3", + "@rollup/rollup-android-arm64": "4.60.3", + "@rollup/rollup-darwin-arm64": "4.60.3", + "@rollup/rollup-darwin-x64": "4.60.3", + "@rollup/rollup-freebsd-arm64": "4.60.3", + "@rollup/rollup-freebsd-x64": "4.60.3", + "@rollup/rollup-linux-arm-gnueabihf": "4.60.3", + "@rollup/rollup-linux-arm-musleabihf": "4.60.3", + "@rollup/rollup-linux-arm64-gnu": "4.60.3", + "@rollup/rollup-linux-arm64-musl": "4.60.3", + "@rollup/rollup-linux-loong64-gnu": "4.60.3", + "@rollup/rollup-linux-loong64-musl": "4.60.3", + "@rollup/rollup-linux-ppc64-gnu": "4.60.3", + "@rollup/rollup-linux-ppc64-musl": "4.60.3", + "@rollup/rollup-linux-riscv64-gnu": "4.60.3", + "@rollup/rollup-linux-riscv64-musl": "4.60.3", + "@rollup/rollup-linux-s390x-gnu": "4.60.3", + "@rollup/rollup-linux-x64-gnu": "4.60.3", + "@rollup/rollup-linux-x64-musl": "4.60.3", + "@rollup/rollup-openbsd-x64": "4.60.3", + "@rollup/rollup-openharmony-arm64": "4.60.3", + "@rollup/rollup-win32-arm64-msvc": "4.60.3", + "@rollup/rollup-win32-ia32-msvc": "4.60.3", + "@rollup/rollup-win32-x64-gnu": "4.60.3", + "@rollup/rollup-win32-x64-msvc": "4.60.3", "fsevents": "~2.3.2" } }, @@ -10474,6 +10606,13 @@ "node": ">= 12" } }, + "node_modules/rollup/node_modules/@types/estree": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "devOptional": true, + "license": "MIT" + }, "node_modules/run-parallel": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", @@ -10499,15 +10638,15 @@ } }, "node_modules/safe-array-concat": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/safe-array-concat/-/safe-array-concat-1.1.3.tgz", - "integrity": "sha512-AURm5f0jYEOydBj7VQlVvDrjeFgthDdEF5H1dP+6mNpoXOMo1quQqJ4wvJDyRZ9+pO3kGWoOdmV08cSv2aJV6Q==", + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/safe-array-concat/-/safe-array-concat-1.1.4.tgz", + "integrity": "sha512-wtZlHyOje6OZTGqAoaDKxFkgRtkF9CnHAVnCHKfuj200wAgL+bSJhdsCD2l0Qx/2ekEXjPWcyKkfGb5CPboslg==", "dev": true, "license": "MIT", "dependencies": { - "call-bind": "^1.0.8", - "call-bound": "^1.0.2", - "get-intrinsic": "^1.2.6", + "call-bind": "^1.0.9", + "call-bound": "^1.0.4", + "get-intrinsic": "^1.3.0", "has-symbols": "^1.1.0", "isarray": "^2.0.5" }, @@ -10602,21 +10741,20 @@ } }, "node_modules/semver": { - "version": "7.7.4", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", - "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, "license": "ISC", "bin": { "semver": "bin/semver.js" - }, - "engines": { - "node": ">=10" } }, "node_modules/set-cookie-parser": { - "version": "2.7.2", - "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz", - "integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==", + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-3.1.0.tgz", + "integrity": "sha512-kjnC1DXBHcxaOaOXBHBeRtltsDG2nUiUni+jP92M9gYdW12rsmx92UsfpH7o5tDRs7I1ZZPSQJQGv3UaRfCiuw==", + "dev": true, "license": "MIT" }, "node_modules/set-function-length": { @@ -10710,14 +10848,14 @@ } }, "node_modules/side-channel-list": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.0.tgz", - "integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==", + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz", + "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==", "dev": true, "license": "MIT", "dependencies": { "es-errors": "^1.3.0", - "object-inspect": "^1.13.3" + "object-inspect": "^1.13.4" }, "engines": { "node": ">= 0.4" @@ -10902,9 +11040,9 @@ } }, "node_modules/std-env": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.0.0.tgz", - "integrity": "sha512-zUMPtQ/HBY3/50VbpkupYHbRroTRZJPRLvreamgErJVys0ceuzMkD44J/QjqhHjOzK42GQ3QZIeFG1OYfOtKqQ==", + "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" }, @@ -11388,13 +11526,13 @@ "license": "MIT" }, "node_modules/stylelint/node_modules/file-entry-cache": { - "version": "11.1.2", - "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-11.1.2.tgz", - "integrity": "sha512-N2WFfK12gmrK1c1GXOqiAJ1tc5YE+R53zvQ+t5P8S5XhnmKYVB5eZEiLNZKDSmoG8wqqbF9EXYBBW/nef19log==", + "version": "11.1.3", + "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-11.1.3.tgz", + "integrity": "sha512-oMbq0PD6VIiIwMF6LIa7MEwd/l9huKwmqRKXqmrkqIZv8CvRbfowL+L0ryAl8h//HfAS0zS+4SbYoRyAoA6BJA==", "dev": true, "license": "MIT", "dependencies": { - "flat-cache": "^6.1.20" + "flat-cache": "^6.1.22" } }, "node_modules/stylelint/node_modules/flat-cache": { @@ -11541,9 +11679,9 @@ } }, "node_modules/table/node_modules/ajv": { - "version": "8.18.0", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.18.0.tgz", - "integrity": "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==", + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", + "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", "dev": true, "license": "MIT", "dependencies": { @@ -11628,6 +11766,16 @@ "node": ">=14.0.0" } }, + "node_modules/tailwindcss/node_modules/jiti": { + "version": "1.21.7", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-1.21.7.tgz", + "integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==", + "dev": true, + "license": "MIT", + "bin": { + "jiti": "bin/jiti.js" + } + }, "node_modules/tailwindcss/node_modules/postcss-selector-parser": { "version": "6.1.2", "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.1.2.tgz", @@ -11680,9 +11828,9 @@ "license": "MIT" }, "node_modules/tinyexec": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.0.4.tgz", - "integrity": "sha512-u9r3uZC0bdpGOXtlxUIdwf9pkmvhqJdrVCH9fapQtgy/OeTTMZ1nqH7agtvEfmGui6e1XxjcdrlxvxJvc3sMqw==", + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.1.2.tgz", + "integrity": "sha512-dAqSqE/RabpBKI8+h26GfLq6Vb3JVXs30XYQjdMjaj/c2tS8IYYMbIzP599KtRj7c57/wYApb3QjgRgXmrCukA==", "dev": true, "license": "MIT", "engines": { @@ -11690,13 +11838,13 @@ } }, "node_modules/tinyglobby": { - "version": "0.2.15", - "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", - "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", + "version": "0.2.16", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.16.tgz", + "integrity": "sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==", "license": "MIT", "dependencies": { "fdir": "^6.5.0", - "picomatch": "^4.0.3" + "picomatch": "^4.0.4" }, "engines": { "node": ">=12.0.0" @@ -11726,22 +11874,22 @@ } }, "node_modules/tldts": { - "version": "7.0.27", - "resolved": "https://registry.npmjs.org/tldts/-/tldts-7.0.27.tgz", - "integrity": "sha512-I4FZcVFcqCRuT0ph6dCDpPuO4Xgzvh+spkcTr1gK7peIvxWauoloVO0vuy1FQnijT63ss6AsHB6+OIM4aXHbPg==", + "version": "7.0.30", + "resolved": "https://registry.npmjs.org/tldts/-/tldts-7.0.30.tgz", + "integrity": "sha512-ELrFxuqsDdHUwoh0XxDbxuLD3Wnz49Z57IFvTtvWy1hJdcMZjXLIuonjilCiWHlT2GbE4Wlv1wKVTzDFnXH1aw==", "dev": true, "license": "MIT", "dependencies": { - "tldts-core": "^7.0.27" + "tldts-core": "^7.0.30" }, "bin": { "tldts": "bin/cli.js" } }, "node_modules/tldts-core": { - "version": "7.0.27", - "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.0.27.tgz", - "integrity": "sha512-YQ7uPjgWUibIK6DW5lrKujGwUKhLevU4hcGbP5O6TcIUb+oTjJYJVWPS4nZsIHrEEEG6myk/oqAJUEQmpZrHsg==", + "version": "7.0.30", + "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.0.30.tgz", + "integrity": "sha512-uiHN8PIB1VmWyS98eZYja4xzlYqeFZVjb4OuYlJQnZAuJhMw4PbKQOKgHKhBdJR3FE/t5mUQ1Kd80++B+qhD1Q==", "dev": true, "license": "MIT" }, @@ -11844,14 +11992,13 @@ "license": "0BSD" }, "node_modules/tsx": { - "version": "4.21.0", - "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.21.0.tgz", - "integrity": "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==", + "version": "4.21.1", + "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.21.1.tgz", + "integrity": "sha512-5QE2Q04cN1u0993w0LT5rPw3faZqZU1fFn1mGE0pV53N1Dn7c+QFFxQu1mBeSgeOXwFyTicZw02wVgp3Tb5cAQ==", "dev": true, "license": "MIT", "dependencies": { - "esbuild": "~0.27.0", - "get-tsconfig": "^4.7.5" + "esbuild": "~0.27.0" }, "bin": { "tsx": "dist/cli.mjs" @@ -11864,9 +12011,9 @@ } }, "node_modules/tsx/node_modules/@esbuild/aix-ppc64": { - "version": "0.27.4", - "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.4.tgz", - "integrity": "sha512-cQPwL2mp2nSmHHJlCyoXgHGhbEPMrEEU5xhkcy3Hs/O7nGZqEpZ2sUtLaL9MORLtDfRvVl2/3PAuEkYZH0Ty8Q==", + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.7.tgz", + "integrity": "sha512-EKX3Qwmhz1eMdEJokhALr0YiD0lhQNwDqkPYyPhiSwKrh7/4KRjQc04sZ8db+5DVVnZ1LmbNDI1uAMPEUBnQPg==", "cpu": [ "ppc64" ], @@ -11881,9 +12028,9 @@ } }, "node_modules/tsx/node_modules/@esbuild/android-arm": { - "version": "0.27.4", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.4.tgz", - "integrity": "sha512-X9bUgvxiC8CHAGKYufLIHGXPJWnr0OCdR0anD2e21vdvgCI8lIfqFbnoeOz7lBjdrAGUhqLZLcQo6MLhTO2DKQ==", + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.7.tgz", + "integrity": "sha512-jbPXvB4Yj2yBV7HUfE2KHe4GJX51QplCN1pGbYjvsyCZbQmies29EoJbkEc+vYuU5o45AfQn37vZlyXy4YJ8RQ==", "cpu": [ "arm" ], @@ -11898,9 +12045,9 @@ } }, "node_modules/tsx/node_modules/@esbuild/android-arm64": { - "version": "0.27.4", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.4.tgz", - "integrity": "sha512-gdLscB7v75wRfu7QSm/zg6Rx29VLdy9eTr2t44sfTW7CxwAtQghZ4ZnqHk3/ogz7xao0QAgrkradbBzcqFPasw==", + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.7.tgz", + "integrity": "sha512-62dPZHpIXzvChfvfLJow3q5dDtiNMkwiRzPylSCfriLvZeq0a1bWChrGx/BbUbPwOrsWKMn8idSllklzBy+dgQ==", "cpu": [ "arm64" ], @@ -11915,9 +12062,9 @@ } }, "node_modules/tsx/node_modules/@esbuild/android-x64": { - "version": "0.27.4", - "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.4.tgz", - "integrity": "sha512-PzPFnBNVF292sfpfhiyiXCGSn9HZg5BcAz+ivBuSsl6Rk4ga1oEXAamhOXRFyMcjwr2DVtm40G65N3GLeH1Lvw==", + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.7.tgz", + "integrity": "sha512-x5VpMODneVDb70PYV2VQOmIUUiBtY3D3mPBG8NxVk5CogneYhkR7MmM3yR/uMdITLrC1ml/NV1rj4bMJuy9MCg==", "cpu": [ "x64" ], @@ -11932,9 +12079,9 @@ } }, "node_modules/tsx/node_modules/@esbuild/darwin-arm64": { - "version": "0.27.4", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.4.tgz", - "integrity": "sha512-b7xaGIwdJlht8ZFCvMkpDN6uiSmnxxK56N2GDTMYPr2/gzvfdQN8rTfBsvVKmIVY/X7EM+/hJKEIbbHs9oA4tQ==", + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.7.tgz", + "integrity": "sha512-5lckdqeuBPlKUwvoCXIgI2D9/ABmPq3Rdp7IfL70393YgaASt7tbju3Ac+ePVi3KDH6N2RqePfHnXkaDtY9fkw==", "cpu": [ "arm64" ], @@ -11949,9 +12096,9 @@ } }, "node_modules/tsx/node_modules/@esbuild/darwin-x64": { - "version": "0.27.4", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.4.tgz", - "integrity": "sha512-sR+OiKLwd15nmCdqpXMnuJ9W2kpy0KigzqScqHI3Hqwr7IXxBp3Yva+yJwoqh7rE8V77tdoheRYataNKL4QrPw==", + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.7.tgz", + "integrity": "sha512-rYnXrKcXuT7Z+WL5K980jVFdvVKhCHhUwid+dDYQpH+qu+TefcomiMAJpIiC2EM3Rjtq0sO3StMV/+3w3MyyqQ==", "cpu": [ "x64" ], @@ -11966,9 +12113,9 @@ } }, "node_modules/tsx/node_modules/@esbuild/freebsd-arm64": { - "version": "0.27.4", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.4.tgz", - "integrity": "sha512-jnfpKe+p79tCnm4GVav68A7tUFeKQwQyLgESwEAUzyxk/TJr4QdGog9sqWNcUbr/bZt/O/HXouspuQDd9JxFSw==", + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.7.tgz", + "integrity": "sha512-B48PqeCsEgOtzME2GbNM2roU29AMTuOIN91dsMO30t+Ydis3z/3Ngoj5hhnsOSSwNzS+6JppqWsuhTp6E82l2w==", "cpu": [ "arm64" ], @@ -11983,9 +12130,9 @@ } }, "node_modules/tsx/node_modules/@esbuild/freebsd-x64": { - "version": "0.27.4", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.4.tgz", - "integrity": "sha512-2kb4ceA/CpfUrIcTUl1wrP/9ad9Atrp5J94Lq69w7UwOMolPIGrfLSvAKJp0RTvkPPyn6CIWrNy13kyLikZRZQ==", + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.7.tgz", + "integrity": "sha512-jOBDK5XEjA4m5IJK3bpAQF9/Lelu/Z9ZcdhTRLf4cajlB+8VEhFFRjWgfy3M1O4rO2GQ/b2dLwCUGpiF/eATNQ==", "cpu": [ "x64" ], @@ -12000,9 +12147,9 @@ } }, "node_modules/tsx/node_modules/@esbuild/linux-arm": { - "version": "0.27.4", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.4.tgz", - "integrity": "sha512-aBYgcIxX/wd5n2ys0yESGeYMGF+pv6g0DhZr3G1ZG4jMfruU9Tl1i2Z+Wnj9/KjGz1lTLCcorqE2viePZqj4Eg==", + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.7.tgz", + "integrity": "sha512-RkT/YXYBTSULo3+af8Ib0ykH8u2MBh57o7q/DAs3lTJlyVQkgQvlrPTnjIzzRPQyavxtPtfg0EopvDyIt0j1rA==", "cpu": [ "arm" ], @@ -12017,9 +12164,9 @@ } }, "node_modules/tsx/node_modules/@esbuild/linux-arm64": { - "version": "0.27.4", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.4.tgz", - "integrity": "sha512-7nQOttdzVGth1iz57kxg9uCz57dxQLHWxopL6mYuYthohPKEK0vU0C3O21CcBK6KDlkYVcnDXY099HcCDXd9dA==", + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.7.tgz", + "integrity": "sha512-RZPHBoxXuNnPQO9rvjh5jdkRmVizktkT7TCDkDmQ0W2SwHInKCAV95GRuvdSvA7w4VMwfCjUiPwDi0ZO6Nfe9A==", "cpu": [ "arm64" ], @@ -12034,9 +12181,9 @@ } }, "node_modules/tsx/node_modules/@esbuild/linux-ia32": { - "version": "0.27.4", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.4.tgz", - "integrity": "sha512-oPtixtAIzgvzYcKBQM/qZ3R+9TEUd1aNJQu0HhGyqtx6oS7qTpvjheIWBbes4+qu1bNlo2V4cbkISr8q6gRBFA==", + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.7.tgz", + "integrity": "sha512-GA48aKNkyQDbd3KtkplYWT102C5sn/EZTY4XROkxONgruHPU72l+gW+FfF8tf2cFjeHaRbWpOYa/uRBz/Xq1Pg==", "cpu": [ "ia32" ], @@ -12051,9 +12198,9 @@ } }, "node_modules/tsx/node_modules/@esbuild/linux-loong64": { - "version": "0.27.4", - "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.4.tgz", - "integrity": "sha512-8mL/vh8qeCoRcFH2nM8wm5uJP+ZcVYGGayMavi8GmRJjuI3g1v6Z7Ni0JJKAJW+m0EtUuARb6Lmp4hMjzCBWzA==", + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.7.tgz", + "integrity": "sha512-a4POruNM2oWsD4WKvBSEKGIiWQF8fZOAsycHOt6JBpZ+JN2n2JH9WAv56SOyu9X5IqAjqSIPTaJkqN8F7XOQ5Q==", "cpu": [ "loong64" ], @@ -12068,9 +12215,9 @@ } }, "node_modules/tsx/node_modules/@esbuild/linux-mips64el": { - "version": "0.27.4", - "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.4.tgz", - "integrity": "sha512-1RdrWFFiiLIW7LQq9Q2NES+HiD4NyT8Itj9AUeCl0IVCA459WnPhREKgwrpaIfTOe+/2rdntisegiPWn/r/aAw==", + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.7.tgz", + "integrity": "sha512-KabT5I6StirGfIz0FMgl1I+R1H73Gp0ofL9A3nG3i/cYFJzKHhouBV5VWK1CSgKvVaG4q1RNpCTR2LuTVB3fIw==", "cpu": [ "mips64el" ], @@ -12085,9 +12232,9 @@ } }, "node_modules/tsx/node_modules/@esbuild/linux-ppc64": { - "version": "0.27.4", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.4.tgz", - "integrity": "sha512-tLCwNG47l3sd9lpfyx9LAGEGItCUeRCWeAx6x2Jmbav65nAwoPXfewtAdtbtit/pJFLUWOhpv0FpS6GQAmPrHA==", + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.7.tgz", + "integrity": "sha512-gRsL4x6wsGHGRqhtI+ifpN/vpOFTQtnbsupUF5R5YTAg+y/lKelYR1hXbnBdzDjGbMYjVJLJTd2OFmMewAgwlQ==", "cpu": [ "ppc64" ], @@ -12102,9 +12249,9 @@ } }, "node_modules/tsx/node_modules/@esbuild/linux-riscv64": { - "version": "0.27.4", - "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.4.tgz", - "integrity": "sha512-BnASypppbUWyqjd1KIpU4AUBiIhVr6YlHx/cnPgqEkNoVOhHg+YiSVxM1RLfiy4t9cAulbRGTNCKOcqHrEQLIw==", + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.7.tgz", + "integrity": "sha512-hL25LbxO1QOngGzu2U5xeXtxXcW+/GvMN3ejANqXkxZ/opySAZMrc+9LY/WyjAan41unrR3YrmtTsUpwT66InQ==", "cpu": [ "riscv64" ], @@ -12119,9 +12266,9 @@ } }, "node_modules/tsx/node_modules/@esbuild/linux-s390x": { - "version": "0.27.4", - "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.4.tgz", - "integrity": "sha512-+eUqgb/Z7vxVLezG8bVB9SfBie89gMueS+I0xYh2tJdw3vqA/0ImZJ2ROeWwVJN59ihBeZ7Tu92dF/5dy5FttA==", + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.7.tgz", + "integrity": "sha512-2k8go8Ycu1Kb46vEelhu1vqEP+UeRVj2zY1pSuPdgvbd5ykAw82Lrro28vXUrRmzEsUV0NzCf54yARIK8r0fdw==", "cpu": [ "s390x" ], @@ -12136,9 +12283,9 @@ } }, "node_modules/tsx/node_modules/@esbuild/linux-x64": { - "version": "0.27.4", - "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.4.tgz", - "integrity": "sha512-S5qOXrKV8BQEzJPVxAwnryi2+Iq5pB40gTEIT69BQONqR7JH1EPIcQ/Uiv9mCnn05jff9umq/5nqzxlqTOg9NA==", + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.7.tgz", + "integrity": "sha512-hzznmADPt+OmsYzw1EE33ccA+HPdIqiCRq7cQeL1Jlq2gb1+OyWBkMCrYGBJ+sxVzve2ZJEVeePbLM2iEIZSxA==", "cpu": [ "x64" ], @@ -12153,9 +12300,9 @@ } }, "node_modules/tsx/node_modules/@esbuild/netbsd-arm64": { - "version": "0.27.4", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.4.tgz", - "integrity": "sha512-xHT8X4sb0GS8qTqiwzHqpY00C95DPAq7nAwX35Ie/s+LO9830hrMd3oX0ZMKLvy7vsonee73x0lmcdOVXFzd6Q==", + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.7.tgz", + "integrity": "sha512-b6pqtrQdigZBwZxAn1UpazEisvwaIDvdbMbmrly7cDTMFnw/+3lVxxCTGOrkPVnsYIosJJXAsILG9XcQS+Yu6w==", "cpu": [ "arm64" ], @@ -12170,9 +12317,9 @@ } }, "node_modules/tsx/node_modules/@esbuild/netbsd-x64": { - "version": "0.27.4", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.4.tgz", - "integrity": "sha512-RugOvOdXfdyi5Tyv40kgQnI0byv66BFgAqjdgtAKqHoZTbTF2QqfQrFwa7cHEORJf6X2ht+l9ABLMP0dnKYsgg==", + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.7.tgz", + "integrity": "sha512-OfatkLojr6U+WN5EDYuoQhtM+1xco+/6FSzJJnuWiUw5eVcicbyK3dq5EeV/QHT1uy6GoDhGbFpprUiHUYggrw==", "cpu": [ "x64" ], @@ -12187,9 +12334,9 @@ } }, "node_modules/tsx/node_modules/@esbuild/openbsd-arm64": { - "version": "0.27.4", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.4.tgz", - "integrity": "sha512-2MyL3IAaTX+1/qP0O1SwskwcwCoOI4kV2IBX1xYnDDqthmq5ArrW94qSIKCAuRraMgPOmG0RDTA74mzYNQA9ow==", + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.7.tgz", + "integrity": "sha512-AFuojMQTxAz75Fo8idVcqoQWEHIXFRbOc1TrVcFSgCZtQfSdc1RXgB3tjOn/krRHENUB4j00bfGjyl2mJrU37A==", "cpu": [ "arm64" ], @@ -12204,9 +12351,9 @@ } }, "node_modules/tsx/node_modules/@esbuild/openbsd-x64": { - "version": "0.27.4", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.4.tgz", - "integrity": "sha512-u8fg/jQ5aQDfsnIV6+KwLOf1CmJnfu1ShpwqdwC0uA7ZPwFws55Ngc12vBdeUdnuWoQYx/SOQLGDcdlfXhYmXQ==", + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.7.tgz", + "integrity": "sha512-+A1NJmfM8WNDv5CLVQYJ5PshuRm/4cI6WMZRg1by1GwPIQPCTs1GLEUHwiiQGT5zDdyLiRM/l1G0Pv54gvtKIg==", "cpu": [ "x64" ], @@ -12221,9 +12368,9 @@ } }, "node_modules/tsx/node_modules/@esbuild/openharmony-arm64": { - "version": "0.27.4", - "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.4.tgz", - "integrity": "sha512-JkTZrl6VbyO8lDQO3yv26nNr2RM2yZzNrNHEsj9bm6dOwwu9OYN28CjzZkH57bh4w0I2F7IodpQvUAEd1mbWXg==", + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.7.tgz", + "integrity": "sha512-+KrvYb/C8zA9CU/g0sR6w2RBw7IGc5J2BPnc3dYc5VJxHCSF1yNMxTV5LQ7GuKteQXZtspjFbiuW5/dOj7H4Yw==", "cpu": [ "arm64" ], @@ -12238,9 +12385,9 @@ } }, "node_modules/tsx/node_modules/@esbuild/sunos-x64": { - "version": "0.27.4", - "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.4.tgz", - "integrity": "sha512-/gOzgaewZJfeJTlsWhvUEmUG4tWEY2Spp5M20INYRg2ZKl9QPO3QEEgPeRtLjEWSW8FilRNacPOg8R1uaYkA6g==", + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.7.tgz", + "integrity": "sha512-ikktIhFBzQNt/QDyOL580ti9+5mL/YZeUPKU2ivGtGjdTYoqz6jObj6nOMfhASpS4GU4Q/Clh1QtxWAvcYKamA==", "cpu": [ "x64" ], @@ -12255,9 +12402,9 @@ } }, "node_modules/tsx/node_modules/@esbuild/win32-arm64": { - "version": "0.27.4", - "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.4.tgz", - "integrity": "sha512-Z9SExBg2y32smoDQdf1HRwHRt6vAHLXcxD2uGgO/v2jK7Y718Ix4ndsbNMU/+1Qiem9OiOdaqitioZwxivhXYg==", + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.7.tgz", + "integrity": "sha512-7yRhbHvPqSpRUV7Q20VuDwbjW5kIMwTHpptuUzV+AA46kiPze5Z7qgt6CLCK3pWFrHeNfDd1VKgyP4O+ng17CA==", "cpu": [ "arm64" ], @@ -12272,9 +12419,9 @@ } }, "node_modules/tsx/node_modules/@esbuild/win32-ia32": { - "version": "0.27.4", - "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.4.tgz", - "integrity": "sha512-DAyGLS0Jz5G5iixEbMHi5KdiApqHBWMGzTtMiJ72ZOLhbu/bzxgAe8Ue8CTS3n3HbIUHQz/L51yMdGMeoxXNJw==", + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.7.tgz", + "integrity": "sha512-SmwKXe6VHIyZYbBLJrhOoCJRB/Z1tckzmgTLfFYOfpMAx63BJEaL9ExI8x7v0oAO3Zh6D/Oi1gVxEYr5oUCFhw==", "cpu": [ "ia32" ], @@ -12289,9 +12436,9 @@ } }, "node_modules/tsx/node_modules/@esbuild/win32-x64": { - "version": "0.27.4", - "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.4.tgz", - "integrity": "sha512-+knoa0BDoeXgkNvvV1vvbZX4+hizelrkwmGJBdT17t8FNPwG2lKemmuMZlmaNQ3ws3DKKCxpb4zRZEIp3UxFCg==", + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.7.tgz", + "integrity": "sha512-56hiAJPhwQ1R4i+21FVF7V8kSD5zZTdHcVuRFMW0hn753vVfQN8xlx4uOPT4xoGH0Z/oVATuR82AiqSTDIpaHg==", "cpu": [ "x64" ], @@ -12306,9 +12453,9 @@ } }, "node_modules/tsx/node_modules/esbuild": { - "version": "0.27.4", - "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.4.tgz", - "integrity": "sha512-Rq4vbHnYkK5fws5NF7MYTU68FPRE1ajX7heQ/8QXXWqNgqqJ/GkmmyxIzUnf2Sr/bakf8l54716CcMGHYhMrrQ==", + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.7.tgz", + "integrity": "sha512-IxpibTjyVnmrIQo5aqNpCgoACA/dTKLTlhMHihVHhdkxKyPO1uBBthumT0rdHmcsk9uMonIWS0m4FljWzILh3w==", "dev": true, "hasInstallScript": true, "license": "MIT", @@ -12319,32 +12466,32 @@ "node": ">=18" }, "optionalDependencies": { - "@esbuild/aix-ppc64": "0.27.4", - "@esbuild/android-arm": "0.27.4", - "@esbuild/android-arm64": "0.27.4", - "@esbuild/android-x64": "0.27.4", - "@esbuild/darwin-arm64": "0.27.4", - "@esbuild/darwin-x64": "0.27.4", - "@esbuild/freebsd-arm64": "0.27.4", - "@esbuild/freebsd-x64": "0.27.4", - "@esbuild/linux-arm": "0.27.4", - "@esbuild/linux-arm64": "0.27.4", - "@esbuild/linux-ia32": "0.27.4", - "@esbuild/linux-loong64": "0.27.4", - "@esbuild/linux-mips64el": "0.27.4", - "@esbuild/linux-ppc64": "0.27.4", - "@esbuild/linux-riscv64": "0.27.4", - "@esbuild/linux-s390x": "0.27.4", - "@esbuild/linux-x64": "0.27.4", - "@esbuild/netbsd-arm64": "0.27.4", - "@esbuild/netbsd-x64": "0.27.4", - "@esbuild/openbsd-arm64": "0.27.4", - "@esbuild/openbsd-x64": "0.27.4", - "@esbuild/openharmony-arm64": "0.27.4", - "@esbuild/sunos-x64": "0.27.4", - "@esbuild/win32-arm64": "0.27.4", - "@esbuild/win32-ia32": "0.27.4", - "@esbuild/win32-x64": "0.27.4" + "@esbuild/aix-ppc64": "0.27.7", + "@esbuild/android-arm": "0.27.7", + "@esbuild/android-arm64": "0.27.7", + "@esbuild/android-x64": "0.27.7", + "@esbuild/darwin-arm64": "0.27.7", + "@esbuild/darwin-x64": "0.27.7", + "@esbuild/freebsd-arm64": "0.27.7", + "@esbuild/freebsd-x64": "0.27.7", + "@esbuild/linux-arm": "0.27.7", + "@esbuild/linux-arm64": "0.27.7", + "@esbuild/linux-ia32": "0.27.7", + "@esbuild/linux-loong64": "0.27.7", + "@esbuild/linux-mips64el": "0.27.7", + "@esbuild/linux-ppc64": "0.27.7", + "@esbuild/linux-riscv64": "0.27.7", + "@esbuild/linux-s390x": "0.27.7", + "@esbuild/linux-x64": "0.27.7", + "@esbuild/netbsd-arm64": "0.27.7", + "@esbuild/netbsd-x64": "0.27.7", + "@esbuild/openbsd-arm64": "0.27.7", + "@esbuild/openbsd-x64": "0.27.7", + "@esbuild/openharmony-arm64": "0.27.7", + "@esbuild/sunos-x64": "0.27.7", + "@esbuild/win32-arm64": "0.27.7", + "@esbuild/win32-ia32": "0.27.7", + "@esbuild/win32-x64": "0.27.7" } }, "node_modules/tween-functions": { @@ -12471,16 +12618,16 @@ } }, "node_modules/typescript-eslint": { - "version": "8.57.2", - "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.57.2.tgz", - "integrity": "sha512-VEPQ0iPgWO/sBaZOU1xo4nuNdODVOajPnTIbog2GKYr31nIlZ0fWPoCQgGfF3ETyBl1vn63F/p50Um9Z4J8O8A==", + "version": "8.59.3", + "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.59.3.tgz", + "integrity": "sha512-KgusgyDgG4LI8Ih/sWaCtZ06tckLAS5CvT5A4D1Q7bYVoAAyzwiZvE4BmwDHkhRVkvhRBepKeASoFzQetha7Fg==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/eslint-plugin": "8.57.2", - "@typescript-eslint/parser": "8.57.2", - "@typescript-eslint/typescript-estree": "8.57.2", - "@typescript-eslint/utils": "8.57.2" + "@typescript-eslint/eslint-plugin": "8.59.3", + "@typescript-eslint/parser": "8.59.3", + "@typescript-eslint/typescript-estree": "8.59.3", + "@typescript-eslint/utils": "8.59.3" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -12491,7 +12638,7 @@ }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", - "typescript": ">=4.8.4 <6.0.0" + "typescript": ">=4.8.4 <6.1.0" } }, "node_modules/unbox-primitive": { @@ -12514,9 +12661,9 @@ } }, "node_modules/undici": { - "version": "7.24.6", - "resolved": "https://registry.npmjs.org/undici/-/undici-7.24.6.tgz", - "integrity": "sha512-Xi4agocCbRzt0yYMZGMA6ApD7gvtUFaxm4ZmeacWI4cZxaF6C+8I8QfofC20NAePiB/IcvZmzkJ7XPa471AEtA==", + "version": "7.25.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-7.25.0.tgz", + "integrity": "sha512-xXnp4kTyor2Zq+J1FfPI6Eq3ew5h6Vl0F/8d9XU5zZQf1tX9s2Su1/3PiMmUANFULpmksxkClamIZcaUqryHsQ==", "dev": true, "license": "MIT", "engines": { @@ -12524,9 +12671,9 @@ } }, "node_modules/undici-types": { - "version": "7.18.2", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz", - "integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==", + "version": "7.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.21.0.tgz", + "integrity": "sha512-w9IMgQrz4O0YN1LtB7K5P63vhlIOvC7opSmouCJ+ZywlPAlO9gIkJ+otk6LvGpAs2wg4econaCz3TvQ9xPoyuQ==", "dev": true, "license": "MIT" }, @@ -12648,6 +12795,7 @@ "version": "9.0.1", "resolved": "https://registry.npmjs.org/uuid/-/uuid-9.0.1.tgz", "integrity": "sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==", + "deprecated": "uuid@10 and below is no longer supported. For ESM codebases, update to uuid@latest. For CommonJS codebases, use uuid@11 (but be aware this version will likely be deprecated in 2028).", "dev": true, "funding": [ "https://github.com/sponsors/broofa", @@ -12659,9 +12807,9 @@ } }, "node_modules/vite": { - "version": "6.4.1", - "resolved": "https://registry.npmjs.org/vite/-/vite-6.4.1.tgz", - "integrity": "sha512-+Oxm7q9hDoLMyJOYfUYBuHQo+dkAloi33apOPP56pzj+vsdJDzr+j1NISE5pyaAuKL4A3UD34qd0lx5+kfKp2g==", + "version": "6.4.2", + "resolved": "https://registry.npmjs.org/vite/-/vite-6.4.2.tgz", + "integrity": "sha512-2N/55r4JDJ4gdrCvGgINMy+HH3iRpNIz8K6SFwVsA+JbQScLiC+clmAxBgwiSPgcG9U15QmvqCGWzMbqda5zGQ==", "dev": true, "license": "MIT", "dependencies": { @@ -12752,19 +12900,19 @@ } }, "node_modules/vitest": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.2.tgz", - "integrity": "sha512-xjR1dMTVHlFLh98JE3i/f/WePqJsah4A0FK9cc8Ehp9Udk0AZk6ccpIZhh1qJ/yxVWRZ+Q54ocnD8TXmkhspGg==", + "version": "4.1.6", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.6.tgz", + "integrity": "sha512-6lvjbS3p9b4CrdCmguzbh2/4uoXhGE2q71R4OX5sqF9R1bo9Xd6fGrMAfvp5wnCzlBnFVdCOp6onuTQVbo8iUQ==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/expect": "4.1.2", - "@vitest/mocker": "4.1.2", - "@vitest/pretty-format": "4.1.2", - "@vitest/runner": "4.1.2", - "@vitest/snapshot": "4.1.2", - "@vitest/spy": "4.1.2", - "@vitest/utils": "4.1.2", + "@vitest/expect": "4.1.6", + "@vitest/mocker": "4.1.6", + "@vitest/pretty-format": "4.1.6", + "@vitest/runner": "4.1.6", + "@vitest/snapshot": "4.1.6", + "@vitest/spy": "4.1.6", + "@vitest/utils": "4.1.6", "es-module-lexer": "^2.0.0", "expect-type": "^1.3.0", "magic-string": "^0.30.21", @@ -12792,10 +12940,12 @@ "@edge-runtime/vm": "*", "@opentelemetry/api": "^1.9.0", "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", - "@vitest/browser-playwright": "4.1.2", - "@vitest/browser-preview": "4.1.2", - "@vitest/browser-webdriverio": "4.1.2", - "@vitest/ui": "4.1.2", + "@vitest/browser-playwright": "4.1.6", + "@vitest/browser-preview": "4.1.6", + "@vitest/browser-webdriverio": "4.1.6", + "@vitest/coverage-istanbul": "4.1.6", + "@vitest/coverage-v8": "4.1.6", + "@vitest/ui": "4.1.6", "happy-dom": "*", "jsdom": "*", "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" @@ -12819,6 +12969,12 @@ "@vitest/browser-webdriverio": { "optional": true }, + "@vitest/coverage-istanbul": { + "optional": true + }, + "@vitest/coverage-v8": { + "optional": true + }, "@vitest/ui": { "optional": true }, @@ -12834,16 +12990,16 @@ } }, "node_modules/vitest/node_modules/@vitest/expect": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.2.tgz", - "integrity": "sha512-gbu+7B0YgUJ2nkdsRJrFFW6X7NTP44WlhiclHniUhxADQJH5Szt9mZ9hWnJPJ8YwOK5zUOSSlSvyzRf0u1DSBQ==", + "version": "4.1.6", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.6.tgz", + "integrity": "sha512-7EHDquPthALSV0jhhjgEW8FXaviMx7rSqu8W6oqCoAuOhKov814P99QDV1pxMA3QPv21YudvJngIhjrNI4opLg==", "dev": true, "license": "MIT", "dependencies": { "@standard-schema/spec": "^1.1.0", "@types/chai": "^5.2.2", - "@vitest/spy": "4.1.2", - "@vitest/utils": "4.1.2", + "@vitest/spy": "4.1.6", + "@vitest/utils": "4.1.6", "chai": "^6.2.2", "tinyrainbow": "^3.1.0" }, @@ -12852,9 +13008,9 @@ } }, "node_modules/vitest/node_modules/@vitest/pretty-format": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.2.tgz", - "integrity": "sha512-dwQga8aejqeuB+TvXCMzSQemvV9hNEtDDpgUKDzOmNQayl2OG241PSWeJwKRH3CiC+sESrmoFd49rfnq7T4RnA==", + "version": "4.1.6", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.6.tgz", + "integrity": "sha512-h5SxD/IzNhZYnrSZRsUZQIC+vD0GY8cUvq0iwsmkFKixRCKLLWqCXa/FIQ4S1R+sI+PGoojkHsdNrbZiM9Qpgw==", "dev": true, "license": "MIT", "dependencies": { @@ -12865,9 +13021,9 @@ } }, "node_modules/vitest/node_modules/@vitest/spy": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.2.tgz", - "integrity": "sha512-DU4fBnbVCJGNBwVA6xSToNXrkZNSiw59H8tcuUspVMsBDBST4nfvsPsEHDHGtWRRnqBERBQu7TrTKskmjqTXKA==", + "version": "4.1.6", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.6.tgz", + "integrity": "sha512-JFKxMx6udhwKh/Ldo270e17QX710vgunMkuPAvXjHSvC6oqLWAHhVhjg/I71q0u0CBSErIODV1Kjv0FQNSWjdg==", "dev": true, "license": "MIT", "funding": { @@ -12875,13 +13031,13 @@ } }, "node_modules/vitest/node_modules/@vitest/utils": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.2.tgz", - "integrity": "sha512-xw2/TiX82lQHA06cgbqRKFb5lCAy3axQ4H4SoUFhUsg+wztiet+co86IAMDtF6Vm1hc7J6j09oh/rgDn+JdKIQ==", + "version": "4.1.6", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.6.tgz", + "integrity": "sha512-FxIY+U81R3LGKCxaHHFRQ5+g6/iRgGLmeHWdp2Amj4ljQRrEIWHmZyDfDYBRZlpyqA7qKxtS9DD1dhk8RnRIVQ==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/pretty-format": "4.1.2", + "@vitest/pretty-format": "4.1.6", "convert-source-map": "^2.0.0", "tinyrainbow": "^3.1.0" }, @@ -13133,10 +13289,9 @@ } }, "node_modules/wrap-ansi": { - "version": "6.2.0", - "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz", - "integrity": "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==", - "dev": true, + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", "license": "MIT", "dependencies": { "ansi-styles": "^4.0.0", @@ -13144,7 +13299,10 @@ "strip-ansi": "^6.0.0" }, "engines": { - "node": ">=8" + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" } }, "node_modules/wrap-ansi-cjs": { @@ -13183,7 +13341,6 @@ "version": "6.0.1", "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", - "dev": true, "license": "MIT", "dependencies": { "ansi-regex": "^5.0.1" @@ -13207,9 +13364,9 @@ } }, "node_modules/ws": { - "version": "8.20.0", - "resolved": "https://registry.npmjs.org/ws/-/ws-8.20.0.tgz", - "integrity": "sha512-sAt8BhgNbzCtgGbt2OxmpuryO63ZoDk/sqaB/znQm94T4fCEsy/yV+7CdC1kJhOU9lboAEU7R3kquuycDoibVA==", + "version": "8.20.1", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.20.1.tgz", + "integrity": "sha512-It4dO0K5v//JtTXuPkfEOaI3uUN87iYPnqo/ZzqCoG3g8uhA66QUMs/SrM0YK7/NAu+r4LMh/9dq2A7k+rHs+w==", "dev": true, "license": "MIT", "engines": { @@ -13300,19 +13457,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/yoctocolors-cjs": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/yoctocolors-cjs/-/yoctocolors-cjs-2.1.3.tgz", - "integrity": "sha512-U/PBtDf35ff0D8X8D0jfdzHYEPFxAI7jJlxZXwCSez5M3190m+QobIfh+sWDWSHMCWWJN2AWamkegn6vr6YBTw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/zen-observable": { "version": "0.10.0", "resolved": "https://registry.npmjs.org/zen-observable/-/zen-observable-0.10.0.tgz", diff --git a/package.json b/package.json index 566a8671..b117af3b 100644 --- a/package.json +++ b/package.json @@ -81,7 +81,7 @@ "eslint-plugin-react-hooks": "^5.1.0", "eslint-plugin-react-refresh": "^0.4.14", "eslint-plugin-storybook": "^0.11.1", - "eslint-plugin-tailwindcss": "^3.17.5", + "eslint-plugin-tailwindcss": "3.18.2", "glob": "^13.0.0", "globals": "^17.3.0", "jsdom": "^28.0.0", diff --git a/public/mockServiceWorker.js b/public/mockServiceWorker.js index b17fcd65..33dde9e7 100644 --- a/public/mockServiceWorker.js +++ b/public/mockServiceWorker.js @@ -7,7 +7,7 @@ * - Please do NOT modify this file. */ -const PACKAGE_VERSION = '2.12.14' +const PACKAGE_VERSION = '2.14.6' const INTEGRITY_CHECKSUM = '4db4a41e972cec1b64cc569c66952d82' const IS_MOCKED_RESPONSE = Symbol('isMockedResponse') const activeClientIds = new Set() diff --git a/src/apis/apis/share.ts b/src/apis/apis/share.ts new file mode 100644 index 00000000..b9080a36 --- /dev/null +++ b/src/apis/apis/share.ts @@ -0,0 +1,12 @@ +import { request } from '../primitives'; +import { ApiUrl } from '../endpoints'; +import { GetChairmanTokenResponseType } from '../responses/share'; + +export const getChairmanToken = (tableId: string) => { + return request( + 'GET', + `${ApiUrl.share}/${encodeURIComponent(tableId)}/chairman-token`, + null, + null, + ); +}; diff --git a/src/apis/endpoints.ts b/src/apis/endpoints.ts index 5469c3d1..ea1fbbb3 100644 --- a/src/apis/endpoints.ts +++ b/src/apis/endpoints.ts @@ -12,4 +12,5 @@ export const ApiUrl = { customize: makeUrl('/table/customize'), poll: makeUrl('/polls'), organization: makeUrl('/organizations'), + share: makeUrl('/share'), }; diff --git a/src/apis/responses/share.ts b/src/apis/responses/share.ts new file mode 100644 index 00000000..62ffe0cd --- /dev/null +++ b/src/apis/responses/share.ts @@ -0,0 +1,3 @@ +export interface GetChairmanTokenResponseType { + chairmanToken: string; +} diff --git a/src/apis/sockets/SocketManager.test.ts b/src/apis/sockets/SocketManager.test.ts index 144977d7..b3d5eeee 100644 --- a/src/apis/sockets/SocketManager.test.ts +++ b/src/apis/sockets/SocketManager.test.ts @@ -2,6 +2,7 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import { socketManager } from './SocketManager'; import type { IFrame, StompConfig } from '@stomp/stompjs'; import { SocketMessage } from './type'; +import SockJS from 'sockjs-client'; // ------------------------------------------------------------------ // 1. 외부 라이브러리 Mocking @@ -97,6 +98,27 @@ describe('SocketManager', () => { expect(getLatestClient().activate).toHaveBeenCalledOnce(); }); + it('connect 옵션의 url을 SockJS 연결 주소로 사용해야 한다', () => { + socketManager.connect({ url: 'https://socket.example.com/ws' }); + getLatestClient().config.webSocketFactory?.(); + + expect(SockJS).toHaveBeenCalledWith('https://socket.example.com/ws'); + }); + + it('connect 옵션의 baseUrl을 /ws 경로와 조합해 SockJS 연결 주소로 사용해야 한다', () => { + socketManager.connect({ baseUrl: 'https://socket.example.com' }); + getLatestClient().config.webSocketFactory?.(); + + expect(SockJS).toHaveBeenCalledWith('https://socket.example.com/ws'); + }); + + it('connect 옵션에 url과 baseUrl이 없으면 환경 변수 기반 주소를 사용해야 한다', () => { + socketManager.connect(); + getLatestClient().config.webSocketFactory?.(); + + expect(SockJS).toHaveBeenCalledWith('https://test.com/ws'); + }); + it('이미 연결된 상태에서 connect를 재호출하면 중복 연결을 방지해야 한다', () => { socketManager.connect(); socketManager.connect(); diff --git a/src/apis/sockets/SocketManager.ts b/src/apis/sockets/SocketManager.ts index 14c7df47..775068f7 100644 --- a/src/apis/sockets/SocketManager.ts +++ b/src/apis/sockets/SocketManager.ts @@ -10,6 +10,12 @@ import { SocketMessage } from './type'; * - `heartbeatOutMs` 발신 하트비트 주기 (기본값 10000 ms) */ export interface SocketOptions { + /** SockJS 연결에 사용할 전체 URL. 지정하면 baseUrl보다 우선합니다. */ + url?: string; + + /** SockJS 연결에 사용할 API base URL. 지정하면 `${baseUrl}/ws`로 연결합니다. */ + baseUrl?: string; + /** 최대 재시도 횟수 (기본값: 3) */ maxRetries?: number; @@ -24,7 +30,16 @@ export interface SocketOptions { } // 기본값 객체 선언 (onConnect를 제외한 모든 필수 속성 정의) -const DEFAULT_OPTIONS: Required = { +type ResolvedSocketOptions = { + url?: string; + baseUrl?: string; + maxRetries: number; + baseRetryDelayMs: number; + heartbeatInMs: number; + heartbeatOutMs: number; +}; + +const DEFAULT_OPTIONS: ResolvedSocketOptions = { maxRetries: 3, baseRetryDelayMs: 1000, heartbeatInMs: 10000, @@ -36,7 +51,7 @@ class SocketManager { // 변수 private client: Client | null = null; private static instance: SocketManager; - private currentOptions = DEFAULT_OPTIONS; // 소켓 설정을 저장하는 변수 + private currentOptions: ResolvedSocketOptions = DEFAULT_OPTIONS; // 소켓 설정을 저장하는 변수 private retryCount: number = 0; // 싱글톤 패턴 적용 @@ -99,13 +114,13 @@ class SocketManager { this.retryCount = 0; this.currentOptions = { ...DEFAULT_OPTIONS, ...options }; - // 환경 변수에서 URL 로드 - const baseUrl = import.meta.env.VITE_API_BASE_URL; - if (!baseUrl) { - console.error('VITE_API_BASE_URL 환경 변수가 설정되지 않았습니다.'); + const wsUrl = this.resolveWebSocketUrl(this.currentOptions); + if (!wsUrl) { + console.error( + '웹소켓 연결 주소를 결정할 수 없습니다. url 또는 baseUrl 옵션, 혹은 VITE_API_BASE_URL 환경 변수를 확인해주세요.', + ); return; } - const wsUrl = baseUrl + '/ws'; const newClient = new Client({ // wss:// 대신 https:// 주소를 SockJS 팩토리에 주입 @@ -265,6 +280,20 @@ class SocketManager { // 계산한 값에 10 ms를 추가 return Math.floor(Math.random() * maxExponentialDelay) + 10; } + + private resolveWebSocketUrl(options: SocketOptions): string | null { + if (options.url) { + return options.url; + } + + const baseUrl = options.baseUrl ?? import.meta.env.VITE_API_BASE_URL; + if (!baseUrl) { + return null; + } + + const normalizedBaseUrl = baseUrl.replace(/\/+$/, ''); + return `${normalizedBaseUrl}/ws`; + } } // 애플리케이션 전역에서 사용할 수 있도록 싱글톤 함수만 노출 diff --git a/src/apis/sockets/util.ts b/src/apis/sockets/util.ts new file mode 100644 index 00000000..b1d23189 --- /dev/null +++ b/src/apis/sockets/util.ts @@ -0,0 +1,24 @@ +import { NonTimerEventType, SocketEventType, TimerEventTypes } from './type'; + +const TIMER_EVENT_TYPES: TimerEventTypes[] = [ + 'NEXT', + 'STOP', + 'BEFORE', + 'PLAY', + 'RESET', + 'TEAM_SWITCH', +]; + +const NON_TIMER_EVENT_TYPES: NonTimerEventType[] = ['FINISHED', 'ERROR']; + +export function isTimerEventType( + event: SocketEventType, +): event is TimerEventTypes { + return TIMER_EVENT_TYPES.includes(event as TimerEventTypes); +} + +export function isNonTimerEventType( + event: SocketEventType, +): event is NonTimerEventType { + return NON_TIMER_EVENT_TYPES.includes(event as NonTimerEventType); +} diff --git a/src/components/icons/Icon.stories.tsx b/src/components/icons/Icon.stories.tsx index aa1a5668..ea391e59 100644 --- a/src/components/icons/Icon.stories.tsx +++ b/src/components/icons/Icon.stories.tsx @@ -18,6 +18,7 @@ import DTShare from './Share'; import DTExchange from './Exchange'; import DTBell from './Bell'; import DTVolume from './Volume'; +import DTShareLive from './ShareLive'; const meta: Meta = { title: 'Design System/Icons', @@ -269,3 +270,15 @@ export const OnVolume: Story = { ), }; + +export const OnShareLive: Story = { + args: { + color: '#FECD4C', + }, + render: (args) => ( +
+ +

라이브로 공유

+
+ ), +}; diff --git a/src/components/icons/ShareLive.tsx b/src/components/icons/ShareLive.tsx new file mode 100644 index 00000000..71395d66 --- /dev/null +++ b/src/components/icons/ShareLive.tsx @@ -0,0 +1,44 @@ +import { IconProps } from './IconProps'; + +export default function DTShareLive({ + color = 'currentColor', + className = '', + ...props +}: IconProps) { + return ( + + + + + + + ); +} diff --git a/src/hooks/query/useGetChairmanToken.ts b/src/hooks/query/useGetChairmanToken.ts new file mode 100644 index 00000000..0d5807cd --- /dev/null +++ b/src/hooks/query/useGetChairmanToken.ts @@ -0,0 +1,20 @@ +import { useQuery } from '@tanstack/react-query'; +import { getChairmanToken } from '../../apis/apis/share'; + +export const chairmanTokenQueryKey = (tableId: string) => + ['chairmanToken', tableId] as const; + +export default function useGetChairmanToken( + tableId: string, + enabled: boolean = false, +) { + return useQuery({ + queryKey: chairmanTokenQueryKey(tableId), + queryFn: async () => { + const { data } = await getChairmanToken(tableId); + return data.chairmanToken; + }, + enabled: enabled && Boolean(tableId), + staleTime: Infinity, + }); +} diff --git a/src/hooks/sockets/useAudienceSocket.test.ts b/src/hooks/sockets/useAudienceSocket.test.ts new file mode 100644 index 00000000..7f8d74a0 --- /dev/null +++ b/src/hooks/sockets/useAudienceSocket.test.ts @@ -0,0 +1,196 @@ +import { act, renderHook } from '@testing-library/react'; +import type { IMessage } from '@stomp/stompjs'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import type { SocketMessage } from '../../apis/sockets/type'; +import useAudienceSocket from './useAudienceSocket'; + +const useSocketMock = vi.hoisted(() => vi.fn()); + +vi.mock('./useSocket', () => ({ + default: useSocketMock, +})); + +describe('useAudienceSocket', () => { + const connect = vi.fn(); + const disconnect = vi.fn(); + const subscribe = vi.fn(); + const unsubscribe = vi.fn(); + const addConnectionListener = vi.fn(); + + beforeEach(() => { + vi.clearAllMocks(); + vi.spyOn(window, 'alert').mockImplementation(() => undefined); + addConnectionListener.mockImplementation(() => vi.fn()); + useSocketMock.mockReturnValue({ + connect, + disconnect, + subscribe, + unsubscribe, + addConnectionListener, + error: null, + }); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('마운트 시 roomId 기반 채널을 구독해야 한다', () => { + renderHook(() => useAudienceSocket(123)); + + expect(subscribe).toHaveBeenCalledWith('/room/123', expect.any(Function)); + }); + + it('메시지를 수신하면 messages 상태를 업데이트해야 한다', () => { + const message: SocketMessage = { + eventType: 'FINISHED', + data: null, + }; + let handleMessage: (message: IMessage) => void = () => undefined; + subscribe.mockImplementation( + (_destination: string, callback: (message: IMessage) => void) => { + handleMessage = callback; + }, + ); + + const { result } = renderHook(() => useAudienceSocket(123)); + + act(() => { + handleMessage({ body: JSON.stringify(message) } as IMessage); + }); + + expect(result.current.messages).toEqual([message]); + }); + + it('connect 호출 전에 기존 messages 상태를 초기화해야 한다', () => { + const message: SocketMessage = { + eventType: 'FINISHED', + data: null, + }; + const options = { baseUrl: 'https://api.example.com' }; + let handleMessage: (message: IMessage) => void = () => undefined; + subscribe.mockImplementation( + (_destination: string, callback: (message: IMessage) => void) => { + handleMessage = callback; + }, + ); + + const { result } = renderHook(() => useAudienceSocket(123)); + + act(() => { + handleMessage({ body: JSON.stringify(message) } as IMessage); + }); + + act(() => { + result.current.connect(options); + }); + + expect(result.current.messages).toEqual([]); + expect(connect).toHaveBeenCalledWith(options); + }); + + it('disconnect 호출 시 messages 상태를 초기화해야 한다', () => { + const message: SocketMessage = { + eventType: 'FINISHED', + data: null, + }; + let handleMessage: (message: IMessage) => void = () => undefined; + subscribe.mockImplementation( + (_destination: string, callback: (message: IMessage) => void) => { + handleMessage = callback; + }, + ); + + const { result } = renderHook(() => useAudienceSocket(123)); + + act(() => { + handleMessage({ body: JSON.stringify(message) } as IMessage); + }); + + act(() => { + result.current.disconnect(); + }); + + expect(result.current.messages).toEqual([]); + expect(disconnect).toHaveBeenCalledOnce(); + }); + + it('roomId가 변경되면 기존 messages 상태를 초기화해야 한다', () => { + const message: SocketMessage = { + eventType: 'FINISHED', + data: null, + }; + const callbacks = new Map void>(); + subscribe.mockImplementation( + (destination: string, callback: (message: IMessage) => void) => { + callbacks.set(destination, callback); + }, + ); + + const { result, rerender } = renderHook( + ({ roomId }) => useAudienceSocket(roomId), + { initialProps: { roomId: 123 } }, + ); + + act(() => { + callbacks.get('/room/123')?.({ + body: JSON.stringify(message), + } as IMessage); + }); + + act(() => { + rerender({ roomId: 456 }); + }); + + expect(result.current.messages).toEqual([]); + expect(unsubscribe).toHaveBeenCalledWith('/room/123'); + expect(subscribe).toHaveBeenCalledWith('/room/456', expect.any(Function)); + }); + + it('소켓 재연결 이벤트가 발생하면 messages 상태를 초기화해야 한다', () => { + const message: SocketMessage = { + eventType: 'FINISHED', + data: null, + }; + let handleMessage: (message: IMessage) => void = () => undefined; + let handleConnection: () => void = () => undefined; + addConnectionListener.mockImplementation((listener: () => void) => { + handleConnection = listener; + return vi.fn(); + }); + subscribe.mockImplementation( + (_destination: string, callback: (message: IMessage) => void) => { + handleMessage = callback; + }, + ); + + const { result } = renderHook(() => useAudienceSocket(123)); + + act(() => { + handleMessage({ body: JSON.stringify(message) } as IMessage); + }); + + act(() => { + handleConnection(); + }); + + expect(result.current.messages).toEqual([]); + }); + + it('error가 발생하면 alert를 호출하고 소켓 연결을 해제해야 한다', () => { + const error = new Error('socket failure'); + useSocketMock.mockReturnValue({ + connect, + disconnect, + subscribe, + unsubscribe, + addConnectionListener, + error, + }); + + renderHook(() => useAudienceSocket(123)); + + expect(window.alert).toHaveBeenCalledWith('socket failure'); + expect(disconnect).toHaveBeenCalledOnce(); + }); +}); diff --git a/src/hooks/sockets/useAudienceSocket.ts b/src/hooks/sockets/useAudienceSocket.ts new file mode 100644 index 00000000..4050211b --- /dev/null +++ b/src/hooks/sockets/useAudienceSocket.ts @@ -0,0 +1,106 @@ +import { useCallback, useEffect, useState } from 'react'; +import { IMessage } from '@stomp/stompjs'; +import useSocket from './useSocket'; +import { SocketMessage } from '../../apis/sockets/type'; + +/** + * 청중 전용 웹소켓 훅입니다. + * 토론 이벤트 채널을 구독하여 실시간 토론 정보를 수령합니다. + * 모든 이벤트는 리스트에 누적되어 저장됩니다. + * + * * ⚠️ 주의: 이 훅을 컴포넌트에서 호출하여 페이지가 마운트(렌더링)되는 순간, + * 내부의 `useEffect`가 실행되어 즉시 `/room/{roomId}` 채널에 대한 구독(Subscribe)을 요청합니다. + * 컴포넌트가 언마운트되면 해당 채널의 구독은 자동으로 해제됩니다. + * * 청중은 토론 데이터를 수신만 하며, 송신(Publish) 권한은 제공되지 않습니다. + * + * @param {number} roomId - 입장한 토론방의 고유 ID + * @returns {Object} 청중 소켓 상태와 제어 함수를 반환합니다. + * @returns {SocketMessage[]} returns.messages - 현재 소켓 세션에서 수신한 메시지 목록입니다. + * @returns {Function} returns.connect - `useSocket.connect`에 위임하기 전에 현재 메시지를 초기화합니다. + * @returns {Function} returns.disconnect - `useSocket.disconnect`에 위임하기 전에 현재 메시지를 초기화합니다. + * @returns {Error | null} returns.error - 가장 최근에 발생한 소켓 오류입니다. + */ +export default function useAudienceSocket(roomId: number) { + const { + connect, + disconnect, + subscribe, + unsubscribe, + addConnectionListener, + error, + } = useSocket(); + const [messages, setMessages] = useState([]); + + /** + * 현재 소켓 세션에서 누적된 청중 메시지를 모두 초기화합니다. + * 세션 간에 오래된 메시지가 남지 않도록, 래핑된 connect 및 disconnect + * 제어 함수에서 사용하는 초기화 동작을 한곳에 모읍니다. + */ + const resetMessages = useCallback(() => { + setMessages([]); + }, []); + + /** + * 이전에 수신한 메시지를 초기화한 뒤 청중 소켓을 시작합니다. + * 새 연결이 이전 소켓 세션의 데이터를 재사용하지 않고 빈 메시지 목록으로 + * 시작되도록 `useSocket.connect`를 래핑합니다. + */ + const connectAudienceSocket = useCallback( + (options?: Parameters[0]) => { + resetMessages(); + connect(options); + }, + [connect, resetMessages], + ); + + /** + * 현재 메시지 목록을 초기화한 뒤 청중 소켓을 종료합니다. + * 수동으로 연결을 해제할 때도 React 상태에 남아 있을 수 있는 세션 단위 + * 메시지를 제거하기 위해 `useSocket.disconnect`를 래핑합니다. + */ + const disconnectAudienceSocket = useCallback(() => { + resetMessages(); + disconnect(); + }, [disconnect, resetMessages]); + + useEffect(() => { + return addConnectionListener(resetMessages); + }, [addConnectionListener, resetMessages]); + + useEffect(() => { + if (!error) { + return; + } + + window.alert(error.message); + disconnectAudienceSocket(); + }, [disconnectAudienceSocket, error]); + + useEffect(() => { + const destination = `/room/${roomId}`; + + resetMessages(); + + // 토론 이벤트를 발행하는 채널 구독 및 메시지 수신 시 상태 업데이트 + subscribe(destination, (message: IMessage) => { + try { + const parsedData = JSON.parse(message.body); + setMessages((prev) => [...prev, parsedData]); + } catch (e) { + console.error('메시지 파싱 오류:', e); + } + }); + + // 컴포넌트 언마운트 또는 roomId 변경 시 해당 채널 구독 해제 + return () => { + unsubscribe(destination); + }; + }, [roomId, resetMessages, subscribe, unsubscribe]); + + return { + messages, + connect: connectAudienceSocket, + disconnect: disconnectAudienceSocket, + error, + }; +} diff --git a/src/hooks/sockets/useChairmanSocket.test.ts b/src/hooks/sockets/useChairmanSocket.test.ts new file mode 100644 index 00000000..3ead249a --- /dev/null +++ b/src/hooks/sockets/useChairmanSocket.test.ts @@ -0,0 +1,276 @@ +import { act, renderHook } from '@testing-library/react'; +import type { IMessage } from '@stomp/stompjs'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import type { TimerDataPayload } from '../../apis/sockets/type'; +import useChairmanSocket from './useChairmanSocket'; + +const useSocketMock = vi.hoisted(() => vi.fn()); +const removeQueries = vi.hoisted(() => vi.fn()); + +vi.mock('@tanstack/react-query', () => ({ + useQueryClient: () => ({ + removeQueries, + }), +})); + +vi.mock('./useSocket', () => ({ + default: useSocketMock, +})); + +describe('useChairmanSocket', () => { + const connect = vi.fn(); + const disconnect = vi.fn(); + const subscribe = vi.fn(); + const unsubscribe = vi.fn(); + const publish = vi.fn(); + const addConnectionListener = vi.fn(); + + beforeEach(() => { + vi.clearAllMocks(); + addConnectionListener.mockImplementation(() => vi.fn()); + useSocketMock.mockReturnValue({ + connect, + disconnect, + subscribe, + unsubscribe, + publish, + addConnectionListener, + error: null, + }); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('마운트 시 roomId 기반 사회자 채널을 구독해야 한다', () => { + renderHook(() => useChairmanSocket(123)); + + expect(subscribe).toHaveBeenCalledWith( + '/chairman/123', + expect.any(Function), + ); + }); + + it('신호를 수신하면 signalCount와 lastSignalTime을 업데이트해야 한다', () => { + const now = 1710000000000; + let handleMessage: (message: IMessage) => void = () => undefined; + vi.spyOn(Date, 'now').mockReturnValue(now); + subscribe.mockImplementation( + (_destination: string, callback: (message: IMessage) => void) => { + handleMessage = callback; + }, + ); + + const { result } = renderHook(() => useChairmanSocket(123)); + + act(() => { + handleMessage({ body: '' } as IMessage); + }); + + expect(result.current.signalCount).toBe(1); + expect(result.current.lastSignalTime).toBe(now); + }); + + it('connect 호출 전에 signalCount와 lastSignalTime을 초기화해야 한다', () => { + const now = 1710000000000; + const options = { baseUrl: 'https://api.example.com' }; + let handleMessage: (message: IMessage) => void = () => undefined; + vi.spyOn(Date, 'now').mockReturnValue(now); + subscribe.mockImplementation( + (_destination: string, callback: (message: IMessage) => void) => { + handleMessage = callback; + }, + ); + + const { result } = renderHook(() => useChairmanSocket(123)); + + act(() => { + handleMessage({ body: '' } as IMessage); + }); + + act(() => { + result.current.connect(options); + }); + + expect(result.current.signalCount).toBe(0); + expect(result.current.lastSignalTime).toBeNull(); + expect(connect).toHaveBeenCalledWith(options); + }); + + it('disconnect 호출 시 signalCount와 lastSignalTime을 초기화해야 한다', () => { + const now = 1710000000000; + let handleMessage: (message: IMessage) => void = () => undefined; + vi.spyOn(Date, 'now').mockReturnValue(now); + subscribe.mockImplementation( + (_destination: string, callback: (message: IMessage) => void) => { + handleMessage = callback; + }, + ); + + const { result } = renderHook(() => useChairmanSocket(123)); + + act(() => { + handleMessage({ body: '' } as IMessage); + }); + + act(() => { + result.current.disconnect(); + }); + + expect(result.current.signalCount).toBe(0); + expect(result.current.lastSignalTime).toBeNull(); + expect(disconnect).toHaveBeenCalledOnce(); + expect(removeQueries).toHaveBeenCalledWith({ + queryKey: ['chairmanToken', '123'], + exact: true, + }); + }); + + it('roomId가 변경되면 signalCount와 lastSignalTime을 초기화해야 한다', () => { + const now = 1710000000000; + const callbacks = new Map void>(); + vi.spyOn(Date, 'now').mockReturnValue(now); + subscribe.mockImplementation( + (destination: string, callback: (message: IMessage) => void) => { + callbacks.set(destination, callback); + }, + ); + + const { result, rerender } = renderHook( + ({ roomId }) => useChairmanSocket(roomId), + { initialProps: { roomId: 123 } }, + ); + + act(() => { + callbacks.get('/chairman/123')?.({ body: '' } as IMessage); + }); + + act(() => { + rerender({ roomId: 456 }); + }); + + expect(result.current.signalCount).toBe(0); + expect(result.current.lastSignalTime).toBeNull(); + expect(unsubscribe).toHaveBeenCalledWith('/chairman/123'); + expect(subscribe).toHaveBeenCalledWith( + '/chairman/456', + expect.any(Function), + ); + }); + + it('error가 발생하면 signalCount와 lastSignalTime을 초기화해야 한다', () => { + const now = 1710000000000; + const error = new Error('socket failure'); + let handleMessage: (message: IMessage) => void = () => undefined; + vi.spyOn(Date, 'now').mockReturnValue(now); + vi.spyOn(console, 'error').mockImplementation(() => undefined); + subscribe.mockImplementation( + (_destination: string, callback: (message: IMessage) => void) => { + handleMessage = callback; + }, + ); + + const { result, rerender } = renderHook(() => useChairmanSocket(123)); + + act(() => { + handleMessage({ body: '' } as IMessage); + }); + + useSocketMock.mockReturnValue({ + connect, + disconnect, + subscribe, + unsubscribe, + publish, + addConnectionListener, + error, + }); + + act(() => { + rerender(); + }); + + expect(result.current.signalCount).toBe(0); + expect(result.current.lastSignalTime).toBeNull(); + expect(removeQueries).not.toHaveBeenCalled(); + }); + + it('소켓 재연결 이벤트가 발생하면 signalCount와 lastSignalTime을 초기화해야 한다', () => { + const now = 1710000000000; + let handleMessage: (message: IMessage) => void = () => undefined; + let handleConnection: () => void = () => undefined; + vi.spyOn(Date, 'now').mockReturnValue(now); + addConnectionListener.mockImplementation((listener: () => void) => { + handleConnection = listener; + return vi.fn(); + }); + subscribe.mockImplementation( + (_destination: string, callback: (message: IMessage) => void) => { + handleMessage = callback; + }, + ); + + const { result } = renderHook(() => useChairmanSocket(123)); + + act(() => { + handleMessage({ body: '' } as IMessage); + }); + + act(() => { + handleConnection(); + }); + + expect(result.current.signalCount).toBe(0); + expect(result.current.lastSignalTime).toBeNull(); + expect(removeQueries).not.toHaveBeenCalled(); + }); + + it('sendDebateEvent 호출 시 payload와 Authorization 헤더를 함께 publish해야 한다', () => { + const payload: TimerDataPayload = { + timerType: 'NORMAL', + currentTeam: 'PROS', + remainingTime: 30, + sequence: 1, + }; + const authToken = 'temporary-chairman-token'; + + const { result } = renderHook(() => useChairmanSocket(123)); + + act(() => { + result.current.sendDebateEvent('NEXT', payload, authToken); + }); + + expect(publish).toHaveBeenCalledWith( + '/app/event/123', + { + eventType: 'NEXT', + data: payload, + }, + { Authorization: authToken }, + ); + }); + + it('error가 발생하면 Toast 대체 console 알림을 호출해야 한다', () => { + const error = new Error('socket failure'); + const consoleErrorSpy = vi + .spyOn(console, 'error') + .mockImplementation(() => undefined); + useSocketMock.mockReturnValue({ + connect, + disconnect, + subscribe, + unsubscribe, + publish, + addConnectionListener, + error, + }); + + renderHook(() => useChairmanSocket(123)); + + expect(consoleErrorSpy).toHaveBeenCalledWith( + 'Chairman socket connection failed.', + error, + ); + }); +}); diff --git a/src/hooks/sockets/useChairmanSocket.ts b/src/hooks/sockets/useChairmanSocket.ts new file mode 100644 index 00000000..24ea0f6f --- /dev/null +++ b/src/hooks/sockets/useChairmanSocket.ts @@ -0,0 +1,155 @@ +import { useEffect, useState, useCallback } from 'react'; +import { useQueryClient } from '@tanstack/react-query'; +import { + SocketEventType, + SocketMessage, + TimerDataPayload, +} from '../../apis/sockets/type'; +import { isTimerEventType } from '../../apis/sockets/util'; +import { chairmanTokenQueryKey } from '../query/useGetChairmanToken'; +import useSocket from './useSocket'; + +/** + * 사회자 전용 웹소켓 훅입니다. + * + * * ⚠️ 주의: 이 훅을 컴포넌트에서 호출하여 페이지가 마운트(렌더링)되는 순간, + * 내부의 `useEffect`가 실행되어 즉시 `/chairman/{roomId}` 채널에 대한 구독(Subscribe)을 요청합니다. + * 컴포넌트가 언마운트되면 해당 채널의 구독은 자동으로 해제됩니다. + * * 사회자는 데이터를 수신할 뿐만 아니라, `sendModeratorAction`을 통해 + * 특정 제어 메시지를 지정된 채널로 송신(Publish)할 수 있습니다. + * + * @param {number} roomId - 관리할 토론방의 고유 ID + * @returns {Object} 사회자 소켓 상태와 제어 함수를 반환합니다. + * @returns {number} returns.signalCount - 현재 소켓 세션에서 수신한 신호 수입니다. + * @returns {number | null} returns.lastSignalTime - 현재 소켓 세션에서 마지막으로 신호를 수신한 시각의 타임스탬프입니다. + * @returns {Function} returns.connect - `useSocket.connect`에 위임하기 전에 현재 신호 상태를 초기화합니다. + * @returns {Function} returns.disconnect - `useSocket.disconnect`에 위임하기 전에 현재 신호 상태를 초기화합니다. + * @returns {Function} returns.sendDebateEvent - 현재 방으로 사회자 토론 이벤트를 발행합니다. + * @returns {Error | null} returns.error - 가장 최근에 발생한 소켓 오류입니다. + */ +export default function useChairmanSocket(roomId: number) { + const queryClient = useQueryClient(); + const { + connect, + disconnect, + subscribe, + unsubscribe, + publish, + addConnectionListener, + error, + } = useSocket(); + + const [signalCount, setSignalCount] = useState(0); + const [lastSignalTime, setLastSignalTime] = useState(null); + + /** + * 현재 사회자 소켓 세션에서 누적된 신호 메타데이터를 초기화합니다. + * 세션 간에 오래된 신호 수나 타임스탬프가 남지 않도록, 래핑된 connect 및 + * disconnect 제어 함수에서 사용하는 초기화 동작을 한곳에 모읍니다. + */ + const resetSignalState = useCallback(() => { + setSignalCount(0); + setLastSignalTime(null); + }, []); + + /** + * 이전에 수신한 신호 상태를 초기화한 뒤 사회자 소켓을 시작합니다. + * 새 연결이 이전 세션의 신호 수나 마지막 신호 타임스탬프 없이 시작되도록 + * `useSocket.connect`를 래핑합니다. + */ + const connectChairmanSocket = useCallback( + (options?: Parameters[0]) => { + resetSignalState(); + connect(options); + }, + [connect, resetSignalState], + ); + + /** + * 현재 신호 메타데이터를 초기화한 뒤 사회자 소켓을 종료합니다. + * 수동으로 연결을 해제할 때도 React 상태에 남아 있을 수 있는 세션 단위 신호 + * 상태를 제거하기 위해 `useSocket.disconnect`를 래핑합니다. + * + * 사용자가 명시적으로 이 함수를 호출한 경우, TanStack Query에 저장된 + * 임시 액세스 토큰 캐시를 제거합니다. + */ + const disconnectChairmanSocket = useCallback(() => { + resetSignalState(); + disconnect(); + queryClient.removeQueries({ + queryKey: chairmanTokenQueryKey(String(roomId)), + exact: true, + }); + }, [disconnect, queryClient, resetSignalState, roomId]); + + useEffect(() => { + return addConnectionListener(resetSignalState); + }, [addConnectionListener, resetSignalState]); + + // 서버로부터 토론 이벤트를 갱신해달라는 요청을 받게 될 채널 구독 + useEffect(() => { + const destination = `/chairman/${roomId}`; + + resetSignalState(); + + subscribe(destination, () => { + setSignalCount((prev) => prev + 1); + setLastSignalTime(Date.now()); + }); + + return () => { + unsubscribe(destination); + }; + }, [roomId, resetSignalState, subscribe, unsubscribe]); + + useEffect(() => { + if (!error) { + return; + } + + // TODO: Replace with Toast when a global Toast API is available. + resetSignalState(); + console.error('Chairman socket connection failed.', error); + }, [error, resetSignalState]); + + // 사회자 권한으로 특정 제어 메시지를 발행하는 함수 + const sendDebateEvent = useCallback( + ( + eventType: SocketEventType, + payload: TimerDataPayload | null, + authToken: string, + ) => { + const destination = `/app/event/${roomId}`; + let body: SocketMessage; + + if (isTimerEventType(eventType)) { + if (payload === null) { + console.error('No payload for timer event.'); + return; + } else { + body = { + eventType, + data: payload, + }; + } + } else { + body = { + eventType, + data: null, + }; + } + + publish(destination, body, { Authorization: authToken }); + }, + [roomId, publish], + ); + + return { + signalCount, + lastSignalTime, + connect: connectChairmanSocket, + disconnect: disconnectChairmanSocket, + sendDebateEvent, + error, + }; +} diff --git a/src/hooks/sockets/useSocket.test.ts b/src/hooks/sockets/useSocket.test.ts new file mode 100644 index 00000000..366216fb --- /dev/null +++ b/src/hooks/sockets/useSocket.test.ts @@ -0,0 +1,176 @@ +import { act, renderHook } from '@testing-library/react'; +import type { StompSubscription } from '@stomp/stompjs'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import useSocket from './useSocket'; + +const socketManagerMock = vi.hoisted(() => ({ + connect: vi.fn(), + disconnect: vi.fn(), + publish: vi.fn(), + subscribe: vi.fn(), + isConnected: vi.fn(), + onConnectEvent: vi.fn(), + offConnectEvent: vi.fn(), +})); + +vi.mock('../../apis/sockets/SocketManager', () => ({ + socketManager: socketManagerMock, +})); + +function createSubscription(): StompSubscription { + return { + id: 'subscription-id', + unsubscribe: vi.fn(), + }; +} + +function getConnectListener() { + return socketManagerMock.onConnectEvent.mock.calls[0][0] as () => void; +} + +describe('useSocket', () => { + beforeEach(() => { + vi.useRealTimers(); + vi.clearAllMocks(); + socketManagerMock.isConnected.mockReturnValue(false); + socketManagerMock.subscribe.mockImplementation(() => createSubscription()); + }); + + it('connect와 disconnect 호출을 SocketManager에 위임해야 한다', () => { + const { result } = renderHook(() => useSocket()); + + act(() => { + result.current.connect({ baseUrl: 'https://example.com' }); + result.current.disconnect(); + }); + + expect(socketManagerMock.connect).toHaveBeenCalledWith({ + baseUrl: 'https://example.com', + }); + expect(socketManagerMock.disconnect).toHaveBeenCalledOnce(); + }); + + it('subscribe와 unsubscribe가 구독 정보를 등록하고 정리해야 한다', () => { + const subscription = createSubscription(); + const callback = vi.fn(); + socketManagerMock.isConnected.mockReturnValue(true); + socketManagerMock.subscribe.mockReturnValue(subscription); + + const { result } = renderHook(() => useSocket()); + + act(() => { + result.current.subscribe('/topic/test', callback); + }); + + expect(socketManagerMock.subscribe).toHaveBeenCalledWith( + '/topic/test', + callback, + ); + + act(() => { + result.current.unsubscribe('/topic/test'); + }); + + expect(subscription.unsubscribe).toHaveBeenCalledOnce(); + }); + + it('언마운트 시 활성화된 모든 구독을 해제해야 한다', () => { + const firstSubscription = createSubscription(); + const secondSubscription = createSubscription(); + socketManagerMock.isConnected.mockReturnValue(true); + socketManagerMock.subscribe + .mockReturnValueOnce(firstSubscription) + .mockReturnValueOnce(secondSubscription); + + const { result, unmount } = renderHook(() => useSocket()); + + act(() => { + result.current.subscribe('/topic/first', vi.fn()); + result.current.subscribe('/topic/second', vi.fn()); + }); + + unmount(); + + expect(firstSubscription.unsubscribe).toHaveBeenCalledOnce(); + expect(secondSubscription.unsubscribe).toHaveBeenCalledOnce(); + expect(socketManagerMock.offConnectEvent).toHaveBeenCalledWith( + getConnectListener(), + ); + }); + + it('재연결 이벤트가 발생하면 백업된 구독을 다시 등록해야 한다', () => { + const callback = vi.fn(); + const subscription = createSubscription(); + socketManagerMock.subscribe.mockReturnValue(subscription); + + const { result } = renderHook(() => useSocket()); + + act(() => { + result.current.subscribe('/topic/recover', callback); + }); + + socketManagerMock.subscribe.mockClear(); + + act(() => { + getConnectListener()(); + }); + + expect(socketManagerMock.subscribe).toHaveBeenCalledWith( + '/topic/recover', + callback, + ); + }); + + it('연결 이벤트가 발생하면 등록한 연결 리스너를 구독 복구 전에 호출해야 한다', () => { + const connectionListener = vi.fn(); + const recoverCallback = vi.fn(); + const subscription = createSubscription(); + socketManagerMock.subscribe.mockReturnValue(subscription); + + const listenerHook = renderHook(() => useSocket()); + + act(() => { + listenerHook.result.current.addConnectionListener(connectionListener); + listenerHook.result.current.subscribe('/topic/recover', recoverCallback); + }); + + socketManagerMock.subscribe.mockClear(); + + act(() => { + getConnectListener()(); + }); + + expect(connectionListener).toHaveBeenCalledOnce(); + expect(socketManagerMock.subscribe).toHaveBeenCalledWith( + '/topic/recover', + recoverCallback, + ); + expect(connectionListener.mock.invocationCallOrder[0]).toBeLessThan( + socketManagerMock.subscribe.mock.invocationCallOrder[0], + ); + + listenerHook.unmount(); + }); + + it('재연결 구독 복구가 재시도 후에도 실패하면 error 상태를 설정해야 한다', async () => { + vi.useFakeTimers(); + const callback = vi.fn(); + socketManagerMock.subscribe.mockReturnValue(null); + + const { result } = renderHook(() => useSocket()); + + act(() => { + result.current.subscribe('/topic/failure', callback); + }); + + act(() => { + getConnectListener()(); + }); + + act(() => { + vi.advanceTimersByTime(1000); + }); + + expect(result.current.error).toBeInstanceOf(Error); + }); +}); diff --git a/src/hooks/useSocket.ts b/src/hooks/sockets/useSocket.ts similarity index 91% rename from src/hooks/useSocket.ts rename to src/hooks/sockets/useSocket.ts index 1c44ee23..04981f5d 100644 --- a/src/hooks/useSocket.ts +++ b/src/hooks/sockets/useSocket.ts @@ -1,7 +1,7 @@ import { useEffect, useRef, useCallback, useState } from 'react'; import { IMessage, StompHeaders, StompSubscription } from '@stomp/stompjs'; -import { socketManager, SocketOptions } from '../apis/sockets/SocketManager'; -import { SocketMessage } from '../apis/sockets/type'; +import { socketManager, SocketOptions } from '../../apis/sockets/SocketManager'; +import { SocketMessage } from '../../apis/sockets/type'; /** * 소켓 연결을 돕는 React 훅입니다. 제공되는 함수는 아래와 같습니다: @@ -15,7 +15,7 @@ import { SocketMessage } from '../apis/sockets/type'; * * 특히, 구독 해제의 책임은 이 훅에 있으나, 연결 자체를 끊는 책임은 소켓을 호출한 페이지에 있다는 점을 유념해주세요. */ -export const useSocket = () => { +export default function useSocket() { // 현재 컴포넌트에서 활성화한 구독을 저장하는 보관소 const activeSubscriptions = useRef>(new Map()); @@ -24,6 +24,8 @@ export const useSocket = () => { new Map(), ); + const connectionListeners = useRef void>>(new Set()); + // 오류 안내를 위한 상태 const [error, setError] = useState(null); @@ -44,6 +46,14 @@ export const useSocket = () => { socketManager.disconnect(); }, []); + const addConnectionListener = useCallback((listener: () => void) => { + connectionListeners.current.add(listener); + + return () => { + connectionListeners.current.delete(listener); + }; + }, []); + /** * 메시지 발행 * 특정 채널로 메시지 발행. @@ -111,6 +121,8 @@ export const useSocket = () => { // 소켓이 연결될 때마다 실행될 핸들러 const handleConnect = () => { const recover = () => { + connectionListeners.current.forEach((listener) => listener()); + // 기존 활성화된 구독 리스트 초기화 activeSubscriptions.current.clear(); @@ -173,6 +185,8 @@ export const useSocket = () => { activeSubscriptions.current.clear(); // eslint-disable-next-line react-hooks/exhaustive-deps subscriptionInfos.current.clear(); + // eslint-disable-next-line react-hooks/exhaustive-deps + connectionListeners.current.clear(); }; }, []); @@ -183,6 +197,7 @@ export const useSocket = () => { subscribe, unsubscribe, publish, + addConnectionListener, error, }; -}; +} diff --git a/src/hooks/usePageTracking.test.tsx b/src/hooks/usePageTracking.test.tsx index f249f405..5d2a03fa 100644 --- a/src/hooks/usePageTracking.test.tsx +++ b/src/hooks/usePageTracking.test.tsx @@ -1,9 +1,5 @@ import { renderHook } from '@testing-library/react'; -import { - RouterProvider, - createMemoryRouter, - Outlet, -} from 'react-router-dom'; +import { RouterProvider, createMemoryRouter, Outlet } from 'react-router-dom'; import type { PropsWithChildren, ReactNode } from 'react'; import usePageTracking from './usePageTracking'; import { analyticsManager } from '../util/analytics'; diff --git a/src/hooks/usePageTracking.ts b/src/hooks/usePageTracking.ts index 0e14294e..6bd84135 100644 --- a/src/hooks/usePageTracking.ts +++ b/src/hooks/usePageTracking.ts @@ -2,7 +2,10 @@ import { useEffect, useRef } from 'react'; import { useLocation, useMatches } from 'react-router-dom'; import { analyticsManager } from '../util/analytics'; -function normalizePath(pathname: string, params: Readonly>): string { +function normalizePath( + pathname: string, + params: Readonly>, +): string { let normalized = pathname; // 긴 값부터 치환해야 짧은 값이 긴 값의 일부를 덮어쓰는 것을 방지 const entries = Object.entries(params) @@ -22,7 +25,10 @@ export default function usePageTracking() { const hasTrackedLeaveRef = useRef(false); const lastMatch = matches[matches.length - 1]; - const normalizedPath = normalizePath(location.pathname, lastMatch?.params ?? {}); + const normalizedPath = normalizePath( + location.pathname, + lastMatch?.params ?? {}, + ); const normalizedPathRef = useRef(normalizedPath); normalizedPathRef.current = normalizedPath; diff --git a/src/page/TableComposition/TableCompositionPage.test.tsx b/src/page/TableComposition/TableCompositionPage.test.tsx index e4f076f3..41aa33e3 100644 --- a/src/page/TableComposition/TableCompositionPage.test.tsx +++ b/src/page/TableComposition/TableCompositionPage.test.tsx @@ -1,5 +1,5 @@ import { describe, it, expect, beforeEach, vi } from 'vitest'; -import { render, screen } from '@testing-library/react'; +import { render, screen, waitFor } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import { MemoryRouter, Route, Routes } from 'react-router-dom'; @@ -71,11 +71,15 @@ describe('TableComposition', () => { ); // Check header title is exist to verify whether TablenameAndType is correctly rendered - expect(screen.findByRole('heading', { name: '토론 정보를 설정해주세요' })); + expect( + await screen.findByRole('heading', { name: '토론 정보를 설정해주세요' }), + ).toBeInTheDocument(); // Go to next step - TimeBoxStep await userEvent.click(await screen.findByRole('button', { name: '다음' })); - expect(screen.findByRole('heading', { name: '주제 없음' })); + expect( + await screen.findByRole('heading', { name: '주제 없음' }), + ).toBeInTheDocument(); // Check whether finish button is disabled const finishButton = await screen.findByRole('button', { @@ -90,11 +94,15 @@ describe('TableComposition', () => { await userEvent.click( await screen.findByRole('button', { name: '설정 완료' }), ); - expect(screen.getByTestId('timebox')).toBeInTheDocument(); + expect(await screen.findByTestId('timebox')).toBeInTheDocument(); // Finish creation flow - await userEvent.click(finishButton); - expect(screen.getByTestId('overview-page')).toBeInTheDocument(); + const enabledFinishButton = await screen.findByRole('button', { + name: '추가하기', + }); + await waitFor(() => expect(enabledFinishButton).toBeEnabled()); + await userEvent.click(enabledFinishButton); + expect(await screen.findByTestId('overview-page')).toBeInTheDocument(); }); it('Modification flow test', async () => { @@ -107,7 +115,9 @@ describe('TableComposition', () => { ); // Check whether user sees TimeBoxStep not TableNameAndType - expect(screen.findByRole('button', { name: '토론 정보 수정하기' })); + expect( + await screen.findByRole('button', { name: '토론 정보 수정하기' }), + ).toBeInTheDocument(); // Check whether timeboxes are correctly displayed const timeboxItems = await screen.findAllByTestId('timebox'); @@ -117,6 +127,10 @@ describe('TableComposition', () => { await userEvent.click( await screen.findByRole('button', { name: '토론 정보 수정하기' }), ); - expect(screen.getByText('토론 정보를 수정해주세요')).toBeInTheDocument(); + expect( + await screen.findByRole('heading', { + name: '토론 정보를 수정해주세요', + }), + ).toBeInTheDocument(); }); }); diff --git a/src/page/TableSharingPage/TableSharingPage.tsx b/src/page/TableSharingPage/TableSharingPage.tsx index 66502e9f..ad2eae0a 100644 --- a/src/page/TableSharingPage/TableSharingPage.tsx +++ b/src/page/TableSharingPage/TableSharingPage.tsx @@ -64,7 +64,10 @@ export default function TableSharingPage() { const [searchParams] = useSearchParams(); const encodedData = searchParams.get('data'); // encodedData가 변경될 때만 디코딩을 재실행해 effect 의존성을 안정화한다. - const decodedData = useMemo(() => getDecodedDataOrNull(encodedData), [encodedData]); + const decodedData = useMemo( + () => getDecodedDataOrNull(encodedData), + [encodedData], + ); const source = searchParams.get('source'); const isTemplateEntry = source === 'template'; diff --git a/src/page/TimerPage/components/NormalTimer.test.tsx b/src/page/TimerPage/components/NormalTimer.test.tsx index 0a49d5d2..24fab366 100644 --- a/src/page/TimerPage/components/NormalTimer.test.tsx +++ b/src/page/TimerPage/components/NormalTimer.test.tsx @@ -98,6 +98,8 @@ describe('NormalTimer - 순서명 정렬 (US3)', () => { it('영어 순서명이 타이머 화면에 표시된다', () => { renderNormalTimer(null, null, 'Opening Statement'); - expect(screen.getByRole('heading', { level: 1 })).toHaveTextContent('Opening Statement'); + expect(screen.getByRole('heading', { level: 1 })).toHaveTextContent( + 'Opening Statement', + ); }); }); From df719b104a275037e35156a83a702cddae76cdb6 Mon Sep 17 00:00:00 2001 From: Shawn Kang <77564014+i-meant-to-be@users.noreply.github.com> Date: Fri, 5 Jun 2026 12:01:44 +0900 Subject: [PATCH 2/7] =?UTF-8?q?[FEAT]=20=ED=83=80=EC=9D=B4=EB=A8=B8=20?= =?UTF-8?q?=ED=99=94=EB=A9=B4=EC=97=90=20=ED=99=94=EB=A9=B4=20=EA=B3=B5?= =?UTF-8?q?=EC=9C=A0=20=EB=B2=84=ED=8A=BC=20=EB=B0=8F=20=EB=AA=A8=EB=8B=AC?= =?UTF-8?q?=20=EA=B5=AC=ED=98=84=20(#452)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * docs: 국제화 항목 추가 * feat: 화면 공유 로직 도메인 변경 * feat: 사회자용 소켓 Hook에 연결 상태를 나타내는 필드 추가 * feat: UI 컴포넌트 구현 * fix: SockJS 오류 수정 * feat: Story 추가 * fix: 일부 오류 수정 * refactor: 조건식 간소화 * refactor: URL 파싱 로직 보강 * fix: 잘못 불러온 패키지 경로 수정 * fix: 테스트 코드의 모방 경로 수정 * fix: 모달을 닫아도 WS 연결이 끊기지 않게 수정 * test: 모달 테스트 코드가 msw 모방 API를 사용하게 수정 * fix: Hook `useSocket`의 상태 `isConnected`를 소켓 연결 종료 시 반드시 갱신하게 수정 * test: 모달 테스트 코드가 msw로 성공, 로딩, 실패를 다 커버하게 수정 * chore: 영문 번역본 추가 * fix: 비회원 모드에서 공유 버튼 출력되는 오류 수정 * refactor: 상태 관리 책임을 모두 TimerPage로 이동 * refactor: 상태 관리 책임 변경에 따른 모달 코드 정리 * refactor: 상태 관리 책임 변경에 따른 타이머 컴포넌트 변경 * chore: 주석 추가 * refactor: 소켓 종료 책임을 훅 useLiveShare에 완전히 위임 * test: 테스트 스위트 이름을 가독성 있게 변경 * fix: 테스트 코드 오류 해결 * fix: 중복 버튼 문제 해결 * fix: 오류 발생 시 소켓 연결 안 끊기던 문제 수정 * fix: 등호 수정 * fix: 터치 시에도 이벤트가 발행되도록 수정 * refactor: 일부 키보드/마우스 동작 비활성화 * refactor: 페이로드의 팀 항목을 필수에서 선택으로 변경 * fix: A/L 키 다시 활성화 --- public/locales/en/translation.json | 6 + public/locales/ko/translation.json | 6 + src/apis/apis/{share.ts => live.ts} | 4 +- src/apis/endpoints.ts | 2 +- src/apis/responses/{share.ts => live.ts} | 0 src/apis/sockets/SocketManager.test.ts | 55 ++++-- src/apis/sockets/SocketManager.ts | 28 +++ src/apis/sockets/type.ts | 2 +- src/hooks/query/useGetChairmanToken.ts | 2 +- src/hooks/sockets/useChairmanSocket.ts | 2 + src/hooks/sockets/useSocket.test.ts | 23 +++ src/hooks/sockets/useSocket.ts | 11 ++ src/mocks/handlers/global.ts | 2 + src/mocks/handlers/live.ts | 16 ++ src/page/TimerPage/TimerPage.tsx | 96 ++++++++++- .../components/LiveShareButton.stories.tsx | 23 +++ .../components/LiveShareButton.test.tsx | 24 +++ .../TimerPage/components/LiveShareButton.tsx | 25 +++ .../components/LiveShareModal.stories.tsx | 144 ++++++++++++++++ .../components/LiveShareModal.test.tsx | 95 +++++++++++ .../TimerPage/components/LiveShareModal.tsx | 81 +++++++++ .../TimerPage/components/RoundControlRow.tsx | 24 ++- .../TimerPage/components/TimeBasedTimer.tsx | 9 +- src/page/TimerPage/components/TimerView.tsx | 48 ++++-- src/page/TimerPage/hooks/useLiveShare.ts | 161 ++++++++++++++++++ src/page/TimerPage/hooks/useTimerHotkey.ts | 60 ++++--- src/page/TimerPage/hooks/useTimerPageState.ts | 23 ++- src/routes/routes.tsx | 6 + vite.config.ts | 4 + 29 files changed, 911 insertions(+), 71 deletions(-) rename src/apis/apis/{share.ts => live.ts} (62%) rename src/apis/responses/{share.ts => live.ts} (100%) create mode 100644 src/mocks/handlers/live.ts create mode 100644 src/page/TimerPage/components/LiveShareButton.stories.tsx create mode 100644 src/page/TimerPage/components/LiveShareButton.test.tsx create mode 100644 src/page/TimerPage/components/LiveShareButton.tsx create mode 100644 src/page/TimerPage/components/LiveShareModal.stories.tsx create mode 100644 src/page/TimerPage/components/LiveShareModal.test.tsx create mode 100644 src/page/TimerPage/components/LiveShareModal.tsx create mode 100644 src/page/TimerPage/hooks/useLiveShare.ts diff --git a/public/locales/en/translation.json b/public/locales/en/translation.json index c4334cf1..e7200bfa 100644 --- a/public/locales/en/translation.json +++ b/public/locales/en/translation.json @@ -1,4 +1,10 @@ { + "토론 타이머 화면 공유": "Share Debate Timer Screen", + "라이브 공유 불가": "Live Sharing Unavailable", + "라이브 공유": "Live Sharing", + "휴대폰 카메라로 QR 코드를 스캔하면 토론 타이머 화면이 자동으로 열립니다.": "Scan the QR code with your phone to open the debate timer.", + "사회자 인증 토큰 발급에 실패했어요...": "Failed to issue moderator access token...", + "라이브 서버 연결에 실패했어요...": "Failed to connect to the live server...", "모달 닫기": "Close modal", "유효하지 않은 투표 링크입니다.": "Invalid vote link.", "승패투표": "Win/Loss Vote", diff --git a/public/locales/ko/translation.json b/public/locales/ko/translation.json index 95aa23fc..1c50a816 100644 --- a/public/locales/ko/translation.json +++ b/public/locales/ko/translation.json @@ -1,4 +1,10 @@ { + "토론 타이머 화면 공유": "토론 타이머 화면 공유", + "라이브 공유 불가": "라이브 공유 불가", + "라이브 공유": "라이브 공유", + "휴대폰 카메라로 QR 코드를 스캔하면 토론 타이머 화면이 자동으로 열립니다.": "휴대폰 카메라로 QR 코드를 스캔하면 토론 타이머 화면이 자동으로 열립니다.", + "사회자 인증 토큰 발급에 실패했어요...": "사회자 인증 토큰 발급에 실패했어요...", + "라이브 서버 연결에 실패했어요...": "라이브 서버 연결에 실패했어요...", "모달 닫기": "모달 닫기", "유효하지 않은 투표 링크입니다.": "유효하지 않은 투표 링크입니다.", "승패투표": "승패투표", diff --git a/src/apis/apis/share.ts b/src/apis/apis/live.ts similarity index 62% rename from src/apis/apis/share.ts rename to src/apis/apis/live.ts index b9080a36..633e94e3 100644 --- a/src/apis/apis/share.ts +++ b/src/apis/apis/live.ts @@ -1,11 +1,11 @@ import { request } from '../primitives'; import { ApiUrl } from '../endpoints'; -import { GetChairmanTokenResponseType } from '../responses/share'; +import { GetChairmanTokenResponseType } from '../responses/live'; export const getChairmanToken = (tableId: string) => { return request( 'GET', - `${ApiUrl.share}/${encodeURIComponent(tableId)}/chairman-token`, + `${ApiUrl.live}/${encodeURIComponent(tableId)}/chairman-token`, null, null, ); diff --git a/src/apis/endpoints.ts b/src/apis/endpoints.ts index ea1fbbb3..c93a18ee 100644 --- a/src/apis/endpoints.ts +++ b/src/apis/endpoints.ts @@ -12,5 +12,5 @@ export const ApiUrl = { customize: makeUrl('/table/customize'), poll: makeUrl('/polls'), organization: makeUrl('/organizations'), - share: makeUrl('/share'), + live: makeUrl('/live'), }; diff --git a/src/apis/responses/share.ts b/src/apis/responses/live.ts similarity index 100% rename from src/apis/responses/share.ts rename to src/apis/responses/live.ts diff --git a/src/apis/sockets/SocketManager.test.ts b/src/apis/sockets/SocketManager.test.ts index b3d5eeee..a6f8e5e5 100644 --- a/src/apis/sockets/SocketManager.test.ts +++ b/src/apis/sockets/SocketManager.test.ts @@ -91,35 +91,35 @@ describe('SocketManager', () => { // --- [그룹 1] 연결 & 해제 --- describe('Connection & Disconnection', () => { - it('connect 호출 시 Client 인스턴스를 생성하고 activate를 실행해야 한다', () => { + it('소켓 연결 시도 시 클라이언트 인스턴스를 생성하고, 인스턴스 생성 여부를 검증해야 한다', () => { socketManager.connect(); expect(clientInstances).toHaveLength(1); expect(getLatestClient().activate).toHaveBeenCalledOnce(); }); - it('connect 옵션의 url을 SockJS 연결 주소로 사용해야 한다', () => { + it('소켓 연결 시도 시 주어진 url을 SockJS의 연결 주소로 사용해야 한다', () => { socketManager.connect({ url: 'https://socket.example.com/ws' }); getLatestClient().config.webSocketFactory?.(); expect(SockJS).toHaveBeenCalledWith('https://socket.example.com/ws'); }); - it('connect 옵션의 baseUrl을 /ws 경로와 조합해 SockJS 연결 주소로 사용해야 한다', () => { + it('소켓 연결 시도 시 주어진 baseUrl을 /ws 경로와 조합해 SockJS 연결 주소로 사용해야 한다', () => { socketManager.connect({ baseUrl: 'https://socket.example.com' }); getLatestClient().config.webSocketFactory?.(); expect(SockJS).toHaveBeenCalledWith('https://socket.example.com/ws'); }); - it('connect 옵션에 url과 baseUrl이 없으면 환경 변수 기반 주소를 사용해야 한다', () => { + it('소켓 연결 시도 시 url과 baseUrl이 제공되지 않은 경우 환경 변수 기반 주소를 사용해야 한다', () => { socketManager.connect(); getLatestClient().config.webSocketFactory?.(); expect(SockJS).toHaveBeenCalledWith('https://test.com/ws'); }); - it('이미 연결된 상태에서 connect를 재호출하면 중복 연결을 방지해야 한다', () => { + it('이미 연결된 상태에서 소켓 연결을 다시 시도하면 중복 연결을 방지해야 한다', () => { socketManager.connect(); socketManager.connect(); @@ -127,7 +127,7 @@ describe('SocketManager', () => { expect(clientInstances).toHaveLength(1); }); - it('disconnect 호출 시 deactivate를 실행해야 한다', () => { + it('연결을 끊을 때, 클라이언트 인스턴스의 비활성화 여부를 검증해야 한다', () => { socketManager.connect(); const client = getLatestClient(); @@ -136,7 +136,7 @@ describe('SocketManager', () => { expect(client.deactivate).toHaveBeenCalledOnce(); }); - it('disconnect 후 재연결하면 새 Client 인스턴스를 생성해야 한다', () => { + it('연결을 끊고 재연결하면, 새 클라이언트 인스턴스를 생성해야 한다', () => { socketManager.connect(); socketManager.disconnect(); socketManager.connect(); @@ -161,7 +161,7 @@ describe('SocketManager', () => { * 1번째 close: calculateBackoffDelay(0) = floor(0.5 * 1000 * 2^0) = 500 + 10 → retryCount: 0 → 1 * 2번째 close: calculateBackoffDelay(1) = floor(0.5 * 1000 * 2^1) = 1000 + 10 → retryCount: 1 → 2 */ - it('onWebSocketClose 발생 시 지수 백오프 딜레이를 갱신해야 한다', () => { + it('웹 소켓 연결이 끊기면 재시도 전에 서버 부하를 방지하기 위해 지수 백오프 딜레이를 갱신해야 한다', () => { vi.spyOn(Math, 'random').mockReturnValue(0.5); socketManager.connect({ baseRetryDelayMs: 1000 }); @@ -179,7 +179,7 @@ describe('SocketManager', () => { expect(client.reconnectDelay).toBe(1000 + 10); }); - it('최대 재시도 횟수(maxRetries) 초과 시 reconnectDelay를 0으로 설정하고 연결을 해제해야 한다', () => { + it('최대 재시도 횟수 초과 시 재시도 딜레이를 0으로 설정하고 연결을 해제해야 한다', () => { socketManager.connect({ maxRetries: 3 }); const client = getLatestClient(); @@ -195,7 +195,7 @@ describe('SocketManager', () => { expect(client.deactivate).toHaveBeenCalledOnce(); }); - it('onConnect 실행 시 retryCount가 0으로 리셋되어야 한다', () => { + it('연결에 성공할 경우 재시도 횟수 카운터를 0으로 리셋해야 한다', () => { vi.spyOn(Math, 'random').mockReturnValue(0.5); socketManager.connect({ baseRetryDelayMs: 1000, maxRetries: 5 }); const client = getLatestClient(); @@ -215,12 +215,12 @@ describe('SocketManager', () => { // --- [그룹 3] 구독 & 발행 --- describe('Subscribe & Publish', () => { - it('연결 전 subscribe 호출 시 null을 반환해야 한다', () => { + it('연결이 되지 않았는데 채널 구독 시, null을 반환해야 한다', () => { const result = socketManager.subscribe('/topic/test', vi.fn()); expect(result).toBeNull(); }); - it('연결 후 subscribe 호출 시 client.subscribe를 실행해야 한다', () => { + it('연결이 성립된 후 채널 구독 시, 클라이언트의 구독 함수가 실행되어야 한다', () => { socketManager.connect(); const client = getLatestClient(); client.connected = true; // 연결 완료 상태 시뮬레이션 @@ -231,12 +231,12 @@ describe('SocketManager', () => { expect(client.subscribe).toHaveBeenCalledWith('/topic/test', callback); }); - it('연결 전 publish 호출 시 메시지를 전송하지 않아야 한다', () => { + it('연결이 되지 않았는데 메시지를 발행할 시, 전송하지 않아야 한다', () => { socketManager.publish('/app/test', {} as SocketMessage); expect(clientInstances).toHaveLength(0); }); - it('연결 후 publish 호출 시 JSON 직렬화된 body를 전송해야 한다', () => { + it('연결이 성립된 후 메시지를 발행할 시, 직렬화된 JSON 데이터를 전송해야 한다', () => { socketManager.connect(); const client = getLatestClient(); client.connected = true; @@ -261,7 +261,7 @@ describe('SocketManager', () => { }); describe('Connect Listeners (Observer Pattern)', () => { - it('onConnectEvent로 등록한 리스너가 onConnect 시 호출되어야 한다', () => { + it('소켓 연결 시 동작해야 할 함수 목록에 등록한 리스너는, 연결 시 호출되어야 한다', () => { const listener = vi.fn(); socketManager.onConnectEvent(listener); socketManager.connect(); @@ -272,7 +272,7 @@ describe('SocketManager', () => { expect(listener).toHaveBeenCalledOnce(); }); - it('offConnectEvent로 제거한 리스너는 onConnect 시 호출되지 않아야 한다', () => { + it('소켓 연결 시 동작해야 할 함수 목록에서 삭제한 리스너는, 연결 시 호출되지 않아야 한다', () => { const listener = vi.fn(); socketManager.onConnectEvent(listener); socketManager.offConnectEvent(listener); @@ -283,5 +283,28 @@ describe('SocketManager', () => { expect(listener).not.toHaveBeenCalled(); }); + + it('소켓 연결 종료 시 동작해야 할 함수 목록에 등록한 리스너는, 연결 종료 시 호출되어야 한다', () => { + const listener = vi.fn(); + socketManager.onCloseEvent(listener); + socketManager.connect(); + + const client = getLatestClient(); + client.config.onWebSocketClose?.({} as CloseEvent); + + expect(listener).toHaveBeenCalledOnce(); + }); + + it('소켓 연결 종료 시 동작해야 할 함수 목록에서 삭제한 리스너는, 연결 종료 시 시 호출되지 않아야 한다', () => { + const listener = vi.fn(); + socketManager.onCloseEvent(listener); + socketManager.offCloseEvent(listener); + socketManager.connect(); + + const client = getLatestClient(); + client.config.onWebSocketClose?.({} as CloseEvent); + + expect(listener).not.toHaveBeenCalled(); + }); }); }); diff --git a/src/apis/sockets/SocketManager.ts b/src/apis/sockets/SocketManager.ts index 775068f7..77a58b6e 100644 --- a/src/apis/sockets/SocketManager.ts +++ b/src/apis/sockets/SocketManager.ts @@ -71,6 +71,7 @@ class SocketManager { // - 관찰자(observer)는 구독을 관리하는 useSocket 훅 // - 발행자(publisher)는 재연결 여부를 알리는 이 클래스 (SocketManager) private connectListeners: Set<() => void> = new Set(); + private closeListeners: Set<() => void> = new Set(); /** * 관찰자를 등록하는 함수 @@ -88,6 +89,22 @@ class SocketManager { this.connectListeners.delete(listener); } + /** + * 소켓 연결 종료 관찰자를 등록하는 함수 + * @param listener - 연결 종료 시 실행할 콜백 함수 + */ + public onCloseEvent(listener: () => void) { + this.closeListeners.add(listener); + } + + /** + * 소켓 연결 종료 관찰자를 제거하는 함수 + * @param listener - 제거할 콜백 함수 + */ + public offCloseEvent(listener: () => void) { + this.closeListeners.delete(listener); + } + /** * 연결 여부를 확인하는 함수 * @returns 연결 여부 @@ -165,6 +182,16 @@ class SocketManager { } console.log('⚠️ 웹 소켓 연결이 종료되었습니다.'); + this.closeListeners.forEach((listener) => { + try { + listener(); + } catch (error) { + console.error( + '종료 리스너 오류 발생. 다음 리스너로 진행합니다.', + error, + ); + } + }); this.handleReconnection(); }, }); @@ -187,6 +214,7 @@ class SocketManager { this.client = null; this.currentOptions = DEFAULT_OPTIONS; this.connectListeners.clear(); + this.closeListeners.clear(); console.log('🛑 웹 소켓 연결을 수동으로 해제했습니다.'); } diff --git a/src/apis/sockets/type.ts b/src/apis/sockets/type.ts index 34eac83a..bd054ebc 100644 --- a/src/apis/sockets/type.ts +++ b/src/apis/sockets/type.ts @@ -27,7 +27,7 @@ export interface TimerDataPayload { sequence: number; /** 현재 발언하고 있는 팀의 진영 */ - currentTeam: Stance; + currentTeam?: Stance; /** 남은 시간 (초 단위) */ remainingTime: number; diff --git a/src/hooks/query/useGetChairmanToken.ts b/src/hooks/query/useGetChairmanToken.ts index 0d5807cd..856e8a7e 100644 --- a/src/hooks/query/useGetChairmanToken.ts +++ b/src/hooks/query/useGetChairmanToken.ts @@ -1,5 +1,5 @@ import { useQuery } from '@tanstack/react-query'; -import { getChairmanToken } from '../../apis/apis/share'; +import { getChairmanToken } from '../../apis/apis/live'; export const chairmanTokenQueryKey = (tableId: string) => ['chairmanToken', tableId] as const; diff --git a/src/hooks/sockets/useChairmanSocket.ts b/src/hooks/sockets/useChairmanSocket.ts index 24ea0f6f..52be63c0 100644 --- a/src/hooks/sockets/useChairmanSocket.ts +++ b/src/hooks/sockets/useChairmanSocket.ts @@ -36,6 +36,7 @@ export default function useChairmanSocket(roomId: number) { unsubscribe, publish, addConnectionListener, + isConnected, error, } = useSocket(); @@ -150,6 +151,7 @@ export default function useChairmanSocket(roomId: number) { connect: connectChairmanSocket, disconnect: disconnectChairmanSocket, sendDebateEvent, + isConnected, error, }; } diff --git a/src/hooks/sockets/useSocket.test.ts b/src/hooks/sockets/useSocket.test.ts index 366216fb..571f20ec 100644 --- a/src/hooks/sockets/useSocket.test.ts +++ b/src/hooks/sockets/useSocket.test.ts @@ -11,6 +11,8 @@ const socketManagerMock = vi.hoisted(() => ({ isConnected: vi.fn(), onConnectEvent: vi.fn(), offConnectEvent: vi.fn(), + onCloseEvent: vi.fn(), + offCloseEvent: vi.fn(), })); vi.mock('../../apis/sockets/SocketManager', () => ({ @@ -28,6 +30,10 @@ function getConnectListener() { return socketManagerMock.onConnectEvent.mock.calls[0][0] as () => void; } +function getCloseListener() { + return socketManagerMock.onCloseEvent.mock.calls[0][0] as () => void; +} + describe('useSocket', () => { beforeEach(() => { vi.useRealTimers(); @@ -96,6 +102,23 @@ describe('useSocket', () => { expect(socketManagerMock.offConnectEvent).toHaveBeenCalledWith( getConnectListener(), ); + expect(socketManagerMock.offCloseEvent).toHaveBeenCalledWith( + getCloseListener(), + ); + }); + + it('소켓 종료 이벤트가 발생하면 연결 상태를 false로 갱신해야 한다', () => { + socketManagerMock.isConnected.mockReturnValue(true); + + const { result } = renderHook(() => useSocket()); + + expect(result.current.isConnected).toBe(true); + + act(() => { + getCloseListener()(); + }); + + expect(result.current.isConnected).toBe(false); }); it('재연결 이벤트가 발생하면 백업된 구독을 다시 등록해야 한다', () => { diff --git a/src/hooks/sockets/useSocket.ts b/src/hooks/sockets/useSocket.ts index 04981f5d..ce7c97ad 100644 --- a/src/hooks/sockets/useSocket.ts +++ b/src/hooks/sockets/useSocket.ts @@ -28,6 +28,7 @@ export default function useSocket() { // 오류 안내를 위한 상태 const [error, setError] = useState(null); + const [isConnected, setIsConnected] = useState(socketManager.isConnected()); /** * 소켓 연결 @@ -44,6 +45,7 @@ export default function useSocket() { */ const disconnect = useCallback(() => { socketManager.disconnect(); + setIsConnected(false); }, []); const addConnectionListener = useCallback((listener: () => void) => { @@ -120,6 +122,8 @@ export default function useSocket() { useEffect(() => { // 소켓이 연결될 때마다 실행될 핸들러 const handleConnect = () => { + setIsConnected(true); + const recover = () => { connectionListeners.current.forEach((listener) => listener()); @@ -162,8 +166,13 @@ export default function useSocket() { } }; + const handleClose = () => { + setIsConnected(false); + }; + // 이 핸들러 함수를 발행자(SocketManager)에 등록 socketManager.onConnectEvent(handleConnect); + socketManager.onCloseEvent(handleClose); // 리스너 등록 이후 연결이 이미 되어 있는 게 확인되면, 핸들러 바로 실행 if (socketManager.isConnected()) { @@ -174,6 +183,7 @@ export default function useSocket() { return () => { // 먼저 발행자(SocketManager)에게 등록된 핸들러부터 제거 socketManager.offConnectEvent(handleConnect); + socketManager.offCloseEvent(handleClose); // 활성화된 모든 구독 해제 activeSubscriptions.current.forEach((subscription) => @@ -198,6 +208,7 @@ export default function useSocket() { unsubscribe, publish, addConnectionListener, + isConnected, error, }; } diff --git a/src/mocks/handlers/global.ts b/src/mocks/handlers/global.ts index 628aade4..b6207c68 100644 --- a/src/mocks/handlers/global.ts +++ b/src/mocks/handlers/global.ts @@ -4,6 +4,7 @@ import { memberHandlers } from './member'; import { pollHandlers } from './poll'; import { organizationHandlers } from './organization'; import { staticAssetHandlers } from './static_asset'; +import { liveHandlers } from './live'; const TRANSLATIONS: Record> = { ko: { @@ -49,4 +50,5 @@ export const allHandlers = [ ...pollHandlers, ...organizationHandlers, ...staticAssetHandlers, + ...liveHandlers, ]; diff --git a/src/mocks/handlers/live.ts b/src/mocks/handlers/live.ts new file mode 100644 index 00000000..c0184665 --- /dev/null +++ b/src/mocks/handlers/live.ts @@ -0,0 +1,16 @@ +import { http, HttpResponse } from 'msw'; +import { ApiUrl } from '../../apis/endpoints'; +import { GetChairmanTokenResponseType } from '../../apis/responses/live'; + +export const liveHandlers = [ + http.get(`${ApiUrl.live}/:tableId/chairman-token`, ({ params }) => { + const { tableId } = params; + console.log(`# Table ID = ${tableId}`); + + const response: GetChairmanTokenResponseType = { + chairmanToken: 'mock-chairman-token-12345', + }; + + return HttpResponse.json(response); + }), +]; diff --git a/src/page/TimerPage/TimerPage.tsx b/src/page/TimerPage/TimerPage.tsx index 9665e20d..e148c61e 100644 --- a/src/page/TimerPage/TimerPage.tsx +++ b/src/page/TimerPage/TimerPage.tsx @@ -11,6 +11,8 @@ import RoundControlRow from './components/RoundControlRow'; import TimerView from './components/TimerView'; import { FirstUseToolTipModal } from './components/FirstUseToolTipModal'; import { LoginAndStoreModal } from './components/LoginAndStoreModal'; +import LiveShareButton from './components/LiveShareButton'; +import LiveShareModal from './components/LiveShareModal'; import { useTimerPageModal } from './hooks/useTimerPageModal'; import { bgColorMap } from '../../type/type'; import DTHelp from '../../components/icons/Help'; @@ -27,6 +29,9 @@ import { } from 'react-icons/ri'; import DTVolume from '../../components/icons/Volume'; import VolumeBar from '../../components/VolumeBar/VolumeBar'; +import { isLoggedIn } from '../../util/accessToken'; +import { useLiveShare } from './hooks/useLiveShare'; +import { SocketEventType, TimerDataPayload } from '../../apis/sockets/type'; // 토론 타이머 실행, 라운드 이동, 종료 흐름을 관리하는 메인 페이지다. export default function TimerPage() { @@ -48,7 +53,6 @@ export default function TimerPage() { useDebateTracking(); const { trackEvent } = useAnalytics(); - useTimerHotkey(state); const timerStartedRef = useRef(false); const isMuted = state.volume === 0; const { @@ -67,7 +71,70 @@ export default function TimerPage() { setFullscreen, toggleFullscreen, volumeRef, + prosConsSelected, + timer1, + timer2, + normalTimer, } = state; + const timerType = data && data.table[index].boxType; + const remainingTime = + timerType === 'NORMAL' + ? normalTimer.timer + : prosConsSelected === 'PROS' + ? timer1.speakingTimer + : timer2.speakingTimer; + + const { + isLiveShareModalOpen, + toggleLiveShareModal, + liveShareModalRef, + issueEvent, + // connect, 나중에 명시적 연결이 필요할 때를 대비하여 주석으로 남겨둠 + // disconnect, 나중에 명시적 연결 종료가 필요할 때를 대비하여 주석으로 남겨둠 + isSocketConnected, + shareUrl: liveShareUrl, + isLoading: isSocketLoading, + isError: isSocketError, + errorType: socketErrorType, + } = useLiveShare(tableId); + + // 타이머 이벤트를 핸들링하는 래퍼 함수 선언 + const handleTimerEvent = (invoke: () => void, eventType: SocketEventType) => { + // 이벤트 실행 + invoke(); + + // 만약 소켓 열려 있으면, 발송 + if (isSocketConnected) { + // 타입에 따른 페이로드 준비 + let innerPayload: TimerDataPayload; + + if (timerType === 'NORMAL') { + innerPayload = { + currentTeam: prosConsSelected, + timerType: timerType, + remainingTime: remainingTime, + sequence: index, + } as TimerDataPayload; + } else if (timerType === 'TIME_BASED') { + innerPayload = { + timerType: timerType, + remainingTime: remainingTime, + sequence: index, + } as TimerDataPayload; + } else { + // 피드백 타이머 타입은 여기 올 수 없음 + // 따라서 별도 작업 하지 않고 그냥 반환 + return; + } + + const payload = eventType === 'FINISHED' ? null : innerPayload; + + // 이벤트 발행 + issueEvent(eventType, payload); + } + }; + + useTimerHotkey(state, handleTimerEvent); // timer_started 이벤트 발화 (데이터 로드 후 1회) useEffect(() => { @@ -200,14 +267,39 @@ export default function TimerPage() { bgColorMap[bg], )} > + {/* 라이브 공유 버튼 및 모달 */} + {isLoggedIn() && ( +
+ {!isLiveShareModalOpen && ( + + )} + + {isLiveShareModalOpen && ( +
+ +
+ )} +
+ )} + {/* 타이머 두 개 + ENTER 버튼 */} - + {/* Round control buttons on the bottom side */} {data && ( { // 전체 화면 상태에서 토론을 끝낼 경우, 전체 화면을 비활성화 if (isFullscreen) { diff --git a/src/page/TimerPage/components/LiveShareButton.stories.tsx b/src/page/TimerPage/components/LiveShareButton.stories.tsx new file mode 100644 index 00000000..3fb86eb0 --- /dev/null +++ b/src/page/TimerPage/components/LiveShareButton.stories.tsx @@ -0,0 +1,23 @@ +import { Meta, StoryObj } from '@storybook/react'; +import LiveShareButton from './LiveShareButton'; + +const meta: Meta = { + title: 'page/TimerPage/components/LiveShareButton', + component: LiveShareButton, + tags: ['autodocs'], + parameters: { + layout: 'centered', + }, +}; + +export default meta; + +type Story = StoryObj; + +export const Default: Story = { + args: { + onClick: () => { + console.log('LiveShareButton clicked'); + }, + }, +}; diff --git a/src/page/TimerPage/components/LiveShareButton.test.tsx b/src/page/TimerPage/components/LiveShareButton.test.tsx new file mode 100644 index 00000000..0e96041b --- /dev/null +++ b/src/page/TimerPage/components/LiveShareButton.test.tsx @@ -0,0 +1,24 @@ +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { vi } from 'vitest'; +import LiveShareButton from './LiveShareButton'; + +describe('LiveShareButton', () => { + test('버튼이 렌더링된다', () => { + render(); + + expect( + screen.getByRole('button', { name: '라이브 공유' }), + ).toBeInTheDocument(); + }); + + test('버튼을 클릭하면 onClick이 호출된다', async () => { + const user = userEvent.setup(); + const handleClick = vi.fn(); + render(); + + await user.click(screen.getByRole('button', { name: '라이브 공유' })); + + expect(handleClick).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/page/TimerPage/components/LiveShareButton.tsx b/src/page/TimerPage/components/LiveShareButton.tsx new file mode 100644 index 00000000..7690a91f --- /dev/null +++ b/src/page/TimerPage/components/LiveShareButton.tsx @@ -0,0 +1,25 @@ +import { useTranslation } from 'react-i18next'; +import ShareLive from '../../../components/icons/ShareLive'; +import { ButtonHTMLAttributes } from 'react'; + +interface LiveShareButtonProps extends ButtonHTMLAttributes { + onClick: () => void; +} + +export default function LiveShareButton({ onClick }: LiveShareButtonProps) { + const { t } = useTranslation(); + + return ( + + ); +} diff --git a/src/page/TimerPage/components/LiveShareModal.stories.tsx b/src/page/TimerPage/components/LiveShareModal.stories.tsx new file mode 100644 index 00000000..129ca021 --- /dev/null +++ b/src/page/TimerPage/components/LiveShareModal.stories.tsx @@ -0,0 +1,144 @@ +import { useEffect } from 'react'; +import { Meta, StoryObj } from '@storybook/react'; +import { http, HttpResponse, delay } from 'msw'; +import { ApiUrl } from '../../../apis/endpoints'; +import { socketManager } from '../../../apis/sockets/SocketManager'; +import LiveShareModal from './LiveShareModal'; +import { StompSubscription } from '@stomp/stompjs'; + +const meta: Meta = { + title: 'page/TimerPage/components/LiveShareModal', + component: LiveShareModal, + tags: ['autodocs'], + args: { + shareUrl: '', + toggleModal: () => {}, + }, + parameters: { + layout: 'centered', + }, +}; + +export default meta; + +type Story = StoryObj; + +export const Default: Story = { + args: { + isLoading: false, + isError: false, + errorType: 'else', + }, + parameters: { + msw: { + handlers: [ + http.get(`${ApiUrl.live}/:tableId/chairman-token`, () => { + return HttpResponse.json({ + chairmanToken: 'mock-token-123', + }); + }), + ], + }, + }, + decorators: [ + (Story) => { + // Monkey patch socketManager to simulate a successful connection + const originalIsConnected = socketManager.isConnected; + const originalSubscribe = socketManager.subscribe; + const originalConnect = socketManager.connect; + const originalDisconnect = socketManager.disconnect; + + socketManager.isConnected = () => true; + socketManager.subscribe = () => + ({ + id: 'dummy', + unsubscribe: () => {}, + }) as StompSubscription; + socketManager.connect = () => {}; + socketManager.disconnect = () => {}; + + useEffect(() => { + return () => { + socketManager.isConnected = originalIsConnected; + socketManager.subscribe = originalSubscribe; + socketManager.connect = originalConnect; + socketManager.disconnect = originalDisconnect; + }; + }, [ + originalConnect, + originalIsConnected, + originalSubscribe, + originalDisconnect, + ]); + + return ; + }, + ], +}; + +export const Failed: Story = { + args: { + isLoading: false, + isError: true, + errorType: 'token', + }, + parameters: { + msw: { + handlers: [ + http.get(`${ApiUrl.live}/:tableId/chairman-token`, () => { + return new HttpResponse(null, { status: 500 }); + }), + ], + }, + }, + decorators: [ + (Story) => { + // Prevent socketManager from attempting to connect to a real server + const originalConnect = socketManager.connect; + socketManager.connect = () => {}; + + useEffect(() => { + return () => { + socketManager.connect = originalConnect; + }; + }, [originalConnect]); + + return ; + }, + ], +}; + +export const Loading: Story = { + args: { + isLoading: true, + isError: false, + errorType: 'else', + }, + parameters: { + msw: { + handlers: [ + http.get(`${ApiUrl.live}/:tableId/chairman-token`, async () => { + await delay('infinite'); + return HttpResponse.json({ + chairmanToken: 'mock-token-123', + }); + }), + ], + }, + }, + decorators: [ + (Story) => { + // Prevent socketManager from attempting to connect to a real server + const originalConnect = socketManager.connect; + socketManager.connect = () => {}; + + useEffect(() => { + return () => { + socketManager.connect = originalConnect; + }; + }, [originalConnect]); + + return ; + }, + ], +}; diff --git a/src/page/TimerPage/components/LiveShareModal.test.tsx b/src/page/TimerPage/components/LiveShareModal.test.tsx new file mode 100644 index 00000000..8c523b9b --- /dev/null +++ b/src/page/TimerPage/components/LiveShareModal.test.tsx @@ -0,0 +1,95 @@ +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import type { ComponentProps } from 'react'; +import { vi } from 'vitest'; +import LiveShareModal from './LiveShareModal'; + +vi.mock('qrcode.react', () => ({ + QRCodeSVG: ({ value }: { value: string }) => ( + + ), +})); + +vi.mock('../../../components/LoadingSpinner', () => ({ + default: () =>
loading
, +})); + +type LiveShareModalProps = ComponentProps; + +const SHARE_URL = 'https://example.com/live/1'; +const SUCCESS_TITLE = '토론 타이머 화면 공유'; +const ERROR_TITLE = '라이브 공유 불가'; +const TOKEN_ERROR_MESSAGE = '사회자 인증 토큰 발급에 실패했어요...'; +const LIVE_SERVER_ERROR_MESSAGE = '라이브 서버 연결에 실패했어요...'; +const CLOSE_LABEL = '모달 닫기'; +const QR_DESCRIPTION = + '휴대폰 카메라로 QR 코드를 스캔하면 토론 타이머 화면이 자동으로 열립니다.'; + +function renderLiveShareModal(overrides: Partial = {}) { + const props: LiveShareModalProps = { + shareUrl: SHARE_URL, + isLoading: false, + isError: false, + errorType: 'else', + toggleModal: vi.fn(), + ...overrides, + }; + + render(); + + return props; +} + +describe('LiveShareModal', () => { + test('로딩 상태에서 스피너를 보여준다', () => { + renderLiveShareModal({ isLoading: true }); + + expect(screen.getByRole('status')).toHaveTextContent('loading'); + expect(screen.queryByRole('heading')).not.toBeInTheDocument(); + expect(screen.queryByLabelText('qr-code')).not.toBeInTheDocument(); + }); + + test('성공 상태에서 공유 제목과 QR 코드를 보여준다', () => { + renderLiveShareModal(); + + expect( + screen.getByRole('heading', { name: SUCCESS_TITLE }), + ).toBeInTheDocument(); + expect(screen.getByLabelText('qr-code')).toHaveAttribute( + 'data-value', + SHARE_URL, + ); + expect(screen.getByText(QR_DESCRIPTION)).toBeInTheDocument(); + }); + + test('닫기 버튼을 클릭하면 toggleModal을 호출한다', async () => { + const user = userEvent.setup(); + const toggleModal = vi.fn(); + + renderLiveShareModal({ toggleModal }); + + await user.click(screen.getByRole('button', { name: CLOSE_LABEL })); + + expect(toggleModal).toHaveBeenCalledTimes(1); + }); + + test('토큰 에러 상태를 보여준다', () => { + renderLiveShareModal({ isError: true, errorType: 'token' }); + + expect( + screen.getByRole('heading', { name: ERROR_TITLE }), + ).toBeInTheDocument(); + expect(screen.getByText(TOKEN_ERROR_MESSAGE)).toBeInTheDocument(); + expect(screen.queryByLabelText('qr-code')).not.toBeInTheDocument(); + }); + + test('라이브 서버 에러 상태를 보여준다', () => { + renderLiveShareModal({ isError: true, errorType: 'else' }); + + expect( + screen.getByRole('heading', { name: ERROR_TITLE }), + ).toBeInTheDocument(); + expect(screen.getByText(LIVE_SERVER_ERROR_MESSAGE)).toBeInTheDocument(); + expect(screen.queryByLabelText('qr-code')).not.toBeInTheDocument(); + }); +}); diff --git a/src/page/TimerPage/components/LiveShareModal.tsx b/src/page/TimerPage/components/LiveShareModal.tsx new file mode 100644 index 00000000..a80a5ca4 --- /dev/null +++ b/src/page/TimerPage/components/LiveShareModal.tsx @@ -0,0 +1,81 @@ +import { useTranslation } from 'react-i18next'; +import { QRCodeSVG } from 'qrcode.react'; +import LoadingSpinner from '../../../components/LoadingSpinner'; +import DTClose from '../../../components/icons/Close'; +import { LiveShareErrorType } from '../hooks/useLiveShare'; + +interface LiveShareModalProps { + shareUrl: string; + isLoading: boolean; + isError: boolean; + errorType: LiveShareErrorType; + toggleModal: () => void; +} + +export default function LiveShareModal({ + shareUrl, + isLoading, + isError, + errorType, + toggleModal, +}: LiveShareModalProps) { + const { t } = useTranslation(); + const errorMessage = + errorType === 'token' + ? t('사회자 인증 토큰 발급에 실패했어요...') + : t('라이브 서버 연결에 실패했어요...'); + + return ( +
+ {isLoading ? ( +
+ +
+ ) : ( + <> +
+

+ {isError ? t('라이브 공유 불가') : t('토론 타이머 화면 공유')} +

+ + +
+ + {!isError ? ( + <> + + +
+
+ +

+ {t( + '휴대폰 카메라로 QR 코드를 스캔하면 토론 타이머 화면이 자동으로 열립니다.', + )} +

+
+ + ) : ( +

{errorMessage}

+ )} + + )} +
+ ); +} diff --git a/src/page/TimerPage/components/RoundControlRow.tsx b/src/page/TimerPage/components/RoundControlRow.tsx index 73438679..938e22af 100644 --- a/src/page/TimerPage/components/RoundControlRow.tsx +++ b/src/page/TimerPage/components/RoundControlRow.tsx @@ -1,31 +1,47 @@ import clsx from 'clsx'; import RoundControlButton from '../../../components/RoundControlButton/RoundControlButton'; import { TimeBoxInfo } from '../../../type/type'; +import { SocketEventType } from '../../../apis/sockets/type'; interface RoundControlRowProps { table: TimeBoxInfo[]; index: number; goToOtherItem: (isPrev: boolean) => void; openDoneModal: () => void; + onEvent: (invoke: () => void, eventType: SocketEventType) => void; className?: string; } export default function RoundControlRow(props: RoundControlRowProps) { - const { table, index, goToOtherItem, openDoneModal, className = '' } = props; + const { + table, + index, + goToOtherItem, + openDoneModal, + onEvent, + className = '', + } = props; + return (
{index !== 0 && ( - goToOtherItem(true)} /> + onEvent(() => goToOtherItem(true), 'BEFORE')} + /> )}
{index === table.length - 1 ? ( - + onEvent(openDoneModal, 'FINISHED')} + /> ) : ( goToOtherItem(false)} + onClick={() => onEvent(() => goToOtherItem(false), 'NEXT')} /> )}
diff --git a/src/page/TimerPage/components/TimeBasedTimer.tsx b/src/page/TimerPage/components/TimeBasedTimer.tsx index fa91d99d..35d2cb8f 100644 --- a/src/page/TimerPage/components/TimeBasedTimer.tsx +++ b/src/page/TimerPage/components/TimeBasedTimer.tsx @@ -21,7 +21,7 @@ type TimeBasedTimerInstance = { interface TimeBasedTimerProps { timeBasedTimerInstance: TimeBasedTimerInstance; isSelected: boolean; - onActivate?: () => void; + onActivate: () => void; prosCons: TimeBasedStance; teamName: string; item: TimeBoxInfo; @@ -101,10 +101,15 @@ export default function TimeBasedTimer({ return (
{ + // '26. 6. 3. 부로 마우스 클릭을 활용한 팀 전환은 비활성화 + // onActivate() + }} className={clsx( 'flex w-[400px] flex-col items-center justify-center space-y-[12px] xl:min-w-[560px] xl:space-y-[20px]', { - 'pointer-events-none opacity-50 grayscale': !isSelected, + 'opacity-50 grayscale': !isSelected, + 'pointer-events-none': !isSelected, // 마우스 클릭을 활용한 팀 전환 활성화 시에 이 태그를 지우세요. }, )} > diff --git a/src/page/TimerPage/components/TimerView.tsx b/src/page/TimerPage/components/TimerView.tsx index 933ee49d..21e4ee60 100644 --- a/src/page/TimerPage/components/TimerView.tsx +++ b/src/page/TimerPage/components/TimerView.tsx @@ -1,10 +1,17 @@ // components/TimerView.tsx +import { SocketEventType } from '../../../apis/sockets/type'; import DTExchange from '../../../components/icons/Exchange'; import { TimerPageLogics } from '../hooks/useTimerPageState'; import NormalTimer from './NormalTimer'; import TimeBasedTimer from './TimeBasedTimer'; -export default function TimerView({ state }: { state: TimerPageLogics }) { +interface TimerViewProps { + state: TimerPageLogics; + onEvent: (invoke: () => void, eventType: SocketEventType) => void; +} + +export default function TimerView({ state, onEvent }: TimerViewProps) { + // 상태 풀기 const { data, normalTimer, @@ -16,6 +23,8 @@ export default function TimerView({ state }: { state: TimerPageLogics }) { handleActivateTeam, switchCamp, } = state; + + // 일반 타이머 if (data && data.table[index].boxType === 'NORMAL') { return ( onEvent(normalTimer.startTimer, 'PLAY'), + pauseTimer: () => onEvent(normalTimer.pauseTimer, 'STOP'), + resetTimer: () => onEvent(normalTimer.resetTimer, 'RESET'), setTimer: normalTimer.setTimer, }} isAdditionalTimerAvailable={isAdditionalTimerAvailable} @@ -42,6 +51,8 @@ export default function TimerView({ state }: { state: TimerPageLogics }) { /> ); } + + // 자유 토론 타이머 if (data && data.table[index].boxType === 'TIME_BASED') { return (
@@ -51,20 +62,25 @@ export default function TimerView({ state }: { state: TimerPageLogics }) { totalTimer: timer1.totalTimer, speakingTimer: timer1.speakingTimer, isRunning: timer1.isRunning, - startTimer: timer1.startTimer, - pauseTimer: timer1.pauseTimer, - resetCurrentTimer: () => timer1.resetCurrentTimer(timer2.isDone), + startTimer: () => onEvent(timer1.startTimer, 'PLAY'), + pauseTimer: () => onEvent(timer1.pauseTimer, 'STOP'), + resetCurrentTimer: () => + onEvent(() => timer1.resetCurrentTimer(timer2.isDone), 'RESET'), }} item={data.table[index]} isSelected={prosConsSelected === 'PROS'} - onActivate={() => handleActivateTeam('PROS')} + onActivate={() => + handleActivateTeam('PROS', (invoke) => + onEvent(invoke, 'TEAM_SWITCH'), + ) + } prosCons="PROS" teamName={data.info.prosTeamName} /> {/* ENTER 버튼 */}
); } + return null; } diff --git a/src/page/TimerPage/hooks/useLiveShare.ts b/src/page/TimerPage/hooks/useLiveShare.ts new file mode 100644 index 00000000..4637d5b4 --- /dev/null +++ b/src/page/TimerPage/hooks/useLiveShare.ts @@ -0,0 +1,161 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { socketManager } from '../../../apis/sockets/SocketManager'; +import useChairmanSocket from '../../../hooks/sockets/useChairmanSocket'; +import useGetChairmanToken from '../../../hooks/query/useGetChairmanToken'; +import { SocketEventType, TimerDataPayload } from '../../../apis/sockets/type'; + +export type LiveShareErrorType = 'token' | 'else'; + +export function useLiveShare(tableId: number) { + // 테이블 ID 검증 + const isValidTableId = Number.isFinite(tableId) && tableId > 0; + + // 모달 공유 관련 + const hasConnectedRef = useRef(false); + const [isLiveShareModalOpen, setIsLiveShareModalOpen] = useState(false); + const toggleLiveShareModal = useCallback(() => { + setIsLiveShareModalOpen((prev) => !prev); + }, []); + + // 사회자 임시 토큰 발급 훅 + const { + data: chairmanToken, + isPending: isTokenPending, + isError: isTokenError, + } = useGetChairmanToken( + String(tableId), + isLiveShareModalOpen && isValidTableId, + ); + + // 소켓 훅 + const { + connect, + disconnect, + isConnected: isSocketConnected, + sendDebateEvent, + error: socketError, + } = useChairmanSocket(tableId); + + // 공유용 URL + const shareUrl = useMemo(() => { + const baseUrl = + import.meta.env.VITE_SHARE_BASE_URL || window.location.origin; + const normalizedBaseUrl = baseUrl.endsWith('/') + ? baseUrl.slice(0, -1) + : baseUrl; + return `${normalizedBaseUrl}/live/${tableId}`; + }, [tableId]); + + // 모달 바깥 영역에서의 클릭을 처리하기 위한 레퍼런스와 함수 + const liveShareModalRef = useRef(null); + + // 반환용 값들 + const isLoading = + isLiveShareModalOpen && + isValidTableId && + !isTokenError && + !socketError && + (isTokenPending || Boolean(chairmanToken)) && + !isSocketConnected; + const isError = !isValidTableId || isTokenError || Boolean(socketError); + const errorType: LiveShareErrorType = + !isValidTableId || isTokenError ? 'token' : 'else'; + + // 토큰을 첨부하여 토론 이벤트를 전송하는 함수 + const issueEvent = ( + eventType: SocketEventType, + payload: TimerDataPayload | null, + ) => { + sendDebateEvent(eventType, payload, chairmanToken ?? ''); + }; + + /** + * 토론 공유 모달 바깥 클릭을 처리하는 부수 효과 + */ + useEffect( + function handleClickOutsideModal() { + const handle = (event: MouseEvent) => { + if ( + liveShareModalRef.current && + !liveShareModalRef.current.contains(event.target as Node) && + isLiveShareModalOpen + ) { + toggleLiveShareModal(); + } + }; + document.addEventListener('mousedown', handle); + return () => { + document.removeEventListener('mousedown', handle); + }; + }, + [isLiveShareModalOpen, toggleLiveShareModal], + ); + + /** + * 만약 소켓 사용 중이라면 타이머 페이지가 언마운트될 때 같이 닫음 + */ + useEffect( + function closeSocketOnUnmount() { + return () => { + // 훅 레벨에서 먼저 소켓 닫기 + disconnect(); + hasConnectedRef.current = false; + + // 그리고 싱글톤 인스턴스 레벨에서 완전히 닫기 + // 현재로서는 사회자가 소켓을 사용하는 기능이 이거 말고 없기 때문에 + // 싱글톤 인스턴스도 여기서 닫아도 다른 기능에 방해를 주지 않음 + if (socketManager.isConnected()) { + socketManager.disconnect(); + } + }; + }, + [disconnect], + ); + + /** + * 모달이 열렸을 시, 소켓 연결을 시도하는 부수 효과 + */ + useEffect( + function connectLiveShareSocket() { + if ( + !isLiveShareModalOpen || + !chairmanToken || + socketError || + hasConnectedRef.current + ) { + return; + } + + connect(); + hasConnectedRef.current = true; + }, + [chairmanToken, connect, isLiveShareModalOpen, socketError], + ); + + /** + * 오류 발생 시 연결 해제 및 상태 초기화 (정리) + */ + useEffect( + function cleanupOnError() { + if (isError) { + hasConnectedRef.current = false; + disconnect(); + } + }, + [isError, disconnect], + ); + + return { + isLiveShareModalOpen, + toggleLiveShareModal, + liveShareModalRef, + issueEvent, + connect, + disconnect, + shareUrl, + isLoading, + isError, + errorType, + isSocketConnected, + }; +} diff --git a/src/page/TimerPage/hooks/useTimerHotkey.ts b/src/page/TimerPage/hooks/useTimerHotkey.ts index cf066907..21709580 100644 --- a/src/page/TimerPage/hooks/useTimerHotkey.ts +++ b/src/page/TimerPage/hooks/useTimerHotkey.ts @@ -1,14 +1,19 @@ import { useEffect } from 'react'; import { TimerPageLogics } from './useTimerPageState'; +import { SocketEventType } from '../../../apis/sockets/type'; /** * 타이머 페이지에서 키보드 단축키(핫키) 기능을 제공하는 커스텀 훅입니다. * - Space: 타이머 시작/일시정지 * - KeyR: 타이머 리셋 - * - KeyA/KeyL: 각각 찬/반 진영 타이머 활성화 * - Enter, NumpadEnter: 진영 전환 + * + * ('26. 6. 3. 부로 A키와 L키를 활용한 팀 전환은 비활성화) */ -export function useTimerHotkey(state: TimerPageLogics) { +export function useTimerHotkey( + state: TimerPageLogics, + onEvent: (invoke: () => void, eventType: SocketEventType) => void, +) { const { data, index, @@ -34,8 +39,8 @@ export function useTimerHotkey(state: TimerPageLogics) { const keysToDisable = new Set([ 'Space', 'KeyR', - 'KeyA', - 'KeyL', + // 'KeyA', + // 'KeyL', 'Enter', 'NumpadEnter', ]); @@ -52,9 +57,9 @@ export function useTimerHotkey(state: TimerPageLogics) { // 찬/반 타이머 토글 (시작/정지) const toggleTimer = (timer: typeof timer1 | typeof timer2) => { if (timer.isRunning) { - timer.pauseTimer(); + onEvent(timer.pauseTimer, 'STOP'); } else { - timer.startTimer(); + onEvent(timer.startTimer, 'PLAY'); } }; @@ -63,9 +68,9 @@ export function useTimerHotkey(state: TimerPageLogics) { // 일반 타이머/진영별 타이머 동작 제어 if (boxType === 'NORMAL') { if (normalTimer.isRunning) { - normalTimer.pauseTimer(); + onEvent(normalTimer.pauseTimer, 'STOP'); } else { - normalTimer.startTimer(); + onEvent(normalTimer.startTimer, 'PLAY'); } } else { if (prosConsSelected === 'PROS') { @@ -78,39 +83,49 @@ export function useTimerHotkey(state: TimerPageLogics) { case 'KeyR': // 타이머 리셋 if (boxType === 'NORMAL') { - normalTimer.resetTimer(); + onEvent(normalTimer.resetTimer, 'RESET'); } else { if (prosConsSelected === 'PROS') { - timer1.resetCurrentTimer(timer2.isDone); + onEvent(() => timer1.resetCurrentTimer(timer2.isDone), 'RESET'); } else { - timer2.resetCurrentTimer(timer1.isDone); + onEvent(() => timer2.resetCurrentTimer(timer1.isDone), 'RESET'); } } break; case 'KeyA': // 찬성 진영 선택 및 반대 타이머 정지 if (prosConsSelected === 'CONS') { - if (timer1.isDone) { - setProsConsSelected('PROS'); - } else { - switchCamp(); - } + const handleSwitching = () => { + if (timer1.isDone) { + setProsConsSelected('PROS'); + } else { + switchCamp(); + } + }; + + onEvent(handleSwitching, 'TEAM_SWITCH'); } + break; case 'KeyL': // 반대 진영 선택 및 찬성 타이머 정지 if (prosConsSelected === 'PROS') { - if (timer1.isDone) { - setProsConsSelected('CONS'); - } else { - switchCamp(); - } + const handleSwitching = () => { + if (timer1.isDone) { + setProsConsSelected('CONS'); + } else { + switchCamp(); + } + }; + + onEvent(handleSwitching, 'TEAM_SWITCH'); } + break; case 'Enter': case 'NumpadEnter': // 진영 전환 - switchCamp(); + onEvent(switchCamp, 'TEAM_SWITCH'); break; } }; @@ -129,5 +144,6 @@ export function useTimerHotkey(state: TimerPageLogics) { goToOtherItem, setProsConsSelected, switchCamp, + onEvent, ]); } diff --git a/src/page/TimerPage/hooks/useTimerPageState.ts b/src/page/TimerPage/hooks/useTimerPageState.ts index 45b7d627..7a2c6fb9 100644 --- a/src/page/TimerPage/hooks/useTimerPageState.ts +++ b/src/page/TimerPage/hooks/useTimerPageState.ts @@ -21,6 +21,8 @@ import useFullscreen from '../../../hooks/useFullscreen'; const VOLUME_SCALE = 10; +type SwitchCampRunner = (invoke: () => void) => void; + /** * 타이머 페이지의 상태(타이머, 라운드, 벨 등) 전반을 관리하는 커스텀 훅 */ @@ -154,7 +156,7 @@ export function useTimerPageState(tableId: number): TimerPageLogics { * - 현재 진영이 아닌 타이머를 클릭한 경우에만 동작 */ const handleActivateTeam = useCallback( - (team: TimeBasedStance) => { + (team: TimeBasedStance, runSwitchCamp: SwitchCampRunner) => { const clickedTimerStance = team === 'PROS' ? 'PROS' : 'CONS'; const clickedTimer = clickedTimerStance === 'PROS' ? timer1 : timer2; @@ -162,11 +164,15 @@ export function useTimerPageState(tableId: number): TimerPageLogics { if (prosConsSelected === clickedTimerStance) return; // 아니라면, 타이머 변경 - if (clickedTimer.isDone) { - setProsConsSelected(clickedTimerStance); - } else { - switchCamp(); - } + const handleSwitching = () => { + if (clickedTimer.isDone) { + setProsConsSelected(clickedTimerStance); + } else { + switchCamp(); + } + }; + + runSwitchCamp(handleSwitching); }, [prosConsSelected, switchCamp, timer1, timer2], ); @@ -325,7 +331,10 @@ export interface TimerPageLogics { setProsConsSelected: Dispatch>; goToOtherItem: (isPrev: boolean) => void; switchCamp: () => void; - handleActivateTeam: (team: TimeBasedStance) => void; + handleActivateTeam: ( + team: TimeBasedStance, + runSwitchCamp: SwitchCampRunner, + ) => void; tableId: number; isLoading: boolean; isError: boolean; diff --git a/src/routes/routes.tsx b/src/routes/routes.tsx index bd3f90b0..9d3fb421 100644 --- a/src/routes/routes.tsx +++ b/src/routes/routes.tsx @@ -17,6 +17,7 @@ import VoteParticipationPage from '../page/VoteParticipationPage/VoteParticipati import VoteCompletePage from '../page/VoteCompletePage/VoteCompletePage'; import DebateVoteResultPage from '../page/DebateVoteResultPage/DebateVoteResultPage'; import LanguageWrapper from './LanguageWrapper'; +import { Fragment } from 'react'; const appRoutes = [ { @@ -84,6 +85,11 @@ const appRoutes = [ element: , requiresAuth: false, }, + { + path: 'live/:id', + element: , + requiresAuth: false, + }, { path: '*', element: , diff --git a/vite.config.ts b/vite.config.ts index a37f05c4..76c47990 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -42,6 +42,10 @@ const viteConfig = defineViteConfig(({ mode }) => { }, port: 3000, }, + define: { + // global을 window로 정의하여 sockjs-client의 참조 오류 해결 + global: 'window', + }, }; }); From e3a5719955f7dfbb0a9d3ec5b78a09f0685f4462 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yuseon=20Kim=28=EC=8D=AC=EB=8D=B0=EC=9D=B4=29?= <74897720+useon@users.noreply.github.com> Date: Fri, 5 Jun 2026 17:40:48 +0900 Subject: [PATCH 3/7] =?UTF-8?q?[FEAT]=20Sentry=20=EB=85=B8=EC=9D=B4?= =?UTF-8?q?=EC=A6=88=20=EA=B0=90=EC=86=8C=20=EB=B0=8F=20API=20=EC=97=90?= =?UTF-8?q?=EB=9F=AC=20=ED=92=88=EC=A7=88=20=EA=B0=9C=EC=84=A0=20(#453)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: 에러 수집 노이즈 필터링 및 민감 정보 마스킹 적용 * feat: Sentry 이벤트 언어 컨텍스트 태깅 추가 * feat: API 응답 상태 기반 Sentry 에러 레벨 조정 적용 * feat: API 에러를 엔드포인트 기준으로 묶어 이슈 분산 감소 * refactor: Sentry API 에러 extra에서 baseURL 제거 * fix: UUID 경로가 숫자 치환에 먼저 깨지는 문제 순서 수정으로 해결 * fix: Sentry 에러 그룹에 HTTP 메서드 추가해 서로 다른 요청이 섞이지 않게 수정 * fix: authorization 소문자도 추가하여 대소문자 형태 모두 안전하게 삭제하도록 수정 * fix: Sentry beforeSend에서 optional hint 안전 접근 처리 * fix: i18n 언어 변경 시 Sentry language 태그 갱신 --- src/apis/axiosInstance.ts | 42 ++++++++++++++++++++++++++++++++++++--- src/instrument.ts | 37 ++++++++++++++++++++++++++++++++++ src/main.tsx | 17 +++++++++++++--- 3 files changed, 90 insertions(+), 6 deletions(-) diff --git a/src/apis/axiosInstance.ts b/src/apis/axiosInstance.ts index 14190626..6c8d64f0 100644 --- a/src/apis/axiosInstance.ts +++ b/src/apis/axiosInstance.ts @@ -1,5 +1,6 @@ import axios from 'axios'; import * as Sentry from '@sentry/react'; +import type { SeverityLevel } from '@sentry/react'; import { getAccessToken, removeAccessToken, @@ -22,6 +23,29 @@ type SentryCapturedError = { __sentry_captured__?: boolean; }; +export function normalizeEndpoint(url?: string) { + if (!url) { + return 'unknown'; + } + + return url + .replace( + /\b[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}\b/gi, + ':uuid', + ) + .replace(/[0-9]+/g, ':id'); +} + +function resolveApiErrorLevel(status: number | undefined) { + // 400/409/422는 사용자 입력·요청 상태 충돌 성격이 커서 warning으로 분리 + if (status === 400 || status === 409 || status === 422) { + return 'warning'; + } + + // 그 외(주로 5xx/네트워크 실패)는 운영 대응이 필요한 장애 신호로 처리 + return 'error'; +} + // Axios instance export const axiosInstance = axios.create({ baseURL: @@ -53,27 +77,39 @@ function captureClientApiError(error: unknown) { const { response, config, code } = error; const status = response?.status; + const normalizedUrl = normalizeEndpoint(config?.url); + const requestMethod = config?.method?.toUpperCase() ?? 'UNKNOWN'; - // 401 재발급 흐름과 정상/리다이렉트 응답만 제외하고, 4xx/5xx/네트워크 실패/타임아웃은 수집 + // 401은 토큰 재발급 후 원요청 재시도로 자동 복구되는 정상 인증 흐름이므로 수집에서 제외 + // 정상/리다이렉트 응답(<400)도 제외하고, 그 외 4xx/5xx/네트워크 실패/타임아웃은 수집 if (status === 401 || (status !== undefined && status < 400)) { return; } + const level: SeverityLevel = resolveApiErrorLevel(status); + Sentry.captureException(error, { + level, tags: { errorType: 'api-error', httpStatus: status ? String(status) : 'network-error', + endpoint: `${requestMethod} ${normalizedUrl}`, }, extra: { pathname: window.location.pathname, search: window.location.search, url: config?.url, method: config?.method, - baseURL: config?.baseURL, params: config?.params, timeout: config?.timeout ?? requestTimeoutMs, errorCode: code, }, + fingerprint: [ + 'api-error', + String(status ?? 'network-error'), + requestMethod, + normalizedUrl, + ], }); (error as SentryCapturedError).__sentry_captured__ = true; @@ -98,7 +134,7 @@ axiosInstance.interceptors.response.use( null, ); - // **새 Access Token은 응답 헤더(Authorization)에 담겨 있다고 가정** + // 새 Access Token은 응답 헤더(Authorization)에 담겨 있다고 가정 const headerAuth = refreshResponse.headers['authorization'] || ''; // Authorization: Bearer <새토큰> 형태라면 "Bearer " 부분 제거 diff --git a/src/instrument.ts b/src/instrument.ts index 72579a0a..72655524 100644 --- a/src/instrument.ts +++ b/src/instrument.ts @@ -21,5 +21,42 @@ if (import.meta.env.PROD && dsn) { replaysSessionSampleRate: 0, // 에러가 발생한 세션은 모두 Replay로 남김 replaysOnErrorSampleRate: 1.0, + beforeSend(event, hint) { + const originalException = hint?.originalException; + + // 정상 사용자 흐름(이동/언마운트)에서 자주 생기는 취소성 에러는 노이즈로 간주 + if (originalException instanceof Error) { + const normalizedMessage = originalException.message.toLowerCase(); + const isCanceledRequest = + normalizedMessage.includes('cancel') || + normalizedMessage.includes('aborted') || + originalException.name === 'AbortError' || + originalException.name === 'CanceledError'; + + if (isCanceledRequest) { + return null; + } + } + + // 크로스 오리진 스크립트 에러는 재현 단서가 부족해 운영 액션 가능성이 낮음 + if (event.exception?.values?.[0]?.value === 'Script error.') { + return null; + } + + // 인증 헤더가 extra에 실수로 포함돼도 전송 전에 제거 + if (event.extra && typeof event.extra === 'object') { + const sanitizedExtra = { ...event.extra }; + delete sanitizedExtra.Authorization; + delete sanitizedExtra.authorization; + event.extra = sanitizedExtra; + } + + return event; + }, + initialScope: { + tags: { + language: document.documentElement.lang || 'ko', + }, + }, }); } diff --git a/src/main.tsx b/src/main.tsx index ead0ecf0..dc8fe4a9 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -2,6 +2,7 @@ import './instrument'; import { StrictMode } from 'react'; import { createRoot } from 'react-dom/client'; import { RouterProvider } from 'react-router-dom'; +import * as Sentry from '@sentry/react'; import { GlobalPortal } from './util/GlobalPortal'; import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import router from './routes/routes.tsx'; @@ -13,9 +14,13 @@ import { getMemberId, removeMemberId, } from './util/accessToken'; -import { DEFAULT_LANG } from './util/languageRouting'; +import { DEFAULT_LANG, isSupportedLang } from './util/languageRouting'; import i18n from './i18n'; +function resolveCurrentLang(lang?: string) { + return isSupportedLang(lang) ? lang : DEFAULT_LANG; +} + // Functions that calls msw mocking worker if (import.meta.env.VITE_MOCK_API === 'true') { console.log('[msw] Mocking enabled.'); @@ -50,6 +55,10 @@ if (import.meta.env.VITE_MOCK_API === 'true') { // Function that initializes main React app function initializeApp() { setupAnalytics(); + const currentLang = resolveCurrentLang( + i18n.resolvedLanguage ?? i18n.language, + ); + Sentry.setTag('language', currentLang); // memberId 복원: accessToken + memberId 모두 존재 시 identity 설정 const memberId = getMemberId(); @@ -57,7 +66,7 @@ function initializeApp() { analyticsManager.setUserId(memberId); analyticsManager.setUserProperties({ user_type: 'member', - language: document.documentElement.lang || DEFAULT_LANG, + language: currentLang, }); } else if (memberId) { // accessToken 없이 memberId만 있으면 비회원 처리 @@ -66,9 +75,11 @@ function initializeApp() { // 언어 변경 시 user property 업데이트 i18n.on('languageChanged', (lng: string) => { + const changedLang = resolveCurrentLang(lng); + Sentry.setTag('language', changedLang); analyticsManager.setUserProperties({ user_type: getAccessToken() ? 'member' : 'guest', - language: lng, + language: changedLang, }); }); From 5889831e28a31a21ab12df4bcb1dc90a3ddf6c67 Mon Sep 17 00:00:00 2001 From: Shawn Kang <77564014+i-meant-to-be@users.noreply.github.com> Date: Sat, 11 Jul 2026 21:53:45 +0900 Subject: [PATCH 4/7] =?UTF-8?q?[FEAT]=20=EB=9D=BC=EC=9D=B4=EB=B8=8C=20?= =?UTF-8?q?=EA=B3=B5=EC=9C=A0=EC=9A=A9=20=ED=85=8C=EC=9D=B4=EB=B8=94=20?= =?UTF-8?q?=EB=8D=B0=EC=9D=B4=ED=84=B0=20GET=20API=20=EA=B5=AC=ED=98=84=20?= =?UTF-8?q?(#459)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: 응답 형식 추가 * feat: API 함수 및 테스트 코드 구현 * feat: API 사용을 위한 Hook 구현 * test: msw 모방 핸들러 추가 * fix: CI 오류 해결 * refactor: 타입 방어 강화 * refactor: API 함수 컨벤션에 맞게 수정 * fix: 기타 오류 수정 --- src/apis/apis/live.test.ts | 43 ++++++++ src/apis/apis/live.ts | 30 +++++- src/apis/responses/live.ts | 7 ++ src/hooks/query/useGetChairmanToken.ts | 2 +- .../useGetDebateTableDataForShare.test.tsx | 69 ++++++++++++ .../query/useGetDebateTableDataForShare.ts | 21 ++++ src/hooks/useModal.test.tsx | 29 ++++- src/hooks/useModal.tsx | 73 +++++++------ src/mocks/handlers/live.ts | 100 +++++++++++++++++- src/page/TimerPage/hooks/useTimerHotkey.ts | 4 +- 10 files changed, 337 insertions(+), 41 deletions(-) create mode 100644 src/apis/apis/live.test.ts create mode 100644 src/hooks/query/useGetDebateTableDataForShare.test.tsx create mode 100644 src/hooks/query/useGetDebateTableDataForShare.ts diff --git a/src/apis/apis/live.test.ts b/src/apis/apis/live.test.ts new file mode 100644 index 00000000..41944366 --- /dev/null +++ b/src/apis/apis/live.test.ts @@ -0,0 +1,43 @@ +import { http, HttpResponse } from 'msw'; +import { server } from '../../mocks/server'; +import { ApiUrl } from '../endpoints'; +import { GetDebateTableDataForShareResponseType } from '../responses/live'; +import { getDebateTableDataForShare } from './live'; + +const mockDebateTableForShare: GetDebateTableDataForShareResponseType = { + id: 302, + info: { + name: '공유용 자유토론 테이블', + agenda: '공유 토론 주제', + prosTeamName: '찬성', + consTeamName: '반대', + }, + table: [ + { + stance: 'NEUTRAL', + speechType: '자유토론', + bell: null, + boxType: 'TIME_BASED', + time: null, + timePerTeam: 60, + timePerSpeaking: 30, + speaker: null, + }, + ], +}; + +describe('라이브 공유 테이블 조회 API', () => { + test('테이블 ID로 공유용 토론 테이블 데이터를 조회한다', async () => { + server.use( + http.get(`${ApiUrl.live}/table/customize/:tableId`, ({ params }) => { + expect(params.tableId).toBe('302'); + + return HttpResponse.json(mockDebateTableForShare); + }), + ); + + const data = await getDebateTableDataForShare(302); + + expect(data).toEqual(mockDebateTableForShare); + }); +}); diff --git a/src/apis/apis/live.ts b/src/apis/apis/live.ts index 633e94e3..89891048 100644 --- a/src/apis/apis/live.ts +++ b/src/apis/apis/live.ts @@ -1,12 +1,34 @@ import { request } from '../primitives'; import { ApiUrl } from '../endpoints'; -import { GetChairmanTokenResponseType } from '../responses/live'; +import { + GetChairmanTokenResponseType, + GetDebateTableDataForShareResponseType, +} from '../responses/live'; -export const getChairmanToken = (tableId: string) => { - return request( +// GET /api/live/{tableId}/chairman-token +export async function getChairmanToken( + tableId: string, +): Promise { + const response = await request( 'GET', `${ApiUrl.live}/${encodeURIComponent(tableId)}/chairman-token`, null, null, ); -}; + + return response.data; +} + +// GET /api/live/table/customize/{tableId} +export async function getDebateTableDataForShare( + tableId: number, +): Promise { + const response = await request( + 'GET', + `${ApiUrl.live}/table/customize/${tableId}`, + null, + null, + ); + + return response.data; +} diff --git a/src/apis/responses/live.ts b/src/apis/responses/live.ts index 62ffe0cd..e05dd064 100644 --- a/src/apis/responses/live.ts +++ b/src/apis/responses/live.ts @@ -1,3 +1,10 @@ +import { DebateTableData } from '../../type/type'; + export interface GetChairmanTokenResponseType { chairmanToken: string; } + +// GET /api/live/table/customize/{tableId} +export interface GetDebateTableDataForShareResponseType extends DebateTableData { + id: number; +} diff --git a/src/hooks/query/useGetChairmanToken.ts b/src/hooks/query/useGetChairmanToken.ts index 856e8a7e..50eb7611 100644 --- a/src/hooks/query/useGetChairmanToken.ts +++ b/src/hooks/query/useGetChairmanToken.ts @@ -11,7 +11,7 @@ export default function useGetChairmanToken( return useQuery({ queryKey: chairmanTokenQueryKey(tableId), queryFn: async () => { - const { data } = await getChairmanToken(tableId); + const data = await getChairmanToken(tableId); return data.chairmanToken; }, enabled: enabled && Boolean(tableId), diff --git a/src/hooks/query/useGetDebateTableDataForShare.test.tsx b/src/hooks/query/useGetDebateTableDataForShare.test.tsx new file mode 100644 index 00000000..0a6cd855 --- /dev/null +++ b/src/hooks/query/useGetDebateTableDataForShare.test.tsx @@ -0,0 +1,69 @@ +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { renderHook, waitFor } from '@testing-library/react'; +import { ReactNode } from 'react'; +import { http, HttpResponse } from 'msw'; +import { server } from '../../mocks/server'; +import { ApiUrl } from '../../apis/endpoints'; +import { GetDebateTableDataForShareResponseType } from '../../apis/responses/live'; +import { useGetDebateTableDataForShare } from './useGetDebateTableDataForShare'; + +const mockDebateTableForShare: GetDebateTableDataForShareResponseType = { + id: 302, + info: { + name: '공유용 자유토론 테이블', + agenda: '공유 토론 주제', + prosTeamName: '찬성', + consTeamName: '반대', + }, + table: [ + { + stance: 'NEUTRAL', + speechType: '자유토론', + bell: null, + boxType: 'TIME_BASED', + time: null, + timePerTeam: 60, + timePerSpeaking: 30, + speaker: null, + }, + ], +}; + +function createWrapper(queryClient: QueryClient) { + return function Wrapper({ children }: { children: ReactNode }) { + return ( + {children} + ); + }; +} + +describe('useGetDebateTableDataForShare', () => { + test('공유용 토론 테이블 데이터를 조회하고 캐시에 저장한다', async () => { + const queryClient = new QueryClient({ + defaultOptions: { + queries: { + retry: false, + }, + }, + }); + + server.use( + http.get(`${ApiUrl.live}/table/customize/:tableId`, () => { + return HttpResponse.json(mockDebateTableForShare); + }), + ); + + const { result } = renderHook(() => useGetDebateTableDataForShare(302), { + wrapper: createWrapper(queryClient), + }); + + await waitFor(() => { + expect(result.current.isSuccess).toBe(true); + }); + + expect(result.current.data).toEqual(mockDebateTableForShare); + expect(queryClient.getQueryData(['DebateTableDataForShare', 302])).toEqual( + mockDebateTableForShare, + ); + }); +}); diff --git a/src/hooks/query/useGetDebateTableDataForShare.ts b/src/hooks/query/useGetDebateTableDataForShare.ts new file mode 100644 index 00000000..e500dc36 --- /dev/null +++ b/src/hooks/query/useGetDebateTableDataForShare.ts @@ -0,0 +1,21 @@ +import { useQuery } from '@tanstack/react-query'; +import { getDebateTableDataForShare } from '../../apis/apis/live'; +import { GetDebateTableDataForShareResponseType } from '../../apis/responses/live'; + +export const debateTableDataForShareQueryKey = (tableId: number | undefined) => + ['DebateTableDataForShare', tableId] as const; + +export function useGetDebateTableDataForShare( + tableId: number | undefined, + options?: { enabled?: boolean }, +) { + const isEnabled = + tableId !== undefined && !isNaN(tableId) && (options?.enabled ?? true); + + return useQuery({ + queryKey: debateTableDataForShareQueryKey(tableId), + queryFn: () => getDebateTableDataForShare(tableId!), // 위 isEnabled에서 tableId 타입 검증에서 !가 들어가도 됨 + enabled: isEnabled, + throwOnError: false, + }); +} diff --git a/src/hooks/useModal.test.tsx b/src/hooks/useModal.test.tsx index d3e5fd8e..735e6c8e 100644 --- a/src/hooks/useModal.test.tsx +++ b/src/hooks/useModal.test.tsx @@ -1,6 +1,7 @@ // useModal.test.tsx import { render, screen } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; +import { useState } from 'react'; import { useModal } from './useModal'; import { GlobalPortal } from '../util/GlobalPortal'; @@ -9,6 +10,7 @@ function TestModal({ }: { closeOnOverlayClick?: boolean; }) { + const [renderCount, setRenderCount] = useState(0); const { openModal, closeModal, ModalWrapper } = useModal({ closeOnOverlayClick, }); @@ -19,10 +21,16 @@ function TestModal({ + {/* 모달 렌더링 */} -
Hello Modal
+
+ Hello Modal + +
{/* 모달 닫기 (직접 호출 테스트용) */} @@ -122,4 +130,23 @@ describe('useModal Hook 테스트', () => { // 여전히 모달이 열린 상태 expect(screen.getByTestId('modal-content')).toBeInTheDocument(); }); + + test('부모가 리렌더되어도 열린 모달의 내부 상태를 유지한다.', async () => { + const user = userEvent.setup(); + render(); + + await user.click(screen.getByTestId('open-btn')); + const inputBeforeRerender = screen.getByRole('textbox', { + name: '모달 입력', + }); + await user.type(inputBeforeRerender, '입력값'); + + await user.click(screen.getByRole('button', { name: '부모 리렌더 0' })); + + const inputAfterRerender = screen.getByRole('textbox', { + name: '모달 입력', + }); + expect(inputAfterRerender).toBe(inputBeforeRerender); + expect(inputAfterRerender).toHaveValue('입력값'); + }); }); diff --git a/src/hooks/useModal.tsx b/src/hooks/useModal.tsx index 78f499ba..ec3d070d 100644 --- a/src/hooks/useModal.tsx +++ b/src/hooks/useModal.tsx @@ -9,6 +9,15 @@ interface UseModalOptions { onClose?: () => void; } +interface ModalWrapperProps { + children: ReactNode; + closeButtonColor?: string; +} + +// onClose가 없을 때 렌더마다 새 빈 함수를 만들면 연관된 콜백과 ModalWrapper의 참조도 바뀐다. +// 동일한 기본 함수를 재사용해 열린 모달이 불필요하게 재마운트되지 않도록 한다. +function noop() {} + /** * 모달을 쉽게 열고 닫을 수 있는 훅. * @param options 모달 표시 옵션 @@ -18,7 +27,7 @@ export function useModal(options: UseModalOptions = {}) { const { closeOnOverlayClick = true, isCloseButtonExist = true, - onClose = () => {}, + onClose = noop, } = options; const openModal = useCallback(() => { @@ -51,39 +60,39 @@ export function useModal(options: UseModalOptions = {}) { [closeModal, closeOnOverlayClick], ); - const ModalWrapper = ({ - children, - closeButtonColor = 'text-neutral-0 hover:text-gray-300', - }: { - children: ReactNode; - closeButtonColor?: string; - }) => { - const { t } = useTranslation(); - if (!isOpen) return null; + const ModalWrapper = useCallback( + function ModalWrapper({ + children, + closeButtonColor = 'text-neutral-0 hover:text-gray-300', + }: ModalWrapperProps) { + const { t } = useTranslation(); + if (!isOpen) return null; - return ( - -
-
- {children} - {isCloseButtonExist && ( - - )} + return ( + +
+
+ {children} + {isCloseButtonExist && ( + + )} +
-
- - ); - }; + + ); + }, + [closeModal, handleOverlayClick, isCloseButtonExist, isOpen], + ); return { isOpen, openModal, closeModal, ModalWrapper }; } diff --git a/src/mocks/handlers/live.ts b/src/mocks/handlers/live.ts index c0184665..2da1cff6 100644 --- a/src/mocks/handlers/live.ts +++ b/src/mocks/handlers/live.ts @@ -1,8 +1,106 @@ import { http, HttpResponse } from 'msw'; import { ApiUrl } from '../../apis/endpoints'; -import { GetChairmanTokenResponseType } from '../../apis/responses/live'; +import { + GetChairmanTokenResponseType, + GetDebateTableDataForShareResponseType, +} from '../../apis/responses/live'; export const liveHandlers = [ + http.get(`${ApiUrl.live}/table/customize/:tableId`, ({ params }) => { + const { tableId } = params; + console.log(`# tableId = ${tableId}`); + + const response: GetDebateTableDataForShareResponseType = { + id: 5, + info: { + name: '나의 자유토론 테이블', + agenda: '토론 주제', + prosTeamName: '찬성', + consTeamName: '반대', + }, + table: [ + { + stance: 'NEUTRAL', + speechType: '자유토론', + bell: null, + boxType: 'TIME_BASED', + time: null, + timePerTeam: 60, + timePerSpeaking: 33, + speaker: null, + }, + { + stance: 'NEUTRAL', + speechType: '자유토론', + bell: null, + boxType: 'TIME_BASED', + time: null, + timePerTeam: 35, + timePerSpeaking: null, + speaker: null, + }, + { + stance: 'PROS', + speechType: '입론', + bell: [ + { type: 'BEFORE_END', time: 0, count: 2 }, + { type: 'AFTER_START', time: 5, count: 1 }, + ], + boxType: 'NORMAL', + time: 120, + timePerTeam: null, + timePerSpeaking: null, + speaker: '발언자 1', + }, + { + stance: 'PROS', + speechType: '입론', + bell: [ + { type: 'BEFORE_END', time: 0, count: 2 }, + { type: 'AFTER_START', time: 7, count: 3 }, + ], + boxType: 'NORMAL', + time: 120, + timePerTeam: null, + timePerSpeaking: null, + speaker: '발언자 1', + }, + { + stance: 'NEUTRAL', + speechType: '작전 시간', + bell: [{ type: 'BEFORE_END', time: 0, count: 2 }], + boxType: 'NORMAL', + time: 60, + timePerTeam: null, + timePerSpeaking: null, + speaker: null, + }, + { + stance: 'CONS', + speechType: '최종 발언', + bell: [{ type: 'BEFORE_END', time: 0, count: 2 }], + boxType: 'NORMAL', + time: 120, + timePerTeam: null, + timePerSpeaking: null, + speaker: '발언자 2', + }, + { + stance: 'PROS', + speechType: '최종 발언', + bell: [{ type: 'BEFORE_END', time: 0, count: 2 }], + boxType: 'NORMAL', + time: 120, + timePerTeam: null, + timePerSpeaking: null, + speaker: '발언자 2', + }, + ], + }; + + return HttpResponse.json(response); + }), + http.get(`${ApiUrl.live}/:tableId/chairman-token`, ({ params }) => { const { tableId } = params; console.log(`# Table ID = ${tableId}`); diff --git a/src/page/TimerPage/hooks/useTimerHotkey.ts b/src/page/TimerPage/hooks/useTimerHotkey.ts index 21709580..c9c706fd 100644 --- a/src/page/TimerPage/hooks/useTimerHotkey.ts +++ b/src/page/TimerPage/hooks/useTimerHotkey.ts @@ -105,7 +105,7 @@ export function useTimerHotkey( onEvent(handleSwitching, 'TEAM_SWITCH'); } - + break; case 'KeyL': // 반대 진영 선택 및 찬성 타이머 정지 @@ -120,7 +120,7 @@ export function useTimerHotkey( onEvent(handleSwitching, 'TEAM_SWITCH'); } - + break; case 'Enter': case 'NumpadEnter': From 3063e11a29319b30408ca65c5abef7eccb95e4e0 Mon Sep 17 00:00:00 2001 From: Shawn Kang <77564014+i-meant-to-be@users.noreply.github.com> Date: Sat, 11 Jul 2026 22:01:11 +0900 Subject: [PATCH 5/7] =?UTF-8?q?[FEAT]=20=EC=B2=AD=EC=A4=91=EC=9A=A9=20?= =?UTF-8?q?=EB=9D=BC=EC=9D=B4=EB=B8=8C=20=ED=86=A0=EB=A1=A0=20=EA=B3=B5?= =?UTF-8?q?=EC=9C=A0=20=ED=8E=98=EC=9D=B4=EC=A7=80=20=EA=B5=AC=ED=98=84=20?= =?UTF-8?q?(#455)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * refactor: 소켓 오류 관련 개선 적용 * feat: 시간 포매팅 함수 추가 * feat: 청중 화면 베이스 타입 추가 * feat: 청중 화면 타이머 훅 추가 * feat: 서브컴포넌트 구현 * feat: 청중용 화면 관리를 위한 훅 구현 * feat: 청중용 화면 구현 * docs: 국제화 키-값 쌍 추가 * test: 테스트 설명 국문으로 변경 * fix: 리뷰 반영 * fix: 오류 해결 * refactor: 불필요한 ! 연산자 제거 * test: 코드 변경에 따른 테스트 코드 대응 --- public/locales/en/translation.json | 16 +- public/locales/ko/translation.json | 16 +- src/apis/sockets/SocketManager.test.ts | 222 ++++++++- src/apis/sockets/SocketManager.ts | 78 +++- src/apis/sockets/error.test.ts | 24 + src/apis/sockets/error.ts | 21 + src/apis/sockets/util.test.ts | 185 ++++++++ src/apis/sockets/util.ts | 61 ++- src/hooks/sockets/useAudienceSocket.test.ts | 139 +++++- src/hooks/sockets/useAudienceSocket.ts | 58 +-- src/hooks/sockets/useChairmanSocket.test.ts | 24 - src/hooks/sockets/useChairmanSocket.ts | 2 - src/hooks/sockets/useSocket.test.ts | 72 +++ src/hooks/sockets/useSocket.ts | 18 +- .../AudienceSharePage.test.tsx | 302 +++++++++++++ .../AudienceSharePage/AudienceSharePage.tsx | 170 +++++++ .../components/AudienceNormalTimer.test.tsx | 19 + .../components/AudienceNormalTimer.tsx | 23 + .../AudienceTimeBasedTimer.test.tsx | 66 +++ .../components/AudienceTimeBasedTimer.tsx | 89 ++++ src/page/AudienceSharePage/error.test.ts | 25 ++ src/page/AudienceSharePage/error.ts | 22 + .../hooks/useAudienceCountdown.test.ts | 101 +++++ .../hooks/useAudienceCountdown.ts | 61 +++ .../hooks/useAudienceShareState.test.ts | 422 ++++++++++++++++++ .../hooks/useAudienceShareState.ts | 204 +++++++++ src/page/TimerPage/TimerPage.tsx | 2 +- src/routes/routes.tsx | 4 +- src/util/formatting.test.ts | 26 ++ src/util/formatting.ts | 6 + 30 files changed, 2368 insertions(+), 110 deletions(-) create mode 100644 src/apis/sockets/error.test.ts create mode 100644 src/apis/sockets/error.ts create mode 100644 src/apis/sockets/util.test.ts create mode 100644 src/page/AudienceSharePage/AudienceSharePage.test.tsx create mode 100644 src/page/AudienceSharePage/AudienceSharePage.tsx create mode 100644 src/page/AudienceSharePage/components/AudienceNormalTimer.test.tsx create mode 100644 src/page/AudienceSharePage/components/AudienceNormalTimer.tsx create mode 100644 src/page/AudienceSharePage/components/AudienceTimeBasedTimer.test.tsx create mode 100644 src/page/AudienceSharePage/components/AudienceTimeBasedTimer.tsx create mode 100644 src/page/AudienceSharePage/error.test.ts create mode 100644 src/page/AudienceSharePage/error.ts create mode 100644 src/page/AudienceSharePage/hooks/useAudienceCountdown.test.ts create mode 100644 src/page/AudienceSharePage/hooks/useAudienceCountdown.ts create mode 100644 src/page/AudienceSharePage/hooks/useAudienceShareState.test.ts create mode 100644 src/page/AudienceSharePage/hooks/useAudienceShareState.ts create mode 100644 src/util/formatting.test.ts diff --git a/public/locales/en/translation.json b/public/locales/en/translation.json index e7200bfa..f73c967c 100644 --- a/public/locales/en/translation.json +++ b/public/locales/en/translation.json @@ -260,5 +260,19 @@ "503 서비스가 일시적으로 중단됨": "503 Service Unavailable", "504 게이트웨이 시간 초과": "504 Gateway Timeout", "나의 토론 주제": "My Debate Topic", - "나의 시간표": "My Timetable" + "나의 시간표": "My Timetable", + "유효하지 않은 토론방 ID입니다.": "Invalid debate room ID.", + "실시간 연결 주소를 확인할 수 없어요.": "Could not verify live connection address.", + "토론방 연결이 거부되었어요.": "Connection to the debate room was rejected.", + "실시간 연결에서 서버 오류가 발생했어요.": "A server error occurred during live connection.", + "실시간 연결을 복구하지 못했어요.": "Failed to recover the live connection.", + "토론 상태를 오래 받지 못했어요.": "Did not receive debate status for a long time.", + "서버에서 토론 진행 오류를 전달했어요.": "The server reported a debate progress error.", + "알 수 없는 실시간 오류가 발생했어요.": "An unknown live error occurred.", + "토론 시작을 대기 중입니다.": "Waiting for the debate to start.", + "토론이 종료되었습니다.": "The debate has ended.", + "페이지 닫기": "Close page", + "남은 시간": "Time Remaining", + "현재 발언 중": "Currently speaking", + "발언 중": "Speaking" } diff --git a/public/locales/ko/translation.json b/public/locales/ko/translation.json index 1c50a816..c0267a24 100644 --- a/public/locales/ko/translation.json +++ b/public/locales/ko/translation.json @@ -260,5 +260,19 @@ "503 서비스가 일시적으로 중단됨": "503 서비스가 일시적으로 중단됨", "504 게이트웨이 시간 초과": "504 게이트웨이 시간 초과", "나의 토론 주제": "나의 토론 주제", - "나의 시간표": "나의 시간표" + "나의 시간표": "나의 시간표", + "유효하지 않은 토론방 ID입니다.": "유효하지 않은 토론방 ID입니다.", + "실시간 연결 주소를 확인할 수 없어요.": "실시간 연결 주소를 확인할 수 없어요.", + "토론방 연결이 거부되었어요.": "토론방 연결이 거부되었어요.", + "실시간 연결에서 서버 오류가 발생했어요.": "실시간 연결에서 서버 오류가 발생했어요.", + "실시간 연결을 복구하지 못했어요.": "실시간 연결을 복구하지 못했어요.", + "토론 상태를 오래 받지 못했어요.": "토론 상태를 오래 받지 못했어요.", + "서버에서 토론 진행 오류를 전달했어요.": "서버에서 토론 진행 오류를 전달했어요.", + "알 수 없는 실시간 오류가 발생했어요.": "알 수 없는 실시간 오류가 발생했어요.", + "토론 시작을 대기 중입니다.": "토론 시작을 대기 중입니다.", + "토론이 종료되었습니다.": "토론이 종료되었습니다.", + "페이지 닫기": "페이지 닫기", + "남은 시간": "남은 시간", + "현재 발언 중": "현재 발언 중", + "발언 중": "발언 중" } diff --git a/src/apis/sockets/SocketManager.test.ts b/src/apis/sockets/SocketManager.test.ts index a6f8e5e5..c925782d 100644 --- a/src/apis/sockets/SocketManager.test.ts +++ b/src/apis/sockets/SocketManager.test.ts @@ -71,7 +71,7 @@ const getLatestClient = () => clientInstances[clientInstances.length - 1]; // 3. 테스트 스위트 // ------------------------------------------------------------------ -describe('SocketManager', () => { +describe('소켓 관리자 테스트', () => { beforeEach(() => { /** * 순서 중요: @@ -90,29 +90,29 @@ describe('SocketManager', () => { }); // --- [그룹 1] 연결 & 해제 --- - describe('Connection & Disconnection', () => { - it('소켓 연결 시도 시 클라이언트 인스턴스를 생성하고, 인스턴스 생성 여부를 검증해야 한다', () => { + describe('연결 및 연결 종료', () => { + it('소켓 연결 시 클라이언트 객체를 만들고 실행해야 한다', () => { socketManager.connect(); expect(clientInstances).toHaveLength(1); expect(getLatestClient().activate).toHaveBeenCalledOnce(); }); - it('소켓 연결 시도 시 주어진 url을 SockJS의 연결 주소로 사용해야 한다', () => { + it('직접 지정한 주소로 소켓에 연결해야 한다', () => { socketManager.connect({ url: 'https://socket.example.com/ws' }); getLatestClient().config.webSocketFactory?.(); expect(SockJS).toHaveBeenCalledWith('https://socket.example.com/ws'); }); - it('소켓 연결 시도 시 주어진 baseUrl을 /ws 경로와 조합해 SockJS 연결 주소로 사용해야 한다', () => { + it('기본 주소에 소켓 경로를 붙여 연결해야 한다', () => { socketManager.connect({ baseUrl: 'https://socket.example.com' }); getLatestClient().config.webSocketFactory?.(); expect(SockJS).toHaveBeenCalledWith('https://socket.example.com/ws'); }); - it('소켓 연결 시도 시 url과 baseUrl이 제공되지 않은 경우 환경 변수 기반 주소를 사용해야 한다', () => { + it('주소가 없으면 환경 변수 기반 주소로 연결해야 한다', () => { socketManager.connect(); getLatestClient().config.webSocketFactory?.(); @@ -127,7 +127,7 @@ describe('SocketManager', () => { expect(clientInstances).toHaveLength(1); }); - it('연결을 끊을 때, 클라이언트 인스턴스의 비활성화 여부를 검증해야 한다', () => { + it('연결을 끊을 때 클라이언트를 비활성화해야 한다', () => { socketManager.connect(); const client = getLatestClient(); @@ -136,7 +136,7 @@ describe('SocketManager', () => { expect(client.deactivate).toHaveBeenCalledOnce(); }); - it('연결을 끊고 재연결하면, 새 클라이언트 인스턴스를 생성해야 한다', () => { + it('연결을 끊고 다시 연결하면 새 클라이언트를 만들어야 한다', () => { socketManager.connect(); socketManager.disconnect(); socketManager.connect(); @@ -147,7 +147,7 @@ describe('SocketManager', () => { }); // --- [그룹 2] 지수 백오프 재연결 --- - describe('Reconnection & Full Jitter Backoff', () => { + describe('재연결 대기 시간', () => { afterEach(() => { vi.restoreAllMocks(); }); @@ -161,11 +161,12 @@ describe('SocketManager', () => { * 1번째 close: calculateBackoffDelay(0) = floor(0.5 * 1000 * 2^0) = 500 + 10 → retryCount: 0 → 1 * 2번째 close: calculateBackoffDelay(1) = floor(0.5 * 1000 * 2^1) = 1000 + 10 → retryCount: 1 → 2 */ - it('웹 소켓 연결이 끊기면 재시도 전에 서버 부하를 방지하기 위해 지수 백오프 딜레이를 갱신해야 한다', () => { + it('연결이 끊기면 서버 부담을 줄이도록 재시도 대기 시간을 늘려야 한다', () => { vi.spyOn(Math, 'random').mockReturnValue(0.5); socketManager.connect({ baseRetryDelayMs: 1000 }); const client = getLatestClient(); + client.config.onConnect?.({} as IFrame); // 연결 성공 상태로 만들기 // 초기 딜레이: calculateBackoffDelay(0) = 500 + 10 expect(client.reconnectDelay).toBe(500 + 10); @@ -179,9 +180,10 @@ describe('SocketManager', () => { expect(client.reconnectDelay).toBe(1000 + 10); }); - it('최대 재시도 횟수 초과 시 재시도 딜레이를 0으로 설정하고 연결을 해제해야 한다', () => { + it('최대 재시도 횟수를 넘으면 재시도를 멈추고 연결을 해제해야 한다', () => { socketManager.connect({ maxRetries: 3 }); const client = getLatestClient(); + client.config.onConnect?.({} as IFrame); // 연결 성공 상태로 만들기 // 3번까지는 아직 maxRetries 미초과 → deactivate 호출 안 됨 for (let i = 0; i < 3; i++) { @@ -199,6 +201,7 @@ describe('SocketManager', () => { vi.spyOn(Math, 'random').mockReturnValue(0.5); socketManager.connect({ baseRetryDelayMs: 1000, maxRetries: 5 }); const client = getLatestClient(); + client.config.onConnect?.({} as IFrame); // 처음 연결 성공 상태로 만들기 // 끊김 2번 → retryCount = 2 client.config.onWebSocketClose?.({} as CloseEvent); @@ -214,8 +217,8 @@ describe('SocketManager', () => { }); // --- [그룹 3] 구독 & 발행 --- - describe('Subscribe & Publish', () => { - it('연결이 되지 않았는데 채널 구독 시, null을 반환해야 한다', () => { + describe('구독 및 메시지 발행', () => { + it('연결 전 채널 구독은 빈 결과를 반환해야 한다', () => { const result = socketManager.subscribe('/topic/test', vi.fn()); expect(result).toBeNull(); }); @@ -236,7 +239,7 @@ describe('SocketManager', () => { expect(clientInstances).toHaveLength(0); }); - it('연결이 성립된 후 메시지를 발행할 시, 직렬화된 JSON 데이터를 전송해야 한다', () => { + it('연결 후 메시지를 발행하면 직렬화된 데이터를 전송해야 한다', () => { socketManager.connect(); const client = getLatestClient(); client.connected = true; @@ -260,8 +263,8 @@ describe('SocketManager', () => { }); }); - describe('Connect Listeners (Observer Pattern)', () => { - it('소켓 연결 시 동작해야 할 함수 목록에 등록한 리스너는, 연결 시 호출되어야 한다', () => { + describe('연결 이벤트 리스너', () => { + it('연결 리스너를 등록하면 연결 시 호출되어야 한다', () => { const listener = vi.fn(); socketManager.onConnectEvent(listener); socketManager.connect(); @@ -272,7 +275,7 @@ describe('SocketManager', () => { expect(listener).toHaveBeenCalledOnce(); }); - it('소켓 연결 시 동작해야 할 함수 목록에서 삭제한 리스너는, 연결 시 호출되지 않아야 한다', () => { + it('연결 리스너를 해제하면 연결 시 호출되지 않아야 한다', () => { const listener = vi.fn(); socketManager.onConnectEvent(listener); socketManager.offConnectEvent(listener); @@ -284,7 +287,7 @@ describe('SocketManager', () => { expect(listener).not.toHaveBeenCalled(); }); - it('소켓 연결 종료 시 동작해야 할 함수 목록에 등록한 리스너는, 연결 종료 시 호출되어야 한다', () => { + it('연결 종료 리스너를 등록하면 연결 종료 시 호출되어야 한다', () => { const listener = vi.fn(); socketManager.onCloseEvent(listener); socketManager.connect(); @@ -295,7 +298,7 @@ describe('SocketManager', () => { expect(listener).toHaveBeenCalledOnce(); }); - it('소켓 연결 종료 시 동작해야 할 함수 목록에서 삭제한 리스너는, 연결 종료 시 시 호출되지 않아야 한다', () => { + it('연결 종료 리스너를 해제하면 연결 종료 시 호출되지 않아야 한다', () => { const listener = vi.fn(); socketManager.onCloseEvent(listener); socketManager.offCloseEvent(listener); @@ -307,4 +310,185 @@ describe('SocketManager', () => { expect(listener).not.toHaveBeenCalled(); }); }); + + describe('오류 발생 및 이벤트 발행', () => { + let consoleErrorSpy: ReturnType; + + beforeEach(() => { + consoleErrorSpy = vi + .spyOn(console, 'error') + .mockImplementation(() => undefined); + }); + + afterEach(() => { + consoleErrorSpy.mockRestore(); + }); + + it('오류 리스너를 등록하면 소켓 오류를 받고 해제 후에는 호출되지 않아야 한다', () => { + const listener = vi.fn(); + socketManager.onErrorEvent(listener); + + // trigger error by failing URL + socketManager.connect({ url: '', baseUrl: '' }); + expect(listener).toHaveBeenCalledOnce(); + const error = listener.mock.calls[0][0]; + expect(error.code).toBe('SOCKET_URL_UNAVAILABLE'); + expect(consoleErrorSpy).toHaveBeenCalledWith( + '[SocketManager] 소켓 오류 발생', + expect.objectContaining({ + code: 'SOCKET_URL_UNAVAILABLE', + message: '웹소켓 연결 주소를 결정할 수 없습니다.', + detail: undefined, + }), + ); + + listener.mockClear(); + consoleErrorSpy.mockClear(); + socketManager.offErrorEvent(listener); + socketManager.connect({ url: '', baseUrl: '' }); + expect(listener).not.toHaveBeenCalled(); + expect(consoleErrorSpy).toHaveBeenCalledWith( + '[SocketManager] 소켓 오류 발생', + expect.objectContaining({ + code: 'SOCKET_URL_UNAVAILABLE', + }), + ); + }); + + it('연결 주소를 결정할 수 없으면 주소 없음 오류를 한 번 발행해야 한다', () => { + const listener = vi.fn(); + socketManager.onErrorEvent(listener); + + socketManager.connect({ url: '', baseUrl: '' }); + + expect(listener).toHaveBeenCalledOnce(); + const error = listener.mock.calls[0][0]; + expect(error.code).toBe('SOCKET_URL_UNAVAILABLE'); + expect(consoleErrorSpy).toHaveBeenCalledWith( + '[SocketManager] 소켓 오류 발생', + expect.objectContaining({ + code: 'SOCKET_URL_UNAVAILABLE', + message: '웹소켓 연결 주소를 결정할 수 없습니다.', + detail: undefined, + }), + ); + }); + + it('실시간 통신 오류가 발생하면 오류 메시지와 본문 상세를 함께 발행해야 한다', () => { + const listener = vi.fn(); + socketManager.onErrorEvent(listener); + socketManager.connect(); + + const client = getLatestClient(); + client.config.onStompError?.({ + headers: { message: 'stomp msg' }, + body: 'body content', + } as unknown as IFrame); + + expect(listener).toHaveBeenCalledOnce(); + const error = listener.mock.calls[0][0]; + expect(error.code).toBe('SOCKET_STOMP_ERROR'); + expect(error.message).toBe('stomp msg'); + expect(error.detail).toEqual({ + headers: { message: 'stomp msg' }, + body: 'body content', + }); + expect(consoleErrorSpy).toHaveBeenCalledWith( + '[SocketManager] 소켓 오류 발생', + expect.objectContaining({ + code: 'SOCKET_STOMP_ERROR', + message: 'stomp msg', + detail: { + headers: { message: 'stomp msg' }, + body: 'body content', + }, + }), + ); + }); + + it('한 번도 연결에 성공하지 못한 채 종료되면 서버 거부 오류를 발행해야 한다', () => { + const listener = vi.fn(); + socketManager.onErrorEvent(listener); + socketManager.connect(); + + const client = getLatestClient(); + client.config.onWebSocketClose?.({} as CloseEvent); + + expect(listener).toHaveBeenCalledOnce(); + const error = listener.mock.calls[0][0]; + expect(error.code).toBe('SOCKET_SERVER_REJECTED'); + expect(consoleErrorSpy).toHaveBeenCalledWith( + '[SocketManager] 소켓 오류 발생', + expect.objectContaining({ + code: 'SOCKET_SERVER_REJECTED', + message: + '서버에 의해 웹소켓 연결이 거부되었거나 즉시 종료되었습니다.', + detail: undefined, + }), + ); + }); + + it('연결 성공 후 종료되면 재시도 횟수가 남은 동안 오류 없이 재시도해야 한다', () => { + const listener = vi.fn(); + socketManager.onErrorEvent(listener); + socketManager.connect({ maxRetries: 2 }); + + const client = getLatestClient(); + client.config.onConnect?.({} as IFrame); // 연결 성공 + + // 1차 끊김: 오류 발행 안됨 + client.config.onWebSocketClose?.({} as CloseEvent); + expect(listener).not.toHaveBeenCalled(); + + // 2차 끊김: 오류 발행 안됨 + client.config.onWebSocketClose?.({} as CloseEvent); + expect(listener).not.toHaveBeenCalled(); + + // 3차 끊김: 재시도 초과 -> 오류 발행 + client.config.onWebSocketClose?.({} as CloseEvent); + expect(listener).toHaveBeenCalledOnce(); + const error = listener.mock.calls[0][0]; + expect(error.code).toBe('SOCKET_RETRY_EXHAUSTED'); + expect(consoleErrorSpy).toHaveBeenCalledWith( + '[SocketManager] 소켓 오류 발생', + expect.objectContaining({ + code: 'SOCKET_RETRY_EXHAUSTED', + message: '최대 재연결 시도 횟수를 초과했습니다.', + detail: undefined, + }), + ); + }); + + it('상태 확인 신호 누락으로 종료되어도 같은 재시도 정책을 따라야 한다', () => { + const listener = vi.fn(); + socketManager.onErrorEvent(listener); + socketManager.connect({ maxRetries: 1 }); + + const client = getLatestClient(); + client.config.onConnect?.({} as IFrame); // 연결 성공 + + // 하트비트 누락에 의한 1차 끊김: 오류 발행 안됨 + client.config.onWebSocketClose?.({} as CloseEvent); + expect(listener).not.toHaveBeenCalled(); + + // 2차 끊김: 재시도 초과 -> 오류 발행 + client.config.onWebSocketClose?.({} as CloseEvent); + expect(listener).toHaveBeenCalledOnce(); + const error = listener.mock.calls[0][0]; + expect(error.code).toBe('SOCKET_RETRY_EXHAUSTED'); + }); + + it('명시적으로 연결을 끊은 뒤에는 늦게 도착한 이벤트가 오류를 발행하지 않아야 한다', () => { + const listener = vi.fn(); + socketManager.onErrorEvent(listener); + socketManager.connect(); + + const client = getLatestClient(); + socketManager.disconnect(); + + // 과거 세션 이벤트 + client.config.onWebSocketClose?.({} as CloseEvent); + expect(listener).not.toHaveBeenCalled(); + }); + }); }); diff --git a/src/apis/sockets/SocketManager.ts b/src/apis/sockets/SocketManager.ts index 77a58b6e..88a3a68f 100644 --- a/src/apis/sockets/SocketManager.ts +++ b/src/apis/sockets/SocketManager.ts @@ -1,6 +1,7 @@ import { Client, IMessage, StompHeaders } from '@stomp/stompjs'; import SockJS from 'sockjs-client'; import { SocketMessage } from './type'; +import { SocketError } from './error'; /** * 소켓 설정을 정하는 인터페이스 @@ -72,6 +73,9 @@ class SocketManager { // - 발행자(publisher)는 재연결 여부를 알리는 이 클래스 (SocketManager) private connectListeners: Set<() => void> = new Set(); private closeListeners: Set<() => void> = new Set(); + private errorListeners: Set<(error: SocketError) => void> = new Set(); + + private hasConnected: boolean = false; /** * 관찰자를 등록하는 함수 @@ -105,6 +109,36 @@ class SocketManager { this.closeListeners.delete(listener); } + public onErrorEvent(listener: (error: SocketError) => void) { + this.errorListeners.add(listener); + } + + public offErrorEvent(listener: (error: SocketError) => void) { + this.errorListeners.delete(listener); + } + + private dispatchError(error: SocketError) { + this.errorListeners.forEach((listener) => { + try { + listener(error); + } catch (e) { + console.error( + '오류 리스너 실행 중 오류 발생. 다음 리스너로 진행합니다.', + e, + ); + } + }); + } + + private reportSocketError(error: SocketError) { + console.error('[SocketManager] 소켓 오류 발생', { + code: error.code, + message: error.message, + detail: error.detail, + }); + this.dispatchError(error); + } + /** * 연결 여부를 확인하는 함수 * @returns 연결 여부 @@ -129,12 +163,16 @@ class SocketManager { // 사용자가 지정한 옵션이 있다면 덮어쓰기 this.retryCount = 0; + this.hasConnected = false; this.currentOptions = { ...DEFAULT_OPTIONS, ...options }; const wsUrl = this.resolveWebSocketUrl(this.currentOptions); if (!wsUrl) { - console.error( - '웹소켓 연결 주소를 결정할 수 없습니다. url 또는 baseUrl 옵션, 혹은 VITE_API_BASE_URL 환경 변수를 확인해주세요.', + this.reportSocketError( + new SocketError( + 'SOCKET_URL_UNAVAILABLE', + '웹소켓 연결 주소를 결정할 수 없습니다.', + ), ); return; } @@ -158,6 +196,7 @@ class SocketManager { onConnect: () => { console.log('✅ 웹 소켓(STOMP) 연결 성공'); this.retryCount = 0; + this.hasConnected = true; // 모든 관찰자에게 연결이 수립되었다고 알림 this.connectListeners.forEach((listener) => { @@ -170,8 +209,13 @@ class SocketManager { }, onStompError: (frame) => { - console.error('❌ 브로커 에러 발생:', frame.headers['message']); - console.error('상세 내용:', frame.body); + this.reportSocketError( + new SocketError( + 'SOCKET_STOMP_ERROR', + frame.headers['message'] || 'STOMP 오류 발생', + { headers: frame.headers, body: frame.body }, + ), + ); }, onWebSocketClose: () => { @@ -192,6 +236,24 @@ class SocketManager { ); } }); + + if (!this.hasConnected) { + this.reportSocketError( + new SocketError( + 'SOCKET_SERVER_REJECTED', + '서버에 의해 웹소켓 연결이 거부되었거나 즉시 종료되었습니다.', + ), + ); + + if (this.client) { + this.client.reconnectDelay = 0; + const clientToDeactivate = this.client; + this.client = null; + clientToDeactivate.deactivate(); + } + return; + } + this.handleReconnection(); }, }); @@ -215,6 +277,7 @@ class SocketManager { this.currentOptions = DEFAULT_OPTIONS; this.connectListeners.clear(); this.closeListeners.clear(); + this.errorListeners.clear(); console.log('🛑 웹 소켓 연결을 수동으로 해제했습니다.'); } @@ -268,8 +331,11 @@ class SocketManager { // 재시도 횟수를 초과했을 경우 연결을 즉시 종료 if (this.retryCount >= this.currentOptions.maxRetries) { - console.error( - '🚨 최대 재연결 시도 횟수를 초과했습니다. 연결을 포기합니다.', + this.reportSocketError( + new SocketError( + 'SOCKET_RETRY_EXHAUSTED', + '최대 재연결 시도 횟수를 초과했습니다.', + ), ); this.client.reconnectDelay = 0; diff --git a/src/apis/sockets/error.test.ts b/src/apis/sockets/error.test.ts new file mode 100644 index 00000000..9d011c10 --- /dev/null +++ b/src/apis/sockets/error.test.ts @@ -0,0 +1,24 @@ +import { describe, it, expect } from 'vitest'; +import { SocketError, isSocketError } from './error'; + +describe('SocketError 테스트', () => { + it('SocketError 인스턴스는 지정된 코드와 상세 내용을 가져야 한다', () => { + const error = new SocketError('SOCKET_URL_UNAVAILABLE', 'Message', { + foo: 'bar', + }); + expect(error.code).toBe('SOCKET_URL_UNAVAILABLE'); + expect(error.message).toBe('Message'); + expect(error.detail).toEqual({ foo: 'bar' }); + expect(error.name).toBe('SocketError'); + }); + + it('isSocketError는 SocketError 인스턴스에 대해 true를 반환한다', () => { + const error = new SocketError('SOCKET_STOMP_ERROR', 'Message'); + expect(isSocketError(error)).toBe(true); + }); + + it('isSocketError는 일반 Error 인스턴스에 대해 false를 반환한다', () => { + const error = new Error('Message'); + expect(isSocketError(error)).toBe(false); + }); +}); diff --git a/src/apis/sockets/error.ts b/src/apis/sockets/error.ts new file mode 100644 index 00000000..e76992ae --- /dev/null +++ b/src/apis/sockets/error.ts @@ -0,0 +1,21 @@ +export type SocketErrorCode = + | 'SOCKET_URL_UNAVAILABLE' + | 'SOCKET_SERVER_REJECTED' + | 'SOCKET_STOMP_ERROR' + | 'SOCKET_RETRY_EXHAUSTED'; + +export class SocketError extends Error { + public readonly code: SocketErrorCode; + public readonly detail?: unknown; + + constructor(code: SocketErrorCode, message: string, detail?: unknown) { + super(message); + this.name = 'SocketError'; + this.code = code; + this.detail = detail; + } +} + +export function isSocketError(error: unknown): error is SocketError { + return error instanceof SocketError; +} diff --git a/src/apis/sockets/util.test.ts b/src/apis/sockets/util.test.ts new file mode 100644 index 00000000..4c112e84 --- /dev/null +++ b/src/apis/sockets/util.test.ts @@ -0,0 +1,185 @@ +import { describe, it, expect } from 'vitest'; +import { isSocketMessage } from './util'; + +describe('소켓 메시지 여부 검증', () => { + it('유효한 모든 타이머 이벤트 메시지는 런타임 검증을 통과한다', () => { + expect( + isSocketMessage({ + eventType: 'PLAY', + data: { + timerType: 'NORMAL', + sequence: 1, + remainingTime: 10, + }, + }), + ).toBe(true); + + expect( + isSocketMessage({ + eventType: 'NEXT', + data: { + timerType: 'TIME_BASED', + sequence: 0, + currentTeam: 'PROS', + remainingTime: 300, + }, + }), + ).toBe(true); + }); + + it('FINISHED와 ERROR는 data가 null일 때만 런타임 검증을 통과한다', () => { + expect( + isSocketMessage({ + eventType: 'FINISHED', + data: null, + }), + ).toBe(true); + + expect( + isSocketMessage({ + eventType: 'ERROR', + data: null, + }), + ).toBe(true); + }); + + it('잘못된 이벤트 값, 페이로드 타입, CUSTOMIZE 타이머 타입은 런타임 검증에서 거부된다', () => { + // Missing eventType + expect(isSocketMessage({ data: null })).toBe(false); + // Unknown eventType + expect(isSocketMessage({ eventType: 'UNKNOWN', data: null })).toBe(false); + + // Timer event with data: null + expect( + isSocketMessage({ + eventType: 'PLAY', + data: null, + }), + ).toBe(false); + + // Non-timer event with non-null data + expect( + isSocketMessage({ + eventType: 'FINISHED', + data: { + timerType: 'NORMAL', + sequence: 1, + remainingTime: 10, + }, + }), + ).toBe(false); + + // CUSTOMIZE timerType + expect( + isSocketMessage({ + eventType: 'PLAY', + data: { + timerType: 'CUSTOMIZE', + sequence: 1, + remainingTime: 10, + }, + }), + ).toBe(false); + + // Invalid sequence (e.g. NaN, string) + expect( + isSocketMessage({ + eventType: 'PLAY', + data: { + timerType: 'NORMAL', + sequence: NaN, + remainingTime: 10, + }, + }), + ).toBe(false); + + expect( + isSocketMessage({ + eventType: 'PLAY', + data: { + timerType: 'NORMAL', + sequence: '1', + remainingTime: 10, + }, + }), + ).toBe(false); + + // Invalid remainingTime + expect( + isSocketMessage({ + eventType: 'PLAY', + data: { + timerType: 'NORMAL', + sequence: 1, + remainingTime: Infinity, + }, + }), + ).toBe(false); + }); + + it('currentTeam은 누락되거나 PROS 또는 CONS일 때만 허용되며 NEUTRAL은 런타임 검증에서 거부된다', () => { + // Missing currentTeam is valid + expect( + isSocketMessage({ + eventType: 'PLAY', + data: { + timerType: 'NORMAL', + sequence: 1, + remainingTime: 10, + }, + }), + ).toBe(true); + + // PROS is valid + expect( + isSocketMessage({ + eventType: 'PLAY', + data: { + timerType: 'NORMAL', + sequence: 1, + remainingTime: 10, + currentTeam: 'PROS', + }, + }), + ).toBe(true); + + // CONS is valid + expect( + isSocketMessage({ + eventType: 'PLAY', + data: { + timerType: 'NORMAL', + sequence: 1, + remainingTime: 10, + currentTeam: 'CONS', + }, + }), + ).toBe(true); + + // NEUTRAL is invalid + expect( + isSocketMessage({ + eventType: 'PLAY', + data: { + timerType: 'NORMAL', + sequence: 1, + remainingTime: 10, + currentTeam: 'NEUTRAL', + }, + }), + ).toBe(false); + + // Random string is invalid + expect( + isSocketMessage({ + eventType: 'PLAY', + data: { + timerType: 'NORMAL', + sequence: 1, + remainingTime: 10, + currentTeam: 'INVALID', + }, + }), + ).toBe(false); + }); +}); diff --git a/src/apis/sockets/util.ts b/src/apis/sockets/util.ts index b1d23189..76b7983e 100644 --- a/src/apis/sockets/util.ts +++ b/src/apis/sockets/util.ts @@ -1,4 +1,9 @@ -import { NonTimerEventType, SocketEventType, TimerEventTypes } from './type'; +import { + NonTimerEventType, + SocketEventType, + TimerEventTypes, + SocketMessage, +} from './type'; const TIMER_EVENT_TYPES: TimerEventTypes[] = [ 'NEXT', @@ -22,3 +27,57 @@ export function isNonTimerEventType( ): event is NonTimerEventType { return NON_TIMER_EVENT_TYPES.includes(event as NonTimerEventType); } + +export function isSocketMessage(value: unknown): value is SocketMessage { + if (typeof value !== 'object' || value === null) { + return false; + } + + const obj = value as Record; + const eventType = obj.eventType as SocketEventType; + + if (isTimerEventType(eventType)) { + const data = obj.data as Record; + if (typeof data !== 'object' || data === null) { + return false; + } + + if (data.timerType !== 'NORMAL' && data.timerType !== 'TIME_BASED') { + return false; + } + + if (typeof data.sequence !== 'number' || !Number.isFinite(data.sequence)) { + return false; + } + + if ( + typeof data.remainingTime !== 'number' || + !Number.isFinite(data.remainingTime) + ) { + return false; + } + + if ( + data.currentTeam !== undefined && + data.currentTeam !== null && + data.currentTeam !== 'PROS' && + data.currentTeam !== 'CONS' + ) { + return false; + } + + if (data.timerType === 'TIME_BASED') { + if (data.currentTeam !== 'PROS' && data.currentTeam !== 'CONS') { + return false; + } + } + + return true; + } + + if (isNonTimerEventType(eventType)) { + return obj.data === null; + } + + return false; +} diff --git a/src/hooks/sockets/useAudienceSocket.test.ts b/src/hooks/sockets/useAudienceSocket.test.ts index 7f8d74a0..a41e4245 100644 --- a/src/hooks/sockets/useAudienceSocket.test.ts +++ b/src/hooks/sockets/useAudienceSocket.test.ts @@ -1,6 +1,14 @@ import { act, renderHook } from '@testing-library/react'; import type { IMessage } from '@stomp/stompjs'; -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { + afterEach, + beforeEach, + describe, + expect, + it, + vi, + MockInstance, +} from 'vitest'; import type { SocketMessage } from '../../apis/sockets/type'; import useAudienceSocket from './useAudienceSocket'; @@ -17,9 +25,13 @@ describe('useAudienceSocket', () => { const unsubscribe = vi.fn(); const addConnectionListener = vi.fn(); + let consoleLogSpy: MockInstance; + beforeEach(() => { vi.clearAllMocks(); - vi.spyOn(window, 'alert').mockImplementation(() => undefined); + consoleLogSpy = vi + .spyOn(console, 'log') + .mockImplementation(() => undefined); addConnectionListener.mockImplementation(() => vi.fn()); useSocketMock.mockReturnValue({ connect, @@ -27,6 +39,7 @@ describe('useAudienceSocket', () => { subscribe, unsubscribe, addConnectionListener, + isConnected: true, error: null, }); }); @@ -41,7 +54,7 @@ describe('useAudienceSocket', () => { expect(subscribe).toHaveBeenCalledWith('/room/123', expect.any(Function)); }); - it('메시지를 수신하면 messages 상태를 업데이트해야 한다', () => { + it('유효한 메시지를 수신하면 latestMessage 상태를 업데이트해야 한다', () => { const message: SocketMessage = { eventType: 'FINISHED', data: null, @@ -59,10 +72,103 @@ describe('useAudienceSocket', () => { handleMessage({ body: JSON.stringify(message) } as IMessage); }); - expect(result.current.messages).toEqual([message]); + expect(result.current.latestMessage).toEqual(message); + }); + + it('유효한 메시지를 여러 번 수신하면 가장 최근 메시지만 노출해야 한다', () => { + const message1: SocketMessage = { + eventType: 'FINISHED', + data: null, + }; + const message2: SocketMessage = { + eventType: 'ERROR', + data: null, + }; + let handleMessage: (message: IMessage) => void = () => undefined; + subscribe.mockImplementation( + (_destination: string, callback: (message: IMessage) => void) => { + handleMessage = callback; + }, + ); + + const { result } = renderHook(() => useAudienceSocket(123)); + + act(() => { + handleMessage({ body: JSON.stringify(message1) } as IMessage); + }); + + expect(result.current.latestMessage).toEqual(message1); + + act(() => { + handleMessage({ body: JSON.stringify(message2) } as IMessage); + }); + + expect(result.current.latestMessage).toEqual(message2); + }); + + it('잘못된 JSON을 수신하면 console.log를 호출하고 마지막 정상 메시지를 유지해야 한다', () => { + const validMessage: SocketMessage = { + eventType: 'FINISHED', + data: null, + }; + let handleMessage: (message: IMessage) => void = () => undefined; + subscribe.mockImplementation( + (_destination: string, callback: (message: IMessage) => void) => { + handleMessage = callback; + }, + ); + + const { result } = renderHook(() => useAudienceSocket(123)); + + act(() => { + handleMessage({ body: JSON.stringify(validMessage) } as IMessage); + }); + + act(() => { + handleMessage({ body: '{ invalid_json }' } as IMessage); + }); + + expect(consoleLogSpy).toHaveBeenCalledWith( + '메시지 파싱 오류:', + expect.any(Error), + ); + expect(result.current.latestMessage).toEqual(validMessage); }); - it('connect 호출 전에 기존 messages 상태를 초기화해야 한다', () => { + it('계약 불일치 메시지(타입 가드 실패)를 수신하면 console.log를 호출하고 마지막 정상 메시지를 유지해야 한다', () => { + const validMessage: SocketMessage = { + eventType: 'FINISHED', + data: null, + }; + const invalidMessage = { + eventType: 'UNKNOWN_EVENT', + data: { some: 'data' }, + }; + let handleMessage: (message: IMessage) => void = () => undefined; + subscribe.mockImplementation( + (_destination: string, callback: (message: IMessage) => void) => { + handleMessage = callback; + }, + ); + + const { result } = renderHook(() => useAudienceSocket(123)); + + act(() => { + handleMessage({ body: JSON.stringify(validMessage) } as IMessage); + }); + + act(() => { + handleMessage({ body: JSON.stringify(invalidMessage) } as IMessage); + }); + + expect(consoleLogSpy).toHaveBeenCalledWith( + '잘못된 소켓 메시지 형식입니다:', + invalidMessage, + ); + expect(result.current.latestMessage).toEqual(validMessage); + }); + + it('connect 호출 전에 기존 latestMessage 상태를 초기화해야 한다', () => { const message: SocketMessage = { eventType: 'FINISHED', data: null, @@ -85,11 +191,11 @@ describe('useAudienceSocket', () => { result.current.connect(options); }); - expect(result.current.messages).toEqual([]); + expect(result.current.latestMessage).toBeNull(); expect(connect).toHaveBeenCalledWith(options); }); - it('disconnect 호출 시 messages 상태를 초기화해야 한다', () => { + it('disconnect 호출 시 latestMessage 상태를 초기화해야 한다', () => { const message: SocketMessage = { eventType: 'FINISHED', data: null, @@ -111,11 +217,11 @@ describe('useAudienceSocket', () => { result.current.disconnect(); }); - expect(result.current.messages).toEqual([]); + expect(result.current.latestMessage).toBeNull(); expect(disconnect).toHaveBeenCalledOnce(); }); - it('roomId가 변경되면 기존 messages 상태를 초기화해야 한다', () => { + it('roomId가 변경되면 기존 latestMessage 상태를 초기화해야 한다', () => { const message: SocketMessage = { eventType: 'FINISHED', data: null, @@ -142,12 +248,12 @@ describe('useAudienceSocket', () => { rerender({ roomId: 456 }); }); - expect(result.current.messages).toEqual([]); + expect(result.current.latestMessage).toBeNull(); expect(unsubscribe).toHaveBeenCalledWith('/room/123'); expect(subscribe).toHaveBeenCalledWith('/room/456', expect.any(Function)); }); - it('소켓 재연결 이벤트가 발생하면 messages 상태를 초기화해야 한다', () => { + it('소켓 재연결 이벤트가 발생하면 latestMessage 상태를 초기화해야 한다', () => { const message: SocketMessage = { eventType: 'FINISHED', data: null, @@ -174,10 +280,10 @@ describe('useAudienceSocket', () => { handleConnection(); }); - expect(result.current.messages).toEqual([]); + expect(result.current.latestMessage).toBeNull(); }); - it('error가 발생하면 alert를 호출하고 소켓 연결을 해제해야 한다', () => { + it('에러가 발생해도 기존 부수 효과 없이 error 객체 자체와 isConnected 상태를 그대로 노출해야 한다', () => { const error = new Error('socket failure'); useSocketMock.mockReturnValue({ connect, @@ -185,12 +291,13 @@ describe('useAudienceSocket', () => { subscribe, unsubscribe, addConnectionListener, + isConnected: false, error, }); - renderHook(() => useAudienceSocket(123)); + const { result } = renderHook(() => useAudienceSocket(123)); - expect(window.alert).toHaveBeenCalledWith('socket failure'); - expect(disconnect).toHaveBeenCalledOnce(); + expect(result.current.error).toBe(error); + expect(result.current.isConnected).toBe(false); }); }); diff --git a/src/hooks/sockets/useAudienceSocket.ts b/src/hooks/sockets/useAudienceSocket.ts index 4050211b..5782fad3 100644 --- a/src/hooks/sockets/useAudienceSocket.ts +++ b/src/hooks/sockets/useAudienceSocket.ts @@ -2,11 +2,11 @@ import { useCallback, useEffect, useState } from 'react'; import { IMessage } from '@stomp/stompjs'; import useSocket from './useSocket'; import { SocketMessage } from '../../apis/sockets/type'; +import { isSocketMessage } from '../../apis/sockets/util'; /** * 청중 전용 웹소켓 훅입니다. * 토론 이벤트 채널을 구독하여 실시간 토론 정보를 수령합니다. - * 모든 이벤트는 리스트에 누적되어 저장됩니다. * * * ⚠️ 주의: 이 훅을 컴포넌트에서 호출하여 페이지가 마운트(렌더링)되는 순간, * 내부의 `useEffect`가 실행되어 즉시 `/room/{roomId}` 채널에 대한 구독(Subscribe)을 요청합니다. @@ -15,7 +15,8 @@ import { SocketMessage } from '../../apis/sockets/type'; * * @param {number} roomId - 입장한 토론방의 고유 ID * @returns {Object} 청중 소켓 상태와 제어 함수를 반환합니다. - * @returns {SocketMessage[]} returns.messages - 현재 소켓 세션에서 수신한 메시지 목록입니다. + * @returns {SocketMessage | null} returns.latestMessage - 검증된 가장 최근의 수신 메시지입니다. + * @returns {boolean} returns.isConnected - 소켓 연결 상태입니다. * @returns {Function} returns.connect - `useSocket.connect`에 위임하기 전에 현재 메시지를 초기화합니다. * @returns {Function} returns.disconnect - `useSocket.disconnect`에 위임하기 전에 현재 메시지를 초기화합니다. * @returns {Error | null} returns.error - 가장 최근에 발생한 소켓 오류입니다. @@ -27,67 +28,65 @@ export default function useAudienceSocket(roomId: number) { subscribe, unsubscribe, addConnectionListener, + isConnected, error, } = useSocket(); - const [messages, setMessages] = useState([]); + const [latestMessage, setLatestMessage] = useState( + null, + ); /** - * 현재 소켓 세션에서 누적된 청중 메시지를 모두 초기화합니다. + * 수신한 최신 청중 메시지를 초기화합니다. * 세션 간에 오래된 메시지가 남지 않도록, 래핑된 connect 및 disconnect * 제어 함수에서 사용하는 초기화 동작을 한곳에 모읍니다. */ - const resetMessages = useCallback(() => { - setMessages([]); + const resetMessage = useCallback(() => { + setLatestMessage(null); }, []); /** * 이전에 수신한 메시지를 초기화한 뒤 청중 소켓을 시작합니다. - * 새 연결이 이전 소켓 세션의 데이터를 재사용하지 않고 빈 메시지 목록으로 + * 새 연결이 이전 소켓 세션의 데이터를 재사용하지 않고 빈 상태로 * 시작되도록 `useSocket.connect`를 래핑합니다. */ const connectAudienceSocket = useCallback( (options?: Parameters[0]) => { - resetMessages(); + resetMessage(); connect(options); }, - [connect, resetMessages], + [connect, resetMessage], ); /** - * 현재 메시지 목록을 초기화한 뒤 청중 소켓을 종료합니다. + * 현재 메시지를 초기화한 뒤 청중 소켓을 종료합니다. * 수동으로 연결을 해제할 때도 React 상태에 남아 있을 수 있는 세션 단위 * 메시지를 제거하기 위해 `useSocket.disconnect`를 래핑합니다. */ const disconnectAudienceSocket = useCallback(() => { - resetMessages(); + resetMessage(); disconnect(); - }, [disconnect, resetMessages]); - - useEffect(() => { - return addConnectionListener(resetMessages); - }, [addConnectionListener, resetMessages]); + }, [disconnect, resetMessage]); useEffect(() => { - if (!error) { - return; - } - - window.alert(error.message); - disconnectAudienceSocket(); - }, [disconnectAudienceSocket, error]); + return addConnectionListener(resetMessage); + }, [addConnectionListener, resetMessage]); useEffect(() => { const destination = `/room/${roomId}`; - resetMessages(); + resetMessage(); // 토론 이벤트를 발행하는 채널 구독 및 메시지 수신 시 상태 업데이트 subscribe(destination, (message: IMessage) => { try { const parsedData = JSON.parse(message.body); - setMessages((prev) => [...prev, parsedData]); + if (isSocketMessage(parsedData)) { + setLatestMessage(parsedData); + } else { + console.log('잘못된 소켓 메시지 형식입니다:', parsedData); + } } catch (e) { - console.error('메시지 파싱 오류:', e); + console.log('메시지 파싱 오류:', e); } }); @@ -95,12 +94,13 @@ export default function useAudienceSocket(roomId: number) { return () => { unsubscribe(destination); }; - }, [roomId, resetMessages, subscribe, unsubscribe]); + }, [roomId, resetMessage, subscribe, unsubscribe]); return { - messages, + latestMessage, + isConnected, + error, connect: connectAudienceSocket, disconnect: disconnectAudienceSocket, - error, }; } diff --git a/src/hooks/sockets/useChairmanSocket.test.ts b/src/hooks/sockets/useChairmanSocket.test.ts index 3ead249a..96577bcb 100644 --- a/src/hooks/sockets/useChairmanSocket.test.ts +++ b/src/hooks/sockets/useChairmanSocket.test.ts @@ -164,7 +164,6 @@ describe('useChairmanSocket', () => { const error = new Error('socket failure'); let handleMessage: (message: IMessage) => void = () => undefined; vi.spyOn(Date, 'now').mockReturnValue(now); - vi.spyOn(console, 'error').mockImplementation(() => undefined); subscribe.mockImplementation( (_destination: string, callback: (message: IMessage) => void) => { handleMessage = callback; @@ -250,27 +249,4 @@ describe('useChairmanSocket', () => { { Authorization: authToken }, ); }); - - it('error가 발생하면 Toast 대체 console 알림을 호출해야 한다', () => { - const error = new Error('socket failure'); - const consoleErrorSpy = vi - .spyOn(console, 'error') - .mockImplementation(() => undefined); - useSocketMock.mockReturnValue({ - connect, - disconnect, - subscribe, - unsubscribe, - publish, - addConnectionListener, - error, - }); - - renderHook(() => useChairmanSocket(123)); - - expect(consoleErrorSpy).toHaveBeenCalledWith( - 'Chairman socket connection failed.', - error, - ); - }); }); diff --git a/src/hooks/sockets/useChairmanSocket.ts b/src/hooks/sockets/useChairmanSocket.ts index 52be63c0..104ae748 100644 --- a/src/hooks/sockets/useChairmanSocket.ts +++ b/src/hooks/sockets/useChairmanSocket.ts @@ -108,9 +108,7 @@ export default function useChairmanSocket(roomId: number) { return; } - // TODO: Replace with Toast when a global Toast API is available. resetSignalState(); - console.error('Chairman socket connection failed.', error); }, [error, resetSignalState]); // 사회자 권한으로 특정 제어 메시지를 발행하는 함수 diff --git a/src/hooks/sockets/useSocket.test.ts b/src/hooks/sockets/useSocket.test.ts index 571f20ec..e1690ef5 100644 --- a/src/hooks/sockets/useSocket.test.ts +++ b/src/hooks/sockets/useSocket.test.ts @@ -13,6 +13,8 @@ const socketManagerMock = vi.hoisted(() => ({ offConnectEvent: vi.fn(), onCloseEvent: vi.fn(), offCloseEvent: vi.fn(), + onErrorEvent: vi.fn(), + offErrorEvent: vi.fn(), })); vi.mock('../../apis/sockets/SocketManager', () => ({ @@ -34,6 +36,12 @@ function getCloseListener() { return socketManagerMock.onCloseEvent.mock.calls[0][0] as () => void; } +function getErrorListener() { + return socketManagerMock.onErrorEvent.mock.calls[0][0] as ( + error: unknown, + ) => void; +} + describe('useSocket', () => { beforeEach(() => { vi.useRealTimers(); @@ -105,6 +113,9 @@ describe('useSocket', () => { expect(socketManagerMock.offCloseEvent).toHaveBeenCalledWith( getCloseListener(), ); + expect(socketManagerMock.offErrorEvent).toHaveBeenCalledWith( + getErrorListener(), + ); }); it('소켓 종료 이벤트가 발생하면 연결 상태를 false로 갱신해야 한다', () => { @@ -196,4 +207,65 @@ describe('useSocket', () => { expect(result.current.error).toBeInstanceOf(Error); }); + + it('SocketManager의 SocketError가 발생하면 error 상태에 반영된다', () => { + const { result } = renderHook(() => useSocket()); + const mockError = new Error('test error'); + + act(() => { + getErrorListener()(mockError); + }); + + expect(result.current.error).toBe(mockError); + }); + + it('새 연결을 시도하면 이전 error 상태가 초기화된다', () => { + const { result } = renderHook(() => useSocket()); + const mockError = new Error('test error'); + + act(() => { + getErrorListener()(mockError); + }); + + expect(result.current.error).toBe(mockError); + + act(() => { + result.current.connect({ baseUrl: 'https://example.com' }); + }); + + expect(result.current.error).toBeNull(); + }); + + it('연결이 성공하면 이전 error 상태가 초기화된다', () => { + const { result } = renderHook(() => useSocket()); + const mockError = new Error('test error'); + + act(() => { + getErrorListener()(mockError); + }); + + expect(result.current.error).toBe(mockError); + + act(() => { + getConnectListener()(); + }); + + expect(result.current.error).toBeNull(); + }); + + it('disconnect 호출 시 isConnected가 false가 되고 error 상태가 초기화된다', () => { + const { result } = renderHook(() => useSocket()); + const mockError = new Error('test error'); + + act(() => { + getErrorListener()(mockError); + }); + + act(() => { + result.current.disconnect(); + }); + + expect(result.current.isConnected).toBe(false); + expect(result.current.error).toBeNull(); + }); }); diff --git a/src/hooks/sockets/useSocket.ts b/src/hooks/sockets/useSocket.ts index ce7c97ad..a39784ae 100644 --- a/src/hooks/sockets/useSocket.ts +++ b/src/hooks/sockets/useSocket.ts @@ -36,6 +36,7 @@ export default function useSocket() { * @param options - (선택 옵션) 소켓 연결에 사용할 옵션. 상세 항목은 [SocketManager.ts](../apis/sockets/SocketManager.ts) 참고. */ const connect = useCallback((options?: SocketOptions) => { + setError(null); socketManager.connect(options); }, []); @@ -44,6 +45,7 @@ export default function useSocket() { * WS 연결을 끊음. */ const disconnect = useCallback(() => { + setError(null); socketManager.disconnect(); setIsConnected(false); }, []); @@ -122,6 +124,7 @@ export default function useSocket() { useEffect(() => { // 소켓이 연결될 때마다 실행될 핸들러 const handleConnect = () => { + setError(null); setIsConnected(true); const recover = () => { @@ -143,19 +146,12 @@ export default function useSocket() { // 재시도 및 오류 복구 최초 1회 try { recover(); - } catch (error) { - console.warn('🚨 리스너 복구 1차 실패, 1초 후 재시도합니다.', error); - + } catch { // 1초 후 2차 복구 시도 setTimeout(() => { try { recover(); } catch (retryError) { - console.error( - '🚨 재시도 실패! 복구 불가능한 상태입니다.', - retryError, - ); - // 💡 훅이 자체적으로 에러 상태를 업데이트 setError( retryError instanceof Error ? retryError @@ -170,9 +166,14 @@ export default function useSocket() { setIsConnected(false); }; + const handleError = (err: Error) => { + setError(err); + }; + // 이 핸들러 함수를 발행자(SocketManager)에 등록 socketManager.onConnectEvent(handleConnect); socketManager.onCloseEvent(handleClose); + socketManager.onErrorEvent(handleError); // 리스너 등록 이후 연결이 이미 되어 있는 게 확인되면, 핸들러 바로 실행 if (socketManager.isConnected()) { @@ -184,6 +185,7 @@ export default function useSocket() { // 먼저 발행자(SocketManager)에게 등록된 핸들러부터 제거 socketManager.offConnectEvent(handleConnect); socketManager.offCloseEvent(handleClose); + socketManager.offErrorEvent(handleError); // 활성화된 모든 구독 해제 activeSubscriptions.current.forEach((subscription) => diff --git a/src/page/AudienceSharePage/AudienceSharePage.test.tsx b/src/page/AudienceSharePage/AudienceSharePage.test.tsx new file mode 100644 index 00000000..ae7ac2c2 --- /dev/null +++ b/src/page/AudienceSharePage/AudienceSharePage.test.tsx @@ -0,0 +1,302 @@ +import { act, render, screen } from '@testing-library/react'; +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { MemoryRouter, Route, Routes } from 'react-router-dom'; +import userEvent from '@testing-library/user-event'; +import { Component, ReactNode } from 'react'; +import AudienceSharePage, { + AudienceShareDisplayError, +} from './AudienceSharePage'; +import { useAudienceShareState } from './hooks/useAudienceShareState'; +import { AudienceShareError, AudienceShareErrorCode } from './error'; + +// 모의 (Mock) +vi.mock('./hooks/useAudienceShareState'); +vi.mock('react-i18next', () => ({ + useTranslation: () => ({ + t: (key: string) => key, + }), +})); + +const mockUseAudienceShareState = vi.mocked(useAudienceShareState); + +class TestErrorBoundary extends Component< + { children: ReactNode; onError: (error: Error) => void }, + { hasError: boolean } +> { + constructor(props: { children: ReactNode; onError: (error: Error) => void }) { + super(props); + this.state = { hasError: false }; + } + + static getDerivedStateFromError() { + return { hasError: true }; + } + + componentDidCatch(error: Error) { + this.props.onError(error); + } + + render() { + if (this.state.hasError) { + return
Error Caught
; + } + return this.props.children; + } +} + +describe('AudienceSharePage', () => { + const renderPage = ( + initialRoute: string, + onError: (error: Error) => void = () => {}, + ) => { + return render( + + + + } /> + + + , + ); + }; + + beforeEach(() => { + vi.clearAllMocks(); + mockUseAudienceShareState.mockReturnValue({ + status: 'connecting', + error: null, + }); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + describe('ID Validation', () => { + it.each([ + ['/live/abc', 'abc'], + ['/live/-1', '-1'], + ['/live/0', '0'], + ['/live/1.5', '1.5'], + ])('유효하지 않은 ID(%s)는 번역된 Error를 throw한다', (route) => { + let caughtError: Error | null = null; + + const consoleErrorSpy = vi + .spyOn(console, 'error') + .mockImplementation(() => {}); // Error boundary logging 숨김 + + renderPage(route, (err) => { + caughtError = err; + }); + + expect(caughtError).toBeInstanceOf(Error); + expect(caughtError!.message).toBe('유효하지 않은 토론방 ID입니다.'); + + consoleErrorSpy.mockRestore(); + }); + }); + + describe('상태별 화면 렌더링', () => { + it('connecting 상태에서는 LoadingSpinner만 표시되고 타이머 조작 요소가 없다', () => { + mockUseAudienceShareState.mockReturnValue({ + status: 'connecting', + error: null, + }); + renderPage('/live/123'); + + expect( + screen.getByRole('status', { name: 'Loading' }), + ).toBeInTheDocument(); + // 타이머 요소 없는지 확인 + expect(screen.queryByText('남은 시간')).not.toBeInTheDocument(); + expect(screen.queryByText('찬성')).not.toBeInTheDocument(); + }); + + it('waiting 상태에서는 서버 데이터 대기 번역 문구가 표시된다', () => { + mockUseAudienceShareState.mockReturnValue({ + status: 'waiting', + error: null, + }); + renderPage('/live/123'); + + expect( + screen.getByText('토론 시작을 대기 중입니다.'), + ).toBeInTheDocument(); + }); + + it('displaying 상태 (NORMAL)에서는 AudienceNormalTimer에 올바른 props가 전달된다', () => { + mockUseAudienceShareState.mockReturnValue({ + status: 'displaying', + error: null, + displayData: { + timerType: 'NORMAL', + currentTeam: null, + isRunning: true, + singleTime: 120, + }, + }); + renderPage('/live/123'); + + expect(screen.getByText('남은 시간')).toBeInTheDocument(); + expect(screen.getByText('02:00')).toBeInTheDocument(); + }); + + it('PLAY 상태의 NORMAL 타이머는 화면에서 로컬 카운트다운을 진행한다', () => { + vi.useFakeTimers(); + mockUseAudienceShareState.mockReturnValue({ + status: 'displaying', + error: null, + displayData: { + timerType: 'NORMAL', + currentTeam: null, + isRunning: true, + singleTime: 10, + }, + }); + renderPage('/live/123'); + + expect(screen.getByText('00:10')).toBeInTheDocument(); + + act(() => { + vi.advanceTimersByTime(2000); + }); + + expect(screen.getByText('00:08')).toBeInTheDocument(); + }); + + it('정지 상태의 NORMAL 타이머는 수신된 시간을 유지한다', () => { + vi.useFakeTimers(); + mockUseAudienceShareState.mockReturnValue({ + status: 'displaying', + error: null, + displayData: { + timerType: 'NORMAL', + currentTeam: null, + isRunning: false, + singleTime: 10, + }, + }); + renderPage('/live/123'); + + act(() => { + vi.advanceTimersByTime(2000); + }); + + expect(screen.getByText('00:10')).toBeInTheDocument(); + }); + + it('displaying 상태 (TIME_BASED)에서는 AudienceTimeBasedTimer에 올바른 props가 전달된다', () => { + mockUseAudienceShareState.mockReturnValue({ + status: 'displaying', + error: null, + displayData: { + timerType: 'TIME_BASED', + currentTeam: 'PROS', + isRunning: true, + prosTime: 180, + consTime: 150, + }, + }); + renderPage('/live/123'); + + expect(screen.getByText('찬성')).toBeInTheDocument(); + expect(screen.getByText('03:00')).toBeInTheDocument(); + expect(screen.getByText('반대')).toBeInTheDocument(); + expect(screen.getByText('02:30')).toBeInTheDocument(); + }); + + it('PLAY 상태의 TIME_BASED 타이머는 현재 발언 팀만 로컬 카운트다운을 진행한다', () => { + vi.useFakeTimers(); + mockUseAudienceShareState.mockReturnValue({ + status: 'displaying', + error: null, + displayData: { + timerType: 'TIME_BASED', + currentTeam: 'PROS', + isRunning: true, + prosTime: 10, + consTime: 20, + }, + }); + renderPage('/live/123'); + + expect(screen.getByText('00:10')).toBeInTheDocument(); + expect(screen.getByText('00:20')).toBeInTheDocument(); + + act(() => { + vi.advanceTimersByTime(2000); + }); + + expect(screen.getByText('00:08')).toBeInTheDocument(); + expect(screen.getByText('00:20')).toBeInTheDocument(); + }); + + it('finished 상태에서는 종료 문구와 페이지 닫기 버튼이 표시되며 클릭 시 window.close를 호출한다', async () => { + mockUseAudienceShareState.mockReturnValue({ + status: 'finished', + error: null, + }); + + const windowCloseSpy = vi + .spyOn(window, 'close') + .mockImplementation(() => {}); + const user = userEvent.setup(); + + renderPage('/live/123'); + + expect(screen.getByText('토론이 종료되었습니다.')).toBeInTheDocument(); + + const closeButton = screen.getByRole('button', { name: '페이지 닫기' }); + expect(closeButton).toBeInTheDocument(); + + await user.click(closeButton); + + expect(windowCloseSpy).toHaveBeenCalled(); + + // 상태 변경이나 홈 이동이 없어야 함 + expect(screen.getByText('토론이 종료되었습니다.')).toBeInTheDocument(); + }); + }); + + describe('오류 처리 로직 (AudienceShareDisplayError)', () => { + const errorMap: Record = { + SOCKET_URL_UNAVAILABLE: '실시간 연결 주소를 확인할 수 없어요.', + SOCKET_SERVER_REJECTED: '토론방 연결이 거부되었어요.', + SOCKET_STOMP_ERROR: '실시간 연결에서 서버 오류가 발생했어요.', + SOCKET_RETRY_EXHAUSTED: '실시간 연결을 복구하지 못했어요.', + EVENT_TIMEOUT: '토론 상태를 오래 받지 못했어요.', + SERVER_ERROR: '서버에서 토론 진행 오류를 전달했어요.', + UNKNOWN: '알 수 없는 실시간 오류가 발생했어요.', + }; + + it.each(Object.entries(errorMap))( + '상태 훅에서 %s 오류가 발생하면 번역된 Error를 throw하고 원본 오류를 보존한다', + (code, expectedMessage) => { + const sourceError = new AudienceShareError( + code as AudienceShareErrorCode, + ); + mockUseAudienceShareState.mockReturnValue({ + status: 'connecting', + error: sourceError, + }); + + let caughtError: AudienceShareDisplayError | null = null; + + const consoleErrorSpy = vi + .spyOn(console, 'error') + .mockImplementation(() => {}); // Error boundary logging 숨김 + + renderPage('/live/123', (err) => { + caughtError = err as AudienceShareDisplayError; + }); + + expect(caughtError).toBeInstanceOf(Error); + expect(caughtError!.name).toBe('AudienceShareDisplayError'); + expect(caughtError!.message).toBe(expectedMessage); + expect(caughtError!.sourceError).toBe(sourceError); + + consoleErrorSpy.mockRestore(); + }, + ); + }); +}); diff --git a/src/page/AudienceSharePage/AudienceSharePage.tsx b/src/page/AudienceSharePage/AudienceSharePage.tsx new file mode 100644 index 00000000..2517b168 --- /dev/null +++ b/src/page/AudienceSharePage/AudienceSharePage.tsx @@ -0,0 +1,170 @@ +import { useParams } from 'react-router-dom'; +import { useTranslation } from 'react-i18next'; +import { useAudienceShareState } from './hooks/useAudienceShareState'; +import { useAudienceCountdown } from './hooks/useAudienceCountdown'; +import { AudienceShareError, AudienceShareErrorCode } from './error'; +import AudienceNormalTimer from './components/AudienceNormalTimer'; +import AudienceTimeBasedTimer from './components/AudienceTimeBasedTimer'; +import DefaultLayout from '../../layout/defaultLayout/DefaultLayout'; +import LoadingSpinner from '../../components/LoadingSpinner'; + +export class AudienceShareDisplayError extends Error { + public readonly sourceError: AudienceShareError; + + constructor(message: string, sourceError: AudienceShareError) { + super(message); + this.name = 'AudienceShareDisplayError'; + this.sourceError = sourceError; + } +} + +export default function AudienceSharePage() { + const { id } = useParams(); + const { t } = useTranslation(); + + const tableId = Number(id); + + if (!id || isNaN(tableId) || tableId <= 0 || !Number.isInteger(tableId)) { + throw new Error(t('유효하지 않은 토론방 ID입니다.')); + } + + const state = useAudienceShareState(tableId); + const audienceDisplayData = + state.status === 'displaying' ? state.displayData : null; + + const normalCountdown = useAudienceCountdown({ + receivedTime: + audienceDisplayData?.timerType === 'NORMAL' + ? audienceDisplayData.singleTime + : null, + isRunning: + audienceDisplayData?.timerType === 'NORMAL' + ? audienceDisplayData.isRunning + : false, + }); + const prosCountdown = useAudienceCountdown({ + receivedTime: + audienceDisplayData?.timerType === 'TIME_BASED' + ? audienceDisplayData.prosTime + : null, + isRunning: + audienceDisplayData?.timerType === 'TIME_BASED' && + audienceDisplayData.isRunning && + audienceDisplayData.currentTeam === 'PROS', + }); + const consCountdown = useAudienceCountdown({ + receivedTime: + audienceDisplayData?.timerType === 'TIME_BASED' + ? audienceDisplayData.consTime + : null, + isRunning: + audienceDisplayData?.timerType === 'TIME_BASED' && + audienceDisplayData.isRunning && + audienceDisplayData.currentTeam === 'CONS', + }); + + const handleClosePage = () => { + // 일단 페이지 닫기 + window.close(); + + // 페이지를 못 닫을 경우 홈으로 + setTimeout(() => { + window.location.href = '/'; + }, 100); + }; + + if (state.error) { + const errorMessages: Record = { + SOCKET_URL_UNAVAILABLE: '실시간 연결 주소를 확인할 수 없어요.', + SOCKET_SERVER_REJECTED: '토론방 연결이 거부되었어요.', + SOCKET_STOMP_ERROR: '실시간 연결에서 서버 오류가 발생했어요.', + SOCKET_RETRY_EXHAUSTED: '실시간 연결을 복구하지 못했어요.', + EVENT_TIMEOUT: '토론 상태를 오래 받지 못했어요.', + SERVER_ERROR: '서버에서 토론 진행 오류를 전달했어요.', + UNKNOWN: '알 수 없는 실시간 오류가 발생했어요.', + }; + + throw new AudienceShareDisplayError( + t(errorMessages[state.error.code]), + state.error, + ); + } + + const renderContent = () => { + console.log(`# Status = ${state.status}`); + + if (state.status === 'connecting') { + return ( +
+ +
+ ); + } + + if (state.status === 'waiting') { + return ( +
+

+ {t('토론 시작을 대기 중입니다.')} +

+
+ ); + } + + if (state.status === 'displaying') { + const { displayData } = state; + if (displayData.timerType === 'NORMAL') { + return ( +
+ +
+ ); + } + + if (displayData.timerType === 'TIME_BASED') { + return ( +
+ +
+ ); + } + } + + if (state.status === 'finished') { + return ( +
+

+ {t('토론이 종료되었습니다.')} +

+ +
+ ); + } + + return null; + }; + + return ( + + +
+ {renderContent()} +
+
+
+ ); +} diff --git a/src/page/AudienceSharePage/components/AudienceNormalTimer.test.tsx b/src/page/AudienceSharePage/components/AudienceNormalTimer.test.tsx new file mode 100644 index 00000000..1933a79b --- /dev/null +++ b/src/page/AudienceSharePage/components/AudienceNormalTimer.test.tsx @@ -0,0 +1,19 @@ +import { render, screen } from '@testing-library/react'; +import { describe, it, expect } from 'vitest'; +import AudienceNormalTimer from './AudienceNormalTimer'; + +describe('AudienceNormalTimer', () => { + it('남은 시간 레이블과 포맷팅된 시간을 올바르게 렌더링한다', () => { + render(); + + // @AC-037: "남은 시간" 레이블은 i18next 번역을 통해 렌더링된다. + expect(screen.getByText('남은 시간')).toBeInTheDocument(); + + // @AC-036: remainingTime={65}일 때 화면에 01:05가 표시된다. + expect(screen.getByText('01:05')).toBeInTheDocument(); + + // @AC-038: 컴포넌트는 사용자 조작 요소 없이 props만으로 동일한 UI를 렌더링한다. + // 버튼 등 조작 요소가 없는지 확인한다. + expect(screen.queryByRole('button')).not.toBeInTheDocument(); + }); +}); diff --git a/src/page/AudienceSharePage/components/AudienceNormalTimer.tsx b/src/page/AudienceSharePage/components/AudienceNormalTimer.tsx new file mode 100644 index 00000000..77b4dc85 --- /dev/null +++ b/src/page/AudienceSharePage/components/AudienceNormalTimer.tsx @@ -0,0 +1,23 @@ +import { useTranslation } from 'react-i18next'; +import { Formatting } from '../../../util/formatting'; + +interface AudienceNormalTimerProps { + remainingTime: number; +} + +export default function AudienceNormalTimer({ + remainingTime, +}: AudienceNormalTimerProps) { + const { t } = useTranslation(); + + return ( +
+

+ {t('남은 시간')} +

+ + {Formatting.formatSecondsToMMSS(remainingTime)} + +
+ ); +} diff --git a/src/page/AudienceSharePage/components/AudienceTimeBasedTimer.test.tsx b/src/page/AudienceSharePage/components/AudienceTimeBasedTimer.test.tsx new file mode 100644 index 00000000..d3a5a52a --- /dev/null +++ b/src/page/AudienceSharePage/components/AudienceTimeBasedTimer.test.tsx @@ -0,0 +1,66 @@ +import { render, screen } from '@testing-library/react'; +import { describe, it, expect, vi } from 'vitest'; +import AudienceTimeBasedTimer from './AudienceTimeBasedTimer'; + +// Mock react-i18next +vi.mock('react-i18next', () => ({ + useTranslation: () => ({ + t: (key: string) => key, + }), +})); + +describe('AudienceTimeBasedTimer', () => { + it('양 팀에 수신값이 있으면 각각 독립적으로 포맷되어 표시된다 (@AC-039)', () => { + render( + , + ); + expect(screen.getByText('01:05')).toBeInTheDocument(); + expect(screen.getByText('00:40')).toBeInTheDocument(); + }); + + it('수신 이력이 없는 팀의 null 값은 --:--로 표시된다 (@AC-040)', () => { + render( + , + ); + const nullDisplays = screen.getAllByText('--:--'); + expect(nullDisplays).toHaveLength(2); + }); + + it('PROS인 현재 발언 팀은 접근성 상태와 텍스트로 식별된다 (@AC-041)', () => { + render( + , + ); + const prosSpeakingStatus = screen.getByTestId('pros-speaking-status'); + expect(prosSpeakingStatus).toBeInTheDocument(); + expect( + screen.queryByTestId('cons-speaking-status'), + ).not.toBeInTheDocument(); + }); + + it('CONS인 현재 발언 팀은 접근성 상태와 텍스트로 식별된다 (@AC-041)', () => { + render( + , + ); + const consSpeakingStatus = screen.getByTestId('cons-speaking-status'); + expect(consSpeakingStatus).toBeInTheDocument(); + expect( + screen.queryByTestId('pros-speaking-status'), + ).not.toBeInTheDocument(); + }); +}); diff --git a/src/page/AudienceSharePage/components/AudienceTimeBasedTimer.tsx b/src/page/AudienceSharePage/components/AudienceTimeBasedTimer.tsx new file mode 100644 index 00000000..54acfc06 --- /dev/null +++ b/src/page/AudienceSharePage/components/AudienceTimeBasedTimer.tsx @@ -0,0 +1,89 @@ +import { useTranslation } from 'react-i18next'; +import { TimeBasedStance } from '../../../type/type'; +import { Formatting } from '../../../util/formatting'; +import clsx from 'clsx'; + +export interface AudienceTimeBasedTimerProps { + prosRemainingTime: number | null; + consRemainingTime: number | null; + currentTeam: TimeBasedStance; +} + +export default function AudienceTimeBasedTimer({ + prosRemainingTime, + consRemainingTime, + currentTeam, +}: AudienceTimeBasedTimerProps) { + const { t } = useTranslation(); + + const formatTime = (time: number | null) => { + if (time === null) return '--:--'; + return Formatting.formatSecondsToMMSS(time); + }; + + return ( +
+ {/* 찬성 팀 영역 */} +
+
+ {currentTeam === 'PROS' && ( + + {t('현재 발언 중')} + + )} + {currentTeam === 'PROS' && ( + + )} +
+

{t('찬성')}

+
+ {formatTime(prosRemainingTime)} +
+
+ + {/* 반대 팀 영역 */} +
+
+ {currentTeam === 'CONS' && ( + + {t('현재 발언 중')} + + )} + {currentTeam === 'CONS' && ( + + )} +
+

{t('반대')}

+
+ {formatTime(consRemainingTime)} +
+
+
+ ); +} diff --git a/src/page/AudienceSharePage/error.test.ts b/src/page/AudienceSharePage/error.test.ts new file mode 100644 index 00000000..57b00d0d --- /dev/null +++ b/src/page/AudienceSharePage/error.test.ts @@ -0,0 +1,25 @@ +import { AudienceShareError } from './error'; +import { SocketError } from '../../apis/sockets/error'; + +describe('AudienceShareError 오류 객체', () => { + it('올바른 코드와 이름으로 초기화된다', () => { + const error = new AudienceShareError('EVENT_TIMEOUT'); + expect(error.name).toBe('AudienceShareError'); + expect(error.code).toBe('EVENT_TIMEOUT'); + expect(error.technicalError).toBeNull(); + }); + + it('원본 technicalError를 유지한다', () => { + const technical = new Error('Some technical details'); + const error = new AudienceShareError('SERVER_ERROR', technical); + expect(error.code).toBe('SERVER_ERROR'); + expect(error.technicalError).toBe(technical); + }); + + it('소켓 오류를 저장할 수 있다', () => { + const socketError = new SocketError('SOCKET_STOMP_ERROR', 'Stomp failed'); + const error = new AudienceShareError('SOCKET_STOMP_ERROR', socketError); + expect(error.code).toBe('SOCKET_STOMP_ERROR'); + expect(error.technicalError).toBe(socketError); + }); +}); diff --git a/src/page/AudienceSharePage/error.ts b/src/page/AudienceSharePage/error.ts new file mode 100644 index 00000000..cd618e8f --- /dev/null +++ b/src/page/AudienceSharePage/error.ts @@ -0,0 +1,22 @@ +import { SocketErrorCode } from '../../apis/sockets/error'; + +export type AudienceShareErrorCode = + | SocketErrorCode + | 'EVENT_TIMEOUT' + | 'SERVER_ERROR' + | 'UNKNOWN'; + +export class AudienceShareError extends Error { + public readonly code: AudienceShareErrorCode; + public readonly technicalError: Error | null; + + constructor( + code: AudienceShareErrorCode, + technicalError: Error | null = null, + ) { + super(code); + this.name = 'AudienceShareError'; + this.code = code; + this.technicalError = technicalError; + } +} diff --git a/src/page/AudienceSharePage/hooks/useAudienceCountdown.test.ts b/src/page/AudienceSharePage/hooks/useAudienceCountdown.test.ts new file mode 100644 index 00000000..7d35420f --- /dev/null +++ b/src/page/AudienceSharePage/hooks/useAudienceCountdown.test.ts @@ -0,0 +1,101 @@ +import { renderHook, act } from '@testing-library/react'; +import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest'; +import { useAudienceCountdown } from './useAudienceCountdown'; + +describe('useAudienceCountdown', () => { + beforeEach(() => { + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.useRealTimers(); + vi.restoreAllMocks(); + }); + + it('초기 수신값 표시', () => { + const { result } = renderHook(() => + useAudienceCountdown({ receivedTime: 65, isRunning: false }), + ); + + expect(result.current.currentSeconds).toBe(65); + expect(result.current.formattedTime).toBe('01:05'); + }); + + it('실행 중 시간 감소', () => { + const { result } = renderHook(() => + useAudienceCountdown({ receivedTime: 10, isRunning: true }), + ); + + act(() => { + vi.advanceTimersByTime(1000); + }); + + expect(result.current.currentSeconds).toBe(9); + expect(result.current.formattedTime).toBe('00:09'); + }); + + it('정지 상태 유지', () => { + const { result } = renderHook(() => + useAudienceCountdown({ receivedTime: 10, isRunning: false }), + ); + + act(() => { + vi.advanceTimersByTime(1000); + }); + + expect(result.current.currentSeconds).toBe(10); + }); + + it('새 수신값 재동기화', () => { + const { result, rerender } = renderHook( + (props) => useAudienceCountdown(props), + { initialProps: { receivedTime: 10, isRunning: true } }, + ); + + act(() => { + vi.advanceTimersByTime(2000); + }); + expect(result.current.currentSeconds).toBe(8); + + rerender({ receivedTime: 15, isRunning: true }); + expect(result.current.currentSeconds).toBe(15); + + act(() => { + vi.advanceTimersByTime(1000); + }); + expect(result.current.currentSeconds).toBe(14); + }); + + it('0 아래로 내려가지 않음', () => { + const { result } = renderHook(() => + useAudienceCountdown({ receivedTime: 1, isRunning: true }), + ); + + act(() => { + vi.advanceTimersByTime(2000); + }); + + expect(result.current.currentSeconds).toBe(0); + expect(result.current.formattedTime).toBe('00:00'); + }); + + it('입력 변경과 언마운트 시 interval 정리', () => { + const clearIntervalSpy = vi.spyOn(global, 'clearInterval'); + + // 언마운트 시 정리 확인 + const hook1 = renderHook((props) => useAudienceCountdown(props), { + initialProps: { receivedTime: 10, isRunning: true }, + }); + hook1.unmount(); + expect(clearIntervalSpy).toHaveBeenCalledTimes(1); + + clearIntervalSpy.mockClear(); + + // 입력 변경(isRunning false) 시 정리 확인 + const hook2 = renderHook((props) => useAudienceCountdown(props), { + initialProps: { receivedTime: 10, isRunning: true }, + }); + hook2.rerender({ receivedTime: 10, isRunning: false }); + expect(clearIntervalSpy).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/page/AudienceSharePage/hooks/useAudienceCountdown.ts b/src/page/AudienceSharePage/hooks/useAudienceCountdown.ts new file mode 100644 index 00000000..2ee1e248 --- /dev/null +++ b/src/page/AudienceSharePage/hooks/useAudienceCountdown.ts @@ -0,0 +1,61 @@ +import { useState, useRef, useEffect } from 'react'; +import { Formatting } from '../../../util/formatting'; + +export interface UseAudienceCountdownParams { + receivedTime: number | null; + isRunning: boolean; +} + +export interface UseAudienceCountdownReturn { + currentSeconds: number | null; + formattedTime: string; +} + +export function useAudienceCountdown({ + receivedTime, + isRunning, +}: UseAudienceCountdownParams): UseAudienceCountdownReturn { + const [currentSeconds, setCurrentSeconds] = useState( + receivedTime, + ); + const intervalRef = useRef | null>(null); + const targetTimeRef = useRef(null); + + useEffect(() => { + // cleanup previous interval + if (intervalRef.current) { + clearInterval(intervalRef.current); + intervalRef.current = null; + } + + setCurrentSeconds(receivedTime); + + if (isRunning && receivedTime !== null) { + targetTimeRef.current = Date.now() + receivedTime * 1000; + + intervalRef.current = setInterval(() => { + if (targetTimeRef.current === null) return; + const now = Date.now(); + const remainingTime = Math.max( + 0, + Math.ceil((targetTimeRef.current - now) / 1000), + ); + setCurrentSeconds(remainingTime); + }, 200); + } + + return () => { + if (intervalRef.current) { + clearInterval(intervalRef.current); + intervalRef.current = null; + } + }; + }, [receivedTime, isRunning]); + + const formattedTime = + currentSeconds !== null + ? Formatting.formatSecondsToMMSS(currentSeconds) + : '00:00'; + + return { currentSeconds, formattedTime }; +} diff --git a/src/page/AudienceSharePage/hooks/useAudienceShareState.test.ts b/src/page/AudienceSharePage/hooks/useAudienceShareState.test.ts new file mode 100644 index 00000000..3b3398d0 --- /dev/null +++ b/src/page/AudienceSharePage/hooks/useAudienceShareState.test.ts @@ -0,0 +1,422 @@ +import { renderHook, act } from '@testing-library/react'; +import { useAudienceShareState } from './useAudienceShareState'; +import { useAudienceCountdown } from './useAudienceCountdown'; +import * as useAudienceSocketModule from '../../../hooks/sockets/useAudienceSocket'; +import { SocketMessage } from '../../../apis/sockets/type'; +import { SocketError } from '../../../apis/sockets/error'; + +vi.mock('../../../hooks/sockets/useAudienceSocket'); + +describe('useAudienceShareState', () => { + let mockConnect: ReturnType; + let mockDisconnect: ReturnType; + + beforeEach(() => { + vi.useFakeTimers(); + mockConnect = vi.fn(); + mockDisconnect = vi.fn(); + + vi.spyOn(useAudienceSocketModule, 'default').mockReturnValue({ + connect: mockConnect as unknown as (options?: unknown) => void, + disconnect: mockDisconnect as unknown as () => void, + latestMessage: null, + isConnected: false, + error: null, + }); + }); + + afterEach(() => { + vi.useRealTimers(); + vi.clearAllMocks(); + }); + + const setSocketState = ( + state: Partial>, + ) => { + vi.spyOn(useAudienceSocketModule, 'default').mockReturnValue({ + connect: mockConnect as unknown as (options?: unknown) => void, + disconnect: mockDisconnect as unknown as () => void, + latestMessage: null, + isConnected: true, + error: null, + ...state, + }); + }; + + it('훅 마운트와 언마운트가 소켓 connect/disconnect 수명 주기를 각각 한 번 수행한다.', () => { + const { unmount } = renderHook(() => useAudienceShareState(1)); + expect(mockConnect).toHaveBeenCalledTimes(1); + unmount(); + expect(mockDisconnect).toHaveBeenCalledTimes(1); + }); + + it('연결 전에는 connecting, 연결 후 데이터 대기, 데이터 표시, 종료 상태가 입력에 따라 명확히 구분된다.', () => { + // 연결 전 connecting + const { result, rerender } = renderHook(() => useAudienceShareState(1)); + expect(result.current.status).toBe('connecting'); + + // 연결 후 첫 메시지 전에는 waiting + setSocketState({ isConnected: true, latestMessage: null }); + rerender(); + expect(result.current.status).toBe('waiting'); + + // 데이터 수신 시 displaying + setSocketState({ + isConnected: true, + latestMessage: { + eventType: 'PLAY', + data: { timerType: 'NORMAL', sequence: 0, remainingTime: 100 }, + }, + }); + rerender(); + expect(result.current.status).toBe('displaying'); + if (result.current.status === 'displaying') { + expect(result.current.displayData.timerType).toBe('NORMAL'); + } + + // 종료 메시지 시 finished + setSocketState({ + isConnected: true, + latestMessage: { eventType: 'FINISHED', data: null }, + }); + rerender(); + expect(result.current.status).toBe('finished'); + }); + + it('일반 타이머 이벤트는 단일 표시 시간을 서버 수신값으로 동기화한다.', () => { + setSocketState({ + isConnected: true, + latestMessage: { + eventType: 'STOP', + data: { timerType: 'NORMAL', sequence: 1, remainingTime: 60 }, + }, + }); + const { result } = renderHook(() => useAudienceShareState(1)); + + expect(result.current.status).toBe('displaying'); + if (result.current.status === 'displaying') { + expect(result.current.displayData.timerType).toBe('NORMAL'); + if (result.current.displayData.timerType === 'NORMAL') { + expect(result.current.displayData.singleTime).toBe(60); + } + } + }); + + it('시간 기반 이벤트는 현재 팀 시간만 갱신하고 상대 팀의 마지막 수신 시간 또는 null을 유지한다.', () => { + const { result, rerender } = renderHook(() => useAudienceShareState(1)); + + // 최초 메시지: 찬성 팀 + setSocketState({ + isConnected: true, + latestMessage: { + eventType: 'PLAY', + data: { + timerType: 'TIME_BASED', + currentTeam: 'PROS', + sequence: 0, + remainingTime: 120, + }, + }, + }); + rerender(); + expect(result.current.status).toBe('displaying'); + if (result.current.status === 'displaying') { + expect(result.current.displayData.timerType).toBe('TIME_BASED'); + if (result.current.displayData.timerType === 'TIME_BASED') { + expect(result.current.displayData.currentTeam).toBe('PROS'); + expect(result.current.displayData.prosTime).toBe(120); + expect(result.current.displayData.consTime).toBeNull(); // 최초 TIME_BASED에서 비활성 팀 null 유지 + } + } + + // 다음 메시지: 반대 팀으로 전환 + setSocketState({ + isConnected: true, + latestMessage: { + eventType: 'PLAY', + data: { + timerType: 'TIME_BASED', + currentTeam: 'CONS', + sequence: 1, + remainingTime: 90, + }, + }, + }); + rerender(); + expect(result.current.status).toBe('displaying'); + if (result.current.status === 'displaying') { + expect(result.current.displayData.timerType).toBe('TIME_BASED'); + if (result.current.displayData.timerType === 'TIME_BASED') { + expect(result.current.displayData.currentTeam).toBe('CONS'); + expect(result.current.displayData.prosTime).toBe(120); // 찬성 시간 유지 + expect(result.current.displayData.consTime).toBe(90); + } + } + }); + + it('TEAM_SWITCH는 수신 팀의 시간을 갱신하고 표시 팀은 반대 진영으로 전환한다.', () => { + setSocketState({ + isConnected: true, + latestMessage: { + eventType: 'TEAM_SWITCH', + data: { + timerType: 'TIME_BASED', + currentTeam: 'PROS', + sequence: 1, + remainingTime: 77, + }, + }, + }); + const { result, rerender } = renderHook(() => useAudienceShareState(1)); + + expect(result.current.status).toBe('displaying'); + if (result.current.status === 'displaying') { + expect(result.current.displayData.timerType).toBe('TIME_BASED'); + if (result.current.displayData.timerType === 'TIME_BASED') { + expect(result.current.displayData.currentTeam).toBe('CONS'); + expect(result.current.displayData.prosTime).toBe(77); + expect(result.current.displayData.consTime).toBeNull(); + expect(result.current.displayData.isRunning).toBe(false); + } + } + + setSocketState({ + isConnected: true, + latestMessage: { + eventType: 'TEAM_SWITCH', + data: { + timerType: 'TIME_BASED', + currentTeam: 'CONS', + sequence: 2, + remainingTime: 55, + }, + }, + }); + rerender(); + + expect(result.current.status).toBe('displaying'); + if (result.current.status === 'displaying') { + expect(result.current.displayData.timerType).toBe('TIME_BASED'); + if (result.current.displayData.timerType === 'TIME_BASED') { + expect(result.current.displayData.currentTeam).toBe('PROS'); + expect(result.current.displayData.prosTime).toBe(77); + expect(result.current.displayData.consTime).toBe(55); + expect(result.current.displayData.isRunning).toBe(false); + } + } + }); + + it('시간 기반 이벤트에 현재 팀이 없으면 표시 상태를 만들지 않는다.', () => { + setSocketState({ + isConnected: true, + latestMessage: { + eventType: 'PLAY', + data: { + timerType: 'TIME_BASED', + sequence: 0, + remainingTime: 120, + }, + }, + }); + + const { result } = renderHook(() => useAudienceShareState(1)); + + expect(result.current.status).toBe('waiting'); + }); + + it('sequence는 내부 최신 payload에는 유지되지만 반환 UI 상태와 렌더링 props에는 노출되지 않는다.', () => { + setSocketState({ + isConnected: true, + latestMessage: { + eventType: 'PLAY', + data: { timerType: 'NORMAL', sequence: 5, remainingTime: 60 }, + }, + }); + const { result } = renderHook(() => useAudienceShareState(1)); + + expect(result.current.status).toBe('displaying'); + if (result.current.status === 'displaying') { + expect( + (result.current.displayData as Record).sequence, + ).toBeUndefined(); + } + }); + + it('PLAY는 로컬 감소를 시작하고 STOP은 수신값에서 감소를 멈추며 새 이벤트는 표시값을 재동기화한다.', () => { + // 이 부분은 useAudienceCountdown과 연동하여 테스트합니다. (Review notes 요구사항 포함) + let latestMessage: SocketMessage = { + eventType: 'PLAY', + data: { + timerType: 'TIME_BASED', + currentTeam: 'PROS', + sequence: 0, + remainingTime: 10, + }, + }; + + setSocketState({ isConnected: true, latestMessage }); + + const { result, rerender } = renderHook(() => { + const state = useAudienceShareState(1); + const isProsRunning = + state.status === 'displaying' && + state.displayData.isRunning && + state.displayData.currentTeam === 'PROS'; + const isConsRunning = + state.status === 'displaying' && + state.displayData.isRunning && + state.displayData.currentTeam === 'CONS'; + + const prosTime = + state.status === 'displaying' && + state.displayData.timerType === 'TIME_BASED' + ? state.displayData.prosTime + : null; + const consTime = + state.status === 'displaying' && + state.displayData.timerType === 'TIME_BASED' + ? state.displayData.consTime + : null; + + const prosCountdown = useAudienceCountdown({ + receivedTime: prosTime, + isRunning: isProsRunning, + }); + const consCountdown = useAudienceCountdown({ + receivedTime: consTime, + isRunning: isConsRunning, + }); + + return { state, prosCountdown, consCountdown }; + }); + + expect(result.current.prosCountdown.currentSeconds).toBe(10); + expect(result.current.consCountdown.currentSeconds).toBeNull(); + + // 시간이 흐름 + act(() => { + vi.advanceTimersByTime(2000); + }); + + // PROS 시간 감소, CONS는 그대로 null + expect(result.current.prosCountdown.currentSeconds).toBe(8); + expect(result.current.consCountdown.currentSeconds).toBeNull(); + + // STOP 이벤트 수신 + latestMessage = { + eventType: 'STOP', + data: { + timerType: 'TIME_BASED', + currentTeam: 'PROS', + sequence: 0, + remainingTime: 8, + }, // 8초 수신 + }; + setSocketState({ isConnected: true, latestMessage }); + rerender(); + + // STOP 수신 시 동기화 + expect(result.current.prosCountdown.currentSeconds).toBe(8); + + // 시간 흐름 - 감소하지 않음 + act(() => { + vi.advanceTimersByTime(2000); + }); + expect(result.current.prosCountdown.currentSeconds).toBe(8); + }); + + it('유효한 토론 이벤트가 600초 동안 없으면 오류 상태가 생성되고 소켓 및 시간 자원이 정리된다.', () => { + setSocketState({ + isConnected: true, + latestMessage: { + eventType: 'PLAY', + data: { timerType: 'NORMAL', sequence: 0, remainingTime: 60 }, + }, + }); + + const { result, rerender } = renderHook(() => useAudienceShareState(1)); + expect(result.current.status).toBe('displaying'); + + act(() => { + vi.advanceTimersByTime(600 * 1000); + }); + rerender(); + + expect(result.current.status).toBe('displaying'); // wait, the error is set but what is the status? + // Wait, earlier I set it to just keep status as is and append error? Or does it change status to 'displaying' but with error? + // "오류 상태가 생성되고" -> error 필드가 갱신됨. + expect(result.current.error?.code).toBe('EVENT_TIMEOUT'); + expect(mockDisconnect).toHaveBeenCalled(); + }); + + it('소켓 연결 후 첫 이벤트가 600초 동안 없으면 EVENT_TIMEOUT 오류가 생성되고 소켓이 정리된다.', () => { + setSocketState({ isConnected: true, latestMessage: null }); + + const { result, rerender } = renderHook(() => useAudienceShareState(1)); + expect(result.current.status).toBe('waiting'); + + act(() => { + vi.advanceTimersByTime(600 * 1000); + }); + rerender(); + + expect(result.current.status).toBe('waiting'); + expect(result.current.error?.code).toBe('EVENT_TIMEOUT'); + expect(mockDisconnect).toHaveBeenCalled(); + }); + + it('ERROR 메시지와 소켓 오류 처리', () => { + // 서버 ERROR 메시지 + setSocketState({ + isConnected: true, + latestMessage: { eventType: 'ERROR', data: null }, + }); + const { result: res1 } = renderHook(() => useAudienceShareState(1)); + expect(res1.current.error?.code).toBe('SERVER_ERROR'); + expect(mockDisconnect).toHaveBeenCalledTimes(1); + + mockDisconnect.mockClear(); + + // SocketError + setSocketState({ + isConnected: true, + error: new SocketError('SOCKET_STOMP_ERROR', 'Stomp failed'), + }); + const { result: res2 } = renderHook(() => useAudienceShareState(2)); + expect(res2.current.error?.code).toBe('SOCKET_STOMP_ERROR'); + expect(mockDisconnect).toHaveBeenCalledTimes(1); + + mockDisconnect.mockClear(); + + // 미분류 오류 + setSocketState({ + isConnected: true, + error: new Error('Network Down'), + }); + const { result: res3 } = renderHook(() => useAudienceShareState(3)); + expect(res3.current.error?.code).toBe('UNKNOWN'); + expect(mockDisconnect).toHaveBeenCalledTimes(1); + }); + + it('재연결 세션에서는 이전 최신 메시지와 표시 화면이 첫 데이터로 재사용되지 않는다.', () => { + const { result, rerender } = renderHook(() => useAudienceShareState(1)); + + setSocketState({ + isConnected: true, + latestMessage: { + eventType: 'PLAY', + data: { timerType: 'NORMAL', sequence: 0, remainingTime: 60 }, + }, + }); + rerender(); + expect(result.current.status).toBe('displaying'); + + // 끊김 후 재연결, 메시지 null + setSocketState({ + isConnected: true, + latestMessage: null, + }); + rerender(); + expect(result.current.status).toBe('waiting'); + }); +}); diff --git a/src/page/AudienceSharePage/hooks/useAudienceShareState.ts b/src/page/AudienceSharePage/hooks/useAudienceShareState.ts new file mode 100644 index 00000000..db895c9e --- /dev/null +++ b/src/page/AudienceSharePage/hooks/useAudienceShareState.ts @@ -0,0 +1,204 @@ +import { useState, useEffect, useRef, useCallback } from 'react'; +import useAudienceSocket from '../../../hooks/sockets/useAudienceSocket'; +import { AudienceShareError, AudienceShareErrorCode } from '../error'; +import { TimeBasedStance } from '../../../type/type'; +import { isSocketError } from '../../../apis/sockets/error'; + +type AudienceNormalDisplayData = { + timerType: 'NORMAL'; + currentTeam: null; + isRunning: boolean; + singleTime: number; +}; + +type AudienceTimeBasedDisplayData = { + timerType: 'TIME_BASED'; + currentTeam: TimeBasedStance; + isRunning: boolean; + prosTime: number | null; + consTime: number | null; +}; + +type AudienceDisplayData = + | AudienceNormalDisplayData + | AudienceTimeBasedDisplayData; + +export type AudienceShareState = + | { status: 'connecting'; error: AudienceShareError | null } + | { status: 'waiting'; error: AudienceShareError | null } + | { + status: 'displaying'; + error: AudienceShareError | null; + displayData: AudienceDisplayData; + } + | { status: 'finished'; error: AudienceShareError | null }; + +const EVENT_TIMEOUT_MS = 600 * 1000; + +function getNextTeam(team: TimeBasedStance): TimeBasedStance { + return team === 'PROS' ? 'CONS' : 'PROS'; +} + +export function useAudienceShareState(roomId: number): AudienceShareState { + const { + connect, + disconnect, + latestMessage, + isConnected, + error: socketError, + } = useAudienceSocket(roomId); + + const [error, setError] = useState(null); + const [isFinished, setIsFinished] = useState(false); + + const [displayData, setDisplayData] = useState( + null, + ); + + const timeoutRef = useRef | null>(null); + + const cleanup = useCallback(() => { + if (timeoutRef.current) { + clearTimeout(timeoutRef.current); + timeoutRef.current = null; + } + disconnect(); + }, [disconnect]); + + // Mount/Unmount + useEffect(() => { + connect(); + return () => { + cleanup(); + }; + }, [connect, cleanup]); + + // Handle Socket Error + useEffect(() => { + if (socketError) { + cleanup(); + let code: AudienceShareErrorCode = 'UNKNOWN'; + if (isSocketError(socketError)) { + code = socketError.code; + } + setError( + new AudienceShareError( + code, + socketError instanceof Error + ? socketError + : new Error(String(socketError)), + ), + ); + } + }, [socketError, cleanup]); + + // Handle Messages and Disconnects + useEffect(() => { + if (error || isFinished) return; // Ignore if already failed or finished + + if (!isConnected) { + if (timeoutRef.current) { + clearTimeout(timeoutRef.current); + timeoutRef.current = null; + } + setDisplayData(null); + return; + } + + const resetEventTimeout = () => { + if (timeoutRef.current) { + clearTimeout(timeoutRef.current); + } + timeoutRef.current = setTimeout(() => { + cleanup(); + setError(new AudienceShareError('EVENT_TIMEOUT')); + }, EVENT_TIMEOUT_MS); + }; + + if (!latestMessage) { + resetEventTimeout(); + setDisplayData(null); + return; + } + + resetEventTimeout(); + + if (latestMessage.eventType === 'ERROR') { + cleanup(); + setError(new AudienceShareError('SERVER_ERROR')); + return; + } + + if (latestMessage.eventType === 'FINISHED') { + cleanup(); + setIsFinished(true); + return; + } + + const { eventType, data } = latestMessage; + if (!data) return; + + const isRunning = eventType === 'PLAY'; + + setDisplayData((prev) => { + if (data.timerType === 'NORMAL') { + return { + timerType: 'NORMAL', + currentTeam: null, + isRunning, + singleTime: data.remainingTime, + }; + } + + if (data.currentTeam !== 'PROS' && data.currentTeam !== 'CONS') { + return prev; + } + + const previousTimeBasedData = + prev?.timerType === 'TIME_BASED' ? prev : null; + const receivedCurrentTeam = data.currentTeam; + const displayCurrentTeam = + eventType === 'TEAM_SWITCH' + ? getNextTeam(receivedCurrentTeam) + : receivedCurrentTeam; + + return { + timerType: 'TIME_BASED', + currentTeam: displayCurrentTeam, + isRunning, + prosTime: + receivedCurrentTeam === 'PROS' + ? data.remainingTime + : (previousTimeBasedData?.prosTime ?? null), + consTime: + receivedCurrentTeam === 'CONS' + ? data.remainingTime + : (previousTimeBasedData?.consTime ?? null), + }; + }); + }, [isConnected, latestMessage, error, isFinished, cleanup]); + + let status: AudienceShareState['status'] = 'connecting'; + if (isFinished) { + status = 'finished'; + } else if (!isConnected) { + status = 'connecting'; + } else if (!displayData) { + status = 'waiting'; + } else { + status = 'displaying'; + } + + if (status === 'displaying') { + return { + status, + error, + displayData: displayData!, + }; + } + + return { + status, + error, + }; +} diff --git a/src/page/TimerPage/TimerPage.tsx b/src/page/TimerPage/TimerPage.tsx index e148c61e..8d82c681 100644 --- a/src/page/TimerPage/TimerPage.tsx +++ b/src/page/TimerPage/TimerPage.tsx @@ -110,13 +110,13 @@ export default function TimerPage() { if (timerType === 'NORMAL') { innerPayload = { - currentTeam: prosConsSelected, timerType: timerType, remainingTime: remainingTime, sequence: index, } as TimerDataPayload; } else if (timerType === 'TIME_BASED') { innerPayload = { + currentTeam: prosConsSelected, timerType: timerType, remainingTime: remainingTime, sequence: index, diff --git a/src/routes/routes.tsx b/src/routes/routes.tsx index 9d3fb421..bc0e61b9 100644 --- a/src/routes/routes.tsx +++ b/src/routes/routes.tsx @@ -17,7 +17,7 @@ import VoteParticipationPage from '../page/VoteParticipationPage/VoteParticipati import VoteCompletePage from '../page/VoteCompletePage/VoteCompletePage'; import DebateVoteResultPage from '../page/DebateVoteResultPage/DebateVoteResultPage'; import LanguageWrapper from './LanguageWrapper'; -import { Fragment } from 'react'; +import AudienceSharePage from '../page/AudienceSharePage/AudienceSharePage'; const appRoutes = [ { @@ -87,7 +87,7 @@ const appRoutes = [ }, { path: 'live/:id', - element: , + element: , requiresAuth: false, }, { diff --git a/src/util/formatting.test.ts b/src/util/formatting.test.ts new file mode 100644 index 00000000..86059191 --- /dev/null +++ b/src/util/formatting.test.ts @@ -0,0 +1,26 @@ +import { describe, expect, it } from 'vitest'; +import { Formatting } from './formatting'; + +describe('Formatting 포맷 유틸리티', () => { + describe('formatSecondsToMMSS 초 단위 시간 포맷 함수', () => { + it('0초를 00:00으로 포맷한다', () => { + expect(Formatting.formatSecondsToMMSS(0)).toBe('00:00'); + }); + + it('5초를 00:05로 포맷한다', () => { + expect(Formatting.formatSecondsToMMSS(5)).toBe('00:05'); + }); + + it('65초를 01:05로 포맷한다', () => { + expect(Formatting.formatSecondsToMMSS(65)).toBe('01:05'); + }); + + it('음수 입력값을 0으로 보정한다', () => { + expect(Formatting.formatSecondsToMMSS(-10)).toBe('00:00'); + }); + + it('600초를 10:00으로 포맷한다', () => { + expect(Formatting.formatSecondsToMMSS(600)).toBe('10:00'); + }); + }); +}); diff --git a/src/util/formatting.ts b/src/util/formatting.ts index 075731f6..d40f95ad 100644 --- a/src/util/formatting.ts +++ b/src/util/formatting.ts @@ -9,4 +9,10 @@ export const Formatting = { formatTwoDigits: (num: number): string => { return num.toString().padStart(2, '0'); }, + formatSecondsToMMSS: (time: number): string => { + const clampedTime = Math.max(0, time); + const minutes = Math.floor(clampedTime / 60); + const seconds = clampedTime % 60; + return `${Formatting.formatTwoDigits(minutes)}:${Formatting.formatTwoDigits(seconds)}`; + }, }; From 0fbb8d93520a3e9f16b986f2cc30409669060a58 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yuseon=20Kim=28=EC=8D=AC=EB=8D=B0=EC=9D=B4=29?= <74897720+useon@users.noreply.github.com> Date: Sun, 12 Jul 2026 17:39:21 +0900 Subject: [PATCH 6/7] =?UTF-8?q?[FEAT]=20=EB=8B=B5=EB=B3=80=20=ED=83=80?= =?UTF-8?q?=EC=9D=B4=EB=A8=B8=20=EA=B8=B0=EB=8A=A5=20=EA=B5=AC=ED=98=84=20?= =?UTF-8?q?(#457)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: 답변시간 설정 UI 추가 * feat: 타이머 최초 시작 시 답변 시간 타이머 안내하는 기능 구현 * feat: 답변시간 타이머 시작 버튼 구현 * feat: 답변시간 타이머 진행바 UI 추가 * refactor: 답변시간 타이머 표시 상태를 TimerView로 이동 * feat: 답변시간 타이머 카운트업 및 진행바 표시 개선 * feat: 답변시간 타이머 시작 조건 추가 * feat: 답변시간 타이머 자동 초기화 추가 * refactor: 답변시간 타이머 수동 초기화 흐름 명시 * feat: 답변시간 타이머 Shift 단축키 연결 * feat: 답변시간 타이머 노출 전환 애니메이션 추가 * refactor: 답변시간 타이머 초기화 경로 통일 * fix: 답변시간 타이머 보장 상태 색상 정책 수정 * fix: 답변 타이머 fade-out시 요소 덜컹거리는 문제 해결 * fix: 답변타이머 안내 모달 자동 닫힘 안정화 * refactor: 답변타이머 클릭 핸들러 참조 안정화 * fix: 답변타이머 시작 버튼 비활성화 동작 수정 * refactor: 답변타이머를 필수 prop으로 받도록 수정 * refactor: 답변 타이머 로직 커스텀 훅으로 분리 * refactor: 답변 타이머 컴포넌트 분리 --- public/locales/en/translation.json | 8 + public/locales/ko/translation.json | 8 + .../header/StickyTriSectionHeader.tsx | 2 +- src/page/TimerPage/TimerPage.tsx | 30 ++- .../components/AnswerTimeGuideModal.tsx | 33 ++++ .../components/AnswerTimeProgress.tsx | 65 +++++++ .../components/AnswerTimeSetting.tsx | 136 +++++++++++++ .../TimerPage/components/AnswerTimeTimer.tsx | 45 +++++ .../TimerPage/components/NormalTimer.test.tsx | 1 + src/page/TimerPage/components/NormalTimer.tsx | 5 + .../TimerPage/components/TimeBasedTimer.tsx | 5 + src/page/TimerPage/components/TimerView.tsx | 47 ++++- src/page/TimerPage/hooks/useAnswerTimer.ts | 181 ++++++++++++++++++ src/page/TimerPage/hooks/useTimerPageModal.ts | 26 ++- .../stories/NormalTimerTestPage.stories.tsx | 1 + 15 files changed, 583 insertions(+), 10 deletions(-) create mode 100644 src/page/TimerPage/components/AnswerTimeGuideModal.tsx create mode 100644 src/page/TimerPage/components/AnswerTimeProgress.tsx create mode 100644 src/page/TimerPage/components/AnswerTimeSetting.tsx create mode 100644 src/page/TimerPage/components/AnswerTimeTimer.tsx create mode 100644 src/page/TimerPage/hooks/useAnswerTimer.ts diff --git a/public/locales/en/translation.json b/public/locales/en/translation.json index f73c967c..237065b4 100644 --- a/public/locales/en/translation.json +++ b/public/locales/en/translation.json @@ -20,6 +20,14 @@ "테이블 이름 없음": "Unnamed table", "주제 없음": "No Topic", "도움말": "Help", + "답변시간 설정": "Answer time", + "Shift 키 또는 답변 타이머 버튼을 눌러\n[시작 → 정지 → 초기화] 할 수 있어요": "Press Shift or the Answer Timer button to\n[Start → Stop → Reset]", + "답변시간 타이머 시작": "Start Answer Timer", + "답변": "Answer", + "보장 시간": "Guaranteed time", + "{{seconds}}초": "{{seconds}} sec", + "보장 중": "Within guarantee", + "보장됨": "Guaranteed", "전체 화면": "Fullscreen", "테이블 ID가 올바르지 않습니다.": "Invalid table ID.", "공유받은 테이블을 저장하지 못했어요.": "Couldn't save the shared table.", diff --git a/public/locales/ko/translation.json b/public/locales/ko/translation.json index c0267a24..98dcb290 100644 --- a/public/locales/ko/translation.json +++ b/public/locales/ko/translation.json @@ -20,6 +20,14 @@ "테이블 이름 없음": "테이블 이름 없음", "주제 없음": "주제 없음", "도움말": "도움말", + "답변시간 설정": "답변시간 설정", + "Shift 키 또는 답변 타이머 버튼을 눌러\n[시작 → 정지 → 초기화] 할 수 있어요": "Shift 키 또는 답변 타이머 버튼을 눌러\n[시작 → 정지 → 초기화] 할 수 있어요", + "답변시간 타이머 시작": "답변시간 타이머 시작", + "답변": "답변", + "보장 시간": "보장 시간", + "{{seconds}}초": "{{seconds}}초", + "보장 중": "보장 중", + "보장됨": "보장됨", "전체 화면": "전체 화면", "테이블 ID가 올바르지 않습니다.": "테이블 ID가 올바르지 않습니다.", "공유받은 테이블을 저장하지 못했어요.": "공유받은 테이블을 저장하지 못했어요.", diff --git a/src/layout/components/header/StickyTriSectionHeader.tsx b/src/layout/components/header/StickyTriSectionHeader.tsx index 5e7e115d..3449e93a 100644 --- a/src/layout/components/header/StickyTriSectionHeader.tsx +++ b/src/layout/components/header/StickyTriSectionHeader.tsx @@ -81,7 +81,7 @@ StickyTriSectionHeader.Right = function Right(props: PropsWithChildren) { -
+
{/* props으로 들어오는 버튼들 */} {buttons} diff --git a/src/page/TimerPage/TimerPage.tsx b/src/page/TimerPage/TimerPage.tsx index 8d82c681..9e69f60a 100644 --- a/src/page/TimerPage/TimerPage.tsx +++ b/src/page/TimerPage/TimerPage.tsx @@ -1,4 +1,4 @@ -import { useEffect, useRef } from 'react'; +import { useEffect, useRef, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { useParams } from 'react-router-dom'; import DefaultLayout from '../../layout/defaultLayout/DefaultLayout'; @@ -23,8 +23,8 @@ import { isGuestFlow } from '../../util/sessionStorage'; import useAnalytics from '../../hooks/useAnalytics'; import { consumeTemplateOrigin } from '../../util/analytics/templateOrigin'; import { - RiFullscreenFill, RiFullscreenExitFill, + RiFullscreenFill, RiVolumeMuteFill, } from 'react-icons/ri'; import DTVolume from '../../components/icons/Volume'; @@ -32,16 +32,21 @@ import VolumeBar from '../../components/VolumeBar/VolumeBar'; import { isLoggedIn } from '../../util/accessToken'; import { useLiveShare } from './hooks/useLiveShare'; import { SocketEventType, TimerDataPayload } from '../../apis/sockets/type'; +import AnswerTimeSetting from './components/AnswerTimeSetting'; +import AnswerTimeGuideModal from './components/AnswerTimeGuideModal'; // 토론 타이머 실행, 라운드 이동, 종료 흐름을 관리하는 메인 페이지다. export default function TimerPage() { const { t } = useTranslation(); + const [answerTime, setAnswerTime] = useState(30); const pathParams = useParams(); const tableId = Number(pathParams.id); const { openUseTooltipModal, UseToolTipWrapper, + isAnswerTimeGuideOpen, closeUseTooltipModal, + closeAnswerTimeGuideModal, LoginAndStoreModalWrapper, closeLoginAndStoreModal, openLoginAndStoreModalOrGoToDebateEndPage, @@ -98,6 +103,10 @@ export default function TimerPage() { errorType: socketErrorType, } = useLiveShare(tableId); + const handleChangeAnswerTime = (time: number) => { + setAnswerTime(time); + }; + // 타이머 이벤트를 핸들링하는 래퍼 함수 선언 const handleTimerEvent = (invoke: () => void, eventType: SocketEventType) => { // 이벤트 실행 @@ -204,6 +213,10 @@ export default function TimerPage() { )} + - -
+ ); +} diff --git a/src/page/TimerPage/components/AnswerTimeSetting.tsx b/src/page/TimerPage/components/AnswerTimeSetting.tsx new file mode 100644 index 00000000..f3891301 --- /dev/null +++ b/src/page/TimerPage/components/AnswerTimeSetting.tsx @@ -0,0 +1,136 @@ +import { useCallback, useEffect, useRef, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import clsx from 'clsx'; +import DTExpand from '../../../components/icons/Expand'; + +export const ANSWER_TIME_OPTIONS = [15, 30, 45, 60] as const; + +interface AnswerTimeSettingProps { + answerTime: number; + onChangeAnswerTime: (time: number) => void; +} + +export default function AnswerTimeSetting({ + answerTime, + onChangeAnswerTime, +}: AnswerTimeSettingProps) { + const { t } = useTranslation(); + const [isOpen, setIsOpen] = useState(false); + const settingRef = useRef(null); + + const handleCloseSetting = useCallback(() => { + setIsOpen(false); + }, []); + + const isClickOutsideSetting = useCallback((target: EventTarget | null) => { + return ( + settingRef.current !== null && + target instanceof Node && + !settingRef.current.contains(target) + ); + }, []); + + useEffect(() => { + const handleClickOutside = (event: MouseEvent) => { + if (isClickOutsideSetting(event.target)) { + handleCloseSetting(); + } + }; + + document.addEventListener('mousedown', handleClickOutside); + return () => { + document.removeEventListener('mousedown', handleClickOutside); + }; + }, [handleCloseSetting, isClickOutsideSetting]); + + const handleSelectAnswerTime = (time: number) => { + onChangeAnswerTime(time); + handleCloseSetting(); + }; + + return ( +
+ + +
+

+ {t('보장 시간')} +

+ +
+ +
+ {ANSWER_TIME_OPTIONS.map((seconds) => ( + + ))} +
+ +
+ + +
+
+
+ ); +} + +interface StatusLegendProps { + colorClassName: string; + label: string; +} + +function StatusLegend({ colorClassName, label }: StatusLegendProps) { + return ( + + + {label} + + ); +} diff --git a/src/page/TimerPage/components/AnswerTimeTimer.tsx b/src/page/TimerPage/components/AnswerTimeTimer.tsx new file mode 100644 index 00000000..d11786aa --- /dev/null +++ b/src/page/TimerPage/components/AnswerTimeTimer.tsx @@ -0,0 +1,45 @@ +import { useTranslation } from 'react-i18next'; +import { AnswerTimerOwner, AnswerTimerState } from '../hooks/useAnswerTimer'; +import AnswerTimeProgress from './AnswerTimeProgress'; + +interface AnswerTimeTimerProps { + owner: AnswerTimerOwner; + answerTime: number; + answerTimerState: AnswerTimerState | null; + isMainTimerRunning: boolean; + onClick: (owner: AnswerTimerOwner, isMainTimerRunning: boolean) => void; +} + +export default function AnswerTimeTimer({ + owner, + answerTime, + answerTimerState, + isMainTimerRunning, + onClick, +}: AnswerTimeTimerProps) { + const { t } = useTranslation(); + + return ( +
+ {answerTimerState?.owner === owner ? ( + onClick(owner, isMainTimerRunning)} + /> + ) : ( + + )} +
+ ); +} diff --git a/src/page/TimerPage/components/NormalTimer.test.tsx b/src/page/TimerPage/components/NormalTimer.test.tsx index 24fab366..24404cb5 100644 --- a/src/page/TimerPage/components/NormalTimer.test.tsx +++ b/src/page/TimerPage/components/NormalTimer.test.tsx @@ -38,6 +38,7 @@ function renderNormalTimer( isAdditionalTimerAvailable={false} item={item} teamName={teamName} + answerTimer={null} />, ); } diff --git a/src/page/TimerPage/components/NormalTimer.tsx b/src/page/TimerPage/components/NormalTimer.tsx index 967204dc..f207ca76 100644 --- a/src/page/TimerPage/components/NormalTimer.tsx +++ b/src/page/TimerPage/components/NormalTimer.tsx @@ -1,3 +1,4 @@ +import { ReactNode } from 'react'; import { useTranslation } from 'react-i18next'; import { TimeBoxInfo } from '../../../type/type'; import TimerController from './TimerController'; @@ -26,6 +27,7 @@ interface NormalTimerProps { isAdditionalTimerAvailable: boolean; item: TimeBoxInfo; teamName: string | null; + answerTimer: ReactNode; } export default function NormalTimer({ @@ -33,6 +35,7 @@ export default function NormalTimer({ isAdditionalTimerAvailable, item, teamName, + answerTimer, }: NormalTimerProps) { const { t } = useTranslation(); const getSpeechTypeLabel = (value: string) => { @@ -162,6 +165,8 @@ export default function NormalTimer({
+ {answerTimer} + {/* 조작부 */} + {answerTimer} + {/* 조작부 */} void, eventType: SocketEventType) => void; + answerTime: number; } -export default function TimerView({ state, onEvent }: TimerViewProps) { +export default function TimerView({ + state, + onEvent, + answerTime, +}: TimerViewProps) { // 상태 풀기 const { data, @@ -24,6 +30,16 @@ export default function TimerView({ state, onEvent }: TimerViewProps) { switchCamp, } = state; + const { answerTimerState, handleClickAnswerTimer } = useAnswerTimer({ + answerTime, + data, + index, + normalTimer, + prosConsSelected, + timer1, + timer2, + }); + // 일반 타이머 if (data && data.table[index].boxType === 'NORMAL') { return ( @@ -41,6 +57,15 @@ export default function TimerView({ state, onEvent }: TimerViewProps) { }} isAdditionalTimerAvailable={isAdditionalTimerAvailable} item={data.table[index]} + answerTimer={ + + } teamName={ data.table[index].stance === 'NEUTRAL' ? null @@ -76,6 +101,15 @@ export default function TimerView({ state, onEvent }: TimerViewProps) { } prosCons="PROS" teamName={data.info.prosTeamName} + answerTimer={ + + } /> {/* ENTER 버튼 */} @@ -107,6 +141,15 @@ export default function TimerView({ state, onEvent }: TimerViewProps) { } prosCons="CONS" teamName={data.info.consTeamName} + answerTimer={ + + } />
); diff --git a/src/page/TimerPage/hooks/useAnswerTimer.ts b/src/page/TimerPage/hooks/useAnswerTimer.ts new file mode 100644 index 00000000..b56599e8 --- /dev/null +++ b/src/page/TimerPage/hooks/useAnswerTimer.ts @@ -0,0 +1,181 @@ +import { useCallback, useEffect, useRef, useState } from 'react'; +import { DebateTableData, TimeBasedStance } from '../../../type/type'; +import { NormalTimerLogics } from './useNormalTimer'; +import { TimeBasedTimerLogics } from './useTimeBasedTimer'; + +export type AnswerTimerOwner = 'NORMAL' | 'PROS' | 'CONS'; + +type AnswerTimerStatus = 'running' | 'stopped' | 'resetting'; + +export interface AnswerTimerState { + owner: AnswerTimerOwner; + status: AnswerTimerStatus; + elapsedTime: number; +} + +interface UseAnswerTimerParams { + answerTime: number; + data: DebateTableData | undefined; + index: number; + normalTimer: NormalTimerLogics; + timer1: TimeBasedTimerLogics; + timer2: TimeBasedTimerLogics; + prosConsSelected: TimeBasedStance; +} + +export function useAnswerTimer({ + answerTime, + data, + index, + normalTimer, + timer1, + timer2, + prosConsSelected, +}: UseAnswerTimerParams) { + const [answerTimerState, setAnswerTimerState] = + useState(null); + const previousProsConsSelectedRef = useRef(prosConsSelected); + const answerTimerOwner = answerTimerState?.owner; + const isAnswerTimerMainTimerRunning = + answerTimerOwner === 'NORMAL' + ? normalTimer.isRunning + : answerTimerOwner === 'PROS' + ? timer1.isRunning + : answerTimerOwner === 'CONS' + ? timer2.isRunning + : true; + + const resetAnswerTimer = useCallback(() => { + setAnswerTimerState((currentState) => { + if (!currentState) return currentState; + + return { ...currentState, status: 'resetting', elapsedTime: 0 }; + }); + }, []); + + const handleClickAnswerTimer = useCallback( + (owner: AnswerTimerOwner, isMainTimerRunning: boolean) => { + setAnswerTimerState((currentState) => { + if (currentState?.owner !== owner) { + if (!isMainTimerRunning) return currentState; + + return { owner, status: 'running', elapsedTime: 0 }; + } + + if (currentState.status === 'running') { + return { ...currentState, status: 'stopped' }; + } + + return { ...currentState, status: 'resetting', elapsedTime: 0 }; + }); + }, + [], + ); + + useEffect(() => { + if (answerTimerState?.status !== 'running') return; + + const intervalId = window.setInterval(() => { + setAnswerTimerState((currentState) => { + if (currentState?.status !== 'running') { + return currentState; + } + + const nextElapsedTime = Number( + (currentState.elapsedTime + 0.1).toFixed(1), + ); + + if (nextElapsedTime >= answerTime) { + return { + ...currentState, + status: 'stopped', + elapsedTime: answerTime, + }; + } + + return { + ...currentState, + elapsedTime: nextElapsedTime, + }; + }); + }, 100); + + return () => window.clearInterval(intervalId); + }, [answerTime, answerTimerState?.status]); + + useEffect(() => { + if (answerTimerState?.status !== 'resetting') return; + + const timeoutId = window.setTimeout(() => { + setAnswerTimerState((currentState) => { + if (currentState?.status !== 'resetting') return currentState; + + return null; + }); + }, 350); + + return () => window.clearTimeout(timeoutId); + }, [answerTimerState?.status]); + + useEffect(() => { + if (!answerTimerOwner || isAnswerTimerMainTimerRunning) return; + + resetAnswerTimer(); + }, [answerTimerOwner, isAnswerTimerMainTimerRunning, resetAnswerTimer]); + + useEffect(() => { + if (previousProsConsSelectedRef.current === prosConsSelected) return; + + previousProsConsSelectedRef.current = prosConsSelected; + + if (answerTimerOwner !== 'PROS' && answerTimerOwner !== 'CONS') return; + + resetAnswerTimer(); + }, [answerTimerOwner, prosConsSelected, resetAnswerTimer]); + + useEffect(() => { + if (!data) return; + + const handleKeyDown = (event: KeyboardEvent) => { + if (event.code !== 'ShiftLeft' && event.code !== 'ShiftRight') return; + if (event.repeat) return; + + event.preventDefault(); + + if (event.target instanceof HTMLElement) { + event.target.blur(); + } + + const boxType = data.table[index].boxType; + + if (boxType === 'NORMAL') { + handleClickAnswerTimer('NORMAL', normalTimer.isRunning); + return; + } + + if (prosConsSelected === 'PROS') { + handleClickAnswerTimer('PROS', timer1.isRunning); + return; + } + + handleClickAnswerTimer('CONS', timer2.isRunning); + }; + + window.addEventListener('keydown', handleKeyDown); + + return () => window.removeEventListener('keydown', handleKeyDown); + }, [ + data, + handleClickAnswerTimer, + index, + normalTimer.isRunning, + prosConsSelected, + timer1.isRunning, + timer2.isRunning, + ]); + + return { + answerTimerState, + handleClickAnswerTimer, + }; +} diff --git a/src/page/TimerPage/hooks/useTimerPageModal.ts b/src/page/TimerPage/hooks/useTimerPageModal.ts index 13cccb0a..4716b3c3 100644 --- a/src/page/TimerPage/hooks/useTimerPageModal.ts +++ b/src/page/TimerPage/hooks/useTimerPageModal.ts @@ -1,5 +1,5 @@ // hooks/useTimerPageModal.ts -import { useEffect } from 'react'; +import { useCallback, useEffect, useRef, useState } from 'react'; import { useModal } from '../../../hooks/useModal'; import { useNavigate } from 'react-router-dom'; import { isGuestFlow } from '../../../util/sessionStorage'; @@ -17,6 +17,8 @@ export function useTimerPageModal(tableId: number) { const lang = isSupportedLang(currentLang) ? currentLang : DEFAULT_LANG; const IS_VISITED = 'isVisited'; const TRUE = 'true'; + const [isAnswerTimeGuideOpen, setIsAnswerTimeGuideOpen] = useState(false); + const hasOpenedAnswerTimeGuideRef = useRef(false); // 툴팁(처음 사용 안내) 모달 const { @@ -31,6 +33,13 @@ export function useTimerPageModal(tableId: number) { isCloseButtonExist: false, }); + const openAnswerTimeGuide = useCallback(() => { + if (hasOpenedAnswerTimeGuideRef.current) return; + + hasOpenedAnswerTimeGuideRef.current = true; + setIsAnswerTimeGuideOpen(true); + }, []); + // 로그인/저장 유도 모달 const { openModal: openLoginAndStoreModal, @@ -43,10 +52,21 @@ export function useTimerPageModal(tableId: number) { const isVisited = localStorage.getItem(IS_VISITED); if (isVisited === null || isVisited !== TRUE) { openUseTooltipModal(); + return; } + openAnswerTimeGuide(); // eslint-disable-next-line }, []); + const closeUseTooltipModalAndOpenAnswerTimeGuide = useCallback(() => { + closeUseTooltipModal(); + openAnswerTimeGuide(); + }, [closeUseTooltipModal, openAnswerTimeGuide]); + + const closeAnswerTimeGuide = useCallback(() => { + setIsAnswerTimeGuideOpen(false); + }, []); + const openLoginAndStoreModalOrGoToDebateEndPage = () => { if (isGuestFlow()) { openLoginAndStoreModal(); @@ -57,11 +77,13 @@ export function useTimerPageModal(tableId: number) { return { isUseTooltipOpen, + isAnswerTimeGuideOpen, isLoginAndStoreOpen, UseToolTipWrapper, LoginAndStoreModalWrapper, openUseTooltipModal, - closeUseTooltipModal, + closeUseTooltipModal: closeUseTooltipModalAndOpenAnswerTimeGuide, + closeAnswerTimeGuideModal: closeAnswerTimeGuide, openLoginAndStoreModal, closeLoginAndStoreModal, openLoginAndStoreModalOrGoToDebateEndPage, diff --git a/src/page/TimerPage/stories/NormalTimerTestPage.stories.tsx b/src/page/TimerPage/stories/NormalTimerTestPage.stories.tsx index 96d4cfad..cf753c0e 100644 --- a/src/page/TimerPage/stories/NormalTimerTestPage.stories.tsx +++ b/src/page/TimerPage/stories/NormalTimerTestPage.stories.tsx @@ -49,6 +49,7 @@ function NormalTimerTestPage() { item={item} teamName="찬성" isAdditionalTimerAvailable={false} + answerTimer={null} /> {/* NEXT 버튼만 하단에 표시 */} From 7cf90bf01328bd96af5ac1dd86e4bc698ec9e457 Mon Sep 17 00:00:00 2001 From: Shawn Kang <77564014+i-meant-to-be@users.noreply.github.com> Date: Mon, 13 Jul 2026 12:15:47 +0900 Subject: [PATCH 7/7] =?UTF-8?q?[CHORE]=20=ED=86=A0=EB=A1=A0=20=EB=9D=BC?= =?UTF-8?q?=EC=9D=B4=EB=B8=8C=20=EA=B3=B5=EC=9C=A0=20=EA=B8=B0=EB=8A=A5=20?= =?UTF-8?q?=EB=B9=84=ED=99=9C=EC=84=B1=ED=99=94=20(#462)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * chore: 토론 라이브 공유 기능 비활성화 * fix: 빌드에 영향 주는 코드 주석 처리 * chore: Feature Flag 방식으로 수정 --- src/page/TimerPage/TimerPage.tsx | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/page/TimerPage/TimerPage.tsx b/src/page/TimerPage/TimerPage.tsx index 9e69f60a..f945c2e5 100644 --- a/src/page/TimerPage/TimerPage.tsx +++ b/src/page/TimerPage/TimerPage.tsx @@ -35,6 +35,9 @@ import { SocketEventType, TimerDataPayload } from '../../apis/sockets/type'; import AnswerTimeSetting from './components/AnswerTimeSetting'; import AnswerTimeGuideModal from './components/AnswerTimeGuideModal'; +// 피처 플래그 +const IS_LIVE_SHARE_ENABLED = false; + // 토론 타이머 실행, 라운드 이동, 종료 흐름을 관리하는 메인 페이지다. export default function TimerPage() { const { t } = useTranslation(); @@ -279,7 +282,7 @@ export default function TimerPage() { )} > {/* 라이브 공유 버튼 및 모달 */} - {isLoggedIn() && ( + {IS_LIVE_SHARE_ENABLED && isLoggedIn() && (