From ef0d18bb61a228d6b2449ddf539862110badada3 Mon Sep 17 00:00:00 2001 From: Stephen Zhou <38493346+hyoban@users.noreply.github.com> Date: Thu, 5 Feb 2026 14:31:21 +0800 Subject: [PATCH 1/3] test: fix test (#31975) --- .github/workflows/web-tests.yml | 2 +- web/app/components/base/tooltip/index.tsx | 29 +++++++++++++++++-- .../create/common-modal.spec.tsx | 29 ++++++++++++------- .../components/update-dsl-modal.spec.tsx | 11 +++++++ web/package.json | 6 ++-- web/pnpm-lock.yaml | 15 ++++++++++ 6 files changed, 75 insertions(+), 17 deletions(-) diff --git a/.github/workflows/web-tests.yml b/.github/workflows/web-tests.yml index 191ce56aaa..78d0b2af40 100644 --- a/.github/workflows/web-tests.yml +++ b/.github/workflows/web-tests.yml @@ -39,7 +39,7 @@ jobs: run: pnpm install --frozen-lockfile - name: Run tests - run: pnpm test:coverage + run: pnpm test:ci - name: Coverage Summary if: always() diff --git a/web/app/components/base/tooltip/index.tsx b/web/app/components/base/tooltip/index.tsx index ee9928745d..d1047ff902 100644 --- a/web/app/components/base/tooltip/index.tsx +++ b/web/app/components/base/tooltip/index.tsx @@ -4,7 +4,7 @@ import type { FC } from 'react' import { RiQuestionLine } from '@remixicon/react' import { useBoolean } from 'ahooks' import * as React from 'react' -import { useEffect, useRef, useState } from 'react' +import { useCallback, useEffect, useRef, useState } from 'react' import { PortalToFollowElem, PortalToFollowElemContent, PortalToFollowElemTrigger } from '@/app/components/base/portal-to-follow-elem' import { cn } from '@/utils/classnames' import { tooltipManager } from './TooltipManager' @@ -61,6 +61,20 @@ const Tooltip: FC = ({ isHoverTriggerRef.current = isHoverTrigger }, [isHoverTrigger]) + const closeTimeoutRef = useRef | null>(null) + const clearCloseTimeout = useCallback(() => { + if (closeTimeoutRef.current) { + clearTimeout(closeTimeoutRef.current) + closeTimeoutRef.current = null + } + }, []) + + useEffect(() => { + return () => { + clearCloseTimeout() + } + }, [clearCloseTimeout]) + const close = () => setOpen(false) const handleLeave = (isTrigger: boolean) => { @@ -71,7 +85,9 @@ const Tooltip: FC = ({ // give time to move to the popup if (needsDelay) { - setTimeout(() => { + clearCloseTimeout() + closeTimeoutRef.current = setTimeout(() => { + closeTimeoutRef.current = null if (!isHoverPopupRef.current && !isHoverTriggerRef.current) { setOpen(false) tooltipManager.clear(close) @@ -79,6 +95,7 @@ const Tooltip: FC = ({ }, 300) } else { + clearCloseTimeout() setOpen(false) tooltipManager.clear(close) } @@ -95,6 +112,7 @@ const Tooltip: FC = ({ onClick={() => triggerMethod === 'click' && setOpen(v => !v)} onMouseEnter={() => { if (triggerMethod === 'hover') { + clearCloseTimeout() setHoverTrigger() tooltipManager.register(close) setOpen(true) @@ -115,7 +133,12 @@ const Tooltip: FC = ({ !noDecoration && 'system-xs-regular relative max-w-[300px] break-words rounded-md bg-components-panel-bg px-3 py-2 text-left text-text-tertiary shadow-lg', popupClassName, )} - onMouseEnter={() => triggerMethod === 'hover' && setHoverPopup()} + onMouseEnter={() => { + if (triggerMethod === 'hover') { + clearCloseTimeout() + setHoverPopup() + } + }} onMouseLeave={() => triggerMethod === 'hover' && handleLeave(false)} > {popupContent} diff --git a/web/app/components/plugins/plugin-detail-panel/subscription-list/create/common-modal.spec.tsx b/web/app/components/plugins/plugin-detail-panel/subscription-list/create/common-modal.spec.tsx index 0c1b5efc29..6edb493e17 100644 --- a/web/app/components/plugins/plugin-detail-panel/subscription-list/create/common-modal.spec.tsx +++ b/web/app/components/plugins/plugin-detail-panel/subscription-list/create/common-modal.spec.tsx @@ -599,20 +599,30 @@ describe('CommonCreateModal', () => { }, }) mockUsePluginStore.mockReturnValue(detailWithCredentials) + const existingBuilder = createMockSubscriptionBuilder() mockVerifyCredentials.mockImplementation((params, { onSuccess }) => { onSuccess() }) - render() - - await waitFor(() => { - expect(mockCreateBuilder).toHaveBeenCalled() - }) + render() fireEvent.click(screen.getByTestId('modal-confirm')) await waitFor(() => { - expect(mockVerifyCredentials).toHaveBeenCalled() + expect(mockVerifyCredentials).toHaveBeenCalledWith( + expect.objectContaining({ + provider: 'test-provider', + subscriptionBuilderId: existingBuilder.id, + }), + expect.objectContaining({ + onSuccess: expect.any(Function), + onError: expect.any(Function), + }), + ) + }) + + await waitFor(() => { + expect(screen.getByTestId('modal-confirm')).toHaveTextContent('pluginTrigger.modal.common.create') }) }) @@ -629,15 +639,12 @@ describe('CommonCreateModal', () => { }, }) mockUsePluginStore.mockReturnValue(detailWithCredentials) + const existingBuilder = createMockSubscriptionBuilder() mockVerifyCredentials.mockImplementation((params, { onError }) => { onError(new Error('Verification failed')) }) - render() - - await waitFor(() => { - expect(mockCreateBuilder).toHaveBeenCalled() - }) + render() fireEvent.click(screen.getByTestId('modal-confirm')) diff --git a/web/app/components/rag-pipeline/components/update-dsl-modal.spec.tsx b/web/app/components/rag-pipeline/components/update-dsl-modal.spec.tsx index 317f2b19d4..6643d8239d 100644 --- a/web/app/components/rag-pipeline/components/update-dsl-modal.spec.tsx +++ b/web/app/components/rag-pipeline/components/update-dsl-modal.spec.tsx @@ -4,6 +4,17 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { DSLImportStatus } from '@/models/app' import UpdateDSLModal from './update-dsl-modal' +class MockFileReader { + onload: ((this: FileReader, event: ProgressEvent) => void) | null = null + + readAsText(_file: Blob) { + const event = { target: { result: 'test content' } } as unknown as ProgressEvent + this.onload?.call(this as unknown as FileReader, event) + } +} + +vi.stubGlobal('FileReader', MockFileReader as unknown as typeof FileReader) + // Mock react-i18next vi.mock('react-i18next', () => ({ useTranslation: () => ({ diff --git a/web/package.json b/web/package.json index 219a613363..b37a46681f 100644 --- a/web/package.json +++ b/web/package.json @@ -46,7 +46,8 @@ "uglify-embed": "node ./bin/uglify-embed", "i18n:check": "tsx ./scripts/check-i18n.js", "test": "vitest run", - "test:coverage": "vitest run --coverage --reporter=dot --silent=passed-only", + "test:coverage": "vitest run --coverage", + "test:ci": "vitest run --coverage --reporter vitest-tiny-reporter --silent=passed-only", "test:watch": "vitest --watch", "analyze-component": "node ./scripts/analyze-component.js", "refactor-component": "node ./scripts/refactor-component.js", @@ -234,7 +235,8 @@ "vite": "7.3.1", "vite-tsconfig-paths": "6.0.4", "vitest": "4.0.17", - "vitest-canvas-mock": "1.1.3" + "vitest-canvas-mock": "1.1.3", + "vitest-tiny-reporter": "1.3.1" }, "pnpm": { "overrides": { diff --git a/web/pnpm-lock.yaml b/web/pnpm-lock.yaml index 9119d2554a..5266b4ac6f 100644 --- a/web/pnpm-lock.yaml +++ b/web/pnpm-lock.yaml @@ -582,6 +582,9 @@ importers: vitest-canvas-mock: specifier: 1.1.3 version: 1.1.3(vitest@4.0.17) + vitest-tiny-reporter: + specifier: 1.3.1 + version: 1.3.1(@vitest/runner@4.0.17)(vitest@4.0.17) packages: @@ -7230,6 +7233,12 @@ packages: peerDependencies: vitest: ^3.0.0 || ^4.0.0 + vitest-tiny-reporter@1.3.1: + resolution: {integrity: sha512-9WfLruQBbxm4EqMIS0jDZmQjvMgsWgHUso9mHQWgjA6hM3tEVhjdG8wYo7ePFh1XbwEFzEo3XUQqkGoKZ/Td2Q==} + peerDependencies: + '@vitest/runner': ^2.0.0 || ^3.0.2 || ^4.0.0 + vitest: ^2.0.0 || ^3.0.2 || ^4.0.0 + vitest@4.0.17: resolution: {integrity: sha512-FQMeF0DJdWY0iOnbv466n/0BudNdKj1l5jYgl5JVTwjSsZSlqyXFt/9+1sEyhR6CLowbZpV7O1sCHrzBhucKKg==} engines: {node: ^20.0.0 || ^22.0.0 || >=24.0.0} @@ -15228,6 +15237,12 @@ snapshots: moo-color: 1.0.3 vitest: 4.0.17(@types/node@18.15.0)(@vitest/browser-playwright@4.0.17)(jiti@1.21.7)(jsdom@27.3.0(canvas@3.2.1))(sass@1.93.2)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2) + vitest-tiny-reporter@1.3.1(@vitest/runner@4.0.17)(vitest@4.0.17): + dependencies: + '@vitest/runner': 4.0.17 + tinyrainbow: 3.0.3 + vitest: 4.0.17(@types/node@18.15.0)(@vitest/browser-playwright@4.0.17)(jiti@1.21.7)(jsdom@27.3.0(canvas@3.2.1))(sass@1.93.2)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2) + vitest@4.0.17(@types/node@18.15.0)(@vitest/browser-playwright@4.0.17)(jiti@1.21.7)(jsdom@27.3.0(canvas@3.2.1))(sass@1.93.2)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2): dependencies: '@vitest/expect': 4.0.17 From b886b3f6c8a0305aa58961d1a861b8b982eb96cf Mon Sep 17 00:00:00 2001 From: wangxiaolei Date: Thu, 5 Feb 2026 14:42:34 +0800 Subject: [PATCH 2/3] fix: fix miss use db.session (#31971) --- api/tasks/document_indexing_update_task.py | 6 +- .../test_document_indexing_update_task.py | 182 ++++++++++++++++++ 2 files changed, 184 insertions(+), 4 deletions(-) create mode 100644 api/tests/test_containers_integration_tests/tasks/test_document_indexing_update_task.py diff --git a/api/tasks/document_indexing_update_task.py b/api/tasks/document_indexing_update_task.py index 67a23be952..45d58c92ec 100644 --- a/api/tasks/document_indexing_update_task.py +++ b/api/tasks/document_indexing_update_task.py @@ -8,7 +8,6 @@ from sqlalchemy import delete, select from core.db.session_factory import session_factory from core.indexing_runner import DocumentIsPausedError, IndexingRunner from core.rag.index_processor.index_processor_factory import IndexProcessorFactory -from extensions.ext_database import db from libs.datetime_utils import naive_utc_now from models.dataset import Dataset, Document, DocumentSegment @@ -27,7 +26,7 @@ def document_indexing_update_task(dataset_id: str, document_id: str): logger.info(click.style(f"Start update document: {document_id}", fg="green")) start_at = time.perf_counter() - with session_factory.create_session() as session: + with session_factory.create_session() as session, session.begin(): document = session.query(Document).where(Document.id == document_id, Document.dataset_id == dataset_id).first() if not document: @@ -36,7 +35,6 @@ def document_indexing_update_task(dataset_id: str, document_id: str): document.indexing_status = "parsing" document.processing_started_at = naive_utc_now() - session.commit() # delete all document segment and index try: @@ -56,7 +54,7 @@ def document_indexing_update_task(dataset_id: str, document_id: str): segment_ids = [segment.id for segment in segments] segment_delete_stmt = delete(DocumentSegment).where(DocumentSegment.id.in_(segment_ids)) session.execute(segment_delete_stmt) - db.session.commit() + end_at = time.perf_counter() logger.info( click.style( diff --git a/api/tests/test_containers_integration_tests/tasks/test_document_indexing_update_task.py b/api/tests/test_containers_integration_tests/tasks/test_document_indexing_update_task.py new file mode 100644 index 0000000000..7f37f84113 --- /dev/null +++ b/api/tests/test_containers_integration_tests/tasks/test_document_indexing_update_task.py @@ -0,0 +1,182 @@ +from unittest.mock import MagicMock, patch + +import pytest +from faker import Faker + +from models import Account, Tenant, TenantAccountJoin, TenantAccountRole +from models.dataset import Dataset, Document, DocumentSegment +from tasks.document_indexing_update_task import document_indexing_update_task + + +class TestDocumentIndexingUpdateTask: + @pytest.fixture + def mock_external_dependencies(self): + """Patch external collaborators used by the update task. + - IndexProcessorFactory.init_index_processor().clean(...) + - IndexingRunner.run([...]) + """ + with ( + patch("tasks.document_indexing_update_task.IndexProcessorFactory") as mock_factory, + patch("tasks.document_indexing_update_task.IndexingRunner") as mock_runner, + ): + processor_instance = MagicMock() + mock_factory.return_value.init_index_processor.return_value = processor_instance + + runner_instance = MagicMock() + mock_runner.return_value = runner_instance + + yield { + "factory": mock_factory, + "processor": processor_instance, + "runner": mock_runner, + "runner_instance": runner_instance, + } + + def _create_dataset_document_with_segments(self, db_session_with_containers, *, segment_count: int = 2): + fake = Faker() + + # Account and tenant + account = Account( + email=fake.email(), + name=fake.name(), + interface_language="en-US", + status="active", + ) + db_session_with_containers.add(account) + db_session_with_containers.commit() + + tenant = Tenant(name=fake.company(), status="normal") + db_session_with_containers.add(tenant) + db_session_with_containers.commit() + + join = TenantAccountJoin( + tenant_id=tenant.id, + account_id=account.id, + role=TenantAccountRole.OWNER, + current=True, + ) + db_session_with_containers.add(join) + db_session_with_containers.commit() + + # Dataset and document + dataset = Dataset( + tenant_id=tenant.id, + name=fake.company(), + description=fake.text(max_nb_chars=64), + data_source_type="upload_file", + indexing_technique="high_quality", + created_by=account.id, + ) + db_session_with_containers.add(dataset) + db_session_with_containers.commit() + + document = Document( + tenant_id=tenant.id, + dataset_id=dataset.id, + position=0, + data_source_type="upload_file", + batch="test_batch", + name=fake.file_name(), + created_from="upload_file", + created_by=account.id, + indexing_status="waiting", + enabled=True, + doc_form="text_model", + ) + db_session_with_containers.add(document) + db_session_with_containers.commit() + + # Segments + node_ids = [] + for i in range(segment_count): + node_id = f"node-{i + 1}" + seg = DocumentSegment( + tenant_id=tenant.id, + dataset_id=dataset.id, + document_id=document.id, + position=i, + content=fake.text(max_nb_chars=32), + answer=None, + word_count=10, + tokens=5, + index_node_id=node_id, + status="completed", + created_by=account.id, + ) + db_session_with_containers.add(seg) + node_ids.append(node_id) + db_session_with_containers.commit() + + # Refresh to ensure ORM state + db_session_with_containers.refresh(dataset) + db_session_with_containers.refresh(document) + + return dataset, document, node_ids + + def test_cleans_segments_and_reindexes(self, db_session_with_containers, mock_external_dependencies): + dataset, document, node_ids = self._create_dataset_document_with_segments(db_session_with_containers) + + # Act + document_indexing_update_task(dataset.id, document.id) + + # Ensure we see committed changes from another session + db_session_with_containers.expire_all() + + # Assert document status updated before reindex + updated = db_session_with_containers.query(Document).where(Document.id == document.id).first() + assert updated.indexing_status == "parsing" + assert updated.processing_started_at is not None + + # Segments should be deleted + remaining = ( + db_session_with_containers.query(DocumentSegment).where(DocumentSegment.document_id == document.id).count() + ) + assert remaining == 0 + + # Assert index processor clean was called with expected args + clean_call = mock_external_dependencies["processor"].clean.call_args + assert clean_call is not None + args, kwargs = clean_call + # args[0] is a Dataset instance (from another session) — validate by id + assert getattr(args[0], "id", None) == dataset.id + # args[1] should contain our node_ids + assert set(args[1]) == set(node_ids) + assert kwargs.get("with_keywords") is True + assert kwargs.get("delete_child_chunks") is True + + # Assert indexing runner invoked with the updated document + run_call = mock_external_dependencies["runner_instance"].run.call_args + assert run_call is not None + run_docs = run_call[0][0] + assert len(run_docs) == 1 + first = run_docs[0] + assert getattr(first, "id", None) == document.id + + def test_clean_error_is_logged_and_indexing_continues(self, db_session_with_containers, mock_external_dependencies): + dataset, document, node_ids = self._create_dataset_document_with_segments(db_session_with_containers) + + # Force clean to raise; task should continue to indexing + mock_external_dependencies["processor"].clean.side_effect = Exception("boom") + + document_indexing_update_task(dataset.id, document.id) + + # Ensure we see committed changes from another session + db_session_with_containers.expire_all() + + # Indexing should still be triggered + mock_external_dependencies["runner_instance"].run.assert_called_once() + + # Segments should remain (since clean failed before DB delete) + remaining = ( + db_session_with_containers.query(DocumentSegment).where(DocumentSegment.document_id == document.id).count() + ) + assert remaining > 0 + + def test_document_not_found_noop(self, db_session_with_containers, mock_external_dependencies): + fake = Faker() + # Act with non-existent document id + document_indexing_update_task(dataset_id=fake.uuid4(), document_id=fake.uuid4()) + + # Neither processor nor runner should be called + mock_external_dependencies["processor"].clean.assert_not_called() + mock_external_dependencies["runner_instance"].run.assert_not_called() From 8c31b69c8e03dcfa72696ff650bacc9c90c4db61 Mon Sep 17 00:00:00 2001 From: Joel Date: Thu, 5 Feb 2026 14:44:51 +0800 Subject: [PATCH 3/3] chore: sticky the applist header in explore page (#31967) --- web/app/components/explore/app-list/index.tsx | 121 +++++++++--------- web/app/components/explore/index.tsx | 2 +- 2 files changed, 64 insertions(+), 59 deletions(-) diff --git a/web/app/components/explore/app-list/index.tsx b/web/app/components/explore/app-list/index.tsx index 04f75107da..5021185a03 100644 --- a/web/app/components/explore/app-list/index.tsx +++ b/web/app/components/explore/app-list/index.tsx @@ -159,69 +159,74 @@ const Apps = ({ return (
- {systemFeatures.enable_explore_banner && ( -
- -
- )} -
-
-
{!hasFilterCondition ? t('apps.title', { ns: 'explore' }) : t('apps.resultNum', { num: searchFilteredList.length, ns: 'explore' })}
- {hasFilterCondition && ( - <> -
- - - )} -
- handleKeywordsChange(e.target.value)} - onClear={() => handleKeywordsChange('')} - /> -
+
+ {systemFeatures.enable_explore_banner && ( +
+ +
+ )} -
- -
- -
- +
+ +
+ +
+
+ +
+ +
{isShowCreateModal && ( = ({ } > -
+
{children}