Merge branch 'main' into feat/evaluation-fe

This commit is contained in:
JzoNg 2026-03-17 10:42:44 +08:00
commit 4d3738d225
232 changed files with 4007 additions and 2985 deletions

View File

@ -63,8 +63,9 @@ jobs:
if: needs.check-changes.outputs.web-changed == 'true' if: needs.check-changes.outputs.web-changed == 'true'
uses: ./.github/workflows/web-tests.yml uses: ./.github/workflows/web-tests.yml
with: with:
base_sha: ${{ github.event_name == 'pull_request' && github.event.pull_request.base.sha || github.event.before }} base_sha: ${{ github.event.before || github.event.pull_request.base.sha }}
head_sha: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.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: style-check:
name: Style Check name: Style Check

View File

@ -6,6 +6,9 @@ on:
base_sha: base_sha:
required: false required: false
type: string type: string
diff_range_mode:
required: false
type: string
head_sha: head_sha:
required: false required: false
type: string type: string
@ -86,13 +89,24 @@ jobs:
- name: Merge reports - name: Merge reports
run: vp test --merge-reports --reporter=json --reporter=agent --coverage 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: env:
BASE_SHA: ${{ inputs.base_sha }} 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 }} HEAD_SHA: ${{ inputs.head_sha }}
run: node ./scripts/check-components-diff-coverage.mjs run: node ./scripts/check-components-diff-coverage.mjs
- name: Coverage Summary - name: Check Coverage Summary
if: always() if: always()
id: coverage-summary id: coverage-summary
run: | run: |
@ -101,313 +115,15 @@ jobs:
COVERAGE_FILE="coverage/coverage-final.json" COVERAGE_FILE="coverage/coverage-final.json"
COVERAGE_SUMMARY_FILE="coverage/coverage-summary.json" COVERAGE_SUMMARY_FILE="coverage/coverage-summary.json"
if [ ! -f "$COVERAGE_FILE" ] && [ ! -f "$COVERAGE_SUMMARY_FILE" ]; then if [ -f "$COVERAGE_FILE" ] || [ -f "$COVERAGE_SUMMARY_FILE" ]; then
echo "has_coverage=false" >> "$GITHUB_OUTPUT" echo "has_coverage=true" >> "$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"
exit 0 exit 0
fi fi
echo "has_coverage=true" >> "$GITHUB_OUTPUT" echo "has_coverage=false" >> "$GITHUB_OUTPUT"
echo "### 🚨 app/components Diff Coverage" >> "$GITHUB_STEP_SUMMARY"
node <<'NODE' >> "$GITHUB_STEP_SUMMARY" echo "" >> "$GITHUB_STEP_SUMMARY"
const fs = require('fs'); echo "Coverage artifacts not found. Ensure Vitest merge reports ran with coverage enabled." >> "$GITHUB_STEP_SUMMARY"
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('<details><summary>File coverage (lowest lines first)</summary>');
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('</details>');
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('<details><summary>Vitest coverage table</summary>');
console.log('');
console.log(headerRow);
console.log(dividerRow);
rowsForOutput.forEach((row) => console.log(formatRow(row)));
console.log('</details>');
}
NODE
- name: Upload Coverage Artifact - name: Upload Coverage Artifact
if: steps.coverage-summary.outputs.has_coverage == 'true' if: steps.coverage-summary.outputs.has_coverage == 'true'

View File

@ -737,24 +737,25 @@ SANDBOX_EXPIRED_RECORDS_RETENTION_DAYS=30
SANDBOX_EXPIRED_RECORDS_CLEAN_TASK_LOCK_TTL=90000 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 # celery worker
# defaults to url constructed from `REDIS_*` # defaults to url constructed from `REDIS_*`
# configurations # configurations
PUBSUB_REDIS_URL= EVENT_BUS_REDIS_URL=
# Pub/sub channel type for streaming events. # Event transport type. Options are:
# valid options are:
# #
# - pubsub: for normal Pub/Sub # - pubsub: normal Pub/Sub (at-most-once)
# - sharded: for sharded Pub/Sub # - 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 # Note: Before enabling 'streams' in production, estimate your expected event volume and retention needs.
# for large deployments. # Configure Redis memory limits and stream trimming appropriately (e.g., MAXLEN and key expiry) to reduce
PUBSUB_REDIS_CHANNEL_TYPE=pubsub # the risk of data loss from Redis auto-eviction under memory pressure.
# Whether to use Redis cluster mode while running # Also accepts ENV: EVENT_BUS_REDIS_CHANNEL_TYPE.
# PubSub. 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. # 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 # Whether to Enable human input timeout check task
ENABLE_HUMAN_INPUT_TIMEOUT_TASK=true ENABLE_HUMAN_INPUT_TIMEOUT_TASK=true

View File

@ -41,10 +41,10 @@ class RedisPubSubConfig(BaseSettings, RedisConfigDefaultsMixin):
) )
PUBSUB_REDIS_USE_CLUSTERS: bool = Field( 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=( description=(
"Enable Redis Cluster mode for pub/sub or streams transport. Recommended for large deployments. " "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, default=False,
) )

View File

@ -5,6 +5,7 @@ import re
import threading import threading
import time import time
import uuid import uuid
from collections.abc import Mapping
from typing import Any from typing import Any
from flask import Flask, current_app from flask import Flask, current_app
@ -37,7 +38,7 @@ from extensions.ext_storage import storage
from libs import helper from libs import helper
from libs.datetime_utils import naive_utc_now from libs.datetime_utils import naive_utc_now
from models import Account 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.dataset import Document as DatasetDocument
from models.model import UploadFile from models.model import UploadFile
from services.feature_service import FeatureService from services.feature_service import FeatureService
@ -265,7 +266,7 @@ class IndexingRunner:
self, self,
tenant_id: str, tenant_id: str,
extract_settings: list[ExtractSetting], extract_settings: list[ExtractSetting],
tmp_processing_rule: dict, tmp_processing_rule: Mapping[str, Any],
doc_form: str | None = None, doc_form: str | None = None,
doc_language: str = "English", doc_language: str = "English",
dataset_id: str | None = None, dataset_id: str | None = None,
@ -376,7 +377,7 @@ class IndexingRunner:
return IndexingEstimate(total_segments=total_segments, preview=preview_texts) return IndexingEstimate(total_segments=total_segments, preview=preview_texts)
def _extract( 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]: ) -> list[Document]:
data_source_info = dataset_document.data_source_info_dict data_source_info = dataset_document.data_source_info_dict
text_docs = [] text_docs = []
@ -543,6 +544,7 @@ class IndexingRunner:
""" """
Clean the document text according to the processing rules. Clean the document text according to the processing rules.
""" """
rules: AutomaticRulesConfig | dict[str, Any]
if processing_rule.mode == "automatic": if processing_rule.mode == "automatic":
rules = DatasetProcessRule.AUTOMATIC_RULES rules = DatasetProcessRule.AUTOMATIC_RULES
else: else:
@ -756,7 +758,7 @@ class IndexingRunner:
dataset: Dataset, dataset: Dataset,
text_docs: list[Document], text_docs: list[Document],
doc_language: str, doc_language: str,
process_rule: dict, process_rule: Mapping[str, Any],
current_user: Account | None = None, current_user: Account | None = None,
) -> list[Document]: ) -> list[Document]:
# get embedding model instance # get embedding model instance

View File

