diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..6ec3079 --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,38 @@ +name: Test + +on: + push: + branches: + - '**' + +jobs: + test: + name: Run Tests & Coverage + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: '20' + cache: 'npm' + cache-dependency-path: vehicle-management/package-lock.json + + - name: Install dependencies + working-directory: vehicle-management + run: npm ci + + - name: Run tests with coverage + working-directory: vehicle-management + run: npm run test:coverage + + - name: Upload coverage report + uses: actions/upload-artifact@v4 + if: always() + with: + name: coverage-report + path: vehicle-management/coverage/ + retention-days: 30 diff --git a/.gitignore b/.gitignore index 3091c27..e41016f 100644 --- a/.gitignore +++ b/.gitignore @@ -41,3 +41,6 @@ Thumbs.db # Claude Code — 保留進版控 # .claude/ # openspec/ + +# PR review draft +pr-review.md diff --git a/jest.config.js b/jest.config.js new file mode 100644 index 0000000..ad9b38a --- /dev/null +++ b/jest.config.js @@ -0,0 +1,3 @@ +export default { + testPathIgnorePatterns: ['/node_modules/', '/vehicle-management/'], +} diff --git a/openspec/changes/archive/2026-05-04-user-activity-log-page/.openspec.yaml b/openspec/changes/archive/2026-05-04-user-activity-log-page/.openspec.yaml new file mode 100644 index 0000000..905325f --- /dev/null +++ b/openspec/changes/archive/2026-05-04-user-activity-log-page/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-05-04 diff --git a/openspec/changes/archive/2026-05-04-user-activity-log-page/design.md b/openspec/changes/archive/2026-05-04-user-activity-log-page/design.md new file mode 100644 index 0000000..f68e87d --- /dev/null +++ b/openspec/changes/archive/2026-05-04-user-activity-log-page/design.md @@ -0,0 +1,77 @@ +## Context + +VMS 目前缺乏稽核軌跡(audit trail)功能,管理者無法追蹤使用者在系統內執行了哪些操作(例如登入、車輛新增/修改/刪除、員工資料異動)。此變更新增一個僅限 admin 存取的「使用者紀錄」頁面,以表格形式呈現所有操作日誌,並支援篩選與分頁。 + +由於專案沒有真實後端,所有資料均由 MSW mock。 + +## Goals / Non-Goals + +**Goals:** +- 在 `/activity-logs` 新增受保護的管理者頁面 +- 提供 MSW mock API `GET /api/activity-logs`,回傳分頁日誌資料 +- 支援依使用者與操作類型篩選 +- 與現有 Layout / ProtectedRoute 整合,不改變 auth 流程 + +**Non-Goals:** +- 真實後端儲存或即時更新 +- 日誌匯出(CSV / PDF) +- 使用者自助查詢自己的紀錄 + +## Decisions + +### 1. 路由與存取控制:使用現有 ProtectedRoute + requiredRole + +- **決定**:新增 `/activity-logs` 路由,套用 `` +- **理由**:與 `/employees` 的保護方式一致,不需引入新的守衛機制 +- **替代方案**:在 Layout 層做 role check — 較脆弱且不符合現有模式 + +### 2. Mock 資料結構:獨立的 `activityLogs.ts` mock 資料檔 + +- **決定**:將 mock 日誌資料放在 `src/mocks/activityLogs.ts`,handler 從該檔 import +- **理由**:與 `data.ts` 的分離模式一致,handler 保持薄薄的(thin);資料易於維護 +- **替代方案**:直接在 handler 內 inline — 難以閱讀,未來不易擴充 + +### 3. API 設計:支援 query string 篩選與分頁 + +- **決定**:`GET /api/activity-logs?username=&action=&page=1&pageSize=20` +- **理由**:由 MSW handler 在記憶體中對 mock 陣列做過濾,行為與真實 API 一致 +- **替代方案**:前端全量取回後再過濾 — 不符合 API 設計慣例,未來難以換成真實後端 + +### 4. 日誌資料模型 + +```ts +type ActivityAction = 'login' | 'logout' | 'vehicle_create' | 'vehicle_update' | 'vehicle_delete' | 'employee_create' | 'employee_update' | 'employee_delete' + +interface ActivityLog { + id: string + timestamp: string // ISO 8601 + username: string + role: 'admin' | 'user' + action: ActivityAction + target?: string // e.g., 車牌號碼或員工姓名 + description: string // 人類可讀描述(繁體中文) +} +``` + +### 5. UI 元件:表格 + 篩選列 + +- 使用 shadcn/ui Table 元件(與車輛/員工頁一致) +- 篩選列:使用者下拉選單 + 操作類型下拉選單 + 重設按鈕 +- 分頁元件(上/下頁按鈕 + 目前頁數顯示) + +## Risks / Trade-offs + +- **靜態 mock 資料** → 新增/修改/刪除車輛或員工時不會自動產生新日誌。已知限制,屬 Non-goal。 +- **分頁在前端計算** → mock handler 對陣列做 slice,行為符合預期但非資料庫分頁。可接受。 +- **無法測試真實時序** → 日誌時間戳為靜態假資料,不反映實際操作順序。 + +## Migration Plan + +無需資料遷移。新增路由與 MSW handler 不影響現有功能。部署步驟: +1. 新增 mock 資料與 handler +2. 新增頁面元件與路由 +3. 更新 Layout 導覽列 + +## Open Questions + +- 是否需要顯示 IP 位址欄位?(目前 mock 資料不包含,可後續擴充) diff --git a/openspec/changes/archive/2026-05-04-user-activity-log-page/proposal.md b/openspec/changes/archive/2026-05-04-user-activity-log-page/proposal.md new file mode 100644 index 0000000..2a301a0 --- /dev/null +++ b/openspec/changes/archive/2026-05-04-user-activity-log-page/proposal.md @@ -0,0 +1,32 @@ +## Why + +管理者目前無法追蹤系統內的使用者操作行為,缺乏可視性與稽核能力。新增使用者紀錄頁面,讓管理者能夠查看所有帳號的登入、登出及資料操作等活動日誌,提升系統安全性與可管理性。 + +## What Changes + +- 新增「使用者紀錄」頁面(限 admin 角色存取) +- 在側邊導覽列新增「使用者紀錄」入口 +- 紀錄列表支援依時間排序、依使用者與操作類型篩選 +- 新增 MSW mock API `/api/activity-logs` 回傳活動日誌資料 + +## Capabilities + +### New Capabilities + +- `user-activity-log`: 使用者活動日誌頁面,顯示所有帳號的操作紀錄,支援篩選與分頁,限 admin 存取 + +### Modified Capabilities + +- `auth`: 登入與登出事件需記錄至活動日誌(新增 mock 資料中的 auth 事件類型) + +## Non-goals + +- 不實作真實後端儲存,所有資料皆為 MSW mock +- 不支援日誌匯出(CSV / PDF) +- 不實作即時(WebSocket)更新 + +## Impact + +- **新增檔案**:`vehicle-management/src/pages/activity-logs.tsx`、`vehicle-management/src/mocks/activityLogs.ts` +- **修改檔案**:`vehicle-management/src/mocks/handlers.ts`(新增 `/api/activity-logs` handler)、`vehicle-management/src/App.tsx`(新增路由)、`vehicle-management/src/components/Layout.tsx`(新增導覽連結) +- **受影響角色**:僅 admin diff --git a/openspec/changes/archive/2026-05-04-user-activity-log-page/specs/user-activity-log/spec.md b/openspec/changes/archive/2026-05-04-user-activity-log-page/specs/user-activity-log/spec.md new file mode 100644 index 0000000..9c8e4fe --- /dev/null +++ b/openspec/changes/archive/2026-05-04-user-activity-log-page/specs/user-activity-log/spec.md @@ -0,0 +1,64 @@ +## ADDED Requirements + +### Requirement: 管理者可存取使用者紀錄頁面 +系統 SHALL 在路由 `/activity-logs` 提供一個使用者活動紀錄頁面,且僅允許角色為 `admin` 的使用者存取。 + +#### Scenario: 管理者存取使用者紀錄頁面 +- **WHEN** 角色為 `admin` 的使用者導航至 `/activity-logs` +- **THEN** 系統顯示使用者紀錄頁面,包含活動日誌列表 + +#### Scenario: 一般使用者嘗試存取使用者紀錄頁面 +- **WHEN** 角色為 `user` 的使用者導航至 `/activity-logs` +- **THEN** 系統重定向至 `/`(儀表板),不顯示紀錄內容 + +### Requirement: 系統顯示活動日誌列表 +系統 SHALL 以表格形式顯示所有使用者的操作紀錄,每筆紀錄包含:時間、使用者名稱、角色、操作類型、操作對象(若有)、描述。 + +#### Scenario: 日誌列表正常載入 +- **WHEN** 管理者進入使用者紀錄頁面 +- **THEN** 系統顯示活動日誌表格,按時間由新至舊排序,每頁最多顯示 20 筆 + +#### Scenario: 無符合紀錄 +- **WHEN** 篩選條件下無任何符合的日誌 +- **THEN** 系統顯示「目前沒有符合的紀錄」提示訊息,不顯示表格列 + +### Requirement: 管理者可依使用者與操作類型篩選日誌 +系統 SHALL 提供篩選列,允許管理者依使用者名稱與操作類型縮小日誌範圍。 + +#### Scenario: 依使用者篩選 +- **WHEN** 管理者從使用者下拉選單選擇特定使用者 +- **THEN** 列表僅顯示該使用者的活動紀錄 + +#### Scenario: 依操作類型篩選 +- **WHEN** 管理者從操作類型下拉選單選擇特定類型(如「登入」) +- **THEN** 列表僅顯示該操作類型的紀錄 + +#### Scenario: 重設篩選條件 +- **WHEN** 管理者點擊「重設」按鈕 +- **THEN** 所有篩選條件清空,列表回復顯示全部紀錄 + +### Requirement: 日誌列表支援分頁 +系統 SHALL 提供分頁功能,允許管理者在多頁日誌間切換。 + +#### Scenario: 切換至下一頁 +- **WHEN** 管理者點擊「下一頁」按鈕,且後續有更多紀錄 +- **THEN** 系統顯示下一頁的紀錄,頁碼更新 + +#### Scenario: 已在最後一頁 +- **WHEN** 管理者在最後一頁 +- **THEN** 「下一頁」按鈕為禁用狀態 + +#### Scenario: 已在第一頁 +- **WHEN** 管理者在第一頁 +- **THEN** 「上一頁」按鈕為禁用狀態 + +### Requirement: 系統提供活動日誌 API +系統 SHALL 在 `GET /api/activity-logs` 提供活動日誌資料,支援 query string 參數:`username`、`action`、`page`(預設 1)、`pageSize`(預設 20)。 + +#### Scenario: 取得全部日誌 +- **WHEN** 呼叫 `GET /api/activity-logs` 無任何 query 參數 +- **THEN** API 回傳第一頁的所有日誌資料及總筆數 + +#### Scenario: 依條件篩選日誌 +- **WHEN** 呼叫 `GET /api/activity-logs?username=admin&action=login` +- **THEN** API 回傳符合 username=admin 且 action=login 的紀錄,並套用分頁 diff --git a/openspec/changes/archive/2026-05-04-user-activity-log-page/tasks.md b/openspec/changes/archive/2026-05-04-user-activity-log-page/tasks.md new file mode 100644 index 0000000..33224f7 --- /dev/null +++ b/openspec/changes/archive/2026-05-04-user-activity-log-page/tasks.md @@ -0,0 +1,22 @@ +## 1. Mock 資料與 API + +- [x] 1.1 建立 `vehicle-management/src/mocks/activityLogs.ts`,定義 `ActivityLog` 型別與至少 30 筆涵蓋各 action 類型的靜態 mock 資料 +- [x] 1.2 在 `vehicle-management/src/mocks/handlers.ts` 新增 `GET /api/activity-logs` handler,支援 `username`、`action`、`page`、`pageSize` query 參數,回傳分頁結果與 `total` 欄位 + +## 2. 頁面元件 + +- [x] 2.1 建立 `vehicle-management/src/pages/activity-logs.tsx`,實作頁面骨架(標題、篩選列、表格、分頁) +- [x] 2.2 實作篩選列:使用者下拉選單(從 mock 資料中取得不重複 username)+ 操作類型下拉選單 + 重設按鈕 +- [x] 2.3 實作活動日誌表格,欄位:時間、使用者、角色、操作類型、操作對象、描述 +- [x] 2.4 實作分頁控制列:「上一頁」/「下一頁」按鈕 + 目前頁數 / 總頁數顯示,邊界條件下按鈕禁用 + +## 3. 路由與導覽整合 + +- [x] 3.1 在 `vehicle-management/src/App.tsx` 新增 `/activity-logs` 路由,套用 `` +- [x] 3.2 在 `vehicle-management/src/components/Layout.tsx` 導覽列新增「使用者紀錄」連結(僅 admin 角色可見) + +## 4. 驗證與收尾 + +- [x] 4.1 啟動 dev server(`npm run dev`),以 admin 帳號測試頁面存取、篩選、分頁功能 +- [x] 4.2 以 user 帳號確認 `/activity-logs` 正確重定向至 `/` +- [x] 4.3 執行 `npm run lint` 確認無 ESLint 錯誤 diff --git a/openspec/specs/user-activity-log/spec.md b/openspec/specs/user-activity-log/spec.md new file mode 100644 index 0000000..4fd7486 --- /dev/null +++ b/openspec/specs/user-activity-log/spec.md @@ -0,0 +1,70 @@ +# User Activity Log + +## Purpose + +提供管理者查看所有使用者操作活動日誌的頁面,支援依使用者與操作類型篩選及分頁瀏覽,僅限 `admin` 角色存取。 + +## Requirements + +### Requirement: 管理者可存取使用者紀錄頁面 +系統 SHALL 在路由 `/activity-logs` 提供一個使用者活動紀錄頁面,且僅允許角色為 `admin` 的使用者存取。 + +#### Scenario: 管理者存取使用者紀錄頁面 +- **WHEN** 角色為 `admin` 的使用者導航至 `/activity-logs` +- **THEN** 系統顯示使用者紀錄頁面,包含活動日誌列表 + +#### Scenario: 一般使用者嘗試存取使用者紀錄頁面 +- **WHEN** 角色為 `user` 的使用者導航至 `/activity-logs` +- **THEN** 系統重定向至 `/`(儀表板),不顯示紀錄內容 + +### Requirement: 系統顯示活動日誌列表 +系統 SHALL 以表格形式顯示所有使用者的操作紀錄,每筆紀錄包含:時間、使用者名稱、角色、操作類型、操作對象(若有)、描述。 + +#### Scenario: 日誌列表正常載入 +- **WHEN** 管理者進入使用者紀錄頁面 +- **THEN** 系統顯示活動日誌表格,按時間由新至舊排序,每頁最多顯示 20 筆 + +#### Scenario: 無符合紀錄 +- **WHEN** 篩選條件下無任何符合的日誌 +- **THEN** 系統顯示「目前沒有符合的紀錄」提示訊息,不顯示表格列 + +### Requirement: 管理者可依使用者與操作類型篩選日誌 +系統 SHALL 提供篩選列,允許管理者依使用者名稱與操作類型縮小日誌範圍。 + +#### Scenario: 依使用者篩選 +- **WHEN** 管理者從使用者下拉選單選擇特定使用者 +- **THEN** 列表僅顯示該使用者的活動紀錄 + +#### Scenario: 依操作類型篩選 +- **WHEN** 管理者從操作類型下拉選單選擇特定類型(如「登入」) +- **THEN** 列表僅顯示該操作類型的紀錄 + +#### Scenario: 重設篩選條件 +- **WHEN** 管理者點擊「重設」按鈕 +- **THEN** 所有篩選條件清空,列表回復顯示全部紀錄 + +### Requirement: 日誌列表支援分頁 +系統 SHALL 提供分頁功能,允許管理者在多頁日誌間切換。 + +#### Scenario: 切換至下一頁 +- **WHEN** 管理者點擊「下一頁」按鈕,且後續有更多紀錄 +- **THEN** 系統顯示下一頁的紀錄,頁碼更新 + +#### Scenario: 已在最後一頁 +- **WHEN** 管理者在最後一頁 +- **THEN** 「下一頁」按鈕為禁用狀態 + +#### Scenario: 已在第一頁 +- **WHEN** 管理者在第一頁 +- **THEN** 「上一頁」按鈕為禁用狀態 + +### Requirement: 系統提供活動日誌 API +系統 SHALL 在 `GET /api/activity-logs` 提供活動日誌資料,支援 query string 參數:`username`、`action`、`page`(預設 1)、`pageSize`(預設 20)。 + +#### Scenario: 取得全部日誌 +- **WHEN** 呼叫 `GET /api/activity-logs` 無任何 query 參數 +- **THEN** API 回傳第一頁的所有日誌資料及總筆數 + +#### Scenario: 依條件篩選日誌 +- **WHEN** 呼叫 `GET /api/activity-logs?username=admin&action=login` +- **THEN** API 回傳符合 username=admin 且 action=login 的紀錄,並套用分頁 diff --git a/vehicle-management/.gitignore b/vehicle-management/.gitignore index a547bf3..785a308 100644 --- a/vehicle-management/.gitignore +++ b/vehicle-management/.gitignore @@ -10,6 +10,7 @@ lerna-debug.log* node_modules dist dist-ssr +coverage *.local # Editor directories and files diff --git a/vehicle-management/doc/test/frontend/login-page.md b/vehicle-management/doc/test/frontend/login-page.md new file mode 100644 index 0000000..2adee11 --- /dev/null +++ b/vehicle-management/doc/test/frontend/login-page.md @@ -0,0 +1,84 @@ +--- +description: LoginPage 登入頁面測試案例 +--- + +> 狀態:初始為 [ ]、完成為 [x] +> 注意:狀態只能在測試通過後由流程更新。 + +--- + +## [x] 【渲染】顯示帳號輸入欄位 +**範例輸入**:渲染 LoginPage +**期待輸出**:頁面上存在 label 為「帳號」的 input(id="username") + +--- + +## [x] 【渲染】顯示密碼輸入欄位 +**範例輸入**:渲染 LoginPage +**期待輸出**:頁面上存在 label 為「密碼」的 input(id="password",type="password") + +--- + +## [x] 【渲染】顯示登入按鈕 +**範例輸入**:渲染 LoginPage +**期待輸出**:頁面上存在文字為「登入」的 button,且 disabled=false + +--- + +## [x] 【渲染】顯示測試帳號提示 +**範例輸入**:渲染 LoginPage +**期待輸出**:頁面上顯示「admin / admin123」與「user / user123」提示文字 + +--- + +## [x] 【表單驗證】帳號與密碼均為空時顯示錯誤訊息 +**範例輸入**:不填帳號、不填密碼,點擊登入 +**期待輸出**:顯示「帳號與密碼為必填欄位」錯誤訊息,login() 未被呼叫 + +--- + +## [x] 【表單驗證】僅帳號為空時顯示錯誤訊息 +**範例輸入**:帳號留空,密碼填 "admin123",點擊登入 +**期待輸出**:顯示「帳號與密碼為必填欄位」錯誤訊息,login() 未被呼叫 + +--- + +## [x] 【表單驗證】僅密碼為空時顯示錯誤訊息 +**範例輸入**:帳號填 "admin",密碼留空,點擊登入 +**期待輸出**:顯示「帳號與密碼為必填欄位」錯誤訊息,login() 未被呼叫 + +--- + +## [x] 【表單驗證】帳號與密碼均為空白字元時顯示錯誤訊息 +**範例輸入**:帳號填 " ",密碼填 " ",點擊登入 +**期待輸出**:顯示「帳號與密碼為必填欄位」錯誤訊息,login() 未被呼叫 + +--- + +## [x] 【登入成功】呼叫 login() 並導向首頁 +**範例輸入**:帳號填 "admin",密碼填 "admin123",login() mock 為成功(resolve) +**期待輸出**:login("admin", "admin123") 被呼叫一次;navigate("/", { replace: true }) 被呼叫 + +--- + +## [x] 【登入失敗】login() 拋出 Error 時顯示錯誤訊息 +**範例輸入**:帳號填 "wrong",密碼填 "wrong",login() mock 拋出 new Error("帳號或密碼錯誤") +**期待輸出**:顯示「帳號或密碼錯誤」錯誤訊息,未導向其他頁面 + +--- + +## [x] 【登入失敗】login() 拋出非 Error 時顯示預設錯誤訊息 +**範例輸入**:login() mock 拋出字串 "unknown" +**期待輸出**:顯示「登入失敗,請稍後再試」錯誤訊息 + +--- + +## [x] 【Loading 狀態】送出表單期間按鈕顯示「登入中...」並禁用 +**範例輸入**:login() mock 為永不 resolve 的 Promise,送出表單後立即檢查 +**期待輸出**:按鈕顯示「登入中...」且 disabled=true + +--- + +## [x] 【Loading 狀態】登入完成後按鈕恢復可用 +**範例輸入**:login() mock 拋出錯誤(確保流程結束),送出表單並等待 +**期待輸出**:loading 結束後,按鈕 disabled=false diff --git a/vehicle-management/eslint.config.js b/vehicle-management/eslint.config.js index ef614d2..254bc4d 100644 --- a/vehicle-management/eslint.config.js +++ b/vehicle-management/eslint.config.js @@ -6,7 +6,7 @@ import tseslint from 'typescript-eslint' import { defineConfig, globalIgnores } from 'eslint/config' export default defineConfig([ - globalIgnores(['dist']), + globalIgnores(['dist', 'coverage', 'public/mockServiceWorker.js']), { files: ['**/*.{ts,tsx}'], extends: [ diff --git a/vehicle-management/package-lock.json b/vehicle-management/package-lock.json index ce5c999..8ed8004 100644 --- a/vehicle-management/package-lock.json +++ b/vehicle-management/package-lock.json @@ -26,20 +26,84 @@ "devDependencies": { "@eslint/js": "^10.0.1", "@tailwindcss/vite": "^4.2.4", + "@testing-library/jest-dom": "^6.9.1", + "@testing-library/react": "^16.3.2", + "@testing-library/user-event": "^14.6.1", "@types/node": "^24.12.2", "@types/react": "^19.2.14", "@types/react-dom": "^19.2.3", "@vitejs/plugin-react": "^6.0.1", + "@vitest/coverage-v8": "^4.1.5", "eslint": "^10.2.1", "eslint-plugin-react-hooks": "^7.1.1", "eslint-plugin-react-refresh": "^0.5.2", "globals": "^17.5.0", + "jsdom": "^29.1.1", "tailwindcss": "^4.2.4", "typescript": "~6.0.2", "typescript-eslint": "^8.58.2", - "vite": "^8.0.10" + "vite": "^8.0.10", + "vitest": "^4.1.5" } }, + "node_modules/@adobe/css-tools": { + "version": "4.4.4", + "resolved": "https://registry.npmjs.org/@adobe/css-tools/-/css-tools-4.4.4.tgz", + "integrity": "sha512-Elp+iwUx5rN5+Y8xLt5/GRoG20WGoDCQ/1Fb+1LiGtvwbDavuSk0jhD/eZdckHAuzcDzccnkv+rEjyWfRx18gg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@asamuzakjp/css-color": { + "version": "5.1.11", + "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-5.1.11.tgz", + "integrity": "sha512-KVw6qIiCTUQhByfTd78h2yD1/00waTmm9uy/R7Ck/ctUyAPj+AEDLkQIdJW0T8+qGgj3j5bpNKK7Q3G+LedJWg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/generational-cache": "^1.0.1", + "@csstools/css-calc": "^3.2.0", + "@csstools/css-color-parser": "^4.1.0", + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/@asamuzakjp/dom-selector": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-7.1.1.tgz", + "integrity": "sha512-67RZDnYRc8H/8MLDgQCDE//zoqVFwajkepHZgmXrbwybzXOEwOWGPYGmALYl9J2DOLfFPPs6kKCqmbzV895hTQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/generational-cache": "^1.0.1", + "@asamuzakjp/nwsapi": "^2.3.9", + "bidi-js": "^1.0.3", + "css-tree": "^3.2.1", + "is-potential-custom-element-name": "^1.0.1" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/@asamuzakjp/generational-cache": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@asamuzakjp/generational-cache/-/generational-cache-1.0.1.tgz", + "integrity": "sha512-wajfB8KqzMCN2KGNFdLkReeHncd0AslUSrvHVvvYWuU8ghncRJoA50kT3zP9MVL0+9g4/67H+cdvBskj9THPzg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/@asamuzakjp/nwsapi": { + "version": "2.3.9", + "resolved": "https://registry.npmjs.org/@asamuzakjp/nwsapi/-/nwsapi-2.3.9.tgz", + "integrity": "sha512-n8GuYSrI9bF7FFZ/SjhwevlHc8xaVlb/7HmHelnc/PZXBD2ZR49NnN9sMMuDdEGPeeRQ5d0hqlSlEpgCX3Wl0Q==", + "dev": true, + "license": "MIT" + }, "node_modules/@babel/code-frame": { "version": "7.29.0", "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz", @@ -514,6 +578,169 @@ } } }, + "node_modules/@bcoe/v8-coverage": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@bcoe/v8-coverage/-/v8-coverage-1.0.2.tgz", + "integrity": "sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/@bramus/specificity": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/@bramus/specificity/-/specificity-2.4.2.tgz", + "integrity": "sha512-ctxtJ/eA+t+6q2++vj5j7FYX3nRu311q1wfYH3xjlLOsczhlhxAg2FWNUXhpGvAw3BWo1xBcvOV6/YLc2r5FJw==", + "dev": true, + "license": "MIT", + "dependencies": { + "css-tree": "^3.0.0" + }, + "bin": { + "specificity": "bin/cli.js" + } + }, + "node_modules/@csstools/color-helpers": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-6.0.2.tgz", + "integrity": "sha512-LMGQLS9EuADloEFkcTBR3BwV/CGHV7zyDxVRtVDTwdI2Ca4it0CCVTT9wCkxSgokjE5Ho41hEPgb8OEUwoXr6Q==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "engines": { + "node": ">=20.19.0" + } + }, + "node_modules/@csstools/css-calc": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-3.2.0.tgz", + "integrity": "sha512-bR9e6o2BDB12jzN/gIbjHa5wLJ4UjD1CB9pM7ehlc0ddk6EBz+yYS1EV2MF55/HUxrHcB/hehAyt5vhsA3hx7w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-color-parser": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-4.1.0.tgz", + "integrity": "sha512-U0KhLYmy2GVj6q4T3WaAe6NPuFYCPQoE3b0dRGxejWDgcPp8TP7S5rVdM5ZrFaqu4N67X8YaPBw14dQSYx3IyQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "dependencies": { + "@csstools/color-helpers": "^6.0.2", + "@csstools/css-calc": "^3.2.0" + }, + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-parser-algorithms": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-4.0.0.tgz", + "integrity": "sha512-+B87qS7fIG3L5h3qwJ/IFbjoVoOe/bpOdh9hAjXbvx0o8ImEmUsGXN0inFOnk2ChCFgqkkGFQ+TpM5rbhkKe4w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-syntax-patches-for-csstree": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.1.3.tgz", + "integrity": "sha512-SH60bMfrRCJF3morcdk57WklujF4Jr/EsQUzqkarfHXEFcAR1gg7fS/chAE922Sehgzc1/+Tz5H3Ypa1HiEKrg==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "peerDependencies": { + "css-tree": "^3.2.1" + }, + "peerDependenciesMeta": { + "css-tree": { + "optional": true + } + } + }, + "node_modules/@csstools/css-tokenizer": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-4.0.0.tgz", + "integrity": "sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + } + }, "node_modules/@dotenvx/dotenvx": { "version": "1.64.0", "resolved": "https://registry.npmjs.org/@dotenvx/dotenvx/-/dotenvx-1.64.0.tgz", @@ -845,6 +1072,24 @@ "node": "^20.19.0 || ^22.13.0 || >=24" } }, + "node_modules/@exodus/bytes": { + "version": "1.15.0", + "resolved": "https://registry.npmjs.org/@exodus/bytes/-/bytes-1.15.0.tgz", + "integrity": "sha512-UY0nlA+feH81UGSHv92sLEPLCeZFjXOuHhrIo0HQydScuQc8s0A7kL/UdgwgDq8g8ilksmuoF35YVTNphV2aBQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + }, + "peerDependencies": { + "@noble/hashes": "^1.8.0 || ^2.0.0" + }, + "peerDependenciesMeta": { + "@noble/hashes": { + "optional": true + } + } + }, "node_modules/@floating-ui/core": { "version": "1.7.5", "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.7.5.tgz", @@ -1939,6 +2184,96 @@ "vite": "^5.2.0 || ^6 || ^7 || ^8" } }, + "node_modules/@testing-library/dom": { + "version": "10.4.1", + "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz", + "integrity": "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/code-frame": "^7.10.4", + "@babel/runtime": "^7.12.5", + "@types/aria-query": "^5.0.1", + "aria-query": "5.3.0", + "dom-accessibility-api": "^0.5.9", + "lz-string": "^1.5.0", + "picocolors": "1.1.1", + "pretty-format": "^27.0.2" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@testing-library/jest-dom": { + "version": "6.9.1", + "resolved": "https://registry.npmjs.org/@testing-library/jest-dom/-/jest-dom-6.9.1.tgz", + "integrity": "sha512-zIcONa+hVtVSSep9UT3jZ5rizo2BsxgyDYU7WFD5eICBE7no3881HGeb/QkGfsJs6JTkY1aQhT7rIPC7e+0nnA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@adobe/css-tools": "^4.4.0", + "aria-query": "^5.0.0", + "css.escape": "^1.5.1", + "dom-accessibility-api": "^0.6.3", + "picocolors": "^1.1.1", + "redent": "^3.0.0" + }, + "engines": { + "node": ">=14", + "npm": ">=6", + "yarn": ">=1" + } + }, + "node_modules/@testing-library/jest-dom/node_modules/dom-accessibility-api": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.6.3.tgz", + "integrity": "sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@testing-library/react": { + "version": "16.3.2", + "resolved": "https://registry.npmjs.org/@testing-library/react/-/react-16.3.2.tgz", + "integrity": "sha512-XU5/SytQM+ykqMnAnvB2umaJNIOsLF3PVv//1Ew4CTcpz0/BRyy/af40qqrt7SjKpDdT1saBMc42CUok5gaw+g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.12.5" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@testing-library/dom": "^10.0.0", + "@types/react": "^18.0.0 || ^19.0.0", + "@types/react-dom": "^18.0.0 || ^19.0.0", + "react": "^18.0.0 || ^19.0.0", + "react-dom": "^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@testing-library/user-event": { + "version": "14.6.1", + "resolved": "https://registry.npmjs.org/@testing-library/user-event/-/user-event-14.6.1.tgz", + "integrity": "sha512-vq7fv0rnt+QTXgPxr5Hjc210p6YKq2kmdziLgnsZGgLJ9e6VAShx1pACLuRjd/AS/sr7phAR58OIIpf0LlmQNw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12", + "npm": ">=6" + }, + "peerDependencies": { + "@testing-library/dom": ">=7.21.4" + } + }, "node_modules/@ts-morph/common": { "version": "0.27.0", "resolved": "https://registry.npmjs.org/@ts-morph/common/-/common-0.27.0.tgz", @@ -1961,6 +2296,25 @@ "tslib": "^2.4.0" } }, + "node_modules/@types/aria-query": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz", + "integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, "node_modules/@types/d3-array": { "version": "3.2.2", "resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.2.tgz", @@ -2024,6 +2378,13 @@ "integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==", "license": "MIT" }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/esrecurse": { "version": "4.3.1", "resolved": "https://registry.npmjs.org/@types/esrecurse/-/esrecurse-4.3.1.tgz", @@ -2370,48 +2731,192 @@ } } }, - "node_modules/accepts": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz", - "integrity": "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==", + "node_modules/@vitest/coverage-v8": { + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-4.1.5.tgz", + "integrity": "sha512-38C0/Ddb7HcRG0Z4/DUem8x57d2p9jYgp18mkaYswEOQBGsI1CG4f/hjm0ZCeaJfWhSZ4k7jgs29V1Zom7Ki9A==", + "dev": true, "license": "MIT", "dependencies": { - "mime-types": "^3.0.0", - "negotiator": "^1.0.0" + "@bcoe/v8-coverage": "^1.0.2", + "@vitest/utils": "4.1.5", + "ast-v8-to-istanbul": "^1.0.0", + "istanbul-lib-coverage": "^3.2.2", + "istanbul-lib-report": "^3.0.1", + "istanbul-reports": "^3.2.0", + "magicast": "^0.5.2", + "obug": "^2.1.1", + "std-env": "^4.0.0-rc.1", + "tinyrainbow": "^3.1.0" }, - "engines": { - "node": ">= 0.6" + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@vitest/browser": "4.1.5", + "vitest": "4.1.5" + }, + "peerDependenciesMeta": { + "@vitest/browser": { + "optional": true + } } }, - "node_modules/acorn": { - "version": "8.16.0", - "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", - "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", + "node_modules/@vitest/expect": { + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.5.tgz", + "integrity": "sha512-PWBaRY5JoKuRnHlUHfpV/KohFylaDZTupcXN1H9vYryNLOnitSw60Mw9IAE2r67NbwwzBw/Cc/8q9BK3kIX8Kw==", "dev": true, "license": "MIT", - "bin": { - "acorn": "bin/acorn" + "dependencies": { + "@standard-schema/spec": "^1.1.0", + "@types/chai": "^5.2.2", + "@vitest/spy": "4.1.5", + "@vitest/utils": "4.1.5", + "chai": "^6.2.2", + "tinyrainbow": "^3.1.0" }, - "engines": { - "node": ">=0.4.0" + "funding": { + "url": "https://opencollective.com/vitest" } }, - "node_modules/acorn-jsx": { - "version": "5.3.2", - "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", - "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", + "node_modules/@vitest/mocker": { + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.5.tgz", + "integrity": "sha512-/x2EmFC4mT4NNzqvC3fmesuV97w5FC903KPmey4gsnJiMQ3Be1IlDKVaDaG8iqaLFHqJ2FVEkxZk5VmeLjIItw==", "dev": true, "license": "MIT", + "dependencies": { + "@vitest/spy": "4.1.5", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.21" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, "peerDependencies": { - "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" + "msw": "^2.4.9", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } } }, - "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==", + "node_modules/@vitest/pretty-format": { + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.5.tgz", + "integrity": "sha512-7I3q6l5qr03dVfMX2wCo9FxwSJbPdwKjy2uu/YPpU3wfHvIL4QHwVRp57OfGrDFeUJ8/8QdfBKIV12FTtLn00g==", + "dev": true, "license": "MIT", - "engines": { + "dependencies": { + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.5.tgz", + "integrity": "sha512-2D+o7Pr82IEO46YPpoA/YU0neeyr6FTerQb5Ro7BUnBuv6NQtT/kmVnczngiMEBhzgqz2UZYl5gArejsyERDSQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "4.1.5", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.5.tgz", + "integrity": "sha512-zypXEt4KH/XgKGPUz4eC2AvErYx0My5hfL8oDb1HzGFpEk1P62bxSohdyOmvz+d9UJwanI68MKwr2EquOaOgMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.5", + "@vitest/utils": "4.1.5", + "magic-string": "^0.30.21", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.5.tgz", + "integrity": "sha512-2lNOsh6+R2Idnf1TCZqSwYlKN2E/iDlD8sgU59kYVl+OMDmvldO1VDk39smRfpUNwYpNRVn3w4YfuC7KfbBnkQ==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.5.tgz", + "integrity": "sha512-76wdkrmfXfqGjueGgnb45ITPyUi1ycZ4IHgC2bhPDUfWHklY/q3MdLOAB+TF1e6xfl8NxNY0ZYaPCFNWSsw3Ug==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.5", + "convert-source-map": "^2.0.0", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/accepts": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz", + "integrity": "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==", + "license": "MIT", + "dependencies": { + "mime-types": "^3.0.0", + "negotiator": "^1.0.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/acorn": { + "version": "8.16.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", + "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", + "dev": true, + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-jsx": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", + "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" + } + }, + "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==", + "license": "MIT", + "engines": { "node": ">= 14" } }, @@ -2501,6 +3006,26 @@ "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", "license": "Python-2.0" }, + "node_modules/aria-query": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.0.tgz", + "integrity": "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "dequal": "^2.0.3" + } + }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, "node_modules/ast-types": { "version": "0.16.1", "resolved": "https://registry.npmjs.org/ast-types/-/ast-types-0.16.1.tgz", @@ -2513,6 +3038,25 @@ "node": ">=4" } }, + "node_modules/ast-v8-to-istanbul": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/ast-v8-to-istanbul/-/ast-v8-to-istanbul-1.0.0.tgz", + "integrity": "sha512-1fSfIwuDICFA4LKkCzRPO7F0hzFf0B7+Xqrl27ynQaa+Rh0e1Es0v6kWHPott3lU10AyAr7oKHa65OppjLn3Rg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/trace-mapping": "^0.3.31", + "estree-walker": "^3.0.3", + "js-tokens": "^10.0.0" + } + }, + "node_modules/ast-v8-to-istanbul/node_modules/js-tokens": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-10.0.0.tgz", + "integrity": "sha512-lM/UBzQmfJRo9ABXbPWemivdCW8V2G8FHaHdypQaIy523snUjog0W71ayWXTjiR+ixeMyVHN2XcpnTd/liPg/Q==", + "dev": true, + "license": "MIT" + }, "node_modules/balanced-match": { "version": "4.0.4", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", @@ -2534,6 +3078,16 @@ "node": ">=6.0.0" } }, + "node_modules/bidi-js": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.0.3.tgz", + "integrity": "sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==", + "dev": true, + "license": "MIT", + "dependencies": { + "require-from-string": "^2.0.2" + } + }, "node_modules/body-parser": { "version": "2.2.2", "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.2.tgz", @@ -2697,6 +3251,16 @@ ], "license": "CC-BY-4.0" }, + "node_modules/chai": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", + "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/chalk": { "version": "5.6.2", "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.6.2.tgz", @@ -2920,6 +3484,27 @@ "node": ">= 8" } }, + "node_modules/css-tree": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.2.1.tgz", + "integrity": "sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==", + "dev": true, + "license": "MIT", + "dependencies": { + "mdn-data": "2.27.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0" + } + }, + "node_modules/css.escape": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/css.escape/-/css.escape-1.5.1.tgz", + "integrity": "sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg==", + "dev": true, + "license": "MIT" + }, "node_modules/cssesc": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz", @@ -3069,6 +3654,20 @@ "node": ">= 12" } }, + "node_modules/data-urls": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-7.0.0.tgz", + "integrity": "sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA==", + "dev": true, + "license": "MIT", + "dependencies": { + "whatwg-mimetype": "^5.0.0", + "whatwg-url": "^16.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, "node_modules/debug": { "version": "4.4.3", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", @@ -3086,6 +3685,13 @@ } } }, + "node_modules/decimal.js": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz", + "integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==", + "dev": true, + "license": "MIT" + }, "node_modules/decimal.js-light": { "version": "2.5.1", "resolved": "https://registry.npmjs.org/decimal.js-light/-/decimal.js-light-2.5.1.tgz", @@ -3171,6 +3777,16 @@ "node": ">= 0.8" } }, + "node_modules/dequal": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", + "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/detect-libc": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", @@ -3190,6 +3806,14 @@ "node": ">=0.3.1" } }, + "node_modules/dom-accessibility-api": { + "version": "0.5.16", + "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz", + "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==", + "dev": true, + "license": "MIT", + "peer": true + }, "node_modules/dotenv": { "version": "17.4.2", "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.4.2.tgz", @@ -3274,6 +3898,19 @@ "node": ">=10.13.0" } }, + "node_modules/entities": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-8.0.0.tgz", + "integrity": "sha512-zwfzJecQ/Uej6tusMqwAqU/6KL2XaB2VZ2Jg54Je6ahNBGNH6Ek6g3jjNCF0fG9EWQKGZNddNjU5F1ZQn/sBnA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=20.19.0" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, "node_modules/env-paths": { "version": "2.2.1", "resolved": "https://registry.npmjs.org/env-paths/-/env-paths-2.2.1.tgz", @@ -3310,6 +3947,13 @@ "node": ">= 0.4" } }, + "node_modules/es-module-lexer": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.1.0.tgz", + "integrity": "sha512-n27zTYMjYu1aj4MjCWzSP7G9r75utsaoc8m61weK+W8JMBGGQybd43GstCXZ3WNmSFtGT9wi59qQTW6mhTR5LQ==", + "dev": true, + "license": "MIT" + }, "node_modules/es-object-atoms": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", @@ -3545,6 +4189,16 @@ "node": ">=4.0" } }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, "node_modules/esutils": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", @@ -3617,6 +4271,16 @@ "url": "https://github.com/sindresorhus/execa?sponsor=1" } }, + "node_modules/expect-type": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz", + "integrity": "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/express": { "version": "5.2.1", "resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz", @@ -4172,6 +4836,16 @@ "node": "^12.22.0 || ^14.16.0 || ^16.0.0 || >=17.0.0" } }, + "node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/has-symbols": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", @@ -4232,6 +4906,26 @@ "node": ">=16.9.0" } }, + "node_modules/html-encoding-sniffer": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-6.0.0.tgz", + "integrity": "sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@exodus/bytes": "^1.6.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/html-escaper": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz", + "integrity": "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==", + "dev": true, + "license": "MIT" + }, "node_modules/http-errors": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", @@ -4335,6 +5029,16 @@ "node": ">=0.8.19" } }, + "node_modules/indent-string": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/indent-string/-/indent-string-4.0.0.tgz", + "integrity": "sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/inherits": { "version": "2.0.4", "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", @@ -4500,6 +5204,13 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/is-potential-custom-element-name": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", + "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==", + "dev": true, + "license": "MIT" + }, "node_modules/is-promise": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz", @@ -4563,6 +5274,45 @@ "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", "license": "ISC" }, + "node_modules/istanbul-lib-coverage": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.2.tgz", + "integrity": "sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=8" + } + }, + "node_modules/istanbul-lib-report": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/istanbul-lib-report/-/istanbul-lib-report-3.0.1.tgz", + "integrity": "sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "istanbul-lib-coverage": "^3.0.0", + "make-dir": "^4.0.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/istanbul-reports": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/istanbul-reports/-/istanbul-reports-3.2.0.tgz", + "integrity": "sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "html-escaper": "^2.0.0", + "istanbul-lib-report": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/jiti": { "version": "2.6.1", "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.6.1.tgz", @@ -4600,6 +5350,57 @@ "js-yaml": "bin/js-yaml.js" } }, + "node_modules/jsdom": { + "version": "29.1.1", + "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-29.1.1.tgz", + "integrity": "sha512-ECi4Fi2f7BdJtUKTflYRTiaMxIB0O6zfR1fX0GXpUrf6flp8QIYn1UT20YQqdSOfk2dfkCwS8LAFoJDEppNK5Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/css-color": "^5.1.11", + "@asamuzakjp/dom-selector": "^7.1.1", + "@bramus/specificity": "^2.4.2", + "@csstools/css-syntax-patches-for-csstree": "^1.1.3", + "@exodus/bytes": "^1.15.0", + "css-tree": "^3.2.1", + "data-urls": "^7.0.0", + "decimal.js": "^10.6.0", + "html-encoding-sniffer": "^6.0.0", + "is-potential-custom-element-name": "^1.0.1", + "lru-cache": "^11.3.5", + "parse5": "^8.0.1", + "saxes": "^6.0.0", + "symbol-tree": "^3.2.4", + "tough-cookie": "^6.0.1", + "undici": "^7.25.0", + "w3c-xmlserializer": "^5.0.0", + "webidl-conversions": "^8.0.1", + "whatwg-mimetype": "^5.0.0", + "whatwg-url": "^16.0.1", + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24.0.0" + }, + "peerDependencies": { + "canvas": "^3.0.0" + }, + "peerDependenciesMeta": { + "canvas": { + "optional": true + } + } + }, + "node_modules/jsdom/node_modules/lru-cache": { + "version": "11.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/jsesc": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", @@ -5043,6 +5844,17 @@ "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, + "node_modules/lz-string": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz", + "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==", + "dev": true, + "license": "MIT", + "peer": true, + "bin": { + "lz-string": "bin/bin.js" + } + }, "node_modules/magic-string": { "version": "0.30.21", "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", @@ -5053,6 +5865,47 @@ "@jridgewell/sourcemap-codec": "^1.5.5" } }, + "node_modules/magicast": { + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/magicast/-/magicast-0.5.2.tgz", + "integrity": "sha512-E3ZJh4J3S9KfwdjZhe2afj6R9lGIN5Pher1pF39UGrXRqq/VDaGVIGN13BjHd2u8B61hArAGOnso7nBOouW3TQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.0", + "@babel/types": "^7.29.0", + "source-map-js": "^1.2.1" + } + }, + "node_modules/make-dir": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-4.0.0.tgz", + "integrity": "sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==", + "dev": true, + "license": "MIT", + "dependencies": { + "semver": "^7.5.3" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/make-dir/node_modules/semver": { + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/math-intrinsics": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", @@ -5062,6 +5915,13 @@ "node": ">= 0.4" } }, + "node_modules/mdn-data": { + "version": "2.27.1", + "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.27.1.tgz", + "integrity": "sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==", + "dev": true, + "license": "CC0-1.0" + }, "node_modules/media-typer": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.0.tgz", @@ -5169,6 +6029,16 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/min-indent": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/min-indent/-/min-indent-1.0.1.tgz", + "integrity": "sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, "node_modules/minimatch": { "version": "10.2.5", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", @@ -5429,6 +6299,17 @@ "node": ">= 10" } }, + "node_modules/obug": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.1.tgz", + "integrity": "sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==", + "dev": true, + "funding": [ + "https://github.com/sponsors/sxzz", + "https://opencollective.com/debug" + ], + "license": "MIT" + }, "node_modules/on-finished": { "version": "2.4.1", "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", @@ -5656,6 +6537,19 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/parse5": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-8.0.1.tgz", + "integrity": "sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw==", + "dev": true, + "license": "MIT", + "dependencies": { + "entities": "^8.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, "node_modules/parseurl": { "version": "1.3.3", "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", @@ -5696,6 +6590,13 @@ "integrity": "sha512-Yhpw4T9C6hPpgPeA28us07OJeqZ5EzQTkbfwuhsUg0c237RomFoETJgmp2sa3F/41gfLE6G5cqcYwznmeEeOlQ==", "license": "MIT" }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", @@ -5786,6 +6687,44 @@ "node": ">= 0.8.0" } }, + "node_modules/pretty-format": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.5.1.tgz", + "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "ansi-regex": "^5.0.1", + "ansi-styles": "^5.0.0", + "react-is": "^17.0.1" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + } + }, + "node_modules/pretty-format/node_modules/ansi-styles": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", + "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/pretty-format/node_modules/react-is": { + "version": "17.0.2", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", + "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==", + "dev": true, + "license": "MIT", + "peer": true + }, "node_modules/pretty-ms": { "version": "9.3.0", "resolved": "https://registry.npmjs.org/pretty-ms/-/pretty-ms-9.3.0.tgz", @@ -6046,6 +6985,20 @@ "react-is": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, + "node_modules/redent": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/redent/-/redent-3.0.0.tgz", + "integrity": "sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==", + "dev": true, + "license": "MIT", + "dependencies": { + "indent-string": "^4.0.0", + "strip-indent": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/redux": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz", @@ -6234,6 +7187,19 @@ "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", "license": "MIT" }, + "node_modules/saxes": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz", + "integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==", + "dev": true, + "license": "ISC", + "dependencies": { + "xmlchars": "^2.2.0" + }, + "engines": { + "node": ">=v12.22.7" + } + }, "node_modules/scheduler": { "version": "0.27.0", "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", @@ -6453,6 +7419,13 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, "node_modules/signal-exit": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", @@ -6489,6 +7462,13 @@ "node": ">=0.10.0" } }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, "node_modules/statuses": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", @@ -6498,6 +7478,13 @@ "node": ">= 0.8" } }, + "node_modules/std-env": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.1.0.tgz", + "integrity": "sha512-Rq7ybcX2RuC55r9oaPVEW7/xu3tj8u4GeBYHBWCychFtzMIr86A7e3PPEBPT37sHStKX3+TiX/Fr/ACmJLVlLQ==", + "dev": true, + "license": "MIT" + }, "node_modules/stdin-discarder": { "version": "0.2.2", "resolved": "https://registry.npmjs.org/stdin-discarder/-/stdin-discarder-0.2.2.tgz", @@ -6580,6 +7567,39 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/strip-indent": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/strip-indent/-/strip-indent-3.0.0.tgz", + "integrity": "sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "min-indent": "^1.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/symbol-tree": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz", + "integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==", + "dev": true, + "license": "MIT" + }, "node_modules/tagged-tag": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/tagged-tag/-/tagged-tag-1.0.0.tgz", @@ -6629,6 +7649,23 @@ "integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==", "license": "MIT" }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "1.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": { + "node": ">=18" + } + }, "node_modules/tinyglobby": { "version": "0.2.16", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.16.tgz", @@ -6646,6 +7683,16 @@ "url": "https://github.com/sponsors/SuperchupuDev" } }, + "node_modules/tinyrainbow": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.0.tgz", + "integrity": "sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, "node_modules/tldts": { "version": "7.0.30", "resolved": "https://registry.npmjs.org/tldts/-/tldts-7.0.30.tgz", @@ -6697,6 +7744,19 @@ "node": ">=16" } }, + "node_modules/tr46": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-6.0.0.tgz", + "integrity": "sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==", + "dev": true, + "license": "MIT", + "dependencies": { + "punycode": "^2.3.1" + }, + "engines": { + "node": ">=20" + } + }, "node_modules/ts-api-utils": { "version": "2.5.0", "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.5.0.tgz", @@ -6829,6 +7889,16 @@ "typescript": ">=4.8.4 <6.1.0" } }, + "node_modules/undici": { + "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": { + "node": ">=20.18.1" + } + }, "node_modules/undici-types": { "version": "7.16.0", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz", @@ -7047,6 +8117,109 @@ } } }, + "node_modules/vitest": { + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.5.tgz", + "integrity": "sha512-9Xx1v3/ih3m9hN+SbfkUyy0JAs72ap3r7joc87XL6jwF0jGg6mFBvQ1SrwaX+h8BlkX6Hz9shdd1uo6AF+ZGpg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/expect": "4.1.5", + "@vitest/mocker": "4.1.5", + "@vitest/pretty-format": "4.1.5", + "@vitest/runner": "4.1.5", + "@vitest/snapshot": "4.1.5", + "@vitest/spy": "4.1.5", + "@vitest/utils": "4.1.5", + "es-module-lexer": "^2.0.0", + "expect-type": "^1.3.0", + "magic-string": "^0.30.21", + "obug": "^2.1.1", + "pathe": "^2.0.3", + "picomatch": "^4.0.3", + "std-env": "^4.0.0-rc.1", + "tinybench": "^2.9.0", + "tinyexec": "^1.0.2", + "tinyglobby": "^0.2.15", + "tinyrainbow": "^3.1.0", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@opentelemetry/api": "^1.9.0", + "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", + "@vitest/browser-playwright": "4.1.5", + "@vitest/browser-preview": "4.1.5", + "@vitest/browser-webdriverio": "4.1.5", + "@vitest/coverage-istanbul": "4.1.5", + "@vitest/coverage-v8": "4.1.5", + "@vitest/ui": "4.1.5", + "happy-dom": "*", + "jsdom": "*", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@opentelemetry/api": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser-playwright": { + "optional": true + }, + "@vitest/browser-preview": { + "optional": true + }, + "@vitest/browser-webdriverio": { + "optional": true + }, + "@vitest/coverage-istanbul": { + "optional": true + }, + "@vitest/coverage-v8": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + }, + "vite": { + "optional": false + } + } + }, + "node_modules/w3c-xmlserializer": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz", + "integrity": "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==", + "dev": true, + "license": "MIT", + "dependencies": { + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/web-streams-polyfill": { "version": "3.3.3", "resolved": "https://registry.npmjs.org/web-streams-polyfill/-/web-streams-polyfill-3.3.3.tgz", @@ -7056,6 +8229,41 @@ "node": ">= 8" } }, + "node_modules/webidl-conversions": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-8.0.1.tgz", + "integrity": "sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=20" + } + }, + "node_modules/whatwg-mimetype": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-5.0.0.tgz", + "integrity": "sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20" + } + }, + "node_modules/whatwg-url": { + "version": "16.0.1", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-16.0.1.tgz", + "integrity": "sha512-1to4zXBxmXHV3IiSSEInrreIlu02vUOvrhxJJH5vcxYTBDAx51cqZiKdyTxlecdKNSjj8EcxGBxNf6Vg+945gw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@exodus/bytes": "^1.11.0", + "tr46": "^6.0.0", + "webidl-conversions": "^8.0.1" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, "node_modules/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", @@ -7071,6 +8279,23 @@ "node": ">= 8" } }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/word-wrap": { "version": "1.2.5", "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", @@ -7120,6 +8345,23 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/xml-name-validator": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz", + "integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/xmlchars": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz", + "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==", + "dev": true, + "license": "MIT" + }, "node_modules/y18n": { "version": "5.0.8", "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", diff --git a/vehicle-management/package.json b/vehicle-management/package.json index 199d807..ce0ce98 100644 --- a/vehicle-management/package.json +++ b/vehicle-management/package.json @@ -7,7 +7,10 @@ "dev": "vite", "build": "tsc -b && vite build", "lint": "eslint .", - "preview": "vite preview" + "preview": "vite preview", + "test": "vitest run", + "test:coverage": "vitest run --coverage", + "test:watch": "vitest" }, "dependencies": { "@base-ui/react": "^1.4.1", @@ -28,18 +31,24 @@ "devDependencies": { "@eslint/js": "^10.0.1", "@tailwindcss/vite": "^4.2.4", + "@testing-library/jest-dom": "^6.9.1", + "@testing-library/react": "^16.3.2", + "@testing-library/user-event": "^14.6.1", "@types/node": "^24.12.2", "@types/react": "^19.2.14", "@types/react-dom": "^19.2.3", "@vitejs/plugin-react": "^6.0.1", + "@vitest/coverage-v8": "^4.1.5", "eslint": "^10.2.1", "eslint-plugin-react-hooks": "^7.1.1", "eslint-plugin-react-refresh": "^0.5.2", "globals": "^17.5.0", + "jsdom": "^29.1.1", "tailwindcss": "^4.2.4", "typescript": "~6.0.2", "typescript-eslint": "^8.58.2", - "vite": "^8.0.10" + "vite": "^8.0.10", + "vitest": "^4.1.5" }, "msw": { "workerDirectory": [ diff --git a/vehicle-management/src/App.tsx b/vehicle-management/src/App.tsx index 9873cc1..58470b9 100644 --- a/vehicle-management/src/App.tsx +++ b/vehicle-management/src/App.tsx @@ -6,6 +6,7 @@ import { LoginPage } from '@/pages/LoginPage' import { DashboardPage } from '@/pages/DashboardPage' import { VehiclesPage } from '@/pages/VehiclesPage' import { EmployeesPage } from '@/pages/EmployeesPage' +import { ActivityLogsPage } from '@/pages/ActivityLogsPage' export default function App() { return ( @@ -30,6 +31,14 @@ export default function App() { } /> + + + + } + /> } /> diff --git a/vehicle-management/src/components/EmployeeDialog.tsx b/vehicle-management/src/components/EmployeeDialog.tsx index f637483..7f5f7e8 100644 --- a/vehicle-management/src/components/EmployeeDialog.tsx +++ b/vehicle-management/src/components/EmployeeDialog.tsx @@ -21,6 +21,7 @@ export function EmployeeDialog({ open, onOpenChange, employee, onSave }: Props) useEffect(() => { if (open) { + // eslint-disable-next-line react-hooks/set-state-in-effect setForm( employee ? { name: employee.name, department: employee.department, title: employee.title, email: employee.email, phone: employee.phone } diff --git a/vehicle-management/src/components/Layout.tsx b/vehicle-management/src/components/Layout.tsx index e64a72a..a249d79 100644 --- a/vehicle-management/src/components/Layout.tsx +++ b/vehicle-management/src/components/Layout.tsx @@ -11,6 +11,7 @@ const navItems = [ { to: '/', label: '儀表板', icon: '📊', exact: true }, { to: '/vehicles', label: '車輛管理', icon: '🚗' }, { to: '/employees', label: '員工管理', icon: '👥', adminOnly: true }, + { to: '/activity-logs', label: '使用者紀錄', icon: '📋', adminOnly: true }, ] export function Layout() { diff --git a/vehicle-management/src/components/VehicleDialog.tsx b/vehicle-management/src/components/VehicleDialog.tsx index 72edf92..b1435b0 100644 --- a/vehicle-management/src/components/VehicleDialog.tsx +++ b/vehicle-management/src/components/VehicleDialog.tsx @@ -29,6 +29,7 @@ export function VehicleDialog({ open, onOpenChange, vehicle, employees, onSave } useEffect(() => { if (open) { + // eslint-disable-next-line react-hooks/set-state-in-effect setForm(vehicle ? { plate: vehicle.plate, model: vehicle.model, status: vehicle.status, assignedEmployeeId: vehicle.assignedEmployeeId } : EMPTY) setErrors({}) } diff --git a/vehicle-management/src/components/ui/badge.tsx b/vehicle-management/src/components/ui/badge.tsx index b20959d..d517d65 100644 --- a/vehicle-management/src/components/ui/badge.tsx +++ b/vehicle-management/src/components/ui/badge.tsx @@ -49,4 +49,5 @@ function Badge({ }) } +// eslint-disable-next-line react-refresh/only-export-components export { Badge, badgeVariants } diff --git a/vehicle-management/src/components/ui/button.tsx b/vehicle-management/src/components/ui/button.tsx index 09df753..f670740 100644 --- a/vehicle-management/src/components/ui/button.tsx +++ b/vehicle-management/src/components/ui/button.tsx @@ -55,4 +55,5 @@ function Button({ ) } +// eslint-disable-next-line react-refresh/only-export-components export { Button, buttonVariants } diff --git a/vehicle-management/src/contexts/AuthContext.tsx b/vehicle-management/src/contexts/AuthContext.tsx index a5a7d34..ebed3e6 100644 --- a/vehicle-management/src/contexts/AuthContext.tsx +++ b/vehicle-management/src/contexts/AuthContext.tsx @@ -55,6 +55,7 @@ export function AuthProvider({ children }: { children: ReactNode }) { return {children} } +// eslint-disable-next-line react-refresh/only-export-components export function useAuth() { const ctx = useContext(AuthContext) if (!ctx) throw new Error('useAuth must be used within AuthProvider') diff --git a/vehicle-management/src/mocks/activityLogs.ts b/vehicle-management/src/mocks/activityLogs.ts new file mode 100644 index 0000000..3342246 --- /dev/null +++ b/vehicle-management/src/mocks/activityLogs.ts @@ -0,0 +1,63 @@ +export type ActivityAction = + | 'login' + | 'logout' + | 'vehicle_create' + | 'vehicle_update' + | 'vehicle_delete' + | 'employee_create' + | 'employee_update' + | 'employee_delete' + +export interface ActivityLog { + id: string + timestamp: string + username: string + role: 'admin' | 'user' + action: ActivityAction + target?: string + description: string +} + +export const ACTION_LABELS: Record = { + login: '登入', + logout: '登出', + vehicle_create: '新增車輛', + vehicle_update: '修改車輛', + vehicle_delete: '刪除車輛', + employee_create: '新增員工', + employee_update: '修改員工', + employee_delete: '刪除員工', +} + +export const activityLogs: ActivityLog[] = [ + { id: '1', timestamp: '2026-05-04T09:00:00.000Z', username: 'admin', role: 'admin', action: 'login', description: '管理者登入系統' }, + { id: '2', timestamp: '2026-05-04T09:02:15.000Z', username: 'admin', role: 'admin', action: 'vehicle_create', target: 'ABC-1234', description: '新增車輛 ABC-1234(Toyota Camry)' }, + { id: '3', timestamp: '2026-05-04T09:05:40.000Z', username: 'user', role: 'user', action: 'login', description: '一般使用者登入系統' }, + { id: '4', timestamp: '2026-05-04T09:10:22.000Z', username: 'admin', role: 'admin', action: 'employee_create', target: '王小明', description: '新增員工 王小明(業務部)' }, + { id: '5', timestamp: '2026-05-04T09:15:05.000Z', username: 'admin', role: 'admin', action: 'vehicle_update', target: 'XYZ-5678', description: '修改車輛 XYZ-5678 狀態為使用中' }, + { id: '6', timestamp: '2026-05-04T09:20:30.000Z', username: 'user', role: 'user', action: 'logout', description: '一般使用者登出系統' }, + { id: '7', timestamp: '2026-05-04T09:25:11.000Z', username: 'admin', role: 'admin', action: 'employee_update', target: '陳美玲', description: '修改員工 陳美玲 職稱為資深業務' }, + { id: '8', timestamp: '2026-05-04T09:30:44.000Z', username: 'admin', role: 'admin', action: 'vehicle_delete', target: 'DEF-9012', description: '刪除車輛 DEF-9012(Ford Ranger)' }, + { id: '9', timestamp: '2026-05-04T09:35:00.000Z', username: 'user', role: 'user', action: 'login', description: '一般使用者登入系統' }, + { id: '10', timestamp: '2026-05-04T09:40:18.000Z', username: 'admin', role: 'admin', action: 'vehicle_create', target: 'GHI-3456', description: '新增車輛 GHI-3456(Honda CR-V)' }, + { id: '11', timestamp: '2026-05-04T09:45:55.000Z', username: 'admin', role: 'admin', action: 'employee_delete', target: '李大華', description: '刪除員工 李大華(財務部)' }, + { id: '12', timestamp: '2026-05-04T10:00:03.000Z', username: 'user', role: 'user', action: 'logout', description: '一般使用者登出系統' }, + { id: '13', timestamp: '2026-05-04T10:05:27.000Z', username: 'admin', role: 'admin', action: 'vehicle_update', target: 'JKL-7890', description: '修改車輛 JKL-7890 狀態為維修中' }, + { id: '14', timestamp: '2026-05-04T10:10:42.000Z', username: 'admin', role: 'admin', action: 'employee_create', target: '張志偉', description: '新增員工 張志偉(工程部)' }, + { id: '15', timestamp: '2026-05-04T10:15:09.000Z', username: 'user', role: 'user', action: 'login', description: '一般使用者登入系統' }, + { id: '16', timestamp: '2026-05-04T10:20:33.000Z', username: 'admin', role: 'admin', action: 'logout', description: '管理者登出系統' }, + { id: '17', timestamp: '2026-05-04T10:25:50.000Z', username: 'admin', role: 'admin', action: 'login', description: '管理者登入系統' }, + { id: '18', timestamp: '2026-05-04T10:30:16.000Z', username: 'admin', role: 'admin', action: 'vehicle_create', target: 'MNO-1122', description: '新增車輛 MNO-1122(Mazda CX-5)' }, + { id: '19', timestamp: '2026-05-04T10:35:48.000Z', username: 'user', role: 'user', action: 'logout', description: '一般使用者登出系統' }, + { id: '20', timestamp: '2026-05-04T10:40:05.000Z', username: 'admin', role: 'admin', action: 'employee_update', target: '林佳穎', description: '修改員工 林佳穎 部門為人資部' }, + { id: '21', timestamp: '2026-05-04T10:45:22.000Z', username: 'admin', role: 'admin', action: 'vehicle_delete', target: 'PQR-3344', description: '刪除車輛 PQR-3344(Nissan Tiida)' }, + { id: '22', timestamp: '2026-05-04T10:50:37.000Z', username: 'user', role: 'user', action: 'login', description: '一般使用者登入系統' }, + { id: '23', timestamp: '2026-05-04T10:55:14.000Z', username: 'admin', role: 'admin', action: 'vehicle_update', target: 'STU-5566', description: '修改車輛 STU-5566 狀態為可用' }, + { id: '24', timestamp: '2026-05-04T11:00:01.000Z', username: 'admin', role: 'admin', action: 'employee_create', target: '黃雅婷', description: '新增員工 黃雅婷(行政部)' }, + { id: '25', timestamp: '2026-05-04T11:05:44.000Z', username: 'user', role: 'user', action: 'logout', description: '一般使用者登出系統' }, + { id: '26', timestamp: '2026-05-04T11:10:29.000Z', username: 'admin', role: 'admin', action: 'vehicle_create', target: 'VWX-7788', description: '新增車輛 VWX-7788(Toyota Corolla)' }, + { id: '27', timestamp: '2026-05-04T11:15:58.000Z', username: 'admin', role: 'admin', action: 'employee_delete', target: '蔡明哲', description: '刪除員工 蔡明哲(工程部)' }, + { id: '28', timestamp: '2026-05-04T11:20:13.000Z', username: 'user', role: 'user', action: 'login', description: '一般使用者登入系統' }, + { id: '29', timestamp: '2026-05-04T11:25:36.000Z', username: 'admin', role: 'admin', action: 'logout', description: '管理者登出系統' }, + { id: '30', timestamp: '2026-05-04T11:30:00.000Z', username: 'admin', role: 'admin', action: 'login', description: '管理者登入系統' }, +] diff --git a/vehicle-management/src/mocks/data.ts b/vehicle-management/src/mocks/data.ts index 31e4d60..3d2a1d2 100644 --- a/vehicle-management/src/mocks/data.ts +++ b/vehicle-management/src/mocks/data.ts @@ -42,7 +42,7 @@ export const USERS: User[] = [ { id: 'u2', username: 'user', password: 'user123', name: '一般使用者', role: 'user' }, ] -export let employees: Employee[] = [ +export const employees: Employee[] = [ { id: 'e1', name: '王小明', department: '業務部', title: '業務專員', email: 'wang@example.com', phone: '0912-345-678' }, { id: 'e2', name: '李美華', department: '工程部', title: '資深工程師', email: 'lee@example.com', phone: '0923-456-789' }, { id: 'e3', name: '張志偉', department: '業務部', title: '業務經理', email: 'chang@example.com', phone: '0934-567-890' }, @@ -53,7 +53,7 @@ export let employees: Employee[] = [ { id: 'e8', name: '劉婉如', department: '行政部', title: '行政助理', email: 'liu@example.com', phone: '0989-012-345' }, ] -export let vehicles: Vehicle[] = [ +export const vehicles: Vehicle[] = [ { id: 'v1', plate: 'ABC-1234', model: 'Toyota Corolla', status: 'available', assignedEmployeeId: null }, { id: 'v2', plate: 'DEF-5678', model: 'Honda Civic', status: 'in-use', assignedEmployeeId: 'e1' }, { id: 'v3', plate: 'GHI-9012', model: 'Mazda CX-5', status: 'maintenance', assignedEmployeeId: null }, diff --git a/vehicle-management/src/mocks/handlers.ts b/vehicle-management/src/mocks/handlers.ts index 961b3c4..7f4f85c 100644 --- a/vehicle-management/src/mocks/handlers.ts +++ b/vehicle-management/src/mocks/handlers.ts @@ -1,5 +1,6 @@ import { http, HttpResponse, passthrough } from 'msw' import { USERS, employees, vehicles, getDashboardStats, getTrend } from './data' +import { activityLogs } from './activityLogs' let nextId = 100 @@ -72,6 +73,25 @@ export const handlers = [ return HttpResponse.json({ success: true }) }), + // Activity Logs + http.get('/api/activity-logs', ({ request }) => { + const url = new URL(request.url) + const username = url.searchParams.get('username') ?? '' + const action = url.searchParams.get('action') ?? '' + const page = parseInt(url.searchParams.get('page') ?? '1', 10) + const pageSize = parseInt(url.searchParams.get('pageSize') ?? '20', 10) + + let filtered = [...activityLogs].sort( + (a, b) => new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime(), + ) + if (username) filtered = filtered.filter((l) => l.username === username) + if (action) filtered = filtered.filter((l) => l.action === action) + + const total = filtered.length + const items = filtered.slice((page - 1) * pageSize, page * pageSize) + return HttpResponse.json({ items, total, page, pageSize }) + }), + // 明確 passthrough 所有非 API 請求(Vite HMR、靜態資源、字型等) http.all('*', ({ request }) => { if (!new URL(request.url).pathname.startsWith('/api/')) { diff --git a/vehicle-management/src/pages/ActivityLogsPage.tsx b/vehicle-management/src/pages/ActivityLogsPage.tsx new file mode 100644 index 0000000..a1714a1 --- /dev/null +++ b/vehicle-management/src/pages/ActivityLogsPage.tsx @@ -0,0 +1,203 @@ +import { useEffect, useState } from 'react' +import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table' +import { Button } from '@/components/ui/button' +import { BlurFade } from '@/components/ui/blur-fade' +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select' +import { cn } from '@/lib/utils' +import type { ActivityLog, ActivityAction } from '@/mocks/activityLogs' +import { ACTION_LABELS, activityLogs as allLogs } from '@/mocks/activityLogs' + +const PAGE_SIZE = 10 + +const ACTION_COLORS: Record = { + login: 'bg-emerald-100 text-emerald-700', + logout: 'bg-slate-100 text-slate-600', + vehicle_create: 'bg-blue-100 text-blue-700', + vehicle_update: 'bg-indigo-100 text-indigo-700', + vehicle_delete: 'bg-red-100 text-red-700', + employee_create: 'bg-purple-100 text-purple-700', + employee_update: 'bg-violet-100 text-violet-700', + employee_delete: 'bg-pink-100 text-pink-700', +} + +interface LogsResponse { + items: ActivityLog[] + total: number + page: number + pageSize: number +} + +const ALL_USERNAMES = [...new Set(allLogs.map((l) => l.username))] +const ALL_ACTIONS = Object.keys(ACTION_LABELS) as ActivityAction[] + +export function ActivityLogsPage() { + const [logs, setLogs] = useState([]) + const [total, setTotal] = useState(0) + const [page, setPage] = useState(1) + const [filterUsername, setFilterUsername] = useState('') + const [filterAction, setFilterAction] = useState('') + + const totalPages = Math.max(1, Math.ceil(total / PAGE_SIZE)) + + useEffect(() => { + const params = new URLSearchParams({ page: String(page), pageSize: String(PAGE_SIZE) }) + if (filterUsername) params.set('username', filterUsername) + if (filterAction) params.set('action', filterAction) + let cancelled = false + fetch(`/api/activity-logs?${params}`) + .then((r) => r.json()) + .then((data: LogsResponse) => { + if (cancelled) return + setLogs(data.items) + setTotal(data.total) + }) + return () => { cancelled = true } + }, [page, filterUsername, filterAction]) + + const handleReset = () => { + setFilterUsername('') + setFilterAction('') + setPage(1) + } + + const handleUsernameChange = (val: string | null) => { + setFilterUsername(val ?? '') + setPage(1) + } + + const handleActionChange = (val: string | null) => { + setFilterAction(val ?? '') + setPage(1) + } + + const formatTime = (iso: string) => + new Date(iso).toLocaleString('zh-TW', { timeZone: 'Asia/Taipei', hour12: false }) + + return ( +
+ +
+

