Jelajahi Sumber

Merge pull request #11247 from growilabs/fix/index-rebuild-fails-on-boot

fix: Index rebuild fails on boot when initializing a Crowi instance takes a long time
mergify[bot] 4 hari lalu
induk
melakukan
8a2e70187d

+ 1 - 1
.github/workflows/ci-app-prod.yml

@@ -52,7 +52,7 @@ jobs:
   #     SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }}
 
   test-prod-node24:
-    uses: growilabs/growi/.github/workflows/reusable-app-prod.yml@master
+    uses: ./.github/workflows/reusable-app-prod.yml
     if: |
       ( github.event_name == 'push'
         || github.base_ref == 'master'

+ 84 - 15
.github/workflows/ci-app.yml

@@ -47,7 +47,7 @@ jobs:
         node-version: [24.x]
 
     steps:
-      - uses: actions/checkout@v4
+      - uses: actions/checkout@v6
 
       - uses: pnpm/action-setup@v6
 
@@ -102,16 +102,18 @@ jobs:
           - 27017/tcp
 
     steps:
-      - uses: actions/checkout@v4
+      - uses: actions/checkout@v6
 
       - uses: pnpm/action-setup@v6
 
-      - uses: actions/setup-node@v6
+      - &setup-node-step
+        uses: actions/setup-node@v6
         with:
           node-version: ${{ matrix.node-version }}
           cache: 'pnpm'
 
-      - name: Cache/Restore dist
+      - &cache-restore-dist-step
+        name: Cache/Restore dist
         uses: actions/cache@v4
         with:
           path: |
@@ -122,24 +124,22 @@ jobs:
           restore-keys: |
             dist-ci-app-${{ runner.OS }}-node${{ matrix.node-version }}-
 
-      - name: Install dependencies
+      - &install-dependencies-step
+        name: Install dependencies
         run: |
           pnpm add turbo --global
           pnpm install --frozen-lockfile
 
-      - name: Test
+      - name: Test (app - unit & component)
         run: |
-          turbo run test --filter=@growi/app --filter=./packages/* --env-mode=loose
+          turbo run test:unit test:components --filter=@growi/app --env-mode=loose
         env:
           MONGO_URI: mongodb://localhost:${{ job.services.mongodb.ports['27017'] }}/growi_test
 
-      - name: Upload coverage report as artifact
-        uses: actions/upload-artifact@v4
-        with:
-          name: coverage-mongo${{ matrix.mongodb-version }}
-          path: |
-            apps/app/coverage
-            packages/remark-growi-directive/coverage
+      - name: Test (packages)
+        run: |
+          turbo run test --filter=./packages/* --env-mode=loose
+
 
       - name: Slack Notification
         uses: weseek/ghaction-slack-notification@master
@@ -152,6 +152,75 @@ jobs:
           url: ${{ secrets.SLACK_WEBHOOK_URL }}
 
 
+  ci-app-test-integration:
+    runs-on: ubuntu-latest
+
+    strategy:
+      matrix:
+        node-version: [24.x]
+        mongodb-version: ['8.0']
+        elasticsearch-version:
+          - env: 8
+            stack-version: '8.19.16'
+          - env: 9
+            stack-version: '9.3.3'
+
+    # [MEMO] Elasticsearch is started in a step to install plugins before starting it.
+    services:
+      mongodb:
+        image: mongo:${{ matrix.mongodb-version }}
+        ports:
+          - 27017/tcp
+
+    steps:
+      - uses: actions/checkout@v6
+
+      - uses: pnpm/action-setup@v6
+
+      - *setup-node-step
+
+      - *cache-restore-dist-step
+
+      - *install-dependencies-step
+
+      - name: Start Elasticsearch with plugins
+        uses: elastic/elastic-github-actions/elasticsearch@master
+        with:
+          stack-version: ${{ matrix.elasticsearch-version.stack-version }}
+          plugins: |
+            analysis-kuromoji
+            analysis-icu
+          security-enabled: false
+
+      - name: Wait for Elasticsearch to be ready
+        run: |
+          curl \
+            --no-progress-meter \
+            -X GET \
+            --retry 60 \
+            --retry-delay 1 \
+            --retry-connrefused \
+            http://localhost:9200/_cluster/health?wait_for_status=green
+
+      - name: Test
+        run: |
+          turbo run test:integ --filter=@growi/app --env-mode=loose
+        env:
+          MONGO_URI: mongodb://localhost:${{ job.services.mongodb.ports['27017'] }}/growi_test
+          VITE_ELASTICSEARCH_URI: http://localhost:9200/growi
+          VITE_ELASTICSEARCH_VERSION: ${{ matrix.elasticsearch-version.env }}
+
+      - name: Slack Notification
+        uses: weseek/ghaction-slack-notification@master
+        if: failure()
+        with:
+          type: ${{ job.status }}
+          job_name: '*Node CI for growi - test-es (${{ matrix.node-version }})*'
+          channel: '#ci'
+          isCompactMode: true
+          url: ${{ secrets.SLACK_WEBHOOK_URL }}
+
+
   ci-app-launch-dev:
     runs-on: ubuntu-latest
 
@@ -167,7 +236,7 @@ jobs:
           - 27017/tcp
 
     steps:
-      - uses: actions/checkout@v4
+      - uses: actions/checkout@v6
 
       - uses: pnpm/action-setup@v6
 

+ 129 - 35
.github/workflows/reusable-app-prod.yml

@@ -127,14 +127,6 @@ jobs:
         image: mongo:${{ matrix.mongodb-version }}
         ports:
         - 27017/tcp
-      elasticsearch:
-        image: docker.elastic.co/elasticsearch/elasticsearch:9.0.1
-        ports:
-        - 9200/tcp
-        env:
-          discovery.type: single-node
-          # ES 9.x enables security (HTTPS + auth) by default; disable for plaintext CI access
-          xpack.security.enabled: false
 
     steps:
     - uses: actions/setup-node@v6
@@ -153,6 +145,25 @@ jobs:
     # Run after extraction so pnpm/action-setup@v6 can read packageManager from package.json
     - uses: pnpm/action-setup@v6
 
+    - name: Start Elasticsearch with plugins
+      uses: elastic/elastic-github-actions/elasticsearch@master
+      with:
+        stack-version: 9.3.3
+        plugins: |
+          analysis-kuromoji
+          analysis-icu
+        security-enabled: false
+
+    - name: Wait for Elasticsearch to be ready
+      run: |
+        curl \
+          --no-progress-meter \
+          -X GET \
+          --retry 60 \
+          --retry-delay 1 \
+          --retry-connrefused \
+          http://localhost:9200/_cluster/health?wait_for_status=green
+
     - name: pnpm run server:ci
       working-directory: ./apps/app
       run: |
@@ -160,7 +171,7 @@ jobs:
         pnpm run server:ci
       env:
         MONGO_URI: mongodb://localhost:${{ job.services.mongodb.ports['27017'] }}/growi
-        ELASTICSEARCH_URI: http://localhost:${{ job.services.elasticsearch.ports['9200'] }}/growi
+        ELASTICSEARCH_URI: http://localhost:9200/growi
 
     - name: Slack Notification
       uses: weseek/ghaction-slack-notification@master
@@ -173,18 +184,48 @@ jobs:
         url: ${{ secrets.SLACK_WEBHOOK_URL }}
 
 
+  prime-playwright-cache:
+    if: |
+      github.event_name == 'workflow_dispatch' ||
+      (!inputs.skip-e2e-test && startsWith(github.head_ref, 'mergify/merge-queue/'))
+
+    runs-on: ubuntu-latest
+
+    steps:
+    - uses: actions/checkout@v4
+
+    - uses: pnpm/action-setup@v6
+
+    - uses: actions/setup-node@v6
+      with:
+        node-version: ${{ inputs.node-version }}
+        cache: 'pnpm'
+
+    - name: Install dependencies
+      run: |
+        pnpm install --frozen-lockfile
+
+    - name: Cache Playwright browsers
+      id: playwright-cache
+      uses: actions/cache@v4
+      with:
+        path: ~/.cache/ms-playwright
+        key: playwright-${{ runner.os }}-${{ hashFiles('pnpm-lock.yaml') }}
+
+    - name: Install Playwright browsers
+      if: steps.playwright-cache.outputs.cache-hit != 'true'
+      run: |
+        pnpm playwright install
+
+
   run-playwright:
-    needs: [build-prod]
+    needs: [build-prod, prime-playwright-cache]
 
     if: |
       github.event_name == 'workflow_dispatch' ||
       (!inputs.skip-e2e-test && startsWith(github.head_ref, 'mergify/merge-queue/'))
 
     runs-on: ubuntu-latest
-    container:
-      # Match the Playwright version
-      # https://github.com/microsoft/playwright/issues/20010
-      image: mcr.microsoft.com/playwright:v1.58.2-jammy
 
     # Playwright spawns `pnpm run server` inside the extracted prod dir via
     # GROWI_WEBSERVER_COMMAND. That dir lacks pnpm-workspace.yaml and packages/*,
@@ -204,14 +245,6 @@ jobs:
         image: mongo:${{ matrix.mongodb-version }}
         ports:
         - 27017/tcp
-      elasticsearch:
-        image: docker.elastic.co/elasticsearch/elasticsearch:9.0.1
-        ports:
-        - 9200/tcp
-        env:
-          discovery.type: single-node
-          # ES 9.x enables security (HTTPS + auth) by default; disable for plaintext CI access
-          xpack.security.enabled: false
 
     steps:
     - uses: actions/checkout@v4
@@ -227,9 +260,40 @@ jobs:
       run: |
         pnpm install --frozen-lockfile
 
-    - name: Install Playwright browsers
+    # Browsers are pre-populated by `prime-playwright-cache`. Always a cache hit;
+    # restore-keys provides a partial fallback if the keyed cache was evicted.
+    - name: Restore Playwright browser cache
+      uses: actions/cache/restore@v4
+      with:
+        path: ~/.cache/ms-playwright
+        key: playwright-${{ runner.os }}-${{ hashFiles('pnpm-lock.yaml') }}
+        restore-keys: |
+          playwright-${{ runner.os }}-
+
+    # `--with-deps` installs apt system libraries and re-downloads any browser
+    # that wasn't in the restored cache. With a primed cache, this only runs apt.
+    - name: Install Playwright system deps and any missing browsers
       run: |
-        pnpm playwright install --with-deps ${{ matrix.browser }}
+        pnpm playwright install --with-deps
+
+    - name: Start Elasticsearch with plugins
+      uses: elastic/elastic-github-actions/elasticsearch@master
+      with:
+        stack-version: 9.3.3
+        plugins: |
+          analysis-kuromoji
+          analysis-icu
+        security-enabled: false
+
+    - name: Wait for Elasticsearch to be ready
+      run: |
+        curl \
+          --no-progress-meter \
+          -X GET \
+          --retry 60 \
+          --retry-delay 1 \
+          --retry-connrefused \
+          http://localhost:9200/_cluster/health?wait_for_status=green
 
     - name: Download production files artifact
       uses: actions/download-artifact@v4
@@ -245,17 +309,31 @@ jobs:
       run: |
         cat apps/app/config/ci/.env.local.for-ci >> /tmp/growi-prod/apps/app/.env.production.local
 
+    # The installer suite is not sharded, so run it once per mongodb-version
+    # (shard 1/2 only) rather than redundantly in every chromium shard job.
     - name: Playwright Run (--project=chromium/installer)
-      if: ${{ matrix.browser == 'chromium' }}
+      if: ${{ matrix.browser == 'chromium' && matrix.shard == '1/2' }}
       working-directory: ./apps/app
       run: |
         pnpm playwright test --project=chromium/installer
       env:
         DEBUG: pw:api
-        HOME: /root # ref: https://github.com/microsoft/playwright/issues/6500
         GROWI_WEBSERVER_COMMAND: 'cd /tmp/growi-prod/apps/app && pnpm run server'
-        MONGO_URI: mongodb://mongodb:27017/growi-playwright-installer
-        ELASTICSEARCH_URI: http://elasticsearch:9200/growi
+        MONGO_URI: mongodb://localhost:${{ job.services.mongodb.ports['27017'] }}/growi-playwright-installer
+        ELASTICSEARCH_URI: http://localhost:9200/growi
+
+    # Each `playwright test` run clears blob-report/ when it writes its report
+    # (the blob reporter empties its output dir), so move this run's blob out to a
+    # staging dir before the next run wipes it. Prefixing the mongodb-version keeps
+    # filenames unique once report-playwright flattens every artifact together.
+    - name: Stash installer blob report
+      if: ${{ always() && matrix.browser == 'chromium' && matrix.shard == '1/2' }}
+      run: |
+        mkdir -p apps/app/all-blobs
+        for f in apps/app/blob-report/*.zip; do
+          [ -e "$f" ] || continue
+          mv "$f" "apps/app/all-blobs/mongo${{ matrix.mongodb-version }}-$(basename "$f")"
+        done
 
     - name: Copy dotenv file for automatic installation
       run: |
@@ -267,10 +345,18 @@ jobs:
         pnpm playwright test --project=${{ matrix.browser }} --shard=${{ matrix.shard }}
       env:
         DEBUG: pw:api
-        HOME: /root # ref: https://github.com/microsoft/playwright/issues/6500
         GROWI_WEBSERVER_COMMAND: 'cd /tmp/growi-prod/apps/app && pnpm run server'
-        MONGO_URI: mongodb://mongodb:27017/growi-playwright
-        ELASTICSEARCH_URI: http://elasticsearch:9200/growi
+        MONGO_URI: mongodb://localhost:${{ job.services.mongodb.ports['27017'] }}/growi-playwright
+        ELASTICSEARCH_URI: http://localhost:9200/growi
+
+    - name: Stash main blob report
+      if: always()
+      run: |
+        mkdir -p apps/app/all-blobs
+        for f in apps/app/blob-report/*.zip; do
+          [ -e "$f" ] || continue
+          mv "$f" "apps/app/all-blobs/mongo${{ matrix.mongodb-version }}-$(basename "$f")"
+        done
 
     - name: Copy dotenv file for automatic installation with allowing guest mode
       run: |
@@ -282,10 +368,18 @@ jobs:
         pnpm playwright test --project=${{ matrix.browser }}/guest-mode --shard=${{ matrix.shard }}
       env:
         DEBUG: pw:api
-        HOME: /root # ref: https://github.com/microsoft/playwright/issues/6500
         GROWI_WEBSERVER_COMMAND: 'cd /tmp/growi-prod/apps/app && pnpm run server'
-        MONGO_URI: mongodb://mongodb:27017/growi-playwright-guest-mode
-        ELASTICSEARCH_URI: http://elasticsearch:9200/growi
+        MONGO_URI: mongodb://localhost:${{ job.services.mongodb.ports['27017'] }}/growi-playwright-guest-mode
+        ELASTICSEARCH_URI: http://localhost:9200/growi
+
+    - name: Stash guest-mode blob report
+      if: always()
+      run: |
+        mkdir -p apps/app/all-blobs
+        for f in apps/app/blob-report/*.zip; do
+          [ -e "$f" ] || continue
+          mv "$f" "apps/app/all-blobs/mongo${{ matrix.mongodb-version }}-$(basename "$f")"
+        done
 
     - name: Generate shard ID
       id: shard-id
@@ -299,7 +393,7 @@ jobs:
       if: always()
       with:
         name: blob-report-${{ matrix.browser }}-mongo${{ matrix.mongodb-version }}-${{ steps.shard-id.outputs.shard_id }}
-        path: ./apps/app/blob-report
+        path: ./apps/app/all-blobs
         retention-days: 30
 
     - name: Slack Notification

+ 2 - 0
apps/app/.env.test

@@ -7,3 +7,5 @@
 ##
 VITE_MONGOMS_VERSION="6.0.9"
 # VITE_MONGOMS_DEBUG=1
+
+VITE_ELASTICSEARCH_URI="http://elasticsearch:9200/growi"

+ 3 - 1
apps/app/package.json

@@ -42,10 +42,12 @@
     "prelint:openapi:apiv3": "pnpm run openapi:generate-spec:apiv3",
     "prelint:openapi:apiv1": "pnpm run openapi:generate-spec:apiv1",
     "test": "vitest run",
+    "test:unit": "vitest run --project=app-unit",
+    "test:integ": "vitest run --project=app-integration",
+    "test:components": "vitest run --project=app-components",
     "test:coverage": "run-p test:coverage:* test:integ",
     "test:coverage:unit": "COLUMNS=200 vitest run --coverage --project=app-unit",
     "test:coverage:components": "COLUMNS=200 vitest run --coverage --project=app-components",
-    "test:integ": "vitest run --project=app-integration",
     "//// misc": "",
     "console": "npm run repl",
     "repl": "cross-env NODE_ENV=development npm run ts-node src/server/repl.ts",

+ 29 - 28
apps/app/src/server/crowi/index.ts

@@ -349,10 +349,10 @@ class Crowi {
     // mongoUri = mongodb://user:password@host/dbname
     const mongoUri = getMongoUri();
 
-    return mongoose.connect(mongoUri, mongoOptions);
+    return await mongoose.connect(mongoUri, mongoOptions);
   }
 
-  async setupSessionConfig(): Promise<void> {
+  setupSessionConfig(): void {
     const session = require('express-session');
     const sessionMaxAge =
       this.configManager.getConfig('security:sessionMaxAge') || 2592000000; // default: 30days
@@ -407,10 +407,10 @@ class Crowi {
 
   async setupConfigManager(): Promise<void> {
     this.configManager = configManagerSingletonInstance;
-    return this.configManager.loadConfigs();
+    return await this.configManager.loadConfigs();
   }
 
-  async setupS2sMessagingService(): Promise<void> {
+  setupS2sMessagingService(): void {
     const s2sMessagingService = require('../service/s2s-messaging')(this);
     if (s2sMessagingService != null) {
       s2sMessagingService.subscribe();
@@ -422,7 +422,7 @@ class Crowi {
     }
   }
 
-  async setupSocketIoService(): Promise<void> {
+  setupSocketIoService(): void {
     this.socketIoService = new SocketIoService(this);
   }
 
@@ -482,12 +482,12 @@ class Crowi {
     this.passportService.setupSerializer();
     // setup strategies
     try {
-      this.passportService.setupStrategyById('local');
-      this.passportService.setupStrategyById('ldap');
-      this.passportService.setupStrategyById('saml');
-      this.passportService.setupStrategyById('oidc');
-      this.passportService.setupStrategyById('google');
-      this.passportService.setupStrategyById('github');
+      await this.passportService.setupStrategyById('local');
+      await this.passportService.setupStrategyById('ldap');
+      await this.passportService.setupStrategyById('saml');
+      await this.passportService.setupStrategyById('oidc');
+      await this.passportService.setupStrategyById('google');
+      await this.passportService.setupStrategyById('github');
     } catch (err) {
       logger.error(err);
     }
@@ -499,10 +499,10 @@ class Crowi {
   }
 
   async setupSearcher(): Promise<void> {
-    this.searchService = new SearchService(this);
+    this.searchService = await SearchService.create(this);
   }
 
-  async setupMailer(): Promise<void> {
+  setupMailer(): void {
     const MailService = require('~/server/service/mail').default;
     this.mailService = new MailService(this);
 
@@ -660,6 +660,7 @@ class Crowi {
         await mongoose.disconnect();
         return;
       },
+      // biome-ignore lint/suspicious/useAwait: onShutdown should be async
       onShutdown: async () => {
         logger.info('Cleanup finished, server is shutting down');
       },
@@ -707,7 +708,7 @@ class Crowi {
   /**
    * setup UserNotificationService
    */
-  async setUpUserNotification(): Promise<void> {
+  setUpUserNotification(): void {
     if (this.userNotificationService == null) {
       this.userNotificationService = new UserNotificationService(this);
     }
@@ -716,7 +717,7 @@ class Crowi {
   /**
    * setup AclService
    */
-  async setUpAcl(): Promise<void> {
+  setUpAcl(): void {
     this.aclService = aclServiceSingletonInstance;
   }
 
@@ -741,7 +742,7 @@ class Crowi {
   /**
    * setup AppService
    */
-  async setUpApp(): Promise<void> {
+  setUpApp(): void {
     if (this.appService == null) {
       this.appService = new AppService(this);
 
@@ -756,7 +757,7 @@ class Crowi {
   /**
    * setup FileUploadService
    */
-  async setUpFileUpload(isForceUpdate = false): Promise<void> {
+  setUpFileUpload(isForceUpdate = false): void {
     if (this.fileUploadService == null || isForceUpdate) {
       this.fileUploadService = getUploader(this);
     }
@@ -765,7 +766,7 @@ class Crowi {
   /**
    * setup FileUploaderSwitchService
    */
-  async setUpFileUploaderSwitchService(): Promise<void> {
+  setUpFileUploaderSwitchService(): void {
     const FileUploaderSwitchService = require('../service/file-uploader-switch');
     this.fileUploaderSwitchService = new FileUploaderSwitchService(this);
     // add as a message handler
@@ -784,7 +785,7 @@ class Crowi {
   /**
    * setup AttachmentService
    */
-  async setupAttachmentService(): Promise<void> {
+  setupAttachmentService(): void {
     if (this.attachmentService == null) {
       this.attachmentService = new AttachmentService(this);
     }
@@ -793,21 +794,21 @@ class Crowi {
   async setupUserGroupService(): Promise<void> {
     if (this.userGroupService == null) {
       this.userGroupService = new UserGroupService(this);
-      return this.userGroupService.init();
+      return await this.userGroupService.init();
     }
   }
 
-  async setUpGrowiBridge(): Promise<void> {
+  setUpGrowiBridge(): void {
     if (this.growiBridgeService == null) {
       this.growiBridgeService = new GrowiBridgeService(this);
     }
   }
 
-  async setupExport(): Promise<void> {
+  setupExport(): void {
     instanciateExportService(this);
   }
 
-  async setupImport(): Promise<void> {
+  setupImport(): void {
     initializeImportService(this);
   }
 
@@ -833,7 +834,7 @@ class Crowi {
     this.pageOperationService = instanciatePageOperationService(this);
   }
 
-  async setupInAppNotificationService(): Promise<void> {
+  setupInAppNotificationService(): void {
     if (this.inAppNotificationService == null) {
       this.inAppNotificationService = new InAppNotificationService(this);
     }
@@ -846,13 +847,13 @@ class Crowi {
     }
   }
 
-  async setupCommentService(): Promise<void> {
+  setupCommentService(): void {
     if (this.commentService == null) {
       this.commentService = new CommentService(this);
     }
   }
 
-  async setupSyncPageStatusService(): Promise<void> {
+  setupSyncPageStatusService(): void {
     if (this.syncPageStatusService == null) {
       this.syncPageStatusService = new SyncPageStatusService(
         this,
@@ -867,7 +868,7 @@ class Crowi {
     }
   }
 
-  async setupSlackIntegrationService(): Promise<void> {
+  setupSlackIntegrationService(): void {
     if (this.slackIntegrationService == null) {
       this.slackIntegrationService = new SlackIntegrationService(this);
     }
@@ -878,7 +879,7 @@ class Crowi {
     }
   }
 
-  async setupG2GTransferService(): Promise<void> {
+  setupG2GTransferService(): void {
     if (this.g2gTransferPusherService == null) {
       this.g2gTransferPusherService = new G2GTransferPusherService(this);
     }

+ 2 - 1
apps/app/src/server/routes/apiv3/search.js

@@ -254,6 +254,7 @@ module.exports = (crowi) => {
     async (req, res) => {
       const operation = req.body.operation;
 
+      // @type {import('~/server/service/search').SearchService}
       const { searchService } = crowi;
 
       if (!searchService.isConfigured) {
@@ -288,7 +289,7 @@ module.exports = (crowi) => {
               .send({ message: 'Operation is successfully processed.' });
           case 'rebuild':
             // NOT wait the processing is terminated
-            searchService.rebuildIndex();
+            searchService.rebuildIndex(true);
 
             activityEvent.emit('update', res.locals.activity._id, {
               action: SupportedAction.ACTION_ADMIN_SEARCH_INDICES_REBUILD,

+ 1 - 1
apps/app/src/server/service/installer.ts

@@ -40,7 +40,7 @@ export class InstallerService {
     }
 
     try {
-      await searchService.rebuildIndex();
+      await searchService.rebuildIndex(true);
     } catch (err) {
       logger.error('Rebuild index failed', err);
     }

+ 8 - 0
apps/app/src/server/service/interfaces/search.ts

@@ -2,3 +2,11 @@ export type UpdateOrInsertPagesOpts = {
   shouldEmitProgress?: boolean;
   invokeGarbageCollection?: boolean;
 };
+
+export type AddAllPagesOption = {
+  shouldEmitProgress?: boolean;
+};
+
+export type RebuildIndexOption = {
+  shouldEmitProgress?: boolean;
+};

+ 82 - 0
apps/app/src/server/service/search-delegator/elasticsearch.integ.ts

@@ -0,0 +1,82 @@
+import { vi } from 'vitest';
+
+import { configManager } from '~/server/service/config-manager';
+import { SocketIoService } from '~/server/service/socket-io/socket-io';
+
+import { getInstance } from '../../../../test/setup/crowi';
+import type { RebuildIndexOption } from '../interfaces/search';
+import ElasticsearchDelegator from './elasticsearch';
+
+// ELASTICSEARCH_URI is mapped from VITE_ELASTICSEARCH_URI by test/setup/elasticsearch.ts
+const hasElasticsearch = !!process.env.ELASTICSEARCH_URI;
+
+describe.skipIf(!hasElasticsearch)(
+  'ElasticsearchDelegator#init() with ELASTICSEARCH_REINDEX_ON_BOOT',
+  () => {
+    // Execute sequentially to use the same index name
+    describe.sequential('when ELASTICSEARCH_REINDEX_ON_BOOT=true', () => {
+      beforeAll(async () => {
+        process.env.ELASTICSEARCH_REINDEX_ON_BOOT = 'true';
+        await configManager.loadConfigs();
+      });
+      afterAll(() => {
+        delete process.env.ELASTICSEARCH_REINDEX_ON_BOOT;
+      });
+
+      describe('with a SocketIoService with an attached dummy HTTP server', () => {
+        it('should invoke rebuildIndex and complete without error', async () => {
+          // arrange
+          const crowi = await getInstance(); // attached SocketIoService with dummy server in setupCrowi.ts
+          const delegator = new ElasticsearchDelegator(crowi.socketIoService);
+          type WithRebuildIndex = {
+            rebuildIndex: (option?: RebuildIndexOption) => Promise<void>;
+          };
+          const rebuildSpy = vi.spyOn(
+            delegator as unknown as WithRebuildIndex,
+            'rebuildIndex',
+          );
+
+          // act
+          await delegator.init();
+
+          // assert
+          expect(rebuildSpy).toHaveBeenCalledOnce();
+          const { isNormalized } = await delegator.getInfoForAdmin();
+          expect(isNormalized).toBe(true);
+          await expect(
+            rebuildSpy.mock.results[0].value,
+          ).resolves.toBeUndefined();
+        }, 60_000);
+      });
+
+      describe('with a SocketIoService without an attached HTTP server', () => {
+        it('should invoke rebuildIndex and complete without error', async () => {
+          // arrange
+          const crowi = await getInstance();
+          // Use a SocketIoService without an attached HTTP server, as in actual boot.
+          // If rebuildIndex incorrectly emits progress, getAdminSocket() throws.
+          const socketIoService = new SocketIoService(crowi);
+          const delegator = new ElasticsearchDelegator(socketIoService);
+          type WithRebuildIndex = {
+            rebuildIndex: (option?: RebuildIndexOption) => Promise<void>;
+          };
+          const rebuildSpy = vi.spyOn(
+            delegator as unknown as WithRebuildIndex,
+            'rebuildIndex',
+          );
+
+          // act
+          await delegator.init();
+
+          // assert
+          expect(rebuildSpy).toHaveBeenCalledOnce();
+          const { isNormalized } = await delegator.getInfoForAdmin();
+          expect(isNormalized).toBe(true);
+          await expect(
+            rebuildSpy.mock.results[0].value,
+          ).resolves.toBeUndefined();
+        }, 60_000);
+      });
+    });
+  },
+);

+ 46 - 36
apps/app/src/server/service/search-delegator/elasticsearch.ts

@@ -24,7 +24,11 @@ import type {
 import type { PageModel } from '../../models/page';
 import { createBatchStream } from '../../util/batch-stream';
 import { configManager } from '../config-manager';
-import type { UpdateOrInsertPagesOpts } from '../interfaces/search';
+import type {
+  AddAllPagesOption,
+  RebuildIndexOption,
+  UpdateOrInsertPagesOpts,
+} from '../interfaces/search';
 import { aggregatePipelineToIndex } from './aggregate-to-index';
 import type {
   AggregatedPage,
@@ -211,7 +215,7 @@ class ElasticsearchDelegator
     const normalizeIndices = await this.normalizeIndices();
     if (this.isElasticsearchReindexOnBoot) {
       try {
-        await this.rebuildIndex();
+        await this.rebuildIndex({ shouldEmitProgress: false });
       } catch (err) {
         logger.error('Rebuild index on boot failed', err);
       }
@@ -333,13 +337,22 @@ class ElasticsearchDelegator
   /**
    * rebuild index
    */
-  async rebuildIndex(): Promise<void> {
+  async rebuildIndex(
+    option: RebuildIndexOption = { shouldEmitProgress: false },
+  ): Promise<void> {
     const { client, indexName, aliasName } = this;
+    const { shouldEmitProgress } = option;
 
     const tmpIndexName = `${indexName}-tmp`;
 
     try {
       // reindex to tmp index
+      const isExistsTmpIndex = await client.indices.exists({
+        index: tmpIndexName,
+      });
+      if (isExistsTmpIndex) {
+        await client.indices.delete({ index: tmpIndexName });
+      }
       await this.createIndex(tmpIndexName);
       await client.reindex(indexName, tmpIndexName);
 
@@ -356,13 +369,15 @@ class ElasticsearchDelegator
         index: indexName,
       });
       await this.createIndex(indexName);
-      await this.addAllPages();
+      await this.addAllPages({ shouldEmitProgress });
     } catch (error) {
-      logger.error("An error occured while 'rebuildIndex'.", error);
-      logger.error('error.meta.body', error?.meta?.body);
+      logger.error({ err: error }, "An error occured while 'rebuildIndex'.");
+      logger.error({ body: error?.meta?.body }, 'error.meta.body');
 
-      const socket = this.socketIoService.getAdminSocket();
-      socket.emit(SocketEventName.RebuildingFailed, { error: error.message });
+      if (shouldEmitProgress) {
+        const socket = this.socketIoService.getAdminSocket();
+        socket.emit(SocketEventName.RebuildingFailed, { error: error.message });
+      }
 
       throw error;
     } finally {
@@ -424,11 +439,7 @@ class ElasticsearchDelegator
     }
 
     if (isES9ClientDelegator(this.client)) {
-      const { mappings } =
-        process.env.CI == null
-          ? await import('./mappings/mappings-es9')
-          : await import('./mappings/mappings-es9-for-ci');
-
+      const { mappings } = await import('./mappings/mappings-es9');
       return this.client.indices.create({
         index,
         ...mappings,
@@ -502,10 +513,11 @@ class ElasticsearchDelegator
     body.push(command);
   }
 
-  addAllPages() {
+  addAllPages(option: AddAllPagesOption = { shouldEmitProgress: false }) {
+    const { shouldEmitProgress } = option;
     const Page = this.getPageModel();
     return this.updateOrInsertPages(() => Page.find(), {
-      shouldEmitProgress: true,
+      shouldEmitProgress,
       invokeGarbageCollection: true,
     });
   }
@@ -528,10 +540,12 @@ class ElasticsearchDelegator
    */
   async updateOrInsertPages(
     queryFactory,
-    option: UpdateOrInsertPagesOpts = {},
+    option: UpdateOrInsertPagesOpts = {
+      shouldEmitProgress: false,
+      invokeGarbageCollection: false,
+    },
   ): Promise<void> {
-    const { shouldEmitProgress = false, invokeGarbageCollection = false } =
-      option;
+    const { shouldEmitProgress, invokeGarbageCollection } = option;
 
     const Page = this.getPageModel();
     const { PageQueryBuilder } = Page;
@@ -669,7 +683,7 @@ class ElasticsearchDelegator
       const validateQueryResponse = await (async () => {
         if (isES7ClientDelegator(this.client)) {
           const es7SearchQuery = query as ES7SearchQuery;
-          return this.client.indices.validateQuery({
+          return await this.client.indices.validateQuery({
             explain: true,
             index: es7SearchQuery.index,
             body: {
@@ -680,7 +694,7 @@ class ElasticsearchDelegator
 
         if (isES8ClientDelegator(this.client)) {
           const es8SearchQuery = query as ES8SearchQuery;
-          return this.client.indices.validateQuery({
+          return await this.client.indices.validateQuery({
             explain: true,
             index: es8SearchQuery.index,
             query: es8SearchQuery.body.query,
@@ -689,7 +703,7 @@ class ElasticsearchDelegator
 
         if (isES9ClientDelegator(this.client)) {
           const es9SearchQuery = query as ES9SearchQuery;
-          return this.client.indices.validateQuery({
+          return await this.client.indices.validateQuery({
             explain: true,
             index: es9SearchQuery.index,
             query: es9SearchQuery.body.query,
@@ -705,16 +719,16 @@ class ElasticsearchDelegator
 
     const searchResponse = await (async () => {
       if (isES7ClientDelegator(this.client)) {
-        return this.client.search(query as ES7SearchQuery);
+        return await this.client.search(query as ES7SearchQuery);
       }
 
       if (isES8ClientDelegator(this.client)) {
-        return this.client.search(query as ES8SearchQuery);
+        return await this.client.search(query as ES8SearchQuery);
       }
 
       if (isES9ClientDelegator(this.client)) {
         const { body, ...rest } = query as ES9SearchQuery;
-        return this.client.search({
+        return await this.client.search({
           ...rest,
           // Elimination of the body property since ES9
           // https://raw.githubusercontent.com/elastic/elasticsearch-js/2f6200eb397df0e54d23848d769a93614ee1fb45/docs/release-notes/breaking-changes.md
@@ -956,11 +970,7 @@ class ElasticsearchDelegator
     }
   }
 
-  async filterPagesByViewer(
-    query: SearchQuery,
-    user,
-    userGroups,
-  ): Promise<void> {
+  filterPagesByViewer(query: SearchQuery, user, userGroups): void {
     const showPagesRestrictedByOwner = !configManager.getConfig(
       'security:list-policy:hideRestrictedByOwner',
     );
@@ -1096,7 +1106,7 @@ class ElasticsearchDelegator
     const query = this.createSearchQuery();
 
     this.appendCriteriaForQueryString(query, terms);
-    await this.filterPagesByViewer(query, user, userGroups);
+    this.filterPagesByViewer(query, user, userGroups);
     await this.appendFunctionScore(query, queryString);
 
     this.appendResultSize(query, from, size);
@@ -1105,7 +1115,7 @@ class ElasticsearchDelegator
 
     this.appendHighlight(query);
 
-    return this.searchKeyword(query);
+    return await this.searchKeyword(query);
   }
 
   isTermsNormalized(terms: Partial<QueryTerms>): terms is ESQueryTerms {
@@ -1129,7 +1139,7 @@ class ElasticsearchDelegator
 
   async syncPageUpdated(page, user) {
     logger.debug('SearchClient.syncPageUpdated', page.path);
-    return this.updateOrInsertPageById(page._id);
+    return await this.updateOrInsertPageById(page._id);
   }
 
   // remove pages whitch should nod Indexed
@@ -1147,7 +1157,7 @@ class ElasticsearchDelegator
   }
 
   async syncDescendantsPagesUpdated(parentPage, user) {
-    return this.updateOrInsertDescendantsPagesById(parentPage, user);
+    return await this.updateOrInsertDescendantsPagesById(parentPage, user);
   }
 
   async syncDescendantsPagesDeleted(pages, user) {
@@ -1175,19 +1185,19 @@ class ElasticsearchDelegator
   async syncBookmarkChanged(pageId) {
     logger.debug('SearchClient.syncBookmarkChanged', pageId);
 
-    return this.updateOrInsertPageById(pageId);
+    return await this.updateOrInsertPageById(pageId);
   }
 
   async syncCommentChanged(comment) {
     logger.debug('SearchClient.syncCommentChanged', comment);
 
-    return this.updateOrInsertPageById(comment.page);
+    return await this.updateOrInsertPageById(comment.page);
   }
 
   async syncTagChanged(page) {
     logger.debug('SearchClient.syncTagChanged', page.path);
 
-    return this.updateOrInsertPageById(page._id);
+    return await this.updateOrInsertPageById(page._id);
   }
 }
 

+ 0 - 121
apps/app/src/server/service/search-delegator/mappings/mappings-es9-for-ci.ts

@@ -1,121 +0,0 @@
-import type { estypes } from '@elastic/elasticsearch9';
-
-type Mappings = {
-  settings: estypes.IndicesCreateRequest['settings'];
-  mappings: estypes.IndicesCreateRequest['mappings'];
-};
-
-export const mappings: Mappings = {
-  settings: {
-    analysis: {
-      filter: {
-        english_stop: {
-          type: 'stop',
-          stopwords: '_english_',
-        },
-      },
-      tokenizer: {
-        edge_ngram_tokenizer: {
-          type: 'edge_ngram',
-          min_gram: 2,
-          max_gram: 20,
-          token_chars: ['letter', 'digit'],
-        },
-      },
-      analyzer: {
-        japanese: {
-          type: 'custom',
-          tokenizer: 'edge_ngram_tokenizer',
-          filter: ['lowercase', 'english_stop'],
-        },
-        english_edge_ngram: {
-          type: 'custom',
-          tokenizer: 'edge_ngram_tokenizer',
-          filter: ['lowercase', 'english_stop'],
-        },
-      },
-    },
-  },
-  mappings: {
-    properties: {
-      path: {
-        type: 'text',
-        fields: {
-          raw: {
-            type: 'text',
-            analyzer: 'keyword',
-          },
-          ja: {
-            type: 'text',
-            analyzer: 'japanese',
-          },
-          en: {
-            type: 'text',
-            analyzer: 'english_edge_ngram',
-            search_analyzer: 'standard',
-          },
-        },
-      },
-      body: {
-        type: 'text',
-        fields: {
-          ja: {
-            type: 'text',
-            analyzer: 'japanese',
-          },
-          en: {
-            type: 'text',
-            analyzer: 'english_edge_ngram',
-            search_analyzer: 'standard',
-          },
-        },
-      },
-      comments: {
-        type: 'text',
-        fields: {
-          ja: {
-            type: 'text',
-            analyzer: 'japanese',
-          },
-          en: {
-            type: 'text',
-            analyzer: 'english_edge_ngram',
-            search_analyzer: 'standard',
-          },
-        },
-      },
-      username: {
-        type: 'keyword',
-      },
-      comment_count: {
-        type: 'integer',
-      },
-      bookmark_count: {
-        type: 'integer',
-      },
-      like_count: {
-        type: 'integer',
-      },
-      grant: {
-        type: 'integer',
-      },
-      granted_users: {
-        type: 'keyword',
-      },
-      granted_groups: {
-        type: 'keyword',
-      },
-      created_at: {
-        type: 'date',
-        format: 'date_optional_time',
-      },
-      updated_at: {
-        type: 'date',
-        format: 'date_optional_time',
-      },
-      tag_names: {
-        type: 'keyword',
-      },
-    },
-  },
-};

+ 5 - 0
apps/app/src/server/service/search-query.spec.ts

@@ -34,6 +34,11 @@ vi.mock('~/server/service/config-manager/config-manager', () => {
 });
 
 class TestSearchService extends SearchService {
+  constructor(crowi: Crowi) {
+    super();
+    this.crowi = crowi;
+  }
+
   override generateFullTextSearchDelegator(): ElasticsearchDelegator {
     return mock<ElasticsearchDelegator>();
   }

+ 23 - 13
apps/app/src/server/service/search.ts

@@ -87,36 +87,46 @@ const findPageListByIds = async (pageIds: ObjectIdLike[], crowi: any) => {
 };
 
 class SearchService implements SearchQueryParser, SearchResolver {
+  protected constructor() {}
+
   crowi: Crowi;
 
   isErrorOccuredOnHealthcheck: boolean | null;
 
   isErrorOccuredOnSearching: boolean | null;
 
-  fullTextSearchDelegator: any & ElasticsearchDelegator;
+  fullTextSearchDelegator: ElasticsearchDelegator;
 
   nqDelegators: { [key in SearchDelegatorName]: SearchDelegator };
 
-  constructor(crowi: Crowi) {
-    this.crowi = crowi;
+  static async create(crowi: Crowi) {
+    const instance = new SearchService();
+
+    instance.crowi = crowi;
 
-    this.isErrorOccuredOnHealthcheck = null;
-    this.isErrorOccuredOnSearching = null;
+    instance.isErrorOccuredOnHealthcheck = null;
+    instance.isErrorOccuredOnSearching = null;
 
     try {
-      this.fullTextSearchDelegator = this.generateFullTextSearchDelegator();
-      this.nqDelegators = this.generateNQDelegators(
-        this.fullTextSearchDelegator,
+      const tmpFullTextSearchDelegator =
+        instance.generateFullTextSearchDelegator();
+      if (tmpFullTextSearchDelegator == null) {
+        throw new Error('Failed to initialize search delegator');
+      }
+      instance.fullTextSearchDelegator = tmpFullTextSearchDelegator;
+      instance.nqDelegators = instance.generateNQDelegators(
+        instance.fullTextSearchDelegator,
       );
       logger.info('Succeeded to initialize search delegators');
     } catch (err) {
       logger.error(err);
     }
 
-    if (this.isConfigured) {
-      this.fullTextSearchDelegator.init();
-      this.registerUpdateEvent();
+    if (instance.isConfigured) {
+      await instance.fullTextSearchDelegator.init();
+      instance.registerUpdateEvent();
     }
+    return instance;
   }
 
   get isConfigured() {
@@ -321,8 +331,8 @@ class SearchService implements SearchQueryParser, SearchResolver {
     return this.fullTextSearchDelegator.normalizeIndices();
   }
 
-  async rebuildIndex() {
-    return this.fullTextSearchDelegator.rebuildIndex();
+  async rebuildIndex(shouldEmitProgress = false) {
+    return this.fullTextSearchDelegator.rebuildIndex({ shouldEmitProgress });
   }
 
   async parseSearchQuery(

+ 1 - 1
apps/app/src/server/service/search/search-service.integ.ts

@@ -34,7 +34,7 @@ describe('SearchService test', () => {
 
   beforeAll(async () => {
     crowi = await getInstance();
-    searchService = new SearchService(crowi);
+    searchService = await SearchService.create(crowi);
     searchService.nqDelegators = {
       ...searchService.nqDelegators,
       [DEFAULT]: dummyFullTextSearchDelegator, // override with dummy full-text search delegator

+ 14 - 0
apps/app/test/setup/elasticsearch.ts

@@ -0,0 +1,14 @@
+// Vitest injects VITE_- and VITE_-prefixed .env.test vars into process.env for Node environments.
+// Map VITE_-prefixed vars to the names that the app's configManager expects.
+const VITE_ENV_MAP: Record<string, string> = {
+  VITE_ELASTICSEARCH_URI: 'ELASTICSEARCH_URI',
+  VITE_ELASTICSEARCH_VERSION: 'ELASTICSEARCH_VERSION',
+  VITE_ELASTICSEARCH_REINDEX_ON_BOOT: 'ELASTICSEARCH_REINDEX_ON_BOOT',
+};
+
+for (const [vitestKey, appKey] of Object.entries(VITE_ENV_MAP)) {
+  const value = process.env[vitestKey];
+  if (value != null) {
+    process.env[appKey] = value;
+  }
+}

+ 12 - 0
apps/app/turbo.json

@@ -89,6 +89,18 @@
       "dependsOn": ["^dev"],
       "outputLogs": "new-only"
     },
+    "test:unit": {
+      "dependsOn": ["^dev"],
+      "outputLogs": "new-only"
+    },
+    "test:integ": {
+      "dependsOn": ["^dev"],
+      "outputLogs": "new-only"
+    },
+    "test:components": {
+      "dependsOn": ["^dev"],
+      "outputLogs": "new-only"
+    },
 
     "version:patch": {
       "cache": false,

+ 1 - 0
apps/app/vitest.workspace.mts

@@ -46,6 +46,7 @@ export default defineWorkspace([
       // Pre-download the MongoDB binary before workers start to avoid lock-file race conditions
       globalSetup: ['./test/setup/mongo/global-setup.ts'],
       setupFiles: [
+        './test/setup/elasticsearch.ts',
         './test/setup/migrate-mongo.ts',
         './test/setup/mongo/index.ts',
       ],

+ 1 - 1
package.json

@@ -45,7 +45,7 @@
     "@changesets/changelog-github": "^0.5.0",
     "@changesets/cli": "^2.27.3",
     "@faker-js/faker": "^9.0.1",
-    "@playwright/test": "^1.58.2",
+    "@playwright/test": "^1.60.0",
     "@swc-node/register": "^1.10.9",
     "@swc/core": "^1.5.25",
     "@swc/helpers": "^0.5.18",

+ 26 - 25
pnpm-lock.yaml

@@ -32,8 +32,8 @@ importers:
         specifier: ^9.0.1
         version: 9.0.1
       '@playwright/test':
-        specifier: ^1.58.2
-        version: 1.58.2
+        specifier: ^1.60.0
+        version: 1.60.0
       '@swc-node/register':
         specifier: ^1.10.9
         version: 1.10.9(@swc/core@1.10.7(@swc/helpers@0.5.18))(@swc/types@0.1.17)(typescript@5.9.3)
@@ -357,7 +357,7 @@ importers:
         version: 3.9.1
       babel-plugin-superjson-next:
         specifier: ^0.4.2
-        version: 0.4.5(next@16.2.6(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(@playwright/test@1.58.2)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)(sass@1.77.6))(superjson@2.2.2)
+        version: 0.4.5(next@16.2.6(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(@playwright/test@1.60.0)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)(sass@1.77.6))(superjson@2.2.2)
       body-parser:
         specifier: ^1.20.3
         version: 1.20.3
@@ -570,13 +570,13 @@ importers:
         version: 4.2.0
       next:
         specifier: ^16.2.6
-        version: 16.2.6(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(@playwright/test@1.58.2)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)(sass@1.77.6)
+        version: 16.2.6(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(@playwright/test@1.60.0)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)(sass@1.77.6)
       next-dynamic-loading-props:
         specifier: ^0.1.1
         version: 0.1.1(react@18.2.0)
       next-i18next:
         specifier: ^15.3.1
-        version: 15.3.1(i18next@23.16.5)(next@16.2.6(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(@playwright/test@1.58.2)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)(sass@1.77.6))(react-i18next@15.1.1(i18next@23.16.5)(react-dom@18.2.0(react@18.2.0))(react@18.2.0))(react@18.2.0)
+        version: 15.3.1(i18next@23.16.5)(next@16.2.6(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(@playwright/test@1.60.0)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)(sass@1.77.6))(react-i18next@15.1.1(i18next@23.16.5)(react-dom@18.2.0(react@18.2.0))(react@18.2.0))(react@18.2.0)
       next-themes:
         specifier: ^0.4.6
         version: 0.4.6(react-dom@18.2.0(react@18.2.0))(react@18.2.0)
@@ -1452,7 +1452,7 @@ importers:
     dependencies:
       next:
         specifier: ^16
-        version: 16.2.6(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(@playwright/test@1.58.2)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)(sass@1.77.6)
+        version: 16.2.6(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(@playwright/test@1.60.0)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)(sass@1.77.6)
       react:
         specifier: ^18.2.0
         version: 18.2.0
@@ -1760,7 +1760,7 @@ importers:
         version: 6.13.9(@aws-sdk/client-sso-oidc@3.600.0)
       next:
         specifier: ^16
-        version: 16.2.6(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(@playwright/test@1.58.2)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)(sass@1.77.6)
+        version: 16.2.6(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(@playwright/test@1.60.0)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)(sass@1.77.6)
       react:
         specifier: ^18.2.0
         version: 18.2.0
@@ -1855,7 +1855,7 @@ importers:
     dependencies:
       next:
         specifier: ^16
-        version: 16.2.6(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(@playwright/test@1.58.2)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)(sass@1.77.6)
+        version: 16.2.6(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(@playwright/test@1.60.0)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)(sass@1.77.6)
       react:
         specifier: ^18.2.0
         version: 18.2.0
@@ -4223,8 +4223,8 @@ packages:
     resolution: {integrity: sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==}
     engines: {node: '>=14'}
 
-  '@playwright/test@1.58.2':
-    resolution: {integrity: sha512-akea+6bHYBBfA9uQqSYmlJXn61cTa+jbO87xVLCWbTqbWadRVmhxlXATaOjOgcBaWU4ePo0wB41KMFv3o35IXA==}
+  '@playwright/test@1.60.0':
+    resolution: {integrity: sha512-O71yZIbAh/PxDMNGns37GHBIfrVkEVyn+AXyIa5dOTfb4/xNvRWV+Vv/NMbNCtODB/pO7vLlF2OTmMVLhmr7Ag==}
     engines: {node: '>=18'}
     hasBin: true
 
@@ -9747,6 +9747,7 @@ packages:
     resolution: {integrity: sha512-Quz3MvAwHxVYNXsOByL7xI5EB2WYOeFswqaHIA3qOK3isRWTxiplBEocmmru6XmxDB2L7jDNYtYA4FyimoAFEw==}
     engines: {node: '>=8.17.0'}
     hasBin: true
+    bundledDependencies: []
 
   jsonfile@3.0.1:
     resolution: {integrity: sha512-oBko6ZHlubVB5mRFkur5vgYR1UyqX+S6Y/oCfLhqNdcc2fYFlDpIoNc7AfKS1KOGcnNAkvsr0grLck9ANM815w==}
@@ -11529,13 +11530,13 @@ packages:
   plantuml-encoder@1.4.0:
     resolution: {integrity: sha512-sxMwpDw/ySY1WB2CE3+IdMuEcWibJ72DDOsXLkSmEaSzwEUaYBT6DWgOfBiHGCux4q433X6+OEFWjlVqp7gL6g==}
 
-  playwright-core@1.58.2:
-    resolution: {integrity: sha512-yZkEtftgwS8CsfYo7nm0KE8jsvm6i/PTgVtB8DL726wNf6H2IMsDuxCpJj59KDaxCtSnrWan2AeDqM7JBaultg==}
+  playwright-core@1.60.0:
+    resolution: {integrity: sha512-9bW6zvX/m0lEbgTKJ6YppOKx8H3VOPBMOCFh2irXFOT4BbHgrx5hPjwJYLT40Lu+4qtD36qKc/Hn56StUW57IA==}
     engines: {node: '>=18'}
     hasBin: true
 
-  playwright@1.58.2:
-    resolution: {integrity: sha512-vA30H8Nvkq/cPBnNw4Q8TWz1EJyqgpuinBcHET0YVJVFldr8JDNiU9LaWAE1KqSkRYazuaBhTpB5ZzShOezQ6A==}
+  playwright@1.60.0:
+    resolution: {integrity: sha512-hheHdokM8cdqCb0lcE3s+zT4t4W+vvjpGxsZlDnikarzx8tSzMebh3UiFtgqwFwnTnjYQcsyMF8ei2mCO/tpeA==}
     engines: {node: '>=18'}
     hasBin: true
 
@@ -17926,9 +17927,9 @@ snapshots:
   '@pkgjs/parseargs@0.11.0':
     optional: true
 
-  '@playwright/test@1.58.2':
+  '@playwright/test@1.60.0':
     dependencies:
-      playwright: 1.58.2
+      playwright: 1.60.0
 
   '@pnpm/config.env-replace@1.1.0': {}
 
@@ -20783,12 +20784,12 @@ snapshots:
 
   b4a@1.6.6: {}
 
-  babel-plugin-superjson-next@0.4.5(next@16.2.6(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(@playwright/test@1.58.2)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)(sass@1.77.6))(superjson@2.2.2):
+  babel-plugin-superjson-next@0.4.5(next@16.2.6(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(@playwright/test@1.60.0)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)(sass@1.77.6))(superjson@2.2.2):
     dependencies:
       '@babel/helper-module-imports': 7.28.6
       '@babel/types': 7.29.0
       hoist-non-react-statics: 3.3.2
-      next: 16.2.6(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(@playwright/test@1.58.2)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)(sass@1.77.6)
+      next: 16.2.6(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(@playwright/test@1.60.0)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)(sass@1.77.6)
       superjson: 2.2.2
     transitivePeerDependencies:
       - supports-color
@@ -25685,7 +25686,7 @@ snapshots:
     dependencies:
       react: 18.2.0
 
-  next-i18next@15.3.1(i18next@23.16.5)(next@16.2.6(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(@playwright/test@1.58.2)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)(sass@1.77.6))(react-i18next@15.1.1(i18next@23.16.5)(react-dom@18.2.0(react@18.2.0))(react@18.2.0))(react@18.2.0):
+  next-i18next@15.3.1(i18next@23.16.5)(next@16.2.6(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(@playwright/test@1.60.0)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)(sass@1.77.6))(react-i18next@15.1.1(i18next@23.16.5)(react-dom@18.2.0(react@18.2.0))(react@18.2.0))(react@18.2.0):
     dependencies:
       '@babel/runtime': 7.29.7
       '@types/hoist-non-react-statics': 3.3.5
@@ -25693,7 +25694,7 @@ snapshots:
       hoist-non-react-statics: 3.3.2
       i18next: 23.16.5
       i18next-fs-backend: 2.3.2
-      next: 16.2.6(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(@playwright/test@1.58.2)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)(sass@1.77.6)
+      next: 16.2.6(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(@playwright/test@1.60.0)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)(sass@1.77.6)
       react: 18.2.0
       react-i18next: 15.1.1(i18next@23.16.5)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)
 
@@ -25702,7 +25703,7 @@ snapshots:
       react: 18.2.0
       react-dom: 18.2.0(react@18.2.0)
 
-  next@16.2.6(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(@playwright/test@1.58.2)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)(sass@1.77.6):
+  next@16.2.6(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(@playwright/test@1.60.0)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)(sass@1.77.6):
     dependencies:
       '@next/env': 16.2.6
       '@swc/helpers': 0.5.15
@@ -25722,7 +25723,7 @@ snapshots:
       '@next/swc-win32-arm64-msvc': 16.2.6
       '@next/swc-win32-x64-msvc': 16.2.6
       '@opentelemetry/api': 1.9.0
-      '@playwright/test': 1.58.2
+      '@playwright/test': 1.60.0
       sass: 1.77.6
       sharp: 0.34.5
     transitivePeerDependencies:
@@ -26481,11 +26482,11 @@ snapshots:
 
   plantuml-encoder@1.4.0: {}
 
-  playwright-core@1.58.2: {}
+  playwright-core@1.60.0: {}
 
-  playwright@1.58.2:
+  playwright@1.60.0:
     dependencies:
-      playwright-core: 1.58.2
+      playwright-core: 1.60.0
     optionalDependencies:
       fsevents: 2.3.2