@ -55,15 +55,31 @@ def build_protected_resource_metadata_discovery_urls(
""" """
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 # First priority: URL from WWW-Authenticate header
if www_auth_resource_metadata_url: 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 # 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) # Priority 2: With path insertion (e.g., /.well-known/oauth-protected-resource/public/mcp)
if path: if path:
path_url = f"{base_url}/.well-known/oauth-protected-resource{path}" path_url = f"{base_url}/.well-known/oauth-protected-resource{path}"

View File

@ -6,6 +6,5 @@ of responses based on upstream node outputs and constants.
""" """
from .coordinator import ResponseStreamCoordinator from .coordinator import ResponseStreamCoordinator
from .session import RESPONSE_SESSION_NODE_TYPES
__all__ = ["RESPONSE_SESSION_NODE_TYPES", "ResponseStreamCoordinator"] __all__ = ["ResponseStreamCoordinator"]

View File

@ -3,10 +3,6 @@ Internal response session management for response coordinator.
This module contains the private ResponseSession class used internally This module contains the private ResponseSession class used internally
by ResponseStreamCoordinator to manage streaming sessions. 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 from __future__ import annotations
@ -14,7 +10,6 @@ from __future__ import annotations
from dataclasses import dataclass from dataclasses import dataclass
from typing import Protocol, cast from typing import Protocol, cast
from dify_graph.enums import BuiltinNodeTypes, NodeType
from dify_graph.nodes.base.template import Template from dify_graph.nodes.base.template import Template
from dify_graph.runtime.graph_runtime_state import NodeProtocol from dify_graph.runtime.graph_runtime_state import NodeProtocol
@ -25,12 +20,6 @@ class _ResponseSessionNodeProtocol(NodeProtocol, Protocol):
def get_streaming_template(self) -> Template: ... def get_streaming_template(self) -> Template: ...
RESPONSE_SESSION_NODE_TYPES: list[NodeType] = [
BuiltinNodeTypes.ANSWER,
BuiltinNodeTypes.END,
]
@dataclass @dataclass
class ResponseSession: class ResponseSession:
""" """
@ -49,8 +38,8 @@ class ResponseSession:
Create a ResponseSession from a response-capable node. 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. 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` At runtime this must be a node that implements `get_streaming_template()`. The coordinator decides which
and which implements `get_streaming_template()`. graph nodes should be treated as response-capable before they reach this factory.
Args: Args:
node: Node from the materialized workflow graph. node: Node from the materialized workflow graph.
@ -59,15 +48,8 @@ class ResponseSession:
ResponseSession configured with the node's streaming template ResponseSession configured with the node's streaming template
Raises: 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) response_node = cast(_ResponseSessionNodeProtocol, node)
try: try:
template = response_node.get_streaming_template() template = response_node.get_streaming_template()

View File

@ -8,6 +8,8 @@ from collections.abc import Mapping, Sequence
from datetime import datetime, timedelta from datetime import datetime, timedelta
from typing import Annotated, Any, ClassVar, Literal, Self from typing import Annotated, Any, ClassVar, Literal, Self
import bleach
import markdown
from pydantic import BaseModel, Field, field_validator, model_validator from pydantic import BaseModel, Field, field_validator, model_validator
from dify_graph.entities.base_node_data import BaseNodeData from dify_graph.entities.base_node_data import BaseNodeData
@ -58,6 +60,39 @@ class EmailDeliveryConfig(BaseModel):
"""Configuration for email delivery method.""" """Configuration for email delivery method."""
URL_PLACEHOLDER: ClassVar[str] = "{{#url#}}" 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 recipients: EmailRecipients
@ -98,6 +133,43 @@ class EmailDeliveryConfig(BaseModel):
return templated_body return templated_body
return variable_pool.convert_template(templated_body).text 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): class _DeliveryMethodBase(BaseModel):
"""Base delivery method configuration.""" """Base delivery method configuration."""

View File

@ -10,7 +10,7 @@ import re
import time import time
from datetime import datetime from datetime import datetime
from json import JSONDecodeError from json import JSONDecodeError
from typing import Any, cast from typing import Any, TypedDict, cast
from uuid import uuid4 from uuid import uuid4
import sqlalchemy as sa import sqlalchemy as sa
@ -37,6 +37,61 @@ from .types import AdjustedJSON, BinaryData, EnumText, LongText, StringUUID, adj
logger = logging.getLogger(__name__) 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): class DatasetPermissionEnum(enum.StrEnum):
ONLY_ME = "only_me" ONLY_ME = "only_me"
ALL_TEAM = "all_team_members" ALL_TEAM = "all_team_members"
@ -334,7 +389,7 @@ class DatasetProcessRule(Base): # bug
MODES = ["automatic", "custom", "hierarchical"] MODES = ["automatic", "custom", "hierarchical"]
PRE_PROCESSING_RULES = ["remove_stopwords", "remove_extra_spaces", "remove_urls_emails"] PRE_PROCESSING_RULES = ["remove_stopwords", "remove_extra_spaces", "remove_urls_emails"]
AUTOMATIC_RULES: dict[str, Any] = { AUTOMATIC_RULES: AutomaticRulesConfig = {
"pre_processing_rules": [ "pre_processing_rules": [
{"id": "remove_extra_spaces", "enabled": True}, {"id": "remove_extra_spaces", "enabled": True},
{"id": "remove_urls_emails", "enabled": False}, {"id": "remove_urls_emails", "enabled": False},
@ -342,7 +397,7 @@ class DatasetProcessRule(Base): # bug
"segmentation": {"delimiter": "\n", "max_tokens": 500, "chunk_overlap": 50}, "segmentation": {"delimiter": "\n", "max_tokens": 500, "chunk_overlap": 50},
} }
def to_dict(self) -> dict[str, Any]: def to_dict(self) -> ProcessRuleDict:
return { return {
"id": self.id, "id": self.id,
"dataset_id": self.dataset_id, "dataset_id": self.dataset_id,
@ -531,7 +586,7 @@ class Document(Base):
return self.updated_at return self.updated_at
@property @property
def doc_metadata_details(self) -> list[dict[str, Any]] | None: def doc_metadata_details(self) -> list[DocMetadataDetailItem] | None:
if self.doc_metadata: if self.doc_metadata:
document_metadatas = ( document_metadatas = (
db.session.query(DatasetMetadata) db.session.query(DatasetMetadata)
@ -541,9 +596,9 @@ class Document(Base):
) )
.all() .all()
) )
metadata_list: list[dict[str, Any]] = [] metadata_list: list[DocMetadataDetailItem] = []
for metadata in document_metadatas: for metadata in document_metadatas:
metadata_dict: dict[str, Any] = { metadata_dict: DocMetadataDetailItem = {
"id": metadata.id, "id": metadata.id,
"name": metadata.name, "name": metadata.name,
"type": metadata.type, "type": metadata.type,
@ -557,13 +612,13 @@ class Document(Base):
return None return None
@property @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: if self.dataset_process_rule_id and self.dataset_process_rule:
return self.dataset_process_rule.to_dict() return self.dataset_process_rule.to_dict()
return None return None
def get_built_in_fields(self) -> list[dict[str, Any]]: def get_built_in_fields(self) -> list[DocMetadataDetailItem]:
built_in_fields: list[dict[str, Any]] = [] built_in_fields: list[DocMetadataDetailItem] = []
built_in_fields.append( built_in_fields.append(
{ {
"id": "built-in", "id": "built-in",
@ -877,7 +932,7 @@ class DocumentSegment(Base):
return text return text
@property @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 # Use JOIN to fetch attachments in a single query instead of two separate queries
attachments_with_bindings = db.session.execute( attachments_with_bindings = db.session.execute(
select(SegmentAttachmentBinding, UploadFile) select(SegmentAttachmentBinding, UploadFile)
@ -891,7 +946,7 @@ class DocumentSegment(Base):
).all() ).all()
if not attachments_with_bindings: if not attachments_with_bindings:
return [] return []
attachment_list = [] attachment_list: list[AttachmentItem] = []
for _, attachment in attachments_with_bindings: for _, attachment in attachments_with_bindings:
upload_file_id = attachment.id upload_file_id = attachment.id
nonce = os.urandom(16).hex() 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 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 { return {
"id": self.id, "id": self.id,
"tenant_id": self.tenant_id, "tenant_id": self.tenant_id,
@ -1281,13 +1336,13 @@ class ExternalKnowledgeApis(TypeBase):
return None return None
@property @property
def dataset_bindings(self) -> list[dict[str, Any]]: def dataset_bindings(self) -> list[DatasetBindingItem]:
external_knowledge_bindings = db.session.scalars( external_knowledge_bindings = db.session.scalars(
select(ExternalKnowledgeBindings).where(ExternalKnowledgeBindings.external_knowledge_api_id == self.id) select(ExternalKnowledgeBindings).where(ExternalKnowledgeBindings.external_knowledge_api_id == self.id)
).all() ).all()
dataset_ids = [binding.dataset_id for binding in external_knowledge_bindings] dataset_ids = [binding.dataset_id for binding in external_knowledge_bindings]
datasets = db.session.scalars(select(Dataset).where(Dataset.id.in_(dataset_ids))).all() 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: for dataset in datasets:
dataset_bindings.append({"id": dataset.id, "name": dataset.name}) dataset_bindings.append({"id": dataset.id, "name": dataset.name})

View File

@ -40,7 +40,7 @@ dependencies = [
"numpy~=1.26.4", "numpy~=1.26.4",
"openpyxl~=3.1.5", "openpyxl~=3.1.5",
"opik~=1.10.37", "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-api==1.28.0",
"opentelemetry-distro==0.49b0", "opentelemetry-distro==0.49b0",
"opentelemetry-exporter-otlp==1.28.0", "opentelemetry-exporter-otlp==1.28.0",
@ -91,6 +91,7 @@ dependencies = [
"apscheduler>=3.11.0", "apscheduler>=3.11.0",
"weave>=0.52.16", "weave>=0.52.16",
"fastopenapi[flask]>=0.7.0", "fastopenapi[flask]>=0.7.0",
"bleach~=6.2.0",
] ]
# Before adding new dependency, consider place it in # Before adding new dependency, consider place it in
# alphabet order (a-z) and suitable group. # alphabet order (a-z) and suitable group.
@ -118,7 +119,7 @@ dev = [
"pytest~=9.0.2", "pytest~=9.0.2",
"pytest-benchmark~=5.2.3", "pytest-benchmark~=5.2.3",
"pytest-cov~=7.0.0", "pytest-cov~=7.0.0",
"pytest-env~=1.1.3", "pytest-env~=1.6.0",
"pytest-mock~=3.15.1", "pytest-mock~=3.15.1",
"testcontainers~=4.14.1", "testcontainers~=4.14.1",
"types-aiofiles~=25.1.0", "types-aiofiles~=25.1.0",
@ -251,10 +252,7 @@ ignore_errors = true
[tool.pyrefly] [tool.pyrefly]
project-includes = ["."] project-includes = ["."]
project-excludes = [ project-excludes = [".venv", "migrations/"]
".venv",
"migrations/",
]
python-platform = "linux" python-platform = "linux"
python-version = "3.11.0" python-version = "3.11.0"
infer-with-first-use = false infer-with-first-use = false

View File

@ -155,13 +155,15 @@ class EmailDeliveryTestHandler:
context=context, context=context,
recipient_email=recipient_email, 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( templated_body = EmailDeliveryConfig.render_body_template(
body=method.config.body, body=method.config.body,
url=substitutions.get("form_link"), url=substitutions.get("form_link"),
variable_pool=context.variable_pool, variable_pool=context.variable_pool,
) )
body = render_email_template(templated_body, substitutions) body = render_email_template(templated_body, substitutions)
body = EmailDeliveryConfig.render_markdown_body(body)
mail.send( mail.send(
to=recipient_email, to=recipient_email,

View File

@ -156,7 +156,8 @@ class VectorService:
) )
# use full doc mode to generate segment's child chunk # use full doc mode to generate segment's child chunk
processing_rule_dict = processing_rule.to_dict() 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( documents = index_processor.transform(
[document], [document],
embedding_model_instance=embedding_model_instance, embedding_model_instance=embedding_model_instance,

View File

@ -111,7 +111,7 @@ def _render_body(
url=form_link, url=form_link,
variable_pool=variable_pool, variable_pool=variable_pool,
) )
return body return EmailDeliveryConfig.render_markdown_body(body)
def _load_variable_pool(workflow_run_id: str | None) -> VariablePool | None: 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: for recipient in job.recipients:
form_link = _build_form_link(recipient.token) form_link = _build_form_link(recipient.token)
body = _render_body(job.body, form_link, variable_pool=variable_pool) body = _render_body(job.body, form_link, variable_pool=variable_pool)
subject = EmailDeliveryConfig.sanitize_subject(job.subject)
mail.send( mail.send(
to=recipient.email, to=recipient.email,
subject=job.subject, subject=subject,
html=body, html=body,
) )

View File

@ -186,7 +186,7 @@ class DifyTestContainers:
# Start Dify Plugin Daemon container for plugin management # Start Dify Plugin Daemon container for plugin management
# Dify Plugin Daemon provides plugin lifecycle management and execution # Dify Plugin Daemon provides plugin lifecycle management and execution
logger.info("Initializing Dify Plugin Daemon container...") 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.network
) )
self.dify_plugin_daemon.with_exposed_ports(5002) self.dify_plugin_daemon.with_exposed_ports(5002)

View File

@ -22,7 +22,7 @@ from controllers.console.extension import (
) )
if _NEEDS_METHOD_VIEW_CLEANUP: if _NEEDS_METHOD_VIEW_CLEANUP:
delattr(builtins, "MethodView") del builtins.MethodView
from models.account import AccountStatus from models.account import AccountStatus
from models.api_based_extension import APIBasedExtension from models.api_based_extension import APIBasedExtension

View File

@ -801,6 +801,27 @@ class TestAuthOrchestration:
urls = build_protected_resource_metadata_discovery_urls(None, "https://api.example.com") urls = build_protected_resource_metadata_discovery_urls(None, "https://api.example.com")
assert urls == ["https://api.example.com/.well-known/oauth-protected-resource"] 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): def test_build_oauth_authorization_server_metadata_discovery_urls(self):
# Case 1: with auth_server_url # Case 1: with auth_server_url
urls = build_oauth_authorization_server_metadata_discovery_urls( urls = build_oauth_authorization_server_metadata_discovery_urls(

View File

@ -4,9 +4,7 @@ from __future__ import annotations
import pytest 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.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.graph_engine.response_coordinator.session import ResponseSession
from dify_graph.nodes.base.template import Template, TextSegment from dify_graph.nodes.base.template import Template, TextSegment
@ -35,28 +33,14 @@ class DummyNodeWithoutStreamingTemplate:
self.state = NodeState.UNKNOWN self.state = NodeState.UNKNOWN
def test_response_session_from_node_rejects_node_types_outside_allowlist() -> None: def test_response_session_from_node_accepts_nodes_outside_previous_allowlist() -> None:
"""Unsupported node types are rejected even if they expose a template.""" """Session creation depends on the streaming-template contract rather than node type."""
node = DummyResponseNode( node = DummyResponseNode(
node_id="llm-node", node_id="llm-node",
node_type=BuiltinNodeTypes.LLM, node_type=BuiltinNodeTypes.LLM,
template=Template(segments=[TextSegment(text="hello")]), 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) session = ResponseSession.from_node(node)
assert session.node_id == "llm-node" assert session.node_id == "llm-node"

View File

@ -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) result = config.render_body_template(body=config.body, url="https://example.com", variable_pool=variable_pool)
assert result == "Hello World https://example.com" 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 "<strong>Bold</strong>" in rendered
assert '<a href="https://example.com">link</a>' in rendered
def test_render_markdown_body_sanitizes_unsafe_html():
rendered = EmailDeliveryConfig.render_markdown_body(
'<script>alert("xss")</script><a href="javascript:alert(1)" onclick="alert(2)">Click</a>'
)
assert "<script" not in rendered
assert "<a" not in rendered
assert "onclick" not in rendered
assert "javascript:" not in rendered
assert "Click" in rendered
def test_render_markdown_body_sanitizes_markdown_link_with_javascript_href():
rendered = EmailDeliveryConfig.render_markdown_body("[bad](javascript:alert(1)) and [ok](https://example.com)")
assert "javascript:" not in rendered
assert "<a>bad</a>" in rendered
assert '<a href="https://example.com">ok</a>' in rendered
def test_render_markdown_body_does_not_allow_raw_html_tags():
rendered = EmailDeliveryConfig.render_markdown_body("<b>raw html</b> and **markdown**")
assert "<b>" not in rendered
assert "raw html" in rendered
assert "<strong>markdown</strong>" in rendered
def test_render_markdown_body_supports_table_syntax():
rendered = EmailDeliveryConfig.render_markdown_body("| h1 | h2 |\n| --- | ---: |\n| v1 | v2 |")
assert "<table>" in rendered
assert "<thead>" in rendered
assert "<tbody>" 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("<b>Alert</b><img src=x onerror=1>")
assert "<" not in sanitized
assert ">" not in sanitized
assert sanitized == "Alert"

View File

@ -140,7 +140,7 @@ class TestLoginRequired:
# Remove ensure_sync to simulate Flask 1.x # Remove ensure_sync to simulate Flask 1.x
if hasattr(setup_app, "ensure_sync"): if hasattr(setup_app, "ensure_sync"):
delattr(setup_app, "ensure_sync") del setup_app.ensure_sync
with setup_app.test_request_context(): with setup_app.test_request_context():
mock_user = MockUser("test_user", is_authenticated=True) mock_user = MockUser("test_user", is_authenticated=True)

View File

@ -207,6 +207,45 @@ class TestEmailDeliveryTestHandler:
assert kwargs["to"] == "test@example.com" assert kwargs["to"] == "test@example.com"
assert "RENDERED_Subj" in kwargs["subject"] 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="<b>Notice</b>\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): def test_resolve_recipients(self):
handler = EmailDeliveryTestHandler(session_factory=MagicMock()) handler = EmailDeliveryTestHandler(session_factory=MagicMock())

View File

@ -120,4 +120,37 @@ def test_dispatch_human_input_email_task_replaces_body_variables(monkeypatch: py
session_factory=lambda: _DummySession(form), session_factory=lambda: _DummySession(form),
) )
assert mail.sent[0]["html"] == "Body OK" assert mail.sent[0]["html"] == "<p>Body OK</p>"
@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 <b>Alert</b>",
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"

177
api/uv.lock generated
View File

@ -457,14 +457,14 @@ wheels = [
[[package]] [[package]]
name = "authlib" name = "authlib"
version = "1.6.7" version = "1.6.9"
source = { registry = "https://pypi.org/simple" } source = { registry = "https://pypi.org/simple" }
dependencies = [ dependencies = [
{ name = "cryptography" }, { 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 = [ 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]] [[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" }, { 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]] [[package]]
name = "blinker" name = "blinker"
version = "1.9.0" version = "1.9.0"
@ -708,16 +720,16 @@ wheels = [
[[package]] [[package]]
name = "boto3-stubs" name = "boto3-stubs"
version = "1.41.3" version = "1.42.68"
source = { registry = "https://pypi.org/simple" } source = { registry = "https://pypi.org/simple" }
dependencies = [ dependencies = [
{ name = "botocore-stubs" }, { name = "botocore-stubs" },
{ name = "types-s3transfer" }, { name = "types-s3transfer" },
{ name = "typing-extensions", marker = "python_full_version < '3.12'" }, { 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 = [ 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] [package.optional-dependencies]
@ -1529,6 +1541,7 @@ dependencies = [
{ name = "arize-phoenix-otel" }, { name = "arize-phoenix-otel" },
{ name = "azure-identity" }, { name = "azure-identity" },
{ name = "beautifulsoup4" }, { name = "beautifulsoup4" },
{ name = "bleach" },
{ name = "boto3" }, { name = "boto3" },
{ name = "bs4" }, { name = "bs4" },
{ name = "cachetools" }, { name = "cachetools" },
@ -1730,6 +1743,7 @@ requires-dist = [
{ name = "arize-phoenix-otel", specifier = "~=0.15.0" }, { name = "arize-phoenix-otel", specifier = "~=0.15.0" },
{ name = "azure-identity", specifier = "==1.25.3" }, { name = "azure-identity", specifier = "==1.25.3" },
{ name = "beautifulsoup4", specifier = "==4.14.3" }, { name = "beautifulsoup4", specifier = "==4.14.3" },
{ name = "bleach", specifier = "~=6.2.0" },
{ name = "boto3", specifier = "==1.42.68" }, { name = "boto3", specifier = "==1.42.68" },
{ name = "bs4", specifier = "~=0.0.1" }, { name = "bs4", specifier = "~=0.0.1" },
{ name = "cachetools", specifier = "~=5.3.0" }, { name = "cachetools", specifier = "~=5.3.0" },
@ -1831,7 +1845,7 @@ dev = [
{ name = "pytest", specifier = "~=9.0.2" }, { name = "pytest", specifier = "~=9.0.2" },
{ name = "pytest-benchmark", specifier = "~=5.2.3" }, { name = "pytest-benchmark", specifier = "~=5.2.3" },
{ name = "pytest-cov", specifier = "~=7.0.0" }, { 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-mock", specifier = "~=3.15.1" },
{ name = "pytest-timeout", specifier = ">=2.4.0" }, { name = "pytest-timeout", specifier = ">=2.4.0" },
{ name = "pytest-xdist", specifier = ">=3.8.0" }, { name = "pytest-xdist", specifier = ">=3.8.0" },
@ -3143,14 +3157,14 @@ wheels = [
[[package]] [[package]]
name = "hypothesis" name = "hypothesis"
version = "6.148.2" version = "6.151.9"
source = { registry = "https://pypi.org/simple" } source = { registry = "https://pypi.org/simple" }
dependencies = [ dependencies = [
{ name = "sortedcontainers" }, { 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 = [ 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]] [[package]]
@ -3164,19 +3178,17 @@ wheels = [
[[package]] [[package]]
name = "import-linter" name = "import-linter"
version = "2.10" version = "2.11"
source = { registry = "https://pypi.org/simple" } source = { registry = "https://pypi.org/simple" }
dependencies = [ dependencies = [
{ name = "click" }, { name = "click" },
{ name = "fastapi" },
{ name = "grimp" }, { name = "grimp" },
{ name = "rich" }, { name = "rich" },
{ name = "typing-extensions" }, { 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 = [ 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]] [[package]]
@ -3918,14 +3930,14 @@ wheels = [
[[package]] [[package]]
name = "mypy-boto3-bedrock-runtime" name = "mypy-boto3-bedrock-runtime"
version = "1.41.2" version = "1.42.42"
source = { registry = "https://pypi.org/simple" } source = { registry = "https://pypi.org/simple" }
dependencies = [ dependencies = [
{ name = "typing-extensions", marker = "python_full_version < '3.12'" }, { 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 = [ 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]] [[package]]
@ -5514,14 +5526,15 @@ wheels = [
[[package]] [[package]]
name = "pytest-env" name = "pytest-env"
version = "1.1.5" version = "1.6.0"
source = { registry = "https://pypi.org/simple" } source = { registry = "https://pypi.org/simple" }
dependencies = [ dependencies = [
{ name = "pytest" }, { 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 = [ 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]] [[package]]
@ -6033,27 +6046,27 @@ wheels = [
[[package]] [[package]]
name = "ruff" name = "ruff"
version = "0.15.5" version = "0.15.6"
source = { registry = "https://pypi.org/simple" } 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 = [ 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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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]] [[package]]
@ -6092,14 +6105,14 @@ wheels = [
[[package]] [[package]]
name = "scipy-stubs" name = "scipy-stubs"
version = "1.16.3.1" version = "1.17.1.2"
source = { registry = "https://pypi.org/simple" } source = { registry = "https://pypi.org/simple" }
dependencies = [ dependencies = [
{ name = "optype", extra = ["numpy"] }, { 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 = [ 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]] [[package]]
@ -6788,14 +6801,14 @@ wheels = [
[[package]] [[package]]
name = "types-cffi" name = "types-cffi"
version = "1.17.0.20250915" version = "2.0.0.20260316"
source = { registry = "https://pypi.org/simple" } source = { registry = "https://pypi.org/simple" }
dependencies = [ dependencies = [
{ name = "types-setuptools" }, { 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 = [ 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]] [[package]]
@ -6827,11 +6840,11 @@ wheels = [
[[package]] [[package]]
name = "types-docutils" name = "types-docutils"
version = "0.22.3.20260223" version = "0.22.3.20260316"
source = { registry = "https://pypi.org/simple" } 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 = [ 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]] [[package]]
@ -6861,15 +6874,15 @@ wheels = [
[[package]] [[package]]
name = "types-gevent" name = "types-gevent"
version = "25.9.0.20251102" version = "25.9.0.20251228"
source = { registry = "https://pypi.org/simple" } source = { registry = "https://pypi.org/simple" }
dependencies = [ dependencies = [
{ name = "types-greenlet" }, { name = "types-greenlet" },
{ name = "types-psutil" }, { 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 = [ 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]] [[package]]
@ -6895,11 +6908,11 @@ wheels = [
[[package]] [[package]]
name = "types-jmespath" name = "types-jmespath"
version = "1.0.2.20250809" version = "1.1.0.20260124"
source = { registry = "https://pypi.org/simple" } 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 = [ 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]] [[package]]
@ -6952,20 +6965,20 @@ wheels = [
[[package]] [[package]]
name = "types-openpyxl" name = "types-openpyxl"
version = "3.1.5.20250919" version = "3.1.5.20260316"
source = { registry = "https://pypi.org/simple" } 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 = [ 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]] [[package]]
name = "types-pexpect" name = "types-pexpect"
version = "4.9.0.20250916" version = "4.9.0.20260127"
source = { registry = "https://pypi.org/simple" } 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 = [ 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]] [[package]]
@ -6988,11 +7001,11 @@ wheels = [
[[package]] [[package]]
name = "types-psycopg2" name = "types-psycopg2"
version = "2.9.21.20251012" version = "2.9.21.20260223"
source = { registry = "https://pypi.org/simple" } 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 = [ 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]] [[package]]
@ -7009,11 +7022,11 @@ wheels = [
[[package]] [[package]]
name = "types-pymysql" name = "types-pymysql"
version = "1.1.0.20250916" version = "1.1.0.20251220"
source = { registry = "https://pypi.org/simple" } 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 = [ 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]] [[package]]
@ -7031,11 +7044,11 @@ wheels = [
[[package]] [[package]]
name = "types-python-dateutil" name = "types-python-dateutil"
version = "2.9.0.20251115" version = "2.9.0.20260305"
source = { registry = "https://pypi.org/simple" } 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 = [ 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]] [[package]]
@ -7049,11 +7062,11 @@ wheels = [
[[package]] [[package]]
name = "types-pywin32" name = "types-pywin32"
version = "311.0.0.20251008" version = "311.0.0.20260316"
source = { registry = "https://pypi.org/simple" } 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 = [ 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]] [[package]]
@ -7110,11 +7123,11 @@ wheels = [
[[package]] [[package]]
name = "types-setuptools" name = "types-setuptools"
version = "80.9.0.20250822" version = "82.0.0.20260210"
source = { registry = "https://pypi.org/simple" } 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 = [ 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]] [[package]]
@ -7149,28 +7162,28 @@ wheels = [
[[package]] [[package]]
name = "types-tensorflow" name = "types-tensorflow"
version = "2.18.0.20251008" version = "2.18.0.20260224"
source = { registry = "https://pypi.org/simple" } source = { registry = "https://pypi.org/simple" }
dependencies = [ dependencies = [
{ name = "numpy" }, { name = "numpy" },
{ name = "types-protobuf" }, { name = "types-protobuf" },
{ name = "types-requests" }, { 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 = [ 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]] [[package]]
name = "types-tqdm" name = "types-tqdm"
version = "4.67.0.20250809" version = "4.67.3.20260303"
source = { registry = "https://pypi.org/simple" } source = { registry = "https://pypi.org/simple" }
dependencies = [ dependencies = [
{ name = "types-requests" }, { 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 = [ 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]] [[package]]

View File

@ -1546,24 +1546,25 @@ SANDBOX_EXPIRED_RECORDS_CLEAN_BATCH_MAX_INTERVAL=200
SANDBOX_EXPIRED_RECORDS_RETENTION_DAYS=30 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 # celery worker
# defaults to url constructed from `REDIS_*` # defaults to url constructed from `REDIS_*`
# configurations # configurations
PUBSUB_REDIS_URL= EVENT_BUS_REDIS_URL=
# Pub/sub channel type for streaming events. # Event transport type. Options are:
# valid options are:
# #
# - pubsub: for normal Pub/Sub # - pubsub: normal Pub/Sub (at-most-once)
# - sharded: for sharded Pub/Sub # - 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 # Note: Before enabling 'streams' in production, estimate your expected event volume and retention needs.
# for large deployments. # Configure Redis memory limits and stream trimming appropriately (e.g., MAXLEN and key expiry) to reduce
PUBSUB_REDIS_CHANNEL_TYPE=pubsub # the risk of data loss from Redis auto-eviction under memory pressure.
# Whether to use Redis cluster mode while running # Also accepts ENV: EVENT_BUS_REDIS_CHANNEL_TYPE.
# PubSub. 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. # 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 # Whether to Enable human input timeout check task
ENABLE_HUMAN_INPUT_TIMEOUT_TASK=true ENABLE_HUMAN_INPUT_TIMEOUT_TASK=true

View File

@ -269,7 +269,7 @@ services:
# plugin daemon # plugin daemon
plugin_daemon: plugin_daemon:
image: langgenius/dify-plugin-daemon:0.5.3-local image: langgenius/dify-plugin-daemon:0.5.4-local
restart: always restart: always
environment: environment:
# Use the shared environment variables. # Use the shared environment variables.

View File

@ -123,7 +123,7 @@ services:
# plugin daemon # plugin daemon
plugin_daemon: plugin_daemon:
image: langgenius/dify-plugin-daemon:0.5.3-local image: langgenius/dify-plugin-daemon:0.5.4-local
restart: always restart: always
env_file: env_file:
- ./middleware.env - ./middleware.env

View File

@ -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_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_CLEAN_BATCH_MAX_INTERVAL: ${SANDBOX_EXPIRED_RECORDS_CLEAN_BATCH_MAX_INTERVAL:-200}
SANDBOX_EXPIRED_RECORDS_RETENTION_DAYS: ${SANDBOX_EXPIRED_RECORDS_RETENTION_DAYS:-30} SANDBOX_EXPIRED_RECORDS_RETENTION_DAYS: ${SANDBOX_EXPIRED_RECORDS_RETENTION_DAYS:-30}
PUBSUB_REDIS_URL: ${PUBSUB_REDIS_URL:-} EVENT_BUS_REDIS_URL: ${EVENT_BUS_REDIS_URL:-}
PUBSUB_REDIS_CHANNEL_TYPE: ${PUBSUB_REDIS_CHANNEL_TYPE:-pubsub} EVENT_BUS_REDIS_CHANNEL_TYPE: ${EVENT_BUS_REDIS_CHANNEL_TYPE:-pubsub}
PUBSUB_REDIS_USE_CLUSTERS: ${PUBSUB_REDIS_USE_CLUSTERS:-false} EVENT_BUS_REDIS_USE_CLUSTERS: ${EVENT_BUS_REDIS_USE_CLUSTERS:-false}
ENABLE_HUMAN_INPUT_TIMEOUT_TASK: ${ENABLE_HUMAN_INPUT_TIMEOUT_TASK:-true} ENABLE_HUMAN_INPUT_TIMEOUT_TASK: ${ENABLE_HUMAN_INPUT_TIMEOUT_TASK:-true}
HUMAN_INPUT_TIMEOUT_TASK_INTERVAL: ${HUMAN_INPUT_TIMEOUT_TASK_INTERVAL:-1} 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} SANDBOX_EXPIRED_RECORDS_CLEAN_TASK_LOCK_TTL: ${SANDBOX_EXPIRED_RECORDS_CLEAN_TASK_LOCK_TTL:-90000}
@ -976,7 +976,7 @@ services:
# plugin daemon # plugin daemon
plugin_daemon: plugin_daemon:
image: langgenius/dify-plugin-daemon:0.5.3-local image: langgenius/dify-plugin-daemon:0.5.4-local
restart: always restart: always
environment: environment:
# Use the shared environment variables. # Use the shared environment variables.

View File

@ -1,4 +1,5 @@
import { import {
buildGitDiffRevisionArgs,
getChangedBranchCoverage, getChangedBranchCoverage,
getChangedStatementCoverage, getChangedStatementCoverage,
getIgnoredChangedLinesFromSource, getIgnoredChangedLinesFromSource,
@ -7,6 +8,11 @@ import {
} from '../scripts/check-components-diff-coverage-lib.mjs' } from '../scripts/check-components-diff-coverage-lib.mjs'
describe('check-components-diff-coverage helpers', () => { 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', () => { it('should parse changed line maps from unified diffs', () => {
const diff = [ const diff = [
'diff --git a/web/app/components/share/a.ts b/web/app/components/share/a.ts', '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({ expect(coverage).toEqual({
covered: 0, covered: 0,
total: 2, total: 1,
uncoveredBranches: [ uncoveredBranches: [
{ armIndex: 0, line: 33 }, { 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 }, { armIndex: 1, line: 35 },
], ],
}) })

View File

@ -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 },
})
})
})

View File

@ -218,7 +218,7 @@ describe('ParamConfigContent', () => {
}) })
render(<ParamConfigContent />) render(<ParamConfigContent />)
const input = screen.getByRole('spinbutton') as HTMLInputElement const input = screen.getByRole('textbox') as HTMLInputElement
fireEvent.change(input, { target: { value: '4' } }) fireEvent.change(input, { target: { value: '4' } })
const updatedFile = getLatestFileConfig() const updatedFile = getLatestFileConfig()

View File

@ -184,8 +184,8 @@ describe('dataset-config/params-config', () => {
await user.click(incrementButtons[0]) await user.click(incrementButtons[0])
await waitFor(() => { await waitFor(() => {
const [topKInput] = dialogScope.getAllByRole('spinbutton') const [topKInput] = dialogScope.getAllByRole('textbox')
expect(topKInput).toHaveValue(5) expect(topKInput).toHaveValue('5')
}) })
await user.click(dialogScope.getByRole('button', { name: 'common.operation.save' })) 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' })) await user.click(screen.getByRole('button', { name: 'dataset.retrievalSettings' }))
const reopenedDialog = await screen.findByRole('dialog', {}, { timeout: 3000 }) const reopenedDialog = await screen.findByRole('dialog', {}, { timeout: 3000 })
const reopenedScope = within(reopenedDialog) const reopenedScope = within(reopenedDialog)
const [reopenedTopKInput] = reopenedScope.getAllByRole('spinbutton') const [reopenedTopKInput] = reopenedScope.getAllByRole('textbox')
// Assert // Assert
expect(reopenedTopKInput).toHaveValue(5) expect(reopenedTopKInput).toHaveValue('5')
}) })
it('should discard changes when cancel is clicked', async () => { it('should discard changes when cancel is clicked', async () => {
@ -217,8 +217,8 @@ describe('dataset-config/params-config', () => {
await user.click(incrementButtons[0]) await user.click(incrementButtons[0])
await waitFor(() => { await waitFor(() => {
const [topKInput] = dialogScope.getAllByRole('spinbutton') const [topKInput] = dialogScope.getAllByRole('textbox')
expect(topKInput).toHaveValue(5) expect(topKInput).toHaveValue('5')
}) })
const cancelButton = await dialogScope.findByRole('button', { name: 'common.operation.cancel' }) 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' })) await user.click(screen.getByRole('button', { name: 'dataset.retrievalSettings' }))
const reopenedDialog = await screen.findByRole('dialog', {}, { timeout: 3000 }) const reopenedDialog = await screen.findByRole('dialog', {}, { timeout: 3000 })
const reopenedScope = within(reopenedDialog) const reopenedScope = within(reopenedDialog)
const [reopenedTopKInput] = reopenedScope.getAllByRole('spinbutton') const [reopenedTopKInput] = reopenedScope.getAllByRole('textbox')
// Assert // Assert
expect(reopenedTopKInput).toHaveValue(4) expect(reopenedTopKInput).toHaveValue('4')
}) })
it('should prevent saving when rerank model is required but invalid', async () => { it('should prevent saving when rerank model is required but invalid', async () => {

View File

@ -137,4 +137,31 @@ describe('SelectDataSet', () => {
expect(screen.getByRole('link', { name: 'appDebug.feature.dataSet.toCreate' })).toHaveAttribute('href', '/datasets/create') expect(screen.getByRole('link', { name: 'appDebug.feature.dataSet.toCreate' })).toHaveAttribute('href', '/datasets/create')
expect(screen.getByRole('button', { name: 'common.operation.add' })).toBeDisabled() 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(<SelectDataSet {...baseProps} onSelect={onSelect} selectedIds={['set-1']} />)
})
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])
})
}) })

View File

@ -4,7 +4,7 @@ import type { DataSet } from '@/models/datasets'
import { useInfiniteScroll } from 'ahooks' import { useInfiniteScroll } from 'ahooks'
import Link from 'next/link' import Link from 'next/link'
import * as React from 'react' import * as React from 'react'
import { useEffect, useMemo, useRef, useState } from 'react' import { useMemo, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import AppIcon from '@/app/components/base/app-icon' import AppIcon from '@/app/components/base/app-icon'
import Badge from '@/app/components/base/badge' import Badge from '@/app/components/base/badge'
@ -31,17 +31,21 @@ const SelectDataSet: FC<ISelectDataSetProps> = ({
onSelect, onSelect,
}) => { }) => {
const { t } = useTranslation() const { t } = useTranslation()
const [selected, setSelected] = useState<DataSet[]>([]) const [selectedIdsInModal, setSelectedIdsInModal] = useState(() => selectedIds)
const canSelectMulti = true const canSelectMulti = true
const { formatIndexingTechniqueAndMethod } = useKnowledge() const { formatIndexingTechniqueAndMethod } = useKnowledge()
const { data, isLoading, isFetchingNextPage, fetchNextPage, hasNextPage } = useInfiniteDatasets( const { data, isLoading, isFetchingNextPage, fetchNextPage, hasNextPage } = useInfiniteDatasets(
{ page: 1 }, { page: 1 },
{ enabled: isShow, staleTime: 0, refetchOnMount: 'always' }, { enabled: isShow, staleTime: 0, refetchOnMount: 'always' },
) )
const pages = data?.pages || []
const datasets = useMemo(() => { const datasets = useMemo(() => {
const pages = data?.pages || []
return pages.flatMap(page => page.data.filter(item => item.indexing_technique || item.provider === 'external')) 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 hasNoData = !isLoading && datasets.length === 0
const listRef = useRef<HTMLDivElement>(null) const listRef = useRef<HTMLDivElement>(null)
@ -61,50 +65,14 @@ const SelectDataSet: FC<ISelectDataSetProps> = ({
}, },
) )
const prevSelectedIdsRef = useRef<string[]>([])
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) => { const toggleSelect = (dataSet: DataSet) => {
hasUserModifiedSelectionRef.current = true setSelectedIdsInModal((prev) => {
const isSelected = selected.some(item => item.id === dataSet.id) const isSelected = prev.includes(dataSet.id)
if (isSelected) { if (isSelected)
setSelected(selected.filter(item => item.id !== dataSet.id)) return prev.filter(id => id !== dataSet.id)
}
else { return canSelectMulti ? [...prev, dataSet.id] : [dataSet.id]
if (canSelectMulti) })
setSelected([...selected, dataSet])
else
setSelected([dataSet])
}
} }
const handleSelect = () => { const handleSelect = () => {
@ -126,7 +94,7 @@ const SelectDataSet: FC<ISelectDataSetProps> = ({
{hasNoData && ( {hasNoData && (
<div <div
className="mt-6 flex h-[128px] items-center justify-center space-x-1 rounded-lg border text-[13px]" className="mt-6 flex h-[128px] items-center justify-center space-x-1 rounded-lg border text-[13px]"
style={{ style={{
background: 'rgba(0, 0, 0, 0.02)', background: 'rgba(0, 0, 0, 0.02)',
borderColor: 'rgba(0, 0, 0, 0.02', borderColor: 'rgba(0, 0, 0, 0.02',
@ -145,7 +113,7 @@ const SelectDataSet: FC<ISelectDataSetProps> = ({
key={item.id} key={item.id}
className={cn( 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', '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', !item.embedding_available && 'hover:border-components-panel-border-subtle hover:bg-components-panel-on-panel-item-bg hover:shadow-xs',
)} )}
onClick={() => { onClick={() => {
@ -195,7 +163,7 @@ const SelectDataSet: FC<ISelectDataSetProps> = ({
)} )}
{!isLoading && ( {!isLoading && (
<div className="mt-8 flex items-center justify-between"> <div className="mt-8 flex items-center justify-between">
<div className="text-sm font-medium text-text-secondary"> <div className="text-sm font-medium text-text-secondary">
{selected.length > 0 && `${selected.length} ${t('feature.dataSet.selected', { ns: 'appDebug' })}`} {selected.length > 0 && `${selected.length} ${t('feature.dataSet.selected', { ns: 'appDebug' })}`}
</div> </div>
<div className="flex space-x-2"> <div className="flex space-x-2">

View File

@ -4,7 +4,6 @@ import type { AppIconSelection } from '../../base/app-icon-picker'
import { RiArrowRightLine, RiArrowRightSLine, RiExchange2Fill } from '@remixicon/react' import { RiArrowRightLine, RiArrowRightSLine, RiExchange2Fill } from '@remixicon/react'
import { useDebounceFn, useKeyPress } from 'ahooks' import { useDebounceFn, useKeyPress } from 'ahooks'
import Image from 'next/image'
import { useRouter } from 'next/navigation' import { useRouter } from 'next/navigation'
import { useCallback, useEffect, useRef, useState } from 'react' import { useCallback, useEffect, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
@ -117,10 +116,10 @@ function CreateApp({ onClose, onSuccess, onCreateFromTemplate, defaultAppMode }:
<div className="px-10"> <div className="px-10">
<div className="h-6 w-full 2xl:h-[139px]" /> <div className="h-6 w-full 2xl:h-[139px]" />
<div className="pb-6 pt-1"> <div className="pb-6 pt-1">
<span className="title-2xl-semi-bold text-text-primary">{t('newApp.startFromBlank', { ns: 'app' })}</span> <span className="text-text-primary title-2xl-semi-bold">{t('newApp.startFromBlank', { ns: 'app' })}</span>
</div> </div>
<div className="mb-2 leading-6"> <div className="mb-2 leading-6">
<span className="system-sm-semibold text-text-secondary">{t('newApp.chooseAppType', { ns: 'app' })}</span> <span className="text-text-secondary system-sm-semibold">{t('newApp.chooseAppType', { ns: 'app' })}</span>
</div> </div>
<div className="flex w-[660px] flex-col gap-4"> <div className="flex w-[660px] flex-col gap-4">
<div> <div>
@ -160,7 +159,7 @@ function CreateApp({ onClose, onSuccess, onCreateFromTemplate, defaultAppMode }:
className="flex cursor-pointer items-center border-0 bg-transparent p-0" className="flex cursor-pointer items-center border-0 bg-transparent p-0"
onClick={() => setIsAppTypeExpanded(!isAppTypeExpanded)} onClick={() => setIsAppTypeExpanded(!isAppTypeExpanded)}
> >
<span className="system-2xs-medium-uppercase text-text-tertiary">{t('newApp.forBeginners', { ns: 'app' })}</span> <span className="text-text-tertiary system-2xs-medium-uppercase">{t('newApp.forBeginners', { ns: 'app' })}</span>
<RiArrowRightSLine className={`ml-1 h-4 w-4 text-text-tertiary transition-transform ${isAppTypeExpanded ? 'rotate-90' : ''}`} /> <RiArrowRightSLine className={`ml-1 h-4 w-4 text-text-tertiary transition-transform ${isAppTypeExpanded ? 'rotate-90' : ''}`} />
</button> </button>
</div> </div>
@ -212,7 +211,7 @@ function CreateApp({ onClose, onSuccess, onCreateFromTemplate, defaultAppMode }:
<div className="flex items-center space-x-3"> <div className="flex items-center space-x-3">
<div className="flex-1"> <div className="flex-1">
<div className="mb-1 flex h-6 items-center"> <div className="mb-1 flex h-6 items-center">
<label className="system-sm-semibold text-text-secondary">{t('newApp.captionName', { ns: 'app' })}</label> <label className="text-text-secondary system-sm-semibold">{t('newApp.captionName', { ns: 'app' })}</label>
</div> </div>
<Input <Input
value={name} value={name}
@ -243,8 +242,8 @@ function CreateApp({ onClose, onSuccess, onCreateFromTemplate, defaultAppMode }:
</div> </div>
<div> <div>
<div className="mb-1 flex h-6 items-center"> <div className="mb-1 flex h-6 items-center">
<label className="system-sm-semibold text-text-secondary">{t('newApp.captionDescription', { ns: 'app' })}</label> <label className="text-text-secondary system-sm-semibold">{t('newApp.captionDescription', { ns: 'app' })}</label>
<span className="system-xs-regular ml-1 text-text-tertiary"> <span className="ml-1 text-text-tertiary system-xs-regular">
( (
{t('newApp.optional', { ns: 'app' })} {t('newApp.optional', { ns: 'app' })}
) )
@ -260,7 +259,7 @@ function CreateApp({ onClose, onSuccess, onCreateFromTemplate, defaultAppMode }:
</div> </div>
{isAppsFull && <AppsFull className="mt-4" loc="app-create" />} {isAppsFull && <AppsFull className="mt-4" loc="app-create" />}
<div className="flex items-center justify-between pb-10 pt-5"> <div className="flex items-center justify-between pb-10 pt-5">
<div className="system-xs-regular flex cursor-pointer items-center gap-1 text-text-tertiary" onClick={onCreateFromTemplate}> <div className="flex cursor-pointer items-center gap-1 text-text-tertiary system-xs-regular" onClick={onCreateFromTemplate}>
<span>{t('newApp.noIdeaTip', { ns: 'app' })}</span> <span>{t('newApp.noIdeaTip', { ns: 'app' })}</span>
<div className="p-[1px]"> <div className="p-[1px]">
<RiArrowRightLine className="h-3.5 w-3.5" /> <RiArrowRightLine className="h-3.5 w-3.5" />
@ -334,8 +333,8 @@ function AppTypeCard({ icon, title, description, active, onClick }: AppTypeCardP
onClick={onClick} onClick={onClick}
> >
{icon} {icon}
<div className="system-sm-semibold mb-0.5 mt-2 text-text-secondary">{title}</div> <div className="mb-0.5 mt-2 text-text-secondary system-sm-semibold">{title}</div>
<div className="system-xs-regular line-clamp-2 text-text-tertiary" title={description}>{description}</div> <div className="line-clamp-2 text-text-tertiary system-xs-regular" title={description}>{description}</div>
</div> </div>
) )
} }
@ -367,8 +366,8 @@ function AppPreview({ mode }: { mode: AppModeEnum }) {
const previewInfo = modeToPreviewInfoMap[mode] const previewInfo = modeToPreviewInfoMap[mode]
return ( return (
<div className="px-8 py-4"> <div className="px-8 py-4">
<h4 className="system-sm-semibold-uppercase text-text-secondary">{previewInfo.title}</h4> <h4 className="text-text-secondary system-sm-semibold-uppercase">{previewInfo.title}</h4>
<div className="system-xs-regular mt-1 min-h-8 max-w-96 text-text-tertiary"> <div className="mt-1 min-h-8 max-w-96 text-text-tertiary system-xs-regular">
<span>{previewInfo.description}</span> <span>{previewInfo.description}</span>
</div> </div>
</div> </div>
@ -389,7 +388,7 @@ function AppScreenShot({ mode, show }: { mode: AppModeEnum, show: boolean }) {
<source media="(resolution: 1x)" srcSet={`${basePath}/screenshots/${theme}/${modeToImageMap[mode]}.png`} /> <source media="(resolution: 1x)" srcSet={`${basePath}/screenshots/${theme}/${modeToImageMap[mode]}.png`} />
<source media="(resolution: 2x)" srcSet={`${basePath}/screenshots/${theme}/${modeToImageMap[mode]}@2x.png`} /> <source media="(resolution: 2x)" srcSet={`${basePath}/screenshots/${theme}/${modeToImageMap[mode]}@2x.png`} />
<source media="(resolution: 3x)" srcSet={`${basePath}/screenshots/${theme}/${modeToImageMap[mode]}@3x.png`} /> <source media="(resolution: 3x)" srcSet={`${basePath}/screenshots/${theme}/${modeToImageMap[mode]}@3x.png`} />
<Image <img
className={show ? '' : 'hidden'} className={show ? '' : 'hidden'}
src={`${basePath}/screenshots/${theme}/${modeToImageMap[mode]}.png`} src={`${basePath}/screenshots/${theme}/${modeToImageMap[mode]}.png`}
alt="App Screen Shot" alt="App Screen Shot"

View File

@ -1,5 +1,3 @@
/* eslint-disable next/no-img-element */
import type { ImgHTMLAttributes } from 'react'
import type { EmbeddedChatbotContextValue } from '../../context' import type { EmbeddedChatbotContextValue } from '../../context'
import type { AppData } from '@/models/share' import type { AppData } from '@/models/share'
import type { SystemFeatures } from '@/types/feature' import type { SystemFeatures } from '@/types/feature'
@ -22,15 +20,6 @@ vi.mock('@/app/components/base/chat/embedded-chatbot/inputs-form/view-form-dropd
default: () => <div data-testid="view-form-dropdown" />, default: () => <div data-testid="view-form-dropdown" />,
})) }))
// Mock next/image to render a normal img tag for testing
vi.mock('next/image', () => ({
__esModule: true,
default: (props: ImgHTMLAttributes<HTMLImageElement> & { unoptimized?: boolean }) => {
const { unoptimized: _, ...rest } = props
return <img {...rest} />
},
}))
type GlobalPublicStoreMock = { type GlobalPublicStoreMock = {
systemFeatures: SystemFeatures systemFeatures: SystemFeatures
setSystemFeatures: (systemFeatures: SystemFeatures) => void setSystemFeatures: (systemFeatures: SystemFeatures) => void

View File

@ -1,13 +1,7 @@
/* eslint-disable next/no-img-element */
import type { ImgHTMLAttributes } from 'react'
import { render, screen } from '@testing-library/react' import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event' import userEvent from '@testing-library/user-event'
import CheckboxList from '..' import CheckboxList from '..'
vi.mock('next/image', () => ({
default: (props: ImgHTMLAttributes<HTMLImageElement>) => <img {...props} />,
}))
describe('checkbox list component', () => { describe('checkbox list component', () => {
const options = [ const options = [
{ label: 'Option 1', value: 'option1' }, { label: 'Option 1', value: 'option1' },

View File

@ -1,6 +1,5 @@
'use client' 'use client'
import type { FC } from 'react' import type { FC } from 'react'
import Image from 'next/image'
import { useCallback, useMemo, useState } from 'react' import { useCallback, useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import Badge from '@/app/components/base/badge' import Badge from '@/app/components/base/badge'
@ -169,7 +168,7 @@ const CheckboxList: FC<CheckboxListProps> = ({
{searchQuery {searchQuery
? ( ? (
<div className="flex flex-col items-center justify-center gap-2"> <div className="flex flex-col items-center justify-center gap-2">
<Image alt="search menu" src={SearchMenu} width={32} /> <img alt="search menu" src={SearchMenu.src} width={32} />
<span className="text-text-secondary system-sm-regular">{t('operation.noSearchResults', { ns: 'common', content: title })}</span> <span className="text-text-secondary system-sm-regular">{t('operation.noSearchResults', { ns: 'common', content: title })}</span>
<Button variant="secondary-accent" size="small" onClick={() => setSearchQuery('')}>{t('operation.resetKeywords', { ns: 'common' })}</Button> <Button variant="secondary-accent" size="small" onClick={() => setSearchQuery('')}>{t('operation.resetKeywords', { ns: 'common' })}</Button>
</div> </div>

View File

@ -1,14 +1,7 @@
/* eslint-disable next/no-img-element */
import type { ImgHTMLAttributes } from 'react'
import { fireEvent, render, screen } from '@testing-library/react' import { fireEvent, render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event' import userEvent from '@testing-library/user-event'
import FileThumb from '../index' import FileThumb from '../index'
vi.mock('next/image', () => ({
__esModule: true,
default: (props: ImgHTMLAttributes<HTMLImageElement>) => <img {...props} />,
}))
describe('FileThumb Component', () => { describe('FileThumb Component', () => {
const mockImageFile = { const mockImageFile = {
name: 'test-image.jpg', name: 'test-image.jpg',

View File

@ -22,7 +22,7 @@ describe('NumberInputField', () => {
it('should render current number value', () => { it('should render current number value', () => {
render(<NumberInputField label="Count" />) render(<NumberInputField label="Count" />)
expect(screen.getByDisplayValue('2')).toBeInTheDocument() expect(screen.getByRole('textbox')).toHaveValue('2')
}) })
it('should update value when users click increment', () => { it('should update value when users click increment', () => {

View File

@ -45,7 +45,7 @@ describe('BaseField', () => {
it('should render a number input when configured as number input', () => { it('should render a number input when configured as number input', () => {
render(<FieldHarness config={createConfig({ type: BaseFieldType.numberInput, label: 'Age' })} initialData={{ fieldA: 20 }} />) render(<FieldHarness config={createConfig({ type: BaseFieldType.numberInput, label: 'Age' })} initialData={{ fieldA: 20 }} />)
expect(screen.getByRole('spinbutton')).toBeInTheDocument() expect(screen.getByRole('textbox')).toBeInTheDocument()
expect(screen.getByText('Age')).toBeInTheDocument() expect(screen.getByText('Age')).toBeInTheDocument()
}) })

View File

@ -13,7 +13,7 @@ describe('InputNumber Component', () => {
it('renders input with default values', () => { it('renders input with default values', () => {
render(<InputNumber {...defaultProps} />) render(<InputNumber {...defaultProps} />)
const input = screen.getByRole('spinbutton') const input = screen.getByRole('textbox')
expect(input).toBeInTheDocument() expect(input).toBeInTheDocument()
}) })
@ -60,7 +60,7 @@ describe('InputNumber Component', () => {
it('handles direct input changes', () => { it('handles direct input changes', () => {
const onChange = vi.fn() const onChange = vi.fn()
render(<InputNumber onChange={onChange} />) render(<InputNumber onChange={onChange} />)
const input = screen.getByRole('spinbutton') const input = screen.getByRole('textbox')
fireEvent.change(input, { target: { value: '42' } }) fireEvent.change(input, { target: { value: '42' } })
expect(onChange).toHaveBeenCalledWith(42) expect(onChange).toHaveBeenCalledWith(42)
@ -69,38 +69,25 @@ describe('InputNumber Component', () => {
it('handles empty input', () => { it('handles empty input', () => {
const onChange = vi.fn() const onChange = vi.fn()
render(<InputNumber onChange={onChange} value={1} />) render(<InputNumber onChange={onChange} value={1} />)
const input = screen.getByRole('spinbutton') const input = screen.getByRole('textbox')
fireEvent.change(input, { target: { value: '' } }) fireEvent.change(input, { target: { value: '' } })
expect(onChange).toHaveBeenCalledWith(0) 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() const onChange = vi.fn()
render(<InputNumber onChange={onChange} />) render(<InputNumber onChange={onChange} />)
const input = screen.getByRole('spinbutton') const input = screen.getByRole('textbox')
const originalNumber = globalThis.Number fireEvent.change(input, { target: { value: 'abc' } })
const numberSpy = vi.spyOn(globalThis, 'Number').mockImplementation((val: unknown) => { expect(onChange).not.toHaveBeenCalled()
if (val === '123') {
return Number.NaN
}
return originalNumber(val)
})
try {
fireEvent.change(input, { target: { value: '123' } })
expect(onChange).not.toHaveBeenCalled()
}
finally {
numberSpy.mockRestore()
}
}) })
it('does not call onChange when direct input exceeds range', () => { it('does not call onChange when direct input exceeds range', () => {
const onChange = vi.fn() const onChange = vi.fn()
render(<InputNumber onChange={onChange} max={10} min={0} />) render(<InputNumber onChange={onChange} max={10} min={0} />)
const input = screen.getByRole('spinbutton') const input = screen.getByRole('textbox')
fireEvent.change(input, { target: { value: '11' } }) fireEvent.change(input, { target: { value: '11' } })
@ -141,7 +128,7 @@ describe('InputNumber Component', () => {
it('disables controls when disabled prop is true', () => { it('disables controls when disabled prop is true', () => {
const onChange = vi.fn() const onChange = vi.fn()
render(<InputNumber onChange={onChange} disabled />) render(<InputNumber onChange={onChange} disabled />)
const input = screen.getByRole('spinbutton') const input = screen.getByRole('textbox')
const incrementBtn = screen.getByRole('button', { name: /increment/i }) const incrementBtn = screen.getByRole('button', { name: /increment/i })
const decrementBtn = screen.getByRole('button', { name: /decrement/i }) const decrementBtn = screen.getByRole('button', { name: /decrement/i })
@ -211,6 +198,16 @@ describe('InputNumber Component', () => {
expect(onChange).not.toHaveBeenCalled() expect(onChange).not.toHaveBeenCalled()
}) })
it('uses fallback step guard when step is any', async () => {
const user = userEvent.setup()
const onChange = vi.fn()
render(<InputNumber onChange={onChange} value={10} max={10} step="any" />)
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 () => { it('prevents decrement below min with custom amount', async () => {
const user = userEvent.setup() const user = userEvent.setup()
const onChange = vi.fn() const onChange = vi.fn()
@ -244,7 +241,7 @@ describe('InputNumber Component', () => {
it('validates input against max constraint', () => { it('validates input against max constraint', () => {
const onChange = vi.fn() const onChange = vi.fn()
render(<InputNumber onChange={onChange} max={10} />) render(<InputNumber onChange={onChange} max={10} />)
const input = screen.getByRole('spinbutton') const input = screen.getByRole('textbox')
fireEvent.change(input, { target: { value: '15' } }) fireEvent.change(input, { target: { value: '15' } })
expect(onChange).not.toHaveBeenCalled() expect(onChange).not.toHaveBeenCalled()
@ -253,7 +250,7 @@ describe('InputNumber Component', () => {
it('validates input against min constraint', () => { it('validates input against min constraint', () => {
const onChange = vi.fn() const onChange = vi.fn()
render(<InputNumber onChange={onChange} min={5} />) render(<InputNumber onChange={onChange} min={5} />)
const input = screen.getByRole('spinbutton') const input = screen.getByRole('textbox')
fireEvent.change(input, { target: { value: '2' } }) fireEvent.change(input, { target: { value: '2' } })
expect(onChange).not.toHaveBeenCalled() expect(onChange).not.toHaveBeenCalled()
@ -262,7 +259,7 @@ describe('InputNumber Component', () => {
it('accepts input within min and max constraints', () => { it('accepts input within min and max constraints', () => {
const onChange = vi.fn() const onChange = vi.fn()
render(<InputNumber onChange={onChange} min={0} max={100} />) render(<InputNumber onChange={onChange} min={0} max={100} />)
const input = screen.getByRole('spinbutton') const input = screen.getByRole('textbox')
fireEvent.change(input, { target: { value: '50' } }) fireEvent.change(input, { target: { value: '50' } })
expect(onChange).toHaveBeenCalledWith(50) expect(onChange).toHaveBeenCalledWith(50)
@ -296,6 +293,25 @@ describe('InputNumber Component', () => {
expect(wrapper).toHaveClass(wrapClassName) expect(wrapper).toHaveClass(wrapClassName)
}) })
it('applies wrapperClassName to outer div for Input compatibility', () => {
const onChange = vi.fn()
const wrapperClassName = 'custom-input-wrapper'
render(<InputNumber onChange={onChange} wrapperClassName={wrapperClassName} />)
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(<InputNumber onChange={onChange} styleCss={{ color: 'red' }} />)
expect(screen.getByRole('textbox')).toHaveStyle({ color: 'rgb(255, 0, 0)' })
})
it('applies controlWrapClassName to control buttons container', () => { it('applies controlWrapClassName to control buttons container', () => {
const onChange = vi.fn() const onChange = vi.fn()
const controlWrapClassName = 'custom-control-wrap' const controlWrapClassName = 'custom-control-wrap'
@ -327,7 +343,7 @@ describe('InputNumber Component', () => {
it('handles zero as a valid input', () => { it('handles zero as a valid input', () => {
const onChange = vi.fn() const onChange = vi.fn()
render(<InputNumber onChange={onChange} min={-5} max={5} value={1} />) render(<InputNumber onChange={onChange} min={-5} max={5} value={1} />)
const input = screen.getByRole('spinbutton') const input = screen.getByRole('textbox')
fireEvent.change(input, { target: { value: '0' } }) fireEvent.change(input, { target: { value: '0' } })
expect(onChange).toHaveBeenCalledWith(0) expect(onChange).toHaveBeenCalledWith(0)

View File

@ -1,10 +1,23 @@
import type { FC } from 'react' import type { NumberFieldRoot as BaseNumberFieldRoot } from '@base-ui/react/number-field'
import type { InputProps } from '../input' import type { CSSProperties, FC, InputHTMLAttributes } from 'react'
import { useCallback } 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 { cn } from '@/utils/classnames'
import Input from '../input'
export type InputNumberProps = { type InputNumberInputProps = Omit<
InputHTMLAttributes<HTMLInputElement>,
'defaultValue' | 'max' | 'min' | 'onChange' | 'size' | 'type' | 'value'
>
export type InputNumberProps = InputNumberInputProps & {
unit?: string unit?: string
value?: number value?: number
onChange: (value: number) => void onChange: (value: number) => void
@ -12,19 +25,69 @@ export type InputNumberProps = {
size?: 'regular' | 'large' size?: 'regular' | 'large'
max?: number max?: number
min?: number min?: number
step?: number | 'any'
defaultValue?: number defaultValue?: number
disabled?: boolean disabled?: boolean
wrapClassName?: string wrapClassName?: string
wrapperClassName?: string
styleCss?: CSSProperties
controlWrapClassName?: string controlWrapClassName?: string
controlClassName?: string controlClassName?: string
} & Omit<InputProps, 'value' | 'onChange' | 'size' | 'min' | 'max' | 'defaultValue'> type?: 'number'
}
const STEPPER_REASONS = new Set<BaseNumberFieldRoot.ChangeEventDetails['reason']>([
'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<InputNumberProps> = (props) => { export const InputNumber: FC<InputNumberProps> = (props) => {
const { const {
unit, unit,
className, className,
wrapperClassName,
styleCss,
onChange, onChange,
amount = 1, amount,
value, value,
size = 'regular', size = 'regular',
max, max,
@ -34,96 +97,97 @@ export const InputNumber: FC<InputNumberProps> = (props) => {
controlWrapClassName, controlWrapClassName,
controlClassName, controlClassName,
disabled, disabled,
step,
id,
name,
readOnly,
required,
type: _type,
...rest ...rest
} = props } = props
const isValidValue = useCallback((v: number) => { const resolvedStep = resolveStep(amount, step)
if (typeof max === 'number' && v > max) const stepAmount = typeof resolvedStep === 'number' ? resolvedStep : 1
return false
return !(typeof min === 'number' && v < min)
}, [max, min])
const inc = () => { const handleValueChange = useCallback((
/* v8 ignore next 2 - @preserve */ nextValue: number | null,
if (disabled) eventDetails: BaseNumberFieldRoot.ChangeEventDetails,
return ) => {
if (value === undefined && STEPPER_REASONS.has(eventDetails.reason)) {
if (value === undefined) {
onChange(defaultValue ?? 0) onChange(defaultValue ?? 0)
return return
} }
const newValue = value + amount
if (!isValidValue(newValue))
return
onChange(newValue)
}
const dec = () => {
/* v8 ignore next 2 - @preserve */
if (disabled)
return
if (value === undefined) { if (nextValue === null) {
onChange(defaultValue ?? 0)
return
}
const newValue = value - amount
if (!isValidValue(newValue))
return
onChange(newValue)
}
const handleInputChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
if (e.target.value === '') {
onChange(0) onChange(0)
return 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 return
if (!isValidValue(parsed)) onChange(nextValue)
return }, [defaultValue, max, min, onChange, stepAmount, value])
onChange(parsed)
}, [isValidValue, onChange])
return ( return (
<div data-testid="input-number-wrapper" className={cn('flex', wrapClassName)}> <div data-testid="input-number-wrapper" className={cn('flex w-full min-w-0', wrapClassName, wrapperClassName)}>
<Input <NumberField
{...rest} className="min-w-0 grow"
// disable default controller value={value ?? null}
type="number"
className={cn('rounded-r-none no-spinner', className)}
value={value ?? 0}
max={max}
min={min} min={min}
max={max}
step={resolvedStep}
disabled={disabled} disabled={disabled}
onChange={handleInputChange} readOnly={readOnly}
unit={unit} required={required}
size={size} id={id}
/> name={name}
<div allowOutOfRange
data-testid="input-number-controls" onValueChange={handleValueChange}
className={cn('flex flex-col rounded-r-md border-l border-divider-subtle bg-components-input-bg-normal text-text-tertiary focus:shadow-xs', disabled && 'cursor-not-allowed opacity-50', controlWrapClassName)}
> >
<button <NumberFieldGroup size={size}>
type="button" <NumberFieldInput
onClick={inc} {...rest}
disabled={disabled} size={size}
aria-label="increment" style={styleCss}
className={cn(size === 'regular' ? 'pt-1' : 'pt-1.5', 'px-1.5 hover:bg-components-input-bg-hover', disabled && 'cursor-not-allowed hover:bg-transparent', controlClassName)} className={className}
> />
<span className="i-ri-arrow-up-s-line size-3" /> {unit && (
</button> <NumberFieldUnit size={size}>
<button {unit}
type="button" </NumberFieldUnit>
onClick={dec} )}
disabled={disabled} <NumberFieldControls
aria-label="decrement" data-testid="input-number-controls"
className={cn(size === 'regular' ? 'pb-1' : 'pb-1.5', 'px-1.5 hover:bg-components-input-bg-hover', disabled && 'cursor-not-allowed hover:bg-transparent', controlClassName)} className={controlWrapClassName}
> >
<span className="i-ri-arrow-down-s-line size-3" /> <NumberFieldIncrement
</button> aria-label="increment"
</div> size={size}
className={controlClassName}
>
<span aria-hidden="true" className="i-ri-arrow-up-s-line size-3" />
</NumberFieldIncrement>
<NumberFieldDecrement
aria-label="decrement"
size={size}
className={controlClassName}
>
<span aria-hidden="true" className="i-ri-arrow-down-s-line size-3" />
</NumberFieldDecrement>
</NumberFieldControls>
</NumberFieldGroup>
</NumberField>
</div> </div>
) )
} }

View File

@ -1,10 +1,6 @@
import { render, screen } from '@testing-library/react' import { render, screen } from '@testing-library/react'
import WithIconCardItem from './with-icon-card-item' import WithIconCardItem from './with-icon-card-item'
vi.mock('next/image', () => ({
default: ({ unoptimized: _unoptimized, ...props }: React.ImgHTMLAttributes<HTMLImageElement> & { unoptimized?: boolean }) => <img {...props} />,
}))
describe('WithIconCardItem', () => { describe('WithIconCardItem', () => {
beforeEach(() => { beforeEach(() => {
vi.clearAllMocks() vi.clearAllMocks()

View File

@ -1,6 +1,5 @@
import type { ReactNode } from 'react' import type { ReactNode } from 'react'
import type { WithIconCardItemProps } from './markdown-with-directive-schema' import type { WithIconCardItemProps } from './markdown-with-directive-schema'
import Image from 'next/image'
import { cn } from '@/utils/classnames' import { cn } from '@/utils/classnames'
type WithIconItemProps = WithIconCardItemProps & { type WithIconItemProps = WithIconCardItemProps & {
@ -11,18 +10,13 @@ type WithIconItemProps = WithIconCardItemProps & {
function WithIconCardItem({ icon, children, className, iconAlt }: WithIconItemProps) { function WithIconCardItem({ icon, children, className, iconAlt }: WithIconItemProps) {
return ( return (
<div className={cn('flex h-11 items-center space-x-3 rounded-lg bg-background-section px-2', className)}> <div className={cn('flex h-11 items-center space-x-3 rounded-lg bg-background-section px-2', className)}>
{/* <img
* unoptimized to "url parameter is not allowed" for external domains despite correct remotePatterns configuration.
* https://github.com/vercel/next.js/issues/88873
*/}
<Image
src={icon} src={icon}
className="!border-none object-contain" className="!border-none object-contain"
alt={iconAlt ?? ''} alt={iconAlt ?? ''}
aria-hidden={iconAlt ? undefined : true} aria-hidden={iconAlt ? undefined : true}
width={40} width={40}
height={40} height={40}
unoptimized
/> />
<div className="min-w-0 grow overflow-hidden text-text-secondary system-sm-medium [&_p]:!m-0 [&_p]:block [&_p]:w-full [&_p]:overflow-hidden [&_p]:text-ellipsis [&_p]:whitespace-nowrap"> <div className="min-w-0 grow overflow-hidden text-text-secondary system-sm-medium [&_p]:!m-0 [&_p]:block [&_p]:w-full [&_p]:overflow-hidden [&_p]:text-ellipsis [&_p]:whitespace-nowrap">
{children} {children}

View File

@ -7,10 +7,6 @@ import { MarkdownWithDirective } from './index'
const FOUR_COLON_RE = /:{4}/ const FOUR_COLON_RE = /:{4}/
vi.mock('next/image', () => ({
default: (props: React.ImgHTMLAttributes<HTMLImageElement>) => <img {...props} />,
}))
function expectDecorativeIcon(container: HTMLElement, src: string) { function expectDecorativeIcon(container: HTMLElement, src: string) {
const icon = container.querySelector('img') const icon = container.querySelector('img')
expect(icon).toBeInTheDocument() expect(icon).toBeInTheDocument()

View File

@ -3,7 +3,7 @@ import userEvent from '@testing-library/user-event'
import { describe, expect, it, vi } from 'vitest' import { describe, expect, it, vi } from 'vitest'
import CredentialSelector from '../index' 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', () => ({ vi.mock('@/app/components/datasets/common/credential-icon', () => ({
CredentialIcon: ({ name }: { name: string }) => <div data-testid="credential-icon">{name}</div>, CredentialIcon: ({ name }: { name: string }) => <div data-testid="credential-icon">{name}</div>,
})) }))

View File

@ -53,7 +53,7 @@ describe('ParamItem', () => {
it('should render InputNumber and Slider', () => { it('should render InputNumber and Slider', () => {
render(<ParamItem {...defaultProps} />) render(<ParamItem {...defaultProps} />)
expect(screen.getByRole('spinbutton')).toBeInTheDocument() expect(screen.getByRole('textbox')).toBeInTheDocument()
expect(screen.getByRole('slider')).toBeInTheDocument() expect(screen.getByRole('slider')).toBeInTheDocument()
}) })
}) })
@ -68,7 +68,7 @@ describe('ParamItem', () => {
it('should disable InputNumber when enable is false', () => { it('should disable InputNumber when enable is false', () => {
render(<ParamItem {...defaultProps} enable={false} />) render(<ParamItem {...defaultProps} enable={false} />)
expect(screen.getByRole('spinbutton')).toBeDisabled() expect(screen.getByRole('textbox')).toBeDisabled()
}) })
it('should disable Slider when enable is false', () => { it('should disable Slider when enable is false', () => {
@ -104,7 +104,7 @@ describe('ParamItem', () => {
} }
render(<StatefulParamItem />) render(<StatefulParamItem />)
const input = screen.getByRole('spinbutton') const input = screen.getByRole('textbox')
await user.clear(input) await user.clear(input)
await user.type(input, '0.8') await user.type(input, '0.8')
@ -166,14 +166,10 @@ describe('ParamItem', () => {
expect(slider).toHaveAttribute('aria-valuemax', '10') 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(<ParamItem {...defaultProps} />) render(<ParamItem {...defaultProps} />)
const input = screen.getByRole('spinbutton') const input = screen.getByRole('textbox')
expect(input).toBeInTheDocument()
// Component renders without error with default step/min
expect(screen.getByRole('spinbutton')).toBeInTheDocument()
expect(input).toHaveAttribute('step', '0.1')
expect(input).toHaveAttribute('min', '0')
}) })
}) })
}) })

View File

@ -31,7 +31,7 @@ describe('ScoreThresholdItem', () => {
it('should render InputNumber and Slider', () => { it('should render InputNumber and Slider', () => {
render(<ScoreThresholdItem {...defaultProps} />) render(<ScoreThresholdItem {...defaultProps} />)
expect(screen.getByRole('spinbutton')).toBeInTheDocument() expect(screen.getByRole('textbox')).toBeInTheDocument()
expect(screen.getByRole('slider')).toBeInTheDocument() expect(screen.getByRole('slider')).toBeInTheDocument()
}) })
}) })
@ -62,7 +62,7 @@ describe('ScoreThresholdItem', () => {
it('should disable controls when enable is false', () => { it('should disable controls when enable is false', () => {
render(<ScoreThresholdItem {...defaultProps} enable={false} />) render(<ScoreThresholdItem {...defaultProps} enable={false} />)
expect(screen.getByRole('spinbutton')).toBeDisabled() expect(screen.getByRole('textbox')).toBeDisabled()
expect(screen.getByRole('slider')).toHaveAttribute('aria-disabled', 'true') expect(screen.getByRole('slider')).toHaveAttribute('aria-disabled', 'true')
}) })
}) })
@ -70,23 +70,19 @@ describe('ScoreThresholdItem', () => {
describe('Value Clamping', () => { describe('Value Clamping', () => {
it('should clamp values to minimum of 0', () => { it('should clamp values to minimum of 0', () => {
render(<ScoreThresholdItem {...defaultProps} />) render(<ScoreThresholdItem {...defaultProps} />)
const input = screen.getByRole('spinbutton') const input = screen.getByRole('textbox')
expect(input).toBeInTheDocument()
expect(input).toHaveAttribute('min', '0')
}) })
it('should clamp values to maximum of 1', () => { it('should clamp values to maximum of 1', () => {
render(<ScoreThresholdItem {...defaultProps} />) render(<ScoreThresholdItem {...defaultProps} />)
const input = screen.getByRole('spinbutton') const input = screen.getByRole('textbox')
expect(input).toBeInTheDocument()
expect(input).toHaveAttribute('max', '1')
}) })
it('should use step of 0.01', () => { it('should use step of 0.01', () => {
render(<ScoreThresholdItem {...defaultProps} />) render(<ScoreThresholdItem {...defaultProps} value={0.5} />)
const input = screen.getByRole('spinbutton') expect(screen.getByRole('textbox')).toHaveValue('0.5')
expect(input).toHaveAttribute('step', '0.01')
}) })
it('should call onChange with rounded value when input changes', async () => { it('should call onChange with rounded value when input changes', async () => {
@ -107,7 +103,7 @@ describe('ScoreThresholdItem', () => {
} }
render(<StatefulScoreThresholdItem />) render(<StatefulScoreThresholdItem />)
const input = screen.getByRole('spinbutton') const input = screen.getByRole('textbox')
await user.clear(input) await user.clear(input)
await user.type(input, '0.55') await user.type(input, '0.55')
@ -138,8 +134,14 @@ describe('ScoreThresholdItem', () => {
it('should clamp to max=1 when value exceeds maximum', () => { it('should clamp to max=1 when value exceeds maximum', () => {
render(<ScoreThresholdItem {...defaultProps} value={1.5} />) render(<ScoreThresholdItem {...defaultProps} value={1.5} />)
const input = screen.getByRole('spinbutton') const input = screen.getByRole('textbox')
expect(input).toHaveValue(1) expect(input).toHaveValue('1')
})
it('should fall back to default value when value is undefined', () => {
render(<ScoreThresholdItem {...defaultProps} value={undefined} />)
const input = screen.getByRole('textbox')
expect(input).toHaveValue('0.7')
}) })
}) })
}) })

View File

@ -36,7 +36,7 @@ describe('TopKItem', () => {
it('should render InputNumber and Slider', () => { it('should render InputNumber and Slider', () => {
render(<TopKItem {...defaultProps} />) render(<TopKItem {...defaultProps} />)
expect(screen.getByRole('spinbutton')).toBeInTheDocument() expect(screen.getByRole('textbox')).toBeInTheDocument()
expect(screen.getByRole('slider')).toBeInTheDocument() expect(screen.getByRole('slider')).toBeInTheDocument()
}) })
}) })
@ -51,7 +51,7 @@ describe('TopKItem', () => {
it('should disable controls when enable is false', () => { it('should disable controls when enable is false', () => {
render(<TopKItem {...defaultProps} enable={false} />) render(<TopKItem {...defaultProps} enable={false} />)
expect(screen.getByRole('spinbutton')).toBeDisabled() expect(screen.getByRole('textbox')).toBeDisabled()
expect(screen.getByRole('slider')).toHaveAttribute('aria-disabled', 'true') expect(screen.getByRole('slider')).toHaveAttribute('aria-disabled', 'true')
}) })
}) })
@ -59,23 +59,20 @@ describe('TopKItem', () => {
describe('Value Limits', () => { describe('Value Limits', () => {
it('should use step of 1', () => { it('should use step of 1', () => {
render(<TopKItem {...defaultProps} />) render(<TopKItem {...defaultProps} />)
const input = screen.getByRole('spinbutton') const input = screen.getByRole('textbox')
expect(input).toHaveValue('2')
expect(input).toHaveAttribute('step', '1')
}) })
it('should use minimum of 1', () => { it('should use minimum of 1', () => {
render(<TopKItem {...defaultProps} />) render(<TopKItem {...defaultProps} />)
const input = screen.getByRole('spinbutton') const input = screen.getByRole('textbox')
expect(input).toBeInTheDocument()
expect(input).toHaveAttribute('min', '1')
}) })
it('should use maximum from env (10)', () => { it('should use maximum from env (10)', () => {
render(<TopKItem {...defaultProps} />) render(<TopKItem {...defaultProps} />)
const input = screen.getByRole('spinbutton') const input = screen.getByRole('textbox')
expect(input).toBeInTheDocument()
expect(input).toHaveAttribute('max', '10')
}) })
it('should render slider with max >= 5 so no scaling is applied', () => { it('should render slider with max >= 5 so no scaling is applied', () => {

View File

@ -6,7 +6,7 @@ import ParamItem from '.'
type Props = { type Props = {
className?: string className?: string
value: number value?: number
onChange: (key: string, value: number) => void onChange: (key: string, value: number) => void
enable: boolean enable: boolean
hasSwitch?: boolean hasSwitch?: boolean
@ -20,6 +20,18 @@ const VALUE_LIMIT = {
max: 1, 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<Props> = ({ const ScoreThresholdItem: FC<Props> = ({
className, className,
value, value,
@ -29,16 +41,10 @@ const ScoreThresholdItem: FC<Props> = ({
onSwitchChange, onSwitchChange,
}) => { }) => {
const { t } = useTranslation() const { t } = useTranslation()
const handleParamChange = (key: string, value: number) => { const handleParamChange = (key: string, nextValue: number) => {
let notOutRangeValue = Number.parseFloat(value.toFixed(2)) onChange(key, normalizeScoreThreshold(nextValue))
notOutRangeValue = Math.max(VALUE_LIMIT.min, notOutRangeValue)
notOutRangeValue = Math.min(VALUE_LIMIT.max, notOutRangeValue)
onChange(key, notOutRangeValue)
} }
const safeValue = Math.min( const safeValue = normalizeScoreThreshold(value)
VALUE_LIMIT.max,
Math.max(VALUE_LIMIT.min, Number.parseFloat(value.toFixed(2))),
)
return ( return (
<ParamItem <ParamItem

View File

@ -0,0 +1,113 @@
import { NumberField as BaseNumberField } from '@base-ui/react/number-field'
import { render, screen } from '@testing-library/react'
import {
NumberField,
NumberFieldControls,
NumberFieldDecrement,
NumberFieldGroup,
NumberFieldIncrement,
NumberFieldInput,
NumberFieldUnit,
} from '../index'
describe('NumberField wrapper', () => {
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(
<NumberField defaultValue={12}>
<NumberFieldGroup size="regular" className="custom-group" data-testid="group">
<NumberFieldInput
aria-label="Regular amount"
placeholder="Regular placeholder"
size="regular"
className="custom-input"
/>
</NumberFieldGroup>
</NumberField>,
)
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(
<NumberField defaultValue={24}>
<NumberFieldGroup size="large" data-testid="group">
<NumberFieldInput aria-label="Large amount" size="large" />
<NumberFieldUnit size="large">ms</NumberFieldUnit>
<NumberFieldControls>
<NumberFieldIncrement aria-label="Increment amount" size="large" />
<NumberFieldDecrement aria-label="Decrement amount" size="large" />
</NumberFieldControls>
</NumberFieldGroup>
</NumberField>,
)
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(
<NumberField defaultValue={8}>
<NumberFieldGroup size="regular">
<NumberFieldInput aria-label="Amount" size="regular" />
<NumberFieldControls className="custom-controls" data-testid="controls">
<NumberFieldIncrement
aria-label="Increment"
size="regular"
className="custom-increment"
data-track-id="increment-track"
/>
<NumberFieldDecrement
aria-label="Decrement"
size="regular"
className="custom-decrement"
data-track-id="decrement-track"
/>
</NumberFieldControls>
</NumberFieldGroup>
</NumberField>,
)
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')
})
})
})

View File

@ -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<typeof BaseNumberField.Group> & VariantProps<typeof numberFieldGroupVariants>
export function NumberFieldGroup({
className,
size = 'regular',
...props
}: NumberFieldGroupProps) {
return (
<BaseNumberField.Group
className={cn(numberFieldGroupVariants({ size }), className)}
{...props}
/>
)
}
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<React.ComponentPropsWithoutRef<typeof BaseNumberField.Input>, 'size'> & VariantProps<typeof numberFieldInputVariants>
export function NumberFieldInput({
className,
size = 'regular',
...props
}: NumberFieldInputProps) {
return (
<BaseNumberField.Input
className={cn(numberFieldInputVariants({ size }), className)}
{...props}
/>
)
}
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<HTMLSpanElement> & VariantProps<typeof numberFieldUnitVariants>
export function NumberFieldUnit({
className,
size = 'regular',
...props
}: NumberFieldUnitProps) {
return (
<span
className={cn(numberFieldUnitVariants({ size }), className)}
{...props}
/>
)
}
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<HTMLDivElement>
export function NumberFieldControls({
className,
...props
}: NumberFieldControlsProps) {
return (
<div
className={cn(numberFieldControlsVariants(), className)}
{...props}
/>
)
}
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<typeof numberFieldControlButtonVariants>,
'direction'
>
type NumberFieldButtonProps = React.ComponentPropsWithoutRef<typeof BaseNumberField.Increment> & NumberFieldButtonVariantProps
export function NumberFieldIncrement({
className,
size = 'regular',
...props
}: NumberFieldButtonProps) {
return (
<BaseNumberField.Increment
className={cn(numberFieldControlButtonVariants({ size, direction: 'increment' }), className)}
{...props}
/>
)
}
export function NumberFieldDecrement({
className,
size = 'regular',
...props
}: NumberFieldButtonProps) {
return (
<BaseNumberField.Decrement
className={cn(numberFieldControlButtonVariants({ size, direction: 'decrement' }), className)}
{...props}
/>
)
}

View File

@ -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 { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event' 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 { 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 { 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 { useModalContext } from '@/context/modal-context'
import { useProviderContext } from '@/context/provider-context' import { useProviderContext } from '@/context/provider-context'
import { defaultSystemFeatures } from '@/types/feature'
import CustomPage from '../index' import CustomPage from '../index'
// Mock external dependencies only
vi.mock('@/context/provider-context', () => ({ vi.mock('@/context/provider-context', () => ({
useProviderContext: vi.fn(), useProviderContext: vi.fn(),
})) }))
vi.mock('@/context/modal-context', () => ({ vi.mock('@/context/modal-context', () => ({
useModalContext: vi.fn(), useModalContext: vi.fn(),
})) }))
vi.mock('@/context/app-context', async (importOriginal) => {
// Mock the complex CustomWebAppBrand component to avoid dependency issues const actual = await importOriginal<typeof import('@/context/app-context')>()
// This is acceptable because it has complex dependencies (fetch, APIs) return {
vi.mock('@/app/components/custom/custom-web-app-brand', () => ({ ...actual,
default: () => <div data-testid="custom-web-app-brand">CustomWebAppBrand</div>, 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', () => { describe('CustomPage', () => {
const mockSetShowPricingModal = vi.fn() const setShowPricingModal = vi.fn()
beforeEach(() => { beforeEach(() => {
vi.clearAllMocks() vi.clearAllMocks()
// Default mock setup mockUseProviderContext.mockReturnValue(createProviderContext())
;(useModalContext as Mock).mockReturnValue({ mockUseModalContext.mockReturnValue({
setShowPricingModal: mockSetShowPricingModal, setShowPricingModal,
}) } as unknown as ReturnType<typeof useModalContext>)
mockUseAppContext.mockReturnValue(createAppContextValue())
mockUseGlobalPublicStore.mockImplementation(selector => selector({
systemFeatures: createSystemFeatures(),
setSystemFeatures: vi.fn(),
}))
mockUseToastContext.mockReturnValue({
notify: vi.fn(),
} as unknown as ReturnType<typeof useToastContext>)
}) })
// Helper function to render with different provider contexts // Integration coverage for the page and its child custom brand section.
const renderWithContext = (overrides = {}) => {
;(useProviderContext as Mock).mockReturnValue(
createMockProviderContextValue(overrides),
)
return render(<CustomPage />)
}
// Rendering tests (REQUIRED)
describe('Rendering', () => { describe('Rendering', () => {
it('should render without crashing', () => { it('should render the custom brand configuration by default', () => {
// Arrange & Act render(<CustomPage />)
renderWithContext()
// Assert expect(screen.getByText('custom.webapp.removeBrand')).toBeInTheDocument()
expect(screen.getByTestId('custom-web-app-brand')).toBeInTheDocument() expect(screen.getByText('Chatflow App')).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.queryByText('custom.upgradeTip.title')).not.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() expect(screen.queryByText('custom.customize.contactUs')).not.toBeInTheDocument()
}) })
it('should not show contact section when plan is sandbox', () => { it('should show the upgrade banner and open pricing modal for sandbox billing', async () => {
// 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
const user = userEvent.setup() const user = userEvent.setup()
renderWithContext({ mockUseProviderContext.mockReturnValue(createProviderContext({
enableBilling: true, enableBilling: true,
plan: { type: Plan.sandbox }, planType: Plan.sandbox,
}) }))
// Act render(<CustomPage />)
const upgradeButton = screen.getByText('billing.upgradeBtn.encourageShort')
await user.click(upgradeButton)
// 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.getByText('custom.upgradeTip.title')).toBeInTheDocument()
expect(screen.queryByText('custom.customize.contactUs')).not.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', () => { it('should show the contact link for professional workspaces', () => {
// Arrange & Act mockUseProviderContext.mockReturnValue(createProviderContext({
renderWithContext({
enableBilling: true, enableBilling: true,
plan: { type: Plan.professional }, planType: Plan.professional,
}) }))
// Assert render(<CustomPage />)
const contactLink = screen.getByText('custom.customize.contactUs').closest('a')
expect(screen.queryByText('custom.upgradeTip.title')).not.toBeInTheDocument() 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', () => { it('should show the contact link for team workspaces', () => {
// Arrange & Act mockUseProviderContext.mockReturnValue(createProviderContext({
renderWithContext({
enableBilling: true, enableBilling: true,
plan: { type: Plan.team }, planType: Plan.team,
}) }))
// Assert render(<CustomPage />)
expect(screen.getByText('custom.customize.contactUs')).toBeInTheDocument()
expect(screen.queryByText('custom.upgradeTip.title')).not.toBeInTheDocument() expect(screen.queryByText('custom.upgradeTip.title')).not.toBeInTheDocument()
expect(screen.getByText('custom.customize.contactUs')).toBeInTheDocument()
}) })
it('should handle empty plan object', () => { it('should hide both billing sections when billing is disabled', () => {
// Arrange & Act mockUseProviderContext.mockReturnValue(createProviderContext({
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({
enableBilling: false, enableBilling: false,
plan: { type: Plan.sandbox }, planType: Plan.sandbox,
}) }))
render(<CustomPage />)
// Assert
expect(screen.getByTestId('custom-web-app-brand')).toBeInTheDocument()
expect(screen.queryByText('custom.upgradeTip.title')).not.toBeInTheDocument() expect(screen.queryByText('custom.upgradeTip.title')).not.toBeInTheDocument()
expect(screen.queryByText('custom.customize.contactUs')).not.toBeInTheDocument() expect(screen.queryByText('custom.customize.contactUs')).not.toBeInTheDocument()
}) })

View File

@ -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 { beforeEach, describe, expect, it, vi } from 'vitest'
import { getImageUploadErrorMessage, imageUpload } from '@/app/components/base/image-uploader/utils' import useWebAppBrand from '../hooks/use-web-app-brand'
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 CustomWebAppBrand from '../index' import CustomWebAppBrand from '../index'
vi.mock('@/app/components/base/toast/context', () => ({ vi.mock('../hooks/use-web-app-brand', () => ({
useToastContext: vi.fn(), default: 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(),
})) }))
const mockNotify = vi.fn() const mockUseWebAppBrand = vi.mocked(useWebAppBrand)
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 defaultPlanUsage = { const createHookState = (overrides: Partial<ReturnType<typeof useWebAppBrand>> = {}): ReturnType<typeof useWebAppBrand> => ({
buildApps: 0, fileId: '',
teamMembers: 0, imgKey: 100,
annotatedResponse: 0, uploadProgress: 0,
documentsUploadQuota: 0, uploading: false,
apiRateLimit: 0, webappLogo: 'https://example.com/replace.png',
triggerEvents: 0, webappBrandRemoved: false,
vectorSpace: 0, 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<ReturnType<typeof useWebAppBrand>> = {}) => {
const hookState = createHookState(overrides)
mockUseWebAppBrand.mockReturnValue(hookState)
return {
hookState,
...render(<CustomWebAppBrand />),
}
} }
const renderComponent = () => render(<CustomWebAppBrand />)
describe('CustomWebAppBrand', () => { describe('CustomWebAppBrand', () => {
beforeEach(() => { beforeEach(() => {
vi.clearAllMocks() vi.clearAllMocks()
mockUseToastContext.mockReturnValue({ notify: mockNotify } as unknown as ReturnType<typeof useToastContext>)
mockUpdateCurrentWorkspace.mockResolvedValue({} as unknown as Awaited<ReturnType<typeof updateCurrentWorkspace>>)
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<typeof useAppContext>)
mockUseProviderContext.mockReturnValue({
plan: {
type: Plan.professional,
usage: defaultPlanUsage,
total: defaultPlanUsage,
reset: {},
},
enableBilling: false,
} as unknown as ReturnType<typeof useProviderContext>)
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<typeof useGlobalPublicStore.getState>) : { systemFeatures: systemFeaturesState })
mockGetImageUploadErrorMessage.mockReturnValue('upload error')
}) })
it('disables upload controls when the user cannot manage the workspace', () => { // Integration coverage for the root component with the hook mocked at the boundary.
mockUseAppContext.mockReturnValue({ describe('Rendering', () => {
currentWorkspace: { it('should render the upload controls and preview cards with restore action', () => {
custom_config: { renderComponent()
replace_webapp_logo: '',
remove_webapp_brand: false,
},
},
mutateCurrentWorkspace: vi.fn(),
isCurrentWorkspaceManager: false,
} as unknown as ReturnType<typeof useAppContext>)
const { container } = renderComponent() expect(screen.getByText('custom.webapp.removeBrand')).toBeInTheDocument()
const fileInput = container.querySelector('input[type="file"]') as HTMLInputElement expect(screen.getByRole('button', { name: 'custom.restore' })).toBeInTheDocument()
expect(fileInput).toBeDisabled() expect(screen.getByRole('button', { name: 'custom.change' })).toBeInTheDocument()
}) expect(screen.getByText('Chatflow App')).toBeInTheDocument()
expect(screen.getByText('Workflow App')).toBeInTheDocument()
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<typeof useAppContext>)
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' })
}) })
const { container } = renderComponent() it('should hide the restore action when uploads are disabled or no logo is configured', () => {
const fileInput = container.querySelector('input[type="file"]') as HTMLInputElement renderComponent({
const testFile = new File(['content'], 'logo.png', { type: 'image/png' }) uploadDisabled: true,
fireEvent.change(fileInput, { target: { files: [testFile] } }) webappLogo: '',
})
await waitFor(() => expect(mockImageUpload).toHaveBeenCalled()) expect(screen.queryByRole('button', { name: 'custom.restore' })).not.toBeInTheDocument()
await waitFor(() => screen.getByRole('button', { name: 'custom.apply' })) expect(screen.getByRole('button', { name: 'custom.upload' })).toBeDisabled()
})
const cancelButton = screen.getByRole('button', { name: 'common.operation.cancel' }) it('should show the uploading button and failure message when upload state requires it', () => {
fireEvent.click(cancelButton) 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)
})
}) })
}) })

View File

@ -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(
<ChatPreviewCard
imgKey={8}
webappLogo="https://example.com/custom-logo.png"
/>,
)
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(
<ChatPreviewCard
imgKey={8}
webappBrandRemoved
webappLogo="https://example.com/custom-logo.png"
/>,
)
expect(screen.queryByText('POWERED BY')).not.toBeInTheDocument()
})
})

View File

@ -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(
<PoweredByBrand
imgKey={1}
workspaceLogo="https://example.com/workspace-logo.png"
webappLogo="https://example.com/custom-logo.png"
/>,
)
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(
<PoweredByBrand
imgKey={42}
webappLogo="https://example.com/custom-logo.png"
/>,
)
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(<PoweredByBrand imgKey={7} />)
expect(screen.getByAltText('Dify logo')).toBeInTheDocument()
})
it('should render nothing when branding is removed', () => {
const { container } = render(<PoweredByBrand imgKey={7} webappBrandRemoved />)
expect(container).toBeEmptyDOMElement()
})
})

View File

@ -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(
<WorkflowPreviewCard
imgKey={9}
workspaceLogo="https://example.com/workspace-logo.png"
/>,
)
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(
<WorkflowPreviewCard
imgKey={9}
webappBrandRemoved
workspaceLogo="https://example.com/workspace-logo.png"
/>,
)
expect(screen.queryByText('POWERED BY')).not.toBeInTheDocument()
})
})

View File

@ -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 (
<div className="flex h-[320px] grow basis-1/2 overflow-hidden rounded-2xl border-[0.5px] border-components-panel-border-subtle bg-background-default-burn">
<div className="flex h-full w-[232px] shrink-0 flex-col p-1 pr-0">
<div className="flex items-center gap-3 p-3 pr-2">
<div className={cn('inline-flex h-8 w-8 items-center justify-center rounded-lg border border-divider-regular', 'bg-components-icon-bg-blue-light-solid')}>
<span className="i-custom-vender-solid-communication-bubble-text-mod h-4 w-4 text-components-avatar-shape-fill-stop-100" />
</div>
<div className="grow text-text-secondary system-md-semibold">Chatflow App</div>
<div className="p-1.5">
<span className="i-ri-layout-left-2-line h-4 w-4 text-text-tertiary" />
</div>
</div>
<div className="shrink-0 px-4 py-3">
<Button variant="secondary-accent" className="w-full justify-center">
<span className="i-ri-edit-box-line mr-1 h-4 w-4" />
<div className="p-1 opacity-20">
<div className="h-2 w-[94px] rounded-sm bg-text-accent-light-mode-only"></div>
</div>
</Button>
</div>
<div className="grow px-3 pt-5">
<div className="flex h-8 items-center px-3 py-1">
<div className="h-2 w-14 rounded-sm bg-text-quaternary opacity-20"></div>
</div>
<div className="flex h-8 items-center px-3 py-1">
<div className="h-2 w-[168px] rounded-sm bg-text-quaternary opacity-20"></div>
</div>
<div className="flex h-8 items-center px-3 py-1">
<div className="h-2 w-[128px] rounded-sm bg-text-quaternary opacity-20"></div>
</div>
</div>
<div className="flex shrink-0 items-center justify-between p-3">
<div className="p-1.5">
<span className="i-ri-equalizer-2-line h-4 w-4 text-text-tertiary" />
</div>
<div className="flex items-center gap-1.5">
<PoweredByBrand
webappBrandRemoved={webappBrandRemoved}
workspaceLogo={workspaceLogo}
webappLogo={webappLogo}
imgKey={imgKey}
/>
</div>
</div>
</div>
<div className="flex w-[138px] grow flex-col justify-between p-2 pr-0">
<div className="flex grow flex-col justify-between rounded-l-2xl border-[0.5px] border-r-0 border-components-panel-border-subtle bg-chatbot-bg pb-4 pl-[22px] pt-16">
<div className="w-[720px] rounded-2xl border border-divider-subtle bg-chat-bubble-bg px-4 py-3">
<div className="mb-1 text-text-primary body-md-regular">Hello! How can I assist you today?</div>
<Button size="small">
<div className="h-2 w-[144px] rounded-sm bg-text-quaternary opacity-20"></div>
</Button>
</div>
<div className="flex h-[52px] w-[578px] items-center rounded-xl border border-components-chat-input-border bg-components-panel-bg-blur pl-3.5 text-text-placeholder shadow-md backdrop-blur-sm body-lg-regular">Talk to Dify</div>
</div>
</div>
</div>
)
}
export default ChatPreviewCard

View File

@ -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 (
<>
<div className="text-text-tertiary system-2xs-medium-uppercase">POWERED BY</div>
{previewLogo
? <img src={previewLogo} alt="logo" className="block h-5 w-auto" />
: <DifyLogo size="small" />}
</>
)
}
export default PoweredByBrand

View File

@ -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 (
<div className="flex h-[320px] grow basis-1/2 flex-col overflow-hidden rounded-2xl border-[0.5px] border-components-panel-border-subtle bg-background-default-burn">
<div className="w-full border-b-[0.5px] border-divider-subtle p-4 pb-0">
<div className="mb-2 flex items-center gap-3">
<div className={cn('inline-flex h-8 w-8 items-center justify-center rounded-lg border border-divider-regular', 'bg-components-icon-bg-indigo-solid')}>
<span className="i-ri-exchange-2-fill h-4 w-4 text-components-avatar-shape-fill-stop-100" />
</div>
<div className="grow text-text-secondary system-md-semibold">Workflow App</div>
<div className="p-1.5">
<span className="i-ri-layout-left-2-line h-4 w-4 text-text-tertiary" />
</div>
</div>
<div className="flex items-center gap-4">
<div className="flex h-10 shrink-0 items-center border-b-2 border-components-tab-active text-text-primary system-md-semibold-uppercase">RUN ONCE</div>
<div className="flex h-10 grow items-center border-b-2 border-transparent text-text-tertiary system-md-semibold-uppercase">RUN BATCH</div>
</div>
</div>
<div className="grow bg-components-panel-bg">
<div className="p-4 pb-1">
<div className="mb-1 py-2">
<div className="h-2 w-20 rounded-sm bg-text-quaternary opacity-20"></div>
</div>
<div className="h-16 w-full rounded-lg bg-components-input-bg-normal"></div>
</div>
<div className="flex items-center justify-between px-4 py-3">
<Button size="small">
<div className="h-2 w-10 rounded-sm bg-text-quaternary opacity-20"></div>
</Button>
<Button variant="primary" size="small" disabled>
<span className="i-ri-play-large-line mr-1 h-4 w-4" />
<span>Execute</span>
</Button>
</div>
</div>
<div className="flex h-12 shrink-0 items-center gap-1.5 bg-components-panel-bg p-4 pt-3">
<PoweredByBrand
webappBrandRemoved={webappBrandRemoved}
workspaceLogo={workspaceLogo}
webappLogo={webappLogo}
imgKey={imgKey}
/>
</div>
</div>
)
}
export default WorkflowPreviewCard

View File

@ -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<typeof import('@/context/app-context')>()
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['branding']> = {}): SystemFeatures => ({
...defaultSystemFeatures,
branding: {
...defaultSystemFeatures.branding,
enabled: true,
workspace_logo: 'https://example.com/workspace-logo.png',
...brandingOverrides,
},
})
const createAppContextValue = (overrides: Partial<AppContextValue> = {}): AppContextValue => {
const { currentWorkspace: currentWorkspaceOverride, ...restOverrides } = overrides
const workspaceOverrides: Partial<AppContextValue['currentWorkspace']> = 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<typeof useToastContext>)
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<HTMLInputElement>)
})
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<HTMLInputElement>)
})
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<HTMLInputElement>)
})
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<HTMLInputElement>)
})
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<HTMLInputElement>)
})
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<HTMLInputElement>)
})
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<HTMLInputElement>)
})
act(() => {
result.current.handleCancel()
})
expect(result.current.fileId).toBe('')
expect(result.current.uploadProgress).toBe(0)
})
})
})

View File

@ -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<string, unknown>) => {
await updateCurrentWorkspace({
url: CUSTOM_CONFIG_URL,
body,
})
mutateCurrentWorkspace()
}
const handleChange = (e: ChangeEvent<HTMLInputElement>) => {
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

View File

@ -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 { useTranslation } from 'react-i18next'
import Button from '@/app/components/base/button' import Button from '@/app/components/base/button'
import Divider from '@/app/components/base/divider' 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 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 { 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 ALLOW_FILE_EXTENSIONS = ['svg', 'png']
const CustomWebAppBrand = () => { const CustomWebAppBrand = () => {
const { t } = useTranslation() const { t } = useTranslation()
const { notify } = useToastContext()
const { plan, enableBilling } = useProviderContext()
const { const {
currentWorkspace, fileId,
mutateCurrentWorkspace, imgKey,
uploadProgress,
uploading,
webappLogo,
webappBrandRemoved,
uploadDisabled,
workspaceLogo,
isCurrentWorkspaceManager, isCurrentWorkspaceManager,
} = useAppContext() isSandbox,
const [fileId, setFileId] = useState('') handleApply,
const [imgKey, setImgKey] = useState(() => Date.now()) handleCancel,
const [uploadProgress, setUploadProgress] = useState(0) handleChange,
const systemFeatures = useGlobalPublicStore(s => s.systemFeatures) handleRestore,
const isSandbox = enableBilling && plan.type === Plan.sandbox handleSwitch,
const uploading = uploadProgress > 0 && uploadProgress < 100 } = useWebAppBrand()
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<HTMLInputElement>) => {
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)
}
return ( return (
<div className="py-4"> <div className="py-4">
@ -149,7 +64,7 @@ const CustomWebAppBrand = () => {
className="relative mr-2" className="relative mr-2"
disabled={uploadDisabled} disabled={uploadDisabled}
> >
<RiImageAddLine className="mr-1 h-4 w-4" /> <span className="i-ri-image-add-line mr-1 h-4 w-4" />
{ {
(webappLogo || fileId) (webappLogo || fileId)
? t('change', { ns: 'custom' }) ? t('change', { ns: 'custom' })
@ -172,7 +87,7 @@ const CustomWebAppBrand = () => {
className="relative mr-2" className="relative mr-2"
disabled={true} disabled={true}
> >
<RiLoader2Line className="mr-1 h-4 w-4 animate-spin" /> <span className="i-ri-loader-2-line mr-1 h-4 w-4 animate-spin" />
{t('uploading', { ns: 'custom' })} {t('uploading', { ns: 'custom' })}
</Button> </Button>
) )
@ -208,118 +123,18 @@ const CustomWebAppBrand = () => {
<Divider bgStyle="gradient" className="grow" /> <Divider bgStyle="gradient" className="grow" />
</div> </div>
<div className="relative mb-2 flex items-center gap-3"> <div className="relative mb-2 flex items-center gap-3">
{/* chat card */} <ChatPreviewCard
<div className="flex h-[320px] grow basis-1/2 overflow-hidden rounded-2xl border-[0.5px] border-components-panel-border-subtle bg-background-default-burn"> webappBrandRemoved={webappBrandRemoved}
<div className="flex h-full w-[232px] shrink-0 flex-col p-1 pr-0"> workspaceLogo={workspaceLogo}
<div className="flex items-center gap-3 p-3 pr-2"> webappLogo={webappLogo}
<div className={cn('inline-flex h-8 w-8 items-center justify-center rounded-lg border border-divider-regular', 'bg-components-icon-bg-blue-light-solid')}> imgKey={imgKey}
<BubbleTextMod className="h-4 w-4 text-components-avatar-shape-fill-stop-100" /> />
</div> <WorkflowPreviewCard
<div className="grow text-text-secondary system-md-semibold">Chatflow App</div> webappBrandRemoved={webappBrandRemoved}
<div className="p-1.5"> workspaceLogo={workspaceLogo}
<RiLayoutLeft2Line className="h-4 w-4 text-text-tertiary" /> webappLogo={webappLogo}
</div> imgKey={imgKey}
</div> />
<div className="shrink-0 px-4 py-3">
<Button variant="secondary-accent" className="w-full justify-center">
<RiEditBoxLine className="mr-1 h-4 w-4" />
<div className="p-1 opacity-20">
<div className="h-2 w-[94px] rounded-sm bg-text-accent-light-mode-only"></div>
</div>
</Button>
</div>
<div className="grow px-3 pt-5">
<div className="flex h-8 items-center px-3 py-1">
<div className="h-2 w-14 rounded-sm bg-text-quaternary opacity-20"></div>
</div>
<div className="flex h-8 items-center px-3 py-1">
<div className="h-2 w-[168px] rounded-sm bg-text-quaternary opacity-20"></div>
</div>
<div className="flex h-8 items-center px-3 py-1">
<div className="h-2 w-[128px] rounded-sm bg-text-quaternary opacity-20"></div>
</div>
</div>
<div className="flex shrink-0 items-center justify-between p-3">
<div className="p-1.5">
<RiEqualizer2Line className="h-4 w-4 text-text-tertiary" />
</div>
<div className="flex items-center gap-1.5">
{!webappBrandRemoved && (
<>
<div className="text-text-tertiary system-2xs-medium-uppercase">POWERED BY</div>
{
systemFeatures.branding.enabled && systemFeatures.branding.workspace_logo
? <img src={systemFeatures.branding.workspace_logo} alt="logo" className="block h-5 w-auto" />
: webappLogo
? <img src={`${webappLogo}?hash=${imgKey}`} alt="logo" className="block h-5 w-auto" />
: <DifyLogo size="small" />
}
</>
)}
</div>
</div>
</div>
<div className="flex w-[138px] grow flex-col justify-between p-2 pr-0">
<div className="flex grow flex-col justify-between rounded-l-2xl border-[0.5px] border-r-0 border-components-panel-border-subtle bg-chatbot-bg pb-4 pl-[22px] pt-16">
<div className="w-[720px] rounded-2xl border border-divider-subtle bg-chat-bubble-bg px-4 py-3">
<div className="mb-1 text-text-primary body-md-regular">Hello! How can I assist you today?</div>
<Button size="small">
<div className="h-2 w-[144px] rounded-sm bg-text-quaternary opacity-20"></div>
</Button>
</div>
<div className="flex h-[52px] w-[578px] items-center rounded-xl border border-components-chat-input-border bg-components-panel-bg-blur pl-3.5 text-text-placeholder shadow-md backdrop-blur-sm body-lg-regular">Talk to Dify</div>
</div>
</div>
</div>
{/* workflow card */}
<div className="flex h-[320px] grow basis-1/2 flex-col overflow-hidden rounded-2xl border-[0.5px] border-components-panel-border-subtle bg-background-default-burn">
<div className="w-full border-b-[0.5px] border-divider-subtle p-4 pb-0">
<div className="mb-2 flex items-center gap-3">
<div className={cn('inline-flex h-8 w-8 items-center justify-center rounded-lg border border-divider-regular', 'bg-components-icon-bg-indigo-solid')}>
<RiExchange2Fill className="h-4 w-4 text-components-avatar-shape-fill-stop-100" />
</div>
<div className="grow text-text-secondary system-md-semibold">Workflow App</div>
<div className="p-1.5">
<RiLayoutLeft2Line className="h-4 w-4 text-text-tertiary" />
</div>
</div>
<div className="flex items-center gap-4">
<div className="flex h-10 shrink-0 items-center border-b-2 border-components-tab-active text-text-primary system-md-semibold-uppercase">RUN ONCE</div>
<div className="flex h-10 grow items-center border-b-2 border-transparent text-text-tertiary system-md-semibold-uppercase">RUN BATCH</div>
</div>
</div>
<div className="grow bg-components-panel-bg">
<div className="p-4 pb-1">
<div className="mb-1 py-2">
<div className="h-2 w-20 rounded-sm bg-text-quaternary opacity-20"></div>
</div>
<div className="h-16 w-full rounded-lg bg-components-input-bg-normal"></div>
</div>
<div className="flex items-center justify-between px-4 py-3">
<Button size="small">
<div className="h-2 w-10 rounded-sm bg-text-quaternary opacity-20"></div>
</Button>
<Button variant="primary" size="small" disabled>
<RiPlayLargeLine className="mr-1 h-4 w-4" />
<span>Execute</span>
</Button>
</div>
</div>
<div className="flex h-12 shrink-0 items-center gap-1.5 bg-components-panel-bg p-4 pt-3">
{!webappBrandRemoved && (
<>
<div className="text-text-tertiary system-2xs-medium-uppercase">POWERED BY</div>
{
systemFeatures.branding.enabled && systemFeatures.branding.workspace_logo
? <img src={systemFeatures.branding.workspace_logo} alt="logo" className="block h-5 w-auto" />
: webappLogo
? <img src={`${webappLogo}?hash=${imgKey}`} alt="logo" className="block h-5 w-auto" />
: <DifyLogo size="small" />
}
</>
)}
</div>
</div>
</div> </div>
</div> </div>
) )

View File

@ -4,13 +4,6 @@ import { RETRIEVE_METHOD } from '@/types/app'
import { retrievalIcon } from '../../../create/icons' import { retrievalIcon } from '../../../create/icons'
import RetrievalMethodInfo, { getIcon } from '../index' import RetrievalMethodInfo, { getIcon } from '../index'
// Override global next/image auto-mock: tests assert on rendered <img> src attributes via data-testid
vi.mock('next/image', () => ({
default: ({ src, alt, className }: { src: string, alt: string, className?: string }) => (
<img src={src} alt={alt || ''} className={className} data-testid="method-icon" />
),
}))
// Mock RadioCard // Mock RadioCard
vi.mock('@/app/components/base/radio-card', () => ({ vi.mock('@/app/components/base/radio-card', () => ({
default: ({ title, description, chosenConfig, icon }: { title: string, description: string, chosenConfig: ReactNode, icon: ReactNode }) => ( 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', () => { it('should render correctly with full config', () => {
render(<RetrievalMethodInfo value={defaultConfig} />) const { container } = render(<RetrievalMethodInfo value={defaultConfig} />)
expect(screen.getByTestId('radio-card')).toBeInTheDocument() expect(screen.getByTestId('radio-card')).toBeInTheDocument()
@ -59,7 +52,7 @@ describe('RetrievalMethodInfo', () => {
expect(screen.getByTestId('card-description')).toHaveTextContent('dataset.retrieval.semantic_search.description') expect(screen.getByTestId('card-description')).toHaveTextContent('dataset.retrieval.semantic_search.description')
// Check Icon // Check Icon
const icon = screen.getByTestId('method-icon') const icon = container.querySelector('img')
expect(icon).toHaveAttribute('src', 'vector-icon.png') expect(icon).toHaveAttribute('src', 'vector-icon.png')
// Check Config Details // Check Config Details
@ -87,18 +80,18 @@ describe('RetrievalMethodInfo', () => {
it('should handle different retrieval methods', () => { it('should handle different retrieval methods', () => {
// Test Hybrid // Test Hybrid
const hybridConfig = { ...defaultConfig, search_method: RETRIEVE_METHOD.hybrid } const hybridConfig = { ...defaultConfig, search_method: RETRIEVE_METHOD.hybrid }
const { unmount } = render(<RetrievalMethodInfo value={hybridConfig} />) const { container, unmount } = render(<RetrievalMethodInfo value={hybridConfig} />)
expect(screen.getByTestId('card-title')).toHaveTextContent('dataset.retrieval.hybrid_search.title') 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() unmount()
// Test FullText // Test FullText
const fullTextConfig = { ...defaultConfig, search_method: RETRIEVE_METHOD.fullText } const fullTextConfig = { ...defaultConfig, search_method: RETRIEVE_METHOD.fullText }
render(<RetrievalMethodInfo value={fullTextConfig} />) const { container: fullTextContainer } = render(<RetrievalMethodInfo value={fullTextConfig} />)
expect(screen.getByTestId('card-title')).toHaveTextContent('dataset.retrieval.full_text_search.title') 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', () => { describe('getIcon utility', () => {
@ -132,17 +125,17 @@ describe('RetrievalMethodInfo', () => {
it('should render correctly with invertedIndex search method', () => { it('should render correctly with invertedIndex search method', () => {
const invertedIndexConfig = { ...defaultConfig, search_method: RETRIEVE_METHOD.invertedIndex } const invertedIndexConfig = { ...defaultConfig, search_method: RETRIEVE_METHOD.invertedIndex }
render(<RetrievalMethodInfo value={invertedIndexConfig} />) const { container } = render(<RetrievalMethodInfo value={invertedIndexConfig} />)
// invertedIndex uses vector icon // 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', () => { it('should render correctly with keywordSearch search method', () => {
const keywordSearchConfig = { ...defaultConfig, search_method: RETRIEVE_METHOD.keywordSearch } const keywordSearchConfig = { ...defaultConfig, search_method: RETRIEVE_METHOD.keywordSearch }
render(<RetrievalMethodInfo value={keywordSearchConfig} />) const { container } = render(<RetrievalMethodInfo value={keywordSearchConfig} />)
// keywordSearch uses vector icon // keywordSearch uses vector icon
expect(screen.getByTestId('method-icon')).toHaveAttribute('src', 'vector-icon.png') expect(container.querySelector('img')).toHaveAttribute('src', 'vector-icon.png')
}) })
}) })

View File

@ -1,7 +1,6 @@
'use client' 'use client'
import type { FC } from 'react' import type { FC } from 'react'
import type { RetrievalConfig } from '@/types/app' import type { RetrievalConfig } from '@/types/app'
import Image from 'next/image'
import * as React from 'react' import * as React from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import RadioCard from '@/app/components/base/radio-card' import RadioCard from '@/app/components/base/radio-card'
@ -28,7 +27,7 @@ const EconomicalRetrievalMethodConfig: FC<Props> = ({
}) => { }) => {
const { t } = useTranslation() const { t } = useTranslation()
const type = value.search_method const type = value.search_method
const icon = <Image className="size-3.5 text-util-colors-purple-purple-600" src={getIcon(type)} alt="" /> const icon = <img className="size-3.5 text-util-colors-purple-purple-600" src={getIcon(type)} alt="" />
return ( return (
<div className="space-y-2"> <div className="space-y-2">
<RadioCard <RadioCard

View File

@ -1,7 +1,6 @@
'use client' 'use client'
import type { FC } from 'react' import type { FC } from 'react'
import type { RetrievalConfig } from '@/types/app' import type { RetrievalConfig } from '@/types/app'
import Image from 'next/image'
import * as React from 'react' import * as React from 'react'
import { useCallback, useMemo } from 'react' import { useCallback, useMemo } from 'react'
@ -127,7 +126,7 @@ const RetrievalParamConfig: FC<Props> = ({
/> />
)} )}
<div className="flex items-center"> <div className="flex items-center">
<span className="system-sm-semibold mr-0.5 text-text-secondary">{t('modelProvider.rerankModel.key', { ns: 'common' })}</span> <span className="mr-0.5 text-text-secondary system-sm-semibold">{t('modelProvider.rerankModel.key', { ns: 'common' })}</span>
<Tooltip <Tooltip
popupContent={ popupContent={
<div className="w-[200px]">{t('modelProvider.rerankModel.tip', { ns: 'common' })}</div> <div className="w-[200px]">{t('modelProvider.rerankModel.tip', { ns: 'common' })}</div>
@ -157,7 +156,7 @@ const RetrievalParamConfig: FC<Props> = ({
<div className="p-1"> <div className="p-1">
<AlertTriangle className="size-4 text-text-warning-secondary" /> <AlertTriangle className="size-4 text-text-warning-secondary" />
</div> </div>
<span className="system-xs-medium text-text-primary"> <span className="text-text-primary system-xs-medium">
{t('form.retrievalSetting.multiModalTip', { ns: 'datasetSettings' })} {t('form.retrievalSetting.multiModalTip', { ns: 'datasetSettings' })}
</span> </span>
</div> </div>
@ -215,11 +214,11 @@ const RetrievalParamConfig: FC<Props> = ({
isChosen={value.reranking_mode === option.value} isChosen={value.reranking_mode === option.value}
onChosen={() => handleChangeRerankMode(option.value)} onChosen={() => handleChangeRerankMode(option.value)}
icon={( icon={(
<Image <img
src={ src={
option.value === RerankingModeEnum.WeightedScore option.value === RerankingModeEnum.WeightedScore
? ProgressIndicator ? ProgressIndicator.src
: Reranking : Reranking.src
} }
alt="" alt=""
/> />
@ -281,7 +280,7 @@ const RetrievalParamConfig: FC<Props> = ({
<div className="p-1"> <div className="p-1">
<AlertTriangle className="size-4 text-text-warning-secondary" /> <AlertTriangle className="size-4 text-text-warning-secondary" />
</div> </div>
<span className="system-xs-medium text-text-primary"> <span className="text-text-primary system-xs-medium">
{t('form.retrievalSetting.multiModalTip', { ns: 'datasetSettings' })} {t('form.retrievalSetting.multiModalTip', { ns: 'datasetSettings' })}
</span> </span>
</div> </div>

View File

@ -20,14 +20,6 @@ vi.mock('next/navigation', () => ({
useRouter: () => mockRouter, 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
<img src={src} alt={alt} className={className} data-testid="next-image" />
),
}))
// Mock API service // Mock API service
const mockFetchIndexingStatusBatch = vi.fn() const mockFetchIndexingStatusBatch = vi.fn()
vi.mock('@/service/datasets', () => ({ vi.mock('@/service/datasets', () => ({
@ -979,9 +971,9 @@ describe('RuleDetail', () => {
}) })
it('should render correct icon for indexing type', () => { it('should render correct icon for indexing type', () => {
render(<RuleDetail indexingType="high_quality" />) const { container } = render(<RuleDetail indexingType="high_quality" />)
const images = screen.getAllByTestId('next-image') const images = container.querySelectorAll('img')
expect(images.length).toBeGreaterThan(0) expect(images.length).toBeGreaterThan(0)
}) })
}) })

View File

@ -1,6 +1,5 @@
import type { FC } from 'react' import type { FC } from 'react'
import type { ProcessRuleResponse } from '@/models/datasets' import type { ProcessRuleResponse } from '@/models/datasets'
import Image from 'next/image'
import { useCallback } from 'react' import { useCallback } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { FieldInfo } from '@/app/components/datasets/documents/detail/metadata' import { FieldInfo } from '@/app/components/datasets/documents/detail/metadata'
@ -119,12 +118,12 @@ const RuleDetail: FC<RuleDetailProps> = ({ sourceData, indexingType, retrievalMe
<FieldInfo <FieldInfo
label={t('stepTwo.indexMode', { ns: 'datasetCreation' })} label={t('stepTwo.indexMode', { ns: 'datasetCreation' })}
displayedValue={indexModeLabel} displayedValue={indexModeLabel}
valueIcon={<Image className="size-4" src={indexMethodIconSrc} alt="" />} valueIcon={<img className="size-4" src={indexMethodIconSrc} alt="" />}
/> />
<FieldInfo <FieldInfo
label={t('form.retrievalSetting.title', { ns: 'datasetSettings' })} label={t('form.retrievalSetting.title', { ns: 'datasetSettings' })}
displayedValue={retrievalLabel} displayedValue={retrievalLabel}
valueIcon={<Image className="size-4" src={retrievalIconSrc} alt="" />} valueIcon={<img className="size-4" src={retrievalIconSrc} alt="" />}
/> />
</div> </div>
) )

View File

@ -5,12 +5,12 @@ import Research from './assets/research-mod.svg'
import Selection from './assets/selection-mod.svg' import Selection from './assets/selection-mod.svg'
export const indexMethodIcon = { export const indexMethodIcon = {
high_quality: GoldIcon, high_quality: GoldIcon.src,
economical: Piggybank, economical: Piggybank.src,
} }
export const retrievalIcon = { export const retrievalIcon = {
vector: Selection, vector: Selection.src,
fullText: Research, fullText: Research.src,
hybrid: PatternRecognition, hybrid: PatternRecognition.src,
} }

View File

@ -47,19 +47,19 @@ describe('MaxLengthInput', () => {
it('should render number input', () => { it('should render number input', () => {
render(<MaxLengthInput onChange={vi.fn()} />) render(<MaxLengthInput onChange={vi.fn()} />)
const input = screen.getByRole('spinbutton') const input = screen.getByRole('textbox')
expect(input).toBeInTheDocument() expect(input).toBeInTheDocument()
}) })
it('should accept value prop', () => { it('should accept value prop', () => {
render(<MaxLengthInput value={500} onChange={vi.fn()} />) render(<MaxLengthInput value={500} onChange={vi.fn()} />)
expect(screen.getByDisplayValue('500')).toBeInTheDocument() expect(screen.getByRole('textbox')).toHaveValue('500')
}) })
it('should have min of 1', () => { it('should have min of 1', () => {
render(<MaxLengthInput onChange={vi.fn()} />) render(<MaxLengthInput onChange={vi.fn()} />)
const input = screen.getByRole('spinbutton') const input = screen.getByRole('textbox')
expect(input).toHaveAttribute('min', '1') expect(input).toBeInTheDocument()
}) })
}) })
@ -75,18 +75,18 @@ describe('OverlapInput', () => {
it('should render number input', () => { it('should render number input', () => {
render(<OverlapInput onChange={vi.fn()} />) render(<OverlapInput onChange={vi.fn()} />)
const input = screen.getByRole('spinbutton') const input = screen.getByRole('textbox')
expect(input).toBeInTheDocument() expect(input).toBeInTheDocument()
}) })
it('should accept value prop', () => { it('should accept value prop', () => {
render(<OverlapInput value={50} onChange={vi.fn()} />) render(<OverlapInput value={50} onChange={vi.fn()} />)
expect(screen.getByDisplayValue('50')).toBeInTheDocument() expect(screen.getByRole('textbox')).toHaveValue('50')
}) })
it('should have min of 1', () => { it('should have min of 1', () => {
render(<OverlapInput onChange={vi.fn()} />) render(<OverlapInput onChange={vi.fn()} />)
const input = screen.getByRole('spinbutton') const input = screen.getByRole('textbox')
expect(input).toHaveAttribute('min', '1') expect(input).toBeInTheDocument()
}) })
}) })

View File

@ -2,13 +2,6 @@ import { fireEvent, render, screen } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest' import { beforeEach, describe, expect, it, vi } from 'vitest'
import { OptionCard, OptionCardHeader } from '../option-card' import { OptionCard, OptionCardHeader } from '../option-card'
// Override global next/image auto-mock: tests assert on rendered <img> elements
vi.mock('next/image', () => ({
default: ({ src, alt, ...props }: { src?: string, alt?: string, width?: number, height?: number }) => (
<img src={src} alt={alt} {...props} />
),
}))
describe('OptionCardHeader', () => { describe('OptionCardHeader', () => {
const defaultProps = { const defaultProps = {
icon: <span data-testid="icon">icon</span>, icon: <span data-testid="icon">icon</span>,

View File

@ -6,7 +6,6 @@ import {
RiAlertFill, RiAlertFill,
RiSearchEyeLine, RiSearchEyeLine,
} from '@remixicon/react' } from '@remixicon/react'
import Image from 'next/image'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import Button from '@/app/components/base/button' import Button from '@/app/components/base/button'
import Checkbox from '@/app/components/base/checkbox' import Checkbox from '@/app/components/base/checkbox'
@ -26,7 +25,7 @@ type TextLabelProps = {
} }
const TextLabel: FC<TextLabelProps> = ({ children }) => { const TextLabel: FC<TextLabelProps> = ({ children }) => {
return <label className="system-sm-semibold text-text-secondary">{children}</label> return <label className="text-text-secondary system-sm-semibold">{children}</label>
} }
type GeneralChunkingOptionsProps = { type GeneralChunkingOptionsProps = {
@ -97,7 +96,7 @@ export const GeneralChunkingOptions: FC<GeneralChunkingOptionsProps> = ({
<OptionCard <OptionCard
className="mb-2 bg-background-section" className="mb-2 bg-background-section"
title={t('stepTwo.general', { ns: 'datasetCreation' })} title={t('stepTwo.general', { ns: 'datasetCreation' })}
icon={<Image width={20} height={20} src={SettingCog} alt={t('stepTwo.general', { ns: 'datasetCreation' })} />} icon={<img width={20} height={20} src={SettingCog.src} alt={t('stepTwo.general', { ns: 'datasetCreation' })} />}
activeHeaderClassName="bg-dataset-option-card-blue-gradient" activeHeaderClassName="bg-dataset-option-card-blue-gradient"
description={t('stepTwo.generalTip', { ns: 'datasetCreation' })} description={t('stepTwo.generalTip', { ns: 'datasetCreation' })}
isActive={isActive} isActive={isActive}
@ -148,7 +147,7 @@ export const GeneralChunkingOptions: FC<GeneralChunkingOptionsProps> = ({
onClick={() => onRuleToggle(rule.id)} onClick={() => onRuleToggle(rule.id)}
> >
<Checkbox checked={rule.enabled} /> <Checkbox checked={rule.enabled} />
<label className="system-sm-regular ml-2 cursor-pointer text-text-secondary"> <label className="ml-2 cursor-pointer text-text-secondary system-sm-regular">
{getRuleName(rule.id)} {getRuleName(rule.id)}
</label> </label>
</div> </div>
@ -183,7 +182,7 @@ export const GeneralChunkingOptions: FC<GeneralChunkingOptionsProps> = ({
checked={currentDocForm === ChunkingMode.qa} checked={currentDocForm === ChunkingMode.qa}
disabled={hasCurrentDatasetDocForm} disabled={hasCurrentDatasetDocForm}
/> />
<label className="system-sm-regular ml-2 cursor-pointer text-text-secondary"> <label className="ml-2 cursor-pointer text-text-secondary system-sm-regular">
{t('stepTwo.useQALanguage', { ns: 'datasetCreation' })} {t('stepTwo.useQALanguage', { ns: 'datasetCreation' })}
</label> </label>
</div> </div>
@ -202,7 +201,7 @@ export const GeneralChunkingOptions: FC<GeneralChunkingOptionsProps> = ({
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]" 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]"
> >
<RiAlertFill className="size-4 text-text-warning-secondary" /> <RiAlertFill className="size-4 text-text-warning-secondary" />
<span className="system-xs-medium text-text-primary"> <span className="text-text-primary system-xs-medium">
{t('stepTwo.QATip', { ns: 'datasetCreation' })} {t('stepTwo.QATip', { ns: 'datasetCreation' })}
</span> </span>
</div> </div>

View File

@ -3,7 +3,6 @@
import type { FC } from 'react' import type { FC } from 'react'
import type { DefaultModel, Model } from '@/app/components/header/account-setting/model-provider-page/declarations' import type { DefaultModel, Model } from '@/app/components/header/account-setting/model-provider-page/declarations'
import type { RetrievalConfig } from '@/types/app' import type { RetrievalConfig } from '@/types/app'
import Image from 'next/image'
import Link from 'next/link' import Link from 'next/link'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import Badge from '@/app/components/base/badge' import Badge from '@/app/components/base/badge'
@ -70,7 +69,7 @@ export const IndexingModeSection: FC<IndexingModeSectionProps> = ({
return ( return (
<> <>
{/* Index Mode */} {/* Index Mode */}
<div className="system-md-semibold mb-1 text-text-secondary"> <div className="mb-1 text-text-secondary system-md-semibold">
{t('stepTwo.indexMode', { ns: 'datasetCreation' })} {t('stepTwo.indexMode', { ns: 'datasetCreation' })}
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
@ -98,7 +97,7 @@ export const IndexingModeSection: FC<IndexingModeSectionProps> = ({
</div> </div>
)} )}
description={t('stepTwo.qualifiedTip', { ns: 'datasetCreation' })} description={t('stepTwo.qualifiedTip', { ns: 'datasetCreation' })}
icon={<Image src={indexMethodIcon.high_quality} alt="" />} icon={<img src={indexMethodIcon.high_quality} alt="" />}
isActive={!hasSetIndexType && indexType === IndexingType.QUALIFIED} isActive={!hasSetIndexType && indexType === IndexingType.QUALIFIED}
disabled={hasSetIndexType} disabled={hasSetIndexType}
onSwitched={() => onIndexTypeChange(IndexingType.QUALIFIED)} onSwitched={() => onIndexTypeChange(IndexingType.QUALIFIED)}
@ -143,7 +142,7 @@ export const IndexingModeSection: FC<IndexingModeSectionProps> = ({
className="h-full" className="h-full"
title={t('stepTwo.economical', { ns: 'datasetCreation' })} title={t('stepTwo.economical', { ns: 'datasetCreation' })}
description={t('stepTwo.economicalTip', { ns: 'datasetCreation' })} description={t('stepTwo.economicalTip', { ns: 'datasetCreation' })}
icon={<Image src={indexMethodIcon.economical} alt="" />} icon={<img src={indexMethodIcon.economical} alt="" />}
isActive={!hasSetIndexType && indexType === IndexingType.ECONOMICAL} isActive={!hasSetIndexType && indexType === IndexingType.ECONOMICAL}
disabled={hasSetIndexType || docForm !== ChunkingMode.text} disabled={hasSetIndexType || docForm !== ChunkingMode.text}
onSwitched={() => onIndexTypeChange(IndexingType.ECONOMICAL)} onSwitched={() => onIndexTypeChange(IndexingType.ECONOMICAL)}
@ -160,7 +159,7 @@ export const IndexingModeSection: FC<IndexingModeSectionProps> = ({
<div className="p-1"> <div className="p-1">
<AlertTriangle className="size-4 text-text-warning-secondary" /> <AlertTriangle className="size-4 text-text-warning-secondary" />
</div> </div>
<span className="system-xs-medium text-text-primary"> <span className="text-text-primary system-xs-medium">
{t('stepTwo.highQualityTip', { ns: 'datasetCreation' })} {t('stepTwo.highQualityTip', { ns: 'datasetCreation' })}
</span> </span>
</div> </div>
@ -168,7 +167,7 @@ export const IndexingModeSection: FC<IndexingModeSectionProps> = ({
{/* Economical index setting tip */} {/* Economical index setting tip */}
{hasSetIndexType && indexType === IndexingType.ECONOMICAL && ( {hasSetIndexType && indexType === IndexingType.ECONOMICAL && (
<div className="system-xs-medium mt-2 text-text-tertiary"> <div className="mt-2 text-text-tertiary system-xs-medium">
{t('stepTwo.indexSettingTip', { ns: 'datasetCreation' })} {t('stepTwo.indexSettingTip', { ns: 'datasetCreation' })}
<Link className="text-text-accent" href={`/datasets/${datasetId}/settings`}> <Link className="text-text-accent" href={`/datasets/${datasetId}/settings`}>
{t('stepTwo.datasetSettingLink', { ns: 'datasetCreation' })} {t('stepTwo.datasetSettingLink', { ns: 'datasetCreation' })}
@ -179,7 +178,7 @@ export const IndexingModeSection: FC<IndexingModeSectionProps> = ({
{/* Embedding model */} {/* Embedding model */}
{indexType === IndexingType.QUALIFIED && ( {indexType === IndexingType.QUALIFIED && (
<div className="mt-5"> <div className="mt-5">
<div className={cn('system-md-semibold mb-1 text-text-secondary', datasetId && 'flex items-center justify-between')}> <div className={cn('mb-1 text-text-secondary system-md-semibold', datasetId && 'flex items-center justify-between')}>
{t('form.embeddingModel', { ns: 'datasetSettings' })} {t('form.embeddingModel', { ns: 'datasetSettings' })}
</div> </div>
<ModelSelector <ModelSelector
@ -190,7 +189,7 @@ export const IndexingModeSection: FC<IndexingModeSectionProps> = ({
onSelect={onEmbeddingModelChange} onSelect={onEmbeddingModelChange}
/> />
{isModelAndRetrievalConfigDisabled && ( {isModelAndRetrievalConfigDisabled && (
<div className="system-xs-medium mt-2 text-text-tertiary"> <div className="mt-2 text-text-tertiary system-xs-medium">
{t('stepTwo.indexSettingTip', { ns: 'datasetCreation' })} {t('stepTwo.indexSettingTip', { ns: 'datasetCreation' })}
<Link className="text-text-accent" href={`/datasets/${datasetId}/settings`}> <Link className="text-text-accent" href={`/datasets/${datasetId}/settings`}>
{t('stepTwo.datasetSettingLink', { ns: 'datasetCreation' })} {t('stepTwo.datasetSettingLink', { ns: 'datasetCreation' })}
@ -207,10 +206,10 @@ export const IndexingModeSection: FC<IndexingModeSectionProps> = ({
{!isModelAndRetrievalConfigDisabled {!isModelAndRetrievalConfigDisabled
? ( ? (
<div className="mb-1"> <div className="mb-1">
<div className="system-md-semibold mb-0.5 text-text-secondary"> <div className="mb-0.5 text-text-secondary system-md-semibold">
{t('form.retrievalSetting.title', { ns: 'datasetSettings' })} {t('form.retrievalSetting.title', { ns: 'datasetSettings' })}
</div> </div>
<div className="body-xs-regular text-text-tertiary"> <div className="text-text-tertiary body-xs-regular">
<a <a
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
@ -224,7 +223,7 @@ export const IndexingModeSection: FC<IndexingModeSectionProps> = ({
</div> </div>
) )
: ( : (
<div className={cn('system-md-semibold mb-0.5 text-text-secondary', 'flex items-center justify-between')}> <div className={cn('mb-0.5 text-text-secondary system-md-semibold', 'flex items-center justify-between')}>
<div>{t('form.retrievalSetting.title', { ns: 'datasetSettings' })}</div> <div>{t('form.retrievalSetting.title', { ns: 'datasetSettings' })}</div>
</div> </div>
)} )}

View File

@ -1,5 +1,4 @@
import type { ComponentProps, FC, ReactNode } from 'react' import type { ComponentProps, FC, ReactNode } from 'react'
import Image from 'next/image'
import { cn } from '@/utils/classnames' import { cn } from '@/utils/classnames'
const TriangleArrow: FC<ComponentProps<'svg'>> = props => ( const TriangleArrow: FC<ComponentProps<'svg'>> = props => (
@ -23,7 +22,7 @@ export const OptionCardHeader: FC<OptionCardHeaderProps> = (props) => {
return ( return (
<div className={cn('relative flex h-full overflow-hidden rounded-t-xl', isActive && activeClassName, !disabled && 'cursor-pointer')}> <div className={cn('relative flex h-full overflow-hidden rounded-t-xl', isActive && activeClassName, !disabled && 'cursor-pointer')}>
<div className="relative flex size-14 items-center justify-center overflow-hidden"> <div className="relative flex size-14 items-center justify-center overflow-hidden">
{isActive && effectImg && <Image src={effectImg} className="absolute left-0 top-0 h-full w-full" alt="" width={56} height={56} />} {isActive && effectImg && <img src={effectImg} className="absolute left-0 top-0 h-full w-full" alt="" width={56} height={56} />}
<div className="p-1"> <div className="p-1">
<div className="flex size-8 justify-center rounded-lg border border-components-panel-border-subtle bg-background-default-dodge p-1.5 shadow-md"> <div className="flex size-8 justify-center rounded-lg border border-components-panel-border-subtle bg-background-default-dodge p-1.5 shadow-md">
{icon} {icon}
@ -34,8 +33,8 @@ export const OptionCardHeader: FC<OptionCardHeaderProps> = (props) => {
className={cn('absolute -bottom-1.5 left-4 text-transparent', isActive && 'text-components-panel-bg')} className={cn('absolute -bottom-1.5 left-4 text-transparent', isActive && 'text-components-panel-bg')}
/> />
<div className="flex-1 space-y-0.5 py-3 pr-4"> <div className="flex-1 space-y-0.5 py-3 pr-4">
<div className="system-md-semibold text-text-secondary">{title}</div> <div className="text-text-secondary system-md-semibold">{title}</div>
<div className="system-xs-regular text-text-tertiary">{description}</div> <div className="text-text-tertiary system-xs-regular">{description}</div>
</div> </div>
</div> </div>
) )

View File

@ -4,7 +4,6 @@ import type { FC } from 'react'
import type { ParentChildConfig } from '../hooks' import type { ParentChildConfig } from '../hooks'
import type { ParentMode, PreProcessingRule, SummaryIndexSetting as SummaryIndexSettingType } from '@/models/datasets' import type { ParentMode, PreProcessingRule, SummaryIndexSetting as SummaryIndexSettingType } from '@/models/datasets'
import { RiSearchEyeLine } from '@remixicon/react' import { RiSearchEyeLine } from '@remixicon/react'
import Image from 'next/image'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import Button from '@/app/components/base/button' import Button from '@/app/components/base/button'
import Checkbox from '@/app/components/base/checkbox' import Checkbox from '@/app/components/base/checkbox'
@ -26,7 +25,7 @@ type TextLabelProps = {
} }
const TextLabel: FC<TextLabelProps> = ({ children }) => { const TextLabel: FC<TextLabelProps> = ({ children }) => {
return <label className="system-sm-semibold text-text-secondary">{children}</label> return <label className="text-text-secondary system-sm-semibold">{children}</label>
} }
type ParentChildOptionsProps = { type ParentChildOptionsProps = {
@ -118,7 +117,7 @@ export const ParentChildOptions: FC<ParentChildOptionsProps> = ({
</div> </div>
<RadioCard <RadioCard
className="mt-1" className="mt-1"
icon={<Image src={Note} alt="" />} icon={<img src={Note.src} alt="" />}
title={t('stepTwo.paragraph', { ns: 'datasetCreation' })} title={t('stepTwo.paragraph', { ns: 'datasetCreation' })}
description={t('stepTwo.paragraphTip', { ns: 'datasetCreation' })} description={t('stepTwo.paragraphTip', { ns: 'datasetCreation' })}
isChosen={parentChildConfig.chunkForContext === 'paragraph'} isChosen={parentChildConfig.chunkForContext === 'paragraph'}
@ -140,7 +139,7 @@ export const ParentChildOptions: FC<ParentChildOptionsProps> = ({
/> />
<RadioCard <RadioCard
className="mt-2" className="mt-2"
icon={<Image src={FileList} alt="" />} icon={<img src={FileList.src} alt="" />}
title={t('stepTwo.fullDoc', { ns: 'datasetCreation' })} title={t('stepTwo.fullDoc', { ns: 'datasetCreation' })}
description={t('stepTwo.fullDocTip', { ns: 'datasetCreation' })} description={t('stepTwo.fullDocTip', { ns: 'datasetCreation' })}
onChosen={() => onChunkForContextChange('full-doc')} onChosen={() => onChunkForContextChange('full-doc')}
@ -186,7 +185,7 @@ export const ParentChildOptions: FC<ParentChildOptionsProps> = ({
onClick={() => onRuleToggle(rule.id)} onClick={() => onRuleToggle(rule.id)}
> >
<Checkbox checked={rule.enabled} /> <Checkbox checked={rule.enabled} />
<label className="system-sm-regular ml-2 cursor-pointer text-text-secondary"> <label className="ml-2 cursor-pointer text-text-secondary system-sm-regular">
{getRuleName(rule.id)} {getRuleName(rule.id)}
</label> </label>
</div> </div>

View File

@ -6,14 +6,6 @@ import { ProcessMode } from '@/models/datasets'
import { RETRIEVE_METHOD } from '@/types/app' import { RETRIEVE_METHOD } from '@/types/app'
import RuleDetail from '../rule-detail' 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 <img src={src} alt={alt} className={className} data-testid="next-image" />
},
}))
// Mock FieldInfo component // Mock FieldInfo component
vi.mock('@/app/components/datasets/documents/detail/metadata', () => ({ vi.mock('@/app/components/datasets/documents/detail/metadata', () => ({
FieldInfo: ({ label, displayedValue, valueIcon }: { label: string, displayedValue: string, valueIcon?: React.ReactNode }) => ( 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', () => { it('should show high_quality icon for qualified indexing', () => {
render(<RuleDetail indexingType={IndexingType.QUALIFIED} />) const { container } = render(<RuleDetail indexingType={IndexingType.QUALIFIED} />)
const images = screen.getAllByTestId('next-image') const images = container.querySelectorAll('img')
expect(images[0]).toHaveAttribute('src', '/icons/high_quality.svg') expect(images[0]).toHaveAttribute('src', '/icons/high_quality.svg')
}) })
it('should show economical icon for economical indexing', () => { it('should show economical icon for economical indexing', () => {
render(<RuleDetail indexingType={IndexingType.ECONOMICAL} />) const { container } = render(<RuleDetail indexingType={IndexingType.ECONOMICAL} />)
const images = screen.getAllByTestId('next-image') const images = container.querySelectorAll('img')
expect(images[0]).toHaveAttribute('src', '/icons/economical.svg') expect(images[0]).toHaveAttribute('src', '/icons/economical.svg')
}) })
}) })
@ -256,38 +248,38 @@ describe('RuleDetail', () => {
}) })
it('should show vector icon for semantic search', () => { it('should show vector icon for semantic search', () => {
render( const { container } = render(
<RuleDetail <RuleDetail
indexingType={IndexingType.QUALIFIED} indexingType={IndexingType.QUALIFIED}
retrievalMethod={RETRIEVE_METHOD.semantic} retrievalMethod={RETRIEVE_METHOD.semantic}
/>, />,
) )
const images = screen.getAllByTestId('next-image') const images = container.querySelectorAll('img')
expect(images[1]).toHaveAttribute('src', '/icons/vector.svg') expect(images[1]).toHaveAttribute('src', '/icons/vector.svg')
}) })
it('should show fullText icon for full text search', () => { it('should show fullText icon for full text search', () => {
render( const { container } = render(
<RuleDetail <RuleDetail
indexingType={IndexingType.QUALIFIED} indexingType={IndexingType.QUALIFIED}
retrievalMethod={RETRIEVE_METHOD.fullText} retrievalMethod={RETRIEVE_METHOD.fullText}
/>, />,
) )
const images = screen.getAllByTestId('next-image') const images = container.querySelectorAll('img')
expect(images[1]).toHaveAttribute('src', '/icons/fullText.svg') expect(images[1]).toHaveAttribute('src', '/icons/fullText.svg')
}) })
it('should show hybrid icon for hybrid search', () => { it('should show hybrid icon for hybrid search', () => {
render( const { container } = render(
<RuleDetail <RuleDetail
indexingType={IndexingType.QUALIFIED} indexingType={IndexingType.QUALIFIED}
retrievalMethod={RETRIEVE_METHOD.hybrid} retrievalMethod={RETRIEVE_METHOD.hybrid}
/>, />,
) )
const images = screen.getAllByTestId('next-image') const images = container.querySelectorAll('img')
expect(images[1]).toHaveAttribute('src', '/icons/hybrid.svg') expect(images[1]).toHaveAttribute('src', '/icons/hybrid.svg')
}) })
}) })
@ -308,9 +300,9 @@ describe('RuleDetail', () => {
}) })
it('should handle undefined retrievalMethod with defined indexingType', () => { it('should handle undefined retrievalMethod with defined indexingType', () => {
render(<RuleDetail indexingType={IndexingType.QUALIFIED} />) const { container } = render(<RuleDetail indexingType={IndexingType.QUALIFIED} />)
const images = screen.getAllByTestId('next-image') const images = container.querySelectorAll('img')
// When retrievalMethod is undefined, vector icon is used as default // When retrievalMethod is undefined, vector icon is used as default
expect(images[1]).toHaveAttribute('src', '/icons/vector.svg') expect(images[1]).toHaveAttribute('src', '/icons/vector.svg')
}) })

View File

@ -1,5 +1,4 @@
import type { ProcessRuleResponse } from '@/models/datasets' import type { ProcessRuleResponse } from '@/models/datasets'
import Image from 'next/image'
import * as React from 'react' import * as React from 'react'
import { useCallback } from 'react' import { useCallback } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
@ -50,7 +49,7 @@ const RuleDetail = ({
label={t('stepTwo.indexMode', { ns: 'datasetCreation' })} label={t('stepTwo.indexMode', { ns: 'datasetCreation' })}
displayedValue={t(`stepTwo.${indexingType === IndexingType.ECONOMICAL ? 'economical' : 'qualified'}`, { ns: 'datasetCreation' }) as string} displayedValue={t(`stepTwo.${indexingType === IndexingType.ECONOMICAL ? 'economical' : 'qualified'}`, { ns: 'datasetCreation' }) as string}
valueIcon={( valueIcon={(
<Image <img
className="size-4" className="size-4"
src={ src={
indexingType === IndexingType.ECONOMICAL indexingType === IndexingType.ECONOMICAL
@ -65,7 +64,7 @@ const RuleDetail = ({
label={t('form.retrievalSetting.title', { ns: 'datasetSettings' })} label={t('form.retrievalSetting.title', { ns: 'datasetSettings' })}
displayedValue={t(`retrieval.${indexingType === IndexingType.ECONOMICAL ? 'keyword_search' : retrievalMethod ?? 'semantic_search'}.title`, { ns: 'dataset' })} displayedValue={t(`retrieval.${indexingType === IndexingType.ECONOMICAL ? 'keyword_search' : retrievalMethod ?? 'semantic_search'}.title`, { ns: 'dataset' })}
valueIcon={( valueIcon={(
<Image <img
className="size-4" className="size-4"
src={ src={
retrievalMethod === RETRIEVE_METHOD.fullText retrievalMethod === RETRIEVE_METHOD.fullText

View File

@ -1,7 +1,6 @@
import type { FC } from 'react' import type { FC } from 'react'
import type { ProcessRuleResponse } from '@/models/datasets' import type { ProcessRuleResponse } from '@/models/datasets'
import type { RETRIEVE_METHOD } from '@/types/app' import type { RETRIEVE_METHOD } from '@/types/app'
import Image from 'next/image'
import * as React from 'react' import * as React from 'react'
import { useCallback } from 'react' import { useCallback } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
@ -101,7 +100,7 @@ const RuleDetail: FC<RuleDetailProps> = React.memo(({
label={t('stepTwo.indexMode', { ns: 'datasetCreation' })} label={t('stepTwo.indexMode', { ns: 'datasetCreation' })}
displayedValue={t(`stepTwo.${isEconomical ? 'economical' : 'qualified'}`, { ns: 'datasetCreation' }) as string} displayedValue={t(`stepTwo.${isEconomical ? 'economical' : 'qualified'}`, { ns: 'datasetCreation' }) as string}
valueIcon={( valueIcon={(
<Image <img
className="size-4" className="size-4"
src={isEconomical ? indexMethodIcon.economical : indexMethodIcon.high_quality} src={isEconomical ? indexMethodIcon.economical : indexMethodIcon.high_quality}
alt="" alt=""
@ -112,7 +111,7 @@ const RuleDetail: FC<RuleDetailProps> = React.memo(({
label={t('form.retrievalSetting.title', { ns: 'datasetSettings' })} label={t('form.retrievalSetting.title', { ns: 'datasetSettings' })}
displayedValue={t(`retrieval.${isEconomical ? 'keyword_search' : retrievalMethod ?? 'semantic_search'}.title`, { ns: 'dataset' })} displayedValue={t(`retrieval.${isEconomical ? 'keyword_search' : retrievalMethod ?? 'semantic_search'}.title`, { ns: 'dataset' })}
valueIcon={( valueIcon={(
<Image <img
className="size-4" className="size-4"
src={getRetrievalIcon(retrievalMethod)} src={getRetrievalIcon(retrievalMethod)}
alt="" alt=""

View File

@ -905,8 +905,8 @@ describe('ExternalKnowledgeBaseCreate', () => {
/>, />,
) )
// The TopKItem should render an input // The TopKItem renders the visible number-field input as a textbox.
const inputs = screen.getAllByRole('spinbutton') const inputs = screen.getAllByRole('textbox')
const topKInput = inputs[0] const topKInput = inputs[0]
fireEvent.change(topKInput, { target: { value: '8' } }) fireEvent.change(topKInput, { target: { value: '8' } })
@ -924,8 +924,8 @@ describe('ExternalKnowledgeBaseCreate', () => {
/>, />,
) )
// The ScoreThresholdItem should render an input // The ScoreThresholdItem renders the visible number-field input as a textbox.
const inputs = screen.getAllByRole('spinbutton') const inputs = screen.getAllByRole('textbox')
const scoreThresholdInput = inputs[1] const scoreThresholdInput = inputs[1]
fireEvent.change(scoreThresholdInput, { target: { value: '0.8' } }) fireEvent.change(scoreThresholdInput, { target: { value: '0.8' } })

View File

@ -14,7 +14,6 @@ import {
RiEqualizer2Line, RiEqualizer2Line,
RiPlayCircleLine, RiPlayCircleLine,
} from '@remixicon/react' } from '@remixicon/react'
import Image from 'next/image'
import * as React from 'react' import * as React from 'react'
import { useCallback, useMemo, useState } from 'react' import { useCallback, useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
@ -178,7 +177,7 @@ const QueryInput = ({
}, [text, externalRetrievalSettings, externalKnowledgeBaseHitTestingMutation, onUpdateList, setExternalHitResult]) }, [text, externalRetrievalSettings, externalKnowledgeBaseHitTestingMutation, onUpdateList, setExternalHitResult])
const retrievalMethod = isEconomy ? RETRIEVE_METHOD.keywordSearch : retrievalConfig.search_method const retrievalMethod = isEconomy ? RETRIEVE_METHOD.keywordSearch : retrievalConfig.search_method
const icon = <Image className="size-3.5 text-util-colors-purple-purple-600" src={getIcon(retrievalMethod)} alt="" /> const icon = <img className="size-3.5 text-util-colors-purple-purple-600" src={getIcon(retrievalMethod)} alt="" />
const TextAreaComp = useMemo(() => { const TextAreaComp = useMemo(() => {
return ( return (
<Textarea <Textarea
@ -206,7 +205,7 @@ const QueryInput = ({
<div className={cn('relative flex h-80 shrink-0 flex-col overflow-hidden rounded-xl bg-gradient-to-r from-components-input-border-active-prompt-1 to-components-input-border-active-prompt-2 p-0.5 shadow-xs')}> <div className={cn('relative flex h-80 shrink-0 flex-col overflow-hidden rounded-xl bg-gradient-to-r from-components-input-border-active-prompt-1 to-components-input-border-active-prompt-2 p-0.5 shadow-xs')}>
<div className="flex h-full flex-col overflow-hidden rounded-[10px] bg-background-section-burn"> <div className="flex h-full flex-col overflow-hidden rounded-[10px] bg-background-section-burn">
<div className="relative flex shrink-0 items-center justify-between p-1.5 pb-1 pl-3"> <div className="relative flex shrink-0 items-center justify-between p-1.5 pb-1 pl-3">
<span className="system-sm-semibold-uppercase text-text-secondary"> <span className="text-text-secondary system-sm-semibold-uppercase">
{t('input.title', { ns: 'datasetHitTesting' })} {t('input.title', { ns: 'datasetHitTesting' })}
</span> </span>
{isExternal {isExternal
@ -218,7 +217,7 @@ const QueryInput = ({
> >
<RiEqualizer2Line className="h-3.5 w-3.5 text-components-button-secondary-text" /> <RiEqualizer2Line className="h-3.5 w-3.5 text-components-button-secondary-text" />
<div className="flex items-center justify-center gap-1 px-[3px]"> <div className="flex items-center justify-center gap-1 px-[3px]">
<span className="system-xs-medium text-components-button-secondary-text">{t('settingTitle', { ns: 'datasetHitTesting' })}</span> <span className="text-components-button-secondary-text system-xs-medium">{t('settingTitle', { ns: 'datasetHitTesting' })}</span>
</div> </div>
</Button> </Button>
) )

View File

@ -43,8 +43,9 @@ describe('InputCombined', () => {
render( render(
<InputCombined type={DataType.number} value={42} onChange={handleChange} />, <InputCombined type={DataType.number} value={42} onChange={handleChange} />,
) )
const input = screen.getByDisplayValue('42') const input = screen.getByRole('textbox')
expect(input).toBeInTheDocument() expect(input).toBeInTheDocument()
expect(input).toHaveValue('42')
}) })
it('should render date picker for time type', () => { it('should render date picker for time type', () => {
@ -96,7 +97,7 @@ describe('InputCombined', () => {
<InputCombined type={DataType.number} value={0} onChange={handleChange} />, <InputCombined type={DataType.number} value={0} onChange={handleChange} />,
) )
const input = screen.getByRole('spinbutton') const input = screen.getByRole('textbox')
fireEvent.change(input, { target: { value: '123' } }) fireEvent.change(input, { target: { value: '123' } })
expect(handleChange).toHaveBeenCalled() expect(handleChange).toHaveBeenCalled()
@ -108,7 +109,7 @@ describe('InputCombined', () => {
<InputCombined type={DataType.number} value={999} onChange={handleChange} />, <InputCombined type={DataType.number} value={999} onChange={handleChange} />,
) )
expect(screen.getByDisplayValue('999')).toBeInTheDocument() expect(screen.getByRole('textbox')).toHaveValue('999')
}) })
it('should apply readOnly prop to number input', () => { it('should apply readOnly prop to number input', () => {
@ -117,7 +118,7 @@ describe('InputCombined', () => {
<InputCombined type={DataType.number} value={42} onChange={handleChange} readOnly />, <InputCombined type={DataType.number} value={42} onChange={handleChange} readOnly />,
) )
const input = screen.getByRole('spinbutton') const input = screen.getByRole('textbox')
expect(input).toHaveAttribute('readonly') expect(input).toHaveAttribute('readonly')
}) })
}) })
@ -186,7 +187,7 @@ describe('InputCombined', () => {
<InputCombined type={DataType.number} value={null} onChange={handleChange} />, <InputCombined type={DataType.number} value={null} onChange={handleChange} />,
) )
const input = screen.getByRole('spinbutton') const input = screen.getByRole('textbox')
expect(input).toBeInTheDocument() expect(input).toBeInTheDocument()
}) })
}) })
@ -208,7 +209,7 @@ describe('InputCombined', () => {
<InputCombined type={DataType.number} value={0} onChange={handleChange} />, <InputCombined type={DataType.number} value={0} onChange={handleChange} />,
) )
const input = screen.getByRole('spinbutton') const input = screen.getByRole('textbox')
expect(input).toHaveClass('rounded-l-md') expect(input).toHaveClass('rounded-l-md')
}) })
}) })
@ -230,7 +231,7 @@ describe('InputCombined', () => {
<InputCombined type={DataType.number} value={0} onChange={handleChange} />, <InputCombined type={DataType.number} value={0} onChange={handleChange} />,
) )
expect(screen.getByDisplayValue('0')).toBeInTheDocument() expect(screen.getByRole('textbox')).toHaveValue('0')
}) })
it('should handle negative number', () => { it('should handle negative number', () => {
@ -239,7 +240,7 @@ describe('InputCombined', () => {
<InputCombined type={DataType.number} value={-100} onChange={handleChange} />, <InputCombined type={DataType.number} value={-100} onChange={handleChange} />,
) )
expect(screen.getByDisplayValue('-100')).toBeInTheDocument() expect(screen.getByRole('textbox')).toHaveValue('-100')
}) })
it('should handle special characters in string', () => { it('should handle special characters in string', () => {
@ -263,7 +264,7 @@ describe('InputCombined', () => {
<InputCombined type={DataType.number} value={42} onChange={handleChange} />, <InputCombined type={DataType.number} value={42} onChange={handleChange} />,
) )
expect(screen.getByRole('spinbutton')).toBeInTheDocument() expect(screen.getByRole('textbox')).toBeInTheDocument()
}) })
}) })
}) })

View File

@ -129,15 +129,15 @@ describe('IndexMethod', () => {
it('should pass keywordNumber to KeywordNumber component', () => { it('should pass keywordNumber to KeywordNumber component', () => {
render(<IndexMethod {...defaultProps} keywordNumber={25} />) render(<IndexMethod {...defaultProps} keywordNumber={25} />)
const input = screen.getByRole('spinbutton') const input = screen.getByRole('textbox')
expect(input).toHaveValue(25) expect(input).toHaveValue('25')
}) })
it('should call onKeywordNumberChange when KeywordNumber changes', () => { it('should call onKeywordNumberChange when KeywordNumber changes', () => {
const handleKeywordChange = vi.fn() const handleKeywordChange = vi.fn()
render(<IndexMethod {...defaultProps} onKeywordNumberChange={handleKeywordChange} />) render(<IndexMethod {...defaultProps} onKeywordNumberChange={handleKeywordChange} />)
const input = screen.getByRole('spinbutton') const input = screen.getByRole('textbox')
fireEvent.change(input, { target: { value: '30' } }) fireEvent.change(input, { target: { value: '30' } })
expect(handleKeywordChange).toHaveBeenCalled() expect(handleKeywordChange).toHaveBeenCalled()
@ -192,14 +192,14 @@ describe('IndexMethod', () => {
it('should handle keywordNumber of 0', () => { it('should handle keywordNumber of 0', () => {
render(<IndexMethod {...defaultProps} keywordNumber={0} />) render(<IndexMethod {...defaultProps} keywordNumber={0} />)
const input = screen.getByRole('spinbutton') const input = screen.getByRole('textbox')
expect(input).toHaveValue(0) expect(input).toHaveValue('0')
}) })
it('should handle max keywordNumber', () => { it('should handle max keywordNumber', () => {
render(<IndexMethod {...defaultProps} keywordNumber={50} />) render(<IndexMethod {...defaultProps} keywordNumber={50} />)
const input = screen.getByRole('spinbutton') const input = screen.getByRole('textbox')
expect(input).toHaveValue(50) expect(input).toHaveValue('50')
}) })
}) })
}) })

View File

@ -38,15 +38,15 @@ describe('KeyWordNumber', () => {
it('should render input number field', () => { it('should render input number field', () => {
render(<KeyWordNumber {...defaultProps} />) render(<KeyWordNumber {...defaultProps} />)
expect(screen.getByRole('spinbutton')).toBeInTheDocument() expect(screen.getByRole('textbox')).toBeInTheDocument()
}) })
}) })
describe('Props', () => { describe('Props', () => {
it('should display correct keywordNumber value in input', () => { it('should display correct keywordNumber value in input', () => {
render(<KeyWordNumber {...defaultProps} keywordNumber={25} />) render(<KeyWordNumber {...defaultProps} keywordNumber={25} />)
const input = screen.getByRole('spinbutton') const input = screen.getByRole('textbox')
expect(input).toHaveValue(25) expect(input).toHaveValue('25')
}) })
it('should display different keywordNumber values', () => { it('should display different keywordNumber values', () => {
@ -54,8 +54,8 @@ describe('KeyWordNumber', () => {
values.forEach((value) => { values.forEach((value) => {
const { unmount } = render(<KeyWordNumber {...defaultProps} keywordNumber={value} />) const { unmount } = render(<KeyWordNumber {...defaultProps} keywordNumber={value} />)
const input = screen.getByRole('spinbutton') const input = screen.getByRole('textbox')
expect(input).toHaveValue(value) expect(input).toHaveValue(String(value))
unmount() unmount()
}) })
}) })
@ -82,7 +82,7 @@ describe('KeyWordNumber', () => {
const handleChange = vi.fn() const handleChange = vi.fn()
render(<KeyWordNumber {...defaultProps} onKeywordNumberChange={handleChange} />) render(<KeyWordNumber {...defaultProps} onKeywordNumberChange={handleChange} />)
const input = screen.getByRole('spinbutton') const input = screen.getByRole('textbox')
fireEvent.change(input, { target: { value: '30' } }) fireEvent.change(input, { target: { value: '30' } })
expect(handleChange).toHaveBeenCalled() expect(handleChange).toHaveBeenCalled()
@ -92,7 +92,7 @@ describe('KeyWordNumber', () => {
const handleChange = vi.fn() const handleChange = vi.fn()
render(<KeyWordNumber {...defaultProps} onKeywordNumberChange={handleChange} />) render(<KeyWordNumber {...defaultProps} onKeywordNumberChange={handleChange} />)
const input = screen.getByRole('spinbutton') const input = screen.getByRole('textbox')
fireEvent.change(input, { target: { value: '' } }) fireEvent.change(input, { target: { value: '' } })
// When value is empty/undefined, handleInputChange should not call onKeywordNumberChange // When value is empty/undefined, handleInputChange should not call onKeywordNumberChange
@ -117,32 +117,32 @@ describe('KeyWordNumber', () => {
describe('Edge Cases', () => { describe('Edge Cases', () => {
it('should handle minimum value (0)', () => { it('should handle minimum value (0)', () => {
render(<KeyWordNumber {...defaultProps} keywordNumber={0} />) render(<KeyWordNumber {...defaultProps} keywordNumber={0} />)
const input = screen.getByRole('spinbutton') const input = screen.getByRole('textbox')
expect(input).toHaveValue(0) expect(input).toHaveValue('0')
}) })
it('should handle maximum value (50)', () => { it('should handle maximum value (50)', () => {
render(<KeyWordNumber {...defaultProps} keywordNumber={50} />) render(<KeyWordNumber {...defaultProps} keywordNumber={50} />)
const input = screen.getByRole('spinbutton') const input = screen.getByRole('textbox')
expect(input).toHaveValue(50) expect(input).toHaveValue('50')
}) })
it('should handle value updates correctly', () => { it('should handle value updates correctly', () => {
const { rerender } = render(<KeyWordNumber {...defaultProps} keywordNumber={10} />) const { rerender } = render(<KeyWordNumber {...defaultProps} keywordNumber={10} />)
let input = screen.getByRole('spinbutton') let input = screen.getByRole('textbox')
expect(input).toHaveValue(10) expect(input).toHaveValue('10')
rerender(<KeyWordNumber {...defaultProps} keywordNumber={25} />) rerender(<KeyWordNumber {...defaultProps} keywordNumber={25} />)
input = screen.getByRole('spinbutton') input = screen.getByRole('textbox')
expect(input).toHaveValue(25) expect(input).toHaveValue('25')
}) })
it('should handle rapid value changes', () => { it('should handle rapid value changes', () => {
const handleChange = vi.fn() const handleChange = vi.fn()
render(<KeyWordNumber {...defaultProps} onKeywordNumberChange={handleChange} />) render(<KeyWordNumber {...defaultProps} onKeywordNumberChange={handleChange} />)
const input = screen.getByRole('spinbutton') const input = screen.getByRole('textbox')
// Simulate rapid changes via input with different values // Simulate rapid changes via input with different values
fireEvent.change(input, { target: { value: '15' } }) fireEvent.change(input, { target: { value: '15' } })
@ -162,7 +162,7 @@ describe('KeyWordNumber', () => {
it('should have accessible input', () => { it('should have accessible input', () => {
render(<KeyWordNumber {...defaultProps} />) render(<KeyWordNumber {...defaultProps} />)
const input = screen.getByRole('spinbutton') const input = screen.getByRole('textbox')
expect(input).toBeInTheDocument() expect(input).toBeInTheDocument()
}) })
}) })

View File

@ -1,4 +1,3 @@
import type { ImgHTMLAttributes } from 'react'
import type { TryAppInfo } from '@/service/try-app' import type { TryAppInfo } from '@/service/try-app'
import { cleanup, fireEvent, render, screen } from '@testing-library/react' import { cleanup, fireEvent, render, screen } from '@testing-library/react'
import * as React from 'react' import * as React from 'react'
@ -11,21 +10,6 @@ vi.mock('../use-get-requirements', () => ({
default: (...args: unknown[]) => mockUseGetRequirements(...args), default: (...args: unknown[]) => mockUseGetRequirements(...args),
})) }))
vi.mock('next/image', () => ({
default: ({
src,
alt,
unoptimized: _unoptimized,
...rest
}: {
src: string
alt: string
unoptimized?: boolean
} & ImgHTMLAttributes<HTMLImageElement>) => (
React.createElement('img', { src, alt, ...rest })
),
}))
const createMockAppDetail = (mode: string, overrides: Partial<TryAppInfo> = {}): TryAppInfo => ({ const createMockAppDetail = (mode: string, overrides: Partial<TryAppInfo> = {}): TryAppInfo => ({
id: 'test-app-id', id: 'test-app-id',
name: 'Test App Name', name: 'Test App Name',

View File

@ -1,7 +1,6 @@
'use client' 'use client'
import type { FC } from 'react' import type { FC } from 'react'
import type { TryAppInfo } from '@/service/try-app' import type { TryAppInfo } from '@/service/try-app'
import Image from 'next/image'
import * as React from 'react' import * as React from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { AppTypeIcon } from '@/app/components/app/type-selector' import { AppTypeIcon } from '@/app/components/app/type-selector'
@ -38,14 +37,13 @@ const RequirementIcon: FC<RequirementIconProps> = ({ iconUrl }) => {
} }
return ( return (
<Image <img
className="size-5 rounded-md object-cover shadow-xs" className="size-5 rounded-md object-cover shadow-xs"
src={iconUrl} src={iconUrl}
alt="" alt=""
aria-hidden="true" aria-hidden="true"
width={requirementIconSize} width={requirementIconSize}
height={requirementIconSize} height={requirementIconSize}
unoptimized
onError={() => setFailedSource(iconUrl)} onError={() => setFailedSource(iconUrl)}
/> />
) )

View File

@ -2,7 +2,7 @@ import { act, render, screen } from '@testing-library/react'
import { usePathname } from 'next/navigation' import { usePathname } from 'next/navigation'
import { vi } from 'vitest' import { vi } from 'vitest'
import { useEventEmitterContextContext } from '@/context/event-emitter' import { useEventEmitterContextContext } from '@/context/event-emitter'
import HeaderWrapper from './header-wrapper' import HeaderWrapper from '../header-wrapper'
vi.mock('next/navigation', () => ({ vi.mock('next/navigation', () => ({
usePathname: vi.fn(), usePathname: vi.fn(),

View File

@ -1,6 +1,6 @@
import { fireEvent, render, screen } from '@testing-library/react' import { fireEvent, render, screen } from '@testing-library/react'
import { vi } from 'vitest' import { vi } from 'vitest'
import Header from './index' import Header from '../index'
function createMockComponent(testId: string) { function createMockComponent(testId: string) {
return () => <div data-testid={testId} /> return () => <div data-testid={testId} />

View File

@ -2,7 +2,7 @@ import { fireEvent, render, screen } from '@testing-library/react'
import { vi } from 'vitest' import { vi } from 'vitest'
import { useLanguage } from '@/app/components/header/account-setting/model-provider-page/hooks' import { useLanguage } from '@/app/components/header/account-setting/model-provider-page/hooks'
import { NOTICE_I18N } from '@/i18n-config/language' import { NOTICE_I18N } from '@/i18n-config/language'
import MaintenanceNotice from './maintenance-notice' import MaintenanceNotice from '../maintenance-notice'
vi.mock('@/app/components/base/icons/src/vender/line/general', () => ({ vi.mock('@/app/components/base/icons/src/vender/line/general', () => ({
X: ({ onClick }: { onClick?: () => void }) => <button type="button" aria-label="close notice" onClick={onClick} />, X: ({ onClick }: { onClick?: () => void }) => <button type="button" aria-label="close notice" onClick={onClick} />,

View File

@ -2,7 +2,7 @@ import type { LangGeniusVersionResponse } from '@/models/common'
import type { SystemFeatures } from '@/types/feature' import type { SystemFeatures } from '@/types/feature'
import { fireEvent, render, screen } from '@testing-library/react' import { fireEvent, render, screen } from '@testing-library/react'
import { useGlobalPublicStore } from '@/context/global-public-context' import { useGlobalPublicStore } from '@/context/global-public-context'
import AccountAbout from './index' import AccountAbout from '../index'
vi.mock('@/context/global-public-context', () => ({ vi.mock('@/context/global-public-context', () => ({
useGlobalPublicStore: vi.fn(), useGlobalPublicStore: vi.fn(),

View File

@ -8,8 +8,8 @@ import { useModalContext } from '@/context/modal-context'
import { baseProviderContextValue, useProviderContext } from '@/context/provider-context' import { baseProviderContextValue, useProviderContext } from '@/context/provider-context'
import { getDocDownloadUrl } from '@/service/common' import { getDocDownloadUrl } from '@/service/common'
import { downloadUrl } from '@/utils/download' import { downloadUrl } from '@/utils/download'
import Toast from '../../base/toast' import Toast from '../../../base/toast'
import Compliance from './compliance' import Compliance from '../compliance'
vi.mock('@/context/provider-context', async (importOriginal) => { vi.mock('@/context/provider-context', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/context/provider-context')>() const actual = await importOriginal<typeof import('@/context/provider-context')>()

View File

@ -10,13 +10,13 @@ import { useGlobalPublicStore } from '@/context/global-public-context'
import { useModalContext } from '@/context/modal-context' import { useModalContext } from '@/context/modal-context'
import { useProviderContext } from '@/context/provider-context' import { useProviderContext } from '@/context/provider-context'
import { useLogout } from '@/service/use-common' import { useLogout } from '@/service/use-common'
import AppSelector from './index' import AppSelector from '../index'
vi.mock('../account-setting', () => ({ vi.mock('../../account-setting', () => ({
default: () => <div data-testid="account-setting">AccountSetting</div>, default: () => <div data-testid="account-setting">AccountSetting</div>,
})) }))
vi.mock('../account-about', () => ({ vi.mock('../../account-about', () => ({
default: ({ onCancel }: { onCancel: () => void }) => ( default: ({ onCancel }: { onCancel: () => void }) => (
<div data-testid="account-about"> <div data-testid="account-about">
Version Version

View File

@ -5,7 +5,7 @@ import { DropdownMenu, DropdownMenuContent, DropdownMenuTrigger } from '@/app/co
import { Plan } from '@/app/components/billing/type' import { Plan } from '@/app/components/billing/type'
import { useAppContext } from '@/context/app-context' import { useAppContext } from '@/context/app-context'
import { baseProviderContextValue, useProviderContext } from '@/context/provider-context' import { baseProviderContextValue, useProviderContext } from '@/context/provider-context'
import Support from './support' import Support from '../support'
const { mockZendeskKey } = vi.hoisted(() => ({ const { mockZendeskKey } = vi.hoisted(() => ({
mockZendeskKey: { value: 'test-key' }, mockZendeskKey: { value: 'test-key' },

View File

@ -5,7 +5,7 @@ import { ToastContext } from '@/app/components/base/toast/context'
import { baseProviderContextValue, useProviderContext } from '@/context/provider-context' import { baseProviderContextValue, useProviderContext } from '@/context/provider-context'
import { useWorkspacesContext } from '@/context/workspace-context' import { useWorkspacesContext } from '@/context/workspace-context'
import { switchWorkspace } from '@/service/common' import { switchWorkspace } from '@/service/common'
import WorkplaceSelector from './index' import WorkplaceSelector from '../index'
vi.mock('@/context/workspace-context', () => ({ vi.mock('@/context/workspace-context', () => ({
useWorkspacesContext: vi.fn(), useWorkspacesContext: vi.fn(),

View File

@ -1,7 +1,7 @@
import type { AccountIntegrate } from '@/models/common' import type { AccountIntegrate } from '@/models/common'
import { render, screen } from '@testing-library/react' import { render, screen } from '@testing-library/react'
import { useAccountIntegrates } from '@/service/use-common' import { useAccountIntegrates } from '@/service/use-common'
import IntegrationsPage from './index' import IntegrationsPage from '../index'
vi.mock('@/service/use-common', () => ({ vi.mock('@/service/use-common', () => ({
useAccountIntegrates: vi.fn(), useAccountIntegrates: vi.fn(),

View File

@ -3,7 +3,7 @@ import {
ACCOUNT_SETTING_TAB, ACCOUNT_SETTING_TAB,
DEFAULT_ACCOUNT_SETTING_TAB, DEFAULT_ACCOUNT_SETTING_TAB,
isValidAccountSettingTab, isValidAccountSettingTab,
} from './constants' } from '../constants'
describe('AccountSetting Constants', () => { describe('AccountSetting Constants', () => {
it('should have correct ACCOUNT_SETTING_MODAL_ACTION', () => { it('should have correct ACCOUNT_SETTING_MODAL_ACTION', () => {

View File

@ -0,0 +1,346 @@
import type { ComponentProps, ReactNode } from 'react'
import type { AppContextValue } from '@/context/app-context'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { fireEvent, render, screen, waitFor, within } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { useEffect } from 'react'
import { useAppContext } from '@/context/app-context'
import { baseProviderContextValue, useProviderContext } from '@/context/provider-context'
import useBreakpoints, { MediaType } from '@/hooks/use-breakpoints'
import { ACCOUNT_SETTING_TAB } from '../constants'
import AccountSetting from '../index'
vi.mock('@/context/provider-context', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/context/provider-context')>()
return {
...actual,
useProviderContext: vi.fn(),
}
})
vi.mock('@/context/app-context', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/context/app-context')>()
return {
...actual,
useAppContext: vi.fn(),
}
})
vi.mock('next/navigation', () => ({
useRouter: vi.fn(() => ({
push: vi.fn(),
replace: vi.fn(),
prefetch: vi.fn(),
})),
usePathname: vi.fn(() => '/'),
useParams: vi.fn(() => ({})),
useSearchParams: vi.fn(() => ({ get: vi.fn() })),
}))
vi.mock('@/hooks/use-breakpoints', () => ({
MediaType: {
mobile: 'mobile',
tablet: 'tablet',
pc: 'pc',
},
default: vi.fn(),
}))
vi.mock('@/app/components/billing/billing-page', () => ({
default: () => <div data-testid="billing-page">Billing Page</div>,
}))
vi.mock('@/app/components/custom/custom-page', () => ({
default: () => <div data-testid="custom-page">Custom Page</div>,
}))
vi.mock('@/app/components/header/account-setting/api-based-extension-page', () => ({
default: () => <div data-testid="api-based-extension-page">API Based Extension Page</div>,
}))
vi.mock('@/app/components/header/account-setting/data-source-page-new', () => ({
default: () => <div data-testid="data-source-page">Data Source Page</div>,
}))
vi.mock('@/app/components/header/account-setting/language-page', () => ({
default: () => <div data-testid="language-page">Language Page</div>,
}))
vi.mock('@/app/components/header/account-setting/members-page', () => ({
default: () => <div data-testid="members-page">Members Page</div>,
}))
vi.mock('@/app/components/header/account-setting/model-provider-page', () => ({
default: ({ searchText }: { searchText: string }) => (
<div data-testid="provider-page">
{`provider-search:${searchText}`}
</div>
),
}))
vi.mock('@/app/components/header/account-setting/menu-dialog', () => ({
default: function MockMenuDialog({
children,
onClose,
show,
}: {
children: ReactNode
onClose: () => void
show?: boolean
}) {
useEffect(() => {
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === 'Escape')
onClose()
}
document.addEventListener('keydown', handleKeyDown)
return () => {
document.removeEventListener('keydown', handleKeyDown)
}
}, [onClose])
if (!show)
return null
return <div role="dialog">{children}</div>
},
}))
const baseAppContextValue: AppContextValue = {
userProfile: {
id: '1',
name: 'Test User',
email: 'test@example.com',
avatar: '',
avatar_url: '',
is_password_set: false,
},
mutateUserProfile: vi.fn(),
currentWorkspace: {
id: '1',
name: 'Workspace',
plan: '',
status: '',
created_at: 0,
role: 'owner',
providers: [],
trial_credits: 0,
trial_credits_used: 0,
next_credit_reset_date: 0,
},
isCurrentWorkspaceManager: true,
isCurrentWorkspaceOwner: true,
isCurrentWorkspaceEditor: true,
isCurrentWorkspaceDatasetOperator: false,
mutateCurrentWorkspace: vi.fn(),
langGeniusVersionInfo: {
current_env: 'testing',
current_version: '0.1.0',
latest_version: '0.1.0',
release_date: '',
release_notes: '',
version: '0.1.0',
can_auto_update: false,
},
useSelector: vi.fn(),
isLoadingCurrentWorkspace: false,
isValidatingCurrentWorkspace: false,
}
describe('AccountSetting', () => {
const mockOnCancel = vi.fn()
const mockOnTabChange = vi.fn()
const renderAccountSetting = (props: Partial<ComponentProps<typeof AccountSetting>> = {}) => {
const queryClient = new QueryClient()
const mergedProps: ComponentProps<typeof AccountSetting> = {
onCancel: mockOnCancel,
...props,
}
const view = render(
<QueryClientProvider client={queryClient}>
<AccountSetting {...mergedProps} />
</QueryClientProvider>,
)
return {
...view,
rerenderAccountSetting(nextProps: Partial<ComponentProps<typeof AccountSetting>>) {
view.rerender(
<QueryClientProvider client={queryClient}>
<AccountSetting {...mergedProps} {...nextProps} />
</QueryClientProvider>,
)
},
}
}
beforeEach(() => {
vi.clearAllMocks()
vi.mocked(useProviderContext).mockReturnValue({
...baseProviderContextValue,
enableBilling: true,
enableReplaceWebAppLogo: true,
})
vi.mocked(useAppContext).mockReturnValue(baseAppContextValue)
vi.mocked(useBreakpoints).mockReturnValue(MediaType.pc)
})
describe('Rendering', () => {
it('should render the sidebar with correct menu items', () => {
renderAccountSetting()
expect(screen.getByText('common.userProfile.settings')).toBeInTheDocument()
expect(screen.getByTitle('common.settings.provider')).toBeInTheDocument()
expect(screen.getByTitle('common.settings.members')).toBeInTheDocument()
expect(screen.getByTitle('common.settings.billing')).toBeInTheDocument()
expect(screen.getByTitle('common.settings.dataSource')).toBeInTheDocument()
expect(screen.getByTitle('common.settings.apiBasedExtension')).toBeInTheDocument()
expect(screen.getByTitle('custom.custom')).toBeInTheDocument()
expect(screen.getByTitle('common.settings.language')).toBeInTheDocument()
expect(screen.getByTestId('members-page')).toBeInTheDocument()
})
it('should respect the activeTab prop', () => {
renderAccountSetting({ activeTab: ACCOUNT_SETTING_TAB.DATA_SOURCE })
expect(screen.getByTestId('data-source-page')).toBeInTheDocument()
})
it('should sync the rendered page when activeTab changes', async () => {
const { rerenderAccountSetting } = renderAccountSetting({
activeTab: ACCOUNT_SETTING_TAB.DATA_SOURCE,
})
expect(screen.getByTestId('data-source-page')).toBeInTheDocument()
rerenderAccountSetting({
activeTab: ACCOUNT_SETTING_TAB.CUSTOM,
})
await waitFor(() => {
expect(screen.getByTestId('custom-page')).toBeInTheDocument()
})
})
it('should hide sidebar labels on mobile', () => {
vi.mocked(useBreakpoints).mockReturnValue(MediaType.mobile)
renderAccountSetting()
expect(screen.queryByText('common.settings.provider')).not.toBeInTheDocument()
})
it('should filter items for dataset operator', () => {
vi.mocked(useAppContext).mockReturnValue({
...baseAppContextValue,
isCurrentWorkspaceDatasetOperator: true,
})
renderAccountSetting()
expect(screen.queryByTitle('common.settings.provider')).not.toBeInTheDocument()
expect(screen.queryByTitle('common.settings.members')).not.toBeInTheDocument()
expect(screen.getByTitle('common.settings.language')).toBeInTheDocument()
})
it('should hide billing and custom tabs when disabled', () => {
vi.mocked(useProviderContext).mockReturnValue({
...baseProviderContextValue,
enableBilling: false,
enableReplaceWebAppLogo: false,
})
renderAccountSetting()
expect(screen.queryByTitle('common.settings.billing')).not.toBeInTheDocument()
expect(screen.queryByTitle('custom.custom')).not.toBeInTheDocument()
})
})
describe('Tab Navigation', () => {
it('should change active tab when clicking on a menu item', async () => {
const user = userEvent.setup()
renderAccountSetting({ onTabChange: mockOnTabChange })
await user.click(screen.getByTitle('common.settings.provider'))
expect(mockOnTabChange).toHaveBeenCalledWith(ACCOUNT_SETTING_TAB.PROVIDER)
expect(screen.getByTestId('provider-page')).toBeInTheDocument()
})
it.each([
['common.settings.billing', 'billing-page'],
['common.settings.dataSource', 'data-source-page'],
['common.settings.apiBasedExtension', 'api-based-extension-page'],
['custom.custom', 'custom-page'],
['common.settings.language', 'language-page'],
['common.settings.members', 'members-page'],
])('should render the "%s" page when its sidebar item is selected', async (menuTitle, pageTestId) => {
const user = userEvent.setup()
renderAccountSetting()
await user.click(screen.getByTitle(menuTitle))
expect(screen.getByTestId(pageTestId)).toBeInTheDocument()
})
})
describe('Interactions', () => {
it('should call onCancel when clicking the close button', async () => {
const user = userEvent.setup()
renderAccountSetting()
const closeControls = screen.getByText('ESC').parentElement
expect(closeControls).not.toBeNull()
if (!closeControls)
throw new Error('Close controls are missing')
await user.click(within(closeControls).getByRole('button'))
expect(mockOnCancel).toHaveBeenCalled()
})
it('should call onCancel when pressing Escape key', () => {
renderAccountSetting()
fireEvent.keyDown(document, { key: 'Escape' })
expect(mockOnCancel).toHaveBeenCalled()
})
it('should update search value in the provider tab', async () => {
const user = userEvent.setup()
renderAccountSetting()
await user.click(screen.getByTitle('common.settings.provider'))
const input = screen.getByRole('textbox')
await user.type(input, 'test-search')
expect(input).toHaveValue('test-search')
expect(screen.getByText('provider-search:test-search')).toBeInTheDocument()
})
it('should handle scroll event in panel', () => {
renderAccountSetting()
const scrollContainer = screen.getByRole('dialog').querySelector('.overflow-y-auto')
expect(scrollContainer).toBeInTheDocument()
if (scrollContainer) {
fireEvent.scroll(scrollContainer, { target: { scrollTop: 100 } })
expect(scrollContainer).toHaveClass('overflow-y-auto')
fireEvent.scroll(scrollContainer, { target: { scrollTop: 0 } })
}
})
})
})

View File

@ -1,5 +1,5 @@
import { fireEvent, render, screen } from '@testing-library/react' import { fireEvent, render, screen } from '@testing-library/react'
import MenuDialog from './menu-dialog' import MenuDialog from '../menu-dialog'
describe('MenuDialog', () => { describe('MenuDialog', () => {
beforeEach(() => { beforeEach(() => {

View File

@ -1,5 +1,5 @@
import { render, screen } from '@testing-library/react' import { render, screen } from '@testing-library/react'
import Empty from './empty' import Empty from '../empty'
describe('Empty State', () => { describe('Empty State', () => {
describe('Rendering', () => { describe('Rendering', () => {

View File

@ -4,7 +4,7 @@ import type { ApiBasedExtension } from '@/models/common'
import { fireEvent, render, screen } from '@testing-library/react' import { fireEvent, render, screen } from '@testing-library/react'
import { useModalContext } from '@/context/modal-context' import { useModalContext } from '@/context/modal-context'
import { useApiBasedExtensions } from '@/service/use-common' import { useApiBasedExtensions } from '@/service/use-common'
import ApiBasedExtensionPage from './index' import ApiBasedExtensionPage from '../index'
vi.mock('@/service/use-common', () => ({ vi.mock('@/service/use-common', () => ({
useApiBasedExtensions: vi.fn(), useApiBasedExtensions: vi.fn(),

View File

@ -5,7 +5,7 @@ import { fireEvent, render, screen, waitFor, within } from '@testing-library/rea
import * as reactI18next from 'react-i18next' import * as reactI18next from 'react-i18next'
import { useModalContext } from '@/context/modal-context' import { useModalContext } from '@/context/modal-context'
import { deleteApiBasedExtension } from '@/service/common' import { deleteApiBasedExtension } from '@/service/common'
import Item from './item' import Item from '../item'
// Mock dependencies // Mock dependencies
vi.mock('@/context/modal-context', () => ({ vi.mock('@/context/modal-context', () => ({

Some files were not shown because too many files have changed in this diff Show More