使用者紀錄

+

查看所有使用者的操作活動日誌(僅限管理者)

+
+
+ + {/* 篩選列 */} + +
+ + + + + {(filterUsername || filterAction) && ( + + )} +
+
+ + {/* 表格 */} + +
+ + + + 時間 + 使用者 + 角色 + 操作類型 + 操作對象 + 描述 + + + + {logs.length === 0 ? ( + + +
+ 📋 + 目前沒有符合的紀錄 +
+
+
+ ) : ( + logs.map((log) => ( + + + {formatTime(log.timestamp)} + + {log.username} + + + {log.role === 'admin' ? '管理者' : '一般使用者'} + + + + + {ACTION_LABELS[log.action]} + + + {log.target ?? '—'} + {log.description} + + )) + )} +
+
+
+
+ + {/* 分頁 */} + +
+

共 {total} 筆紀錄

+
+ + + 第 {page} / {totalPages} 頁 + + +
+
+
+
+ ) +} diff --git a/vehicle-management/src/pages/DashboardPage.tsx b/vehicle-management/src/pages/DashboardPage.tsx index 3eeba0d..fc67e29 100644 --- a/vehicle-management/src/pages/DashboardPage.tsx +++ b/vehicle-management/src/pages/DashboardPage.tsx @@ -16,7 +16,6 @@ import { StatsCard } from '@/components/StatsCard' import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' import { Skeleton } from '@/components/ui/skeleton' import { BlurFade } from '@/components/ui/blur-fade' -import { AnimatedGradientText } from '@/components/ui/animated-gradient-text' import type { DashboardStats, TrendPoint } from '@/mocks/data' const PIE_COLORS = ['#22c55e', '#6366f1', '#f59e0b'] diff --git a/vehicle-management/src/pages/EmployeesPage.tsx b/vehicle-management/src/pages/EmployeesPage.tsx index 45e5c7f..1461e4f 100644 --- a/vehicle-management/src/pages/EmployeesPage.tsx +++ b/vehicle-management/src/pages/EmployeesPage.tsx @@ -15,7 +15,6 @@ import { } from '@/components/ui/alert-dialog' import { EmployeeDialog } from '@/components/EmployeeDialog' import { BlurFade } from '@/components/ui/blur-fade' -import { AnimatedGradientText } from '@/components/ui/animated-gradient-text' import type { Employee } from '@/mocks/data' const DEPT_COLORS: Record = { @@ -38,6 +37,7 @@ export function EmployeesPage() { setEmployees((await res.json()) as Employee[]) } + // eslint-disable-next-line react-hooks/set-state-in-effect useEffect(() => { fetchAll() }, []) const filtered = employees.filter( diff --git a/vehicle-management/src/pages/VehiclesPage.tsx b/vehicle-management/src/pages/VehiclesPage.tsx index fece00a..c5753ec 100644 --- a/vehicle-management/src/pages/VehiclesPage.tsx +++ b/vehicle-management/src/pages/VehiclesPage.tsx @@ -15,7 +15,6 @@ import { } from '@/components/ui/alert-dialog' import { VehicleDialog } from '@/components/VehicleDialog' import { BlurFade } from '@/components/ui/blur-fade' -import { AnimatedGradientText } from '@/components/ui/animated-gradient-text' import type { Vehicle, Employee } from '@/mocks/data' const STATUS_MAP: Record = { @@ -38,6 +37,7 @@ export function VehiclesPage() { setEmployees((await eRes.json()) as Employee[]) } + // eslint-disable-next-line react-hooks/set-state-in-effect useEffect(() => { fetchAll() }, []) const filtered = vehicles.filter( diff --git a/vehicle-management/src/pages/__tests__/LoginPage.test.tsx b/vehicle-management/src/pages/__tests__/LoginPage.test.tsx new file mode 100644 index 0000000..24a497e --- /dev/null +++ b/vehicle-management/src/pages/__tests__/LoginPage.test.tsx @@ -0,0 +1,163 @@ +import { render, screen, waitFor } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { MemoryRouter } from 'react-router-dom' +import { vi, describe, it, expect, beforeEach } from 'vitest' +import { LoginPage } from '../LoginPage' + +const mockLogin = vi.fn() +const mockNavigate = vi.fn() + +vi.mock('@/contexts/AuthContext', () => ({ + useAuth: () => ({ login: mockLogin }), +})) + +vi.mock('react-router-dom', async (importOriginal) => { + const actual = await importOriginal() + return { ...actual, useNavigate: () => mockNavigate } +}) + +vi.mock('@/components/ui/dot-pattern', () => ({ + DotPattern: () => null, +})) + +function renderLoginPage() { + return render( + + + , + ) +} + +beforeEach(() => { + vi.clearAllMocks() +}) + +describe('LoginPage', () => { + describe('渲染', () => { + it('顯示帳號輸入欄位', () => { + renderLoginPage() + expect(screen.getByLabelText('帳號')).toBeInTheDocument() + expect(screen.getByLabelText('帳號')).toHaveAttribute('id', 'usernaaaame') + }) + + it('顯示密碼輸入欄位', () => { + renderLoginPage() + const passwordInput = screen.getByLabelText('密碼') + expect(passwordInput).toBeInTheDocument() + expect(passwordInput).toHaveAttribute('id', 'password') + expect(passwordInput).toHaveAttribute('type', 'password') + }) + + it('顯示登入按鈕', () => { + renderLoginPage() + const button = screen.getByRole('button', { name: '登入' }) + expect(button).toBeInTheDocument() + expect(button).not.toBeDisabled() + }) + + it('顯示測試帳號提示', () => { + renderLoginPage() + expect(screen.getByText('admin / admin123')).toBeInTheDocument() + expect(screen.getByText('user / user123')).toBeInTheDocument() + }) + }) + + describe('表單驗證', () => { + it('帳號與密碼均為空時顯示錯誤訊息', async () => { + renderLoginPage() + await userEvent.click(screen.getByRole('button', { name: '登入' })) + expect(screen.getByText('帳號與密碼為必填欄位')).toBeInTheDocument() + expect(mockLogin).not.toHaveBeenCalled() + }) + + it('僅帳號為空時顯示錯誤訊息', async () => { + renderLoginPage() + await userEvent.type(screen.getByLabelText('密碼'), 'admin123') + await userEvent.click(screen.getByRole('button', { name: '登入' })) + expect(screen.getByText('帳號與密碼為必填欄位')).toBeInTheDocument() + expect(mockLogin).not.toHaveBeenCalled() + }) + + it('僅密碼為空時顯示錯誤訊息', async () => { + renderLoginPage() + await userEvent.type(screen.getByLabelText('帳號'), 'admin') + await userEvent.click(screen.getByRole('button', { name: '登入' })) + expect(screen.getByText('帳號與密碼為必填欄位')).toBeInTheDocument() + expect(mockLogin).not.toHaveBeenCalled() + }) + + it('帳號與密碼均為空白字元時顯示錯誤訊息', async () => { + renderLoginPage() + await userEvent.type(screen.getByLabelText('帳號'), ' ') + await userEvent.type(screen.getByLabelText('密碼'), ' ') + await userEvent.click(screen.getByRole('button', { name: '登入' })) + expect(screen.getByText('帳號與密碼為必填欄位')).toBeInTheDocument() + expect(mockLogin).not.toHaveBeenCalled() + }) + }) + + describe('登入成功', () => { + it('呼叫 login() 並導向首頁', async () => { + mockLogin.mockResolvedValueOnce(undefined) + renderLoginPage() + await userEvent.type(screen.getByLabelText('帳號'), 'admin') + await userEvent.type(screen.getByLabelText('密碼'), 'admin123') + await userEvent.click(screen.getByRole('button', { name: '登入' })) + await waitFor(() => { + expect(mockLogin).toHaveBeenCalledWith('admin', 'admin123') + expect(mockNavigate).toHaveBeenCalledWith('/', { replace: true }) + }) + }) + }) + + describe('登入失敗', () => { + it('login() 拋出 Error 時顯示錯誤訊息', async () => { + mockLogin.mockRejectedValueOnce(new Error('帳號或密碼錯誤')) + renderLoginPage() + await userEvent.type(screen.getByLabelText('帳號'), 'wrong') + await userEvent.type(screen.getByLabelText('密碼'), 'wrong') + await userEvent.click(screen.getByRole('button', { name: '登入' })) + await waitFor(() => { + expect(screen.getByText('帳號或密碼錯誤')).toBeInTheDocument() + }) + expect(mockNavigate).not.toHaveBeenCalled() + }) + + it('login() 拋出非 Error 時顯示預設錯誤訊息', async () => { + mockLogin.mockRejectedValueOnce('unknown') + renderLoginPage() + await userEvent.type(screen.getByLabelText('帳號'), 'admin') + await userEvent.type(screen.getByLabelText('密碼'), 'admin123') + await userEvent.click(screen.getByRole('button', { name: '登入' })) + await waitFor(() => { + expect(screen.getByText('登入失敗,請稍後再試')).toBeInTheDocument() + }) + }) + }) + + describe('Loading 狀態', () => { + it('送出表單期間按鈕顯示「登入中...」並禁用', async () => { + let resolve!: () => void + mockLogin.mockReturnValueOnce(new Promise((r) => { resolve = r })) + renderLoginPage() + await userEvent.type(screen.getByLabelText('帳號'), 'admin') + await userEvent.type(screen.getByLabelText('密碼'), 'admin123') + await userEvent.click(screen.getByRole('button', { name: '登入' })) + await waitFor(() => { + expect(screen.getByRole('button', { name: /登入中/ })).toBeDisabled() + }) + resolve() + }) + + it('登入完成後按鈕恢復可用', async () => { + mockLogin.mockRejectedValueOnce(new Error('錯誤')) + renderLoginPage() + await userEvent.type(screen.getByLabelText('帳號'), 'admin') + await userEvent.type(screen.getByLabelText('密碼'), 'admin123') + await userEvent.click(screen.getByRole('button', { name: '登入' })) + await waitFor(() => { + expect(screen.getByRole('button', { name: '登入' })).not.toBeDisabled() + }) + }) + }) +}) diff --git a/vehicle-management/src/test-setup.ts b/vehicle-management/src/test-setup.ts new file mode 100644 index 0000000..c44951a --- /dev/null +++ b/vehicle-management/src/test-setup.ts @@ -0,0 +1 @@ +import '@testing-library/jest-dom' diff --git a/vehicle-management/vitest.config.ts b/vehicle-management/vitest.config.ts new file mode 100644 index 0000000..db03c55 --- /dev/null +++ b/vehicle-management/vitest.config.ts @@ -0,0 +1,22 @@ +import path from 'path' +import react from '@vitejs/plugin-react' +import { defineConfig } from 'vitest/config' + +export default defineConfig({ + plugins: [react()], + resolve: { + alias: { + '@': path.resolve(__dirname, './src'), + }, + }, + test: { + environment: 'jsdom', + globals: true, + setupFiles: ['./src/test-setup.ts'], + coverage: { + provider: 'v8', + reporter: ['text', 'html', 'lcov'], + reportsDirectory: './coverage', + }, + }, +})