diff --git a/.github/workflows/main-ci.yml b/.github/workflows/main-ci.yml index 4f00a9101c..a19cb50abc 100644 --- a/.github/workflows/main-ci.yml +++ b/.github/workflows/main-ci.yml @@ -63,8 +63,9 @@ jobs: if: needs.check-changes.outputs.web-changed == 'true' uses: ./.github/workflows/web-tests.yml with: - base_sha: ${{ github.event_name == 'pull_request' && github.event.pull_request.base.sha || github.event.before }} - head_sha: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.sha }} + base_sha: ${{ github.event.before || github.event.pull_request.base.sha }} + diff_range_mode: ${{ github.event.before && 'exact' || 'merge-base' }} + head_sha: ${{ github.event.after || github.event.pull_request.head.sha || github.sha }} style-check: name: Style Check diff --git a/.github/workflows/web-tests.yml b/.github/workflows/web-tests.yml index 443a22ea93..11222146cf 100644 --- a/.github/workflows/web-tests.yml +++ b/.github/workflows/web-tests.yml @@ -6,6 +6,9 @@ on: base_sha: required: false type: string + diff_range_mode: + required: false + type: string head_sha: required: false type: string @@ -86,13 +89,24 @@ jobs: - name: Merge reports run: vp test --merge-reports --reporter=json --reporter=agent --coverage - - name: Check app/components diff coverage + - name: Report app/components baseline coverage + run: node ./scripts/report-components-coverage-baseline.mjs + + - name: Report app/components test touch env: BASE_SHA: ${{ inputs.base_sha }} + DIFF_RANGE_MODE: ${{ inputs.diff_range_mode }} + HEAD_SHA: ${{ inputs.head_sha }} + run: node ./scripts/report-components-test-touch.mjs + + - name: Check app/components pure diff coverage + env: + BASE_SHA: ${{ inputs.base_sha }} + DIFF_RANGE_MODE: ${{ inputs.diff_range_mode }} HEAD_SHA: ${{ inputs.head_sha }} run: node ./scripts/check-components-diff-coverage.mjs - - name: Coverage Summary + - name: Check Coverage Summary if: always() id: coverage-summary run: | @@ -101,313 +115,15 @@ jobs: COVERAGE_FILE="coverage/coverage-final.json" COVERAGE_SUMMARY_FILE="coverage/coverage-summary.json" - if [ ! -f "$COVERAGE_FILE" ] && [ ! -f "$COVERAGE_SUMMARY_FILE" ]; then - echo "has_coverage=false" >> "$GITHUB_OUTPUT" - echo "### 🚨 Test Coverage Report :test_tube:" >> "$GITHUB_STEP_SUMMARY" - echo "Coverage data not found. Ensure Vitest runs with coverage enabled." >> "$GITHUB_STEP_SUMMARY" + if [ -f "$COVERAGE_FILE" ] || [ -f "$COVERAGE_SUMMARY_FILE" ]; then + echo "has_coverage=true" >> "$GITHUB_OUTPUT" exit 0 fi - echo "has_coverage=true" >> "$GITHUB_OUTPUT" - - node <<'NODE' >> "$GITHUB_STEP_SUMMARY" - const fs = require('fs'); - const path = require('path'); - let libCoverage = null; - - try { - libCoverage = require('istanbul-lib-coverage'); - } catch (error) { - libCoverage = null; - } - - const summaryPath = path.join('coverage', 'coverage-summary.json'); - const finalPath = path.join('coverage', 'coverage-final.json'); - - const hasSummary = fs.existsSync(summaryPath); - const hasFinal = fs.existsSync(finalPath); - - if (!hasSummary && !hasFinal) { - console.log('### Test Coverage Summary :test_tube:'); - console.log(''); - console.log('No coverage data found.'); - process.exit(0); - } - - const summary = hasSummary - ? JSON.parse(fs.readFileSync(summaryPath, 'utf8')) - : null; - const coverage = hasFinal - ? JSON.parse(fs.readFileSync(finalPath, 'utf8')) - : null; - - const getLineCoverageFromStatements = (statementMap, statementHits) => { - const lineHits = {}; - - if (!statementMap || !statementHits) { - return lineHits; - } - - Object.entries(statementMap).forEach(([key, statement]) => { - const line = statement?.start?.line; - if (!line) { - return; - } - const hits = statementHits[key] ?? 0; - const previous = lineHits[line]; - lineHits[line] = previous === undefined ? hits : Math.max(previous, hits); - }); - - return lineHits; - }; - - const getFileCoverage = (entry) => ( - libCoverage ? libCoverage.createFileCoverage(entry) : null - ); - - const getLineHits = (entry, fileCoverage) => { - const lineHits = entry.l ?? {}; - if (Object.keys(lineHits).length > 0) { - return lineHits; - } - if (fileCoverage) { - return fileCoverage.getLineCoverage(); - } - return getLineCoverageFromStatements(entry.statementMap ?? {}, entry.s ?? {}); - }; - - const getUncoveredLines = (entry, fileCoverage, lineHits) => { - if (lineHits && Object.keys(lineHits).length > 0) { - return Object.entries(lineHits) - .filter(([, count]) => count === 0) - .map(([line]) => Number(line)) - .sort((a, b) => a - b); - } - if (fileCoverage) { - return fileCoverage.getUncoveredLines(); - } - return []; - }; - - const totals = { - lines: { covered: 0, total: 0 }, - statements: { covered: 0, total: 0 }, - branches: { covered: 0, total: 0 }, - functions: { covered: 0, total: 0 }, - }; - const fileSummaries = []; - - if (summary) { - const totalEntry = summary.total ?? {}; - ['lines', 'statements', 'branches', 'functions'].forEach((key) => { - if (totalEntry[key]) { - totals[key].covered = totalEntry[key].covered ?? 0; - totals[key].total = totalEntry[key].total ?? 0; - } - }); - - Object.entries(summary) - .filter(([file]) => file !== 'total') - .forEach(([file, data]) => { - fileSummaries.push({ - file, - pct: data.lines?.pct ?? data.statements?.pct ?? 0, - lines: { - covered: data.lines?.covered ?? 0, - total: data.lines?.total ?? 0, - }, - }); - }); - } else if (coverage) { - Object.entries(coverage).forEach(([file, entry]) => { - const fileCoverage = getFileCoverage(entry); - const lineHits = getLineHits(entry, fileCoverage); - const statementHits = entry.s ?? {}; - const branchHits = entry.b ?? {}; - const functionHits = entry.f ?? {}; - - const lineTotal = Object.keys(lineHits).length; - const lineCovered = Object.values(lineHits).filter((n) => n > 0).length; - - const statementTotal = Object.keys(statementHits).length; - const statementCovered = Object.values(statementHits).filter((n) => n > 0).length; - - const branchTotal = Object.values(branchHits).reduce((acc, branches) => acc + branches.length, 0); - const branchCovered = Object.values(branchHits).reduce( - (acc, branches) => acc + branches.filter((n) => n > 0).length, - 0, - ); - - const functionTotal = Object.keys(functionHits).length; - const functionCovered = Object.values(functionHits).filter((n) => n > 0).length; - - totals.lines.total += lineTotal; - totals.lines.covered += lineCovered; - totals.statements.total += statementTotal; - totals.statements.covered += statementCovered; - totals.branches.total += branchTotal; - totals.branches.covered += branchCovered; - totals.functions.total += functionTotal; - totals.functions.covered += functionCovered; - - const pct = (covered, tot) => (tot > 0 ? (covered / tot) * 100 : 0); - - fileSummaries.push({ - file, - pct: pct(lineCovered || statementCovered, lineTotal || statementTotal), - lines: { - covered: lineCovered || statementCovered, - total: lineTotal || statementTotal, - }, - }); - }); - } - - const pct = (covered, tot) => (tot > 0 ? ((covered / tot) * 100).toFixed(2) : '0.00'); - - console.log('### Test Coverage Summary :test_tube:'); - console.log(''); - console.log('| Metric | Coverage | Covered / Total |'); - console.log('|--------|----------|-----------------|'); - console.log(`| Lines | ${pct(totals.lines.covered, totals.lines.total)}% | ${totals.lines.covered} / ${totals.lines.total} |`); - console.log(`| Statements | ${pct(totals.statements.covered, totals.statements.total)}% | ${totals.statements.covered} / ${totals.statements.total} |`); - console.log(`| Branches | ${pct(totals.branches.covered, totals.branches.total)}% | ${totals.branches.covered} / ${totals.branches.total} |`); - console.log(`| Functions | ${pct(totals.functions.covered, totals.functions.total)}% | ${totals.functions.covered} / ${totals.functions.total} |`); - - console.log(''); - console.log('
File coverage (lowest lines first)'); - console.log(''); - console.log('```'); - fileSummaries - .sort((a, b) => (a.pct - b.pct) || (b.lines.total - a.lines.total)) - .slice(0, 25) - .forEach(({ file, pct, lines }) => { - console.log(`${pct.toFixed(2)}%\t${lines.covered}/${lines.total}\t${file}`); - }); - console.log('```'); - console.log('
'); - - if (coverage) { - const pctValue = (covered, tot) => { - if (tot === 0) { - return '0'; - } - return ((covered / tot) * 100) - .toFixed(2) - .replace(/\.?0+$/, ''); - }; - - const formatLineRanges = (lines) => { - if (lines.length === 0) { - return ''; - } - const ranges = []; - let start = lines[0]; - let end = lines[0]; - - for (let i = 1; i < lines.length; i += 1) { - const current = lines[i]; - if (current === end + 1) { - end = current; - continue; - } - ranges.push(start === end ? `${start}` : `${start}-${end}`); - start = current; - end = current; - } - ranges.push(start === end ? `${start}` : `${start}-${end}`); - return ranges.join(','); - }; - - const tableTotals = { - statements: { covered: 0, total: 0 }, - branches: { covered: 0, total: 0 }, - functions: { covered: 0, total: 0 }, - lines: { covered: 0, total: 0 }, - }; - const tableRows = Object.entries(coverage) - .map(([file, entry]) => { - const fileCoverage = getFileCoverage(entry); - const lineHits = getLineHits(entry, fileCoverage); - const statementHits = entry.s ?? {}; - const branchHits = entry.b ?? {}; - const functionHits = entry.f ?? {}; - - const lineTotal = Object.keys(lineHits).length; - const lineCovered = Object.values(lineHits).filter((n) => n > 0).length; - const statementTotal = Object.keys(statementHits).length; - const statementCovered = Object.values(statementHits).filter((n) => n > 0).length; - const branchTotal = Object.values(branchHits).reduce((acc, branches) => acc + branches.length, 0); - const branchCovered = Object.values(branchHits).reduce( - (acc, branches) => acc + branches.filter((n) => n > 0).length, - 0, - ); - const functionTotal = Object.keys(functionHits).length; - const functionCovered = Object.values(functionHits).filter((n) => n > 0).length; - - tableTotals.lines.total += lineTotal; - tableTotals.lines.covered += lineCovered; - tableTotals.statements.total += statementTotal; - tableTotals.statements.covered += statementCovered; - tableTotals.branches.total += branchTotal; - tableTotals.branches.covered += branchCovered; - tableTotals.functions.total += functionTotal; - tableTotals.functions.covered += functionCovered; - - const uncoveredLines = getUncoveredLines(entry, fileCoverage, lineHits); - - const filePath = entry.path ?? file; - const relativePath = path.isAbsolute(filePath) - ? path.relative(process.cwd(), filePath) - : filePath; - - return { - file: relativePath || file, - statements: pctValue(statementCovered, statementTotal), - branches: pctValue(branchCovered, branchTotal), - functions: pctValue(functionCovered, functionTotal), - lines: pctValue(lineCovered, lineTotal), - uncovered: formatLineRanges(uncoveredLines), - }; - }) - .sort((a, b) => a.file.localeCompare(b.file)); - - const columns = [ - { key: 'file', header: 'File', align: 'left' }, - { key: 'statements', header: '% Stmts', align: 'right' }, - { key: 'branches', header: '% Branch', align: 'right' }, - { key: 'functions', header: '% Funcs', align: 'right' }, - { key: 'lines', header: '% Lines', align: 'right' }, - { key: 'uncovered', header: 'Uncovered Line #s', align: 'left' }, - ]; - - const allFilesRow = { - file: 'All files', - statements: pctValue(tableTotals.statements.covered, tableTotals.statements.total), - branches: pctValue(tableTotals.branches.covered, tableTotals.branches.total), - functions: pctValue(tableTotals.functions.covered, tableTotals.functions.total), - lines: pctValue(tableTotals.lines.covered, tableTotals.lines.total), - uncovered: '', - }; - - const rowsForOutput = [allFilesRow, ...tableRows]; - const formatRow = (row) => `| ${columns - .map(({ key }) => String(row[key] ?? '')) - .join(' | ')} |`; - const headerRow = `| ${columns.map(({ header }) => header).join(' | ')} |`; - const dividerRow = `| ${columns - .map(({ align }) => (align === 'right' ? '---:' : ':---')) - .join(' | ')} |`; - - console.log(''); - console.log('
Vitest coverage table'); - console.log(''); - console.log(headerRow); - console.log(dividerRow); - rowsForOutput.forEach((row) => console.log(formatRow(row))); - console.log('
'); - } - NODE + echo "has_coverage=false" >> "$GITHUB_OUTPUT" + echo "### 🚨 app/components Diff Coverage" >> "$GITHUB_STEP_SUMMARY" + echo "" >> "$GITHUB_STEP_SUMMARY" + echo "Coverage artifacts not found. Ensure Vitest merge reports ran with coverage enabled." >> "$GITHUB_STEP_SUMMARY" - name: Upload Coverage Artifact if: steps.coverage-summary.outputs.has_coverage == 'true' diff --git a/api/.env.example b/api/.env.example index 8195a3c074..40e1c2dfdf 100644 --- a/api/.env.example +++ b/api/.env.example @@ -737,24 +737,25 @@ SANDBOX_EXPIRED_RECORDS_RETENTION_DAYS=30 SANDBOX_EXPIRED_RECORDS_CLEAN_TASK_LOCK_TTL=90000 -# Redis URL used for PubSub between API and +# Redis URL used for event bus between API and # celery worker # defaults to url constructed from `REDIS_*` # configurations -PUBSUB_REDIS_URL= -# Pub/sub channel type for streaming events. -# valid options are: +EVENT_BUS_REDIS_URL= +# Event transport type. Options are: # -# - pubsub: for normal Pub/Sub -# - sharded: for sharded Pub/Sub +# - pubsub: normal Pub/Sub (at-most-once) +# - sharded: sharded Pub/Sub (at-most-once) +# - streams: Redis Streams (at-least-once, recommended to avoid subscriber races) # -# It's highly recommended to use sharded Pub/Sub AND redis cluster -# for large deployments. -PUBSUB_REDIS_CHANNEL_TYPE=pubsub -# Whether to use Redis cluster mode while running -# PubSub. +# Note: Before enabling 'streams' in production, estimate your expected event volume and retention needs. +# Configure Redis memory limits and stream trimming appropriately (e.g., MAXLEN and key expiry) to reduce +# the risk of data loss from Redis auto-eviction under memory pressure. +# Also accepts ENV: EVENT_BUS_REDIS_CHANNEL_TYPE. +EVENT_BUS_REDIS_CHANNEL_TYPE=pubsub +# Whether to use Redis cluster mode while use redis as event bus. # It's highly recommended to enable this for large deployments. -PUBSUB_REDIS_USE_CLUSTERS=false +EVENT_BUS_REDIS_USE_CLUSTERS=false # Whether to Enable human input timeout check task ENABLE_HUMAN_INPUT_TIMEOUT_TASK=true diff --git a/api/configs/middleware/cache/redis_pubsub_config.py b/api/configs/middleware/cache/redis_pubsub_config.py index 8cddc5677a..d30831a0ec 100644 --- a/api/configs/middleware/cache/redis_pubsub_config.py +++ b/api/configs/middleware/cache/redis_pubsub_config.py @@ -41,10 +41,10 @@ class RedisPubSubConfig(BaseSettings, RedisConfigDefaultsMixin): ) PUBSUB_REDIS_USE_CLUSTERS: bool = Field( - validation_alias=AliasChoices("EVENT_BUS_REDIS_CLUSTERS", "PUBSUB_REDIS_USE_CLUSTERS"), + validation_alias=AliasChoices("EVENT_BUS_REDIS_USE_CLUSTERS", "PUBSUB_REDIS_USE_CLUSTERS"), description=( "Enable Redis Cluster mode for pub/sub or streams transport. Recommended for large deployments. " - "Also accepts ENV: EVENT_BUS_REDIS_CLUSTERS." + "Also accepts ENV: EVENT_BUS_REDIS_USE_CLUSTERS." ), default=False, ) diff --git a/api/core/indexing_runner.py b/api/core/indexing_runner.py index 7eebd9ec95..275c1fc110 100644 --- a/api/core/indexing_runner.py +++ b/api/core/indexing_runner.py @@ -5,6 +5,7 @@ import re import threading import time import uuid +from collections.abc import Mapping from typing import Any from flask import Flask, current_app @@ -37,7 +38,7 @@ from extensions.ext_storage import storage from libs import helper from libs.datetime_utils import naive_utc_now from models import Account -from models.dataset import ChildChunk, Dataset, DatasetProcessRule, DocumentSegment +from models.dataset import AutomaticRulesConfig, ChildChunk, Dataset, DatasetProcessRule, DocumentSegment from models.dataset import Document as DatasetDocument from models.model import UploadFile from services.feature_service import FeatureService @@ -265,7 +266,7 @@ class IndexingRunner: self, tenant_id: str, extract_settings: list[ExtractSetting], - tmp_processing_rule: dict, + tmp_processing_rule: Mapping[str, Any], doc_form: str | None = None, doc_language: str = "English", dataset_id: str | None = None, @@ -376,7 +377,7 @@ class IndexingRunner: return IndexingEstimate(total_segments=total_segments, preview=preview_texts) def _extract( - self, index_processor: BaseIndexProcessor, dataset_document: DatasetDocument, process_rule: dict + self, index_processor: BaseIndexProcessor, dataset_document: DatasetDocument, process_rule: Mapping[str, Any] ) -> list[Document]: data_source_info = dataset_document.data_source_info_dict text_docs = [] @@ -543,6 +544,7 @@ class IndexingRunner: """ Clean the document text according to the processing rules. """ + rules: AutomaticRulesConfig | dict[str, Any] if processing_rule.mode == "automatic": rules = DatasetProcessRule.AUTOMATIC_RULES else: @@ -756,7 +758,7 @@ class IndexingRunner: dataset: Dataset, text_docs: list[Document], doc_language: str, - process_rule: dict, + process_rule: Mapping[str, Any], current_user: Account | None = None, ) -> list[Document]: # get embedding model instance diff --git a/api/core/mcp/auth/auth_flow.py b/api/core/mcp/auth/auth_flow.py index aef1afb235..d015769b54 100644 --- a/api/core/mcp/auth/auth_flow.py +++ b/api/core/mcp/auth/auth_flow.py @@ -55,15 +55,31 @@ def build_protected_resource_metadata_discovery_urls( """ urls = [] + parsed_server_url = urlparse(server_url) + base_url = f"{parsed_server_url.scheme}://{parsed_server_url.netloc}" + path = parsed_server_url.path.rstrip("/") + # First priority: URL from WWW-Authenticate header if www_auth_resource_metadata_url: - urls.append(www_auth_resource_metadata_url) + parsed_metadata_url = urlparse(www_auth_resource_metadata_url) + normalized_metadata_url = None + if parsed_metadata_url.scheme and parsed_metadata_url.netloc: + normalized_metadata_url = www_auth_resource_metadata_url + elif not parsed_metadata_url.scheme and parsed_metadata_url.netloc: + normalized_metadata_url = f"{parsed_server_url.scheme}:{www_auth_resource_metadata_url}" + elif ( + not parsed_metadata_url.scheme + and not parsed_metadata_url.netloc + and parsed_metadata_url.path.startswith("/") + ): + first_segment = parsed_metadata_url.path.lstrip("/").split("/", 1)[0] + if first_segment == ".well-known" or "." not in first_segment: + normalized_metadata_url = urljoin(base_url, parsed_metadata_url.path) + + if normalized_metadata_url: + urls.append(normalized_metadata_url) # Fallback: construct from server URL - parsed = urlparse(server_url) - base_url = f"{parsed.scheme}://{parsed.netloc}" - path = parsed.path.rstrip("/") - # Priority 2: With path insertion (e.g., /.well-known/oauth-protected-resource/public/mcp) if path: path_url = f"{base_url}/.well-known/oauth-protected-resource{path}" diff --git a/api/dify_graph/graph_engine/response_coordinator/__init__.py b/api/dify_graph/graph_engine/response_coordinator/__init__.py index 2a80d316e8..e11d31199c 100644 --- a/api/dify_graph/graph_engine/response_coordinator/__init__.py +++ b/api/dify_graph/graph_engine/response_coordinator/__init__.py @@ -6,6 +6,5 @@ of responses based on upstream node outputs and constants. """ from .coordinator import ResponseStreamCoordinator -from .session import RESPONSE_SESSION_NODE_TYPES -__all__ = ["RESPONSE_SESSION_NODE_TYPES", "ResponseStreamCoordinator"] +__all__ = ["ResponseStreamCoordinator"] diff --git a/api/dify_graph/graph_engine/response_coordinator/session.py b/api/dify_graph/graph_engine/response_coordinator/session.py index 99ac1b5edf..11a9f5dac5 100644 --- a/api/dify_graph/graph_engine/response_coordinator/session.py +++ b/api/dify_graph/graph_engine/response_coordinator/session.py @@ -3,10 +3,6 @@ Internal response session management for response coordinator. This module contains the private ResponseSession class used internally by ResponseStreamCoordinator to manage streaming sessions. - -`RESPONSE_SESSION_NODE_TYPES` is intentionally mutable so downstream applications -can opt additional response-capable node types into session creation without -patching the coordinator. """ from __future__ import annotations @@ -14,7 +10,6 @@ from __future__ import annotations from dataclasses import dataclass from typing import Protocol, cast -from dify_graph.enums import BuiltinNodeTypes, NodeType from dify_graph.nodes.base.template import Template from dify_graph.runtime.graph_runtime_state import NodeProtocol @@ -25,12 +20,6 @@ class _ResponseSessionNodeProtocol(NodeProtocol, Protocol): def get_streaming_template(self) -> Template: ... -RESPONSE_SESSION_NODE_TYPES: list[NodeType] = [ - BuiltinNodeTypes.ANSWER, - BuiltinNodeTypes.END, -] - - @dataclass class ResponseSession: """ @@ -49,8 +38,8 @@ class ResponseSession: Create a ResponseSession from a response-capable node. The parameter is typed as `NodeProtocol` because the graph is exposed behind a protocol at the runtime layer. - At runtime this must be a node whose `node_type` is listed in `RESPONSE_SESSION_NODE_TYPES` - and which implements `get_streaming_template()`. + At runtime this must be a node that implements `get_streaming_template()`. The coordinator decides which + graph nodes should be treated as response-capable before they reach this factory. Args: node: Node from the materialized workflow graph. @@ -59,15 +48,8 @@ class ResponseSession: ResponseSession configured with the node's streaming template Raises: - TypeError: If node is not a supported response node type. + TypeError: If node does not implement the response-session streaming contract. """ - if node.node_type not in RESPONSE_SESSION_NODE_TYPES: - supported_node_types = ", ".join(RESPONSE_SESSION_NODE_TYPES) - raise TypeError( - "ResponseSession.from_node only supports node types in " - f"RESPONSE_SESSION_NODE_TYPES: {supported_node_types}" - ) - response_node = cast(_ResponseSessionNodeProtocol, node) try: template = response_node.get_streaming_template() diff --git a/api/dify_graph/nodes/human_input/entities.py b/api/dify_graph/nodes/human_input/entities.py index 7936e47213..2a33b4a0a8 100644 --- a/api/dify_graph/nodes/human_input/entities.py +++ b/api/dify_graph/nodes/human_input/entities.py @@ -8,6 +8,8 @@ from collections.abc import Mapping, Sequence from datetime import datetime, timedelta from typing import Annotated, Any, ClassVar, Literal, Self +import bleach +import markdown from pydantic import BaseModel, Field, field_validator, model_validator from dify_graph.entities.base_node_data import BaseNodeData @@ -58,6 +60,39 @@ class EmailDeliveryConfig(BaseModel): """Configuration for email delivery method.""" URL_PLACEHOLDER: ClassVar[str] = "{{#url#}}" + _SUBJECT_NEWLINE_PATTERN: ClassVar[re.Pattern[str]] = re.compile(r"[\r\n]+") + _ALLOWED_HTML_TAGS: ClassVar[list[str]] = [ + "a", + "blockquote", + "br", + "code", + "em", + "h1", + "h2", + "h3", + "h4", + "h5", + "h6", + "hr", + "li", + "ol", + "p", + "pre", + "strong", + "table", + "tbody", + "td", + "th", + "thead", + "tr", + "ul", + ] + _ALLOWED_HTML_ATTRIBUTES: ClassVar[dict[str, list[str]]] = { + "a": ["href", "title"], + "td": ["align"], + "th": ["align"], + } + _ALLOWED_PROTOCOLS: ClassVar[list[str]] = ["http", "https", "mailto"] recipients: EmailRecipients @@ -98,6 +133,43 @@ class EmailDeliveryConfig(BaseModel): return templated_body return variable_pool.convert_template(templated_body).text + @classmethod + def render_markdown_body(cls, body: str) -> str: + """Render markdown to safe HTML for email delivery.""" + sanitized_markdown = bleach.clean( + body, + tags=[], + attributes={}, + strip=True, + strip_comments=True, + ) + rendered_html = markdown.markdown( + sanitized_markdown, + extensions=["nl2br", "tables"], + extension_configs={"tables": {"use_align_attribute": True}}, + ) + return bleach.clean( + rendered_html, + tags=cls._ALLOWED_HTML_TAGS, + attributes=cls._ALLOWED_HTML_ATTRIBUTES, + protocols=cls._ALLOWED_PROTOCOLS, + strip=True, + strip_comments=True, + ) + + @classmethod + def sanitize_subject(cls, subject: str) -> str: + """Sanitize email subject to plain text and prevent CRLF injection.""" + sanitized_subject = bleach.clean( + subject, + tags=[], + attributes={}, + strip=True, + strip_comments=True, + ) + sanitized_subject = cls._SUBJECT_NEWLINE_PATTERN.sub(" ", sanitized_subject) + return " ".join(sanitized_subject.split()) + class _DeliveryMethodBase(BaseModel): """Base delivery method configuration.""" diff --git a/api/models/dataset.py b/api/models/dataset.py index b3fa11a58c..8438fda25f 100644 --- a/api/models/dataset.py +++ b/api/models/dataset.py @@ -10,7 +10,7 @@ import re import time from datetime import datetime from json import JSONDecodeError -from typing import Any, cast +from typing import Any, TypedDict, cast from uuid import uuid4 import sqlalchemy as sa @@ -37,6 +37,61 @@ from .types import AdjustedJSON, BinaryData, EnumText, LongText, StringUUID, adj logger = logging.getLogger(__name__) +class PreProcessingRuleItem(TypedDict): + id: str + enabled: bool + + +class SegmentationConfig(TypedDict): + delimiter: str + max_tokens: int + chunk_overlap: int + + +class AutomaticRulesConfig(TypedDict): + pre_processing_rules: list[PreProcessingRuleItem] + segmentation: SegmentationConfig + + +class ProcessRuleDict(TypedDict): + id: str + dataset_id: str + mode: str + rules: dict[str, Any] | None + + +class DocMetadataDetailItem(TypedDict): + id: str + name: str + type: str + value: Any + + +class AttachmentItem(TypedDict): + id: str + name: str + size: int + extension: str + mime_type: str + source_url: str + + +class DatasetBindingItem(TypedDict): + id: str + name: str + + +class ExternalKnowledgeApiDict(TypedDict): + id: str + tenant_id: str + name: str + description: str + settings: dict[str, Any] | None + dataset_bindings: list[DatasetBindingItem] + created_by: str + created_at: str + + class DatasetPermissionEnum(enum.StrEnum): ONLY_ME = "only_me" ALL_TEAM = "all_team_members" @@ -334,7 +389,7 @@ class DatasetProcessRule(Base): # bug MODES = ["automatic", "custom", "hierarchical"] PRE_PROCESSING_RULES = ["remove_stopwords", "remove_extra_spaces", "remove_urls_emails"] - AUTOMATIC_RULES: dict[str, Any] = { + AUTOMATIC_RULES: AutomaticRulesConfig = { "pre_processing_rules": [ {"id": "remove_extra_spaces", "enabled": True}, {"id": "remove_urls_emails", "enabled": False}, @@ -342,7 +397,7 @@ class DatasetProcessRule(Base): # bug "segmentation": {"delimiter": "\n", "max_tokens": 500, "chunk_overlap": 50}, } - def to_dict(self) -> dict[str, Any]: + def to_dict(self) -> ProcessRuleDict: return { "id": self.id, "dataset_id": self.dataset_id, @@ -531,7 +586,7 @@ class Document(Base): return self.updated_at @property - def doc_metadata_details(self) -> list[dict[str, Any]] | None: + def doc_metadata_details(self) -> list[DocMetadataDetailItem] | None: if self.doc_metadata: document_metadatas = ( db.session.query(DatasetMetadata) @@ -541,9 +596,9 @@ class Document(Base): ) .all() ) - metadata_list: list[dict[str, Any]] = [] + metadata_list: list[DocMetadataDetailItem] = [] for metadata in document_metadatas: - metadata_dict: dict[str, Any] = { + metadata_dict: DocMetadataDetailItem = { "id": metadata.id, "name": metadata.name, "type": metadata.type, @@ -557,13 +612,13 @@ class Document(Base): return None @property - def process_rule_dict(self) -> dict[str, Any] | None: + def process_rule_dict(self) -> ProcessRuleDict | None: if self.dataset_process_rule_id and self.dataset_process_rule: return self.dataset_process_rule.to_dict() return None - def get_built_in_fields(self) -> list[dict[str, Any]]: - built_in_fields: list[dict[str, Any]] = [] + def get_built_in_fields(self) -> list[DocMetadataDetailItem]: + built_in_fields: list[DocMetadataDetailItem] = [] built_in_fields.append( { "id": "built-in", @@ -877,7 +932,7 @@ class DocumentSegment(Base): return text @property - def attachments(self) -> list[dict[str, Any]]: + def attachments(self) -> list[AttachmentItem]: # Use JOIN to fetch attachments in a single query instead of two separate queries attachments_with_bindings = db.session.execute( select(SegmentAttachmentBinding, UploadFile) @@ -891,7 +946,7 @@ class DocumentSegment(Base): ).all() if not attachments_with_bindings: return [] - attachment_list = [] + attachment_list: list[AttachmentItem] = [] for _, attachment in attachments_with_bindings: upload_file_id = attachment.id nonce = os.urandom(16).hex() @@ -1261,7 +1316,7 @@ class ExternalKnowledgeApis(TypeBase): DateTime, nullable=False, server_default=func.current_timestamp(), onupdate=func.current_timestamp(), init=False ) - def to_dict(self) -> dict[str, Any]: + def to_dict(self) -> ExternalKnowledgeApiDict: return { "id": self.id, "tenant_id": self.tenant_id, @@ -1281,13 +1336,13 @@ class ExternalKnowledgeApis(TypeBase): return None @property - def dataset_bindings(self) -> list[dict[str, Any]]: + def dataset_bindings(self) -> list[DatasetBindingItem]: external_knowledge_bindings = db.session.scalars( select(ExternalKnowledgeBindings).where(ExternalKnowledgeBindings.external_knowledge_api_id == self.id) ).all() dataset_ids = [binding.dataset_id for binding in external_knowledge_bindings] datasets = db.session.scalars(select(Dataset).where(Dataset.id.in_(dataset_ids))).all() - dataset_bindings: list[dict[str, Any]] = [] + dataset_bindings: list[DatasetBindingItem] = [] for dataset in datasets: dataset_bindings.append({"id": dataset.id, "name": dataset.name}) diff --git a/api/pyproject.toml b/api/pyproject.toml index 57d58ce5b8..ac51d10513 100644 --- a/api/pyproject.toml +++ b/api/pyproject.toml @@ -40,7 +40,7 @@ dependencies = [ "numpy~=1.26.4", "openpyxl~=3.1.5", "opik~=1.10.37", - "litellm==1.82.2", # Pinned to avoid madoka dependency issue + "litellm==1.82.2", # Pinned to avoid madoka dependency issue "opentelemetry-api==1.28.0", "opentelemetry-distro==0.49b0", "opentelemetry-exporter-otlp==1.28.0", @@ -91,6 +91,7 @@ dependencies = [ "apscheduler>=3.11.0", "weave>=0.52.16", "fastopenapi[flask]>=0.7.0", + "bleach~=6.2.0", ] # Before adding new dependency, consider place it in # alphabet order (a-z) and suitable group. @@ -118,7 +119,7 @@ dev = [ "pytest~=9.0.2", "pytest-benchmark~=5.2.3", "pytest-cov~=7.0.0", - "pytest-env~=1.1.3", + "pytest-env~=1.6.0", "pytest-mock~=3.15.1", "testcontainers~=4.14.1", "types-aiofiles~=25.1.0", @@ -251,10 +252,7 @@ ignore_errors = true [tool.pyrefly] project-includes = ["."] -project-excludes = [ - ".venv", - "migrations/", -] +project-excludes = [".venv", "migrations/"] python-platform = "linux" python-version = "3.11.0" infer-with-first-use = false diff --git a/api/services/human_input_delivery_test_service.py b/api/services/human_input_delivery_test_service.py index 80deb37a56..229e6608da 100644 --- a/api/services/human_input_delivery_test_service.py +++ b/api/services/human_input_delivery_test_service.py @@ -155,13 +155,15 @@ class EmailDeliveryTestHandler: context=context, recipient_email=recipient_email, ) - subject = render_email_template(method.config.subject, substitutions) + subject_template = render_email_template(method.config.subject, substitutions) + subject = EmailDeliveryConfig.sanitize_subject(subject_template) templated_body = EmailDeliveryConfig.render_body_template( body=method.config.body, url=substitutions.get("form_link"), variable_pool=context.variable_pool, ) body = render_email_template(templated_body, substitutions) + body = EmailDeliveryConfig.render_markdown_body(body) mail.send( to=recipient_email, diff --git a/api/services/vector_service.py b/api/services/vector_service.py index 73bb46b797..b66fdd7a20 100644 --- a/api/services/vector_service.py +++ b/api/services/vector_service.py @@ -156,7 +156,8 @@ class VectorService: ) # use full doc mode to generate segment's child chunk processing_rule_dict = processing_rule.to_dict() - processing_rule_dict["rules"]["parent_mode"] = ParentMode.FULL_DOC + if processing_rule_dict["rules"] is not None: + processing_rule_dict["rules"]["parent_mode"] = ParentMode.FULL_DOC documents = index_processor.transform( [document], embedding_model_instance=embedding_model_instance, diff --git a/api/tasks/mail_human_input_delivery_task.py b/api/tasks/mail_human_input_delivery_task.py index bded4cea2b..d241783359 100644 --- a/api/tasks/mail_human_input_delivery_task.py +++ b/api/tasks/mail_human_input_delivery_task.py @@ -111,7 +111,7 @@ def _render_body( url=form_link, variable_pool=variable_pool, ) - return body + return EmailDeliveryConfig.render_markdown_body(body) def _load_variable_pool(workflow_run_id: str | None) -> VariablePool | None: @@ -173,10 +173,11 @@ def dispatch_human_input_email_task(form_id: str, node_title: str | None = None, for recipient in job.recipients: form_link = _build_form_link(recipient.token) body = _render_body(job.body, form_link, variable_pool=variable_pool) + subject = EmailDeliveryConfig.sanitize_subject(job.subject) mail.send( to=recipient.email, - subject=job.subject, + subject=subject, html=body, ) diff --git a/api/tests/test_containers_integration_tests/conftest.py b/api/tests/test_containers_integration_tests/conftest.py index 2a23f1ea7d..0bdd3bdc47 100644 --- a/api/tests/test_containers_integration_tests/conftest.py +++ b/api/tests/test_containers_integration_tests/conftest.py @@ -186,7 +186,7 @@ class DifyTestContainers: # Start Dify Plugin Daemon container for plugin management # Dify Plugin Daemon provides plugin lifecycle management and execution logger.info("Initializing Dify Plugin Daemon container...") - self.dify_plugin_daemon = DockerContainer(image="langgenius/dify-plugin-daemon:0.3.0-local").with_network( + self.dify_plugin_daemon = DockerContainer(image="langgenius/dify-plugin-daemon:0.5.4-local").with_network( self.network ) self.dify_plugin_daemon.with_exposed_ports(5002) diff --git a/api/tests/unit_tests/controllers/console/test_extension.py b/api/tests/unit_tests/controllers/console/test_extension.py index 85eb6e7d71..0d1fb39348 100644 --- a/api/tests/unit_tests/controllers/console/test_extension.py +++ b/api/tests/unit_tests/controllers/console/test_extension.py @@ -22,7 +22,7 @@ from controllers.console.extension import ( ) if _NEEDS_METHOD_VIEW_CLEANUP: - delattr(builtins, "MethodView") + del builtins.MethodView from models.account import AccountStatus from models.api_based_extension import APIBasedExtension diff --git a/api/tests/unit_tests/core/mcp/auth/test_auth_flow.py b/api/tests/unit_tests/core/mcp/auth/test_auth_flow.py index abf3c60fe0..fe533e62af 100644 --- a/api/tests/unit_tests/core/mcp/auth/test_auth_flow.py +++ b/api/tests/unit_tests/core/mcp/auth/test_auth_flow.py @@ -801,6 +801,27 @@ class TestAuthOrchestration: urls = build_protected_resource_metadata_discovery_urls(None, "https://api.example.com") assert urls == ["https://api.example.com/.well-known/oauth-protected-resource"] + def test_build_protected_resource_metadata_discovery_urls_with_relative_hint(self): + urls = build_protected_resource_metadata_discovery_urls( + "/.well-known/oauth-protected-resource/tenant/mcp", + "https://api.example.com/tenant/mcp", + ) + assert urls == [ + "https://api.example.com/.well-known/oauth-protected-resource/tenant/mcp", + "https://api.example.com/.well-known/oauth-protected-resource", + ] + + def test_build_protected_resource_metadata_discovery_urls_ignores_scheme_less_hint(self): + urls = build_protected_resource_metadata_discovery_urls( + "/openapi-mcp.cn-hangzhou.aliyuncs.com/.well-known/oauth-protected-resource/tenant/mcp", + "https://openapi-mcp.cn-hangzhou.aliyuncs.com/tenant/mcp", + ) + + assert urls == [ + "https://openapi-mcp.cn-hangzhou.aliyuncs.com/.well-known/oauth-protected-resource/tenant/mcp", + "https://openapi-mcp.cn-hangzhou.aliyuncs.com/.well-known/oauth-protected-resource", + ] + def test_build_oauth_authorization_server_metadata_discovery_urls(self): # Case 1: with auth_server_url urls = build_oauth_authorization_server_metadata_discovery_urls( diff --git a/api/tests/unit_tests/core/workflow/graph_engine/test_response_session.py b/api/tests/unit_tests/core/workflow/graph_engine/test_response_session.py index 198e133454..cd9d56f683 100644 --- a/api/tests/unit_tests/core/workflow/graph_engine/test_response_session.py +++ b/api/tests/unit_tests/core/workflow/graph_engine/test_response_session.py @@ -4,9 +4,7 @@ from __future__ import annotations import pytest -import dify_graph.graph_engine.response_coordinator.session as response_session_module from dify_graph.enums import BuiltinNodeTypes, NodeExecutionType, NodeState, NodeType -from dify_graph.graph_engine.response_coordinator import RESPONSE_SESSION_NODE_TYPES from dify_graph.graph_engine.response_coordinator.session import ResponseSession from dify_graph.nodes.base.template import Template, TextSegment @@ -35,28 +33,14 @@ class DummyNodeWithoutStreamingTemplate: self.state = NodeState.UNKNOWN -def test_response_session_from_node_rejects_node_types_outside_allowlist() -> None: - """Unsupported node types are rejected even if they expose a template.""" +def test_response_session_from_node_accepts_nodes_outside_previous_allowlist() -> None: + """Session creation depends on the streaming-template contract rather than node type.""" node = DummyResponseNode( node_id="llm-node", node_type=BuiltinNodeTypes.LLM, template=Template(segments=[TextSegment(text="hello")]), ) - with pytest.raises(TypeError, match="RESPONSE_SESSION_NODE_TYPES"): - ResponseSession.from_node(node) - - -def test_response_session_from_node_supports_downstream_allowlist_extension(monkeypatch) -> None: - """Downstream applications can extend the supported node-type list.""" - node = DummyResponseNode( - node_id="llm-node", - node_type=BuiltinNodeTypes.LLM, - template=Template(segments=[TextSegment(text="hello")]), - ) - extended_node_types = [*RESPONSE_SESSION_NODE_TYPES, BuiltinNodeTypes.LLM] - monkeypatch.setattr(response_session_module, "RESPONSE_SESSION_NODE_TYPES", extended_node_types) - session = ResponseSession.from_node(node) assert session.node_id == "llm-node" diff --git a/api/tests/unit_tests/core/workflow/nodes/human_input/test_email_delivery_config.py b/api/tests/unit_tests/core/workflow/nodes/human_input/test_email_delivery_config.py index d4939b1071..d52dfa2a65 100644 --- a/api/tests/unit_tests/core/workflow/nodes/human_input/test_email_delivery_config.py +++ b/api/tests/unit_tests/core/workflow/nodes/human_input/test_email_delivery_config.py @@ -14,3 +14,64 @@ def test_render_body_template_replaces_variable_values(): result = config.render_body_template(body=config.body, url="https://example.com", variable_pool=variable_pool) assert result == "Hello World https://example.com" + + +def test_render_markdown_body_renders_markdown_to_html(): + rendered = EmailDeliveryConfig.render_markdown_body("**Bold** and [link](https://example.com)") + + assert "Bold" in rendered + assert 'link' in rendered + + +def test_render_markdown_body_sanitizes_unsafe_html(): + rendered = EmailDeliveryConfig.render_markdown_body( + 'Click' + ) + + assert "bad" in rendered + assert 'ok' in rendered + + +def test_render_markdown_body_does_not_allow_raw_html_tags(): + rendered = EmailDeliveryConfig.render_markdown_body("raw html and **markdown**") + + assert "" not in rendered + assert "raw html" in rendered + assert "markdown" in rendered + + +def test_render_markdown_body_supports_table_syntax(): + rendered = EmailDeliveryConfig.render_markdown_body("| h1 | h2 |\n| --- | ---: |\n| v1 | v2 |") + + assert "" in rendered + assert "" in rendered + assert "" in rendered + assert 'align="right"' in rendered + assert "style=" not in rendered + + +def test_sanitize_subject_removes_crlf(): + sanitized = EmailDeliveryConfig.sanitize_subject("Notice\r\nBCC:attacker@example.com") + + assert "\r" not in sanitized + assert "\n" not in sanitized + assert sanitized == "Notice BCC:attacker@example.com" + + +def test_sanitize_subject_removes_html_tags(): + sanitized = EmailDeliveryConfig.sanitize_subject("Alert") + + assert "<" not in sanitized + assert ">" not in sanitized + assert sanitized == "Alert" diff --git a/api/tests/unit_tests/libs/test_login.py b/api/tests/unit_tests/libs/test_login.py index df80428ee8..a94ba0c00b 100644 --- a/api/tests/unit_tests/libs/test_login.py +++ b/api/tests/unit_tests/libs/test_login.py @@ -140,7 +140,7 @@ class TestLoginRequired: # Remove ensure_sync to simulate Flask 1.x if hasattr(setup_app, "ensure_sync"): - delattr(setup_app, "ensure_sync") + del setup_app.ensure_sync with setup_app.test_request_context(): mock_user = MockUser("test_user", is_authenticated=True) diff --git a/api/tests/unit_tests/services/test_human_input_delivery_test_service.py b/api/tests/unit_tests/services/test_human_input_delivery_test_service.py index 74139fd12d..a23c44b26e 100644 --- a/api/tests/unit_tests/services/test_human_input_delivery_test_service.py +++ b/api/tests/unit_tests/services/test_human_input_delivery_test_service.py @@ -207,6 +207,45 @@ class TestEmailDeliveryTestHandler: assert kwargs["to"] == "test@example.com" assert "RENDERED_Subj" in kwargs["subject"] + def test_send_test_sanitizes_subject(self, monkeypatch): + monkeypatch.setattr( + service_module.FeatureService, + "get_features", + lambda _id: SimpleNamespace(human_input_email_delivery_enabled=True), + ) + monkeypatch.setattr(service_module.mail, "is_inited", lambda: True) + mock_mail_send = MagicMock() + monkeypatch.setattr(service_module.mail, "send", mock_mail_send) + monkeypatch.setattr( + service_module, + "render_email_template", + lambda template, substitutions: template.replace("{{ recipient_email }}", substitutions["recipient_email"]), + ) + + handler = EmailDeliveryTestHandler(session_factory=MagicMock()) + handler._resolve_recipients = MagicMock(return_value=["test@example.com"]) + + context = DeliveryTestContext( + tenant_id="t1", + app_id="a1", + node_id="n1", + node_title="title", + rendered_content="content", + recipients=[DeliveryTestEmailRecipient(email="test@example.com", form_token="token123")], + ) + method = EmailDeliveryMethod( + config=EmailDeliveryConfig( + recipients=EmailRecipients(whole_workspace=False, items=[]), + subject="Notice\r\nBCC:{{ recipient_email }}", + body="Body", + ) + ) + + handler.send_test(context=context, method=method) + + _, kwargs = mock_mail_send.call_args + assert kwargs["subject"] == "Notice BCC:test@example.com" + def test_resolve_recipients(self): handler = EmailDeliveryTestHandler(session_factory=MagicMock()) diff --git a/api/tests/unit_tests/tasks/test_mail_human_input_delivery_task.py b/api/tests/unit_tests/tasks/test_mail_human_input_delivery_task.py index 20cb7a211e..37b7a85451 100644 --- a/api/tests/unit_tests/tasks/test_mail_human_input_delivery_task.py +++ b/api/tests/unit_tests/tasks/test_mail_human_input_delivery_task.py @@ -120,4 +120,37 @@ def test_dispatch_human_input_email_task_replaces_body_variables(monkeypatch: py session_factory=lambda: _DummySession(form), ) - assert mail.sent[0]["html"] == "Body OK" + assert mail.sent[0]["html"] == "

Body OK

" + + +@pytest.mark.parametrize("line_break", ["\r\n", "\r", "\n"]) +def test_dispatch_human_input_email_task_sanitizes_subject( + monkeypatch: pytest.MonkeyPatch, + line_break: str, +): + mail = _DummyMail() + form = SimpleNamespace(id="form-1", tenant_id="tenant-1", workflow_run_id=None) + job = task_module._EmailDeliveryJob( + form_id="form-1", + subject=f"Notice{line_break}BCC:attacker@example.com Alert", + body="Body", + form_content="content", + recipients=[task_module._EmailRecipient(email="user@example.com", token="token-1")], + ) + + monkeypatch.setattr(task_module, "mail", mail) + monkeypatch.setattr( + task_module.FeatureService, + "get_features", + lambda _tenant_id: SimpleNamespace(human_input_email_delivery_enabled=True), + ) + monkeypatch.setattr(task_module, "_load_email_jobs", lambda _session, _form: [job]) + monkeypatch.setattr(task_module, "_load_variable_pool", lambda _workflow_run_id: None) + + task_module.dispatch_human_input_email_task( + form_id="form-1", + node_title="Approve", + session_factory=lambda: _DummySession(form), + ) + + assert mail.sent[0]["subject"] == "Notice BCC:attacker@example.com Alert" diff --git a/api/uv.lock b/api/uv.lock index 547b2fabc7..4bb86aa762 100644 --- a/api/uv.lock +++ b/api/uv.lock @@ -457,14 +457,14 @@ wheels = [ [[package]] name = "authlib" -version = "1.6.7" +version = "1.6.9" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "cryptography" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/49/dc/ed1681bf1339dd6ea1ce56136bad4baabc6f7ad466e375810702b0237047/authlib-1.6.7.tar.gz", hash = "sha256:dbf10100011d1e1b34048c9d120e83f13b35d69a826ae762b93d2fb5aafc337b", size = 164950, upload-time = "2026-02-06T14:04:14.171Z" } +sdist = { url = "https://files.pythonhosted.org/packages/af/98/00d3dd826d46959ad8e32af2dbb2398868fd9fd0683c26e56d0789bd0e68/authlib-1.6.9.tar.gz", hash = "sha256:d8f2421e7e5980cc1ddb4e32d3f5fa659cfaf60d8eaf3281ebed192e4ab74f04", size = 165134, upload-time = "2026-03-02T07:44:01.998Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/f8/00/3ed12264094ec91f534fae429945efbaa9f8c666f3aa7061cc3b2a26a0cd/authlib-1.6.7-py2.py3-none-any.whl", hash = "sha256:c637340d9a02789d2efa1d003a7437d10d3e565237bcb5fcbc6c134c7b95bab0", size = 244115, upload-time = "2026-02-06T14:04:12.141Z" }, + { url = "https://files.pythonhosted.org/packages/53/23/b65f568ed0c22f1efacb744d2db1a33c8068f384b8c9b482b52ebdbc3ef6/authlib-1.6.9-py2.py3-none-any.whl", hash = "sha256:f08b4c14e08f0861dc18a32357b33fbcfd2ea86cfe3fe149484b4d764c4a0ac3", size = 244197, upload-time = "2026-03-02T07:44:00.307Z" }, ] [[package]] @@ -658,6 +658,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/b3/cc/38b6f87170908bd8aaf9e412b021d17e85f690abe00edf50192f1a4566b9/billiard-4.2.3-py3-none-any.whl", hash = "sha256:989e9b688e3abf153f307b68a1328dfacfb954e30a4f920005654e276c69236b", size = 87042, upload-time = "2025-11-16T17:47:29.005Z" }, ] +[[package]] +name = "bleach" +version = "6.2.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "webencodings" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/76/9a/0e33f5054c54d349ea62c277191c020c2d6ef1d65ab2cb1993f91ec846d1/bleach-6.2.0.tar.gz", hash = "sha256:123e894118b8a599fd80d3ec1a6d4cc7ce4e5882b1317a7e1ba69b56e95f991f", size = 203083, upload-time = "2024-10-29T18:30:40.477Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fc/55/96142937f66150805c25c4d0f31ee4132fd33497753400734f9dfdcbdc66/bleach-6.2.0-py3-none-any.whl", hash = "sha256:117d9c6097a7c3d22fd578fcd8d35ff1e125df6736f554da4e432fdd63f31e5e", size = 163406, upload-time = "2024-10-29T18:30:38.186Z" }, +] + [[package]] name = "blinker" version = "1.9.0" @@ -708,16 +720,16 @@ wheels = [ [[package]] name = "boto3-stubs" -version = "1.41.3" +version = "1.42.68" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "botocore-stubs" }, { name = "types-s3transfer" }, { name = "typing-extensions", marker = "python_full_version < '3.12'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/fd/5b/6d274aa25f7fa09f8b7defab5cb9389e6496a7d9b76c1efcf27b0b15e868/boto3_stubs-1.41.3.tar.gz", hash = "sha256:c7cc9706ac969c8ea284c2d45ec45b6371745666d087c6c5e7c9d39dafdd48bc", size = 100010, upload-time = "2025-11-24T20:34:27.052Z" } +sdist = { url = "https://files.pythonhosted.org/packages/4c/8c/dd4b0c95ff008bed5a35ab411452ece121b355539d2a0b6dcd62a0c47be5/boto3_stubs-1.42.68.tar.gz", hash = "sha256:96ad1020735619483fb9b4da7a5e694b460bf2e18f84a34d5d175d0ffe8c4653", size = 101372, upload-time = "2026-03-13T19:49:54.867Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/7e/d6/ef971013d1fc7333c6df322d98ebf4592df9c80e1966fb12732f91e9e71b/boto3_stubs-1.41.3-py3-none-any.whl", hash = "sha256:bec698419b31b499f3740f1dfb6dae6519167d9e3aa536f6f730ed280556230b", size = 69294, upload-time = "2025-11-24T20:34:23.1Z" }, + { url = "https://files.pythonhosted.org/packages/68/15/3ca5848917214a168134512a5b45f856a56e913659888947a052e02031b5/boto3_stubs-1.42.68-py3-none-any.whl", hash = "sha256:ed7f98334ef7b2377fa8532190e63dc2c6d1dc895e3d7cb3d6d1c83771b81bf6", size = 70011, upload-time = "2026-03-13T19:49:42.801Z" }, ] [package.optional-dependencies] @@ -1529,6 +1541,7 @@ dependencies = [ { name = "arize-phoenix-otel" }, { name = "azure-identity" }, { name = "beautifulsoup4" }, + { name = "bleach" }, { name = "boto3" }, { name = "bs4" }, { name = "cachetools" }, @@ -1730,6 +1743,7 @@ requires-dist = [ { name = "arize-phoenix-otel", specifier = "~=0.15.0" }, { name = "azure-identity", specifier = "==1.25.3" }, { name = "beautifulsoup4", specifier = "==4.14.3" }, + { name = "bleach", specifier = "~=6.2.0" }, { name = "boto3", specifier = "==1.42.68" }, { name = "bs4", specifier = "~=0.0.1" }, { name = "cachetools", specifier = "~=5.3.0" }, @@ -1831,7 +1845,7 @@ dev = [ { name = "pytest", specifier = "~=9.0.2" }, { name = "pytest-benchmark", specifier = "~=5.2.3" }, { name = "pytest-cov", specifier = "~=7.0.0" }, - { name = "pytest-env", specifier = "~=1.1.3" }, + { name = "pytest-env", specifier = "~=1.6.0" }, { name = "pytest-mock", specifier = "~=3.15.1" }, { name = "pytest-timeout", specifier = ">=2.4.0" }, { name = "pytest-xdist", specifier = ">=3.8.0" }, @@ -3143,14 +3157,14 @@ wheels = [ [[package]] name = "hypothesis" -version = "6.148.2" +version = "6.151.9" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "sortedcontainers" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/4a/99/a3c6eb3fdd6bfa01433d674b0f12cd9102aa99630689427422d920aea9c6/hypothesis-6.148.2.tar.gz", hash = "sha256:07e65d34d687ddff3e92a3ac6b43966c193356896813aec79f0a611c5018f4b1", size = 469984, upload-time = "2025-11-18T20:21:17.047Z" } +sdist = { url = "https://files.pythonhosted.org/packages/19/e1/ef365ff480903b929d28e057f57b76cae51a30375943e33374ec9a165d9c/hypothesis-6.151.9.tar.gz", hash = "sha256:2f284428dda6c3c48c580de0e18470ff9c7f5ef628a647ee8002f38c3f9097ca", size = 463534, upload-time = "2026-02-16T22:59:23.09Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/b1/d2/c2673aca0127e204965e0e9b3b7a0e91e9b12993859ac8758abd22669b89/hypothesis-6.148.2-py3-none-any.whl", hash = "sha256:bf8ddc829009da73b321994b902b1964bcc3e5c3f0ed9a1c1e6a1631ab97c5fa", size = 536986, upload-time = "2025-11-18T20:21:15.212Z" }, + { url = "https://files.pythonhosted.org/packages/c4/f7/5cc291d701094754a1d327b44d80a44971e13962881d9a400235726171da/hypothesis-6.151.9-py3-none-any.whl", hash = "sha256:7b7220585c67759b1b1ef839b1e6e9e3d82ed468cfc1ece43c67184848d7edd9", size = 529307, upload-time = "2026-02-16T22:59:20.443Z" }, ] [[package]] @@ -3164,19 +3178,17 @@ wheels = [ [[package]] name = "import-linter" -version = "2.10" +version = "2.11" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "click" }, - { name = "fastapi" }, { name = "grimp" }, { name = "rich" }, { name = "typing-extensions" }, - { name = "uvicorn" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/10/c4/a83cc1ea9ed0171725c0e2edc11fd929994d4f026028657e8b30d62bca37/import_linter-2.10.tar.gz", hash = "sha256:c6a5057d2dbd32e1854c4d6b60e90dfad459b7ab5356230486d8521f25872963", size = 1149263, upload-time = "2026-02-06T17:57:24.779Z" } +sdist = { url = "https://files.pythonhosted.org/packages/ba/66/55b697a17bb15c6cb88d97d73716813f5427281527b90f02cc0a600abc6e/import_linter-2.11.tar.gz", hash = "sha256:5abc3394797a54f9bae315e7242dc98715ba485f840ac38c6d3192c370d0085e", size = 1153682, upload-time = "2026-03-06T12:11:38.198Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/1c/e5/4b7b9435eac78ecfd537fa1004a0bcf0f4eac17d3a893f64d38a7bacb51b/import_linter-2.10-py3-none-any.whl", hash = "sha256:cc2ddd7ec0145cbf83f3b25391d2a5dbbf138382aaf80708612497fa6ebc8f60", size = 637081, upload-time = "2026-02-06T17:57:23.386Z" }, + { url = "https://files.pythonhosted.org/packages/e9/aa/2ed2c89543632ded7196e0d93dcc6c7fe87769e88391a648c4a298ea864a/import_linter-2.11-py3-none-any.whl", hash = "sha256:3dc54cae933bae3430358c30989762b721c77aa99d424f56a08265be0eeaa465", size = 637315, upload-time = "2026-03-06T12:11:36.599Z" }, ] [[package]] @@ -3918,14 +3930,14 @@ wheels = [ [[package]] name = "mypy-boto3-bedrock-runtime" -version = "1.41.2" +version = "1.42.42" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "typing-extensions", marker = "python_full_version < '3.12'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/af/f1/00aea4f91501728e7af7e899ce3a75d48d6df97daa720db11e46730fa123/mypy_boto3_bedrock_runtime-1.41.2.tar.gz", hash = "sha256:ba2c11f2f18116fd69e70923389ce68378fa1620f70e600efb354395a1a9e0e5", size = 28890, upload-time = "2025-11-21T20:35:30.074Z" } +sdist = { url = "https://files.pythonhosted.org/packages/46/bb/65dc1b2c5796a6ab5f60bdb57343bd6c3ecb82251c580eca415c8548333e/mypy_boto3_bedrock_runtime-1.42.42.tar.gz", hash = "sha256:3a4088218478b6fbbc26055c03c95bee4fc04624a801090b3cce3037e8275c8d", size = 29840, upload-time = "2026-02-04T20:53:05.999Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/a7/cc/96a2af58c632701edb5be1dda95434464da43df40ae868a1ab1ddf033839/mypy_boto3_bedrock_runtime-1.41.2-py3-none-any.whl", hash = "sha256:a720ff1e98cf10723c37a61a46cff220b190c55b8fb57d4397e6cf286262cf02", size = 34967, upload-time = "2025-11-21T20:35:27.655Z" }, + { url = "https://files.pythonhosted.org/packages/00/43/7ea062f2228f47b5779dcfa14dab48d6e29f979b35d1a5102b0ba80b9c1b/mypy_boto3_bedrock_runtime-1.42.42-py3-none-any.whl", hash = "sha256:b2d16eae22607d0685f90796b3a0afc78c0b09d45872e00eafd634a31dd9358f", size = 36077, upload-time = "2026-02-04T20:53:01.768Z" }, ] [[package]] @@ -5514,14 +5526,15 @@ wheels = [ [[package]] name = "pytest-env" -version = "1.1.5" +version = "1.6.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "pytest" }, + { name = "python-dotenv" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/1f/31/27f28431a16b83cab7a636dce59cf397517807d247caa38ee67d65e71ef8/pytest_env-1.1.5.tar.gz", hash = "sha256:91209840aa0e43385073ac464a554ad2947cc2fd663a9debf88d03b01e0cc1cf", size = 8911, upload-time = "2024-09-17T22:39:18.566Z" } +sdist = { url = "https://files.pythonhosted.org/packages/ff/69/4db1c30625af0621df8dbe73797b38b6d1b04e15d021dd5d26a6d297f78c/pytest_env-1.6.0.tar.gz", hash = "sha256:ac02d6fba16af54d61e311dd70a3c61024a4e966881ea844affc3c8f0bf207d3", size = 16163, upload-time = "2026-03-12T22:39:43.78Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/de/b8/87cfb16045c9d4092cfcf526135d73b88101aac83bc1adcf82dfb5fd3833/pytest_env-1.1.5-py3-none-any.whl", hash = "sha256:ce90cf8772878515c24b31cd97c7fa1f4481cd68d588419fd45f10ecaee6bc30", size = 6141, upload-time = "2024-09-17T22:39:16.942Z" }, + { url = "https://files.pythonhosted.org/packages/27/16/ad52f56b96d851a2bcfdc1e754c3531341885bd7177a128c13ff2ca72ab4/pytest_env-1.6.0-py3-none-any.whl", hash = "sha256:1e7f8a62215e5885835daaed694de8657c908505b964ec8097a7ce77b403d9a3", size = 10400, upload-time = "2026-03-12T22:39:41.887Z" }, ] [[package]] @@ -6033,27 +6046,27 @@ wheels = [ [[package]] name = "ruff" -version = "0.15.5" +version = "0.15.6" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/77/9b/840e0039e65fcf12758adf684d2289024d6140cde9268cc59887dc55189c/ruff-0.15.5.tar.gz", hash = "sha256:7c3601d3b6d76dce18c5c824fc8d06f4eef33d6df0c21ec7799510cde0f159a2", size = 4574214, upload-time = "2026-03-05T20:06:34.946Z" } +sdist = { url = "https://files.pythonhosted.org/packages/51/df/f8629c19c5318601d3121e230f74cbee7a3732339c52b21daa2b82ef9c7d/ruff-0.15.6.tar.gz", hash = "sha256:8394c7bb153a4e3811a4ecdacd4a8e6a4fa8097028119160dffecdcdf9b56ae4", size = 4597916, upload-time = "2026-03-12T23:05:47.51Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/47/20/5369c3ce21588c708bcbe517a8fbe1a8dfdb5dfd5137e14790b1da71612c/ruff-0.15.5-py3-none-linux_armv6l.whl", hash = "sha256:4ae44c42281f42e3b06b988e442d344a5b9b72450ff3c892e30d11b29a96a57c", size = 10478185, upload-time = "2026-03-05T20:06:29.093Z" }, - { url = "https://files.pythonhosted.org/packages/44/ed/e81dd668547da281e5dce710cf0bc60193f8d3d43833e8241d006720e42b/ruff-0.15.5-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:6edd3792d408ebcf61adabc01822da687579a1a023f297618ac27a5b51ef0080", size = 10859201, upload-time = "2026-03-05T20:06:32.632Z" }, - { url = "https://files.pythonhosted.org/packages/c4/8f/533075f00aaf19b07c5cd6aa6e5d89424b06b3b3f4583bfa9c640a079059/ruff-0.15.5-py3-none-macosx_11_0_arm64.whl", hash = "sha256:89f463f7c8205a9f8dea9d658d59eff49db05f88f89cc3047fb1a02d9f344010", size = 10184752, upload-time = "2026-03-05T20:06:40.312Z" }, - { url = "https://files.pythonhosted.org/packages/66/0e/ba49e2c3fa0395b3152bad634c7432f7edfc509c133b8f4529053ff024fb/ruff-0.15.5-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ba786a8295c6574c1116704cf0b9e6563de3432ac888d8f83685654fe528fd65", size = 10534857, upload-time = "2026-03-05T20:06:19.581Z" }, - { url = "https://files.pythonhosted.org/packages/59/71/39234440f27a226475a0659561adb0d784b4d247dfe7f43ffc12dd02e288/ruff-0.15.5-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:fd4b801e57955fe9f02b31d20375ab3a5c4415f2e5105b79fb94cf2642c91440", size = 10309120, upload-time = "2026-03-05T20:06:00.435Z" }, - { url = "https://files.pythonhosted.org/packages/f5/87/4140aa86a93df032156982b726f4952aaec4a883bb98cb6ef73c347da253/ruff-0.15.5-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:391f7c73388f3d8c11b794dbbc2959a5b5afe66642c142a6effa90b45f6f5204", size = 11047428, upload-time = "2026-03-05T20:05:51.867Z" }, - { url = "https://files.pythonhosted.org/packages/5a/f7/4953e7e3287676f78fbe85e3a0ca414c5ca81237b7575bdadc00229ac240/ruff-0.15.5-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8dc18f30302e379fe1e998548b0f5e9f4dff907f52f73ad6da419ea9c19d66c8", size = 11914251, upload-time = "2026-03-05T20:06:22.887Z" }, - { url = "https://files.pythonhosted.org/packages/77/46/0f7c865c10cf896ccf5a939c3e84e1cfaeed608ff5249584799a74d33835/ruff-0.15.5-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:1cc6e7f90087e2d27f98dc34ed1b3ab7c8f0d273cc5431415454e22c0bd2a681", size = 11333801, upload-time = "2026-03-05T20:05:57.168Z" }, - { url = "https://files.pythonhosted.org/packages/d3/01/a10fe54b653061585e655f5286c2662ebddb68831ed3eaebfb0eb08c0a16/ruff-0.15.5-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c1cb7169f53c1ddb06e71a9aebd7e98fc0fea936b39afb36d8e86d36ecc2636a", size = 11206821, upload-time = "2026-03-05T20:06:03.441Z" }, - { url = "https://files.pythonhosted.org/packages/7a/0d/2132ceaf20c5e8699aa83da2706ecb5c5dcdf78b453f77edca7fb70f8a93/ruff-0.15.5-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:9b037924500a31ee17389b5c8c4d88874cc6ea8e42f12e9c61a3d754ff72f1ca", size = 11133326, upload-time = "2026-03-05T20:06:25.655Z" }, - { url = "https://files.pythonhosted.org/packages/72/cb/2e5259a7eb2a0f87c08c0fe5bf5825a1e4b90883a52685524596bfc93072/ruff-0.15.5-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:65bb414e5b4eadd95a8c1e4804f6772bbe8995889f203a01f77ddf2d790929dd", size = 10510820, upload-time = "2026-03-05T20:06:37.79Z" }, - { url = "https://files.pythonhosted.org/packages/ff/20/b67ce78f9e6c59ffbdb5b4503d0090e749b5f2d31b599b554698a80d861c/ruff-0.15.5-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:d20aa469ae3b57033519c559e9bc9cd9e782842e39be05b50e852c7c981fa01d", size = 10302395, upload-time = "2026-03-05T20:05:54.504Z" }, - { url = "https://files.pythonhosted.org/packages/5f/e5/719f1acccd31b720d477751558ed74e9c88134adcc377e5e886af89d3072/ruff-0.15.5-py3-none-musllinux_1_2_i686.whl", hash = "sha256:15388dd28c9161cdb8eda68993533acc870aa4e646a0a277aa166de9ad5a8752", size = 10754069, upload-time = "2026-03-05T20:06:06.422Z" }, - { url = "https://files.pythonhosted.org/packages/c3/9c/d1db14469e32d98f3ca27079dbd30b7b44dbb5317d06ab36718dee3baf03/ruff-0.15.5-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:b30da330cbd03bed0c21420b6b953158f60c74c54c5f4c1dabbdf3a57bf355d2", size = 11304315, upload-time = "2026-03-05T20:06:10.867Z" }, - { url = "https://files.pythonhosted.org/packages/28/3a/950367aee7c69027f4f422059227b290ed780366b6aecee5de5039d50fa8/ruff-0.15.5-py3-none-win32.whl", hash = "sha256:732e5ee1f98ba5b3679029989a06ca39a950cced52143a0ea82a2102cb592b74", size = 10551676, upload-time = "2026-03-05T20:06:13.705Z" }, - { url = "https://files.pythonhosted.org/packages/b8/00/bf077a505b4e649bdd3c47ff8ec967735ce2544c8e4a43aba42ee9bf935d/ruff-0.15.5-py3-none-win_amd64.whl", hash = "sha256:821d41c5fa9e19117616c35eaa3f4b75046ec76c65e7ae20a333e9a8696bc7fe", size = 11678972, upload-time = "2026-03-05T20:06:45.379Z" }, - { url = "https://files.pythonhosted.org/packages/fe/4e/cd76eca6db6115604b7626668e891c9dd03330384082e33662fb0f113614/ruff-0.15.5-py3-none-win_arm64.whl", hash = "sha256:b498d1c60d2fe5c10c45ec3f698901065772730b411f164ae270bb6bfcc4740b", size = 10965572, upload-time = "2026-03-05T20:06:16.984Z" }, + { url = "https://files.pythonhosted.org/packages/9e/2f/4e03a7e5ce99b517e98d3b4951f411de2b0fa8348d39cf446671adcce9a2/ruff-0.15.6-py3-none-linux_armv6l.whl", hash = "sha256:7c98c3b16407b2cf3d0f2b80c80187384bc92c6774d85fefa913ecd941256fff", size = 10508953, upload-time = "2026-03-12T23:05:17.246Z" }, + { url = "https://files.pythonhosted.org/packages/70/60/55bcdc3e9f80bcf39edf0cd272da6fa511a3d94d5a0dd9e0adf76ceebdb4/ruff-0.15.6-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:ee7dcfaad8b282a284df4aa6ddc2741b3f4a18b0555d626805555a820ea181c3", size = 10942257, upload-time = "2026-03-12T23:05:23.076Z" }, + { url = "https://files.pythonhosted.org/packages/e7/f9/005c29bd1726c0f492bfa215e95154cf480574140cb5f867c797c18c790b/ruff-0.15.6-py3-none-macosx_11_0_arm64.whl", hash = "sha256:3bd9967851a25f038fc8b9ae88a7fbd1b609f30349231dffaa37b6804923c4bb", size = 10322683, upload-time = "2026-03-12T23:05:33.738Z" }, + { url = "https://files.pythonhosted.org/packages/5f/74/2f861f5fd7cbb2146bddb5501450300ce41562da36d21868c69b7a828169/ruff-0.15.6-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:13f4594b04e42cd24a41da653886b04d2ff87adbf57497ed4f728b0e8a4866f8", size = 10660986, upload-time = "2026-03-12T23:05:53.245Z" }, + { url = "https://files.pythonhosted.org/packages/c1/a1/309f2364a424eccb763cdafc49df843c282609f47fe53aa83f38272389e0/ruff-0.15.6-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e2ed8aea2f3fe57886d3f00ea5b8aae5bf68d5e195f487f037a955ff9fbaac9e", size = 10332177, upload-time = "2026-03-12T23:05:56.145Z" }, + { url = "https://files.pythonhosted.org/packages/30/41/7ebf1d32658b4bab20f8ac80972fb19cd4e2c6b78552be263a680edc55ac/ruff-0.15.6-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:70789d3e7830b848b548aae96766431c0dc01a6c78c13381f423bf7076c66d15", size = 11170783, upload-time = "2026-03-12T23:06:01.742Z" }, + { url = "https://files.pythonhosted.org/packages/76/be/6d488f6adca047df82cd62c304638bcb00821c36bd4881cfca221561fdfc/ruff-0.15.6-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:542aaf1de3154cea088ced5a819ce872611256ffe2498e750bbae5247a8114e9", size = 12044201, upload-time = "2026-03-12T23:05:28.697Z" }, + { url = "https://files.pythonhosted.org/packages/71/68/e6f125df4af7e6d0b498f8d373274794bc5156b324e8ab4bf5c1b4fc0ec7/ruff-0.15.6-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:1c22e6f02c16cfac3888aa636e9eba857254d15bbacc9906c9689fdecb1953ab", size = 11421561, upload-time = "2026-03-12T23:05:31.236Z" }, + { url = "https://files.pythonhosted.org/packages/f1/9f/f85ef5fd01a52e0b472b26dc1b4bd228b8f6f0435975442ffa4741278703/ruff-0.15.6-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:98893c4c0aadc8e448cfa315bd0cc343a5323d740fe5f28ef8a3f9e21b381f7e", size = 11310928, upload-time = "2026-03-12T23:05:45.288Z" }, + { url = "https://files.pythonhosted.org/packages/8c/26/b75f8c421f5654304b89471ed384ae8c7f42b4dff58fa6ce1626d7f2b59a/ruff-0.15.6-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:70d263770d234912374493e8cc1e7385c5d49376e41dfa51c5c3453169dc581c", size = 11235186, upload-time = "2026-03-12T23:05:50.677Z" }, + { url = "https://files.pythonhosted.org/packages/fc/d4/d5a6d065962ff7a68a86c9b4f5500f7d101a0792078de636526c0edd40da/ruff-0.15.6-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:55a1ad63c5a6e54b1f21b7514dfadc0c7fb40093fa22e95143cf3f64ebdcd512", size = 10635231, upload-time = "2026-03-12T23:05:37.044Z" }, + { url = "https://files.pythonhosted.org/packages/d6/56/7c3acf3d50910375349016cf33de24be021532042afbed87942858992491/ruff-0.15.6-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:8dc473ba093c5ec238bb1e7429ee676dca24643c471e11fbaa8a857925b061c0", size = 10340357, upload-time = "2026-03-12T23:06:04.748Z" }, + { url = "https://files.pythonhosted.org/packages/06/54/6faa39e9c1033ff6a3b6e76b5df536931cd30caf64988e112bbf91ef5ce5/ruff-0.15.6-py3-none-musllinux_1_2_i686.whl", hash = "sha256:85b042377c2a5561131767974617006f99f7e13c63c111b998f29fc1e58a4cfb", size = 10860583, upload-time = "2026-03-12T23:05:58.978Z" }, + { url = "https://files.pythonhosted.org/packages/cb/1e/509a201b843b4dfb0b32acdedf68d951d3377988cae43949ba4c4133a96a/ruff-0.15.6-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:cef49e30bc5a86a6a92098a7fbf6e467a234d90b63305d6f3ec01225a9d092e0", size = 11410976, upload-time = "2026-03-12T23:05:39.955Z" }, + { url = "https://files.pythonhosted.org/packages/6c/25/3fc9114abf979a41673ce877c08016f8e660ad6cf508c3957f537d2e9fa9/ruff-0.15.6-py3-none-win32.whl", hash = "sha256:bbf67d39832404812a2d23020dda68fee7f18ce15654e96fb1d3ad21a5fe436c", size = 10616872, upload-time = "2026-03-12T23:05:42.451Z" }, + { url = "https://files.pythonhosted.org/packages/89/7a/09ece68445ceac348df06e08bf75db72d0e8427765b96c9c0ffabc1be1d9/ruff-0.15.6-py3-none-win_amd64.whl", hash = "sha256:aee25bc84c2f1007ecb5037dff75cef00414fdf17c23f07dc13e577883dca406", size = 11787271, upload-time = "2026-03-12T23:05:20.168Z" }, + { url = "https://files.pythonhosted.org/packages/7f/d0/578c47dd68152ddddddf31cd7fc67dc30b7cdf639a86275fda821b0d9d98/ruff-0.15.6-py3-none-win_arm64.whl", hash = "sha256:c34de3dd0b0ba203be50ae70f5910b17188556630e2178fd7d79fc030eb0d837", size = 11060497, upload-time = "2026-03-12T23:05:25.968Z" }, ] [[package]] @@ -6092,14 +6105,14 @@ wheels = [ [[package]] name = "scipy-stubs" -version = "1.16.3.1" +version = "1.17.1.2" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "optype", extra = ["numpy"] }, ] -sdist = { url = "https://files.pythonhosted.org/packages/0b/3e/8baf960c68f012b8297930d4686b235813974833a417db8d0af798b0b93d/scipy_stubs-1.16.3.1.tar.gz", hash = "sha256:0738d55a7f8b0c94cdb8063f711d53330ebefe166f7d48dec9ffd932a337226d", size = 359990, upload-time = "2025-11-23T23:05:21.274Z" } +sdist = { url = "https://files.pythonhosted.org/packages/c7/ab/43f681ffba42f363b7ed6b767fd215d1e26006578214ff8330586a11bf95/scipy_stubs-1.17.1.2.tar.gz", hash = "sha256:2ecadc8c87a3b61aaf7379d6d6b10f1038a829c53b9efe5b174fb97fc8b52237", size = 388354, upload-time = "2026-03-15T22:33:20.449Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/0c/39/e2a69866518f88dc01940c9b9b044db97c3387f2826bd2a173e49a5c0469/scipy_stubs-1.16.3.1-py3-none-any.whl", hash = "sha256:69bc52ef6c3f8e09208abdfaf32291eb51e9ddf8fa4389401ccd9473bdd2a26d", size = 560397, upload-time = "2025-11-23T23:05:19.432Z" }, + { url = "https://files.pythonhosted.org/packages/8c/0b/ec4fe720c1202d9df729a3e9d9b7e4d2da9f6e7f28bd2877b7d0769f4f75/scipy_stubs-1.17.1.2-py3-none-any.whl", hash = "sha256:f19e8f5273dbe3b7ee6a9554678c3973b9695fa66b91f29206d00830a1536c06", size = 594377, upload-time = "2026-03-15T22:33:18.684Z" }, ] [[package]] @@ -6788,14 +6801,14 @@ wheels = [ [[package]] name = "types-cffi" -version = "1.17.0.20250915" +version = "2.0.0.20260316" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "types-setuptools" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/2a/98/ea454cea03e5f351323af6a482c65924f3c26c515efd9090dede58f2b4b6/types_cffi-1.17.0.20250915.tar.gz", hash = "sha256:4362e20368f78dabd5c56bca8004752cc890e07a71605d9e0d9e069dbaac8c06", size = 17229, upload-time = "2025-09-15T03:01:25.31Z" } +sdist = { url = "https://files.pythonhosted.org/packages/07/4c/805b40b094eb3fd60f8d17fa7b3c58a33781311a95d0e6a74da0751ce294/types_cffi-2.0.0.20260316.tar.gz", hash = "sha256:8fb06ed4709675c999853689941133affcd2250cd6121cc11fd22c0d81ad510c", size = 17399, upload-time = "2026-03-16T07:54:43.059Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/aa/ec/092f2b74b49ec4855cdb53050deb9699f7105b8fda6fe034c0781b8687f3/types_cffi-1.17.0.20250915-py3-none-any.whl", hash = "sha256:cef4af1116c83359c11bb4269283c50f0688e9fc1d7f0eeb390f3661546da52c", size = 20112, upload-time = "2025-09-15T03:01:24.187Z" }, + { url = "https://files.pythonhosted.org/packages/81/5e/9f1a709225ad9d0e1d7a6e4366ff285f0113c749e882d6cbeb40eab32e75/types_cffi-2.0.0.20260316-py3-none-any.whl", hash = "sha256:dd504698029db4c580385f679324621cc64d886e6a23e9821d52bc5169251302", size = 20096, upload-time = "2026-03-16T07:54:41.994Z" }, ] [[package]] @@ -6827,11 +6840,11 @@ wheels = [ [[package]] name = "types-docutils" -version = "0.22.3.20260223" +version = "0.22.3.20260316" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/80/33/92c0129283363e3b3ba270bf6a2b7d077d949d2f90afc4abaf6e73578563/types_docutils-0.22.3.20260223.tar.gz", hash = "sha256:e90e868da82df615ea2217cf36dff31f09660daa15fc0f956af53f89c1364501", size = 57230, upload-time = "2026-02-23T04:11:21.806Z" } +sdist = { url = "https://files.pythonhosted.org/packages/9f/27/a7f16b3a2fad0a4ddd85a668319f9a1d0311c4bd9578894f6471c7e6c788/types_docutils-0.22.3.20260316.tar.gz", hash = "sha256:8ef27d565b9831ff094fe2eac75337a74151013e2d21ecabd445c2955f891564", size = 57263, upload-time = "2026-03-16T04:29:12.211Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/ba/c7/a4ae6a75d5b07d63089d5c04d450a0de4a5d48ffcb84b95659b22d3885fe/types_docutils-0.22.3.20260223-py3-none-any.whl", hash = "sha256:cc2d6b7560a28e351903db0989091474aa619ad287843a018324baee9c4d9a8f", size = 91969, upload-time = "2026-02-23T04:11:20.966Z" }, + { url = "https://files.pythonhosted.org/packages/70/60/c1f22b7cfc4837d5419e5a2d8702c7d65f03343f866364b71cccd8a73b79/types_docutils-0.22.3.20260316-py3-none-any.whl", hash = "sha256:083c7091b8072c242998ec51da1bf1492f0332387da81c3b085efbf5ca754c7d", size = 91968, upload-time = "2026-03-16T04:29:11.114Z" }, ] [[package]] @@ -6861,15 +6874,15 @@ wheels = [ [[package]] name = "types-gevent" -version = "25.9.0.20251102" +version = "25.9.0.20251228" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "types-greenlet" }, { name = "types-psutil" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/4c/21/552d818a475e1a31780fb7ae50308feb64211a05eb403491d1a34df95e5f/types_gevent-25.9.0.20251102.tar.gz", hash = "sha256:76f93513af63f4577bb4178c143676dd6c4780abc305f405a4e8ff8f1fa177f8", size = 38096, upload-time = "2025-11-02T03:07:42.112Z" } +sdist = { url = "https://files.pythonhosted.org/packages/06/85/c5043c4472f82c8ee3d9e0673eb4093c7d16770a26541a137a53a1d096f6/types_gevent-25.9.0.20251228.tar.gz", hash = "sha256:423ef9891d25c5a3af236c3e9aace4c444c86ff773fe13ef22731bc61d59abef", size = 38063, upload-time = "2025-12-28T03:28:28.651Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/60/a1/776d2de31a02123f225aaa790641113ae47f738f6e8e3091d3012240a88e/types_gevent-25.9.0.20251102-py3-none-any.whl", hash = "sha256:0f14b9977cb04bf3d94444b5ae6ec5d78ac30f74c4df83483e0facec86f19d8b", size = 55592, upload-time = "2025-11-02T03:07:41.003Z" }, + { url = "https://files.pythonhosted.org/packages/c8/b7/a2d6b652ab5a26318b68cafd58c46fafb9b15c5313d2d76a70b838febb4b/types_gevent-25.9.0.20251228-py3-none-any.whl", hash = "sha256:e2e225af4fface9241c16044983eb2fc3993f2d13d801f55c2932848649b7f2f", size = 55486, upload-time = "2025-12-28T03:28:27.382Z" }, ] [[package]] @@ -6895,11 +6908,11 @@ wheels = [ [[package]] name = "types-jmespath" -version = "1.0.2.20250809" +version = "1.1.0.20260124" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/d5/ff/6848b1603ca47fff317b44dfff78cc1fb0828262f840b3ab951b619d5a22/types_jmespath-1.0.2.20250809.tar.gz", hash = "sha256:e194efec21c0aeae789f701ae25f17c57c25908e789b1123a5c6f8d915b4adff", size = 10248, upload-time = "2025-08-09T03:14:57.996Z" } +sdist = { url = "https://files.pythonhosted.org/packages/2b/ca/c8d7fc6e450c2f8fc6f510cb194754c43b17f933f2dcabcfc6985cbb97a8/types_jmespath-1.1.0.20260124.tar.gz", hash = "sha256:29d86868e72c0820914577077b27d167dcab08b1fc92157a29d537ff7153fdfe", size = 10709, upload-time = "2026-01-24T03:18:46.557Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/0e/6a/65c8be6b6555beaf1a654ae1c2308c2e19a610c0b318a9730e691b79ac79/types_jmespath-1.0.2.20250809-py3-none-any.whl", hash = "sha256:4147d17cc33454f0dac7e78b4e18e532a1330c518d85f7f6d19e5818ab83da21", size = 11494, upload-time = "2025-08-09T03:14:57.292Z" }, + { url = "https://files.pythonhosted.org/packages/61/91/915c4a6e6e9bd2bca3ec0c21c1771b175c59e204b85e57f3f572370fe753/types_jmespath-1.1.0.20260124-py3-none-any.whl", hash = "sha256:ec387666d446b15624215aa9cbd2867ffd885b6c74246d357c65e830c7a138b3", size = 11509, upload-time = "2026-01-24T03:18:45.536Z" }, ] [[package]] @@ -6952,20 +6965,20 @@ wheels = [ [[package]] name = "types-openpyxl" -version = "3.1.5.20250919" +version = "3.1.5.20260316" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/c4/12/8bc4a25d49f1e4b7bbca868daa3ee80b1983d8137b4986867b5b65ab2ecd/types_openpyxl-3.1.5.20250919.tar.gz", hash = "sha256:232b5906773eebace1509b8994cdadda043f692cfdba9bfbb86ca921d54d32d7", size = 100880, upload-time = "2025-09-19T02:54:39.997Z" } +sdist = { url = "https://files.pythonhosted.org/packages/a1/38/32f8ee633dd66ca6d52b8853b9fd45dc3869490195a6ed435d5c868b9c2d/types_openpyxl-3.1.5.20260316.tar.gz", hash = "sha256:081dda9427ea1141e5649e3dcf630e7013a4cf254a5862a7e0a3f53c123b7ceb", size = 101318, upload-time = "2026-03-16T04:29:05.004Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/36/3c/d49cf3f4489a10e9ddefde18fd258f120754c5825d06d145d9a0aaac770b/types_openpyxl-3.1.5.20250919-py3-none-any.whl", hash = "sha256:bd06f18b12fd5e1c9f0b666ee6151d8140216afa7496f7ebb9fe9d33a1a3ce99", size = 166078, upload-time = "2025-09-19T02:54:38.657Z" }, + { url = "https://files.pythonhosted.org/packages/d5/df/b87ae6226ed7cc84b9e43119c489c7f053a9a25e209e0ebb5d84bc36fa37/types_openpyxl-3.1.5.20260316-py3-none-any.whl", hash = "sha256:38e7e125df520fb7eb72cb1129c9f024eb99ef9564aad2c27f68f080c26bcf2d", size = 166084, upload-time = "2026-03-16T04:29:03.657Z" }, ] [[package]] name = "types-pexpect" -version = "4.9.0.20250916" +version = "4.9.0.20260127" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/0c/e6/cc43e306dc7de14ec7861c24ac4957f688741ae39ae685049695d796b587/types_pexpect-4.9.0.20250916.tar.gz", hash = "sha256:69e5fed6199687a730a572de780a5749248a4c5df2ff1521e194563475c9928d", size = 13322, upload-time = "2025-09-16T02:49:25.61Z" } +sdist = { url = "https://files.pythonhosted.org/packages/2e/32/7e03a07e16f79a404d6200ed6bdfcc320d0fb833436a5c6895a1403dedb7/types_pexpect-4.9.0.20260127.tar.gz", hash = "sha256:f8d43efc24251a8e533c71ea9be03d19bb5d08af096d561611697af9720cba7f", size = 13461, upload-time = "2026-01-27T03:28:30.923Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/aa/6d/7740e235a9fb2570968da7d386d7feb511ce68cd23472402ff8cdf7fc78f/types_pexpect-4.9.0.20250916-py3-none-any.whl", hash = "sha256:7fa43cb96042ac58bc74f7c28e5d85782be0ee01344149886849e9d90936fe8a", size = 17057, upload-time = "2025-09-16T02:49:24.546Z" }, + { url = "https://files.pythonhosted.org/packages/8a/d9/7ac5c9aa5a89a1a64cd835ae348227f4939406d826e461b85b690a8ba1c2/types_pexpect-4.9.0.20260127-py3-none-any.whl", hash = "sha256:69216c0ebf0fe45ad2900823133959b027e9471e24fc3f2e4c7b00605555da5f", size = 17078, upload-time = "2026-01-27T03:28:29.848Z" }, ] [[package]] @@ -6988,11 +7001,11 @@ wheels = [ [[package]] name = "types-psycopg2" -version = "2.9.21.20251012" +version = "2.9.21.20260223" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/9b/b3/2d09eaf35a084cffd329c584970a3fa07101ca465c13cad1576d7c392587/types_psycopg2-2.9.21.20251012.tar.gz", hash = "sha256:4cdafd38927da0cfde49804f39ab85afd9c6e9c492800e42f1f0c1a1b0312935", size = 26710, upload-time = "2025-10-12T02:55:39.5Z" } +sdist = { url = "https://files.pythonhosted.org/packages/55/1f/4daff0ce5e8e191844e65aaa793ed1b9cb40027dc2700906ecf2b6bcc0ed/types_psycopg2-2.9.21.20260223.tar.gz", hash = "sha256:78ed70de2e56bc6b5c26c8c1da8e9af54e49fdc3c94d1504609f3519e2b84f02", size = 27090, upload-time = "2026-02-23T04:11:18.177Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/ec/0c/05feaf8cb51159f2c0af04b871dab7e98a2f83a3622f5f216331d2dd924c/types_psycopg2-2.9.21.20251012-py3-none-any.whl", hash = "sha256:712bad5c423fe979e357edbf40a07ca40ef775d74043de72bd4544ca328cc57e", size = 24883, upload-time = "2025-10-12T02:55:38.439Z" }, + { url = "https://files.pythonhosted.org/packages/8d/e7/c566df58410bc0728348b514e718f0b38fa0d248b5c10599a11494ba25d2/types_psycopg2-2.9.21.20260223-py3-none-any.whl", hash = "sha256:c6228ade72d813b0624f4c03feeb89471950ac27cd0506b5debed6f053086bc8", size = 24919, upload-time = "2026-02-23T04:11:17.214Z" }, ] [[package]] @@ -7009,11 +7022,11 @@ wheels = [ [[package]] name = "types-pymysql" -version = "1.1.0.20250916" +version = "1.1.0.20251220" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/1f/12/bda1d977c07e0e47502bede1c44a986dd45946494d89e005e04cdeb0f8de/types_pymysql-1.1.0.20250916.tar.gz", hash = "sha256:98d75731795fcc06723a192786662bdfa760e1e00f22809c104fbb47bac5e29b", size = 22131, upload-time = "2025-09-16T02:49:22.039Z" } +sdist = { url = "https://files.pythonhosted.org/packages/d3/59/e959dd6d2f8e3b3c3f058d79ac9ece328922a5a8770c707fe9c3a757481c/types_pymysql-1.1.0.20251220.tar.gz", hash = "sha256:ae1c3df32a777489431e2e9963880a0df48f6591e0aa2fd3a6fabd9dee6eca54", size = 22184, upload-time = "2025-12-20T03:07:38.689Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/21/eb/a225e32a6e7b196af67ab2f1b07363595f63255374cc3b88bfdab53b4ee8/types_pymysql-1.1.0.20250916-py3-none-any.whl", hash = "sha256:873eb9836bb5e3de4368cc7010ca72775f86e9692a5c7810f8c7f48da082e55b", size = 23063, upload-time = "2025-09-16T02:49:20.933Z" }, + { url = "https://files.pythonhosted.org/packages/8b/fa/4f4d3bfca9ef6dd17d69ed18b96564c53b32d3ce774132308d0bee849f10/types_pymysql-1.1.0.20251220-py3-none-any.whl", hash = "sha256:fa1082af7dea6c53b6caa5784241924b1296ea3a8d3bd060417352c5e10c0618", size = 23067, upload-time = "2025-12-20T03:07:37.766Z" }, ] [[package]] @@ -7031,11 +7044,11 @@ wheels = [ [[package]] name = "types-python-dateutil" -version = "2.9.0.20251115" +version = "2.9.0.20260305" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/6a/36/06d01fb52c0d57e9ad0c237654990920fa41195e4b3d640830dabf9eeb2f/types_python_dateutil-2.9.0.20251115.tar.gz", hash = "sha256:8a47f2c3920f52a994056b8786309b43143faa5a64d4cbb2722d6addabdf1a58", size = 16363, upload-time = "2025-11-15T03:00:13.717Z" } +sdist = { url = "https://files.pythonhosted.org/packages/1d/c7/025c624f347e10476b439a6619a95f1d200250ea88e7ccea6e09e48a7544/types_python_dateutil-2.9.0.20260305.tar.gz", hash = "sha256:389717c9f64d8f769f36d55a01873915b37e97e52ce21928198d210fbd393c8b", size = 16885, upload-time = "2026-03-05T04:00:47.409Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/43/0b/56961d3ba517ed0df9b3a27bfda6514f3d01b28d499d1bce9068cfe4edd1/types_python_dateutil-2.9.0.20251115-py3-none-any.whl", hash = "sha256:9cf9c1c582019753b8639a081deefd7e044b9fa36bd8217f565c6c4e36ee0624", size = 18251, upload-time = "2025-11-15T03:00:12.317Z" }, + { url = "https://files.pythonhosted.org/packages/0a/77/8c0d1ec97f0d9707ad3d8fa270ab8964e7b31b076d2f641c94987395cc75/types_python_dateutil-2.9.0.20260305-py3-none-any.whl", hash = "sha256:a3be9ca444d38cadabd756cfbb29780d8b338ae2a3020e73c266a83cc3025dd7", size = 18419, upload-time = "2026-03-05T04:00:46.392Z" }, ] [[package]] @@ -7049,11 +7062,11 @@ wheels = [ [[package]] name = "types-pywin32" -version = "311.0.0.20251008" +version = "311.0.0.20260316" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/1a/05/cd94300066241a7abb52238f0dd8d7f4fe1877cf2c72bd1860856604d962/types_pywin32-311.0.0.20251008.tar.gz", hash = "sha256:d6d4faf8e0d7fdc0e0a1ff297b80be07d6d18510f102d793bf54e9e3e86f6d06", size = 329561, upload-time = "2025-10-08T02:51:39.436Z" } +sdist = { url = "https://files.pythonhosted.org/packages/17/a8/b4652002a854fcfe5d272872a0ae2d5df0e9dc482e1a6dfb5e97b905b76f/types_pywin32-311.0.0.20260316.tar.gz", hash = "sha256:c136fa489fe6279a13bca167b750414e18d657169b7cf398025856dc363004e8", size = 329956, upload-time = "2026-03-16T04:28:57.366Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/af/08/00a38e6b71585e6741d5b3b4cc9dd165cf549b6f1ed78815c6585f8b1b58/types_pywin32-311.0.0.20251008-py3-none-any.whl", hash = "sha256:775e1046e0bad6d29ca47501301cce67002f6661b9cebbeca93f9c388c53fab4", size = 392942, upload-time = "2025-10-08T02:51:38.327Z" }, + { url = "https://files.pythonhosted.org/packages/f0/83/704698d93788cf1c2f5e236eae2b37f1b2152ef84dc66b4b83f6c7487b76/types_pywin32-311.0.0.20260316-py3-none-any.whl", hash = "sha256:abb643d50012386d697af49384cc0e6e475eab76b0ca2a7f93d480d0862b3692", size = 392959, upload-time = "2026-03-16T04:28:56.104Z" }, ] [[package]] @@ -7110,11 +7123,11 @@ wheels = [ [[package]] name = "types-setuptools" -version = "80.9.0.20250822" +version = "82.0.0.20260210" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/19/bd/1e5f949b7cb740c9f0feaac430e301b8f1c5f11a81e26324299ea671a237/types_setuptools-80.9.0.20250822.tar.gz", hash = "sha256:070ea7716968ec67a84c7f7768d9952ff24d28b65b6594797a464f1b3066f965", size = 41296, upload-time = "2025-08-22T03:02:08.771Z" } +sdist = { url = "https://files.pythonhosted.org/packages/4b/90/796ac8c774a7f535084aacbaa6b7053d16fff5c630eff87c3ecff7896c37/types_setuptools-82.0.0.20260210.tar.gz", hash = "sha256:d9719fbbeb185254480ade1f25327c4654f8c00efda3fec36823379cebcdee58", size = 44768, upload-time = "2026-02-10T04:22:02.107Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/b6/2d/475bf15c1cdc172e7a0d665b6e373ebfb1e9bf734d3f2f543d668b07a142/types_setuptools-80.9.0.20250822-py3-none-any.whl", hash = "sha256:53bf881cb9d7e46ed12c76ef76c0aaf28cfe6211d3fab12e0b83620b1a8642c3", size = 63179, upload-time = "2025-08-22T03:02:07.643Z" }, + { url = "https://files.pythonhosted.org/packages/3e/54/3489432b1d9bc713c9d8aa810296b8f5b0088403662959fb63a8acdbd4fc/types_setuptools-82.0.0.20260210-py3-none-any.whl", hash = "sha256:5124a7daf67f195c6054e0f00f1d97c69caad12fdcf9113eba33eff0bce8cd2b", size = 68433, upload-time = "2026-02-10T04:22:00.876Z" }, ] [[package]] @@ -7149,28 +7162,28 @@ wheels = [ [[package]] name = "types-tensorflow" -version = "2.18.0.20251008" +version = "2.18.0.20260224" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "numpy" }, { name = "types-protobuf" }, { name = "types-requests" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/0d/0a/13bde03fb5a23faaadcca2d6914f865e444334133902310ea05e6ade780c/types_tensorflow-2.18.0.20251008.tar.gz", hash = "sha256:8db03d4dd391a362e2ea796ffdbccb03c082127606d4d852edb7ed9504745933", size = 257550, upload-time = "2025-10-08T02:51:51.104Z" } +sdist = { url = "https://files.pythonhosted.org/packages/af/cb/4914c2fbc1cf8a8d1ef2a7c727bb6f694879be85edeee880a0c88e696af8/types_tensorflow-2.18.0.20260224.tar.gz", hash = "sha256:9b0ccc91c79c88791e43d3f80d6c879748fa0361409c5ff23c7ffe3709be00f2", size = 258786, upload-time = "2026-02-24T04:06:45.613Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/66/cc/e50e49db621b0cf03c1f3d10be47389de41a02dc9924c3a83a9c1a55bf28/types_tensorflow-2.18.0.20251008-py3-none-any.whl", hash = "sha256:d6b0dd4d81ac6d9c5af803ebcc8ce0f65c5850c063e8b9789dc828898944b5f4", size = 329023, upload-time = "2025-10-08T02:51:50.024Z" }, + { url = "https://files.pythonhosted.org/packages/d4/1d/a1c3c60f0eb1a204500dbdc66e3d18aafabc86ad07a8eca71ea05bc8c5a8/types_tensorflow-2.18.0.20260224-py3-none-any.whl", hash = "sha256:6a25f5f41f3e06f28c1f65c6e09f484d4ba0031d6d8df83a39df9d890245eefc", size = 329746, upload-time = "2026-02-24T04:06:44.4Z" }, ] [[package]] name = "types-tqdm" -version = "4.67.0.20250809" +version = "4.67.3.20260303" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "types-requests" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/fb/d0/cf498fc630d9fdaf2428b93e60b0e67b08008fec22b78716b8323cf644dc/types_tqdm-4.67.0.20250809.tar.gz", hash = "sha256:02bf7ab91256080b9c4c63f9f11b519c27baaf52718e5fdab9e9606da168d500", size = 17200, upload-time = "2025-08-09T03:17:43.489Z" } +sdist = { url = "https://files.pythonhosted.org/packages/e1/64/3e7cb0f40c4bf9578098b6873df33a96f7e0de90f3a039e614d22bfde40a/types_tqdm-4.67.3.20260303.tar.gz", hash = "sha256:7bfddb506a75aedb4030fabf4f05c5638c9a3bbdf900d54ec6c82be9034bfb96", size = 18117, upload-time = "2026-03-03T04:03:49.679Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/3f/13/3ff0781445d7c12730befce0fddbbc7a76e56eb0e7029446f2853238360a/types_tqdm-4.67.0.20250809-py3-none-any.whl", hash = "sha256:1a73053b31fcabf3c1f3e2a9d5ecdba0f301bde47a418cd0e0bdf774827c5c57", size = 24020, upload-time = "2025-08-09T03:17:42.453Z" }, + { url = "https://files.pythonhosted.org/packages/37/32/e4a1fce59155c74082f1a42d0ffafa59652bfb8cff35b04d56333877748e/types_tqdm-4.67.3.20260303-py3-none-any.whl", hash = "sha256:459decf677e4b05cef36f9012ef8d6e20578edefb6b78c15bd0b546247eda62d", size = 24572, upload-time = "2026-03-03T04:03:48.913Z" }, ] [[package]] diff --git a/docker/.env.example b/docker/.env.example index 4ef856b431..9d6cd65318 100644 --- a/docker/.env.example +++ b/docker/.env.example @@ -1546,24 +1546,25 @@ SANDBOX_EXPIRED_RECORDS_CLEAN_BATCH_MAX_INTERVAL=200 SANDBOX_EXPIRED_RECORDS_RETENTION_DAYS=30 -# Redis URL used for PubSub between API and +# Redis URL used for event bus between API and # celery worker # defaults to url constructed from `REDIS_*` # configurations -PUBSUB_REDIS_URL= -# Pub/sub channel type for streaming events. -# valid options are: +EVENT_BUS_REDIS_URL= +# Event transport type. Options are: # -# - pubsub: for normal Pub/Sub -# - sharded: for sharded Pub/Sub +# - pubsub: normal Pub/Sub (at-most-once) +# - sharded: sharded Pub/Sub (at-most-once) +# - streams: Redis Streams (at-least-once, recommended to avoid subscriber races) # -# It's highly recommended to use sharded Pub/Sub AND redis cluster -# for large deployments. -PUBSUB_REDIS_CHANNEL_TYPE=pubsub -# Whether to use Redis cluster mode while running -# PubSub. +# Note: Before enabling 'streams' in production, estimate your expected event volume and retention needs. +# Configure Redis memory limits and stream trimming appropriately (e.g., MAXLEN and key expiry) to reduce +# the risk of data loss from Redis auto-eviction under memory pressure. +# Also accepts ENV: EVENT_BUS_REDIS_CHANNEL_TYPE. +EVENT_BUS_REDIS_CHANNEL_TYPE=pubsub +# Whether to use Redis cluster mode while use redis as event bus. # It's highly recommended to enable this for large deployments. -PUBSUB_REDIS_USE_CLUSTERS=false +EVENT_BUS_REDIS_USE_CLUSTERS=false # Whether to Enable human input timeout check task ENABLE_HUMAN_INPUT_TIMEOUT_TASK=true diff --git a/docker/docker-compose-template.yaml b/docker/docker-compose-template.yaml index fcd4800143..1804592c0e 100644 --- a/docker/docker-compose-template.yaml +++ b/docker/docker-compose-template.yaml @@ -269,7 +269,7 @@ services: # plugin daemon plugin_daemon: - image: langgenius/dify-plugin-daemon:0.5.3-local + image: langgenius/dify-plugin-daemon:0.5.4-local restart: always environment: # Use the shared environment variables. diff --git a/docker/docker-compose.middleware.yaml b/docker/docker-compose.middleware.yaml index 4a739bbbe0..2dca581903 100644 --- a/docker/docker-compose.middleware.yaml +++ b/docker/docker-compose.middleware.yaml @@ -123,7 +123,7 @@ services: # plugin daemon plugin_daemon: - image: langgenius/dify-plugin-daemon:0.5.3-local + image: langgenius/dify-plugin-daemon:0.5.4-local restart: always env_file: - ./middleware.env diff --git a/docker/docker-compose.yaml b/docker/docker-compose.yaml index d5dfdbd16b..d14f0503e7 100644 --- a/docker/docker-compose.yaml +++ b/docker/docker-compose.yaml @@ -699,9 +699,9 @@ x-shared-env: &shared-api-worker-env SANDBOX_EXPIRED_RECORDS_CLEAN_BATCH_SIZE: ${SANDBOX_EXPIRED_RECORDS_CLEAN_BATCH_SIZE:-1000} SANDBOX_EXPIRED_RECORDS_CLEAN_BATCH_MAX_INTERVAL: ${SANDBOX_EXPIRED_RECORDS_CLEAN_BATCH_MAX_INTERVAL:-200} SANDBOX_EXPIRED_RECORDS_RETENTION_DAYS: ${SANDBOX_EXPIRED_RECORDS_RETENTION_DAYS:-30} - PUBSUB_REDIS_URL: ${PUBSUB_REDIS_URL:-} - PUBSUB_REDIS_CHANNEL_TYPE: ${PUBSUB_REDIS_CHANNEL_TYPE:-pubsub} - PUBSUB_REDIS_USE_CLUSTERS: ${PUBSUB_REDIS_USE_CLUSTERS:-false} + EVENT_BUS_REDIS_URL: ${EVENT_BUS_REDIS_URL:-} + EVENT_BUS_REDIS_CHANNEL_TYPE: ${EVENT_BUS_REDIS_CHANNEL_TYPE:-pubsub} + EVENT_BUS_REDIS_USE_CLUSTERS: ${EVENT_BUS_REDIS_USE_CLUSTERS:-false} ENABLE_HUMAN_INPUT_TIMEOUT_TASK: ${ENABLE_HUMAN_INPUT_TIMEOUT_TASK:-true} HUMAN_INPUT_TIMEOUT_TASK_INTERVAL: ${HUMAN_INPUT_TIMEOUT_TASK_INTERVAL:-1} SANDBOX_EXPIRED_RECORDS_CLEAN_TASK_LOCK_TTL: ${SANDBOX_EXPIRED_RECORDS_CLEAN_TASK_LOCK_TTL:-90000} @@ -976,7 +976,7 @@ services: # plugin daemon plugin_daemon: - image: langgenius/dify-plugin-daemon:0.5.3-local + image: langgenius/dify-plugin-daemon:0.5.4-local restart: always environment: # Use the shared environment variables. diff --git a/web/__tests__/check-components-diff-coverage.test.ts b/web/__tests__/check-components-diff-coverage.test.ts index 9ce8b30ceb..62e5ff5ed5 100644 --- a/web/__tests__/check-components-diff-coverage.test.ts +++ b/web/__tests__/check-components-diff-coverage.test.ts @@ -1,4 +1,5 @@ import { + buildGitDiffRevisionArgs, getChangedBranchCoverage, getChangedStatementCoverage, getIgnoredChangedLinesFromSource, @@ -7,6 +8,11 @@ import { } from '../scripts/check-components-diff-coverage-lib.mjs' describe('check-components-diff-coverage helpers', () => { + it('should build exact and merge-base git diff revision args', () => { + expect(buildGitDiffRevisionArgs('base-sha', 'head-sha', 'exact')).toEqual(['base-sha', 'head-sha']) + expect(buildGitDiffRevisionArgs('base-sha', 'head-sha')).toEqual(['base-sha...head-sha']) + }) + it('should parse changed line maps from unified diffs', () => { const diff = [ 'diff --git a/web/app/components/share/a.ts b/web/app/components/share/a.ts', @@ -157,9 +163,38 @@ describe('check-components-diff-coverage helpers', () => { expect(coverage).toEqual({ covered: 0, - total: 2, + total: 1, uncoveredBranches: [ { armIndex: 0, line: 33 }, + ], + }) + }) + + it('should require all branch arms when the branch condition changes', () => { + const entry = { + b: { + 0: [0, 0], + }, + branchMap: { + 0: { + line: 30, + loc: { start: { line: 30 }, end: { line: 35 } }, + locations: [ + { start: { line: 31 }, end: { line: 34 } }, + { start: { line: 35 }, end: { line: 38 } }, + ], + type: 'if', + }, + }, + } + + const coverage = getChangedBranchCoverage(entry, new Set([30])) + + expect(coverage).toEqual({ + covered: 0, + total: 2, + uncoveredBranches: [ + { armIndex: 0, line: 31 }, { armIndex: 1, line: 35 }, ], }) diff --git a/web/__tests__/components-coverage-common.test.ts b/web/__tests__/components-coverage-common.test.ts new file mode 100644 index 0000000000..ab189ed854 --- /dev/null +++ b/web/__tests__/components-coverage-common.test.ts @@ -0,0 +1,72 @@ +import { + getCoverageStats, + isRelevantTestFile, + isTrackedComponentSourceFile, + loadTrackedCoverageEntries, +} from '../scripts/components-coverage-common.mjs' + +describe('components coverage common helpers', () => { + it('should identify tracked component source files and relevant tests', () => { + const excludedComponentCoverageFiles = new Set([ + 'web/app/components/share/types.ts', + ]) + + expect(isTrackedComponentSourceFile('web/app/components/share/index.tsx', excludedComponentCoverageFiles)).toBe(true) + expect(isTrackedComponentSourceFile('web/app/components/share/types.ts', excludedComponentCoverageFiles)).toBe(false) + expect(isTrackedComponentSourceFile('web/app/components/provider/index.tsx', excludedComponentCoverageFiles)).toBe(false) + + expect(isRelevantTestFile('web/__tests__/share/text-generation-run-once-flow.test.tsx')).toBe(true) + expect(isRelevantTestFile('web/app/components/share/__tests__/index.spec.tsx')).toBe(true) + expect(isRelevantTestFile('web/utils/format.spec.ts')).toBe(false) + }) + + it('should load only tracked coverage entries from mixed coverage paths', () => { + const context = { + excludedComponentCoverageFiles: new Set([ + 'web/app/components/share/types.ts', + ]), + repoRoot: '/repo', + webRoot: '/repo/web', + } + const coverage = { + '/repo/web/app/components/provider/index.tsx': { + path: '/repo/web/app/components/provider/index.tsx', + statementMap: { 0: { start: { line: 1 }, end: { line: 1 } } }, + s: { 0: 1 }, + }, + 'app/components/share/index.tsx': { + path: 'app/components/share/index.tsx', + statementMap: { 0: { start: { line: 2 }, end: { line: 2 } } }, + s: { 0: 1 }, + }, + 'app/components/share/types.ts': { + path: 'app/components/share/types.ts', + statementMap: { 0: { start: { line: 3 }, end: { line: 3 } } }, + s: { 0: 1 }, + }, + } + + expect([...loadTrackedCoverageEntries(coverage, context).keys()]).toEqual([ + 'web/app/components/share/index.tsx', + ]) + }) + + it('should calculate coverage stats using statement-derived line hits', () => { + const entry = { + b: { 0: [1, 0] }, + f: { 0: 1, 1: 0 }, + s: { 0: 1, 1: 0 }, + statementMap: { + 0: { start: { line: 10 }, end: { line: 10 } }, + 1: { start: { line: 12 }, end: { line: 13 } }, + }, + } + + expect(getCoverageStats(entry)).toEqual({ + branches: { covered: 1, total: 2 }, + functions: { covered: 1, total: 2 }, + lines: { covered: 1, total: 2 }, + statements: { covered: 1, total: 2 }, + }) + }) +}) diff --git a/web/app/components/app/configuration/config-vision/index.spec.tsx b/web/app/components/app/configuration/config-vision/index.spec.tsx index 5fc7648bea..0c6e1346ce 100644 --- a/web/app/components/app/configuration/config-vision/index.spec.tsx +++ b/web/app/components/app/configuration/config-vision/index.spec.tsx @@ -218,7 +218,7 @@ describe('ParamConfigContent', () => { }) render() - const input = screen.getByRole('spinbutton') as HTMLInputElement + const input = screen.getByRole('textbox') as HTMLInputElement fireEvent.change(input, { target: { value: '4' } }) const updatedFile = getLatestFileConfig() diff --git a/web/app/components/app/configuration/dataset-config/params-config/index.spec.tsx b/web/app/components/app/configuration/dataset-config/params-config/index.spec.tsx index 67d59f2706..7904159109 100644 --- a/web/app/components/app/configuration/dataset-config/params-config/index.spec.tsx +++ b/web/app/components/app/configuration/dataset-config/params-config/index.spec.tsx @@ -184,8 +184,8 @@ describe('dataset-config/params-config', () => { await user.click(incrementButtons[0]) await waitFor(() => { - const [topKInput] = dialogScope.getAllByRole('spinbutton') - expect(topKInput).toHaveValue(5) + const [topKInput] = dialogScope.getAllByRole('textbox') + expect(topKInput).toHaveValue('5') }) await user.click(dialogScope.getByRole('button', { name: 'common.operation.save' })) @@ -197,10 +197,10 @@ describe('dataset-config/params-config', () => { await user.click(screen.getByRole('button', { name: 'dataset.retrievalSettings' })) const reopenedDialog = await screen.findByRole('dialog', {}, { timeout: 3000 }) const reopenedScope = within(reopenedDialog) - const [reopenedTopKInput] = reopenedScope.getAllByRole('spinbutton') + const [reopenedTopKInput] = reopenedScope.getAllByRole('textbox') // Assert - expect(reopenedTopKInput).toHaveValue(5) + expect(reopenedTopKInput).toHaveValue('5') }) it('should discard changes when cancel is clicked', async () => { @@ -217,8 +217,8 @@ describe('dataset-config/params-config', () => { await user.click(incrementButtons[0]) await waitFor(() => { - const [topKInput] = dialogScope.getAllByRole('spinbutton') - expect(topKInput).toHaveValue(5) + const [topKInput] = dialogScope.getAllByRole('textbox') + expect(topKInput).toHaveValue('5') }) const cancelButton = await dialogScope.findByRole('button', { name: 'common.operation.cancel' }) @@ -231,10 +231,10 @@ describe('dataset-config/params-config', () => { await user.click(screen.getByRole('button', { name: 'dataset.retrievalSettings' })) const reopenedDialog = await screen.findByRole('dialog', {}, { timeout: 3000 }) const reopenedScope = within(reopenedDialog) - const [reopenedTopKInput] = reopenedScope.getAllByRole('spinbutton') + const [reopenedTopKInput] = reopenedScope.getAllByRole('textbox') // Assert - expect(reopenedTopKInput).toHaveValue(4) + expect(reopenedTopKInput).toHaveValue('4') }) it('should prevent saving when rerank model is required but invalid', async () => { diff --git a/web/app/components/app/configuration/dataset-config/select-dataset/index.spec.tsx b/web/app/components/app/configuration/dataset-config/select-dataset/index.spec.tsx index 40cb3ffc81..bd6c1976a6 100644 --- a/web/app/components/app/configuration/dataset-config/select-dataset/index.spec.tsx +++ b/web/app/components/app/configuration/dataset-config/select-dataset/index.spec.tsx @@ -137,4 +137,31 @@ describe('SelectDataSet', () => { expect(screen.getByRole('link', { name: 'appDebug.feature.dataSet.toCreate' })).toHaveAttribute('href', '/datasets/create') expect(screen.getByRole('button', { name: 'common.operation.add' })).toBeDisabled() }) + + it('uses selectedIds as the initial modal selection', async () => { + const datasetOne = makeDataset({ + id: 'set-1', + name: 'Dataset One', + }) + mockUseInfiniteDatasets.mockReturnValue({ + data: { pages: [{ data: [datasetOne] }] }, + isLoading: false, + isFetchingNextPage: false, + fetchNextPage: vi.fn(), + hasNextPage: false, + }) + + const onSelect = vi.fn() + await act(async () => { + render() + }) + + expect(screen.getByText('1 appDebug.feature.dataSet.selected')).toBeInTheDocument() + + await act(async () => { + fireEvent.click(screen.getByRole('button', { name: 'common.operation.add' })) + }) + + expect(onSelect).toHaveBeenCalledWith([datasetOne]) + }) }) diff --git a/web/app/components/app/configuration/dataset-config/select-dataset/index.tsx b/web/app/components/app/configuration/dataset-config/select-dataset/index.tsx index 330223f974..91e5353cc4 100644 --- a/web/app/components/app/configuration/dataset-config/select-dataset/index.tsx +++ b/web/app/components/app/configuration/dataset-config/select-dataset/index.tsx @@ -4,7 +4,7 @@ import type { DataSet } from '@/models/datasets' import { useInfiniteScroll } from 'ahooks' import Link from 'next/link' import * as React from 'react' -import { useEffect, useMemo, useRef, useState } from 'react' +import { useMemo, useRef, useState } from 'react' import { useTranslation } from 'react-i18next' import AppIcon from '@/app/components/base/app-icon' import Badge from '@/app/components/base/badge' @@ -31,17 +31,21 @@ const SelectDataSet: FC = ({ onSelect, }) => { const { t } = useTranslation() - const [selected, setSelected] = useState([]) + const [selectedIdsInModal, setSelectedIdsInModal] = useState(() => selectedIds) const canSelectMulti = true const { formatIndexingTechniqueAndMethod } = useKnowledge() const { data, isLoading, isFetchingNextPage, fetchNextPage, hasNextPage } = useInfiniteDatasets( { page: 1 }, { enabled: isShow, staleTime: 0, refetchOnMount: 'always' }, ) - const pages = data?.pages || [] const datasets = useMemo(() => { + const pages = data?.pages || [] return pages.flatMap(page => page.data.filter(item => item.indexing_technique || item.provider === 'external')) - }, [pages]) + }, [data]) + const datasetMap = useMemo(() => new Map(datasets.map(item => [item.id, item])), [datasets]) + const selected = useMemo(() => { + return selectedIdsInModal.map(id => datasetMap.get(id) || ({ id } as DataSet)) + }, [datasetMap, selectedIdsInModal]) const hasNoData = !isLoading && datasets.length === 0 const listRef = useRef(null) @@ -61,50 +65,14 @@ const SelectDataSet: FC = ({ }, ) - const prevSelectedIdsRef = useRef([]) - const hasUserModifiedSelectionRef = useRef(false) - useEffect(() => { - if (isShow) - hasUserModifiedSelectionRef.current = false - }, [isShow]) - useEffect(() => { - const prevSelectedIds = prevSelectedIdsRef.current - const idsChanged = selectedIds.length !== prevSelectedIds.length - || selectedIds.some((id, idx) => id !== prevSelectedIds[idx]) - - if (!selectedIds.length && (!hasUserModifiedSelectionRef.current || idsChanged)) { - setSelected([]) - prevSelectedIdsRef.current = selectedIds - hasUserModifiedSelectionRef.current = false - return - } - - if (!idsChanged && hasUserModifiedSelectionRef.current) - return - - setSelected((prev) => { - const prevMap = new Map(prev.map(item => [item.id, item])) - const nextSelected = selectedIds - .map(id => datasets.find(item => item.id === id) || prevMap.get(id)) - .filter(Boolean) as DataSet[] - return nextSelected - }) - prevSelectedIdsRef.current = selectedIds - hasUserModifiedSelectionRef.current = false - }, [datasets, selectedIds]) - const toggleSelect = (dataSet: DataSet) => { - hasUserModifiedSelectionRef.current = true - const isSelected = selected.some(item => item.id === dataSet.id) - if (isSelected) { - setSelected(selected.filter(item => item.id !== dataSet.id)) - } - else { - if (canSelectMulti) - setSelected([...selected, dataSet]) - else - setSelected([dataSet]) - } + setSelectedIdsInModal((prev) => { + const isSelected = prev.includes(dataSet.id) + if (isSelected) + return prev.filter(id => id !== dataSet.id) + + return canSelectMulti ? [...prev, dataSet.id] : [dataSet.id] + }) } const handleSelect = () => { @@ -126,7 +94,7 @@ const SelectDataSet: FC = ({ {hasNoData && (
= ({ key={item.id} className={cn( 'flex h-10 cursor-pointer items-center rounded-lg border-[0.5px] border-components-panel-border-subtle bg-components-panel-on-panel-item-bg px-2 shadow-xs hover:border-components-panel-border hover:bg-components-panel-on-panel-item-bg-hover hover:shadow-sm', - selected.some(i => i.id === item.id) && 'border-[1.5px] border-components-option-card-option-selected-border bg-state-accent-hover shadow-xs hover:border-components-option-card-option-selected-border hover:bg-state-accent-hover hover:shadow-xs', + selectedIdsInModal.includes(item.id) && 'border-[1.5px] border-components-option-card-option-selected-border bg-state-accent-hover shadow-xs hover:border-components-option-card-option-selected-border hover:bg-state-accent-hover hover:shadow-xs', !item.embedding_available && 'hover:border-components-panel-border-subtle hover:bg-components-panel-on-panel-item-bg hover:shadow-xs', )} onClick={() => { @@ -195,7 +163,7 @@ const SelectDataSet: FC = ({ )} {!isLoading && (
-
+
{selected.length > 0 && `${selected.length} ${t('feature.dataSet.selected', { ns: 'appDebug' })}`}
diff --git a/web/app/components/app/create-app-modal/index.tsx b/web/app/components/app/create-app-modal/index.tsx index 16ca4bdaff..1c22913bb1 100644 --- a/web/app/components/app/create-app-modal/index.tsx +++ b/web/app/components/app/create-app-modal/index.tsx @@ -4,7 +4,6 @@ import type { AppIconSelection } from '../../base/app-icon-picker' import { RiArrowRightLine, RiArrowRightSLine, RiExchange2Fill } from '@remixicon/react' import { useDebounceFn, useKeyPress } from 'ahooks' -import Image from 'next/image' import { useRouter } from 'next/navigation' import { useCallback, useEffect, useRef, useState } from 'react' import { useTranslation } from 'react-i18next' @@ -117,10 +116,10 @@ function CreateApp({ onClose, onSuccess, onCreateFromTemplate, defaultAppMode }:
- {t('newApp.startFromBlank', { ns: 'app' })} + {t('newApp.startFromBlank', { ns: 'app' })}
- {t('newApp.chooseAppType', { ns: 'app' })} + {t('newApp.chooseAppType', { ns: 'app' })}
@@ -160,7 +159,7 @@ function CreateApp({ onClose, onSuccess, onCreateFromTemplate, defaultAppMode }: className="flex cursor-pointer items-center border-0 bg-transparent p-0" onClick={() => setIsAppTypeExpanded(!isAppTypeExpanded)} > - {t('newApp.forBeginners', { ns: 'app' })} + {t('newApp.forBeginners', { ns: 'app' })}
@@ -212,7 +211,7 @@ function CreateApp({ onClose, onSuccess, onCreateFromTemplate, defaultAppMode }:
- +
- - + + ( {t('newApp.optional', { ns: 'app' })} ) @@ -260,7 +259,7 @@ function CreateApp({ onClose, onSuccess, onCreateFromTemplate, defaultAppMode }:
{isAppsFull && }
-
+
{t('newApp.noIdeaTip', { ns: 'app' })}
@@ -334,8 +333,8 @@ function AppTypeCard({ icon, title, description, active, onClick }: AppTypeCardP onClick={onClick} > {icon} -
{title}
-
{description}
+
{title}
+
{description}
) } @@ -367,8 +366,8 @@ function AppPreview({ mode }: { mode: AppModeEnum }) { const previewInfo = modeToPreviewInfoMap[mode] return (
-

{previewInfo.title}

-
+

{previewInfo.title}

+
{previewInfo.description}
@@ -389,7 +388,7 @@ function AppScreenShot({ mode, show }: { mode: AppModeEnum, show: boolean }) { -
, })) -// Mock next/image to render a normal img tag for testing -vi.mock('next/image', () => ({ - __esModule: true, - default: (props: ImgHTMLAttributes & { unoptimized?: boolean }) => { - const { unoptimized: _, ...rest } = props - return - }, -})) - type GlobalPublicStoreMock = { systemFeatures: SystemFeatures setSystemFeatures: (systemFeatures: SystemFeatures) => void diff --git a/web/app/components/base/checkbox-list/__tests__/index.spec.tsx b/web/app/components/base/checkbox-list/__tests__/index.spec.tsx index 7c588f6a33..b4f816dda8 100644 --- a/web/app/components/base/checkbox-list/__tests__/index.spec.tsx +++ b/web/app/components/base/checkbox-list/__tests__/index.spec.tsx @@ -1,13 +1,7 @@ -/* eslint-disable next/no-img-element */ -import type { ImgHTMLAttributes } from 'react' import { render, screen } from '@testing-library/react' import userEvent from '@testing-library/user-event' import CheckboxList from '..' -vi.mock('next/image', () => ({ - default: (props: ImgHTMLAttributes) => , -})) - describe('checkbox list component', () => { const options = [ { label: 'Option 1', value: 'option1' }, diff --git a/web/app/components/base/checkbox-list/index.tsx b/web/app/components/base/checkbox-list/index.tsx index ed328244a1..6eda2aebd0 100644 --- a/web/app/components/base/checkbox-list/index.tsx +++ b/web/app/components/base/checkbox-list/index.tsx @@ -1,6 +1,5 @@ 'use client' import type { FC } from 'react' -import Image from 'next/image' import { useCallback, useMemo, useState } from 'react' import { useTranslation } from 'react-i18next' import Badge from '@/app/components/base/badge' @@ -169,7 +168,7 @@ const CheckboxList: FC = ({ {searchQuery ? (
- + search menu {t('operation.noSearchResults', { ns: 'common', content: title })}
diff --git a/web/app/components/base/file-thumb/__tests__/index.spec.tsx b/web/app/components/base/file-thumb/__tests__/index.spec.tsx index 368f14ae75..f67f291579 100644 --- a/web/app/components/base/file-thumb/__tests__/index.spec.tsx +++ b/web/app/components/base/file-thumb/__tests__/index.spec.tsx @@ -1,14 +1,7 @@ -/* eslint-disable next/no-img-element */ -import type { ImgHTMLAttributes } from 'react' import { fireEvent, render, screen } from '@testing-library/react' import userEvent from '@testing-library/user-event' import FileThumb from '../index' -vi.mock('next/image', () => ({ - __esModule: true, - default: (props: ImgHTMLAttributes) => , -})) - describe('FileThumb Component', () => { const mockImageFile = { name: 'test-image.jpg', diff --git a/web/app/components/base/form/components/field/__tests__/number-input.spec.tsx b/web/app/components/base/form/components/field/__tests__/number-input.spec.tsx index 049e19d75e..84c02f3327 100644 --- a/web/app/components/base/form/components/field/__tests__/number-input.spec.tsx +++ b/web/app/components/base/form/components/field/__tests__/number-input.spec.tsx @@ -22,7 +22,7 @@ describe('NumberInputField', () => { it('should render current number value', () => { render() - expect(screen.getByDisplayValue('2')).toBeInTheDocument() + expect(screen.getByRole('textbox')).toHaveValue('2') }) it('should update value when users click increment', () => { diff --git a/web/app/components/base/form/form-scenarios/base/__tests__/field.spec.tsx b/web/app/components/base/form/form-scenarios/base/__tests__/field.spec.tsx index 7de473e4c8..1d7734f670 100644 --- a/web/app/components/base/form/form-scenarios/base/__tests__/field.spec.tsx +++ b/web/app/components/base/form/form-scenarios/base/__tests__/field.spec.tsx @@ -45,7 +45,7 @@ describe('BaseField', () => { it('should render a number input when configured as number input', () => { render() - expect(screen.getByRole('spinbutton')).toBeInTheDocument() + expect(screen.getByRole('textbox')).toBeInTheDocument() expect(screen.getByText('Age')).toBeInTheDocument() }) diff --git a/web/app/components/base/input-number/__tests__/index.spec.tsx b/web/app/components/base/input-number/__tests__/index.spec.tsx index 53e49a51ed..6056bbf5c0 100644 --- a/web/app/components/base/input-number/__tests__/index.spec.tsx +++ b/web/app/components/base/input-number/__tests__/index.spec.tsx @@ -13,7 +13,7 @@ describe('InputNumber Component', () => { it('renders input with default values', () => { render() - const input = screen.getByRole('spinbutton') + const input = screen.getByRole('textbox') expect(input).toBeInTheDocument() }) @@ -60,7 +60,7 @@ describe('InputNumber Component', () => { it('handles direct input changes', () => { const onChange = vi.fn() render() - const input = screen.getByRole('spinbutton') + const input = screen.getByRole('textbox') fireEvent.change(input, { target: { value: '42' } }) expect(onChange).toHaveBeenCalledWith(42) @@ -69,38 +69,25 @@ describe('InputNumber Component', () => { it('handles empty input', () => { const onChange = vi.fn() render() - const input = screen.getByRole('spinbutton') + const input = screen.getByRole('textbox') fireEvent.change(input, { target: { value: '' } }) expect(onChange).toHaveBeenCalledWith(0) }) - it('does not call onChange when parsed value is NaN', () => { + it('does not call onChange when input is not parseable', () => { const onChange = vi.fn() render() - const input = screen.getByRole('spinbutton') + const input = screen.getByRole('textbox') - const originalNumber = globalThis.Number - const numberSpy = vi.spyOn(globalThis, 'Number').mockImplementation((val: unknown) => { - if (val === '123') { - return Number.NaN - } - return originalNumber(val) - }) - - try { - fireEvent.change(input, { target: { value: '123' } }) - expect(onChange).not.toHaveBeenCalled() - } - finally { - numberSpy.mockRestore() - } + fireEvent.change(input, { target: { value: 'abc' } }) + expect(onChange).not.toHaveBeenCalled() }) it('does not call onChange when direct input exceeds range', () => { const onChange = vi.fn() render() - const input = screen.getByRole('spinbutton') + const input = screen.getByRole('textbox') fireEvent.change(input, { target: { value: '11' } }) @@ -141,7 +128,7 @@ describe('InputNumber Component', () => { it('disables controls when disabled prop is true', () => { const onChange = vi.fn() render() - const input = screen.getByRole('spinbutton') + const input = screen.getByRole('textbox') const incrementBtn = screen.getByRole('button', { name: /increment/i }) const decrementBtn = screen.getByRole('button', { name: /decrement/i }) @@ -211,6 +198,16 @@ describe('InputNumber Component', () => { expect(onChange).not.toHaveBeenCalled() }) + it('uses fallback step guard when step is any', async () => { + const user = userEvent.setup() + const onChange = vi.fn() + render() + const incrementBtn = screen.getByRole('button', { name: /increment/i }) + + await user.click(incrementBtn) + expect(onChange).not.toHaveBeenCalled() + }) + it('prevents decrement below min with custom amount', async () => { const user = userEvent.setup() const onChange = vi.fn() @@ -244,7 +241,7 @@ describe('InputNumber Component', () => { it('validates input against max constraint', () => { const onChange = vi.fn() render() - const input = screen.getByRole('spinbutton') + const input = screen.getByRole('textbox') fireEvent.change(input, { target: { value: '15' } }) expect(onChange).not.toHaveBeenCalled() @@ -253,7 +250,7 @@ describe('InputNumber Component', () => { it('validates input against min constraint', () => { const onChange = vi.fn() render() - const input = screen.getByRole('spinbutton') + const input = screen.getByRole('textbox') fireEvent.change(input, { target: { value: '2' } }) expect(onChange).not.toHaveBeenCalled() @@ -262,7 +259,7 @@ describe('InputNumber Component', () => { it('accepts input within min and max constraints', () => { const onChange = vi.fn() render() - const input = screen.getByRole('spinbutton') + const input = screen.getByRole('textbox') fireEvent.change(input, { target: { value: '50' } }) expect(onChange).toHaveBeenCalledWith(50) @@ -296,6 +293,25 @@ describe('InputNumber Component', () => { expect(wrapper).toHaveClass(wrapClassName) }) + it('applies wrapperClassName to outer div for Input compatibility', () => { + const onChange = vi.fn() + const wrapperClassName = 'custom-input-wrapper' + render() + + const input = screen.getByRole('textbox') + const wrapper = screen.getByTestId('input-number-wrapper') + + expect(input).not.toHaveAttribute('wrapperClassName') + expect(wrapper).toHaveClass(wrapperClassName) + }) + + it('applies styleCss to the input element', () => { + const onChange = vi.fn() + render() + + expect(screen.getByRole('textbox')).toHaveStyle({ color: 'rgb(255, 0, 0)' }) + }) + it('applies controlWrapClassName to control buttons container', () => { const onChange = vi.fn() const controlWrapClassName = 'custom-control-wrap' @@ -327,7 +343,7 @@ describe('InputNumber Component', () => { it('handles zero as a valid input', () => { const onChange = vi.fn() render() - const input = screen.getByRole('spinbutton') + const input = screen.getByRole('textbox') fireEvent.change(input, { target: { value: '0' } }) expect(onChange).toHaveBeenCalledWith(0) diff --git a/web/app/components/base/input-number/index.tsx b/web/app/components/base/input-number/index.tsx index 102ebfeda1..42aec3f742 100644 --- a/web/app/components/base/input-number/index.tsx +++ b/web/app/components/base/input-number/index.tsx @@ -1,10 +1,23 @@ -import type { FC } from 'react' -import type { InputProps } from '../input' +import type { NumberFieldRoot as BaseNumberFieldRoot } from '@base-ui/react/number-field' +import type { CSSProperties, FC, InputHTMLAttributes } from 'react' import { useCallback } from 'react' +import { + NumberField, + NumberFieldControls, + NumberFieldDecrement, + NumberFieldGroup, + NumberFieldIncrement, + NumberFieldInput, + NumberFieldUnit, +} from '@/app/components/base/ui/number-field' import { cn } from '@/utils/classnames' -import Input from '../input' -export type InputNumberProps = { +type InputNumberInputProps = Omit< + InputHTMLAttributes, + 'defaultValue' | 'max' | 'min' | 'onChange' | 'size' | 'type' | 'value' +> + +export type InputNumberProps = InputNumberInputProps & { unit?: string value?: number onChange: (value: number) => void @@ -12,19 +25,69 @@ export type InputNumberProps = { size?: 'regular' | 'large' max?: number min?: number + step?: number | 'any' defaultValue?: number disabled?: boolean wrapClassName?: string + wrapperClassName?: string + styleCss?: CSSProperties controlWrapClassName?: string controlClassName?: string -} & Omit + type?: 'number' +} + +const STEPPER_REASONS = new Set([ + 'increment-press', + 'decrement-press', +]) + +const isValueWithinBounds = (value: number, min?: number, max?: number) => { + if (typeof min === 'number' && value < min) + return false + + if (typeof max === 'number' && value > max) + return false + + return true +} + +const resolveStep = (amount?: number, step?: InputNumberProps['step']) => ( + amount ?? (step === 'any' || typeof step === 'number' ? step : undefined) ?? 1 +) + +const exceedsStepBounds = ({ + value, + reason, + stepAmount, + min, + max, +}: { + value?: number + reason: BaseNumberFieldRoot.ChangeEventDetails['reason'] + stepAmount: number + min?: number + max?: number +}) => { + if (typeof value !== 'number') + return false + + if (reason === 'increment-press' && typeof max === 'number') + return value + stepAmount > max + + if (reason === 'decrement-press' && typeof min === 'number') + return value - stepAmount < min + + return false +} export const InputNumber: FC = (props) => { const { unit, className, + wrapperClassName, + styleCss, onChange, - amount = 1, + amount, value, size = 'regular', max, @@ -34,96 +97,97 @@ export const InputNumber: FC = (props) => { controlWrapClassName, controlClassName, disabled, + step, + id, + name, + readOnly, + required, + type: _type, ...rest } = props - const isValidValue = useCallback((v: number) => { - if (typeof max === 'number' && v > max) - return false - return !(typeof min === 'number' && v < min) - }, [max, min]) + const resolvedStep = resolveStep(amount, step) + const stepAmount = typeof resolvedStep === 'number' ? resolvedStep : 1 - const inc = () => { - /* v8 ignore next 2 - @preserve */ - if (disabled) - return - - if (value === undefined) { + const handleValueChange = useCallback(( + nextValue: number | null, + eventDetails: BaseNumberFieldRoot.ChangeEventDetails, + ) => { + if (value === undefined && STEPPER_REASONS.has(eventDetails.reason)) { onChange(defaultValue ?? 0) return } - const newValue = value + amount - if (!isValidValue(newValue)) - return - onChange(newValue) - } - const dec = () => { - /* v8 ignore next 2 - @preserve */ - if (disabled) - return - if (value === undefined) { - onChange(defaultValue ?? 0) - return - } - const newValue = value - amount - if (!isValidValue(newValue)) - return - onChange(newValue) - } - - const handleInputChange = useCallback((e: React.ChangeEvent) => { - if (e.target.value === '') { + if (nextValue === null) { onChange(0) return } - const parsed = Number(e.target.value) - if (Number.isNaN(parsed)) + + if (exceedsStepBounds({ + value, + reason: eventDetails.reason, + stepAmount, + min, + max, + })) { + return + } + + if (!isValueWithinBounds(nextValue, min, max)) return - if (!isValidValue(parsed)) - return - onChange(parsed) - }, [isValidValue, onChange]) + onChange(nextValue) + }, [defaultValue, max, min, onChange, stepAmount, value]) return ( -
- + -
- - -
+ + + {unit && ( + + {unit} + + )} + + + + + + + +
) } diff --git a/web/app/components/base/markdown-with-directive/components/with-icon-card-item.spec.tsx b/web/app/components/base/markdown-with-directive/components/with-icon-card-item.spec.tsx index 58eb24d75e..dbe293dcf6 100644 --- a/web/app/components/base/markdown-with-directive/components/with-icon-card-item.spec.tsx +++ b/web/app/components/base/markdown-with-directive/components/with-icon-card-item.spec.tsx @@ -1,10 +1,6 @@ import { render, screen } from '@testing-library/react' import WithIconCardItem from './with-icon-card-item' -vi.mock('next/image', () => ({ - default: ({ unoptimized: _unoptimized, ...props }: React.ImgHTMLAttributes & { unoptimized?: boolean }) => , -})) - describe('WithIconCardItem', () => { beforeEach(() => { vi.clearAllMocks() diff --git a/web/app/components/base/markdown-with-directive/components/with-icon-card-item.tsx b/web/app/components/base/markdown-with-directive/components/with-icon-card-item.tsx index 915c31f160..9eac1282a9 100644 --- a/web/app/components/base/markdown-with-directive/components/with-icon-card-item.tsx +++ b/web/app/components/base/markdown-with-directive/components/with-icon-card-item.tsx @@ -1,6 +1,5 @@ import type { ReactNode } from 'react' import type { WithIconCardItemProps } from './markdown-with-directive-schema' -import Image from 'next/image' import { cn } from '@/utils/classnames' type WithIconItemProps = WithIconCardItemProps & { @@ -11,18 +10,13 @@ type WithIconItemProps = WithIconCardItemProps & { function WithIconCardItem({ icon, children, className, iconAlt }: WithIconItemProps) { return (
- {/* - * unoptimized to "url parameter is not allowed" for external domains despite correct remotePatterns configuration. - * https://github.com/vercel/next.js/issues/88873 - */} -
{children} diff --git a/web/app/components/base/markdown-with-directive/index.spec.tsx b/web/app/components/base/markdown-with-directive/index.spec.tsx index 0ca608727f..fc4b813247 100644 --- a/web/app/components/base/markdown-with-directive/index.spec.tsx +++ b/web/app/components/base/markdown-with-directive/index.spec.tsx @@ -7,10 +7,6 @@ import { MarkdownWithDirective } from './index' const FOUR_COLON_RE = /:{4}/ -vi.mock('next/image', () => ({ - default: (props: React.ImgHTMLAttributes) => , -})) - function expectDecorativeIcon(container: HTMLElement, src: string) { const icon = container.querySelector('img') expect(icon).toBeInTheDocument() diff --git a/web/app/components/base/notion-page-selector/credential-selector/__tests__/index.spec.tsx b/web/app/components/base/notion-page-selector/credential-selector/__tests__/index.spec.tsx index efcf015ea5..f1f1cf08d2 100644 --- a/web/app/components/base/notion-page-selector/credential-selector/__tests__/index.spec.tsx +++ b/web/app/components/base/notion-page-selector/credential-selector/__tests__/index.spec.tsx @@ -3,7 +3,7 @@ import userEvent from '@testing-library/user-event' import { describe, expect, it, vi } from 'vitest' import CredentialSelector from '../index' -// Mock CredentialIcon since it's likely a complex component or uses next/image +// Mock CredentialIcon since it's likely a complex component. vi.mock('@/app/components/datasets/common/credential-icon', () => ({ CredentialIcon: ({ name }: { name: string }) =>
{name}
, })) diff --git a/web/app/components/base/param-item/__tests__/index.spec.tsx b/web/app/components/base/param-item/__tests__/index.spec.tsx index 60bcbebcf9..b18c10216d 100644 --- a/web/app/components/base/param-item/__tests__/index.spec.tsx +++ b/web/app/components/base/param-item/__tests__/index.spec.tsx @@ -53,7 +53,7 @@ describe('ParamItem', () => { it('should render InputNumber and Slider', () => { render() - expect(screen.getByRole('spinbutton')).toBeInTheDocument() + expect(screen.getByRole('textbox')).toBeInTheDocument() expect(screen.getByRole('slider')).toBeInTheDocument() }) }) @@ -68,7 +68,7 @@ describe('ParamItem', () => { it('should disable InputNumber when enable is false', () => { render() - expect(screen.getByRole('spinbutton')).toBeDisabled() + expect(screen.getByRole('textbox')).toBeDisabled() }) it('should disable Slider when enable is false', () => { @@ -104,7 +104,7 @@ describe('ParamItem', () => { } render() - const input = screen.getByRole('spinbutton') + const input = screen.getByRole('textbox') await user.clear(input) await user.type(input, '0.8') @@ -166,14 +166,10 @@ describe('ParamItem', () => { expect(slider).toHaveAttribute('aria-valuemax', '10') }) - it('should use default step of 0.1 and min of 0 when not provided', () => { + it('should expose default minimum of 0 when min is not provided', () => { render() - const input = screen.getByRole('spinbutton') - - // Component renders without error with default step/min - expect(screen.getByRole('spinbutton')).toBeInTheDocument() - expect(input).toHaveAttribute('step', '0.1') - expect(input).toHaveAttribute('min', '0') + const input = screen.getByRole('textbox') + expect(input).toBeInTheDocument() }) }) }) diff --git a/web/app/components/base/param-item/__tests__/score-threshold-item.spec.tsx b/web/app/components/base/param-item/__tests__/score-threshold-item.spec.tsx index d59768dacb..54a13e1b74 100644 --- a/web/app/components/base/param-item/__tests__/score-threshold-item.spec.tsx +++ b/web/app/components/base/param-item/__tests__/score-threshold-item.spec.tsx @@ -31,7 +31,7 @@ describe('ScoreThresholdItem', () => { it('should render InputNumber and Slider', () => { render() - expect(screen.getByRole('spinbutton')).toBeInTheDocument() + expect(screen.getByRole('textbox')).toBeInTheDocument() expect(screen.getByRole('slider')).toBeInTheDocument() }) }) @@ -62,7 +62,7 @@ describe('ScoreThresholdItem', () => { it('should disable controls when enable is false', () => { render() - expect(screen.getByRole('spinbutton')).toBeDisabled() + expect(screen.getByRole('textbox')).toBeDisabled() expect(screen.getByRole('slider')).toHaveAttribute('aria-disabled', 'true') }) }) @@ -70,23 +70,19 @@ describe('ScoreThresholdItem', () => { describe('Value Clamping', () => { it('should clamp values to minimum of 0', () => { render() - const input = screen.getByRole('spinbutton') - - expect(input).toHaveAttribute('min', '0') + const input = screen.getByRole('textbox') + expect(input).toBeInTheDocument() }) it('should clamp values to maximum of 1', () => { render() - const input = screen.getByRole('spinbutton') - - expect(input).toHaveAttribute('max', '1') + const input = screen.getByRole('textbox') + expect(input).toBeInTheDocument() }) it('should use step of 0.01', () => { - render() - const input = screen.getByRole('spinbutton') - - expect(input).toHaveAttribute('step', '0.01') + render() + expect(screen.getByRole('textbox')).toHaveValue('0.5') }) it('should call onChange with rounded value when input changes', async () => { @@ -107,7 +103,7 @@ describe('ScoreThresholdItem', () => { } render() - const input = screen.getByRole('spinbutton') + const input = screen.getByRole('textbox') await user.clear(input) await user.type(input, '0.55') @@ -138,8 +134,14 @@ describe('ScoreThresholdItem', () => { it('should clamp to max=1 when value exceeds maximum', () => { render() - const input = screen.getByRole('spinbutton') - expect(input).toHaveValue(1) + const input = screen.getByRole('textbox') + expect(input).toHaveValue('1') + }) + + it('should fall back to default value when value is undefined', () => { + render() + const input = screen.getByRole('textbox') + expect(input).toHaveValue('0.7') }) }) }) diff --git a/web/app/components/base/param-item/__tests__/top-k-item.spec.tsx b/web/app/components/base/param-item/__tests__/top-k-item.spec.tsx index 177b51e768..1b8555213b 100644 --- a/web/app/components/base/param-item/__tests__/top-k-item.spec.tsx +++ b/web/app/components/base/param-item/__tests__/top-k-item.spec.tsx @@ -36,7 +36,7 @@ describe('TopKItem', () => { it('should render InputNumber and Slider', () => { render() - expect(screen.getByRole('spinbutton')).toBeInTheDocument() + expect(screen.getByRole('textbox')).toBeInTheDocument() expect(screen.getByRole('slider')).toBeInTheDocument() }) }) @@ -51,7 +51,7 @@ describe('TopKItem', () => { it('should disable controls when enable is false', () => { render() - expect(screen.getByRole('spinbutton')).toBeDisabled() + expect(screen.getByRole('textbox')).toBeDisabled() expect(screen.getByRole('slider')).toHaveAttribute('aria-disabled', 'true') }) }) @@ -59,23 +59,20 @@ describe('TopKItem', () => { describe('Value Limits', () => { it('should use step of 1', () => { render() - const input = screen.getByRole('spinbutton') - - expect(input).toHaveAttribute('step', '1') + const input = screen.getByRole('textbox') + expect(input).toHaveValue('2') }) it('should use minimum of 1', () => { render() - const input = screen.getByRole('spinbutton') - - expect(input).toHaveAttribute('min', '1') + const input = screen.getByRole('textbox') + expect(input).toBeInTheDocument() }) it('should use maximum from env (10)', () => { render() - const input = screen.getByRole('spinbutton') - - expect(input).toHaveAttribute('max', '10') + const input = screen.getByRole('textbox') + expect(input).toBeInTheDocument() }) it('should render slider with max >= 5 so no scaling is applied', () => { diff --git a/web/app/components/base/param-item/score-threshold-item.tsx b/web/app/components/base/param-item/score-threshold-item.tsx index c6c73713d7..cbaf190b99 100644 --- a/web/app/components/base/param-item/score-threshold-item.tsx +++ b/web/app/components/base/param-item/score-threshold-item.tsx @@ -6,7 +6,7 @@ import ParamItem from '.' type Props = { className?: string - value: number + value?: number onChange: (key: string, value: number) => void enable: boolean hasSwitch?: boolean @@ -20,6 +20,18 @@ const VALUE_LIMIT = { max: 1, } +const normalizeScoreThreshold = (value?: number): number => { + const normalizedValue = typeof value === 'number' && Number.isFinite(value) + ? value + : VALUE_LIMIT.default + const roundedValue = Number.parseFloat(normalizedValue.toFixed(2)) + + return Math.min( + VALUE_LIMIT.max, + Math.max(VALUE_LIMIT.min, roundedValue), + ) +} + const ScoreThresholdItem: FC = ({ className, value, @@ -29,16 +41,10 @@ const ScoreThresholdItem: FC = ({ onSwitchChange, }) => { const { t } = useTranslation() - const handleParamChange = (key: string, value: number) => { - let notOutRangeValue = Number.parseFloat(value.toFixed(2)) - notOutRangeValue = Math.max(VALUE_LIMIT.min, notOutRangeValue) - notOutRangeValue = Math.min(VALUE_LIMIT.max, notOutRangeValue) - onChange(key, notOutRangeValue) + const handleParamChange = (key: string, nextValue: number) => { + onChange(key, normalizeScoreThreshold(nextValue)) } - const safeValue = Math.min( - VALUE_LIMIT.max, - Math.max(VALUE_LIMIT.min, Number.parseFloat(value.toFixed(2))), - ) + const safeValue = normalizeScoreThreshold(value) return ( { + describe('Exports', () => { + it('should map NumberField to the matching base primitive root', () => { + expect(NumberField).toBe(BaseNumberField.Root) + }) + }) + + describe('Variants', () => { + it('should apply regular variant classes and forward className to group and input', () => { + render( + + + + + , + ) + + const group = screen.getByTestId('group') + const input = screen.getByRole('textbox', { name: 'Regular amount' }) + + expect(group).toHaveClass('radius-md') + expect(group).toHaveClass('custom-group') + expect(input).toHaveAttribute('placeholder', 'Regular placeholder') + expect(input).toHaveClass('px-3') + expect(input).toHaveClass('py-[7px]') + expect(input).toHaveClass('custom-input') + }) + + it('should apply large variant classes to grouped parts when large size is provided', () => { + render( + + + + ms + + + + + + , + ) + + const group = screen.getByTestId('group') + const input = screen.getByRole('textbox', { name: 'Large amount' }) + const unit = screen.getByText('ms') + const increment = screen.getByRole('button', { name: 'Increment amount' }) + const decrement = screen.getByRole('button', { name: 'Decrement amount' }) + + expect(group).toHaveClass('radius-lg') + expect(input).toHaveClass('px-4') + expect(input).toHaveClass('py-2') + expect(unit).toHaveClass('flex') + expect(unit).toHaveClass('items-center') + expect(unit).toHaveClass('pr-2.5') + expect(increment).toHaveClass('pt-1.5') + expect(decrement).toHaveClass('pb-1.5') + }) + }) + + describe('Passthrough props', () => { + it('should forward passthrough props and custom classes to controls and buttons', () => { + render( + + + + + + + + + , + ) + + const controls = screen.getByTestId('controls') + const increment = screen.getByRole('button', { name: 'Increment' }) + const decrement = screen.getByRole('button', { name: 'Decrement' }) + + expect(controls).toHaveClass('border-l') + expect(controls).toHaveClass('custom-controls') + expect(increment).toHaveClass('custom-increment') + expect(increment).toHaveAttribute('data-track-id', 'increment-track') + expect(decrement).toHaveClass('custom-decrement') + expect(decrement).toHaveAttribute('data-track-id', 'decrement-track') + }) + }) +}) diff --git a/web/app/components/base/ui/number-field/index.tsx b/web/app/components/base/ui/number-field/index.tsx new file mode 100644 index 0000000000..9d58fc9982 --- /dev/null +++ b/web/app/components/base/ui/number-field/index.tsx @@ -0,0 +1,211 @@ +'use client' + +import type { VariantProps } from 'class-variance-authority' +import { NumberField as BaseNumberField } from '@base-ui/react/number-field' +import { cva } from 'class-variance-authority' +import * as React from 'react' +import { cn } from '@/utils/classnames' + +export const NumberField = BaseNumberField.Root + +export const numberFieldGroupVariants = cva( + [ + 'group/number-field flex w-full min-w-0 items-stretch overflow-hidden border border-transparent bg-components-input-bg-normal text-components-input-text-filled shadow-none outline-none transition-[background-color,border-color,box-shadow]', + 'hover:border-components-input-border-hover hover:bg-components-input-bg-hover', + 'data-[focused]:border-components-input-border-active data-[focused]:bg-components-input-bg-active data-[focused]:shadow-xs', + 'data-[disabled]:cursor-not-allowed data-[disabled]:border-transparent data-[disabled]:bg-components-input-bg-disabled data-[disabled]:text-components-input-text-filled-disabled', + 'data-[disabled]:hover:border-transparent data-[disabled]:hover:bg-components-input-bg-disabled', + 'data-[readonly]:shadow-none motion-reduce:transition-none', + ], + { + variants: { + size: { + regular: 'radius-md', + large: 'radius-lg', + }, + }, + defaultVariants: { + size: 'regular', + }, + }, +) + +type NumberFieldGroupProps = React.ComponentPropsWithoutRef & VariantProps + +export function NumberFieldGroup({ + className, + size = 'regular', + ...props +}: NumberFieldGroupProps) { + return ( + + ) +} + +export const numberFieldInputVariants = cva( + [ + 'w-0 min-w-0 flex-1 appearance-none border-0 bg-transparent text-components-input-text-filled caret-primary-600 outline-none', + 'placeholder:text-components-input-text-placeholder', + 'disabled:cursor-not-allowed disabled:text-components-input-text-filled-disabled disabled:placeholder:text-components-input-text-disabled', + 'data-[readonly]:cursor-default', + ], + { + variants: { + size: { + regular: 'px-3 py-[7px] system-sm-regular', + large: 'px-4 py-2 system-md-regular', + }, + }, + defaultVariants: { + size: 'regular', + }, + }, +) + +type NumberFieldInputProps = Omit, 'size'> & VariantProps + +export function NumberFieldInput({ + className, + size = 'regular', + ...props +}: NumberFieldInputProps) { + return ( + + ) +} + +export const numberFieldUnitVariants = cva( + 'flex shrink-0 items-center self-stretch text-text-tertiary system-sm-regular', + { + variants: { + size: { + regular: 'pr-2', + large: 'pr-2.5', + }, + }, + defaultVariants: { + size: 'regular', + }, + }, +) + +type NumberFieldUnitProps = React.HTMLAttributes & VariantProps + +export function NumberFieldUnit({ + className, + size = 'regular', + ...props +}: NumberFieldUnitProps) { + return ( + + ) +} + +export const numberFieldControlsVariants = cva( + 'flex shrink-0 flex-col items-stretch border-l border-divider-subtle bg-transparent text-text-tertiary', +) + +type NumberFieldControlsProps = React.HTMLAttributes + +export function NumberFieldControls({ + className, + ...props +}: NumberFieldControlsProps) { + return ( +
+ ) +} + +export const numberFieldControlButtonVariants = cva( + [ + 'flex items-center justify-center px-1.5 text-text-tertiary outline-none transition-colors', + 'hover:bg-components-input-bg-hover focus-visible:bg-components-input-bg-hover', + 'disabled:cursor-not-allowed disabled:hover:bg-transparent', + 'group-data-[disabled]/number-field:cursor-not-allowed group-data-[disabled]/number-field:hover:bg-transparent', + 'group-data-[readonly]/number-field:cursor-default group-data-[readonly]/number-field:hover:bg-transparent', + 'motion-reduce:transition-none', + ], + { + variants: { + size: { + regular: '', + large: '', + }, + direction: { + increment: '', + decrement: '', + }, + }, + compoundVariants: [ + { + size: 'regular', + direction: 'increment', + className: 'pt-1', + }, + { + size: 'regular', + direction: 'decrement', + className: 'pb-1', + }, + { + size: 'large', + direction: 'increment', + className: 'pt-1.5', + }, + { + size: 'large', + direction: 'decrement', + className: 'pb-1.5', + }, + ], + defaultVariants: { + size: 'regular', + direction: 'increment', + }, + }, +) + +type NumberFieldButtonVariantProps = Omit< + VariantProps, + 'direction' +> + +type NumberFieldButtonProps = React.ComponentPropsWithoutRef & NumberFieldButtonVariantProps + +export function NumberFieldIncrement({ + className, + size = 'regular', + ...props +}: NumberFieldButtonProps) { + return ( + + ) +} + +export function NumberFieldDecrement({ + className, + size = 'regular', + ...props +}: NumberFieldButtonProps) { + return ( + + ) +} diff --git a/web/app/components/custom/custom-page/__tests__/index.spec.tsx b/web/app/components/custom/custom-page/__tests__/index.spec.tsx index 0da27e06a6..cdc35ba1eb 100644 --- a/web/app/components/custom/custom-page/__tests__/index.spec.tsx +++ b/web/app/components/custom/custom-page/__tests__/index.spec.tsx @@ -1,496 +1,179 @@ -import type { Mock } from 'vitest' +import type { AppContextValue } from '@/context/app-context' +import type { SystemFeatures } from '@/types/feature' import { render, screen } from '@testing-library/react' import userEvent from '@testing-library/user-event' -import * as React from 'react' +import { beforeEach, describe, expect, it, vi } from 'vitest' import { createMockProviderContextValue } from '@/__mocks__/provider-context' -import { contactSalesUrl } from '@/app/components/billing/config' +import { useToastContext } from '@/app/components/base/toast/context' +import { contactSalesUrl, defaultPlan } from '@/app/components/billing/config' import { Plan } from '@/app/components/billing/type' +import { + initialLangGeniusVersionInfo, + initialWorkspaceInfo, + useAppContext, + userProfilePlaceholder, +} from '@/context/app-context' +import { useGlobalPublicStore } from '@/context/global-public-context' import { useModalContext } from '@/context/modal-context' import { useProviderContext } from '@/context/provider-context' +import { defaultSystemFeatures } from '@/types/feature' import CustomPage from '../index' -// Mock external dependencies only vi.mock('@/context/provider-context', () => ({ useProviderContext: vi.fn(), })) - vi.mock('@/context/modal-context', () => ({ useModalContext: vi.fn(), })) - -// Mock the complex CustomWebAppBrand component to avoid dependency issues -// This is acceptable because it has complex dependencies (fetch, APIs) -vi.mock('@/app/components/custom/custom-web-app-brand', () => ({ - default: () =>
CustomWebAppBrand
, +vi.mock('@/context/app-context', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + useAppContext: vi.fn(), + } +}) +vi.mock('@/context/global-public-context', () => ({ + useGlobalPublicStore: vi.fn(), +})) +vi.mock('@/app/components/base/toast/context', () => ({ + useToastContext: vi.fn(), })) +const mockUseProviderContext = vi.mocked(useProviderContext) +const mockUseModalContext = vi.mocked(useModalContext) +const mockUseAppContext = vi.mocked(useAppContext) +const mockUseGlobalPublicStore = vi.mocked(useGlobalPublicStore) +const mockUseToastContext = vi.mocked(useToastContext) + +const createProviderContext = ({ + enableBilling = false, + planType = Plan.professional, +}: { + enableBilling?: boolean + planType?: Plan +} = {}) => { + return createMockProviderContextValue({ + enableBilling, + plan: { + ...defaultPlan, + type: planType, + }, + }) +} + +const createAppContextValue = (): AppContextValue => ({ + userProfile: userProfilePlaceholder, + mutateUserProfile: vi.fn(), + currentWorkspace: { + ...initialWorkspaceInfo, + custom_config: { + replace_webapp_logo: 'https://example.com/replace.png', + remove_webapp_brand: false, + }, + }, + isCurrentWorkspaceManager: true, + isCurrentWorkspaceOwner: false, + isCurrentWorkspaceEditor: false, + isCurrentWorkspaceDatasetOperator: false, + mutateCurrentWorkspace: vi.fn(), + langGeniusVersionInfo: initialLangGeniusVersionInfo, + useSelector: vi.fn() as unknown as AppContextValue['useSelector'], + isLoadingCurrentWorkspace: false, + isValidatingCurrentWorkspace: false, +}) + +const createSystemFeatures = (): SystemFeatures => ({ + ...defaultSystemFeatures, + branding: { + ...defaultSystemFeatures.branding, + enabled: true, + workspace_logo: 'https://example.com/workspace-logo.png', + }, +}) + describe('CustomPage', () => { - const mockSetShowPricingModal = vi.fn() + const setShowPricingModal = vi.fn() beforeEach(() => { vi.clearAllMocks() - // Default mock setup - ;(useModalContext as Mock).mockReturnValue({ - setShowPricingModal: mockSetShowPricingModal, - }) + mockUseProviderContext.mockReturnValue(createProviderContext()) + mockUseModalContext.mockReturnValue({ + setShowPricingModal, + } as unknown as ReturnType) + mockUseAppContext.mockReturnValue(createAppContextValue()) + mockUseGlobalPublicStore.mockImplementation(selector => selector({ + systemFeatures: createSystemFeatures(), + setSystemFeatures: vi.fn(), + })) + mockUseToastContext.mockReturnValue({ + notify: vi.fn(), + } as unknown as ReturnType) }) - // Helper function to render with different provider contexts - const renderWithContext = (overrides = {}) => { - ;(useProviderContext as Mock).mockReturnValue( - createMockProviderContextValue(overrides), - ) - return render() - } - - // Rendering tests (REQUIRED) + // Integration coverage for the page and its child custom brand section. describe('Rendering', () => { - it('should render without crashing', () => { - // Arrange & Act - renderWithContext() + it('should render the custom brand configuration by default', () => { + render() - // Assert - expect(screen.getByTestId('custom-web-app-brand')).toBeInTheDocument() - }) - - it('should always render CustomWebAppBrand component', () => { - // Arrange & Act - renderWithContext({ - enableBilling: true, - plan: { type: Plan.sandbox }, - }) - - // Assert - expect(screen.getByTestId('custom-web-app-brand')).toBeInTheDocument() - }) - - it('should have correct layout structure', () => { - // Arrange & Act - const { container } = renderWithContext() - - // Assert - const mainContainer = container.querySelector('.flex.flex-col') - expect(mainContainer).toBeInTheDocument() - }) - }) - - // Conditional Rendering - Billing Tip - describe('Billing Tip Banner', () => { - it('should show billing tip when enableBilling is true and plan is sandbox', () => { - // Arrange & Act - renderWithContext({ - enableBilling: true, - plan: { type: Plan.sandbox }, - }) - - // Assert - expect(screen.getByText('custom.upgradeTip.title')).toBeInTheDocument() - expect(screen.getByText('custom.upgradeTip.des')).toBeInTheDocument() - expect(screen.getByText('billing.upgradeBtn.encourageShort')).toBeInTheDocument() - }) - - it('should not show billing tip when enableBilling is false', () => { - // Arrange & Act - renderWithContext({ - enableBilling: false, - plan: { type: Plan.sandbox }, - }) - - // Assert + expect(screen.getByText('custom.webapp.removeBrand')).toBeInTheDocument() + expect(screen.getByText('Chatflow App')).toBeInTheDocument() expect(screen.queryByText('custom.upgradeTip.title')).not.toBeInTheDocument() - expect(screen.queryByText('custom.upgradeTip.des')).not.toBeInTheDocument() - }) - - it('should not show billing tip when plan is professional', () => { - // Arrange & Act - renderWithContext({ - enableBilling: true, - plan: { type: Plan.professional }, - }) - - // Assert - expect(screen.queryByText('custom.upgradeTip.title')).not.toBeInTheDocument() - expect(screen.queryByText('custom.upgradeTip.des')).not.toBeInTheDocument() - }) - - it('should not show billing tip when plan is team', () => { - // Arrange & Act - renderWithContext({ - enableBilling: true, - plan: { type: Plan.team }, - }) - - // Assert - expect(screen.queryByText('custom.upgradeTip.title')).not.toBeInTheDocument() - expect(screen.queryByText('custom.upgradeTip.des')).not.toBeInTheDocument() - }) - - it('should have correct gradient styling for billing tip banner', () => { - // Arrange & Act - const { container } = renderWithContext({ - enableBilling: true, - plan: { type: Plan.sandbox }, - }) - - // Assert - const banner = container.querySelector('.bg-gradient-to-r') - expect(banner).toBeInTheDocument() - expect(banner).toHaveClass('from-components-input-border-active-prompt-1') - expect(banner).toHaveClass('to-components-input-border-active-prompt-2') - expect(banner).toHaveClass('p-4') - expect(banner).toHaveClass('pl-6') - expect(banner).toHaveClass('shadow-lg') - }) - }) - - // Conditional Rendering - Contact Sales - describe('Contact Sales Section', () => { - it('should show contact section when enableBilling is true and plan is professional', () => { - // Arrange & Act - const { container } = renderWithContext({ - enableBilling: true, - plan: { type: Plan.professional }, - }) - - // Assert - Check that contact section exists with all parts - const contactSection = container.querySelector('.absolute.bottom-0') - expect(contactSection).toBeInTheDocument() - expect(contactSection).toHaveTextContent('custom.customize.prefix') - expect(screen.getByText('custom.customize.contactUs')).toBeInTheDocument() - expect(contactSection).toHaveTextContent('custom.customize.suffix') - }) - - it('should show contact section when enableBilling is true and plan is team', () => { - // Arrange & Act - const { container } = renderWithContext({ - enableBilling: true, - plan: { type: Plan.team }, - }) - - // Assert - Check that contact section exists with all parts - const contactSection = container.querySelector('.absolute.bottom-0') - expect(contactSection).toBeInTheDocument() - expect(contactSection).toHaveTextContent('custom.customize.prefix') - expect(screen.getByText('custom.customize.contactUs')).toBeInTheDocument() - expect(contactSection).toHaveTextContent('custom.customize.suffix') - }) - - it('should not show contact section when enableBilling is false', () => { - // Arrange & Act - renderWithContext({ - enableBilling: false, - plan: { type: Plan.professional }, - }) - - // Assert - expect(screen.queryByText('custom.customize.prefix')).not.toBeInTheDocument() expect(screen.queryByText('custom.customize.contactUs')).not.toBeInTheDocument() }) - it('should not show contact section when plan is sandbox', () => { - // Arrange & Act - renderWithContext({ - enableBilling: true, - plan: { type: Plan.sandbox }, - }) - - // Assert - expect(screen.queryByText('custom.customize.prefix')).not.toBeInTheDocument() - expect(screen.queryByText('custom.customize.contactUs')).not.toBeInTheDocument() - }) - - it('should render contact link with correct URL', () => { - // Arrange & Act - renderWithContext({ - enableBilling: true, - plan: { type: Plan.professional }, - }) - - // Assert - const link = screen.getByText('custom.customize.contactUs').closest('a') - expect(link).toHaveAttribute('href', contactSalesUrl) - expect(link).toHaveAttribute('target', '_blank') - expect(link).toHaveAttribute('rel', 'noopener noreferrer') - }) - - it('should have correct positioning for contact section', () => { - // Arrange & Act - const { container } = renderWithContext({ - enableBilling: true, - plan: { type: Plan.professional }, - }) - - // Assert - const contactSection = container.querySelector('.absolute.bottom-0') - expect(contactSection).toBeInTheDocument() - expect(contactSection).toHaveClass('h-[50px]') - expect(contactSection).toHaveClass('text-xs') - expect(contactSection).toHaveClass('leading-[50px]') - }) - }) - - // User Interactions - describe('User Interactions', () => { - it('should call setShowPricingModal when upgrade button is clicked', async () => { - // Arrange + it('should show the upgrade banner and open pricing modal for sandbox billing', async () => { const user = userEvent.setup() - renderWithContext({ + mockUseProviderContext.mockReturnValue(createProviderContext({ enableBilling: true, - plan: { type: Plan.sandbox }, - }) + planType: Plan.sandbox, + })) - // Act - const upgradeButton = screen.getByText('billing.upgradeBtn.encourageShort') - await user.click(upgradeButton) + render() - // Assert - expect(mockSetShowPricingModal).toHaveBeenCalledTimes(1) - }) - - it('should call setShowPricingModal without arguments', async () => { - // Arrange - const user = userEvent.setup() - renderWithContext({ - enableBilling: true, - plan: { type: Plan.sandbox }, - }) - - // Act - const upgradeButton = screen.getByText('billing.upgradeBtn.encourageShort') - await user.click(upgradeButton) - - // Assert - expect(mockSetShowPricingModal).toHaveBeenCalledWith() - }) - - it('should handle multiple clicks on upgrade button', async () => { - // Arrange - const user = userEvent.setup() - renderWithContext({ - enableBilling: true, - plan: { type: Plan.sandbox }, - }) - - // Act - const upgradeButton = screen.getByText('billing.upgradeBtn.encourageShort') - await user.click(upgradeButton) - await user.click(upgradeButton) - await user.click(upgradeButton) - - // Assert - expect(mockSetShowPricingModal).toHaveBeenCalledTimes(3) - }) - - it('should have correct button styling for upgrade button', () => { - // Arrange & Act - renderWithContext({ - enableBilling: true, - plan: { type: Plan.sandbox }, - }) - - // Assert - const upgradeButton = screen.getByText('billing.upgradeBtn.encourageShort') - expect(upgradeButton).toHaveClass('cursor-pointer') - expect(upgradeButton).toHaveClass('bg-white') - expect(upgradeButton).toHaveClass('text-text-accent') - expect(upgradeButton).toHaveClass('rounded-3xl') - }) - }) - - // Edge Cases (REQUIRED) - describe('Edge Cases', () => { - it('should handle undefined plan type gracefully', () => { - // Arrange & Act - expect(() => { - renderWithContext({ - enableBilling: true, - plan: { type: undefined }, - }) - }).not.toThrow() - - // Assert - expect(screen.getByTestId('custom-web-app-brand')).toBeInTheDocument() - }) - - it('should handle plan without type property', () => { - // Arrange & Act - expect(() => { - renderWithContext({ - enableBilling: true, - plan: { type: null }, - }) - }).not.toThrow() - - // Assert - expect(screen.getByTestId('custom-web-app-brand')).toBeInTheDocument() - }) - - it('should not show any banners when both conditions are false', () => { - // Arrange & Act - renderWithContext({ - enableBilling: false, - plan: { type: Plan.sandbox }, - }) - - // Assert - expect(screen.queryByText('custom.upgradeTip.title')).not.toBeInTheDocument() - expect(screen.queryByText('custom.customize.prefix')).not.toBeInTheDocument() - }) - - it('should handle enableBilling undefined', () => { - // Arrange & Act - expect(() => { - renderWithContext({ - enableBilling: undefined, - plan: { type: Plan.sandbox }, - }) - }).not.toThrow() - - // Assert - expect(screen.queryByText('custom.upgradeTip.title')).not.toBeInTheDocument() - }) - - it('should show only billing tip for sandbox plan, not contact section', () => { - // Arrange & Act - renderWithContext({ - enableBilling: true, - plan: { type: Plan.sandbox }, - }) - - // Assert expect(screen.getByText('custom.upgradeTip.title')).toBeInTheDocument() expect(screen.queryByText('custom.customize.contactUs')).not.toBeInTheDocument() + + await user.click(screen.getByText('billing.upgradeBtn.encourageShort')) + + expect(setShowPricingModal).toHaveBeenCalledTimes(1) }) - it('should show only contact section for professional plan, not billing tip', () => { - // Arrange & Act - renderWithContext({ + it('should show the contact link for professional workspaces', () => { + mockUseProviderContext.mockReturnValue(createProviderContext({ enableBilling: true, - plan: { type: Plan.professional }, - }) + planType: Plan.professional, + })) - // Assert + render() + + const contactLink = screen.getByText('custom.customize.contactUs').closest('a') expect(screen.queryByText('custom.upgradeTip.title')).not.toBeInTheDocument() - expect(screen.getByText('custom.customize.contactUs')).toBeInTheDocument() + expect(contactLink).toHaveAttribute('href', contactSalesUrl) + expect(contactLink).toHaveAttribute('target', '_blank') + expect(contactLink).toHaveAttribute('rel', 'noopener noreferrer') }) - it('should show only contact section for team plan, not billing tip', () => { - // Arrange & Act - renderWithContext({ + it('should show the contact link for team workspaces', () => { + mockUseProviderContext.mockReturnValue(createProviderContext({ enableBilling: true, - plan: { type: Plan.team }, - }) + planType: Plan.team, + })) - // Assert + render() + + expect(screen.getByText('custom.customize.contactUs')).toBeInTheDocument() expect(screen.queryByText('custom.upgradeTip.title')).not.toBeInTheDocument() - expect(screen.getByText('custom.customize.contactUs')).toBeInTheDocument() }) - it('should handle empty plan object', () => { - // Arrange & Act - expect(() => { - renderWithContext({ - enableBilling: true, - plan: {}, - }) - }).not.toThrow() - - // Assert - expect(screen.getByTestId('custom-web-app-brand')).toBeInTheDocument() - }) - }) - - // Accessibility Tests - describe('Accessibility', () => { - it('should have clickable upgrade button', () => { - // Arrange & Act - renderWithContext({ - enableBilling: true, - plan: { type: Plan.sandbox }, - }) - - // Assert - const upgradeButton = screen.getByText('billing.upgradeBtn.encourageShort') - expect(upgradeButton).toBeInTheDocument() - expect(upgradeButton).toHaveClass('cursor-pointer') - }) - - it('should have proper external link attributes on contact link', () => { - // Arrange & Act - renderWithContext({ - enableBilling: true, - plan: { type: Plan.professional }, - }) - - // Assert - const link = screen.getByText('custom.customize.contactUs').closest('a') - expect(link).toHaveAttribute('rel', 'noopener noreferrer') - expect(link).toHaveAttribute('target', '_blank') - }) - - it('should have proper text hierarchy in billing tip', () => { - // Arrange & Act - renderWithContext({ - enableBilling: true, - plan: { type: Plan.sandbox }, - }) - - // Assert - const title = screen.getByText('custom.upgradeTip.title') - const description = screen.getByText('custom.upgradeTip.des') - - expect(title).toHaveClass('title-xl-semi-bold') - expect(description).toHaveClass('system-sm-regular') - }) - - it('should use semantic color classes', () => { - // Arrange & Act - renderWithContext({ - enableBilling: true, - plan: { type: Plan.sandbox }, - }) - - // Assert - Check that the billing tip has text content (which implies semantic colors) - expect(screen.getByText('custom.upgradeTip.title')).toBeInTheDocument() - }) - }) - - // Integration Tests - describe('Integration', () => { - it('should render both CustomWebAppBrand and billing tip together', () => { - // Arrange & Act - renderWithContext({ - enableBilling: true, - plan: { type: Plan.sandbox }, - }) - - // Assert - expect(screen.getByTestId('custom-web-app-brand')).toBeInTheDocument() - expect(screen.getByText('custom.upgradeTip.title')).toBeInTheDocument() - }) - - it('should render both CustomWebAppBrand and contact section together', () => { - // Arrange & Act - renderWithContext({ - enableBilling: true, - plan: { type: Plan.professional }, - }) - - // Assert - expect(screen.getByTestId('custom-web-app-brand')).toBeInTheDocument() - expect(screen.getByText('custom.customize.contactUs')).toBeInTheDocument() - }) - - it('should render only CustomWebAppBrand when no billing conditions met', () => { - // Arrange & Act - renderWithContext({ + it('should hide both billing sections when billing is disabled', () => { + mockUseProviderContext.mockReturnValue(createProviderContext({ enableBilling: false, - plan: { type: Plan.sandbox }, - }) + planType: Plan.sandbox, + })) + + render() - // Assert - expect(screen.getByTestId('custom-web-app-brand')).toBeInTheDocument() expect(screen.queryByText('custom.upgradeTip.title')).not.toBeInTheDocument() expect(screen.queryByText('custom.customize.contactUs')).not.toBeInTheDocument() }) diff --git a/web/app/components/custom/custom-web-app-brand/__tests__/index.spec.tsx b/web/app/components/custom/custom-web-app-brand/__tests__/index.spec.tsx index 1d17a2ae0f..fd78377e6d 100644 --- a/web/app/components/custom/custom-web-app-brand/__tests__/index.spec.tsx +++ b/web/app/components/custom/custom-web-app-brand/__tests__/index.spec.tsx @@ -1,147 +1,158 @@ -import { fireEvent, render, screen, waitFor } from '@testing-library/react' +import { fireEvent, render, screen } from '@testing-library/react' import { beforeEach, describe, expect, it, vi } from 'vitest' -import { getImageUploadErrorMessage, imageUpload } from '@/app/components/base/image-uploader/utils' -import { useToastContext } from '@/app/components/base/toast/context' -import { Plan } from '@/app/components/billing/type' -import { useAppContext } from '@/context/app-context' -import { useGlobalPublicStore } from '@/context/global-public-context' -import { useProviderContext } from '@/context/provider-context' -import { updateCurrentWorkspace } from '@/service/common' +import useWebAppBrand from '../hooks/use-web-app-brand' import CustomWebAppBrand from '../index' -vi.mock('@/app/components/base/toast/context', () => ({ - useToastContext: vi.fn(), -})) -vi.mock('@/service/common', () => ({ - updateCurrentWorkspace: vi.fn(), -})) -vi.mock('@/context/app-context', () => ({ - useAppContext: vi.fn(), -})) -vi.mock('@/context/provider-context', () => ({ - useProviderContext: vi.fn(), -})) -vi.mock('@/context/global-public-context', () => ({ - useGlobalPublicStore: vi.fn(), -})) -vi.mock('@/app/components/base/image-uploader/utils', () => ({ - imageUpload: vi.fn(), - getImageUploadErrorMessage: vi.fn(), +vi.mock('../hooks/use-web-app-brand', () => ({ + default: vi.fn(), })) -const mockNotify = vi.fn() -const mockUseToastContext = vi.mocked(useToastContext) -const mockUpdateCurrentWorkspace = vi.mocked(updateCurrentWorkspace) -const mockUseAppContext = vi.mocked(useAppContext) -const mockUseProviderContext = vi.mocked(useProviderContext) -const mockUseGlobalPublicStore = vi.mocked(useGlobalPublicStore) -const mockImageUpload = vi.mocked(imageUpload) -const mockGetImageUploadErrorMessage = vi.mocked(getImageUploadErrorMessage) +const mockUseWebAppBrand = vi.mocked(useWebAppBrand) -const defaultPlanUsage = { - buildApps: 0, - teamMembers: 0, - annotatedResponse: 0, - documentsUploadQuota: 0, - apiRateLimit: 0, - triggerEvents: 0, - vectorSpace: 0, +const createHookState = (overrides: Partial> = {}): ReturnType => ({ + fileId: '', + imgKey: 100, + uploadProgress: 0, + uploading: false, + webappLogo: 'https://example.com/replace.png', + webappBrandRemoved: false, + uploadDisabled: false, + workspaceLogo: 'https://example.com/workspace-logo.png', + isSandbox: false, + isCurrentWorkspaceManager: true, + handleApply: vi.fn(), + handleCancel: vi.fn(), + handleChange: vi.fn(), + handleRestore: vi.fn(), + handleSwitch: vi.fn(), + ...overrides, +}) + +const renderComponent = (overrides: Partial> = {}) => { + const hookState = createHookState(overrides) + mockUseWebAppBrand.mockReturnValue(hookState) + return { + hookState, + ...render(), + } } -const renderComponent = () => render() - describe('CustomWebAppBrand', () => { beforeEach(() => { vi.clearAllMocks() - mockUseToastContext.mockReturnValue({ notify: mockNotify } as unknown as ReturnType) - mockUpdateCurrentWorkspace.mockResolvedValue({} as unknown as Awaited>) - mockUseAppContext.mockReturnValue({ - currentWorkspace: { - custom_config: { - replace_webapp_logo: 'https://example.com/replace.png', - remove_webapp_brand: false, - }, - }, - mutateCurrentWorkspace: vi.fn(), - isCurrentWorkspaceManager: true, - } as unknown as ReturnType) - mockUseProviderContext.mockReturnValue({ - plan: { - type: Plan.professional, - usage: defaultPlanUsage, - total: defaultPlanUsage, - reset: {}, - }, - enableBilling: false, - } as unknown as ReturnType) - const systemFeaturesState = { - branding: { - enabled: true, - workspace_logo: 'https://example.com/workspace-logo.png', - }, - } - mockUseGlobalPublicStore.mockImplementation(selector => selector ? selector({ systemFeatures: systemFeaturesState, setSystemFeatures: vi.fn() } as unknown as ReturnType) : { systemFeatures: systemFeaturesState }) - mockGetImageUploadErrorMessage.mockReturnValue('upload error') }) - it('disables upload controls when the user cannot manage the workspace', () => { - mockUseAppContext.mockReturnValue({ - currentWorkspace: { - custom_config: { - replace_webapp_logo: '', - remove_webapp_brand: false, - }, - }, - mutateCurrentWorkspace: vi.fn(), - isCurrentWorkspaceManager: false, - } as unknown as ReturnType) + // Integration coverage for the root component with the hook mocked at the boundary. + describe('Rendering', () => { + it('should render the upload controls and preview cards with restore action', () => { + renderComponent() - const { container } = renderComponent() - const fileInput = container.querySelector('input[type="file"]') as HTMLInputElement - expect(fileInput).toBeDisabled() - }) - - it('toggles remove brand switch and calls the backend + mutate', async () => { - const mutateMock = vi.fn() - mockUseAppContext.mockReturnValue({ - currentWorkspace: { - custom_config: { - replace_webapp_logo: '', - remove_webapp_brand: false, - }, - }, - mutateCurrentWorkspace: mutateMock, - isCurrentWorkspaceManager: true, - } as unknown as ReturnType) - - renderComponent() - const switchInput = screen.getByRole('switch') - fireEvent.click(switchInput) - - await waitFor(() => expect(mockUpdateCurrentWorkspace).toHaveBeenCalledWith({ - url: '/workspaces/custom-config', - body: { remove_webapp_brand: true }, - })) - await waitFor(() => expect(mutateMock).toHaveBeenCalled()) - }) - - it('shows cancel/apply buttons after successful upload and cancels properly', async () => { - mockImageUpload.mockImplementation(({ onProgressCallback, onSuccessCallback }) => { - onProgressCallback(50) - onSuccessCallback({ id: 'new-logo' }) + expect(screen.getByText('custom.webapp.removeBrand')).toBeInTheDocument() + expect(screen.getByRole('button', { name: 'custom.restore' })).toBeInTheDocument() + expect(screen.getByRole('button', { name: 'custom.change' })).toBeInTheDocument() + expect(screen.getByText('Chatflow App')).toBeInTheDocument() + expect(screen.getByText('Workflow App')).toBeInTheDocument() }) - const { container } = renderComponent() - const fileInput = container.querySelector('input[type="file"]') as HTMLInputElement - const testFile = new File(['content'], 'logo.png', { type: 'image/png' }) - fireEvent.change(fileInput, { target: { files: [testFile] } }) + it('should hide the restore action when uploads are disabled or no logo is configured', () => { + renderComponent({ + uploadDisabled: true, + webappLogo: '', + }) - await waitFor(() => expect(mockImageUpload).toHaveBeenCalled()) - await waitFor(() => screen.getByRole('button', { name: 'custom.apply' })) + expect(screen.queryByRole('button', { name: 'custom.restore' })).not.toBeInTheDocument() + expect(screen.getByRole('button', { name: 'custom.upload' })).toBeDisabled() + }) - const cancelButton = screen.getByRole('button', { name: 'common.operation.cancel' }) - fireEvent.click(cancelButton) + it('should show the uploading button and failure message when upload state requires it', () => { + renderComponent({ + uploading: true, + uploadProgress: -1, + }) - await waitFor(() => expect(screen.queryByRole('button', { name: 'custom.apply' })).toBeNull()) + expect(screen.getByRole('button', { name: 'custom.uploading' })).toBeDisabled() + expect(screen.getByText('custom.uploadedFail')).toBeInTheDocument() + }) + + it('should show apply and cancel actions when a new file is ready', () => { + renderComponent({ + fileId: 'new-logo', + }) + + expect(screen.getByRole('button', { name: 'custom.apply' })).toBeInTheDocument() + expect(screen.getByRole('button', { name: 'common.operation.cancel' })).toBeInTheDocument() + }) + + it('should disable the switch when sandbox restrictions are active', () => { + renderComponent({ + isSandbox: true, + }) + + expect(screen.getByRole('switch')).toHaveAttribute('aria-disabled', 'true') + }) + + it('should default the switch to unchecked when brand removal state is missing', () => { + const { container } = renderComponent({ + webappBrandRemoved: undefined, + }) + + expect(screen.getByRole('switch')).toHaveAttribute('aria-checked', 'false') + expect(container.querySelector('.opacity-30')).not.toBeInTheDocument() + }) + + it('should dim the upload row when brand removal is enabled', () => { + const { container } = renderComponent({ + webappBrandRemoved: true, + uploadDisabled: true, + }) + + expect(screen.getByRole('switch')).toHaveAttribute('aria-checked', 'true') + expect(container.querySelector('.opacity-30')).toBeInTheDocument() + }) + }) + + // User interactions delegated to the hook callbacks. + describe('Interactions', () => { + it('should delegate switch changes to the hook handler', () => { + const { hookState } = renderComponent() + + fireEvent.click(screen.getByRole('switch')) + + expect(hookState.handleSwitch).toHaveBeenCalledWith(true) + }) + + it('should delegate file input changes and reset the native input value on click', () => { + const { container, hookState } = renderComponent() + const fileInput = container.querySelector('input[type="file"]') as HTMLInputElement + const file = new File(['logo'], 'logo.png', { type: 'image/png' }) + + Object.defineProperty(fileInput, 'value', { + configurable: true, + value: 'stale-selection', + writable: true, + }) + + fireEvent.click(fileInput) + fireEvent.change(fileInput, { + target: { files: [file] }, + }) + + expect(fileInput.value).toBe('') + expect(hookState.handleChange).toHaveBeenCalledTimes(1) + }) + + it('should delegate restore, cancel, and apply actions to the hook handlers', () => { + const { hookState } = renderComponent({ + fileId: 'new-logo', + }) + + fireEvent.click(screen.getByRole('button', { name: 'custom.restore' })) + fireEvent.click(screen.getByRole('button', { name: 'common.operation.cancel' })) + fireEvent.click(screen.getByRole('button', { name: 'custom.apply' })) + + expect(hookState.handleRestore).toHaveBeenCalledTimes(1) + expect(hookState.handleCancel).toHaveBeenCalledTimes(1) + expect(hookState.handleApply).toHaveBeenCalledTimes(1) + }) }) }) diff --git a/web/app/components/custom/custom-web-app-brand/components/__tests__/chat-preview-card.spec.tsx b/web/app/components/custom/custom-web-app-brand/components/__tests__/chat-preview-card.spec.tsx new file mode 100644 index 0000000000..6605e40831 --- /dev/null +++ b/web/app/components/custom/custom-web-app-brand/components/__tests__/chat-preview-card.spec.tsx @@ -0,0 +1,31 @@ +import { render, screen } from '@testing-library/react' +import { describe, expect, it } from 'vitest' +import ChatPreviewCard from '../chat-preview-card' + +describe('ChatPreviewCard', () => { + it('should render the chat preview with the powered-by footer', () => { + render( + , + ) + + expect(screen.getByText('Chatflow App')).toBeInTheDocument() + expect(screen.getByText('Hello! How can I assist you today?')).toBeInTheDocument() + expect(screen.getByText('Talk to Dify')).toBeInTheDocument() + expect(screen.getByText('POWERED BY')).toBeInTheDocument() + }) + + it('should hide chat branding footer when brand removal is enabled', () => { + render( + , + ) + + expect(screen.queryByText('POWERED BY')).not.toBeInTheDocument() + }) +}) diff --git a/web/app/components/custom/custom-web-app-brand/components/__tests__/powered-by-brand.spec.tsx b/web/app/components/custom/custom-web-app-brand/components/__tests__/powered-by-brand.spec.tsx new file mode 100644 index 0000000000..d77c8ce15b --- /dev/null +++ b/web/app/components/custom/custom-web-app-brand/components/__tests__/powered-by-brand.spec.tsx @@ -0,0 +1,41 @@ +import { render, screen } from '@testing-library/react' +import { describe, expect, it } from 'vitest' +import PoweredByBrand from '../powered-by-brand' + +describe('PoweredByBrand', () => { + it('should render the workspace logo when available', () => { + render( + , + ) + + expect(screen.getByText('POWERED BY')).toBeInTheDocument() + expect(screen.getByAltText('logo')).toHaveAttribute('src', 'https://example.com/workspace-logo.png') + }) + + it('should fall back to the custom web app logo when workspace branding is unavailable', () => { + render( + , + ) + + expect(screen.getByAltText('logo')).toHaveAttribute('src', 'https://example.com/custom-logo.png?hash=42') + }) + + it('should fall back to the Dify logo when no custom branding exists', () => { + render() + + expect(screen.getByAltText('Dify logo')).toBeInTheDocument() + }) + + it('should render nothing when branding is removed', () => { + const { container } = render() + + expect(container).toBeEmptyDOMElement() + }) +}) diff --git a/web/app/components/custom/custom-web-app-brand/components/__tests__/workflow-preview-card.spec.tsx b/web/app/components/custom/custom-web-app-brand/components/__tests__/workflow-preview-card.spec.tsx new file mode 100644 index 0000000000..d563c4f40b --- /dev/null +++ b/web/app/components/custom/custom-web-app-brand/components/__tests__/workflow-preview-card.spec.tsx @@ -0,0 +1,32 @@ +import { render, screen } from '@testing-library/react' +import { describe, expect, it } from 'vitest' +import WorkflowPreviewCard from '../workflow-preview-card' + +describe('WorkflowPreviewCard', () => { + it('should render the workflow preview with execute action and branding footer', () => { + render( + , + ) + + expect(screen.getByText('Workflow App')).toBeInTheDocument() + expect(screen.getByText('RUN ONCE')).toBeInTheDocument() + expect(screen.getByText('RUN BATCH')).toBeInTheDocument() + expect(screen.getByRole('button', { name: /Execute/i })).toBeDisabled() + expect(screen.getByAltText('logo')).toHaveAttribute('src', 'https://example.com/workspace-logo.png') + }) + + it('should hide workflow branding footer when brand removal is enabled', () => { + render( + , + ) + + expect(screen.queryByText('POWERED BY')).not.toBeInTheDocument() + }) +}) diff --git a/web/app/components/custom/custom-web-app-brand/components/chat-preview-card.tsx b/web/app/components/custom/custom-web-app-brand/components/chat-preview-card.tsx new file mode 100644 index 0000000000..5700a04e41 --- /dev/null +++ b/web/app/components/custom/custom-web-app-brand/components/chat-preview-card.tsx @@ -0,0 +1,78 @@ +import Button from '@/app/components/base/button' +import { cn } from '@/utils/classnames' +import PoweredByBrand from './powered-by-brand' + +type ChatPreviewCardProps = { + webappBrandRemoved?: boolean + workspaceLogo?: string + webappLogo?: string + imgKey: number +} + +const ChatPreviewCard = ({ + webappBrandRemoved, + workspaceLogo, + webappLogo, + imgKey, +}: ChatPreviewCardProps) => { + return ( +
+
+
+
+ +
+
Chatflow App
+
+ +
+
+
+ +
+
+
+
+
+
+
+
+
+
+
+
+
+
+ +
+
+ +
+
+
+
+
+
+
Hello! How can I assist you today?
+ +
+
Talk to Dify
+
+
+
+ ) +} + +export default ChatPreviewCard diff --git a/web/app/components/custom/custom-web-app-brand/components/powered-by-brand.tsx b/web/app/components/custom/custom-web-app-brand/components/powered-by-brand.tsx new file mode 100644 index 0000000000..8a0feffbc4 --- /dev/null +++ b/web/app/components/custom/custom-web-app-brand/components/powered-by-brand.tsx @@ -0,0 +1,31 @@ +import DifyLogo from '@/app/components/base/logo/dify-logo' + +type PoweredByBrandProps = { + webappBrandRemoved?: boolean + workspaceLogo?: string + webappLogo?: string + imgKey: number +} + +const PoweredByBrand = ({ + webappBrandRemoved, + workspaceLogo, + webappLogo, + imgKey, +}: PoweredByBrandProps) => { + if (webappBrandRemoved) + return null + + const previewLogo = workspaceLogo || (webappLogo ? `${webappLogo}?hash=${imgKey}` : '') + + return ( + <> +
POWERED BY
+ {previewLogo + ? logo + : } + + ) +} + +export default PoweredByBrand diff --git a/web/app/components/custom/custom-web-app-brand/components/workflow-preview-card.tsx b/web/app/components/custom/custom-web-app-brand/components/workflow-preview-card.tsx new file mode 100644 index 0000000000..276f77ce71 --- /dev/null +++ b/web/app/components/custom/custom-web-app-brand/components/workflow-preview-card.tsx @@ -0,0 +1,64 @@ +import Button from '@/app/components/base/button' +import { cn } from '@/utils/classnames' +import PoweredByBrand from './powered-by-brand' + +type WorkflowPreviewCardProps = { + webappBrandRemoved?: boolean + workspaceLogo?: string + webappLogo?: string + imgKey: number +} + +const WorkflowPreviewCard = ({ + webappBrandRemoved, + workspaceLogo, + webappLogo, + imgKey, +}: WorkflowPreviewCardProps) => { + return ( +
+
+
+
+ +
+
Workflow App
+
+ +
+
+
+
RUN ONCE
+
RUN BATCH
+
+
+
+
+
+
+
+
+
+
+ + +
+
+
+ +
+
+ ) +} + +export default WorkflowPreviewCard diff --git a/web/app/components/custom/custom-web-app-brand/hooks/__tests__/use-web-app-brand.spec.tsx b/web/app/components/custom/custom-web-app-brand/hooks/__tests__/use-web-app-brand.spec.tsx new file mode 100644 index 0000000000..bb19c5accc --- /dev/null +++ b/web/app/components/custom/custom-web-app-brand/hooks/__tests__/use-web-app-brand.spec.tsx @@ -0,0 +1,385 @@ +import type { ChangeEvent } from 'react' +import type { AppContextValue } from '@/context/app-context' +import type { SystemFeatures } from '@/types/feature' +import { act, renderHook } from '@testing-library/react' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { createMockProviderContextValue } from '@/__mocks__/provider-context' +import { getImageUploadErrorMessage, imageUpload } from '@/app/components/base/image-uploader/utils' +import { useToastContext } from '@/app/components/base/toast/context' +import { defaultPlan } from '@/app/components/billing/config' +import { Plan } from '@/app/components/billing/type' +import { + initialLangGeniusVersionInfo, + initialWorkspaceInfo, + useAppContext, + userProfilePlaceholder, +} from '@/context/app-context' +import { useGlobalPublicStore } from '@/context/global-public-context' +import { useProviderContext } from '@/context/provider-context' +import { updateCurrentWorkspace } from '@/service/common' +import { defaultSystemFeatures } from '@/types/feature' +import useWebAppBrand from '../use-web-app-brand' + +vi.mock('@/app/components/base/toast/context', () => ({ + useToastContext: vi.fn(), +})) +vi.mock('@/service/common', () => ({ + updateCurrentWorkspace: vi.fn(), +})) +vi.mock('@/context/app-context', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + useAppContext: vi.fn(), + } +}) +vi.mock('@/context/provider-context', () => ({ + useProviderContext: vi.fn(), +})) +vi.mock('@/context/global-public-context', () => ({ + useGlobalPublicStore: vi.fn(), +})) +vi.mock('@/app/components/base/image-uploader/utils', () => ({ + imageUpload: vi.fn(), + getImageUploadErrorMessage: vi.fn(), +})) + +const mockNotify = vi.fn() +const mockUseToastContext = vi.mocked(useToastContext) +const mockUpdateCurrentWorkspace = vi.mocked(updateCurrentWorkspace) +const mockUseAppContext = vi.mocked(useAppContext) +const mockUseProviderContext = vi.mocked(useProviderContext) +const mockUseGlobalPublicStore = vi.mocked(useGlobalPublicStore) +const mockImageUpload = vi.mocked(imageUpload) +const mockGetImageUploadErrorMessage = vi.mocked(getImageUploadErrorMessage) + +const createProviderContext = ({ + enableBilling = false, + planType = Plan.professional, +}: { + enableBilling?: boolean + planType?: Plan +} = {}) => { + return createMockProviderContextValue({ + enableBilling, + plan: { + ...defaultPlan, + type: planType, + }, + }) +} + +const createSystemFeatures = (brandingOverrides: Partial = {}): SystemFeatures => ({ + ...defaultSystemFeatures, + branding: { + ...defaultSystemFeatures.branding, + enabled: true, + workspace_logo: 'https://example.com/workspace-logo.png', + ...brandingOverrides, + }, +}) + +const createAppContextValue = (overrides: Partial = {}): AppContextValue => { + const { currentWorkspace: currentWorkspaceOverride, ...restOverrides } = overrides + const workspaceOverrides: Partial = currentWorkspaceOverride ?? {} + const currentWorkspace = { + ...initialWorkspaceInfo, + ...workspaceOverrides, + custom_config: { + replace_webapp_logo: 'https://example.com/replace.png', + remove_webapp_brand: false, + ...workspaceOverrides.custom_config, + }, + } + + return { + userProfile: userProfilePlaceholder, + mutateUserProfile: vi.fn(), + isCurrentWorkspaceManager: true, + isCurrentWorkspaceOwner: false, + isCurrentWorkspaceEditor: false, + isCurrentWorkspaceDatasetOperator: false, + mutateCurrentWorkspace: vi.fn(), + langGeniusVersionInfo: initialLangGeniusVersionInfo, + useSelector: vi.fn() as unknown as AppContextValue['useSelector'], + isLoadingCurrentWorkspace: false, + isValidatingCurrentWorkspace: false, + ...restOverrides, + currentWorkspace, + } +} + +describe('useWebAppBrand', () => { + let appContextValue: AppContextValue + let systemFeatures: SystemFeatures + + beforeEach(() => { + vi.clearAllMocks() + + appContextValue = createAppContextValue() + systemFeatures = createSystemFeatures() + + mockUseToastContext.mockReturnValue({ notify: mockNotify } as unknown as ReturnType) + mockUpdateCurrentWorkspace.mockResolvedValue(appContextValue.currentWorkspace) + mockUseAppContext.mockImplementation(() => appContextValue) + mockUseProviderContext.mockReturnValue(createProviderContext()) + mockUseGlobalPublicStore.mockImplementation(selector => selector({ + systemFeatures, + setSystemFeatures: vi.fn(), + })) + mockGetImageUploadErrorMessage.mockReturnValue('upload error') + }) + + // Derived state from context and store inputs. + describe('derived state', () => { + it('should expose workspace branding and upload availability by default', () => { + const { result } = renderHook(() => useWebAppBrand()) + + expect(result.current.webappLogo).toBe('https://example.com/replace.png') + expect(result.current.workspaceLogo).toBe('https://example.com/workspace-logo.png') + expect(result.current.uploadDisabled).toBe(false) + expect(result.current.uploading).toBe(false) + }) + + it('should disable uploads in sandbox workspaces and when branding is removed', () => { + mockUseProviderContext.mockReturnValue(createProviderContext({ + enableBilling: true, + planType: Plan.sandbox, + })) + appContextValue = createAppContextValue({ + currentWorkspace: { + ...initialWorkspaceInfo, + custom_config: { + replace_webapp_logo: 'https://example.com/replace.png', + remove_webapp_brand: true, + }, + }, + }) + + const { result } = renderHook(() => useWebAppBrand()) + + expect(result.current.isSandbox).toBe(true) + expect(result.current.webappBrandRemoved).toBe(true) + expect(result.current.uploadDisabled).toBe(true) + }) + + it('should fall back to an empty workspace logo when branding is disabled', () => { + systemFeatures = createSystemFeatures({ + enabled: false, + workspace_logo: '', + }) + + const { result } = renderHook(() => useWebAppBrand()) + + expect(result.current.workspaceLogo).toBe('') + }) + + it('should fall back to an empty custom logo when custom config is missing', () => { + appContextValue = { + ...createAppContextValue(), + currentWorkspace: { + ...initialWorkspaceInfo, + }, + } + + const { result } = renderHook(() => useWebAppBrand()) + + expect(result.current.webappLogo).toBe('') + }) + }) + + // State transitions driven by user actions. + describe('actions', () => { + it('should ignore empty file selections', () => { + const { result } = renderHook(() => useWebAppBrand()) + + act(() => { + result.current.handleChange({ + target: { files: [] }, + } as unknown as ChangeEvent) + }) + + expect(mockImageUpload).not.toHaveBeenCalled() + }) + + it('should reject oversized files before upload starts', () => { + const { result } = renderHook(() => useWebAppBrand()) + const oversizedFile = new File(['logo'], 'logo.png', { type: 'image/png' }) + + Object.defineProperty(oversizedFile, 'size', { + configurable: true, + value: 5 * 1024 * 1024 + 1, + }) + + act(() => { + result.current.handleChange({ + target: { files: [oversizedFile] }, + } as unknown as ChangeEvent) + }) + + expect(mockImageUpload).not.toHaveBeenCalled() + expect(mockNotify).toHaveBeenCalledWith({ + type: 'error', + message: 'common.imageUploader.uploadFromComputerLimit:{"size":5}', + }) + }) + + it('should update upload state after a successful file upload', () => { + mockImageUpload.mockImplementation(({ onProgressCallback, onSuccessCallback }) => { + onProgressCallback(100) + onSuccessCallback({ id: 'new-logo' }) + }) + + const { result } = renderHook(() => useWebAppBrand()) + + act(() => { + result.current.handleChange({ + target: { files: [new File(['logo'], 'logo.png', { type: 'image/png' })] }, + } as unknown as ChangeEvent) + }) + + expect(result.current.fileId).toBe('new-logo') + expect(result.current.uploadProgress).toBe(100) + expect(result.current.uploading).toBe(false) + }) + + it('should expose the uploading state while progress is incomplete', () => { + mockImageUpload.mockImplementation(({ onProgressCallback }) => { + onProgressCallback(50) + }) + + const { result } = renderHook(() => useWebAppBrand()) + + act(() => { + result.current.handleChange({ + target: { files: [new File(['logo'], 'logo.png', { type: 'image/png' })] }, + } as unknown as ChangeEvent) + }) + + expect(result.current.uploadProgress).toBe(50) + expect(result.current.uploading).toBe(true) + }) + + it('should surface upload errors and set the failure state', () => { + mockImageUpload.mockImplementation(({ onErrorCallback }) => { + onErrorCallback({ response: { code: 'forbidden' } }) + }) + + const { result } = renderHook(() => useWebAppBrand()) + + act(() => { + result.current.handleChange({ + target: { files: [new File(['logo'], 'logo.png', { type: 'image/png' })] }, + } as unknown as ChangeEvent) + }) + + expect(mockGetImageUploadErrorMessage).toHaveBeenCalled() + expect(mockNotify).toHaveBeenCalledWith({ + type: 'error', + message: 'upload error', + }) + expect(result.current.uploadProgress).toBe(-1) + }) + + it('should persist the selected logo and reset transient state on apply', async () => { + const mutateCurrentWorkspace = vi.fn() + appContextValue = createAppContextValue({ + mutateCurrentWorkspace, + }) + mockImageUpload.mockImplementation(({ onSuccessCallback }) => { + onSuccessCallback({ id: 'new-logo' }) + }) + + const { result } = renderHook(() => useWebAppBrand()) + + act(() => { + result.current.handleChange({ + target: { files: [new File(['logo'], 'logo.png', { type: 'image/png' })] }, + } as unknown as ChangeEvent) + }) + + const previousImgKey = result.current.imgKey + const dateNowSpy = vi.spyOn(Date, 'now').mockReturnValue(previousImgKey + 1) + + await act(async () => { + await result.current.handleApply() + }) + + expect(mockUpdateCurrentWorkspace).toHaveBeenCalledWith({ + url: '/workspaces/custom-config', + body: { + remove_webapp_brand: false, + replace_webapp_logo: 'new-logo', + }, + }) + expect(mutateCurrentWorkspace).toHaveBeenCalledTimes(1) + expect(result.current.fileId).toBe('') + expect(result.current.imgKey).toBe(previousImgKey + 1) + dateNowSpy.mockRestore() + }) + + it('should restore the default branding configuration', async () => { + const mutateCurrentWorkspace = vi.fn() + appContextValue = createAppContextValue({ + mutateCurrentWorkspace, + }) + + const { result } = renderHook(() => useWebAppBrand()) + + await act(async () => { + await result.current.handleRestore() + }) + + expect(mockUpdateCurrentWorkspace).toHaveBeenCalledWith({ + url: '/workspaces/custom-config', + body: { + remove_webapp_brand: false, + replace_webapp_logo: '', + }, + }) + expect(mutateCurrentWorkspace).toHaveBeenCalledTimes(1) + }) + + it('should persist brand removal changes', async () => { + const mutateCurrentWorkspace = vi.fn() + appContextValue = createAppContextValue({ + mutateCurrentWorkspace, + }) + + const { result } = renderHook(() => useWebAppBrand()) + + await act(async () => { + await result.current.handleSwitch(true) + }) + + expect(mockUpdateCurrentWorkspace).toHaveBeenCalledWith({ + url: '/workspaces/custom-config', + body: { + remove_webapp_brand: true, + }, + }) + expect(mutateCurrentWorkspace).toHaveBeenCalledTimes(1) + }) + + it('should clear temporary upload state on cancel', () => { + mockImageUpload.mockImplementation(({ onSuccessCallback }) => { + onSuccessCallback({ id: 'new-logo' }) + }) + + const { result } = renderHook(() => useWebAppBrand()) + + act(() => { + result.current.handleChange({ + target: { files: [new File(['logo'], 'logo.png', { type: 'image/png' })] }, + } as unknown as ChangeEvent) + }) + + act(() => { + result.current.handleCancel() + }) + + expect(result.current.fileId).toBe('') + expect(result.current.uploadProgress).toBe(0) + }) + }) +}) diff --git a/web/app/components/custom/custom-web-app-brand/hooks/use-web-app-brand.ts b/web/app/components/custom/custom-web-app-brand/hooks/use-web-app-brand.ts new file mode 100644 index 0000000000..90ba0483c9 --- /dev/null +++ b/web/app/components/custom/custom-web-app-brand/hooks/use-web-app-brand.ts @@ -0,0 +1,121 @@ +import type { ChangeEvent } from 'react' +import { useState } from 'react' +import { useTranslation } from 'react-i18next' +import { getImageUploadErrorMessage, imageUpload } from '@/app/components/base/image-uploader/utils' +import { useToastContext } from '@/app/components/base/toast/context' +import { Plan } from '@/app/components/billing/type' +import { useAppContext } from '@/context/app-context' +import { useGlobalPublicStore } from '@/context/global-public-context' +import { useProviderContext } from '@/context/provider-context' +import { updateCurrentWorkspace } from '@/service/common' + +const MAX_LOGO_FILE_SIZE = 5 * 1024 * 1024 +const CUSTOM_CONFIG_URL = '/workspaces/custom-config' +const WEB_APP_LOGO_UPLOAD_URL = '/workspaces/custom-config/webapp-logo/upload' + +const useWebAppBrand = () => { + const { t } = useTranslation() + const { notify } = useToastContext() + const { plan, enableBilling } = useProviderContext() + const { + currentWorkspace, + mutateCurrentWorkspace, + isCurrentWorkspaceManager, + } = useAppContext() + const [fileId, setFileId] = useState('') + const [imgKey, setImgKey] = useState(() => Date.now()) + const [uploadProgress, setUploadProgress] = useState(0) + const systemFeatures = useGlobalPublicStore(s => s.systemFeatures) + + const isSandbox = enableBilling && plan.type === Plan.sandbox + const uploading = uploadProgress > 0 && uploadProgress < 100 + const webappLogo = currentWorkspace.custom_config?.replace_webapp_logo || '' + const webappBrandRemoved = currentWorkspace.custom_config?.remove_webapp_brand + const uploadDisabled = isSandbox || webappBrandRemoved || !isCurrentWorkspaceManager + const workspaceLogo = systemFeatures.branding.enabled ? systemFeatures.branding.workspace_logo : '' + + const persistWorkspaceBrand = async (body: Record) => { + await updateCurrentWorkspace({ + url: CUSTOM_CONFIG_URL, + body, + }) + mutateCurrentWorkspace() + } + + const handleChange = (e: ChangeEvent) => { + const file = e.target.files?.[0] + + if (!file) + return + + if (file.size > MAX_LOGO_FILE_SIZE) { + notify({ type: 'error', message: t('imageUploader.uploadFromComputerLimit', { ns: 'common', size: 5 }) }) + return + } + + imageUpload({ + file, + onProgressCallback: setUploadProgress, + onSuccessCallback: (res) => { + setUploadProgress(100) + setFileId(res.id) + }, + onErrorCallback: (error) => { + const errorMessage = getImageUploadErrorMessage( + error, + t('imageUploader.uploadFromComputerUploadError', { ns: 'common' }), + t, + ) + notify({ type: 'error', message: errorMessage }) + setUploadProgress(-1) + }, + }, false, WEB_APP_LOGO_UPLOAD_URL) + } + + const handleApply = async () => { + await persistWorkspaceBrand({ + remove_webapp_brand: webappBrandRemoved, + replace_webapp_logo: fileId, + }) + setFileId('') + setImgKey(Date.now()) + } + + const handleRestore = async () => { + await persistWorkspaceBrand({ + remove_webapp_brand: false, + replace_webapp_logo: '', + }) + } + + const handleSwitch = async (checked: boolean) => { + await persistWorkspaceBrand({ + remove_webapp_brand: checked, + }) + } + + const handleCancel = () => { + setFileId('') + setUploadProgress(0) + } + + return { + fileId, + imgKey, + uploadProgress, + uploading, + webappLogo, + webappBrandRemoved, + uploadDisabled, + workspaceLogo, + isSandbox, + isCurrentWorkspaceManager, + handleApply, + handleCancel, + handleChange, + handleRestore, + handleSwitch, + } +} + +export default useWebAppBrand diff --git a/web/app/components/custom/custom-web-app-brand/index.tsx b/web/app/components/custom/custom-web-app-brand/index.tsx index fa79c9540a..02a6419f18 100644 --- a/web/app/components/custom/custom-web-app-brand/index.tsx +++ b/web/app/components/custom/custom-web-app-brand/index.tsx @@ -1,118 +1,33 @@ -import type { ChangeEvent } from 'react' -import { - RiEditBoxLine, - RiEqualizer2Line, - RiExchange2Fill, - RiImageAddLine, - RiLayoutLeft2Line, - RiLoader2Line, - RiPlayLargeLine, -} from '@remixicon/react' -import { useState } from 'react' import { useTranslation } from 'react-i18next' import Button from '@/app/components/base/button' import Divider from '@/app/components/base/divider' -import { BubbleTextMod } from '@/app/components/base/icons/src/vender/solid/communication' -import { getImageUploadErrorMessage, imageUpload } from '@/app/components/base/image-uploader/utils' -import DifyLogo from '@/app/components/base/logo/dify-logo' import Switch from '@/app/components/base/switch' -import { useToastContext } from '@/app/components/base/toast/context' -import { Plan } from '@/app/components/billing/type' -import { useAppContext } from '@/context/app-context' -import { useGlobalPublicStore } from '@/context/global-public-context' -import { useProviderContext } from '@/context/provider-context' -import { - updateCurrentWorkspace, -} from '@/service/common' import { cn } from '@/utils/classnames' +import ChatPreviewCard from './components/chat-preview-card' +import WorkflowPreviewCard from './components/workflow-preview-card' +import useWebAppBrand from './hooks/use-web-app-brand' const ALLOW_FILE_EXTENSIONS = ['svg', 'png'] const CustomWebAppBrand = () => { const { t } = useTranslation() - const { notify } = useToastContext() - const { plan, enableBilling } = useProviderContext() const { - currentWorkspace, - mutateCurrentWorkspace, + fileId, + imgKey, + uploadProgress, + uploading, + webappLogo, + webappBrandRemoved, + uploadDisabled, + workspaceLogo, isCurrentWorkspaceManager, - } = useAppContext() - const [fileId, setFileId] = useState('') - const [imgKey, setImgKey] = useState(() => Date.now()) - const [uploadProgress, setUploadProgress] = useState(0) - const systemFeatures = useGlobalPublicStore(s => s.systemFeatures) - const isSandbox = enableBilling && plan.type === Plan.sandbox - const uploading = uploadProgress > 0 && uploadProgress < 100 - const webappLogo = currentWorkspace.custom_config?.replace_webapp_logo || '' - const webappBrandRemoved = currentWorkspace.custom_config?.remove_webapp_brand - const uploadDisabled = isSandbox || webappBrandRemoved || !isCurrentWorkspaceManager - - const handleChange = (e: ChangeEvent) => { - const file = e.target.files?.[0] - - if (!file) - return - - if (file.size > 5 * 1024 * 1024) { - notify({ type: 'error', message: t('imageUploader.uploadFromComputerLimit', { ns: 'common', size: 5 }) }) - return - } - - imageUpload({ - file, - onProgressCallback: (progress) => { - setUploadProgress(progress) - }, - onSuccessCallback: (res) => { - setUploadProgress(100) - setFileId(res.id) - }, - onErrorCallback: (error?: any) => { - const errorMessage = getImageUploadErrorMessage(error, t('imageUploader.uploadFromComputerUploadError', { ns: 'common' }), t as any) - notify({ type: 'error', message: errorMessage }) - setUploadProgress(-1) - }, - }, false, '/workspaces/custom-config/webapp-logo/upload') - } - - const handleApply = async () => { - await updateCurrentWorkspace({ - url: '/workspaces/custom-config', - body: { - remove_webapp_brand: webappBrandRemoved, - replace_webapp_logo: fileId, - }, - }) - mutateCurrentWorkspace() - setFileId('') - setImgKey(Date.now()) - } - - const handleRestore = async () => { - await updateCurrentWorkspace({ - url: '/workspaces/custom-config', - body: { - remove_webapp_brand: false, - replace_webapp_logo: '', - }, - }) - mutateCurrentWorkspace() - } - - const handleSwitch = async (checked: boolean) => { - await updateCurrentWorkspace({ - url: '/workspaces/custom-config', - body: { - remove_webapp_brand: checked, - }, - }) - mutateCurrentWorkspace() - } - - const handleCancel = () => { - setFileId('') - setUploadProgress(0) - } + isSandbox, + handleApply, + handleCancel, + handleChange, + handleRestore, + handleSwitch, + } = useWebAppBrand() return (
@@ -149,7 +64,7 @@ const CustomWebAppBrand = () => { className="relative mr-2" disabled={uploadDisabled} > - + { (webappLogo || fileId) ? t('change', { ns: 'custom' }) @@ -172,7 +87,7 @@ const CustomWebAppBrand = () => { className="relative mr-2" disabled={true} > - + {t('uploading', { ns: 'custom' })} ) @@ -208,118 +123,18 @@ const CustomWebAppBrand = () => {
- {/* chat card */} -
-
-
-
- -
-
Chatflow App
-
- -
-
-
- -
-
-
-
-
-
-
-
-
-
-
-
-
-
- -
-
- {!webappBrandRemoved && ( - <> -
POWERED BY
- { - systemFeatures.branding.enabled && systemFeatures.branding.workspace_logo - ? logo - : webappLogo - ? logo - : - } - - )} -
-
-
-
-
-
-
Hello! How can I assist you today?
- -
-
Talk to Dify
-
-
-
- {/* workflow card */} -
-
-
-
- -
-
Workflow App
-
- -
-
-
-
RUN ONCE
-
RUN BATCH
-
-
-
-
-
-
-
-
-
-
- - -
-
-
- {!webappBrandRemoved && ( - <> -
POWERED BY
- { - systemFeatures.branding.enabled && systemFeatures.branding.workspace_logo - ? logo - : webappLogo - ? logo - : - } - - )} -
-
+ +
) diff --git a/web/app/components/datasets/common/retrieval-method-info/__tests__/index.spec.tsx b/web/app/components/datasets/common/retrieval-method-info/__tests__/index.spec.tsx index 36120de738..ad230fb596 100644 --- a/web/app/components/datasets/common/retrieval-method-info/__tests__/index.spec.tsx +++ b/web/app/components/datasets/common/retrieval-method-info/__tests__/index.spec.tsx @@ -4,13 +4,6 @@ import { RETRIEVE_METHOD } from '@/types/app' import { retrievalIcon } from '../../../create/icons' import RetrievalMethodInfo, { getIcon } from '../index' -// Override global next/image auto-mock: tests assert on rendered src attributes via data-testid -vi.mock('next/image', () => ({ - default: ({ src, alt, className }: { src: string, alt: string, className?: string }) => ( - {alt - ), -})) - // Mock RadioCard vi.mock('@/app/components/base/radio-card', () => ({ default: ({ title, description, chosenConfig, icon }: { title: string, description: string, chosenConfig: ReactNode, icon: ReactNode }) => ( @@ -50,7 +43,7 @@ describe('RetrievalMethodInfo', () => { }) it('should render correctly with full config', () => { - render() + const { container } = render() expect(screen.getByTestId('radio-card')).toBeInTheDocument() @@ -59,7 +52,7 @@ describe('RetrievalMethodInfo', () => { expect(screen.getByTestId('card-description')).toHaveTextContent('dataset.retrieval.semantic_search.description') // Check Icon - const icon = screen.getByTestId('method-icon') + const icon = container.querySelector('img') expect(icon).toHaveAttribute('src', 'vector-icon.png') // Check Config Details @@ -87,18 +80,18 @@ describe('RetrievalMethodInfo', () => { it('should handle different retrieval methods', () => { // Test Hybrid const hybridConfig = { ...defaultConfig, search_method: RETRIEVE_METHOD.hybrid } - const { unmount } = render() + const { container, unmount } = render() expect(screen.getByTestId('card-title')).toHaveTextContent('dataset.retrieval.hybrid_search.title') - expect(screen.getByTestId('method-icon')).toHaveAttribute('src', 'hybrid-icon.png') + expect(container.querySelector('img')).toHaveAttribute('src', 'hybrid-icon.png') unmount() // Test FullText const fullTextConfig = { ...defaultConfig, search_method: RETRIEVE_METHOD.fullText } - render() + const { container: fullTextContainer } = render() expect(screen.getByTestId('card-title')).toHaveTextContent('dataset.retrieval.full_text_search.title') - expect(screen.getByTestId('method-icon')).toHaveAttribute('src', 'fulltext-icon.png') + expect(fullTextContainer.querySelector('img')).toHaveAttribute('src', 'fulltext-icon.png') }) describe('getIcon utility', () => { @@ -132,17 +125,17 @@ describe('RetrievalMethodInfo', () => { it('should render correctly with invertedIndex search method', () => { const invertedIndexConfig = { ...defaultConfig, search_method: RETRIEVE_METHOD.invertedIndex } - render() + const { container } = render() // invertedIndex uses vector icon - expect(screen.getByTestId('method-icon')).toHaveAttribute('src', 'vector-icon.png') + expect(container.querySelector('img')).toHaveAttribute('src', 'vector-icon.png') }) it('should render correctly with keywordSearch search method', () => { const keywordSearchConfig = { ...defaultConfig, search_method: RETRIEVE_METHOD.keywordSearch } - render() + const { container } = render() // keywordSearch uses vector icon - expect(screen.getByTestId('method-icon')).toHaveAttribute('src', 'vector-icon.png') + expect(container.querySelector('img')).toHaveAttribute('src', 'vector-icon.png') }) }) diff --git a/web/app/components/datasets/common/retrieval-method-info/index.tsx b/web/app/components/datasets/common/retrieval-method-info/index.tsx index 398b79975f..d23d247307 100644 --- a/web/app/components/datasets/common/retrieval-method-info/index.tsx +++ b/web/app/components/datasets/common/retrieval-method-info/index.tsx @@ -1,7 +1,6 @@ 'use client' import type { FC } from 'react' import type { RetrievalConfig } from '@/types/app' -import Image from 'next/image' import * as React from 'react' import { useTranslation } from 'react-i18next' import RadioCard from '@/app/components/base/radio-card' @@ -28,7 +27,7 @@ const EconomicalRetrievalMethodConfig: FC = ({ }) => { const { t } = useTranslation() const type = value.search_method - const icon = + const icon = return (
= ({ /> )}
- {t('modelProvider.rerankModel.key', { ns: 'common' })} + {t('modelProvider.rerankModel.key', { ns: 'common' })} {t('modelProvider.rerankModel.tip', { ns: 'common' })}
@@ -157,7 +156,7 @@ const RetrievalParamConfig: FC = ({
- + {t('form.retrievalSetting.multiModalTip', { ns: 'datasetSettings' })}
@@ -215,11 +214,11 @@ const RetrievalParamConfig: FC = ({ isChosen={value.reranking_mode === option.value} onChosen={() => handleChangeRerankMode(option.value)} icon={( - @@ -281,7 +280,7 @@ const RetrievalParamConfig: FC = ({
- + {t('form.retrievalSetting.multiModalTip', { ns: 'datasetSettings' })}
diff --git a/web/app/components/datasets/create/embedding-process/__tests__/index.spec.tsx b/web/app/components/datasets/create/embedding-process/__tests__/index.spec.tsx index 9f06abdc41..686139250a 100644 --- a/web/app/components/datasets/create/embedding-process/__tests__/index.spec.tsx +++ b/web/app/components/datasets/create/embedding-process/__tests__/index.spec.tsx @@ -20,14 +20,6 @@ vi.mock('next/navigation', () => ({ useRouter: () => mockRouter, })) -// Override global next/image auto-mock: test asserts on data-testid="next-image" -vi.mock('next/image', () => ({ - default: ({ src, alt, className }: { src: string, alt: string, className?: string }) => ( - // eslint-disable-next-line next/no-img-element - {alt} - ), -})) - // Mock API service const mockFetchIndexingStatusBatch = vi.fn() vi.mock('@/service/datasets', () => ({ @@ -979,9 +971,9 @@ describe('RuleDetail', () => { }) it('should render correct icon for indexing type', () => { - render() + const { container } = render() - const images = screen.getAllByTestId('next-image') + const images = container.querySelectorAll('img') expect(images.length).toBeGreaterThan(0) }) }) diff --git a/web/app/components/datasets/create/embedding-process/rule-detail.tsx b/web/app/components/datasets/create/embedding-process/rule-detail.tsx index dff35100cb..553c751056 100644 --- a/web/app/components/datasets/create/embedding-process/rule-detail.tsx +++ b/web/app/components/datasets/create/embedding-process/rule-detail.tsx @@ -1,6 +1,5 @@ import type { FC } from 'react' import type { ProcessRuleResponse } from '@/models/datasets' -import Image from 'next/image' import { useCallback } from 'react' import { useTranslation } from 'react-i18next' import { FieldInfo } from '@/app/components/datasets/documents/detail/metadata' @@ -119,12 +118,12 @@ const RuleDetail: FC = ({ sourceData, indexingType, retrievalMe } + valueIcon={} /> } + valueIcon={} />
) diff --git a/web/app/components/datasets/create/icons.ts b/web/app/components/datasets/create/icons.ts index 10f3a319dc..75cbba0c6b 100644 --- a/web/app/components/datasets/create/icons.ts +++ b/web/app/components/datasets/create/icons.ts @@ -5,12 +5,12 @@ import Research from './assets/research-mod.svg' import Selection from './assets/selection-mod.svg' export const indexMethodIcon = { - high_quality: GoldIcon, - economical: Piggybank, + high_quality: GoldIcon.src, + economical: Piggybank.src, } export const retrievalIcon = { - vector: Selection, - fullText: Research, - hybrid: PatternRecognition, + vector: Selection.src, + fullText: Research.src, + hybrid: PatternRecognition.src, } diff --git a/web/app/components/datasets/create/step-two/components/__tests__/inputs.spec.tsx b/web/app/components/datasets/create/step-two/components/__tests__/inputs.spec.tsx index e48e87560c..aeeef838f4 100644 --- a/web/app/components/datasets/create/step-two/components/__tests__/inputs.spec.tsx +++ b/web/app/components/datasets/create/step-two/components/__tests__/inputs.spec.tsx @@ -47,19 +47,19 @@ describe('MaxLengthInput', () => { it('should render number input', () => { render() - const input = screen.getByRole('spinbutton') + const input = screen.getByRole('textbox') expect(input).toBeInTheDocument() }) it('should accept value prop', () => { render() - expect(screen.getByDisplayValue('500')).toBeInTheDocument() + expect(screen.getByRole('textbox')).toHaveValue('500') }) it('should have min of 1', () => { render() - const input = screen.getByRole('spinbutton') - expect(input).toHaveAttribute('min', '1') + const input = screen.getByRole('textbox') + expect(input).toBeInTheDocument() }) }) @@ -75,18 +75,18 @@ describe('OverlapInput', () => { it('should render number input', () => { render() - const input = screen.getByRole('spinbutton') + const input = screen.getByRole('textbox') expect(input).toBeInTheDocument() }) it('should accept value prop', () => { render() - expect(screen.getByDisplayValue('50')).toBeInTheDocument() + expect(screen.getByRole('textbox')).toHaveValue('50') }) it('should have min of 1', () => { render() - const input = screen.getByRole('spinbutton') - expect(input).toHaveAttribute('min', '1') + const input = screen.getByRole('textbox') + expect(input).toBeInTheDocument() }) }) diff --git a/web/app/components/datasets/create/step-two/components/__tests__/option-card.spec.tsx b/web/app/components/datasets/create/step-two/components/__tests__/option-card.spec.tsx index e543efec86..d59e759ab1 100644 --- a/web/app/components/datasets/create/step-two/components/__tests__/option-card.spec.tsx +++ b/web/app/components/datasets/create/step-two/components/__tests__/option-card.spec.tsx @@ -2,13 +2,6 @@ import { fireEvent, render, screen } from '@testing-library/react' import { beforeEach, describe, expect, it, vi } from 'vitest' import { OptionCard, OptionCardHeader } from '../option-card' -// Override global next/image auto-mock: tests assert on rendered elements -vi.mock('next/image', () => ({ - default: ({ src, alt, ...props }: { src?: string, alt?: string, width?: number, height?: number }) => ( - {alt} - ), -})) - describe('OptionCardHeader', () => { const defaultProps = { icon: icon, diff --git a/web/app/components/datasets/create/step-two/components/general-chunking-options.tsx b/web/app/components/datasets/create/step-two/components/general-chunking-options.tsx index 0beda8f5c8..650fd3ebfb 100644 --- a/web/app/components/datasets/create/step-two/components/general-chunking-options.tsx +++ b/web/app/components/datasets/create/step-two/components/general-chunking-options.tsx @@ -6,7 +6,6 @@ import { RiAlertFill, RiSearchEyeLine, } from '@remixicon/react' -import Image from 'next/image' import { useTranslation } from 'react-i18next' import Button from '@/app/components/base/button' import Checkbox from '@/app/components/base/checkbox' @@ -26,7 +25,7 @@ type TextLabelProps = { } const TextLabel: FC = ({ children }) => { - return + return } type GeneralChunkingOptionsProps = { @@ -97,7 +96,7 @@ export const GeneralChunkingOptions: FC = ({ } + icon={{t('stepTwo.general',} activeHeaderClassName="bg-dataset-option-card-blue-gradient" description={t('stepTwo.generalTip', { ns: 'datasetCreation' })} isActive={isActive} @@ -148,7 +147,7 @@ export const GeneralChunkingOptions: FC = ({ onClick={() => onRuleToggle(rule.id)} > -
@@ -183,7 +182,7 @@ export const GeneralChunkingOptions: FC = ({ checked={currentDocForm === ChunkingMode.qa} disabled={hasCurrentDatasetDocForm} /> -
@@ -202,7 +201,7 @@ export const GeneralChunkingOptions: FC = ({ className="mt-2 flex h-10 items-center gap-2 rounded-xl border border-components-panel-border px-3 text-xs shadow-xs backdrop-blur-[5px]" > - + {t('stepTwo.QATip', { ns: 'datasetCreation' })}
diff --git a/web/app/components/datasets/create/step-two/components/indexing-mode-section.tsx b/web/app/components/datasets/create/step-two/components/indexing-mode-section.tsx index b172778f54..da309348cc 100644 --- a/web/app/components/datasets/create/step-two/components/indexing-mode-section.tsx +++ b/web/app/components/datasets/create/step-two/components/indexing-mode-section.tsx @@ -3,7 +3,6 @@ import type { FC } from 'react' import type { DefaultModel, Model } from '@/app/components/header/account-setting/model-provider-page/declarations' import type { RetrievalConfig } from '@/types/app' -import Image from 'next/image' import Link from 'next/link' import { useTranslation } from 'react-i18next' import Badge from '@/app/components/base/badge' @@ -70,7 +69,7 @@ export const IndexingModeSection: FC = ({ return ( <> {/* Index Mode */} -
+
{t('stepTwo.indexMode', { ns: 'datasetCreation' })}
@@ -98,7 +97,7 @@ export const IndexingModeSection: FC = ({
)} description={t('stepTwo.qualifiedTip', { ns: 'datasetCreation' })} - icon={} + icon={} isActive={!hasSetIndexType && indexType === IndexingType.QUALIFIED} disabled={hasSetIndexType} onSwitched={() => onIndexTypeChange(IndexingType.QUALIFIED)} @@ -143,7 +142,7 @@ export const IndexingModeSection: FC = ({ className="h-full" title={t('stepTwo.economical', { ns: 'datasetCreation' })} description={t('stepTwo.economicalTip', { ns: 'datasetCreation' })} - icon={} + icon={} isActive={!hasSetIndexType && indexType === IndexingType.ECONOMICAL} disabled={hasSetIndexType || docForm !== ChunkingMode.text} onSwitched={() => onIndexTypeChange(IndexingType.ECONOMICAL)} @@ -160,7 +159,7 @@ export const IndexingModeSection: FC = ({
- + {t('stepTwo.highQualityTip', { ns: 'datasetCreation' })}
@@ -168,7 +167,7 @@ export const IndexingModeSection: FC = ({ {/* Economical index setting tip */} {hasSetIndexType && indexType === IndexingType.ECONOMICAL && ( -
+
{t('stepTwo.indexSettingTip', { ns: 'datasetCreation' })} {t('stepTwo.datasetSettingLink', { ns: 'datasetCreation' })} @@ -179,7 +178,7 @@ export const IndexingModeSection: FC = ({ {/* Embedding model */} {indexType === IndexingType.QUALIFIED && (
-
+
{t('form.embeddingModel', { ns: 'datasetSettings' })}
= ({ onSelect={onEmbeddingModelChange} /> {isModelAndRetrievalConfigDisabled && ( -
+
{t('stepTwo.indexSettingTip', { ns: 'datasetCreation' })} {t('stepTwo.datasetSettingLink', { ns: 'datasetCreation' })} @@ -207,10 +206,10 @@ export const IndexingModeSection: FC = ({ {!isModelAndRetrievalConfigDisabled ? (
-
+
{t('form.retrievalSetting.title', { ns: 'datasetSettings' })}
-
+ ) : ( -
+
{t('form.retrievalSetting.title', { ns: 'datasetSettings' })}
)} diff --git a/web/app/components/datasets/create/step-two/components/option-card.tsx b/web/app/components/datasets/create/step-two/components/option-card.tsx index 55cbf5276f..320c7be44f 100644 --- a/web/app/components/datasets/create/step-two/components/option-card.tsx +++ b/web/app/components/datasets/create/step-two/components/option-card.tsx @@ -1,5 +1,4 @@ import type { ComponentProps, FC, ReactNode } from 'react' -import Image from 'next/image' import { cn } from '@/utils/classnames' const TriangleArrow: FC> = props => ( @@ -23,7 +22,7 @@ export const OptionCardHeader: FC = (props) => { return (
- {isActive && effectImg && } + {isActive && effectImg && }
{icon} @@ -34,8 +33,8 @@ export const OptionCardHeader: FC = (props) => { className={cn('absolute -bottom-1.5 left-4 text-transparent', isActive && 'text-components-panel-bg')} />
-
{title}
-
{description}
+
{title}
+
{description}
) diff --git a/web/app/components/datasets/create/step-two/components/parent-child-options.tsx b/web/app/components/datasets/create/step-two/components/parent-child-options.tsx index b7b965a4fd..eb542fd3d5 100644 --- a/web/app/components/datasets/create/step-two/components/parent-child-options.tsx +++ b/web/app/components/datasets/create/step-two/components/parent-child-options.tsx @@ -4,7 +4,6 @@ import type { FC } from 'react' import type { ParentChildConfig } from '../hooks' import type { ParentMode, PreProcessingRule, SummaryIndexSetting as SummaryIndexSettingType } from '@/models/datasets' import { RiSearchEyeLine } from '@remixicon/react' -import Image from 'next/image' import { useTranslation } from 'react-i18next' import Button from '@/app/components/base/button' import Checkbox from '@/app/components/base/checkbox' @@ -26,7 +25,7 @@ type TextLabelProps = { } const TextLabel: FC = ({ children }) => { - return + return } type ParentChildOptionsProps = { @@ -118,7 +117,7 @@ export const ParentChildOptions: FC = ({
} + icon={} title={t('stepTwo.paragraph', { ns: 'datasetCreation' })} description={t('stepTwo.paragraphTip', { ns: 'datasetCreation' })} isChosen={parentChildConfig.chunkForContext === 'paragraph'} @@ -140,7 +139,7 @@ export const ParentChildOptions: FC = ({ /> } + icon={} title={t('stepTwo.fullDoc', { ns: 'datasetCreation' })} description={t('stepTwo.fullDocTip', { ns: 'datasetCreation' })} onChosen={() => onChunkForContextChange('full-doc')} @@ -186,7 +185,7 @@ export const ParentChildOptions: FC = ({ onClick={() => onRuleToggle(rule.id)} > -
diff --git a/web/app/components/datasets/documents/create-from-pipeline/processing/embedding-process/__tests__/rule-detail.spec.tsx b/web/app/components/datasets/documents/create-from-pipeline/processing/embedding-process/__tests__/rule-detail.spec.tsx index c11caeb156..c0873f2c5d 100644 --- a/web/app/components/datasets/documents/create-from-pipeline/processing/embedding-process/__tests__/rule-detail.spec.tsx +++ b/web/app/components/datasets/documents/create-from-pipeline/processing/embedding-process/__tests__/rule-detail.spec.tsx @@ -6,14 +6,6 @@ import { ProcessMode } from '@/models/datasets' import { RETRIEVE_METHOD } from '@/types/app' import RuleDetail from '../rule-detail' -// Override global next/image auto-mock: tests assert on data-testid="next-image" and src attributes -vi.mock('next/image', () => ({ - default: function MockImage({ src, alt, className }: { src: string, alt: string, className?: string }) { - // eslint-disable-next-line next/no-img-element - return {alt} - }, -})) - // Mock FieldInfo component vi.mock('@/app/components/datasets/documents/detail/metadata', () => ({ FieldInfo: ({ label, displayedValue, valueIcon }: { label: string, displayedValue: string, valueIcon?: React.ReactNode }) => ( @@ -184,16 +176,16 @@ describe('RuleDetail', () => { }) it('should show high_quality icon for qualified indexing', () => { - render() + const { container } = render() - const images = screen.getAllByTestId('next-image') + const images = container.querySelectorAll('img') expect(images[0]).toHaveAttribute('src', '/icons/high_quality.svg') }) it('should show economical icon for economical indexing', () => { - render() + const { container } = render() - const images = screen.getAllByTestId('next-image') + const images = container.querySelectorAll('img') expect(images[0]).toHaveAttribute('src', '/icons/economical.svg') }) }) @@ -256,38 +248,38 @@ describe('RuleDetail', () => { }) it('should show vector icon for semantic search', () => { - render( + const { container } = render( , ) - const images = screen.getAllByTestId('next-image') + const images = container.querySelectorAll('img') expect(images[1]).toHaveAttribute('src', '/icons/vector.svg') }) it('should show fullText icon for full text search', () => { - render( + const { container } = render( , ) - const images = screen.getAllByTestId('next-image') + const images = container.querySelectorAll('img') expect(images[1]).toHaveAttribute('src', '/icons/fullText.svg') }) it('should show hybrid icon for hybrid search', () => { - render( + const { container } = render( , ) - const images = screen.getAllByTestId('next-image') + const images = container.querySelectorAll('img') expect(images[1]).toHaveAttribute('src', '/icons/hybrid.svg') }) }) @@ -308,9 +300,9 @@ describe('RuleDetail', () => { }) it('should handle undefined retrievalMethod with defined indexingType', () => { - render() + const { container } = render() - const images = screen.getAllByTestId('next-image') + const images = container.querySelectorAll('img') // When retrievalMethod is undefined, vector icon is used as default expect(images[1]).toHaveAttribute('src', '/icons/vector.svg') }) diff --git a/web/app/components/datasets/documents/create-from-pipeline/processing/embedding-process/rule-detail.tsx b/web/app/components/datasets/documents/create-from-pipeline/processing/embedding-process/rule-detail.tsx index 8fe6af6170..526d31f3fe 100644 --- a/web/app/components/datasets/documents/create-from-pipeline/processing/embedding-process/rule-detail.tsx +++ b/web/app/components/datasets/documents/create-from-pipeline/processing/embedding-process/rule-detail.tsx @@ -1,5 +1,4 @@ import type { ProcessRuleResponse } from '@/models/datasets' -import Image from 'next/image' import * as React from 'react' import { useCallback } from 'react' import { useTranslation } from 'react-i18next' @@ -50,7 +49,7 @@ const RuleDetail = ({ label={t('stepTwo.indexMode', { ns: 'datasetCreation' })} displayedValue={t(`stepTwo.${indexingType === IndexingType.ECONOMICAL ? 'economical' : 'qualified'}`, { ns: 'datasetCreation' }) as string} valueIcon={( - = React.memo(({ label={t('stepTwo.indexMode', { ns: 'datasetCreation' })} displayedValue={t(`stepTwo.${isEconomical ? 'economical' : 'qualified'}`, { ns: 'datasetCreation' }) as string} valueIcon={( - = React.memo(({ label={t('form.retrievalSetting.title', { ns: 'datasetSettings' })} displayedValue={t(`retrieval.${isEconomical ? 'keyword_search' : retrievalMethod ?? 'semantic_search'}.title`, { ns: 'dataset' })} valueIcon={( - { />, ) - // The TopKItem should render an input - const inputs = screen.getAllByRole('spinbutton') + // The TopKItem renders the visible number-field input as a textbox. + const inputs = screen.getAllByRole('textbox') const topKInput = inputs[0] fireEvent.change(topKInput, { target: { value: '8' } }) @@ -924,8 +924,8 @@ describe('ExternalKnowledgeBaseCreate', () => { />, ) - // The ScoreThresholdItem should render an input - const inputs = screen.getAllByRole('spinbutton') + // The ScoreThresholdItem renders the visible number-field input as a textbox. + const inputs = screen.getAllByRole('textbox') const scoreThresholdInput = inputs[1] fireEvent.change(scoreThresholdInput, { target: { value: '0.8' } }) diff --git a/web/app/components/datasets/hit-testing/components/query-input/index.tsx b/web/app/components/datasets/hit-testing/components/query-input/index.tsx index b634859dcf..ebe8581285 100644 --- a/web/app/components/datasets/hit-testing/components/query-input/index.tsx +++ b/web/app/components/datasets/hit-testing/components/query-input/index.tsx @@ -14,7 +14,6 @@ import { RiEqualizer2Line, RiPlayCircleLine, } from '@remixicon/react' -import Image from 'next/image' import * as React from 'react' import { useCallback, useMemo, useState } from 'react' import { useTranslation } from 'react-i18next' @@ -178,7 +177,7 @@ const QueryInput = ({ }, [text, externalRetrievalSettings, externalKnowledgeBaseHitTestingMutation, onUpdateList, setExternalHitResult]) const retrievalMethod = isEconomy ? RETRIEVE_METHOD.keywordSearch : retrievalConfig.search_method - const icon = + const icon = const TextAreaComp = useMemo(() => { return (