Kaynağa Gözat

Merge remote-tracking branch 'origin/master' into support/use-jotai

Yuki Takei 6 ay önce
ebeveyn
işleme
7c30f8c9fc
39 değiştirilmiş dosya ile 1878 ekleme ve 283 silme
  1. 186 0
      .serena/memories/apps-app-pagetree-performance-refactor-plan.md
  2. 17 1
      CHANGELOG.md
  3. 2 4
      apps/app/package.json
  4. 1 1
      apps/app/src/client/util/apiv1-client.ts
  5. 1 1
      apps/app/src/client/util/apiv3-client.ts
  6. 3 0
      apps/app/src/features/opentelemetry/server/custom-metrics/index.ts
  7. 147 0
      apps/app/src/features/opentelemetry/server/custom-metrics/page-counts-metrics.spec.ts
  8. 42 0
      apps/app/src/features/opentelemetry/server/custom-metrics/page-counts-metrics.ts
  9. 3 11
      apps/app/src/interfaces/page-listing-results.ts
  10. 14 0
      apps/app/src/interfaces/page.ts
  11. 3 6
      apps/app/src/server/crowi/index.js
  12. 34 0
      apps/app/src/server/models/openapi/page-listing.ts
  13. 9 80
      apps/app/src/server/routes/apiv3/page-listing.ts
  14. 1 6
      apps/app/src/server/routes/apiv3/pages/index.js
  15. 20 0
      apps/app/src/server/service/growi-info/growi-info.integ.ts
  16. 11 1
      apps/app/src/server/service/growi-info/growi-info.ts
  17. 1 0
      apps/app/src/server/service/page-listing/index.ts
  18. 407 0
      apps/app/src/server/service/page-listing/page-listing.integ.ts
  19. 114 0
      apps/app/src/server/service/page-listing/page-listing.ts
  20. 15 2
      apps/app/src/server/service/page-operation.ts
  21. 3 105
      apps/app/src/server/service/page/index.ts
  22. 0 4
      apps/app/src/server/service/page/page-service.ts
  23. 0 1
      apps/pdf-converter/package.json
  24. 2 2
      apps/slackbot-proxy/package.json
  25. 4 1
      package.json
  26. 12 2
      packages/core/src/interfaces/growi-app-info.ts
  27. 1 1
      packages/pdf-converter-client/package.json
  28. 8 3
      packages/remark-attachment-refs/package.json
  29. 2 0
      packages/remark-attachment-refs/src/@types/declaration.d.ts
  30. 298 0
      packages/remark-attachment-refs/src/client/stores/refs.spec.ts
  31. 4 3
      packages/remark-attachment-refs/src/server/routes/refs.ts
  32. 1 0
      packages/remark-attachment-refs/tsconfig.json
  33. 16 0
      packages/remark-attachment-refs/vitest.config.ts
  34. 3 2
      packages/remark-lsx/package.json
  35. 153 0
      packages/remark-lsx/src/client/stores/lsx/lsx.spec.ts
  36. 2 1
      packages/remark-lsx/src/server/index.ts
  37. 5 0
      packages/remark-lsx/vitest.config.ts
  38. 1 1
      packages/slack/package.json
  39. 332 44
      pnpm-lock.yaml

+ 186 - 0
.serena/memories/apps-app-pagetree-performance-refactor-plan.md

@@ -0,0 +1,186 @@
+# PageTree パフォーマンス改善リファクタ計画 - 現実的戦略
+
+## 🎯 目標
+現在のパフォーマンス問題を解決:
+- **問題**: 5000件の兄弟ページで初期レンダリングが重い
+- **目標**: 表示速度を10-20倍改善、UX維持
+
+## ✅ 戦略2: API軽量化 - **完了済み**
+
+### 実装済み内容
+- **ファイル**: `apps/app/src/server/service/page-listing/page-listing.ts:77`
+- **変更内容**: `.select('_id path parent descendantCount grant isEmpty createdAt updatedAt wip')` を追加
+- **型定義**: `apps/app/src/interfaces/page.ts` の `IPageForTreeItem` 型も対応済み
+- **追加改善**: 計画にはなかった `wip` フィールドも最適化対象に含める
+
+### 実現できた効果
+- **データサイズ**: 推定 500バイト → 約100バイト(5倍軽量化)
+- **ネットワーク転送**: 5000ページ時 2.5MB → 500KB程度に削減
+- **状況**: **実装完了・効果発現中**
+
+---
+
+## 🚀 戦略1: 既存アーキテクチャ活用 + headless-tree部分導入 - **現実的戦略**
+
+### 前回のreact-window失敗原因
+1. **動的itemCount**: ツリー展開時にアイテム数が変化→react-windowの前提と衝突
+2. **非同期ローディング**: APIレスポンス待ちでフラット化不可
+3. **複雑な状態管理**: SWRとreact-windowの状態同期が困難
+
+### 現実的制約の認識
+**ItemsTree/TreeItemLayoutは廃止困難**:
+- **CustomTreeItemの出し分け**: `PageTreeItem` vs `TreeItemForModal`  
+- **共通副作用処理**: rename/duplicate/delete時のmutation処理
+- **多箇所からの利用**: PageTree, PageSelectModal, AiAssistant等
+
+## 📋 修正された実装戦略: **ハイブリッドアプローチ**
+
+### **核心アプローチ**: ItemsTreeを**dataProvider**として活用
+
+**既存の責務は保持しつつ、内部実装のみheadless-tree化**:
+
+1. **ItemsTree**: UIロジック・副作用処理はそのまま
+2. **TreeItemLayout**: 個別アイテムレンダリングはそのまま  
+3. **データ管理**: 内部でheadless-treeを使用(SWR → headless-tree)
+4. **Virtualization**: ItemsTree内部にreact-virtualを導入
+
+### **実装計画: 段階的移行**
+
+#### **Phase 1: データ層のheadless-tree化**
+
+**ファイル**: `ItemsTree.tsx`
+```typescript
+// Before: 複雑なSWR + 子コンポーネント管理
+const tree = useTree<IPageForTreeItem>({
+  rootItemId: initialItemNode.page._id,
+  dataLoader: {
+    getItem: async (itemId) => {
+      const response = await apiv3Get('/page-listing/item', { id: itemId });
+      return response.data;
+    },
+    getChildren: async (itemId) => {
+      const response = await apiv3Get('/page-listing/children', { id: itemId });
+      return response.data.children.map(child => child._id);
+    },
+  },
+  features: [asyncDataLoaderFeature],
+});
+
+// 既存のCustomTreeItemに渡すためのアダプター
+const adaptedNodes = tree.getItems().map(item => 
+  new ItemNode(item.getItemData())
+);
+
+return (
+  <ul className={`${moduleClass} list-group`}>
+    {adaptedNodes.map(node => (
+      <CustomTreeItem
+        key={node.page._id}
+        itemNode={node}
+        // ... 既存のpropsをそのまま渡す
+        onRenamed={onRenamed}
+        onClickDuplicateMenuItem={onClickDuplicateMenuItem}
+        onClickDeleteMenuItem={onClickDeleteMenuItem}
+      />
+    ))}
+  </ul>
+);
+```
+
+#### **Phase 2: Virtualization導入**
+
+**ファイル**: `ItemsTree.tsx` (Phase1をベースに拡張)
+```typescript
+const virtualizer = useVirtualizer({
+  count: adaptedNodes.length,
+  getScrollElement: () => containerRef.current,
+  estimateSize: () => 40,
+});
+
+return (
+  <div ref={containerRef} className="tree-container">
+    <div style={{ height: virtualizer.getTotalSize() }}>
+      {virtualizer.getVirtualItems().map(virtualItem => {
+        const node = adaptedNodes[virtualItem.index];
+        return (
+          <div
+            key={node.page._id}
+            style={{
+              position: 'absolute',
+              top: virtualItem.start,
+              height: virtualItem.size,
+              width: '100%',
+            }}
+          >
+            <CustomTreeItem
+              itemNode={node}
+              // ... 既存props
+            />
+          </div>
+        );
+      })}
+    </div>
+  </div>
+);
+```
+
+#### **Phase 3 (将来): 完全なheadless-tree移行**
+
+最終的にはdrag&drop、selection等のUI機能もheadless-treeに移行可能ですが、**今回のスコープ外**。
+
+## 📁 現実的なファイル変更まとめ
+
+| アクション | ファイル | 内容 | スコープ |
+|---------|---------|------|------|
+| ✅ **完了** | **apps/app/src/server/service/page-listing/page-listing.ts** | selectクエリ追加 | API軽量化 |
+| ✅ **完了** | **apps/app/src/interfaces/page.ts** | IPageForTreeItem型定義 | API軽量化 |
+| 🔄 **修正** | **src/client/components/ItemsTree/ItemsTree.tsx** | headless-tree + virtualization導入 | **今回の核心** |
+| 🆕 **新規** | **src/client/components/ItemsTree/usePageTreeDataLoader.ts** | データローダー分離 | 保守性向上 |
+| ⚠️ **保持** | **src/client/components/TreeItem/TreeItemLayout.tsx** | 既存のまま(後方互換) | 既存責務保持 |
+| ⚠️ **部分削除** | **src/stores/page-listing.tsx** | useSWRxPageChildren削除 | 重複排除 |
+
+**新規ファイル**: 1個(データローダー分離のみ)  
+**変更ファイル**: 2個(ItemsTree改修 + store整理)  
+**削除ファイル**: 0個(既存アーキテクチャ尊重)
+
+---
+
+## 🎯 実装優先順位
+
+**✅ Phase 1**: API軽量化(低リスク・即効性) - **完了**
+
+**📋 Phase 2-A**: ItemsTree内部のheadless-tree化
+- **工数**: 2-3日
+- **リスク**: 低(外部IF変更なし)
+- **効果**: 非同期ローディング最適化、キャッシュ改善
+
+**📋 Phase 2-B**: Virtualization導入  
+- **工数**: 2-3日
+- **リスク**: 低(内部実装のみ)
+- **効果**: レンダリング性能10-20倍改善
+
+**現在の効果**: API軽量化により 5倍のデータ転送量削減済み  
+**Phase 2完了時の予想効果**: 初期表示速度 20-50倍改善
+
+---
+
+## 🏗️ 実装方針: **既存アーキテクチャ尊重**
+
+**基本方針**:
+- **既存のCustomTreeItem責務**は保持(rename/duplicate/delete等)
+- **データ管理層のみ**をheadless-tree化  
+- **外部インターフェース**は変更せず、内部最適化に集中
+- **段階的移行**で低リスク実装
+
+**今回のスコープ**:
+- ✅ 非同期データローディング最適化
+- ✅ Virtualizationによる大量要素対応  
+- ❌ drag&drop/selection(将来フェーズ)
+- ❌ 既存アーキテクチャの破壊的変更
+
+---
+
+## 技術的参考資料
+- **headless-tree**: https://headless-tree.lukasbach.com/ (データ管理層のみ利用)
+- **react-virtual**: @tanstack/react-virtualを使用  
+- **アプローチ**: 既存ItemsTree内部でheadless-tree + virtualizationをハイブリッド活用

+ 17 - 1
CHANGELOG.md

@@ -1,9 +1,25 @@
 # Changelog
 
-## [Unreleased](https://github.com/growilabs/compare/v7.3.1...HEAD)
+## [Unreleased](https://github.com/growilabs/compare/v7.3.2...HEAD)
 
 *Please do not manually update this file. We've automated the process.*
 
+## [v7.3.2](https://github.com/growilabs/compare/v7.3.1...v7.3.2) - 2025-09-29
+
+### 🚀 Improvement
+
+* imprv: Elasticsearch management retrieving status (#10330) @yuki-takei
+
+### 🐛 Bug Fixes
+
+* fix: Ensure login errors display regardless of password login configuration (#10347) @yuki-takei
+* fix: TreeItem opening logic (#10331) @yuki-takei
+
+### 🧰 Maintenance
+
+* support: Improve memory leak (#10329) @yuki-takei
+* support: Fix biome errors (#10338) @yuki-takei
+
 ## [v7.3.1](https://github.com/growilabs/compare/v7.3.0...v7.3.1) - 2025-09-22
 
 ### 🧰 Maintenance

+ 2 - 4
apps/app/package.json

@@ -1,6 +1,6 @@
 {
   "name": "@growi/app",
-  "version": "7.3.2-RC.0",
+  "version": "7.3.3-RC.0",
   "license": "MIT",
   "private": "true",
   "scripts": {
@@ -104,7 +104,7 @@
     "archiver": "^5.3.0",
     "array.prototype.flatmap": "^1.2.2",
     "async-canvas-to-blob": "^1.0.3",
-    "axios": "^0.24.0",
+    "axios": "^1.11.0",
     "axios-retry": "^3.2.4",
     "babel-plugin-superjson-next": "^0.4.2",
     "body-parser": "^1.20.3",
@@ -275,9 +275,7 @@
     "@popperjs/core": "^2.11.8",
     "@swc-node/jest": "^1.8.1",
     "@swc/jest": "^0.2.36",
-    "@testing-library/dom": "^10.4.0",
     "@testing-library/jest-dom": "^6.5.0",
-    "@testing-library/react": "^16.0.1",
     "@testing-library/user-event": "^14.5.2",
     "@types/archiver": "^6.0.2",
     "@types/bunyan": "^1.8.11",

+ 1 - 1
apps/app/src/client/util/apiv1-client.ts

@@ -46,7 +46,7 @@ export async function apiPost<T>(path: string, params: unknown = {}): Promise<T>
 }
 
 export async function apiPostForm<T>(path: string, formData: FormData): Promise<T> {
-  return apiPost<T>(path, formData);
+  return apiRequest<T>('postForm', path, formData);
 }
 
 export async function apiDelete<T>(path: string, params: unknown = {}): Promise<T> {

+ 1 - 1
apps/app/src/client/util/apiv3-client.ts

@@ -48,7 +48,7 @@ export async function apiv3Post<T = any>(path: string, params: unknown = {}): Pr
 }
 
 export async function apiv3PostForm<T = any>(path: string, formData: FormData): Promise<AxiosResponse<T>> {
-  return apiv3Post<T>(path, formData);
+  return apiv3Request('postForm', path, formData);
 }
 
 export async function apiv3Put<T = any>(path: string, params: unknown = {}): Promise<AxiosResponse<T>> {

+ 3 - 0
apps/app/src/features/opentelemetry/server/custom-metrics/index.ts

@@ -1,11 +1,14 @@
 export { addApplicationMetrics } from './application-metrics';
+export { addPageCountsMetrics } from './page-counts-metrics';
 export { addUserCountsMetrics } from './user-counts-metrics';
 
 export const setupCustomMetrics = async (): Promise<void> => {
   const { addApplicationMetrics } = await import('./application-metrics');
   const { addUserCountsMetrics } = await import('./user-counts-metrics');
+  const { addPageCountsMetrics } = await import('./page-counts-metrics');
 
   // Add custom metrics
   addApplicationMetrics();
   addUserCountsMetrics();
+  addPageCountsMetrics();
 };

+ 147 - 0
apps/app/src/features/opentelemetry/server/custom-metrics/page-counts-metrics.spec.ts

@@ -0,0 +1,147 @@
+import { type Meter, metrics, type ObservableGauge } from '@opentelemetry/api';
+import { mock } from 'vitest-mock-extended';
+
+import { addPageCountsMetrics } from './page-counts-metrics';
+
+// Mock external dependencies
+vi.mock('~/utils/logger', () => ({
+  default: () => ({
+    info: vi.fn(),
+  }),
+}));
+
+vi.mock('@opentelemetry/api', () => ({
+  diag: {
+    createComponentLogger: () => ({
+      error: vi.fn(),
+    }),
+  },
+  metrics: {
+    getMeter: vi.fn(),
+  },
+}));
+
+// Mock growi-info service
+const mockGrowiInfoService = {
+  getGrowiInfo: vi.fn(),
+};
+vi.mock('~/server/service/growi-info', async () => ({
+  growiInfoService: mockGrowiInfoService,
+}));
+
+describe('addPageCountsMetrics', () => {
+  const mockMeter = mock<Meter>();
+  const mockPageCountGauge = mock<ObservableGauge>();
+
+  beforeEach(() => {
+    vi.clearAllMocks();
+    vi.mocked(metrics.getMeter).mockReturnValue(mockMeter);
+    mockMeter.createObservableGauge.mockReturnValueOnce(mockPageCountGauge);
+  });
+
+  afterEach(() => {
+    vi.restoreAllMocks();
+  });
+
+  it('should create observable gauges and set up metrics collection', () => {
+    addPageCountsMetrics();
+
+    expect(metrics.getMeter).toHaveBeenCalledWith(
+      'growi-page-counts-metrics',
+      '1.0.0',
+    );
+    expect(mockMeter.createObservableGauge).toHaveBeenCalledWith(
+      'growi.pages.total',
+      {
+        description: 'Total number of pages in GROWI',
+        unit: 'pages',
+      },
+    );
+    expect(mockMeter.addBatchObservableCallback).toHaveBeenCalledWith(
+      expect.any(Function),
+      [mockPageCountGauge],
+    );
+  });
+
+  describe('metrics callback behavior', () => {
+    const mockGrowiInfo = {
+      additionalInfo: {
+        currentPagesCount: 1234,
+      },
+    };
+
+    beforeEach(() => {
+      mockGrowiInfoService.getGrowiInfo.mockResolvedValue(mockGrowiInfo);
+    });
+
+    it('should observe page count metrics when growi info is available', async () => {
+      const mockResult = { observe: vi.fn() };
+
+      addPageCountsMetrics();
+
+      // Get the callback function that was passed to addBatchObservableCallback
+      const callback = mockMeter.addBatchObservableCallback.mock.calls[0][0];
+      await callback(mockResult);
+
+      expect(mockGrowiInfoService.getGrowiInfo).toHaveBeenCalledWith({
+        includePageCountInfo: true,
+      });
+      expect(mockResult.observe).toHaveBeenCalledWith(mockPageCountGauge, 1234);
+    });
+
+    it('should use default values when page counts are missing', async () => {
+      const mockResult = { observe: vi.fn() };
+
+      const growiInfoWithoutCounts = {
+        additionalInfo: {
+          // Missing currentPagesCount
+        },
+      };
+      mockGrowiInfoService.getGrowiInfo.mockResolvedValue(
+        growiInfoWithoutCounts,
+      );
+
+      addPageCountsMetrics();
+
+      const callback = mockMeter.addBatchObservableCallback.mock.calls[0][0];
+      await callback(mockResult);
+
+      expect(mockResult.observe).toHaveBeenCalledWith(mockPageCountGauge, 0);
+    });
+
+    it('should handle missing additionalInfo gracefully', async () => {
+      const mockResult = { observe: vi.fn() };
+
+      const growiInfoWithoutAdditionalInfo = {
+        // Missing additionalInfo entirely
+      };
+      mockGrowiInfoService.getGrowiInfo.mockResolvedValue(
+        growiInfoWithoutAdditionalInfo,
+      );
+
+      addPageCountsMetrics();
+
+      const callback = mockMeter.addBatchObservableCallback.mock.calls[0][0];
+      await callback(mockResult);
+
+      expect(mockResult.observe).toHaveBeenCalledWith(mockPageCountGauge, 0);
+    });
+
+    it('should handle errors in metrics collection gracefully', async () => {
+      mockGrowiInfoService.getGrowiInfo.mockRejectedValue(
+        new Error('Service unavailable'),
+      );
+      const mockResult = { observe: vi.fn() };
+
+      addPageCountsMetrics();
+
+      const callback = mockMeter.addBatchObservableCallback.mock.calls[0][0];
+
+      // Should not throw error
+      await expect(callback(mockResult)).resolves.toBeUndefined();
+
+      // Should not call observe when error occurs
+      expect(mockResult.observe).not.toHaveBeenCalled();
+    });
+  });
+});

+ 42 - 0
apps/app/src/features/opentelemetry/server/custom-metrics/page-counts-metrics.ts

@@ -0,0 +1,42 @@
+import { diag, metrics } from '@opentelemetry/api';
+
+import loggerFactory from '~/utils/logger';
+
+const logger = loggerFactory('growi:opentelemetry:custom-metrics:page-counts');
+const loggerDiag = diag.createComponentLogger({
+  namespace: 'growi:custom-metrics:page-counts',
+});
+
+export function addPageCountsMetrics(): void {
+  logger.info('Starting page counts metrics collection');
+
+  const meter = metrics.getMeter('growi-page-counts-metrics', '1.0.0');
+
+  const pageCountGauge = meter.createObservableGauge('growi.pages.total', {
+    description: 'Total number of pages in GROWI',
+    unit: 'pages',
+  });
+
+  meter.addBatchObservableCallback(
+    async (result) => {
+      try {
+        const { growiInfoService } = await import(
+          '~/server/service/growi-info'
+        );
+
+        const growiInfo = await growiInfoService.getGrowiInfo({
+          includePageCountInfo: true,
+        });
+
+        result.observe(
+          pageCountGauge,
+          growiInfo.additionalInfo?.currentPagesCount || 0,
+        );
+      } catch (error) {
+        loggerDiag.error('Failed to collect page counts metrics', { error });
+      }
+    },
+    [pageCountGauge],
+  );
+  logger.info('Page counts metrics collection started successfully');
+}

+ 3 - 11
apps/app/src/interfaces/page-listing-results.ts

@@ -1,19 +1,11 @@
-import type { IPageHasId } from '@growi/core';
-
-import type { IPageForItem } from './page';
-
-type ParentPath = string;
+import type { IPageForTreeItem } from './page';
 
 export interface RootPageResult {
-  rootPage: IPageHasId;
-}
-
-export interface AncestorsChildrenResult {
-  ancestorsChildren: Record<ParentPath, Partial<IPageForItem>[]>;
+  rootPage: IPageForTreeItem;
 }
 
 export interface ChildrenResult {
-  children: Partial<IPageForItem>[];
+  children: IPageForTreeItem[];
 }
 
 export interface V5MigrationStatus {

+ 14 - 0
apps/app/src/interfaces/page.ts

@@ -21,6 +21,20 @@ export type IPageForItem = Partial<
   IPageHasId & { processData?: IPageOperationProcessData }
 >;
 
+export type IPageForTreeItem = Pick<
+  IPageHasId,
+  | '_id'
+  | 'path'
+  | 'parent'
+  | 'descendantCount'
+  | 'revision'
+  | 'grant'
+  | 'isEmpty'
+  | 'wip'
+> & {
+  processData?: IPageOperationProcessData;
+};
+
 export const UserGroupPageGrantStatus = {
   isGranted: 'isGranted',
   notGranted: 'notGranted',

+ 3 - 6
apps/app/src/server/crowi/index.js

@@ -38,7 +38,7 @@ import { InstallerService } from '../service/installer';
 import { normalizeData } from '../service/normalize-data';
 import PageService from '../service/page';
 import PageGrantService from '../service/page-grant';
-import PageOperationService from '../service/page-operation';
+import instanciatePageOperationService from '../service/page-operation';
 import PassportService from '../service/passport';
 import SearchService from '../service/search';
 import { SlackIntegrationService } from '../service/slack-integration';
@@ -91,7 +91,7 @@ class Crowi {
   /** @type {import('../service/page-grant').default} */
   pageGrantService;
 
-  /** @type {import('../service/page-operation').default} */
+  /** @type {import('../service/page-operation').IPageOperationService} */
   pageOperationService;
 
   /** @type {PassportService} */
@@ -734,10 +734,7 @@ Crowi.prototype.setupPageService = async function() {
     this.pageService = new PageService(this);
     await this.pageService.createTtlIndex();
   }
-  if (this.pageOperationService == null) {
-    this.pageOperationService = new PageOperationService(this);
-    await this.pageOperationService.init();
-  }
+  this.pageOperationService = instanciatePageOperationService(this);
 };
 
 Crowi.prototype.setupInAppNotificationService = async function() {

+ 34 - 0
apps/app/src/server/models/openapi/page-listing.ts

@@ -0,0 +1,34 @@
+/**
+ * @swagger
+ *
+ *  components:
+ *    schemas:
+ *      PageForTreeItem:
+ *        description: Page
+ *        type: object
+ *        properties:
+ *          _id:
+ *            $ref: '#/components/schemas/ObjectId'
+ *          path:
+ *            $ref: '#/components/schemas/PagePath'
+ *          parent:
+ *            $ref: '#/components/schemas/PagePath'
+ *          grant:
+ *            $ref: '#/components/schemas/PageGrant'
+ *          lastUpdateUser:
+ *            $ref: '#/components/schemas/User'
+ *          descendantCount:
+ *            type: number
+ *          isEmpty:
+ *           type: boolean
+ *          wip:
+ *            type: boolean
+ *          createdAt:
+ *            type: string
+ *            description: date created at
+ *            example: 2010-01-01T00:00:00.000Z
+ *          updatedAt:
+ *            type: string
+ *            description: date updated at
+ *            example: 2010-01-01T00:00:00.000Z
+ */

+ 9 - 80
apps/app/src/server/routes/apiv3/page-listing.ts

@@ -1,5 +1,5 @@
 import type {
-  IPageInfoForListing, IPageInfo, IPage, IUserHasId,
+  IPageInfoForListing, IPageInfo, IUserHasId,
 } from '@growi/core';
 import { getIdForRef, isIPageInfoForEntity } from '@growi/core';
 import { SCOPE } from '@growi/core/dist/interfaces';
@@ -10,9 +10,11 @@ import { query, oneOf } from 'express-validator';
 import type { HydratedDocument } from 'mongoose';
 import mongoose from 'mongoose';
 
+import type { IPageForTreeItem } from '~/interfaces/page';
 import { accessTokenParser } from '~/server/middlewares/access-token-parser';
 import { configManager } from '~/server/service/config-manager';
 import type { IPageGrantService } from '~/server/service/page-grant';
+import { pageListingService } from '~/server/service/page-listing';
 import loggerFactory from '~/utils/logger';
 
 import type Crowi from '../../crowi';
@@ -87,88 +89,17 @@ const routerFactory = (crowi: Crowi): Router => {
    *               type: object
    *               properties:
    *                 rootPage:
-   *                   $ref: '#/components/schemas/Page'
+   *                   $ref: '#/components/schemas/PageForTreeItem'
    */
   router.get('/root',
     accessTokenParser([SCOPE.READ.FEATURES.PAGE], { acceptLegacy: true }), loginRequired, async(req: AuthorizedRequest, res: ApiV3Response) => {
-      const Page = mongoose.model<IPage, PageModel>('Page');
-
-      let rootPage;
       try {
-        rootPage = await Page.findByPathAndViewer('/', req.user, null, true);
+        const rootPage: IPageForTreeItem = await pageListingService.findRootByViewer(req.user);
+        return res.apiv3({ rootPage });
       }
       catch (err) {
         return res.apiv3Err(new ErrorV3('rootPage not found'));
       }
-
-      return res.apiv3({ rootPage });
-    });
-
-  /**
-   * @swagger
-   *
-   * /page-listing/ancestors-children:
-   *   get:
-   *     tags: [PageListing]
-   *     security:
-   *       - bearer: []
-   *       - accessTokenInQuery: []
-   *     summary: /page-listing/ancestors-children
-   *     description: Get the ancestors and children of a page
-   *     parameters:
-   *       - name: path
-   *         in: query
-   *         required: true
-   *         schema:
-   *           type: string
-   *     responses:
-   *       200:
-   *         description: Get the ancestors and children of a page
-   *         content:
-   *           application/json:
-   *             schema:
-   *               type: object
-   *               properties:
-   *                 ancestorsChildren:
-   *                   type: object
-   *                   additionalProperties:
-   *                     type: object
-   *                     properties:
-   *                       _id:
-   *                         type: string
-   *                         description: Document ID
-   *                       descendantCount:
-   *                         type: integer
-   *                         description: Number of descendants
-   *                       isEmpty:
-   *                         type: boolean
-   *                         description: Indicates if the node is empty
-   *                       grant:
-   *                         type: integer
-   *                         description: Access level
-   *                       path:
-   *                         type: string
-   *                         description: Path string
-   *                       revision:
-   *                         type: string
-   *                         nullable: true
-   *                         description: Revision ID (nullable)
-   */
-  router.get('/ancestors-children',
-    accessTokenParser([SCOPE.READ.FEATURES.PAGE], { acceptLegacy: true }),
-    loginRequired, ...validator.pagePathRequired, apiV3FormValidator, async(req: AuthorizedRequest, res: ApiV3Response): Promise<any> => {
-      const { path } = req.query;
-
-      const pageService = crowi.pageService;
-      try {
-        const ancestorsChildren = await pageService.findAncestorsChildrenByPathAndViewer(path as string, req.user);
-        return res.apiv3({ ancestorsChildren });
-      }
-      catch (err) {
-        logger.error('Failed to get ancestorsChildren.', err);
-        return res.apiv3Err(new ErrorV3('Failed to get ancestorsChildren.'));
-      }
-
     });
 
   /**
@@ -202,7 +133,7 @@ const routerFactory = (crowi: Crowi): Router => {
    *                 children:
    *                   type: array
    *                   items:
-   *                     $ref: '#/components/schemas/Page'
+   *                     $ref: '#/components/schemas/PageForTreeItem'
    */
   /*
    * In most cases, using id should be prioritized
@@ -212,14 +143,12 @@ const routerFactory = (crowi: Crowi): Router => {
     loginRequired, validator.pageIdOrPathRequired, apiV3FormValidator, async(req: AuthorizedRequest, res: ApiV3Response) => {
       const { id, path } = req.query;
 
-      const pageService = crowi.pageService;
-
       const hideRestrictedByOwner = await configManager.getConfig('security:list-policy:hideRestrictedByOwner');
       const hideRestrictedByGroup = await configManager.getConfig('security:list-policy:hideRestrictedByGroup');
 
       try {
-        const pages = await pageService.findChildrenByParentPathOrIdAndViewer(
-        (id || path) as string, req.user, undefined, !hideRestrictedByOwner, !hideRestrictedByGroup,
+        const pages = await pageListingService.findChildrenByParentPathOrIdAndViewer(
+          (id || path) as string, req.user, !hideRestrictedByOwner, !hideRestrictedByGroup,
         );
         return res.apiv3({ children: pages });
       }

+ 1 - 6
apps/app/src/server/routes/apiv3/pages/index.js

@@ -1,5 +1,6 @@
 
 import { PageGrant } from '@growi/core';
+import { SCOPE } from '@growi/core/dist/interfaces';
 import { ErrorV3 } from '@growi/core/dist/models';
 import { serializeUserSecurely } from '@growi/core/dist/models/serializers';
 import { isCreatablePage, isTrashPage, isUserPage } from '@growi/core/dist/utils/page-path-utils';
@@ -9,7 +10,6 @@ import { body, query } from 'express-validator';
 
 import { SupportedTargetModel, SupportedAction } from '~/interfaces/activity';
 import { subscribeRuleNames } from '~/interfaces/in-app-notification';
-import { SCOPE } from '@growi/core/dist/interfaces';
 import { accessTokenParser } from '~/server/middlewares/access-token-parser';
 import { GlobalNotificationSettingEvent } from '~/server/models/GlobalNotificationSetting';
 import PageTagRelation from '~/server/models/page-tag-relation';
@@ -53,7 +53,6 @@ module.exports = (crowi) => {
     ],
     renamePage: [
       body('pageId').isMongoId().withMessage('pageId is required'),
-      body('revisionId').optional({ nullable: true }).isMongoId().withMessage('revisionId is required'), // required when v4
       body('newPagePath').isLength({ min: 1 }).withMessage('newPagePath is required'),
       body('isRecursively').if(value => value != null).isBoolean().withMessage('isRecursively must be boolean'),
       body('isRenameRedirect').if(value => value != null).isBoolean().withMessage('isRenameRedirect must be boolean'),
@@ -210,10 +209,6 @@ module.exports = (crowi) => {
    *                    $ref: '#/components/schemas/ObjectId'
    *                  path:
    *                    $ref: '#/components/schemas/PagePath'
-   *                  revisionId:
-   *                    type: string
-   *                    description: revision ID
-   *                    example: 5e07345972560e001761fa63
    *                  newPagePath:
    *                    type: string
    *                    description: new path

+ 20 - 0
apps/app/src/server/service/growi-info/growi-info.integ.ts

@@ -1,3 +1,5 @@
+import type { IPage } from '^/../../packages/core/dist';
+import mongoose from 'mongoose';
 import { mock } from 'vitest-mock-extended';
 
 import pkg from '^/package.json';
@@ -7,6 +9,8 @@ import { Config } from '~/server/models/config';
 import { configManager } from '~/server/service/config-manager';
 
 import type Crowi from '../../crowi';
+import type { PageModel } from '../../models/page';
+import pageModel from '../../models/page';
 
 import { growiInfoService } from './growi-info';
 
@@ -14,12 +18,17 @@ describe('GrowiInfoService', () => {
   const appVersion = pkg.version;
 
   let User;
+  let Page;
 
   beforeAll(async() => {
     process.env.APP_SITE_URL = 'http://growi.test.jp';
     process.env.DEPLOYMENT_TYPE = 'growi-docker-compose';
     process.env.SAML_ENABLED = 'true';
 
+    // setup page model before loading configs
+    pageModel(null);
+    Page = mongoose.model<IPage, PageModel>('Page');
+
     await configManager.loadConfigs();
     await configManager.updateConfigs({
       'security:passport-saml:isEnabled': true,
@@ -47,6 +56,12 @@ describe('GrowiInfoService', () => {
     User = userModelFactory(crowiMock);
 
     await User.deleteMany({}); // clear users
+    await Page.deleteMany({}); // clear pages
+
+    await Page.create({
+      path: '/',
+      descendantCount: 0,
+    });
   });
 
   describe('getGrowiInfo', () => {
@@ -109,6 +124,7 @@ describe('GrowiInfoService', () => {
           currentActiveUsersCount: 1,
           attachmentType: 'aws',
           activeExternalAccountTypes: ['saml', 'github'],
+          currentPagesCount: 1,
         },
       });
     });
@@ -158,6 +174,7 @@ describe('GrowiInfoService', () => {
       const growiInfo = await growiInfoService.getGrowiInfo({
         includeAttachmentInfo: true,
         includeUserCountInfo: true,
+        includePageCountInfo: true,
       });
 
       // assert
@@ -167,6 +184,7 @@ describe('GrowiInfoService', () => {
         activeExternalAccountTypes: ['saml', 'github'],
         currentUsersCount: 1,
         currentActiveUsersCount: 1,
+        currentPagesCount: 1,
       });
     });
 
@@ -176,6 +194,7 @@ describe('GrowiInfoService', () => {
         includeAttachmentInfo: true,
         includeInstalledInfo: true,
         includeUserCountInfo: true,
+        includePageCountInfo: true,
       });
 
       // assert
@@ -187,6 +206,7 @@ describe('GrowiInfoService', () => {
         installedAtByOldestUser: new Date('2000-01-01'),
         currentUsersCount: 1,
         currentActiveUsersCount: 1,
+        currentPagesCount: 1,
       });
     });
 

+ 11 - 1
apps/app/src/server/service/growi-info/growi-info.ts

@@ -13,6 +13,7 @@ import mongoose from 'mongoose';
 
 import { IExternalAuthProviderType } from '~/interfaces/external-auth-provider';
 import { Config } from '~/server/models/config';
+import type { PageDocument, PageModel } from '~/server/models/page';
 import { aclService } from '~/server/service/acl';
 import { configManager } from '~/server/service/config-manager';
 import { getGrowiVersion } from '~/utils/growi-version';
@@ -22,6 +23,7 @@ const FULL_ADDITIONAL_INFO_OPTIONS = {
   includeAttachmentInfo: true,
   includeInstalledInfo: true,
   includeUserCountInfo: true,
+  includePageCountInfo: true,
 } as const;
 
 
@@ -116,9 +118,10 @@ export class GrowiInfoService {
 
   private async getAdditionalInfoByOptions<T extends GrowiInfoOptions>(options: T): Promise<IGrowiAdditionalInfoResult<T>> {
     const User = mongoose.model<IUser, Model<IUser>>('User');
+    const Page = mongoose.model<PageDocument, PageModel>('Page');
 
     // Check if any option is enabled to determine if we should return additional info
-    const hasAnyOption = options.includeAttachmentInfo || options.includeInstalledInfo || options.includeUserCountInfo;
+    const hasAnyOption = options.includeAttachmentInfo || options.includeInstalledInfo || options.includeUserCountInfo || options.includePageCountInfo;
 
     if (!hasAnyOption) {
       return undefined as IGrowiAdditionalInfoResult<T>;
@@ -137,6 +140,7 @@ export class GrowiInfoService {
       installedAtByOldestUser: Date | null;
       currentUsersCount: number;
       currentActiveUsersCount: number;
+      currentPagesCount: number;
     }> = {
       attachmentType: configManager.getConfig('app:fileUploadType'),
       activeExternalAccountTypes,
@@ -163,6 +167,12 @@ export class GrowiInfoService {
       partialResult.currentActiveUsersCount = currentActiveUsersCount;
     }
 
+    if (options.includePageCountInfo) {
+      const rootPage = await Page.findOne({ path: '/' });
+      const currentPagesCount = (rootPage?.descendantCount ?? 0) + 1;
+      partialResult.currentPagesCount = currentPagesCount;
+    }
+
     return partialResult as IGrowiAdditionalInfoResult<T>;
   }
 

+ 1 - 0
apps/app/src/server/service/page-listing/index.ts

@@ -0,0 +1 @@
+export * from './page-listing';

+ 407 - 0
apps/app/src/server/service/page-listing/page-listing.integ.ts

@@ -0,0 +1,407 @@
+import type { IPage, IUser } from '@growi/core/dist/interfaces';
+import { isValidObjectId } from '@growi/core/dist/utils/objectid-utils';
+import mongoose from 'mongoose';
+import type { HydratedDocument, Model } from 'mongoose';
+
+import { PageActionType, PageActionStage } from '~/interfaces/page-operation';
+import type { PageModel } from '~/server/models/page';
+import type { IPageOperation } from '~/server/models/page-operation';
+
+import { pageListingService } from './page-listing';
+
+// Mock the page-operation service
+vi.mock('~/server/service/page-operation', () => ({
+  pageOperationService: {
+    generateProcessInfo: vi.fn((pageOperations: IPageOperation[]) => {
+      // eslint-disable-next-line @typescript-eslint/no-explicit-any
+      const processInfo: Record<string, any> = {};
+      pageOperations.forEach((pageOp) => {
+        const pageId = pageOp.page._id.toString();
+        processInfo[pageId] = {
+          [pageOp.actionType]: {
+            [PageActionStage.Main]: { isProcessable: true },
+            [PageActionStage.Sub]: undefined,
+          },
+        };
+      });
+      return processInfo;
+    }),
+  },
+}));
+
+describe('page-listing store integration tests', () => {
+  let Page: PageModel;
+  let User: Model<IUser>;
+  let PageOperation: Model<IPageOperation>;
+  let testUser: HydratedDocument<IUser>;
+  let rootPage: HydratedDocument<IPage>;
+
+  // Helper function to validate IPageForTreeItem type structure
+  // eslint-disable-next-line @typescript-eslint/no-explicit-any
+  const validatePageForTreeItem = (page: any): void => {
+    expect(page).toBeDefined();
+    expect(page._id).toBeDefined();
+    expect(typeof page.path).toBe('string');
+    expect(page.grant).toBeDefined();
+    expect(typeof page.isEmpty).toBe('boolean');
+    expect(typeof page.descendantCount).toBe('number');
+    // revision is required when isEmpty is false
+    if (page.isEmpty === false) {
+      expect(page.revision).toBeDefined();
+      expect(isValidObjectId(page.revision)).toBe(true);
+    }
+    // processData is optional
+    if (page.processData !== undefined) {
+      expect(page.processData).toBeInstanceOf(Object);
+    }
+  };
+
+  beforeAll(async() => {
+    // setup models
+    const setupPage = (await import('~/server/models/page')).default;
+    setupPage(null);
+    const setupUser = (await import('~/server/models/user')).default;
+    setupUser(null);
+
+    // get models
+    Page = mongoose.model<IPage, PageModel>('Page');
+    User = mongoose.model<IUser>('User');
+    PageOperation = (await import('~/server/models/page-operation')).default;
+  });
+
+  beforeEach(async() => {
+    // Clean up database
+    await Page.deleteMany({});
+    await User.deleteMany({});
+    await PageOperation.deleteMany({});
+
+    // Create test user
+    testUser = await User.create({
+      name: 'Test User',
+      username: 'testuser',
+      email: 'test@example.com',
+      lang: 'en_US',
+    });
+
+    // Create root page
+    rootPage = await Page.create({
+      path: '/',
+      revision: new mongoose.Types.ObjectId(),
+      creator: testUser._id,
+      lastUpdateUser: testUser._id,
+      grant: 1, // GRANT_PUBLIC
+      isEmpty: false,
+      descendantCount: 0,
+    });
+  });
+
+  describe('pageListingService.findRootByViewer', () => {
+    test('should return root page successfully', async() => {
+      const rootPageResult = await pageListingService.findRootByViewer(testUser);
+
+      expect(rootPageResult).toBeDefined();
+      expect(rootPageResult.path).toBe('/');
+      expect(rootPageResult._id.toString()).toBe(rootPage._id.toString());
+      expect(rootPageResult.grant).toBe(1);
+      expect(rootPageResult.isEmpty).toBe(false);
+      expect(rootPageResult.descendantCount).toBe(0);
+    });
+
+    test('should handle error when root page does not exist', async() => {
+      // Remove the root page
+      await Page.deleteOne({ path: '/' });
+
+      try {
+        await pageListingService.findRootByViewer(testUser);
+        // Should not reach here
+        expect(true).toBe(false);
+      }
+      catch (error) {
+        expect(error).toBeDefined();
+      }
+    });
+
+    test('should return proper page structure that matches IPageForTreeItem type', async() => {
+      const rootPageResult = await pageListingService.findRootByViewer(testUser);
+
+      // Use helper function to validate type structure
+      validatePageForTreeItem(rootPageResult);
+
+      // Additional type-specific validations
+      expect(typeof rootPageResult._id).toBe('object'); // ObjectId
+      expect(rootPageResult.path).toBe('/');
+      expect([null, 1, 2, 3, 4, 5]).toContain(rootPageResult.grant); // Valid grant values
+      expect(rootPageResult.parent).toBeNull(); // Root page has no parent
+    });
+
+    test('should work without user (guest access) and return type-safe result', async() => {
+      const rootPageResult = await pageListingService.findRootByViewer();
+
+      validatePageForTreeItem(rootPageResult);
+      expect(rootPageResult.path).toBe('/');
+      expect(rootPageResult._id.toString()).toBe(rootPage._id.toString());
+    });
+  });
+
+  describe('pageListingService.findChildrenByParentPathOrIdAndViewer', () => {
+    let childPage1: HydratedDocument<IPage>;
+
+    beforeEach(async() => {
+      // Create child pages
+      childPage1 = await Page.create({
+        path: '/child1',
+        revision: new mongoose.Types.ObjectId(),
+        creator: testUser._id,
+        lastUpdateUser: testUser._id,
+        grant: 1, // GRANT_PUBLIC
+        isEmpty: false,
+        descendantCount: 1,
+        parent: rootPage._id,
+      });
+
+      await Page.create({
+        path: '/child2',
+        revision: new mongoose.Types.ObjectId(),
+        creator: testUser._id,
+        lastUpdateUser: testUser._id,
+        grant: 1, // GRANT_PUBLIC
+        isEmpty: false,
+        descendantCount: 0,
+        parent: rootPage._id,
+      });
+
+      // Create grandchild page
+      await Page.create({
+        path: '/child1/grandchild',
+        revision: new mongoose.Types.ObjectId(),
+        creator: testUser._id,
+        lastUpdateUser: testUser._id,
+        grant: 1, // GRANT_PUBLIC
+        isEmpty: false,
+        descendantCount: 0,
+        parent: childPage1._id,
+      });
+
+      // Update root page descendant count
+      await Page.updateOne(
+        { _id: rootPage._id },
+        { descendantCount: 2 },
+      );
+    });
+
+    test('should find children by parent path and return type-safe results', async() => {
+      const children = await pageListingService.findChildrenByParentPathOrIdAndViewer('/', testUser);
+
+      expect(children).toHaveLength(2);
+      children.forEach((child) => {
+        validatePageForTreeItem(child);
+        expect(child.parent?.toString()).toBe(rootPage._id.toString());
+        expect(['/child1', '/child2']).toContain(child.path);
+      });
+    });
+
+    test('should find children by parent ID and return type-safe results', async() => {
+      const children = await pageListingService.findChildrenByParentPathOrIdAndViewer(rootPage._id.toString(), testUser);
+
+      expect(children).toHaveLength(2);
+      children.forEach((child) => {
+        validatePageForTreeItem(child);
+        expect(child.parent?.toString()).toBe(rootPage._id.toString());
+      });
+    });
+
+    test('should handle nested children correctly', async() => {
+      const nestedChildren = await pageListingService.findChildrenByParentPathOrIdAndViewer('/child1', testUser);
+
+      expect(nestedChildren).toHaveLength(1);
+      const grandChild = nestedChildren[0];
+      validatePageForTreeItem(grandChild);
+      expect(grandChild.path).toBe('/child1/grandchild');
+      expect(grandChild.parent?.toString()).toBe(childPage1._id.toString());
+    });
+
+    test('should return empty array when no children exist', async() => {
+      const noChildren = await pageListingService.findChildrenByParentPathOrIdAndViewer('/child2', testUser);
+
+      expect(noChildren).toHaveLength(0);
+      expect(Array.isArray(noChildren)).toBe(true);
+    });
+
+    test('should work without user (guest access)', async() => {
+      const children = await pageListingService.findChildrenByParentPathOrIdAndViewer('/');
+
+      expect(children).toHaveLength(2);
+      children.forEach((child) => {
+        validatePageForTreeItem(child);
+      });
+    });
+
+    test('should sort children by path in ascending order', async() => {
+      const children = await pageListingService.findChildrenByParentPathOrIdAndViewer('/', testUser);
+
+      expect(children).toHaveLength(2);
+      expect(children[0].path).toBe('/child1');
+      expect(children[1].path).toBe('/child2');
+    });
+  });
+
+  describe('pageListingService processData injection', () => {
+    let operatingPage: HydratedDocument<IPage>;
+
+    beforeEach(async() => {
+      // Create a page that will have operations
+      operatingPage = await Page.create({
+        path: '/operating-page',
+        revision: new mongoose.Types.ObjectId(),
+        creator: testUser._id,
+        lastUpdateUser: testUser._id,
+        grant: 1, // GRANT_PUBLIC
+        isEmpty: false,
+        descendantCount: 0,
+        parent: rootPage._id,
+      });
+
+      // Create a PageOperation for this page
+      await PageOperation.create({
+        actionType: PageActionType.Rename,
+        actionStage: PageActionStage.Main,
+        page: {
+          _id: operatingPage._id,
+          path: operatingPage.path,
+          isEmpty: operatingPage.isEmpty,
+          grant: operatingPage.grant,
+          grantedGroups: [],
+          descendantCount: operatingPage.descendantCount,
+        },
+        user: {
+          _id: testUser._id,
+        },
+        fromPath: '/operating-page',
+        toPath: '/renamed-operating-page',
+        options: {},
+      });
+    });
+
+    test('should inject processData for pages with operations', async() => {
+      const children = await pageListingService.findChildrenByParentPathOrIdAndViewer('/', testUser);
+
+      // Find the operating page in results
+      const operatingResult = children.find(child => child.path === '/operating-page');
+      expect(operatingResult).toBeDefined();
+
+      // Validate type structure
+      if (operatingResult) {
+        validatePageForTreeItem(operatingResult);
+
+        // Check that processData was injected
+        expect(operatingResult.processData).toBeDefined();
+        expect(operatingResult.processData).toBeInstanceOf(Object);
+      }
+    });
+
+    test('should set processData to undefined for pages without operations', async() => {
+      // Create another page without operations
+      await Page.create({
+        path: '/normal-page',
+        revision: new mongoose.Types.ObjectId(),
+        creator: testUser._id,
+        lastUpdateUser: testUser._id,
+        grant: 1, // GRANT_PUBLIC
+        isEmpty: false,
+        descendantCount: 0,
+        parent: rootPage._id,
+      });
+
+      const children = await pageListingService.findChildrenByParentPathOrIdAndViewer('/', testUser);
+      const normalPage = children.find(child => child.path === '/normal-page');
+
+      expect(normalPage).toBeDefined();
+      if (normalPage) {
+        validatePageForTreeItem(normalPage);
+        expect(normalPage.processData).toBeUndefined();
+      }
+    });
+
+    test('should maintain type safety with mixed processData scenarios', async() => {
+      // Create pages with and without operations
+      await Page.create({
+        path: '/mixed-test-1',
+        revision: new mongoose.Types.ObjectId(),
+        creator: testUser._id,
+        lastUpdateUser: testUser._id,
+        grant: 1, // GRANT_PUBLIC
+        isEmpty: false,
+        descendantCount: 0,
+        parent: rootPage._id,
+      });
+
+      await Page.create({
+        path: '/mixed-test-2',
+        revision: new mongoose.Types.ObjectId(),
+        creator: testUser._id,
+        lastUpdateUser: testUser._id,
+        grant: 1, // GRANT_PUBLIC
+        isEmpty: false,
+        descendantCount: 0,
+        parent: rootPage._id,
+      });
+
+      const children = await pageListingService.findChildrenByParentPathOrIdAndViewer('/', testUser);
+
+      // All results should be type-safe regardless of processData presence
+      children.forEach((child) => {
+        validatePageForTreeItem(child);
+
+        // processData should be either undefined or a valid object
+        if (child.processData !== undefined) {
+          expect(child.processData).toBeInstanceOf(Object);
+        }
+      });
+    });
+  });
+
+  describe('PageQueryBuilder exec() type safety tests', () => {
+    test('findRootByViewer should return object with correct _id type', async() => {
+      const result = await pageListingService.findRootByViewer(testUser);
+
+      // PageQueryBuilder.exec() returns any, but we expect ObjectId-like behavior
+      expect(result._id).toBeDefined();
+      expect(result._id.toString).toBeDefined();
+      expect(typeof result._id.toString()).toBe('string');
+      expect(result._id.toString().length).toBe(24); // MongoDB ObjectId string length
+    });
+
+    test('findChildrenByParentPathOrIdAndViewer should return array with correct _id types', async() => {
+      // Create test child page first
+      await Page.create({
+        path: '/test-child',
+        revision: new mongoose.Types.ObjectId(),
+        creator: testUser._id,
+        lastUpdateUser: testUser._id,
+        grant: 1, // GRANT_PUBLIC
+        isEmpty: false,
+        descendantCount: 0,
+        parent: rootPage._id,
+      });
+
+      const results = await pageListingService.findChildrenByParentPathOrIdAndViewer('/', testUser);
+
+      expect(Array.isArray(results)).toBe(true);
+      results.forEach((result) => {
+        // Validate _id behavior from exec() any return type
+        expect(result._id).toBeDefined();
+        expect(result._id.toString).toBeDefined();
+        expect(typeof result._id.toString()).toBe('string');
+        expect(result._id.toString().length).toBe(24);
+
+        // Validate parent _id behavior
+        if (result.parent) {
+          expect(result.parent.toString).toBeDefined();
+          expect(typeof result.parent.toString()).toBe('string');
+          expect(result.parent.toString().length).toBe(24);
+        }
+      });
+    });
+
+  });
+});

+ 114 - 0
apps/app/src/server/service/page-listing/page-listing.ts

@@ -0,0 +1,114 @@
+import type { IUser } from '@growi/core/dist/interfaces';
+import { pagePathUtils } from '@growi/core/dist/utils';
+import mongoose, { type HydratedDocument } from 'mongoose';
+
+import type { IPageForTreeItem } from '~/interfaces/page';
+import { PageActionType, type IPageOperationProcessInfo, type IPageOperationProcessData } from '~/interfaces/page-operation';
+import { PageQueryBuilder, type PageDocument, type PageModel } from '~/server/models/page';
+import PageOperation from '~/server/models/page-operation';
+
+import type { IPageOperationService } from '../page-operation';
+
+const { hasSlash, generateChildrenRegExp } = pagePathUtils;
+
+
+export interface IPageListingService {
+  findRootByViewer(user: IUser): Promise<IPageForTreeItem>,
+  findChildrenByParentPathOrIdAndViewer(
+    parentPathOrId: string,
+    user?: IUser,
+    showPagesRestrictedByOwner?: boolean,
+    showPagesRestrictedByGroup?: boolean,
+  ): Promise<IPageForTreeItem[]>,
+}
+
+let pageOperationService: IPageOperationService;
+async function getPageOperationServiceInstance(): Promise<IPageOperationService> {
+  if (pageOperationService == null) {
+    // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
+    pageOperationService = await import('../page-operation').then(mod => mod.pageOperationService!);
+  }
+  return pageOperationService;
+}
+
+class PageListingService implements IPageListingService {
+
+  async findRootByViewer(user?: IUser): Promise<IPageForTreeItem> {
+    const Page = mongoose.model<HydratedDocument<PageDocument>, PageModel>('Page');
+
+    const builder = new PageQueryBuilder(Page.findOne({ path: '/' }));
+    await builder.addViewerCondition(user);
+
+    return builder.query
+      .select('_id path parent revision descendantCount grant isEmpty wip')
+      .lean()
+      .exec();
+  }
+
+  async findChildrenByParentPathOrIdAndViewer(
+      parentPathOrId: string,
+      user?: IUser,
+      showPagesRestrictedByOwner = false,
+      showPagesRestrictedByGroup = false,
+  ): Promise<IPageForTreeItem[]> {
+    const Page = mongoose.model<HydratedDocument<PageDocument>, PageModel>('Page');
+    let queryBuilder: PageQueryBuilder;
+    if (hasSlash(parentPathOrId)) {
+      const path = parentPathOrId;
+      const regexp = generateChildrenRegExp(path);
+      queryBuilder = new PageQueryBuilder(Page.find({ path: { $regex: regexp } }), true);
+    }
+    else {
+      const parentId = parentPathOrId;
+      // Use $eq for user-controlled sources. see: https://codeql.github.com/codeql-query-help/javascript/js-sql-injection/#recommendation
+      queryBuilder = new PageQueryBuilder(Page.find({ parent: { $eq: parentId } }), true);
+    }
+    await queryBuilder.addViewerCondition(user, null, undefined, showPagesRestrictedByOwner, showPagesRestrictedByGroup);
+
+    const pages: HydratedDocument<Omit<IPageForTreeItem, 'processData'>>[] = await queryBuilder
+      .addConditionToSortPagesByAscPath()
+      .query
+      .select('_id path parent revision descendantCount grant isEmpty wip')
+      .lean()
+      .exec();
+
+    const injectedPages = await this.injectProcessDataIntoPagesByActionTypes(pages, [PageActionType.Rename]);
+
+    // Type-safe conversion to IPageForTreeItem
+    return injectedPages.map(page => (
+      Object.assign(page, { _id: page._id.toString() })
+    ));
+  }
+
+  /**
+   * Inject processData into page docuements
+   * The processData is a combination of actionType as a key and information on whether the action is processable as a value.
+   */
+  private async injectProcessDataIntoPagesByActionTypes<T>(
+      pages: HydratedDocument<T>[],
+      actionTypes: PageActionType[],
+  ): Promise<(HydratedDocument<T> & { processData?: IPageOperationProcessData })[]> {
+
+    const pageOperations = await PageOperation.find({ actionType: { $in: actionTypes } });
+    if (pageOperations == null || pageOperations.length === 0) {
+      return pages.map(page => Object.assign(page, { processData: undefined }));
+    }
+
+    const pageOperationService = await getPageOperationServiceInstance();
+    const processInfo: IPageOperationProcessInfo = pageOperationService.generateProcessInfo(pageOperations);
+    const operatingPageIds: string[] = Object.keys(processInfo);
+
+    // inject processData into pages
+    return pages.map((page) => {
+      const pageId = page._id.toString();
+      if (operatingPageIds.includes(pageId)) {
+        const processData: IPageOperationProcessData = processInfo[pageId];
+        return Object.assign(page, { processData });
+      }
+      return Object.assign(page, { processData: undefined });
+    });
+  }
+
+}
+
+export const pageListingService = new PageListingService();

+ 15 - 2
apps/app/src/server/service/page-operation.ts

@@ -25,7 +25,15 @@ const {
   Duplicate, Delete, DeleteCompletely, Revert, NormalizeParent,
 } = PageActionType;
 
-class PageOperationService {
+export interface IPageOperationService {
+  generateProcessInfo(pageOperations: PageOperationDocument[]): IPageOperationProcessInfo;
+  canOperate(isRecursively: boolean, fromPathToOp: string | null, toPathToOp: string | null): Promise<boolean>;
+  autoUpdateExpiryDate(operationId: ObjectIdLike): NodeJS.Timeout;
+  clearAutoUpdateInterval(timerObj: NodeJS.Timeout): void;
+  getAncestorsPathsByFromAndToPath(fromPath: string, toPath: string): string[];
+}
+
+class PageOperationService implements IPageOperationService {
 
   crowi: Crowi;
 
@@ -201,4 +209,9 @@ class PageOperationService {
 
 }
 
-export default PageOperationService;
+// eslint-disable-next-line import/no-mutable-exports
+export let pageOperationService: PageOperationService | undefined; // singleton instance
+export default function instanciate(crowi: Crowi): PageOperationService {
+  pageOperationService = new PageOperationService(crowi);
+  return pageOperationService;
+}

+ 3 - 105
apps/app/src/server/service/page/index.ts

@@ -33,9 +33,7 @@ import {
   PageDeleteConfigValue, PageSingleDeleteCompConfigValue,
 } from '~/interfaces/page-delete-config';
 import type { PopulatedGrantedGroup } from '~/interfaces/page-grant';
-import {
-  type IPageOperationProcessInfo, type IPageOperationProcessData, PageActionStage, PageActionType,
-} from '~/interfaces/page-operation';
+import { PageActionStage, PageActionType } from '~/interfaces/page-operation';
 import { PageActionOnGroupDelete } from '~/interfaces/user-group';
 import { SocketEventName, type PageMigrationErrorData, type UpdateDescCountRawData } from '~/interfaces/websocket';
 import type { CurrentPageYjsData } from '~/interfaces/yjs';
@@ -4344,45 +4342,10 @@ class PageService implements IPageService {
     return savedPage;
   }
 
-  /*
-   * Find all children by parent's path or id. Using id should be prioritized
-   */
-  async findChildrenByParentPathOrIdAndViewer(
-      parentPathOrId: string,
-      user,
-      userGroups = null,
-      showPagesRestrictedByOwner = false,
-      showPagesRestrictedByGroup = false,
-  ): Promise<(HydratedDocument<PageDocument> & { processData?: IPageOperationProcessData })[]> {
-    const Page = mongoose.model<HydratedDocument<PageDocument>, PageModel>('Page');
-    let queryBuilder: PageQueryBuilder;
-    if (hasSlash(parentPathOrId)) {
-      const path = parentPathOrId;
-      const regexp = generateChildrenRegExp(path);
-      queryBuilder = new PageQueryBuilder(Page.find({ path: { $regex: regexp } }), true);
-    }
-    else {
-      const parentId = parentPathOrId;
-      // Use $eq for user-controlled sources. see: https://codeql.github.com/codeql-query-help/javascript/js-sql-injection/#recommendation
-      queryBuilder = new PageQueryBuilder(Page.find({ parent: { $eq: parentId } } as any), true); // TODO: improve type
-    }
-    await queryBuilder.addViewerCondition(user, userGroups, undefined, showPagesRestrictedByOwner, showPagesRestrictedByGroup);
-
-    const pages: HydratedDocument<PageDocument>[] = await queryBuilder
-      .addConditionToSortPagesByAscPath()
-      .query
-      .lean()
-      .exec();
-
-    await this.injectProcessDataIntoPagesByActionTypes(pages, [PageActionType.Rename]);
-
-    return pages;
-  }
-
   /**
    * Find all pages in trash page
    */
-  async findAllTrashPages(user: IUserHasId, userGroups = null): Promise<PageDocument[]> {
+  async findAllTrashPages(user: IUserHasId, userGroups = null): Promise<HydratedDocument<IPage>[]> {
     const Page = mongoose.model('Page') as unknown as PageModel;
 
     // https://regex101.com/r/KYZWls/1
@@ -4392,80 +4355,15 @@ class PageService implements IPageService {
 
     await queryBuilder.addViewerCondition(user, userGroups);
 
-    const pages = await queryBuilder
+    const pages: HydratedDocument<IPage>[] = await queryBuilder
       .addConditionToSortPagesByAscPath()
       .query
       .lean()
       .exec();
 
-    await this.injectProcessDataIntoPagesByActionTypes(pages, [PageActionType.Rename]);
-
     return pages;
   }
 
-  async findAncestorsChildrenByPathAndViewer(path: string, user, userGroups = null): Promise<Record<string, PageDocument[]>> {
-    const Page = mongoose.model('Page') as unknown as PageModel;
-
-    const ancestorPaths = isTopPage(path) ? ['/'] : collectAncestorPaths(path); // root path is necessary for rendering
-    const regexps = ancestorPaths.map(path => generateChildrenRegExp(path)); // cannot use re2
-
-    // get pages at once
-    const queryBuilder = new PageQueryBuilder(Page.find({ path: { $in: regexps } }), true);
-    await queryBuilder.addViewerCondition(user, userGroups);
-    const pages = await queryBuilder
-      .addConditionAsOnTree()
-      .addConditionToMinimizeDataForRendering()
-      .addConditionToSortPagesByAscPath()
-      .query
-      .lean()
-      .exec();
-
-    await this.injectProcessDataIntoPagesByActionTypes(pages, [PageActionType.Rename]);
-
-    /*
-     * If any non-migrated page is found during creating the pathToChildren map, it will stop incrementing at that moment
-     */
-    const pathToChildren: Record<string, PageDocument[]> = {};
-    const sortedPaths = ancestorPaths.sort((a, b) => a.length - b.length); // sort paths by path.length
-    sortedPaths.every((path) => {
-      const children = pages.filter(page => pathlib.dirname(page.path) === path);
-      if (children.length === 0) {
-        return false; // break when children do not exist
-      }
-      pathToChildren[path] = children;
-      return true;
-    });
-
-    return pathToChildren;
-  }
-
-  /**
-   * Inject processData into page docuements
-   * The processData is a combination of actionType as a key and information on whether the action is processable as a value.
-   */
-  private async injectProcessDataIntoPagesByActionTypes(
-      pages: (HydratedDocument<PageDocument> & { processData?: IPageOperationProcessData })[],
-      actionTypes: PageActionType[],
-  ): Promise<void> {
-
-    const pageOperations = await PageOperation.find({ actionType: { $in: actionTypes } });
-    if (pageOperations == null || pageOperations.length === 0) {
-      return;
-    }
-
-    const processInfo: IPageOperationProcessInfo = this.crowi.pageOperationService.generateProcessInfo(pageOperations);
-    const operatingPageIds: string[] = Object.keys(processInfo);
-
-    // inject processData into pages
-    pages.forEach((page) => {
-      const pageId = page._id.toString();
-      if (operatingPageIds.includes(pageId)) {
-        const processData: IPageOperationProcessData = processInfo[pageId];
-        page.processData = processData;
-      }
-    });
-  }
-
   async getYjsData(pageId: string): Promise<CurrentPageYjsData> {
     const yjsService = getYjsService();
 

+ 0 - 4
apps/app/src/server/service/page/page-service.ts

@@ -31,10 +31,6 @@ export interface IPageService {
   findPageAndMetaDataByViewer(
       pageId: string | null, path: string, user?: HydratedDocument<IUser>, includeEmpty?: boolean, isSharedPage?: boolean,
   ): Promise<IDataWithMeta<HydratedDocument<PageDocument>, IPageInfoExt> | IDataWithMeta<null, IPageNotFoundInfo>>
-  findAncestorsChildrenByPathAndViewer(path: string, user, userGroups?): Promise<Record<string, PageDocument[]>>,
-  findChildrenByParentPathOrIdAndViewer(
-    parentPathOrId: string, user, userGroups?, showPagesRestrictedByOwner?: boolean, showPagesRestrictedByGroup?: boolean,
-  ): Promise<PageDocument[]>,
   resumeRenameSubOperation(renamedPage: PageDocument, pageOp: PageOperationDocument, activity?): Promise<void>
   handlePrivatePagesForGroupsToDelete(
     groupsToDelete: UserGroupDocument[] | ExternalUserGroupDocument[],

+ 0 - 1
apps/pdf-converter/package.json

@@ -36,7 +36,6 @@
     "@tsed/schema": "=8.5.0",
     "@tsed/swagger": "=8.5.0",
     "@tsed/terminus": "=8.5.0",
-    "axios": "^0.24.0",
     "express": "^4.19.2",
     "puppeteer": "^23.1.1",
     "puppeteer-cluster": "^0.24.0",

+ 2 - 2
apps/slackbot-proxy/package.json

@@ -1,6 +1,6 @@
 {
   "name": "@growi/slackbot-proxy",
-  "version": "7.3.2-slackbot-proxy.0",
+  "version": "7.3.3-slackbot-proxy.0",
   "license": "MIT",
   "private": "true",
   "scripts": {
@@ -45,7 +45,7 @@
     "@tsed/schema": "=6.43.0",
     "@tsed/swagger": "=6.43.0",
     "@tsed/typeorm": "=6.43.0",
-    "axios": "^0.24.0",
+    "axios": "^1.11.0",
     "body-parser": "^1.20.3",
     "browser-bunyan": "^1.6.3",
     "bunyan": "^1.8.15",

+ 4 - 1
package.json

@@ -1,6 +1,6 @@
 {
   "name": "growi",
-  "version": "7.3.2-RC.0",
+  "version": "7.3.3-RC.0",
   "description": "Team collaboration software using markdown",
   "license": "MIT",
   "private": "true",
@@ -47,6 +47,9 @@
     "@changesets/cli": "^2.27.3",
     "@faker-js/faker": "^9.0.1",
     "@playwright/test": "^1.49.1",
+    "@testing-library/dom": "^10.4.0",
+    "@testing-library/react": "^16.0.1",
+    "@testing-library/react-hooks": "^8.0.1",
     "@swc-node/register": "^1.10.9",
     "@swc/core": "^1.5.25",
     "@swc/helpers": "^0.5.11",

+ 12 - 2
packages/core/src/interfaces/growi-app-info.ts

@@ -10,8 +10,8 @@ export interface GrowiInfoOptions {
   includeAttachmentInfo?: boolean;
   includeInstalledInfo?: boolean;
   includeUserCountInfo?: boolean;
+  includePageCountInfo?: boolean;
   // Future extensions can be added here
-  // includePageCount?: boolean;
 }
 
 interface IGrowiOSInfo {
@@ -36,9 +36,14 @@ interface IAdditionalUserCountInfo {
   currentActiveUsersCount: number;
 }
 
+interface IAdditionalPageCountInfo {
+  currentPagesCount: number;
+}
+
 export interface IGrowiAdditionalInfo
   extends IAdditionalInstalledAtInfo,
     IAdditionalUserCountInfo,
+    IAdditionalPageCountInfo,
     IAdditionalAttachmentInfo {}
 
 // Type mapping for flexible options
@@ -51,6 +56,9 @@ export type IGrowiAdditionalInfoByOptions<T extends GrowiInfoOptions> =
       : Record<string, never>) &
     (T['includeUserCountInfo'] extends true
       ? IAdditionalUserCountInfo
+      : Record<string, never>) &
+    (T['includePageCountInfo'] extends true
+      ? IAdditionalPageCountInfo
       : Record<string, never>);
 
 // Helper type to check if any option is enabled
@@ -61,7 +69,9 @@ export type HasAnyOption<T extends GrowiInfoOptions> =
       ? true
       : T['includeUserCountInfo'] extends true
         ? true
-        : false;
+        : T['includePageCountInfo'] extends true
+          ? true
+          : false;
 
 // Final result type based on options
 export type IGrowiAdditionalInfoResult<T extends GrowiInfoOptions> =

+ 1 - 1
packages/pdf-converter-client/package.json

@@ -12,7 +12,7 @@
     "build": "pnpm gen:client-code && tsc -p tsconfig.json"
   },
   "dependencies": {
-    "axios": "^0.24.0",
+    "axios": "^1.11.0",
     "tslib": "^2.8.0"
   },
   "devDependencies": {

+ 8 - 3
packages/remark-attachment-refs/package.json

@@ -41,16 +41,16 @@
     "lint:styles": "stylelint \"src/**/*.scss\" \"src/**/*.css\"",
     "lint:typecheck": "vue-tsc --noEmit",
     "lint": "run-p lint:*",
-    "test": ""
+    "test": "vitest run --coverage"
   },
   "dependencies": {
     "@growi/core": "workspace:^",
     "@growi/remark-growi-directive": "workspace:^",
     "@growi/ui": "workspace:^",
-    "axios": "^0.24.0",
+    "axios": "^1.11.0",
     "bunyan": "^1.8.15",
-    "hast-util-select": "^6.0.2",
     "express": "^4.20.0",
+    "hast-util-select": "^6.0.2",
     "mongoose": "^6.13.6",
     "swr": "^2.3.2",
     "universal-bunyan": "^0.9.2",
@@ -59,10 +59,15 @@
   "devDependencies": {
     "@types/bunyan": "^1.8.11",
     "@types/hast": "^3.0.4",
+    "@types/react": "^18.2.14",
+    "@types/react-dom": "^18.2.6",
+    "@types/supertest": "^6.0.2",
     "csstype": "^3.0.2",
+    "happy-dom": "^15.7.4",
     "hast-util-sanitize": "^5.0.1",
     "hast-util-select": "^6.0.2",
     "npm-run-all": "^4.1.5",
+    "supertest": "^7.0.0",
     "unified": "^11.0.0",
     "unist-util-visit": "^5.0.0"
   },

+ 2 - 0
packages/remark-attachment-refs/src/@types/declaration.d.ts

@@ -0,0 +1,2 @@
+// prevent TS2307: Cannot find module './xxx.module.scss' or its corresponding type declarations.
+declare module '*.scss';

+ 298 - 0
packages/remark-attachment-refs/src/client/stores/refs.spec.ts

@@ -0,0 +1,298 @@
+// tests for assuring axios request succeeds in version change
+
+import type { Server } from 'node:http';
+import { renderHook, waitFor } from '@testing-library/react';
+import axios from 'axios';
+import express from 'express';
+import refsMiddleware from '../../server';
+
+import { useSWRxRef, useSWRxRefs } from './refs';
+
+// Mock the IAttachmentHasId type for testing
+const mockAttachment = {
+  _id: '507f1f77bcf86cd799439011',
+  fileFormat: 'image/jpeg',
+  fileName: 'test-image.jpg',
+  originalName: 'test-image.jpg',
+  filePath: 'attachment/507f1f77bcf86cd799439011.jpg',
+  creator: {
+    _id: '507f1f77bcf86cd799439012',
+    name: 'Test User',
+    username: 'testuser',
+  },
+  page: '507f1f77bcf86cd799439013',
+  createdAt: '2023-01-01T00:00:00.000Z',
+  fileSize: 1024000,
+};
+
+// Mock PageQueryBuilder
+const mockPageQueryBuilder = {
+  addConditionToListWithDescendants: vi.fn().mockReturnThis(),
+  addConditionToExcludeTrashed: vi.fn().mockReturnThis(),
+  query: {
+    select: vi.fn().mockReturnValue({
+      exec: vi.fn().mockResolvedValue([{ id: '507f1f77bcf86cd799439013' }]),
+    }),
+    and: vi.fn().mockReturnThis(),
+  },
+};
+
+vi.mock('mongoose', async (importOriginal) => {
+  const actual = await importOriginal<typeof import('mongoose')>();
+  return {
+    ...actual,
+    default: {
+      ...actual.default,
+      model: vi.fn().mockImplementation((modelName) => {
+        const mockModel = {
+          findByPathAndViewer: vi.fn().mockResolvedValue({
+            _id: '507f1f77bcf86cd799439013',
+            path: '/test-page',
+          }),
+          isAccessiblePageByViewer: vi.fn().mockResolvedValue(true),
+          find: vi.fn().mockReturnValue({
+            select: vi.fn().mockReturnValue({
+              exec: vi
+                .fn()
+                .mockResolvedValue([{ id: '507f1f77bcf86cd799439013' }]),
+            }),
+            and: vi.fn().mockReturnThis(),
+          }),
+          addConditionToFilteringByViewerForList: vi.fn(),
+          PageQueryBuilder: vi
+            .fn()
+            .mockImplementation(() => mockPageQueryBuilder),
+        };
+
+        if (modelName === 'Attachment') {
+          return {
+            findOne: vi.fn().mockReturnValue({
+              populate: vi.fn().mockResolvedValue(mockAttachment),
+            }),
+            find: vi.fn().mockReturnValue({
+              and: vi.fn().mockReturnThis(),
+              populate: vi.fn().mockReturnThis(),
+              exec: vi.fn().mockResolvedValue([mockAttachment]),
+            }),
+          };
+        }
+
+        return mockModel;
+      }),
+    },
+    model: vi.fn().mockImplementation((modelName) => {
+      const mockModel = {
+        findByPathAndViewer: vi.fn().mockResolvedValue({
+          _id: '507f1f77bcf86cd799439013',
+          path: '/test-page',
+        }),
+        isAccessiblePageByViewer: vi.fn().mockResolvedValue(true),
+        find: vi.fn().mockReturnValue({
+          select: vi.fn().mockReturnValue({
+            exec: vi
+              .fn()
+              .mockResolvedValue([{ id: '507f1f77bcf86cd799439013' }]),
+          }),
+          and: vi.fn().mockReturnThis(),
+        }),
+        addConditionToFilteringByViewerForList: vi.fn(),
+        PageQueryBuilder: vi
+          .fn()
+          .mockImplementation(() => mockPageQueryBuilder),
+      };
+
+      if (modelName === 'Attachment') {
+        return {
+          findOne: vi.fn().mockReturnValue({
+            populate: vi.fn().mockResolvedValue(mockAttachment),
+          }),
+          find: vi.fn().mockReturnValue({
+            and: vi.fn().mockReturnThis(),
+            populate: vi.fn().mockReturnThis(),
+            exec: vi.fn().mockResolvedValue([mockAttachment]),
+          }),
+        };
+      }
+
+      return mockModel;
+    }),
+  };
+});
+
+// Mock @growi/core modules
+vi.mock('@growi/core', () => ({
+  SCOPE: {
+    READ: { FEATURES: { PAGE: 'read:page' } },
+  },
+}));
+
+vi.mock('@growi/core/dist/models/serializers', () => ({
+  serializeAttachmentSecurely: vi
+    .fn()
+    .mockImplementation((attachment) => attachment),
+}));
+
+vi.mock('@growi/core/dist/remark-plugins', () => ({
+  OptionParser: {
+    parseRange: vi.fn().mockReturnValue({ start: 1, end: 3 }),
+  },
+}));
+
+// Mock FilterXSS
+vi.mock('xss', () => ({
+  FilterXSS: vi.fn().mockImplementation(() => ({
+    process: vi.fn().mockImplementation((input) => input),
+  })),
+}));
+
+const TEST_PORT = 3002;
+const TEST_SERVER_URL = `http://localhost:${TEST_PORT}`;
+
+describe('useSWRxRef and useSWRxRefs integration tests', () => {
+  let server: Server;
+  let app: express.Application;
+
+  const setupAxiosSpy = () => {
+    const originalAxios = axios.create();
+    return vi.spyOn(axios, 'get').mockImplementation((url, config) => {
+      const fullUrl = url.startsWith('/_api')
+        ? `${TEST_SERVER_URL}${url}`
+        : url;
+      return originalAxios.get(fullUrl, config);
+    });
+  };
+
+  beforeAll(async () => {
+    app = express();
+    app.use(express.json());
+    app.use(express.urlencoded({ extended: true }));
+
+    app.use((req, res, next) => {
+      res.header('Access-Control-Allow-Origin', '*');
+      res.header(
+        'Access-Control-Allow-Methods',
+        'GET, POST, PUT, DELETE, OPTIONS',
+      );
+      res.header(
+        'Access-Control-Allow-Headers',
+        'Origin, X-Requested-With, Content-Type, Accept, Authorization',
+      );
+      next();
+    });
+
+    const mockCrowi = {
+      require: () => () => (req: any, res: any, next: any) => next(),
+      accessTokenParser: () => (req: any, res: any, next: any) => {
+        req.user = { _id: '507f1f77bcf86cd799439012', username: 'testuser' };
+        next();
+      },
+    };
+
+    refsMiddleware(mockCrowi, app);
+
+    return new Promise<void>((resolve) => {
+      server = app.listen(TEST_PORT, () => {
+        resolve();
+      });
+    });
+  });
+
+  afterAll(() => {
+    return new Promise<void>((resolve) => {
+      if (server) {
+        server.close(() => {
+          resolve();
+        });
+      } else {
+        resolve();
+      }
+    });
+  });
+
+  describe('useSWRxRef', () => {
+    it('should make actual server request and receive attachment data for single ref request', async () => {
+      const axiosGetSpy = setupAxiosSpy();
+
+      const { result } = renderHook(() =>
+        useSWRxRef('/test-page', 'test-image.jpg', false),
+      );
+
+      await waitFor(() => expect(result.current.data).toBeDefined(), {
+        timeout: 5000,
+      });
+
+      expect(axiosGetSpy).toHaveBeenCalledWith(
+        '/_api/attachment-refs/ref',
+        expect.objectContaining({
+          params: expect.objectContaining({
+            pagePath: '/test-page',
+            fileNameOrId: 'test-image.jpg',
+          }),
+        }),
+      );
+
+      expect(result.current.data).toBeDefined();
+      expect(result.current.error).toBeUndefined();
+
+      axiosGetSpy.mockRestore();
+    });
+  });
+
+  describe('useSWRxRefs', () => {
+    it('should make actual server request and receive attachments data for refs request with pagePath', async () => {
+      const axiosGetSpy = setupAxiosSpy();
+
+      const { result } = renderHook(() =>
+        useSWRxRefs('/test-page', undefined, {}, false),
+      );
+
+      await waitFor(() => expect(result.current.data).toBeDefined(), {
+        timeout: 5000,
+      });
+
+      expect(axiosGetSpy).toHaveBeenCalledWith(
+        '/_api/attachment-refs/refs',
+        expect.objectContaining({
+          params: expect.objectContaining({
+            pagePath: '/test-page',
+            prefix: undefined,
+            options: {},
+          }),
+        }),
+      );
+
+      expect(result.current.data).toBeDefined();
+      expect(result.current.error).toBeUndefined();
+
+      axiosGetSpy.mockRestore();
+    });
+
+    it('should make actual server request and receive attachments data for refs request with prefix', async () => {
+      const axiosGetSpy = setupAxiosSpy();
+
+      const { result } = renderHook(() =>
+        useSWRxRefs('', '/test-prefix', { depth: '2' }, false),
+      );
+
+      await waitFor(() => expect(result.current.data).toBeDefined(), {
+        timeout: 5000,
+      });
+
+      expect(axiosGetSpy).toHaveBeenCalledWith(
+        '/_api/attachment-refs/refs',
+        expect.objectContaining({
+          params: expect.objectContaining({
+            pagePath: '',
+            prefix: '/test-prefix',
+            options: { depth: '2' },
+          }),
+        }),
+      );
+
+      expect(result.current.data).toBeDefined();
+      expect(result.current.error).toBeUndefined();
+
+      axiosGetSpy.mockRestore();
+    });
+  });
+});

+ 4 - 3
packages/remark-attachment-refs/src/server/routes/refs.ts

@@ -179,9 +179,10 @@ export const routesFactory = (crowi): any => {
     async (req: RequestWithUser, res) => {
       const user = req.user;
       const { prefix, pagePath } = req.query;
-      const options: Record<string, string | undefined> = JSON.parse(
-        req.query.options?.toString() ?? '',
-      );
+      const options: Record<string, string | undefined> =
+        typeof req.query.options === 'string'
+          ? JSON.parse(req.query.options)
+          : (req.query.options ?? {});
 
       // check either 'prefix' or 'pagePath ' is specified
       if (prefix == null && pagePath == null) {

+ 1 - 0
packages/remark-attachment-refs/tsconfig.json

@@ -7,6 +7,7 @@
     "paths": {
       "~/*": ["./src/*"]
     },
+    "types": ["vitest/globals"],
 
     /* TODO: remove below flags for strict checking */
     "strict": false,

+ 16 - 0
packages/remark-attachment-refs/vitest.config.ts

@@ -0,0 +1,16 @@
+import tsconfigPaths from 'vite-tsconfig-paths';
+import { defineConfig } from 'vitest/config';
+
+export default defineConfig({
+  plugins: [tsconfigPaths()],
+  test: {
+    environment: 'node',
+    clearMocks: true,
+    globals: true,
+    environmentMatchGlobs: [
+      // Use jsdom for client-side tests
+      ['**/client/**/*.spec.ts', 'jsdom'],
+      ['**/client/**/*.test.ts', 'jsdom'],
+    ],
+  },
+});

+ 3 - 2
packages/remark-lsx/package.json

@@ -47,10 +47,11 @@
   "devDependencies": {
     "@types/express": "^4",
     "@types/hast": "^3.0.4",
-    "axios": "^0.24.0",
-    "is-absolute-url": "^4.0.1",
+    "axios": "^1.11.0",
     "hast-util-sanitize": "^5.0.1",
     "hast-util-select": "^6.0.2",
+    "is-absolute-url": "^4.0.1",
+    "jsdom": "^26.1.0",
     "unified": "^11.0.0",
     "unist-util-visit": "^5.0.0"
   },

+ 153 - 0
packages/remark-lsx/src/client/stores/lsx/lsx.spec.ts

@@ -0,0 +1,153 @@
+// tests for assuring axios request succeeds in version change
+
+import type { Server } from 'node:http';
+import { renderHook, waitFor } from '@testing-library/react';
+import axios from 'axios';
+import express from 'express';
+import lsxMiddleware from '../../../server';
+
+import { useSWRxLsx } from './lsx';
+
+// Mock the generateBaseQuery function
+vi.mock('../../../server/routes/list-pages/generate-base-query', () => ({
+  generateBaseQuery: vi.fn().mockResolvedValue({
+    query: {
+      skip: vi.fn().mockReturnThis(),
+      limit: vi.fn().mockReturnThis(),
+      sort: vi.fn().mockReturnThis(),
+      and: vi.fn().mockReturnThis(),
+      clone: vi.fn().mockReturnThis(),
+      count: vi.fn().mockResolvedValue(10),
+      exec: vi.fn().mockResolvedValue([]),
+    },
+    addConditionToListOnlyDescendants: vi.fn().mockReturnThis(),
+    addConditionToFilteringByViewerForList: vi.fn().mockReturnThis(),
+  }),
+}));
+
+// Mock mongoose model
+vi.mock('mongoose', () => ({
+  model: vi.fn().mockReturnValue({
+    find: vi.fn().mockReturnValue({
+      skip: vi.fn().mockReturnThis(),
+      limit: vi.fn().mockReturnThis(),
+      sort: vi.fn().mockReturnThis(),
+      and: vi.fn().mockReturnThis(),
+      clone: vi.fn().mockReturnThis(),
+      count: vi.fn().mockResolvedValue(10),
+      exec: vi.fn().mockResolvedValue([]),
+    }),
+    countDocuments: vi.fn().mockResolvedValue(0),
+    aggregate: vi.fn().mockResolvedValue([{ count: 5 }]),
+  }),
+}));
+
+const TEST_PORT = 3001;
+const TEST_SERVER_URL = `http://localhost:${TEST_PORT}`;
+
+describe('useSWRxLsx integration tests', () => {
+  let server: Server;
+  let app: express.Application;
+
+  // Helper function to setup axios spy
+  const setupAxiosSpy = () => {
+    const originalAxios = axios.create();
+    return vi.spyOn(axios, 'get').mockImplementation((url, config) => {
+      const fullUrl = url.startsWith('/_api')
+        ? `${TEST_SERVER_URL}${url}`
+        : url;
+      return originalAxios.get(fullUrl, config);
+    });
+  };
+
+  beforeAll(async () => {
+    // Create minimal Express app with just the LSX route
+    app = express();
+    app.use(express.json());
+    app.use(express.urlencoded({ extended: true }));
+
+    // Add CORS headers to prevent cross-origin issues
+    app.use((req, res, next) => {
+      res.header('Access-Control-Allow-Origin', '*');
+      res.header(
+        'Access-Control-Allow-Methods',
+        'GET, POST, PUT, DELETE, OPTIONS',
+      );
+      res.header(
+        'Access-Control-Allow-Headers',
+        'Origin, X-Requested-With, Content-Type, Accept, Authorization',
+      );
+      next();
+    });
+
+    // Mock minimal GROWI-like structure for the middleware
+    const mockCrowi = {
+      require: () => () => (req: any, res: any, next: any) => next(),
+      accessTokenParser: () => (req: any, res: any, next: any) => next(),
+    };
+
+    // Import and setup the LSX middleware
+    lsxMiddleware(mockCrowi, app);
+
+    // Start test server
+    return new Promise<void>((resolve) => {
+      server = app.listen(TEST_PORT, () => {
+        resolve();
+      });
+    });
+  });
+
+  afterAll(() => {
+    return new Promise<void>((resolve) => {
+      if (server) {
+        server.close(() => {
+          resolve();
+        });
+      } else {
+        resolve();
+      }
+    });
+  });
+
+  it('should make actual server request and receive 2xx response for basic lsx request', async () => {
+    const axiosGetSpy = setupAxiosSpy();
+
+    const { result } = renderHook(() =>
+      useSWRxLsx('/test-page', { depth: '1' }, false),
+    );
+
+    await waitFor(() => expect(result.current.data).toBeDefined(), {
+      timeout: 5000,
+    });
+
+    expect(axiosGetSpy).toHaveBeenCalledWith(
+      '/_api/lsx',
+      expect.objectContaining({
+        params: expect.objectContaining({
+          pagePath: '/test-page',
+          options: expect.objectContaining({ depth: '1' }),
+        }),
+      }),
+    );
+
+    expect(result.current.data).toBeDefined();
+    expect(result.current.error).toBeUndefined();
+
+    axiosGetSpy.mockRestore();
+  });
+
+  it('should handle server validation errors properly', async () => {
+    const axiosGetSpy = setupAxiosSpy();
+
+    const { result } = renderHook(() => useSWRxLsx('', {}, false));
+
+    await waitFor(() => expect(result.current.error).toBeDefined(), {
+      timeout: 5000,
+    });
+
+    expect(result.current.error).toBeDefined();
+    expect(result.current.data).toBeUndefined();
+
+    axiosGetSpy.mockRestore();
+  });
+});

+ 2 - 1
packages/remark-lsx/src/server/index.ts

@@ -19,7 +19,8 @@ const lsxValidator = [
     .optional()
     .customSanitizer((options) => {
       try {
-        const jsonData: LsxApiOptions = JSON.parse(options);
+        const jsonData: LsxApiOptions =
+          typeof options === 'string' ? JSON.parse(options) : options;
 
         for (const key in jsonData) {
           jsonData[key] = filterXSS.process(jsonData[key]);

+ 5 - 0
packages/remark-lsx/vitest.config.ts

@@ -7,5 +7,10 @@ export default defineConfig({
     environment: 'node',
     clearMocks: true,
     globals: true,
+    environmentMatchGlobs: [
+      // Use jsdom for client-side tests
+      ['**/client/**/*.spec.ts', 'jsdom'],
+      ['**/client/**/*.test.ts', 'jsdom'],
+    ],
   },
 });

+ 1 - 1
packages/slack/package.json

@@ -54,7 +54,7 @@
     "@types/bunyan": "^1.8.10",
     "@types/http-errors": "^2.0.3",
     "@types/url-join": "^4.0.2",
-    "axios": "^0.24.0",
+    "axios": "^1.11.0",
     "browser-bunyan": "^1.6.3",
     "bunyan": "^1.8.15",
     "crypto": "^1.0.1",

+ 332 - 44
pnpm-lock.yaml

@@ -37,6 +37,15 @@ importers:
       '@swc/helpers':
         specifier: ^0.5.11
         version: 0.5.15
+      '@testing-library/dom':
+        specifier: ^10.4.0
+        version: 10.4.0
+      '@testing-library/react':
+        specifier: ^16.0.1
+        version: 16.0.1(@testing-library/dom@10.4.0)(@types/react-dom@18.3.0)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)
+      '@testing-library/react-hooks':
+        specifier: ^8.0.1
+        version: 8.0.1(react-dom@18.2.0(react@18.2.0))(react@18.2.0)
       '@types/css-modules':
         specifier: ^1.0.2
         version: 1.0.2
@@ -186,7 +195,7 @@ importers:
         version: 5.0.1(typescript@5.0.4)(vite@5.4.20(@types/node@20.19.17)(sass@1.77.6)(terser@5.43.1))
       vitest:
         specifier: ^2.1.1
-        version: 2.1.1(@types/node@20.19.17)(@vitest/ui@2.1.1)(happy-dom@15.7.4)(sass@1.77.6)(terser@5.43.1)
+        version: 2.1.1(@types/node@20.19.17)(@vitest/ui@2.1.1)(happy-dom@15.7.4)(jsdom@26.1.0)(sass@1.77.6)(terser@5.43.1)
       vitest-mock-extended:
         specifier: ^2.0.2
         version: 2.0.2(typescript@5.0.4)(vitest@2.1.1)
@@ -323,8 +332,8 @@ importers:
         specifier: ^1.0.3
         version: 1.0.3
       axios:
-        specifier: ^0.24.0
-        version: 0.24.0
+        specifier: ^1.11.0
+        version: 1.11.0
       axios-retry:
         specifier: ^3.2.4
         version: 3.9.1
@@ -812,15 +821,9 @@ importers:
       '@swc/jest':
         specifier: ^0.2.36
         version: 0.2.36(@swc/core@1.10.7(@swc/helpers@0.5.15))
-      '@testing-library/dom':
-        specifier: ^10.4.0
-        version: 10.4.0
       '@testing-library/jest-dom':
         specifier: ^6.5.0
         version: 6.5.0
-      '@testing-library/react':
-        specifier: ^16.0.1
-        version: 16.0.1(@testing-library/dom@10.4.0)(@types/react-dom@18.3.0)(@types/react@18.3.3)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)
       '@testing-library/user-event':
         specifier: ^14.5.2
         version: 14.5.2(@testing-library/dom@10.4.0)
@@ -1079,9 +1082,6 @@ importers:
       '@tsed/terminus':
         specifier: '=8.5.0'
         version: 8.5.0(@godaddy/terminus@4.12.1)(@tsed/core@8.5.0)(@tsed/di@8.5.0(@tsed/core@8.5.0)(@tsed/hooks@8.5.0)(@tsed/logger@7.0.2)(@tsed/schema@8.5.0(@tsed/core@8.5.0)(@tsed/hooks@8.5.0)(@tsed/openspec@8.5.0)))(@tsed/platform-http@8.5.0(@tsed/engines@8.5.0)(@tsed/logger@7.0.2)(@tsed/openspec@8.5.0))(@tsed/schema@8.5.0(@tsed/core@8.5.0)(@tsed/hooks@8.5.0)(@tsed/openspec@8.5.0))
-      axios:
-        specifier: ^0.24.0
-        version: 0.24.0
       express:
         specifier: ^4.19.2
         version: 4.21.0
@@ -1159,8 +1159,8 @@ importers:
         specifier: '=6.43.0'
         version: 6.43.0(typeorm@0.2.45(mysql2@2.3.3)(redis@3.1.2))
       axios:
-        specifier: ^0.24.0
-        version: 0.24.0
+        specifier: ^1.11.0
+        version: 1.11.0
       body-parser:
         specifier: ^1.20.3
         version: 1.20.3
@@ -1433,8 +1433,8 @@ importers:
   packages/pdf-converter-client:
     dependencies:
       axios:
-        specifier: ^0.24.0
-        version: 0.24.0
+        specifier: ^1.11.0
+        version: 1.11.0
       tslib:
         specifier: ^2.8.0
         version: 2.8.1
@@ -1566,8 +1566,8 @@ importers:
         specifier: workspace:^
         version: link:../ui
       axios:
-        specifier: ^0.24.0
-        version: 0.24.0
+        specifier: ^1.11.0
+        version: 1.11.0
       bunyan:
         specifier: ^1.8.15
         version: 1.8.15
@@ -1602,15 +1602,30 @@ importers:
       '@types/hast':
         specifier: ^3.0.4
         version: 3.0.4
+      '@types/react':
+        specifier: ^18.2.14
+        version: 18.3.3
+      '@types/react-dom':
+        specifier: ^18.2.6
+        version: 18.3.0
+      '@types/supertest':
+        specifier: ^6.0.2
+        version: 6.0.3
       csstype:
         specifier: ^3.0.2
         version: 3.1.3
+      happy-dom:
+        specifier: ^15.7.4
+        version: 15.7.4
       hast-util-sanitize:
         specifier: ^5.0.1
         version: 5.0.1
       npm-run-all:
         specifier: ^4.1.5
         version: 4.1.5
+      supertest:
+        specifier: ^7.0.0
+        version: 7.1.4
       unified:
         specifier: ^11.0.0
         version: 11.0.5
@@ -1777,8 +1792,8 @@ importers:
         specifier: ^3.0.4
         version: 3.0.4
       axios:
-        specifier: ^0.24.0
-        version: 0.24.0
+        specifier: ^1.11.0
+        version: 1.11.0
       hast-util-sanitize:
         specifier: ^5.0.1
         version: 5.0.1
@@ -1788,6 +1803,9 @@ importers:
       is-absolute-url:
         specifier: ^4.0.1
         version: 4.0.1
+      jsdom:
+        specifier: ^26.1.0
+        version: 26.1.0
       unified:
         specifier: ^11.0.0
         version: 11.0.5
@@ -1813,8 +1831,8 @@ importers:
         specifier: ^4.0.2
         version: 4.0.3
       axios:
-        specifier: ^0.24.0
-        version: 0.24.0
+        specifier: ^1.11.0
+        version: 1.11.0
       browser-bunyan:
         specifier: ^1.6.3
         version: 1.8.0
@@ -1926,6 +1944,9 @@ packages:
     peerDependencies:
       openapi-types: '>=7'
 
+  '@asamuzakjp/css-color@3.2.0':
+    resolution: {integrity: sha512-K1A6z8tS3XsmCMM86xoWdn7Fkdn9m6RSVtocUrJYIwZnFVkng/PvkEoWtOWmP+Scc6saYWHWZYbndEEXxl24jw==}
+
   '@asyncapi/specs@4.3.1':
     resolution: {integrity: sha512-EfexhJu/lwF8OdQDm28NKLJHFkx0Gb6O+rcezhZYLPIoNYKXJMh2J1vFGpwmfAcTTh+ffK44Oc2Hs1Q4sLBp+A==}
 
@@ -2737,16 +2758,44 @@ packages:
     resolution: {integrity: sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==}
     engines: {node: '>=12'}
 
+  '@csstools/color-helpers@5.1.0':
+    resolution: {integrity: sha512-S11EXWJyy0Mz5SYvRmY8nJYTFFd1LCNV+7cXyAgQtOOuzb4EsgfqDufL+9esx72/eLhsRdGZwaldu/h+E4t4BA==}
+    engines: {node: '>=18'}
+
+  '@csstools/css-calc@2.1.4':
+    resolution: {integrity: sha512-3N8oaj+0juUw/1H3YwmDDJXCgTB1gKU6Hc/bB502u9zR0q2vd786XJH9QfrKIEgFlZmhZiq6epXl4rHqhzsIgQ==}
+    engines: {node: '>=18'}
+    peerDependencies:
+      '@csstools/css-parser-algorithms': ^3.0.5
+      '@csstools/css-tokenizer': ^3.0.4
+
+  '@csstools/css-color-parser@3.1.0':
+    resolution: {integrity: sha512-nbtKwh3a6xNVIp/VRuXV64yTKnb1IjTAEEh3irzS+HkKjAOYLTGNb9pmVNntZ8iVBHcWDA2Dof0QtPgFI1BaTA==}
+    engines: {node: '>=18'}
+    peerDependencies:
+      '@csstools/css-parser-algorithms': ^3.0.5
+      '@csstools/css-tokenizer': ^3.0.4
+
   '@csstools/css-parser-algorithms@2.6.3':
     resolution: {integrity: sha512-xI/tL2zxzEbESvnSxwFgwvy5HS00oCXxL4MLs6HUiDcYfwowsoQaABKxUElp1ARITrINzBnsECOc1q0eg2GOrA==}
     engines: {node: ^14 || ^16 || >=18}
     peerDependencies:
       '@csstools/css-tokenizer': ^2.3.1
 
+  '@csstools/css-parser-algorithms@3.0.5':
+    resolution: {integrity: sha512-DaDeUkXZKjdGhgYaHNJTV9pV7Y9B3b644jCLs9Upc3VeNGg6LWARAT6O+Q+/COo+2gg/bM5rhpMAtf70WqfBdQ==}
+    engines: {node: '>=18'}
+    peerDependencies:
+      '@csstools/css-tokenizer': ^3.0.4
+
   '@csstools/css-tokenizer@2.3.1':
     resolution: {integrity: sha512-iMNHTyxLbBlWIfGtabT157LH9DUx9X8+Y3oymFEuMj8HNc+rpE3dPFGFgHjpKfjeFDjLjYIAIhXPGvS2lKxL9g==}
     engines: {node: ^14 || ^16 || >=18}
 
+  '@csstools/css-tokenizer@3.0.4':
+    resolution: {integrity: sha512-Vd/9EVDiu6PPJt9yAh6roZP6El1xHrdvIVGjyBsHR0RYwNHgL7FJPyIIW4fANJNG6FtyZfvlRPpFI4ZM/lubvw==}
+    engines: {node: '>=18'}
+
   '@csstools/media-query-list-parser@2.1.11':
     resolution: {integrity: sha512-uox5MVhvNHqitPP+SynrB1o8oPxPMt2JLgp5ghJOWf54WGQ5OKu47efne49r1SWqs3wRP8xSWjnO9MBKxhB1dA==}
     engines: {node: ^14 || ^16 || >=18}
@@ -5247,6 +5296,22 @@ packages:
     resolution: {integrity: sha512-xGGHpBXYSHUUr6XsKBfs85TWlYKpTc37cSBBVrXcib2MkHLboWlkClhWF37JKlDb9KEq3dHs+f2xR7XJEWGBxA==}
     engines: {node: '>=14', npm: '>=6', yarn: '>=1'}
 
+  '@testing-library/react-hooks@8.0.1':
+    resolution: {integrity: sha512-Aqhl2IVmLt8IovEVarNDFuJDVWVvhnr9/GCU6UUnrYXwgDFF9h2L2o2P9KBni1AST5sT6riAyoukFLyjQUgD/g==}
+    engines: {node: '>=12'}
+    peerDependencies:
+      '@types/react': ^16.9.0 || ^17.0.0
+      react: ^16.9.0 || ^17.0.0
+      react-dom: ^16.9.0 || ^17.0.0
+      react-test-renderer: ^16.9.0 || ^17.0.0
+    peerDependenciesMeta:
+      '@types/react':
+        optional: true
+      react-dom:
+        optional: true
+      react-test-renderer:
+        optional: true
+
   '@testing-library/react@16.0.1':
     resolution: {integrity: sha512-dSmwJVtJXmku+iocRhWOUFbrERC76TX2Mnf0ATODz8brzAZrMBbzLwQixlBSanZxR6LddK3eiwpSFZgDET1URg==}
     engines: {node: '>=18'}
@@ -6271,6 +6336,10 @@ packages:
     resolution: {integrity: sha512-H0TSyFNDMomMNJQBn8wFV5YC/2eJ+VXECwOadZJT554xP6cODZHPX3H9QMQECxvrgiSOP1pHjy1sMWQVYJOUOA==}
     engines: {node: '>= 14'}
 
+  agent-base@7.1.4:
+    resolution: {integrity: sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==}
+    engines: {node: '>= 14'}
+
   agentkeepalive@4.5.0:
     resolution: {integrity: sha512-5GG/5IbQQpC9FpkRGsSvZI5QYeSCzlJHdpBQntCsuTOxhKD8lqKhrleg2Yi7yvMIf82Ycmmqln9U8V9qwEiJew==}
     engines: {node: '>= 8.0.0'}
@@ -6580,9 +6649,6 @@ packages:
   axios@0.21.4:
     resolution: {integrity: sha512-ut5vewkiu8jjGBdqpM44XxjuCjq9LAKeHVmoVfHVzy8eHgxxq8SbAVQNovDA8mVi05kP0Ea/n/UzcSHcTJQfNg==}
 
-  axios@0.24.0:
-    resolution: {integrity: sha512-Q6cWsys88HoPgAaFAVUb0WpPk0O8iTeisR9IMqy9G8AbO4NlpVknrnQS03zzF9PGAWgO3cgletO3VjV/P7VztA==}
-
   axios@0.26.1:
     resolution: {integrity: sha512-fPwcX4EvnSHuInCMItEhAGnaSEXRBjtzh9fOtsE6E1G6p7vl7edEeZe11QHf18+6+9gR5PbKV/sGKNaD8YaMeA==}
 
@@ -7860,6 +7926,10 @@ packages:
     resolution: {integrity: sha512-0LrrStPOdJj+SPCCrGhzryycLjwcgUSHBtxNA8aIDxf0GLsRh1cKYhB00Gd1lDOS4yGH69+SNn13+TWbVHETFQ==}
     engines: {node: ^10 || ^12.20.0 || ^14.13.0 || >=15.0.0, npm: '>=7.0.0'}
 
+  cssstyle@4.6.0:
+    resolution: {integrity: sha512-2z+rWdzbbSZv6/rhtvzvqeZQHrBaqgogqt85sqFNbabZOuFbCVFb8kPeEtZjiKkbrm395irpNKiYeFeLiQnFPg==}
+    engines: {node: '>=18'}
+
   csstype@3.1.3:
     resolution: {integrity: sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==}
 
@@ -8059,6 +8129,10 @@ packages:
     resolution: {integrity: sha512-7hvf7/GW8e86rW0ptuwS3OcBGDjIi6SZva7hCyWC0yYry2cOPmLIjXAUHI6DK2HsnwJd9ifmt57i8eV2n4YNpw==}
     engines: {node: '>= 14'}
 
+  data-urls@5.0.0:
+    resolution: {integrity: sha512-ZYP5VBHshaDAiVZxjbRVcFJpc+4xGgT0bK3vzy1HLN8jTO975HEbuYzZJcHoQEY5K1a0z8YayJkyVETa08eNTg==}
+    engines: {node: '>=18'}
+
   data-view-buffer@1.0.1:
     resolution: {integrity: sha512-0lht7OugA5x3iJLOWFhWK/5ehONdprk0ISXqVFn/NFrDu+cuc8iADFrGQz5BnRK7LLU3JmkbXSxaqX+/mXYtUA==}
     engines: {node: '>= 0.4'}
@@ -8163,6 +8237,9 @@ packages:
     resolution: {integrity: sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==}
     engines: {node: '>=0.10.0'}
 
+  decimal.js@10.6.0:
+    resolution: {integrity: sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==}
+
   decode-named-character-reference@1.0.2:
     resolution: {integrity: sha512-O8x12RzrUF8xyVcY0KJowWsmaJxQbmy0/EtnNtHRpsOcT7dFk5W598coHqBVpmWo1oQQfsCqfCmkZN5DJrZVdg==}
 
@@ -8529,6 +8606,10 @@ packages:
     resolution: {integrity: sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==}
     engines: {node: '>=0.12'}
 
+  entities@6.0.1:
+    resolution: {integrity: sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==}
+    engines: {node: '>=0.12'}
+
   env-paths@2.2.1:
     resolution: {integrity: sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==}
     engines: {node: '>=6'}
@@ -9674,6 +9755,10 @@ packages:
     resolution: {integrity: sha512-A91dYTeIB6NoXG+PxTQpCCDDnfHsW9kc06Lvpu1TEe9gnd6ZFeiBoRO9JvzEv6xK7EX97/dUE8g/vBMTqTS3CA==}
     engines: {node: '>=14'}
 
+  html-encoding-sniffer@4.0.0:
+    resolution: {integrity: sha512-Y22oTqIU4uuPgEemfz7NDJz6OeKf12Lsu+QC+s3BVpda64lTiMYCyGwg5ki4vFxkMwQdeZDl2adZoqUgdFuTgQ==}
+    engines: {node: '>=18'}
+
   html-escaper@2.0.2:
     resolution: {integrity: sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==}
 
@@ -9738,6 +9823,10 @@ packages:
     resolution: {integrity: sha512-1e4Wqeblerz+tMKPIq2EMGiiWW1dIjZOksyHWSUm1rmuvw/how9hBHZ38lAGj5ID4Ik6EdkOw7NmWPy6LAwalw==}
     engines: {node: '>= 14'}
 
+  https-proxy-agent@7.0.6:
+    resolution: {integrity: sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==}
+    engines: {node: '>= 14'}
+
   human-id@1.0.2:
     resolution: {integrity: sha512-UNopramDEhHJD+VR+ehk8rOslwSfByxPIZyJRfV739NDhN5LF1fa1MqnzKm2lGTQRjNrjK19Q5fhkgIfjlVUKw==}
 
@@ -10116,6 +10205,9 @@ packages:
     resolution: {integrity: sha512-VRSzKkbMm5jMDoKLbltAkFQ5Qr7VDiTFGXxYFXXowVj387GeGNOCsOH6Msy00SGZ3Fp84b1Naa1psqgcCIEP5Q==}
     engines: {node: '>=0.10.0'}
 
+  is-potential-custom-element-name@1.0.1:
+    resolution: {integrity: sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==}
+
   is-property@1.0.2:
     resolution: {integrity: sha512-Ks/IoX00TtClbGQr4TWXemAnktAQvYB7HzcCxDGqEZU6oCmb2INHuOoKxbtR+HFkmYWBKv/dOZtGRiAjDhj92g==}
 
@@ -10467,6 +10559,15 @@ packages:
   jsbn@1.1.0:
     resolution: {integrity: sha512-4bYVV3aAMtDTTu4+xsDYa6sy9GyJ69/amsu9sYF2zqjiEoZA5xJi3BrfX3uY+/IekIu7MwdObdbDWpoZdBv3/A==}
 
+  jsdom@26.1.0:
+    resolution: {integrity: sha512-Cvc9WUhxSMEo4McES3P7oK3QaXldCfNWp7pl2NNeiIFlCoLr3kfq9kb1fxftiwk1FLV7CvpvDfonxtzUDeSOPg==}
+    engines: {node: '>=18'}
+    peerDependencies:
+      canvas: ^3.0.0
+    peerDependenciesMeta:
+      canvas:
+        optional: true
+
   jsep@1.3.9:
     resolution: {integrity: sha512-i1rBX5N7VPl0eYb6+mHNp52sEuaS2Wi8CDYx1X5sn9naevL78+265XJqy1qENEk7mRKwS06NHpUqiBwR7qeodw==}
     engines: {node: '>= 10.16.0'}
@@ -11885,6 +11986,9 @@ packages:
   numbro@2.5.0:
     resolution: {integrity: sha512-xDcctDimhzko/e+y+Q2/8i3qNC9Svw1QgOkSkQoO0kIPI473tR9QRbo2KP88Ty9p8WbPy+3OpTaAIzehtuHq+A==}
 
+  nwsapi@2.2.22:
+    resolution: {integrity: sha512-ujSMe1OWVn55euT1ihwCI1ZcAaAU3nxUiDwfDQldc51ZXaB9m2AyOn6/jh1BLe2t/G8xd6uKG1UBF2aZJeg2SQ==}
+
   oas-kit-common@1.0.8:
     resolution: {integrity: sha512-pJTS2+T0oGIwgjGpw7sIRU8RQMcUoKCDWFLdBqKB2BNmGpbBMH2sdqAaOXUg8OzonZHU0L7vfJu1mJFEiYDWOQ==}
 
@@ -12179,6 +12283,9 @@ packages:
   parse5@7.1.2:
     resolution: {integrity: sha512-Czj1WaSVpaoj0wbhMzLmWD69anp2WH7FXMB9n1Sy8/ZFF9jolSQVMu1Ij5WIyGmcBmhk7EOndpO4mIpihVqAXw==}
 
+  parse5@7.3.0:
+    resolution: {integrity: sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==}
+
   parseurl@1.3.3:
     resolution: {integrity: sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==}
     engines: {node: '>= 0.8'}
@@ -13294,6 +13401,9 @@ packages:
   roughjs@4.6.6:
     resolution: {integrity: sha512-ZUz/69+SYpFN/g/lUlo2FXcIjRkSu3nDarreVdGGndHEBJ6cXPdKguS8JGxwj5HA5xIbVKSmLgr5b3AWxtRfvQ==}
 
+  rrweb-cssom@0.8.0:
+    resolution: {integrity: sha512-guoltQEx+9aMf2gDZ0s62EcV8lsXR+0w8915TC3ITdn2YueuNjdAYh/levpU9nFaoChh9RUS5ZdQMrKfVEN9tw==}
+
   run-async@2.4.1:
     resolution: {integrity: sha512-tvVnVv01b8c1RrA6Ep7JkStj85Guv/YrMcwqYQnwjsAS2cTmmPGBBjAjpCW7RrSodNSoE2/qg9O4bceNvUuDgQ==}
     engines: {node: '>=0.12.0'}
@@ -13355,6 +13465,10 @@ packages:
   sax@1.3.0:
     resolution: {integrity: sha512-0s+oAmw9zLl1V1cS9BtZN7JAd0cW5e0QH4W3LWEK6a4LaLEA2OTpGYWDY+6XasBLtz6wkm3u1xRw95mRuJ59WA==}
 
+  saxes@6.0.0:
+    resolution: {integrity: sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==}
+    engines: {node: '>=v12.22.7'}
+
   scheduler@0.23.0:
     resolution: {integrity: sha512-CtuThmgHNg7zIZWAXi3AsyIzA3n4xx7aNyjwC2VJldO2LMVDhFK+63xGqq6CsJH4rTAt6/M+N4GhZiDYPx9eUw==}
 
@@ -14073,6 +14187,9 @@ packages:
     peerDependencies:
       react: ^16.11.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
 
+  symbol-tree@3.2.4:
+    resolution: {integrity: sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==}
+
   synckit@0.7.2:
     resolution: {integrity: sha512-CSZRtSRZ8RhJGMtWyLRqlarmWPPlsgZJHtV6cz0VTHNOg+R7UBoE2eNPQmB5Qrhtk3RX2AAcJmVwMXFULVQSwg==}
     engines: {node: ^12.20.0 || ^14.18.0 || >=16.0.0}
@@ -14200,6 +14317,13 @@ packages:
     resolution: {integrity: sha512-n1cw8k1k0x4pgA2+9XrOkFydTerNcJ1zWCO5Nn9scWHTD+5tp8dghT2x1uduQePZTZgd3Tupf+x9BxJjeJi77Q==}
     engines: {node: '>=14.0.0'}
 
+  tldts-core@6.1.86:
+    resolution: {integrity: sha512-Je6p7pkk+KMzMv2XXKmAE3McmolOQFdxkKw0R8EYNr7sELW46JqnNeTX8ybPiQgvg1ymCoF8LXs5fzFaZvJPTA==}
+
+  tldts@6.1.86:
+    resolution: {integrity: sha512-WMi/OQ2axVTf/ykqCQgXiIct+mSQDFdH2fkwhPwgEwvJ1kSzZRiinb0zF2Xb8u4+OqPChmyI6MEu4EezNJz+FQ==}
+    hasBin: true
+
   tmp@0.0.33:
     resolution: {integrity: sha512-jRCJlojKnZ3addtTOjdIqoRuPEKBvNXcGYqzO6zWZX8KfKEpnGY5jfggJQ3EjKuu8D4bJRr0y+cYJFmYbImXGw==}
     engines: {node: '>=0.6.0'}
@@ -14249,6 +14373,10 @@ packages:
     resolution: {integrity: sha512-nlLsUzgm1kfLXSXfRZMc1KLAugd4hqJHDTvc2hDIwS3mZAfMEuMbc03SujMF+GEcpaX/qboeycw6iO8JwVv2+g==}
     engines: {node: '>=0.8'}
 
+  tough-cookie@5.1.2:
+    resolution: {integrity: sha512-FVDYdxtnj0G6Qm/DhNPSb8Ju59ULcup3tuJxkFb5K8Bv2pUXILbf0xZWU8PX8Ov19OXljbUyveOFwRMwkXzO+A==}
+    engines: {node: '>=16'}
+
   tr46@0.0.3:
     resolution: {integrity: sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==}
 
@@ -14260,6 +14388,10 @@ packages:
     resolution: {integrity: sha512-2lv/66T7e5yNyhAAC4NaKe5nVavzuGJQVVtRYLyQ2OI8tsJ61PMLlelehb0wi2Hx6+hT/OJUWZcw8MjlSRnxvw==}
     engines: {node: '>=14'}
 
+  tr46@5.1.1:
+    resolution: {integrity: sha512-hdF5ZgjTqgAntKkklYw0R03MG2x/bSzTtkxmIRw/sTNV8YXsCJ1tfLAX23lhxhHJlEf3CRCOCGGWw3vI3GaSPw==}
+    engines: {node: '>=18'}
+
   traverse@0.3.9:
     resolution: {integrity: sha512-iawgk0hLP3SxGKDfnDJf8wTz4p2qImnyihM5Hh/sGvQ3K37dPi/w8sRhdNIxYA1TwFwc5mDhIJq+O0RsvXBKdQ==}
 
@@ -15075,6 +15207,10 @@ packages:
   w3c-keyname@2.2.8:
     resolution: {integrity: sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==}
 
+  w3c-xmlserializer@5.0.0:
+    resolution: {integrity: sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==}
+    engines: {node: '>=18'}
+
   walker@1.0.8:
     resolution: {integrity: sha512-ts/8E8l5b7kY0vlWLewOkDXMmPdLcVV4GmOQLyxuSswIJsweeFZtAsMF7k1Nszz+TYBQrlYRmzOnr398y1JemQ==}
 
@@ -15124,10 +15260,18 @@ packages:
       webpack-cli:
         optional: true
 
+  whatwg-encoding@3.1.1:
+    resolution: {integrity: sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ==}
+    engines: {node: '>=18'}
+
   whatwg-mimetype@3.0.0:
     resolution: {integrity: sha512-nt+N2dzIutVRxARx1nghPKGv1xHikU7HKdfafKkLNLindmPU/ch3U31NOCGGA/dmPcmb1VlofO0vnKAcsm0o/Q==}
     engines: {node: '>=12'}
 
+  whatwg-mimetype@4.0.0:
+    resolution: {integrity: sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==}
+    engines: {node: '>=18'}
+
   whatwg-url@11.0.0:
     resolution: {integrity: sha512-RKT8HExMpoYx4igMiVMY83lN6UeITKJlBQ+vR/8ZJ8OCdSiN3RwCq+9gH0+Xzj0+5IrM6i4j/6LuvzbZIQgEcQ==}
     engines: {node: '>=12'}
@@ -15136,6 +15280,10 @@ packages:
     resolution: {integrity: sha512-9WWbymnqj57+XEuqADHrCJ2eSXzn8WXIW/YSGaZtb2WKAInQ6CHfaUUcTyyver0p8BDg5StLQq8h1vtZuwmOig==}
     engines: {node: '>=16'}
 
+  whatwg-url@14.2.0:
+    resolution: {integrity: sha512-De72GdQZzNTUBBChsXueQUnPKDkg/5A5zp7pFDuQAj5UFoENpiACU0wlCvzpAGnTkj++ihpKwKyYewn/XNUbKw==}
+    engines: {node: '>=18'}
+
   whatwg-url@5.0.0:
     resolution: {integrity: sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==}
 
@@ -15286,6 +15434,10 @@ packages:
     resolution: {integrity: sha512-4Av83DdvAgUQQMfi/w8G01aJshbEZP9ewjmZMpS9t3H+OCZBDvyK4GJPnHGfWiXlArnPbYvR58JB9qF2x9Ds+Q==}
     engines: {node: '>=12'}
 
+  xml-name-validator@5.0.0:
+    resolution: {integrity: sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==}
+    engines: {node: '>=18'}
+
   xml2js@0.4.19:
     resolution: {integrity: sha512-esZnJZJOiJR9wWKMyuvSE1y6Dq5LCuJanqhxslH2bxM6duahNZ+HMpCLhBQGZkbX6xRf8x1Y2eJlgt2q3qo49Q==}
 
@@ -15305,6 +15457,9 @@ packages:
     resolution: {integrity: sha512-7YXTQc3P2l9+0rjaUbLwMKRhtmwg1M1eDf6nag7urC7pIPYLD9W/jmzQ4ptRSUbodw5S0jfoGTflLemQibSpeQ==}
     engines: {node: '>=4.0'}
 
+  xmlchars@2.2.0:
+    resolution: {integrity: sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==}
+
   xmldom-sre@0.1.31:
     resolution: {integrity: sha512-f9s+fUkX04BxQf+7mMWAp5zk61pciie+fFLC9hX9UVvCeJQfNHRHXpeo5MPcR0EUf57PYLdt+ZO4f3Ipk2oZUw==}
     engines: {node: '>=0.1'}
@@ -15536,6 +15691,14 @@ snapshots:
       call-me-maybe: 1.0.2
       openapi-types: 12.1.3
 
+  '@asamuzakjp/css-color@3.2.0':
+    dependencies:
+      '@csstools/css-calc': 2.1.4(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4)
+      '@csstools/css-color-parser': 3.1.0(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4)
+      '@csstools/css-parser-algorithms': 3.0.5(@csstools/css-tokenizer@3.0.4)
+      '@csstools/css-tokenizer': 3.0.4
+      lru-cache: 10.4.3
+
   '@asyncapi/specs@4.3.1':
     dependencies:
       '@types/json-schema': 7.0.15
@@ -17321,12 +17484,32 @@ snapshots:
     dependencies:
       '@jridgewell/trace-mapping': 0.3.9
 
+  '@csstools/color-helpers@5.1.0': {}
+
+  '@csstools/css-calc@2.1.4(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4)':
+    dependencies:
+      '@csstools/css-parser-algorithms': 3.0.5(@csstools/css-tokenizer@3.0.4)
+      '@csstools/css-tokenizer': 3.0.4
+
+  '@csstools/css-color-parser@3.1.0(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4)':
+    dependencies:
+      '@csstools/color-helpers': 5.1.0
+      '@csstools/css-calc': 2.1.4(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4)
+      '@csstools/css-parser-algorithms': 3.0.5(@csstools/css-tokenizer@3.0.4)
+      '@csstools/css-tokenizer': 3.0.4
+
   '@csstools/css-parser-algorithms@2.6.3(@csstools/css-tokenizer@2.3.1)':
     dependencies:
       '@csstools/css-tokenizer': 2.3.1
 
+  '@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4)':
+    dependencies:
+      '@csstools/css-tokenizer': 3.0.4
+
   '@csstools/css-tokenizer@2.3.1': {}
 
+  '@csstools/css-tokenizer@3.0.4': {}
+
   '@csstools/media-query-list-parser@2.1.11(@csstools/css-parser-algorithms@2.6.3(@csstools/css-tokenizer@2.3.1))(@csstools/css-tokenizer@2.3.1)':
     dependencies:
       '@csstools/css-parser-algorithms': 2.6.3(@csstools/css-tokenizer@2.3.1)
@@ -20579,14 +20762,21 @@ snapshots:
       lodash: 4.17.21
       redent: 3.0.0
 
-  '@testing-library/react@16.0.1(@testing-library/dom@10.4.0)(@types/react-dom@18.3.0)(@types/react@18.3.3)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)':
+  '@testing-library/react-hooks@8.0.1(react-dom@18.2.0(react@18.2.0))(react@18.2.0)':
+    dependencies:
+      '@babel/runtime': 7.25.4
+      react: 18.2.0
+      react-error-boundary: 3.1.4(react@18.2.0)
+    optionalDependencies:
+      react-dom: 18.2.0(react@18.2.0)
+
+  '@testing-library/react@16.0.1(@testing-library/dom@10.4.0)(@types/react-dom@18.3.0)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)':
     dependencies:
       '@babel/runtime': 7.25.4
       '@testing-library/dom': 10.4.0
       react: 18.2.0
       react-dom: 18.2.0(react@18.2.0)
     optionalDependencies:
-      '@types/react': 18.3.3
       '@types/react-dom': 18.3.0
 
   '@testing-library/user-event@14.5.2(@testing-library/dom@10.4.0)':
@@ -21822,7 +22012,7 @@ snapshots:
       std-env: 3.7.0
       test-exclude: 7.0.1
       tinyrainbow: 1.2.0
-      vitest: 2.1.1(@types/node@20.19.17)(@vitest/ui@2.1.1)(happy-dom@15.7.4)(sass@1.77.6)(terser@5.43.1)
+      vitest: 2.1.1(@types/node@20.19.17)(@vitest/ui@2.1.1)(happy-dom@15.7.4)(jsdom@26.1.0)(sass@1.77.6)(terser@5.43.1)
     transitivePeerDependencies:
       - supports-color
 
@@ -21869,7 +22059,7 @@ snapshots:
       sirv: 2.0.4
       tinyglobby: 0.2.6
       tinyrainbow: 1.2.0
-      vitest: 2.1.1(@types/node@20.19.17)(@vitest/ui@2.1.1)(happy-dom@15.7.4)(sass@1.77.6)(terser@5.43.1)
+      vitest: 2.1.1(@types/node@20.19.17)(@vitest/ui@2.1.1)(happy-dom@15.7.4)(jsdom@26.1.0)(sass@1.77.6)(terser@5.43.1)
 
   '@vitest/utils@2.1.1':
     dependencies:
@@ -22087,7 +22277,7 @@ snapshots:
 
   agent-base@6.0.2:
     dependencies:
-      debug: 4.4.0(supports-color@5.5.0)
+      debug: 4.4.1
     transitivePeerDependencies:
       - supports-color
 
@@ -22103,6 +22293,8 @@ snapshots:
     transitivePeerDependencies:
       - supports-color
 
+  agent-base@7.1.4: {}
+
   agentkeepalive@4.5.0:
     dependencies:
       humanize-ms: 1.2.1
@@ -22451,12 +22643,6 @@ snapshots:
     transitivePeerDependencies:
       - debug
 
-  axios@0.24.0:
-    dependencies:
-      follow-redirects: 1.15.9(debug@4.4.0)
-    transitivePeerDependencies:
-      - debug
-
   axios@0.26.1:
     dependencies:
       follow-redirects: 1.15.9(debug@4.4.0)
@@ -23624,6 +23810,11 @@ snapshots:
     dependencies:
       css-tree: 2.2.1
 
+  cssstyle@4.6.0:
+    dependencies:
+      '@asamuzakjp/css-color': 3.2.0
+      rrweb-cssom: 0.8.0
+
   csstype@3.1.3: {}
 
   csurf@1.11.0:
@@ -23846,6 +24037,11 @@ snapshots:
 
   data-uri-to-buffer@6.0.2: {}
 
+  data-urls@5.0.0:
+    dependencies:
+      whatwg-mimetype: 4.0.0
+      whatwg-url: 14.2.0
+
   data-view-buffer@1.0.1:
     dependencies:
       call-bind: 1.0.7
@@ -23923,6 +24119,8 @@ snapshots:
 
   decamelize@1.2.0: {}
 
+  decimal.js@10.6.0: {}
+
   decode-named-character-reference@1.0.2:
     dependencies:
       character-entities: 2.0.2
@@ -24284,6 +24482,8 @@ snapshots:
 
   entities@4.5.0: {}
 
+  entities@6.0.1: {}
+
   env-paths@2.2.1: {}
 
   environment@1.1.0: {}
@@ -25387,7 +25587,7 @@ snapshots:
     dependencies:
       basic-ftp: 5.0.5
       data-uri-to-buffer: 6.0.2
-      debug: 4.4.0(supports-color@5.5.0)
+      debug: 4.4.1
       fs-extra: 11.2.0
     transitivePeerDependencies:
       - supports-color
@@ -25872,6 +26072,10 @@ snapshots:
 
   hpagent@1.2.0: {}
 
+  html-encoding-sniffer@4.0.0:
+    dependencies:
+      whatwg-encoding: 3.1.1
+
   html-escaper@2.0.2: {}
 
   html-parse-stringify@3.0.1:
@@ -25929,7 +26133,7 @@ snapshots:
   http-proxy-agent@7.0.2:
     dependencies:
       agent-base: 7.1.1
-      debug: 4.4.0(supports-color@5.5.0)
+      debug: 4.4.1
     transitivePeerDependencies:
       - supports-color
 
@@ -25970,6 +26174,13 @@ snapshots:
     transitivePeerDependencies:
       - supports-color
 
+  https-proxy-agent@7.0.6:
+    dependencies:
+      agent-base: 7.1.4
+      debug: 4.4.1
+    transitivePeerDependencies:
+      - supports-color
+
   human-id@1.0.2: {}
 
   human-signals@2.1.0: {}
@@ -26301,6 +26512,8 @@ snapshots:
 
   is-plain-object@5.0.0: {}
 
+  is-potential-custom-element-name@1.0.1: {}
+
   is-property@1.0.2: {}
 
   is-regex@1.1.4:
@@ -26406,7 +26619,7 @@ snapshots:
 
   istanbul-lib-source-maps@4.0.1:
     dependencies:
-      debug: 4.4.0(supports-color@5.5.0)
+      debug: 4.4.1
       istanbul-lib-coverage: 3.2.2
       source-map: 0.6.1
     transitivePeerDependencies:
@@ -26818,6 +27031,33 @@ snapshots:
 
   jsbn@1.1.0: {}
 
+  jsdom@26.1.0:
+    dependencies:
+      cssstyle: 4.6.0
+      data-urls: 5.0.0
+      decimal.js: 10.6.0
+      html-encoding-sniffer: 4.0.0
+      http-proxy-agent: 7.0.2
+      https-proxy-agent: 7.0.6
+      is-potential-custom-element-name: 1.0.1
+      nwsapi: 2.2.22
+      parse5: 7.3.0
+      rrweb-cssom: 0.8.0
+      saxes: 6.0.0
+      symbol-tree: 3.2.4
+      tough-cookie: 5.1.2
+      w3c-xmlserializer: 5.0.0
+      webidl-conversions: 7.0.0
+      whatwg-encoding: 3.1.1
+      whatwg-mimetype: 4.0.0
+      whatwg-url: 14.2.0
+      ws: 8.18.0
+      xml-name-validator: 5.0.0
+    transitivePeerDependencies:
+      - bufferutil
+      - supports-color
+      - utf-8-validate
+
   jsep@1.3.9: {}
 
   jsesc@2.5.2: {}
@@ -28621,6 +28861,8 @@ snapshots:
     dependencies:
       bignumber.js: 9.1.2
 
+  nwsapi@2.2.22: {}
+
   oas-kit-common@1.0.8:
     dependencies:
       fast-safe-stringify: 2.1.1
@@ -28915,7 +29157,7 @@ snapshots:
     dependencies:
       '@tootallnate/quickjs-emscripten': 0.23.0
       agent-base: 7.1.1
-      debug: 4.4.0(supports-color@5.5.0)
+      debug: 4.4.1
       get-uri: 6.0.3
       http-proxy-agent: 7.0.2
       https-proxy-agent: 7.0.5
@@ -29012,6 +29254,10 @@ snapshots:
     dependencies:
       entities: 4.5.0
 
+  parse5@7.3.0:
+    dependencies:
+      entities: 6.0.1
+
   parseurl@1.3.3: {}
 
   pascal-case@3.1.2:
@@ -30347,6 +30593,8 @@ snapshots:
       points-on-curve: 0.2.0
       points-on-path: 0.2.1
 
+  rrweb-cssom@0.8.0: {}
+
   run-async@2.4.1: {}
 
   run-async@3.0.0: {}
@@ -30405,6 +30653,10 @@ snapshots:
 
   sax@1.3.0: {}
 
+  saxes@6.0.0:
+    dependencies:
+      xmlchars: 2.2.0
+
   scheduler@0.23.0:
     dependencies:
       loose-envify: 1.4.0
@@ -30751,7 +31003,7 @@ snapshots:
   socks-proxy-agent@7.0.0:
     dependencies:
       agent-base: 6.0.2
-      debug: 4.4.0(supports-color@5.5.0)
+      debug: 4.4.1
       socks: 2.8.3
     transitivePeerDependencies:
       - supports-color
@@ -30759,7 +31011,7 @@ snapshots:
   socks-proxy-agent@8.0.4:
     dependencies:
       agent-base: 7.1.1
-      debug: 4.4.0(supports-color@5.5.0)
+      debug: 4.4.1
       socks: 2.8.3
     transitivePeerDependencies:
       - supports-color
@@ -31318,6 +31570,8 @@ snapshots:
       react: 18.2.0
       use-sync-external-store: 1.4.0(react@18.2.0)
 
+  symbol-tree@3.2.4: {}
+
   synckit@0.7.2:
     dependencies:
       '@pkgr/utils': 2.3.0
@@ -31469,6 +31723,12 @@ snapshots:
 
   tinyspy@3.0.2: {}
 
+  tldts-core@6.1.86: {}
+
+  tldts@6.1.86:
+    dependencies:
+      tldts-core: 6.1.86
+
   tmp@0.0.33:
     dependencies:
       os-tmpdir: 1.0.2
@@ -31509,6 +31769,10 @@ snapshots:
       psl: 1.9.0
       punycode: 2.3.1
 
+  tough-cookie@5.1.2:
+    dependencies:
+      tldts: 6.1.86
+
   tr46@0.0.3: {}
 
   tr46@3.0.0:
@@ -31519,6 +31783,10 @@ snapshots:
     dependencies:
       punycode: 2.3.1
 
+  tr46@5.1.1:
+    dependencies:
+      punycode: 2.3.1
+
   traverse@0.3.9: {}
 
   trim-lines@3.0.1: {}
@@ -32265,9 +32533,9 @@ snapshots:
     dependencies:
       ts-essentials: 10.0.2(typescript@5.0.4)
       typescript: 5.0.4
-      vitest: 2.1.1(@types/node@20.19.17)(@vitest/ui@2.1.1)(happy-dom@15.7.4)(sass@1.77.6)(terser@5.43.1)
+      vitest: 2.1.1(@types/node@20.19.17)(@vitest/ui@2.1.1)(happy-dom@15.7.4)(jsdom@26.1.0)(sass@1.77.6)(terser@5.43.1)
 
-  vitest@2.1.1(@types/node@20.19.17)(@vitest/ui@2.1.1)(happy-dom@15.7.4)(sass@1.77.6)(terser@5.43.1):
+  vitest@2.1.1(@types/node@20.19.17)(@vitest/ui@2.1.1)(happy-dom@15.7.4)(jsdom@26.1.0)(sass@1.77.6)(terser@5.43.1):
     dependencies:
       '@vitest/expect': 2.1.1
       '@vitest/mocker': 2.1.1(@vitest/spy@2.1.1)(vite@5.4.20(@types/node@20.19.17)(sass@1.77.6)(terser@5.43.1))
@@ -32292,6 +32560,7 @@ snapshots:
       '@types/node': 20.19.17
       '@vitest/ui': 2.1.1(vitest@2.1.1)
       happy-dom: 15.7.4
+      jsdom: 26.1.0
     transitivePeerDependencies:
       - less
       - lightningcss
@@ -32356,6 +32625,10 @@ snapshots:
 
   w3c-keyname@2.2.8: {}
 
+  w3c-xmlserializer@5.0.0:
+    dependencies:
+      xml-name-validator: 5.0.0
+
   walker@1.0.8:
     dependencies:
       makeerror: 1.0.12
@@ -32435,8 +32708,14 @@ snapshots:
       - esbuild
       - uglify-js
 
+  whatwg-encoding@3.1.1:
+    dependencies:
+      iconv-lite: 0.6.3
+
   whatwg-mimetype@3.0.0: {}
 
+  whatwg-mimetype@4.0.0: {}
+
   whatwg-url@11.0.0:
     dependencies:
       tr46: 3.0.0
@@ -32447,6 +32726,11 @@ snapshots:
       tr46: 4.1.1
       webidl-conversions: 7.0.0
 
+  whatwg-url@14.2.0:
+    dependencies:
+      tr46: 5.1.1
+      webidl-conversions: 7.0.0
+
   whatwg-url@5.0.0:
     dependencies:
       tr46: 0.0.3
@@ -32580,6 +32864,8 @@ snapshots:
       escape-html: 1.0.3
       xpath: 0.0.32
 
+  xml-name-validator@5.0.0: {}
+
   xml2js@0.4.19:
     dependencies:
       sax: 1.3.0
@@ -32596,6 +32882,8 @@ snapshots:
 
   xmlbuilder@9.0.7: {}
 
+  xmlchars@2.2.0: {}
+
   xmldom-sre@0.1.31: {}
 
   xmlhttprequest-ssl@2.1.2: {}