Merge branch 'main' into pydantic-remaining

This commit is contained in:
Asuka Minato 2025-12-18 18:10:27 +09:00
commit a1dcbda515
71 changed files with 7844 additions and 362 deletions

View File

@ -6,7 +6,7 @@ cd web && pnpm install
pipx install uv
echo "alias start-api=\"cd $WORKSPACE_ROOT/api && uv run python -m flask run --host 0.0.0.0 --port=5001 --debug\"" >> ~/.bashrc
echo "alias start-worker=\"cd $WORKSPACE_ROOT/api && uv run python -m celery -A app.celery worker -P threads -c 1 --loglevel INFO -Q dataset,priority_dataset,priority_pipeline,pipeline,mail,ops_trace,app_deletion,plugin,workflow_storage,conversation,workflow,schedule_poller,schedule_executor,triggered_workflow_dispatcher,trigger_refresh_executor\"" >> ~/.bashrc
echo "alias start-worker=\"cd $WORKSPACE_ROOT/api && uv run python -m celery -A app.celery worker -P threads -c 1 --loglevel INFO -Q dataset,priority_dataset,priority_pipeline,pipeline,mail,ops_trace,app_deletion,plugin,workflow_storage,conversation,workflow,schedule_poller,schedule_executor,triggered_workflow_dispatcher,trigger_refresh_executor,retention\"" >> ~/.bashrc
echo "alias start-web=\"cd $WORKSPACE_ROOT/web && pnpm dev\"" >> ~/.bashrc
echo "alias start-web-prod=\"cd $WORKSPACE_ROOT/web && pnpm build && pnpm start\"" >> ~/.bashrc
echo "alias start-containers=\"cd $WORKSPACE_ROOT/docker && docker-compose -f docker-compose.middleware.yaml -p dify --env-file middleware.env up -d\"" >> ~/.bashrc

2
.github/CODEOWNERS vendored
View File

@ -122,7 +122,7 @@ api/controllers/console/feature.py @GarfieldDai @GareArc
api/controllers/web/feature.py @GarfieldDai @GareArc
# Backend - Database Migrations
api/migrations/ @snakevash @laipz8200
api/migrations/ @snakevash @laipz8200 @MRZHUH
# Frontend
web/ @iamjoel

View File

@ -79,7 +79,7 @@ jobs:
with:
node-version: 22
cache: pnpm
cache-dependency-path: ./web/package.json
cache-dependency-path: ./web/pnpm-lock.yaml
- name: Web dependencies
working-directory: ./web

View File

@ -90,7 +90,7 @@ jobs:
with:
node-version: 22
cache: pnpm
cache-dependency-path: ./web/package.json
cache-dependency-path: ./web/pnpm-lock.yaml
- name: Web dependencies
if: steps.changed-files.outputs.any_changed == 'true'

View File

@ -55,7 +55,7 @@ jobs:
with:
node-version: 'lts/*'
cache: pnpm
cache-dependency-path: ./web/package.json
cache-dependency-path: ./web/pnpm-lock.yaml
- name: Install dependencies
if: env.FILES_CHANGED == 'true'

View File

@ -13,6 +13,7 @@ jobs:
runs-on: ubuntu-latest
defaults:
run:
shell: bash
working-directory: ./web
steps:
@ -21,14 +22,7 @@ jobs:
with:
persist-credentials: false
- name: Check changed files
id: changed-files
uses: tj-actions/changed-files@v46
with:
files: web/**
- name: Install pnpm
if: steps.changed-files.outputs.any_changed == 'true'
uses: pnpm/action-setup@v4
with:
package_json_file: web/package.json
@ -36,23 +30,319 @@ jobs:
- name: Setup Node.js
uses: actions/setup-node@v4
if: steps.changed-files.outputs.any_changed == 'true'
with:
node-version: 22
cache: pnpm
cache-dependency-path: ./web/package.json
cache-dependency-path: ./web/pnpm-lock.yaml
- name: Install dependencies
if: steps.changed-files.outputs.any_changed == 'true'
working-directory: ./web
run: pnpm install --frozen-lockfile
- name: Check i18n types synchronization
if: steps.changed-files.outputs.any_changed == 'true'
working-directory: ./web
run: pnpm run check:i18n-types
- name: Run tests
if: steps.changed-files.outputs.any_changed == 'true'
working-directory: ./web
run: pnpm test
run: |
pnpm exec jest \
--ci \
--runInBand \
--coverage \
--passWithNoTests
- name: Coverage Summary
if: always()
id: coverage-summary
run: |
set -eo pipefail
COVERAGE_FILE="coverage/coverage-final.json"
COVERAGE_SUMMARY_FILE="coverage/coverage-summary.json"
if [ ! -f "$COVERAGE_FILE" ] && [ ! -f "$COVERAGE_SUMMARY_FILE" ]; then
echo "has_coverage=false" >> "$GITHUB_OUTPUT"
echo "### 🚨 Test Coverage Report :test_tube:" >> "$GITHUB_STEP_SUMMARY"
echo "Coverage data not found. Ensure Jest runs with coverage enabled." >> "$GITHUB_STEP_SUMMARY"
exit 0
fi
echo "has_coverage=true" >> "$GITHUB_OUTPUT"
node <<'NODE' >> "$GITHUB_STEP_SUMMARY"
const fs = require('fs');
const path = require('path');
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 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 lineHits = entry.l ?? {};
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 lineHits = entry.l ?? {};
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 = Object.entries(lineHits)
.filter(([, count]) => count === 0)
.map(([line]) => Number(line))
.sort((a, b) => a - b);
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 columnWidths = Object.fromEntries(
columns.map(({ key, header }) => [key, header.length]),
);
rowsForOutput.forEach((row) => {
columns.forEach(({ key }) => {
const value = String(row[key] ?? '');
columnWidths[key] = Math.max(columnWidths[key], value.length);
});
});
const formatRow = (row) => columns
.map(({ key, align }) => {
const value = String(row[key] ?? '');
const width = columnWidths[key];
return align === 'right' ? value.padStart(width) : value.padEnd(width);
})
.join(' | ');
const headerRow = columns
.map(({ header, key, align }) => {
const width = columnWidths[key];
return align === 'right' ? header.padStart(width) : header.padEnd(width);
})
.join(' | ');
const dividerRow = columns
.map(({ key }) => '-'.repeat(columnWidths[key]))
.join('|');
console.log('');
console.log('<details><summary>Jest coverage table</summary>');
console.log('');
console.log('```');
console.log(dividerRow);
console.log(headerRow);
console.log(dividerRow);
rowsForOutput.forEach((row) => console.log(formatRow(row)));
console.log(dividerRow);
console.log('```');
console.log('</details>');
}
NODE
- name: Upload Coverage Artifact
if: steps.coverage-summary.outputs.has_coverage == 'true'
uses: actions/upload-artifact@v4
with:
name: web-coverage-report
path: web/coverage
retention-days: 30
if-no-files-found: error

View File

@ -37,7 +37,7 @@
"-c",
"1",
"-Q",
"dataset,priority_dataset,priority_pipeline,pipeline,mail,ops_trace,app_deletion,plugin,workflow_storage,conversation,workflow,schedule_poller,schedule_executor,triggered_workflow_dispatcher,trigger_refresh_executor",
"dataset,priority_dataset,priority_pipeline,pipeline,mail,ops_trace,app_deletion,plugin,workflow_storage,conversation,workflow,schedule_poller,schedule_executor,triggered_workflow_dispatcher,trigger_refresh_executor,retention",
"--loglevel",
"INFO"
],

View File

@ -690,3 +690,8 @@ ANNOTATION_IMPORT_RATE_LIMIT_PER_MINUTE=5
ANNOTATION_IMPORT_RATE_LIMIT_PER_HOUR=20
# Maximum number of concurrent annotation import tasks per tenant
ANNOTATION_IMPORT_MAX_CONCURRENT=5
# Sandbox expired records clean configuration
SANDBOX_EXPIRED_RECORDS_CLEAN_GRACEFUL_PERIOD=21
SANDBOX_EXPIRED_RECORDS_CLEAN_BATCH_SIZE=1000
SANDBOX_EXPIRED_RECORDS_RETENTION_DAYS=30

View File

@ -84,7 +84,7 @@
1. If you need to handle and debug the async tasks (e.g. dataset importing and documents indexing), please start the worker service.
```bash
uv run celery -A app.celery worker -P threads -c 2 --loglevel INFO -Q dataset,priority_dataset,priority_pipeline,pipeline,mail,ops_trace,app_deletion,plugin,workflow_storage,conversation,workflow,schedule_poller,schedule_executor,triggered_workflow_dispatcher,trigger_refresh_executor
uv run celery -A app.celery worker -P threads -c 2 --loglevel INFO -Q dataset,priority_dataset,priority_pipeline,pipeline,mail,ops_trace,app_deletion,plugin,workflow_storage,conversation,workflow,schedule_poller,schedule_executor,triggered_workflow_dispatcher,trigger_refresh_executor,retention
```
Additionally, if you want to debug the celery scheduled tasks, you can run the following command in another terminal to start the beat service:

View File

@ -218,7 +218,7 @@ class PluginConfig(BaseSettings):
PLUGIN_DAEMON_TIMEOUT: PositiveFloat | None = Field(
description="Timeout in seconds for requests to the plugin daemon (set to None to disable)",
default=300.0,
default=600.0,
)
INNER_API_KEY_FOR_PLUGIN: str = Field(description="Inner api key for plugin", default="inner-api-key")
@ -1270,6 +1270,21 @@ class TenantIsolatedTaskQueueConfig(BaseSettings):
)
class SandboxExpiredRecordsCleanConfig(BaseSettings):
SANDBOX_EXPIRED_RECORDS_CLEAN_GRACEFUL_PERIOD: NonNegativeInt = Field(
description="Graceful period in days for sandbox records clean after subscription expiration",
default=21,
)
SANDBOX_EXPIRED_RECORDS_CLEAN_BATCH_SIZE: PositiveInt = Field(
description="Maximum number of records to process in each batch",
default=1000,
)
SANDBOX_EXPIRED_RECORDS_RETENTION_DAYS: PositiveInt = Field(
description="Retention days for sandbox expired workflow_run records and message records",
default=30,
)
class FeatureConfig(
# place the configs in alphabet order
AppExecutionConfig,
@ -1295,6 +1310,7 @@ class FeatureConfig(
PositionConfig,
RagEtlConfig,
RepositoryConfig,
SandboxExpiredRecordsCleanConfig,
SecurityConfig,
TenantIsolatedTaskQueueConfig,
ToolConfig,

View File

@ -146,7 +146,7 @@ class DatasetUpdatePayload(BaseModel):
embedding_model: str | None = None
embedding_model_provider: str | None = None
retrieval_model: dict[str, Any] | None = None
partial_member_list: list[str] | None = None
partial_member_list: list[dict[str, str]] | None = None
external_retrieval_model: dict[str, Any] | None = None
external_knowledge_id: str | None = None
external_knowledge_api_id: str | None = None

View File

@ -19,15 +19,15 @@ class TagBasePayload(BaseModel):
class TagBindingPayload(BaseModel):
tag_ids: list[str]
target_id: str
type: Literal["knowledge", "app"] | None = None
tag_ids: list[str] = Field(description="Tag IDs to bind")
target_id: str = Field(description="Target ID to bind tags to")
type: Literal["knowledge", "app"] | None = Field(default=None, description="Tag type")
class TagBindingRemovePayload(BaseModel):
tag_id: str
target_id: str
type: Literal["knowledge", "app"] | None = None
tag_id: str = Field(description="Tag ID to remove")
target_id: str = Field(description="Target ID to unbind tag from")
type: Literal["knowledge", "app"] | None = Field(default=None, description="Tag type")
register_schema_models(

View File

@ -49,7 +49,7 @@ class DatasetUpdatePayload(BaseModel):
embedding_model: str | None = None
embedding_model_provider: str | None = None
retrieval_model: RetrievalModel | None = None
partial_member_list: list[str] | None = None
partial_member_list: list[dict[str, str]] | None = None
external_retrieval_model: dict[str, Any] | None = None
external_knowledge_id: str | None = None
external_knowledge_api_id: str | None = None

View File

@ -1,3 +1,4 @@
import json
from collections.abc import Sequence
from enum import StrEnum, auto
from typing import Any, Literal
@ -120,7 +121,7 @@ class VariableEntity(BaseModel):
allowed_file_types: Sequence[FileType] | None = Field(default_factory=list)
allowed_file_extensions: Sequence[str] | None = Field(default_factory=list)
allowed_file_upload_methods: Sequence[FileTransferMethod] | None = Field(default_factory=list)
json_schema: dict[str, Any] | None = Field(default=None)
json_schema: str | None = Field(default=None)
@field_validator("description", mode="before")
@classmethod
@ -134,11 +135,17 @@ class VariableEntity(BaseModel):
@field_validator("json_schema")
@classmethod
def validate_json_schema(cls, schema: dict[str, Any] | None) -> dict[str, Any] | None:
def validate_json_schema(cls, schema: str | None) -> str | None:
if schema is None:
return None
try:
Draft7Validator.check_schema(schema)
json_schema = json.loads(schema)
except json.JSONDecodeError:
raise ValueError(f"invalid json_schema value {schema}")
try:
Draft7Validator.check_schema(json_schema)
except SchemaError as e:
raise ValueError(f"Invalid JSON schema: {e.message}")
return schema

View File

@ -1,3 +1,4 @@
import json
from collections.abc import Generator, Mapping, Sequence
from typing import TYPE_CHECKING, Any, Union, final
@ -175,6 +176,13 @@ class BaseAppGenerator:
value = True
elif value == 0:
value = False
case VariableEntityType.JSON_OBJECT:
if not isinstance(value, str):
raise ValueError(f"{variable_entity.variable} in input form must be a string")
try:
json.loads(value)
except json.JSONDecodeError:
raise ValueError(f"{variable_entity.variable} in input form must be a valid JSON object")
case _:
raise AssertionError("this statement should be unreachable.")

View File

@ -342,9 +342,11 @@ class EasyUIBasedGenerateTaskPipeline(BasedGenerateTaskPipeline):
self._task_state.llm_result.message.content = current_content
if isinstance(event, QueueLLMChunkEvent):
event_type = self._message_cycle_manager.get_message_event_type(message_id=self._message_id)
yield self._message_cycle_manager.message_to_stream_response(
answer=cast(str, delta_text),
message_id=self._message_id,
event_type=event_type,
)
else:
yield self._agent_message_to_stream_response(

View File

@ -5,7 +5,7 @@ from threading import Thread
from typing import Union
from flask import Flask, current_app
from sqlalchemy import select
from sqlalchemy import exists, select
from sqlalchemy.orm import Session
from configs import dify_config
@ -54,6 +54,20 @@ class MessageCycleManager:
):
self._application_generate_entity = application_generate_entity
self._task_state = task_state
self._message_has_file: set[str] = set()
def get_message_event_type(self, message_id: str) -> StreamEvent:
if message_id in self._message_has_file:
return StreamEvent.MESSAGE_FILE
with Session(db.engine, expire_on_commit=False) as session:
has_file = session.query(exists().where(MessageFile.message_id == message_id)).scalar()
if has_file:
self._message_has_file.add(message_id)
return StreamEvent.MESSAGE_FILE
return StreamEvent.MESSAGE
def generate_conversation_name(self, *, conversation_id: str, query: str) -> Thread | None:
"""
@ -214,7 +228,11 @@ class MessageCycleManager:
return None
def message_to_stream_response(
self, answer: str, message_id: str, from_variable_selector: list[str] | None = None
self,
answer: str,
message_id: str,
from_variable_selector: list[str] | None = None,
event_type: StreamEvent | None = None,
) -> MessageStreamResponse:
"""
Message to stream response.
@ -222,16 +240,12 @@ class MessageCycleManager:
:param message_id: message id
:return:
"""
with Session(db.engine, expire_on_commit=False) as session:
message_file = session.scalar(select(MessageFile).where(MessageFile.id == message_id))
event_type = StreamEvent.MESSAGE_FILE if message_file else StreamEvent.MESSAGE
return MessageStreamResponse(
task_id=self._application_generate_entity.task_id,
id=message_id,
answer=answer,
from_variable_selector=from_variable_selector,
event=event_type,
event=event_type or StreamEvent.MESSAGE,
)
def message_replace_to_stream_response(self, answer: str, reason: str = "") -> MessageReplaceStreamResponse:

View File

@ -39,7 +39,7 @@ from core.trigger.errors import (
plugin_daemon_inner_api_baseurl = URL(str(dify_config.PLUGIN_DAEMON_URL))
_plugin_daemon_timeout_config = cast(
float | httpx.Timeout | None,
getattr(dify_config, "PLUGIN_DAEMON_TIMEOUT", 300.0),
getattr(dify_config, "PLUGIN_DAEMON_TIMEOUT", 600.0),
)
plugin_daemon_request_timeout: httpx.Timeout | None
if _plugin_daemon_timeout_config is None:

View File

@ -2,6 +2,7 @@
from __future__ import annotations
import codecs
import re
from typing import Any
@ -52,7 +53,7 @@ class FixedRecursiveCharacterTextSplitter(EnhanceRecursiveCharacterTextSplitter)
def __init__(self, fixed_separator: str = "\n\n", separators: list[str] | None = None, **kwargs: Any):
"""Create a new TextSplitter."""
super().__init__(**kwargs)
self._fixed_separator = fixed_separator
self._fixed_separator = codecs.decode(fixed_separator, "unicode_escape")
self._separators = separators or ["\n\n", "\n", "", ". ", " ", ""]
def split_text(self, text: str) -> list[str]:

View File

@ -1,3 +1,4 @@
import json
from typing import Any
from jsonschema import Draft7Validator, ValidationError
@ -42,15 +43,25 @@ class StartNode(Node[StartNodeData]):
if value is None and variable.required:
raise ValueError(f"{key} is required in input form")
if not isinstance(value, dict):
raise ValueError(f"{key} must be a JSON object")
schema = variable.json_schema
if not schema:
continue
if not value:
continue
try:
Draft7Validator(schema).validate(value)
json_schema = json.loads(schema)
except json.JSONDecodeError as e:
raise ValueError(f"{schema} must be a valid JSON object")
try:
json_value = json.loads(value)
except json.JSONDecodeError as e:
raise ValueError(f"{value} must be a valid JSON object")
try:
Draft7Validator(json_schema).validate(json_value)
except ValidationError as e:
raise ValueError(f"JSON object for '{key}' does not match schema: {e.message}")
node_inputs[key] = value
node_inputs[key] = json_value

View File

@ -34,10 +34,10 @@ if [[ "${MODE}" == "worker" ]]; then
if [[ -z "${CELERY_QUEUES}" ]]; then
if [[ "${EDITION}" == "CLOUD" ]]; then
# Cloud edition: separate queues for dataset and trigger tasks
DEFAULT_QUEUES="dataset,priority_dataset,priority_pipeline,pipeline,mail,ops_trace,app_deletion,plugin,workflow_storage,conversation,workflow_professional,workflow_team,workflow_sandbox,schedule_poller,schedule_executor,triggered_workflow_dispatcher,trigger_refresh_executor"
DEFAULT_QUEUES="dataset,priority_dataset,priority_pipeline,pipeline,mail,ops_trace,app_deletion,plugin,workflow_storage,conversation,workflow_professional,workflow_team,workflow_sandbox,schedule_poller,schedule_executor,triggered_workflow_dispatcher,trigger_refresh_executor,retention"
else
# Community edition (SELF_HOSTED): dataset, pipeline and workflow have separate queues
DEFAULT_QUEUES="dataset,priority_dataset,priority_pipeline,pipeline,mail,ops_trace,app_deletion,plugin,workflow_storage,conversation,workflow,schedule_poller,schedule_executor,triggered_workflow_dispatcher,trigger_refresh_executor"
DEFAULT_QUEUES="dataset,priority_dataset,priority_pipeline,pipeline,mail,ops_trace,app_deletion,plugin,workflow_storage,conversation,workflow,schedule_poller,schedule_executor,triggered_workflow_dispatcher,trigger_refresh_executor,retention"
fi
else
DEFAULT_QUEUES="${CELERY_QUEUES}"
@ -69,6 +69,53 @@ if [[ "${MODE}" == "worker" ]]; then
elif [[ "${MODE}" == "beat" ]]; then
exec celery -A app.celery beat --loglevel ${LOG_LEVEL:-INFO}
elif [[ "${MODE}" == "job" ]]; then
# Job mode: Run a one-time Flask command and exit
# Pass Flask command and arguments via container args
# Example K8s usage:
# args:
# - create-tenant
# - --email
# - admin@example.com
#
# Example Docker usage:
# docker run -e MODE=job dify-api:latest create-tenant --email admin@example.com
if [[ $# -eq 0 ]]; then
echo "Error: No command specified for job mode."
echo ""
echo "Usage examples:"
echo " Kubernetes:"
echo " args: [create-tenant, --email, admin@example.com]"
echo ""
echo " Docker:"
echo " docker run -e MODE=job dify-api create-tenant --email admin@example.com"
echo ""
echo "Available commands:"
echo " create-tenant, reset-password, reset-email, upgrade-db,"
echo " vdb-migrate, install-plugins, and more..."
echo ""
echo "Run 'flask --help' to see all available commands."
exit 1
fi
echo "Running Flask job command: flask $*"
# Temporarily disable exit on error to capture exit code
set +e
flask "$@"
JOB_EXIT_CODE=$?
set -e
if [[ ${JOB_EXIT_CODE} -eq 0 ]]; then
echo "Job completed successfully."
else
echo "Job failed with exit code ${JOB_EXIT_CODE}."
fi
exit ${JOB_EXIT_CODE}
else
if [[ "${DEBUG}" == "true" ]]; then
exec flask run --host=${DIFY_BIND_ADDRESS:-0.0.0.0} --port=${DIFY_PORT:-5001} --debug

View File

@ -1,8 +1,12 @@
import logging
import os
from collections.abc import Sequence
from typing import Literal
import httpx
from pydantic import TypeAdapter
from tenacity import retry, retry_if_exception_type, stop_before_delay, wait_fixed
from typing_extensions import TypedDict
from werkzeug.exceptions import InternalServerError
from enums.cloud_plan import CloudPlan
@ -11,6 +15,15 @@ from extensions.ext_redis import redis_client
from libs.helper import RateLimiter
from models import Account, TenantAccountJoin, TenantAccountRole
logger = logging.getLogger(__name__)
class SubscriptionPlan(TypedDict):
"""Tenant subscriptionplan information."""
plan: str
expiration_date: int
class BillingService:
base_url = os.environ.get("BILLING_API_URL", "BILLING_API_URL")
@ -239,3 +252,39 @@ class BillingService:
def sync_partner_tenants_bindings(cls, account_id: str, partner_key: str, click_id: str):
payload = {"account_id": account_id, "click_id": click_id}
return cls._send_request("PUT", f"/partners/{partner_key}/tenants", json=payload)
@classmethod
def get_plan_bulk(cls, tenant_ids: Sequence[str]) -> dict[str, SubscriptionPlan]:
"""
Bulk fetch billing subscription plan via billing API.
Payload: {"tenant_ids": ["t1", "t2", ...]} (max 200 per request)
Returns:
Mapping of tenant_id -> {plan: str, expiration_date: int}
"""
results: dict[str, SubscriptionPlan] = {}
subscription_adapter = TypeAdapter(SubscriptionPlan)
chunk_size = 200
for i in range(0, len(tenant_ids), chunk_size):
chunk = tenant_ids[i : i + chunk_size]
try:
resp = cls._send_request("POST", "/subscription/plan/batch", json={"tenant_ids": chunk})
data = resp.get("data", {})
for tenant_id, plan in data.items():
subscription_plan = subscription_adapter.validate_python(plan)
results[tenant_id] = subscription_plan
except Exception:
logger.exception("Failed to fetch billing info batch for tenants: %s", chunk)
continue
return results
@classmethod
def get_expired_subscription_cleanup_whitelist(cls) -> Sequence[str]:
resp = cls._send_request("GET", "/subscription/cleanup/whitelist")
data = resp.get("data", [])
tenant_whitelist = []
for item in data:
tenant_whitelist.append(item["tenant_id"])
return tenant_whitelist

View File

@ -23,7 +23,7 @@ class RagPipelineDatasetCreateEntity(BaseModel):
description: str
icon_info: IconInfo
permission: str
partial_member_list: list[str] | None = None
partial_member_list: list[dict[str, str]] | None = None
yaml_content: str | None = None

View File

@ -0,0 +1,420 @@
from types import SimpleNamespace
from unittest.mock import ANY, Mock, patch
import pytest
from core.app.apps.base_app_queue_manager import AppQueueManager
from core.app.entities.app_invoke_entities import ChatAppGenerateEntity
from core.app.entities.queue_entities import (
QueueAgentMessageEvent,
QueueErrorEvent,
QueueLLMChunkEvent,
QueueMessageEndEvent,
QueueMessageFileEvent,
QueuePingEvent,
)
from core.app.entities.task_entities import (
EasyUITaskState,
ErrorStreamResponse,
MessageEndStreamResponse,
MessageFileStreamResponse,
MessageReplaceStreamResponse,
MessageStreamResponse,
PingStreamResponse,
StreamEvent,
)
from core.app.task_pipeline.easy_ui_based_generate_task_pipeline import EasyUIBasedGenerateTaskPipeline
from core.base.tts import AppGeneratorTTSPublisher
from core.model_runtime.entities.llm_entities import LLMResult as RuntimeLLMResult
from core.model_runtime.entities.message_entities import TextPromptMessageContent
from core.ops.ops_trace_manager import TraceQueueManager
from models.model import AppMode
class TestEasyUIBasedGenerateTaskPipelineProcessStreamResponse:
"""Test cases for EasyUIBasedGenerateTaskPipeline._process_stream_response method."""
@pytest.fixture
def mock_application_generate_entity(self):
"""Create a mock application generate entity."""
entity = Mock(spec=ChatAppGenerateEntity)
entity.task_id = "test-task-id"
entity.app_id = "test-app-id"
# minimal app_config used by pipeline internals
entity.app_config = SimpleNamespace(
tenant_id="test-tenant-id",
app_id="test-app-id",
app_mode=AppMode.CHAT,
app_model_config_dict={},
additional_features=None,
sensitive_word_avoidance=None,
)
# minimal model_conf for LLMResult init
entity.model_conf = SimpleNamespace(
model="test-model",
provider_model_bundle=SimpleNamespace(model_type_instance=Mock()),
credentials={},
)
return entity
@pytest.fixture
def mock_queue_manager(self):
"""Create a mock queue manager."""
manager = Mock(spec=AppQueueManager)
return manager
@pytest.fixture
def mock_message_cycle_manager(self):
"""Create a mock message cycle manager."""
manager = Mock()
manager.get_message_event_type.return_value = StreamEvent.MESSAGE
manager.message_to_stream_response.return_value = Mock(spec=MessageStreamResponse)
manager.message_file_to_stream_response.return_value = Mock(spec=MessageFileStreamResponse)
manager.message_replace_to_stream_response.return_value = Mock(spec=MessageReplaceStreamResponse)
manager.handle_retriever_resources = Mock()
manager.handle_annotation_reply.return_value = None
return manager
@pytest.fixture
def mock_conversation(self):
"""Create a mock conversation."""
conversation = Mock()
conversation.id = "test-conversation-id"
conversation.mode = "chat"
return conversation
@pytest.fixture
def mock_message(self):
"""Create a mock message."""
message = Mock()
message.id = "test-message-id"
message.created_at = Mock()
message.created_at.timestamp.return_value = 1234567890
return message
@pytest.fixture
def mock_task_state(self):
"""Create a mock task state."""
task_state = Mock(spec=EasyUITaskState)
# Create LLM result mock
llm_result = Mock(spec=RuntimeLLMResult)
llm_result.prompt_messages = []
llm_result.message = Mock()
llm_result.message.content = ""
task_state.llm_result = llm_result
task_state.answer = ""
return task_state
@pytest.fixture
def pipeline(
self,
mock_application_generate_entity,
mock_queue_manager,
mock_conversation,
mock_message,
mock_message_cycle_manager,
mock_task_state,
):
"""Create an EasyUIBasedGenerateTaskPipeline instance with mocked dependencies."""
with patch(
"core.app.task_pipeline.easy_ui_based_generate_task_pipeline.EasyUITaskState", return_value=mock_task_state
):
pipeline = EasyUIBasedGenerateTaskPipeline(
application_generate_entity=mock_application_generate_entity,
queue_manager=mock_queue_manager,
conversation=mock_conversation,
message=mock_message,
stream=True,
)
pipeline._message_cycle_manager = mock_message_cycle_manager
pipeline._task_state = mock_task_state
return pipeline
def test_get_message_event_type_called_once_when_first_llm_chunk_arrives(
self, pipeline, mock_message_cycle_manager
):
"""Expect get_message_event_type to be called when processing the first LLM chunk event."""
# Setup a minimal LLM chunk event
chunk = Mock()
chunk.delta.message.content = "hi"
chunk.prompt_messages = []
llm_chunk_event = Mock(spec=QueueLLMChunkEvent)
llm_chunk_event.chunk = chunk
mock_queue_message = Mock()
mock_queue_message.event = llm_chunk_event
pipeline.queue_manager.listen.return_value = [mock_queue_message]
# Execute
list(pipeline._process_stream_response(publisher=None, trace_manager=None))
# Assert
mock_message_cycle_manager.get_message_event_type.assert_called_once_with(message_id="test-message-id")
def test_llm_chunk_event_with_text_content(self, pipeline, mock_message_cycle_manager, mock_task_state):
"""Test handling of LLM chunk events with text content."""
# Setup
chunk = Mock()
chunk.delta.message.content = "Hello, world!"
chunk.prompt_messages = []
llm_chunk_event = Mock(spec=QueueLLMChunkEvent)
llm_chunk_event.chunk = chunk
mock_queue_message = Mock()
mock_queue_message.event = llm_chunk_event
pipeline.queue_manager.listen.return_value = [mock_queue_message]
mock_message_cycle_manager.get_message_event_type.return_value = StreamEvent.MESSAGE
# Execute
responses = list(pipeline._process_stream_response(publisher=None, trace_manager=None))
# Assert
assert len(responses) == 1
mock_message_cycle_manager.message_to_stream_response.assert_called_once_with(
answer="Hello, world!", message_id="test-message-id", event_type=StreamEvent.MESSAGE
)
assert mock_task_state.llm_result.message.content == "Hello, world!"
def test_llm_chunk_event_with_list_content(self, pipeline, mock_message_cycle_manager, mock_task_state):
"""Test handling of LLM chunk events with list content."""
# Setup
text_content = Mock(spec=TextPromptMessageContent)
text_content.data = "Hello"
chunk = Mock()
chunk.delta.message.content = [text_content, " world!"]
chunk.prompt_messages = []
llm_chunk_event = Mock(spec=QueueLLMChunkEvent)
llm_chunk_event.chunk = chunk
mock_queue_message = Mock()
mock_queue_message.event = llm_chunk_event
pipeline.queue_manager.listen.return_value = [mock_queue_message]
mock_message_cycle_manager.get_message_event_type.return_value = StreamEvent.MESSAGE
# Execute
responses = list(pipeline._process_stream_response(publisher=None, trace_manager=None))
# Assert
assert len(responses) == 1
mock_message_cycle_manager.message_to_stream_response.assert_called_once_with(
answer="Hello world!", message_id="test-message-id", event_type=StreamEvent.MESSAGE
)
assert mock_task_state.llm_result.message.content == "Hello world!"
def test_agent_message_event(self, pipeline, mock_message_cycle_manager, mock_task_state):
"""Test handling of agent message events."""
# Setup
chunk = Mock()
chunk.delta.message.content = "Agent response"
agent_message_event = Mock(spec=QueueAgentMessageEvent)
agent_message_event.chunk = chunk
mock_queue_message = Mock()
mock_queue_message.event = agent_message_event
pipeline.queue_manager.listen.return_value = [mock_queue_message]
# Ensure method under assertion is a mock to track calls
pipeline._agent_message_to_stream_response = Mock(return_value=Mock())
# Execute
responses = list(pipeline._process_stream_response(publisher=None, trace_manager=None))
# Assert
assert len(responses) == 1
# Agent messages should use _agent_message_to_stream_response
pipeline._agent_message_to_stream_response.assert_called_once_with(
answer="Agent response", message_id="test-message-id"
)
def test_message_end_event(self, pipeline, mock_message_cycle_manager, mock_task_state):
"""Test handling of message end events."""
# Setup
llm_result = Mock(spec=RuntimeLLMResult)
llm_result.message = Mock()
llm_result.message.content = "Final response"
message_end_event = Mock(spec=QueueMessageEndEvent)
message_end_event.llm_result = llm_result
mock_queue_message = Mock()
mock_queue_message.event = message_end_event
pipeline.queue_manager.listen.return_value = [mock_queue_message]
pipeline._save_message = Mock()
pipeline._message_end_to_stream_response = Mock(return_value=Mock(spec=MessageEndStreamResponse))
# Patch db.engine used inside pipeline for session creation
with patch(
"core.app.task_pipeline.easy_ui_based_generate_task_pipeline.db", new=SimpleNamespace(engine=Mock())
):
# Execute
responses = list(pipeline._process_stream_response(publisher=None, trace_manager=None))
# Assert
assert len(responses) == 1
assert mock_task_state.llm_result == llm_result
pipeline._save_message.assert_called_once()
pipeline._message_end_to_stream_response.assert_called_once()
def test_error_event(self, pipeline):
"""Test handling of error events."""
# Setup
error_event = Mock(spec=QueueErrorEvent)
error_event.error = Exception("Test error")
mock_queue_message = Mock()
mock_queue_message.event = error_event
pipeline.queue_manager.listen.return_value = [mock_queue_message]
pipeline.handle_error = Mock(return_value=Exception("Test error"))
pipeline.error_to_stream_response = Mock(return_value=Mock(spec=ErrorStreamResponse))
# Patch db.engine used inside pipeline for session creation
with patch(
"core.app.task_pipeline.easy_ui_based_generate_task_pipeline.db", new=SimpleNamespace(engine=Mock())
):
# Execute
responses = list(pipeline._process_stream_response(publisher=None, trace_manager=None))
# Assert
assert len(responses) == 1
pipeline.handle_error.assert_called_once()
pipeline.error_to_stream_response.assert_called_once()
def test_ping_event(self, pipeline):
"""Test handling of ping events."""
# Setup
ping_event = Mock(spec=QueuePingEvent)
mock_queue_message = Mock()
mock_queue_message.event = ping_event
pipeline.queue_manager.listen.return_value = [mock_queue_message]
pipeline.ping_stream_response = Mock(return_value=Mock(spec=PingStreamResponse))
# Execute
responses = list(pipeline._process_stream_response(publisher=None, trace_manager=None))
# Assert
assert len(responses) == 1
pipeline.ping_stream_response.assert_called_once()
def test_file_event(self, pipeline, mock_message_cycle_manager):
"""Test handling of file events."""
# Setup
file_event = Mock(spec=QueueMessageFileEvent)
file_event.message_file_id = "file-id"
mock_queue_message = Mock()
mock_queue_message.event = file_event
pipeline.queue_manager.listen.return_value = [mock_queue_message]
file_response = Mock(spec=MessageFileStreamResponse)
mock_message_cycle_manager.message_file_to_stream_response.return_value = file_response
# Execute
responses = list(pipeline._process_stream_response(publisher=None, trace_manager=None))
# Assert
assert len(responses) == 1
assert responses[0] == file_response
mock_message_cycle_manager.message_file_to_stream_response.assert_called_once_with(file_event)
def test_publisher_is_called_with_messages(self, pipeline):
"""Test that publisher publishes messages when provided."""
# Setup
publisher = Mock(spec=AppGeneratorTTSPublisher)
ping_event = Mock(spec=QueuePingEvent)
mock_queue_message = Mock()
mock_queue_message.event = ping_event
pipeline.queue_manager.listen.return_value = [mock_queue_message]
pipeline.ping_stream_response = Mock(return_value=Mock(spec=PingStreamResponse))
# Execute
list(pipeline._process_stream_response(publisher=publisher, trace_manager=None))
# Assert
# Called once with message and once with None at the end
assert publisher.publish.call_count == 2
publisher.publish.assert_any_call(mock_queue_message)
publisher.publish.assert_any_call(None)
def test_trace_manager_passed_to_save_message(self, pipeline):
"""Test that trace manager is passed to _save_message."""
# Setup
trace_manager = Mock(spec=TraceQueueManager)
message_end_event = Mock(spec=QueueMessageEndEvent)
message_end_event.llm_result = None
mock_queue_message = Mock()
mock_queue_message.event = message_end_event
pipeline.queue_manager.listen.return_value = [mock_queue_message]
pipeline._save_message = Mock()
pipeline._message_end_to_stream_response = Mock(return_value=Mock(spec=MessageEndStreamResponse))
# Patch db.engine used inside pipeline for session creation
with patch(
"core.app.task_pipeline.easy_ui_based_generate_task_pipeline.db", new=SimpleNamespace(engine=Mock())
):
# Execute
list(pipeline._process_stream_response(publisher=None, trace_manager=trace_manager))
# Assert
pipeline._save_message.assert_called_once_with(session=ANY, trace_manager=trace_manager)
def test_multiple_events_sequence(self, pipeline, mock_message_cycle_manager, mock_task_state):
"""Test handling multiple events in sequence."""
# Setup
chunk1 = Mock()
chunk1.delta.message.content = "Hello"
chunk1.prompt_messages = []
chunk2 = Mock()
chunk2.delta.message.content = " world!"
chunk2.prompt_messages = []
llm_chunk_event1 = Mock(spec=QueueLLMChunkEvent)
llm_chunk_event1.chunk = chunk1
ping_event = Mock(spec=QueuePingEvent)
llm_chunk_event2 = Mock(spec=QueueLLMChunkEvent)
llm_chunk_event2.chunk = chunk2
mock_queue_messages = [
Mock(event=llm_chunk_event1),
Mock(event=ping_event),
Mock(event=llm_chunk_event2),
]
pipeline.queue_manager.listen.return_value = mock_queue_messages
mock_message_cycle_manager.get_message_event_type.return_value = StreamEvent.MESSAGE
pipeline.ping_stream_response = Mock(return_value=Mock(spec=PingStreamResponse))
# Execute
responses = list(pipeline._process_stream_response(publisher=None, trace_manager=None))
# Assert
assert len(responses) == 3
assert mock_task_state.llm_result.message.content == "Hello world!"
# Verify calls to message_to_stream_response
assert mock_message_cycle_manager.message_to_stream_response.call_count == 2
mock_message_cycle_manager.message_to_stream_response.assert_any_call(
answer="Hello", message_id="test-message-id", event_type=StreamEvent.MESSAGE
)
mock_message_cycle_manager.message_to_stream_response.assert_any_call(
answer=" world!", message_id="test-message-id", event_type=StreamEvent.MESSAGE
)

View File

@ -0,0 +1,166 @@
"""Unit tests for the message cycle manager optimization."""
from types import SimpleNamespace
from unittest.mock import ANY, Mock, patch
import pytest
from flask import current_app
from core.app.entities.task_entities import MessageStreamResponse, StreamEvent
from core.app.task_pipeline.message_cycle_manager import MessageCycleManager
class TestMessageCycleManagerOptimization:
"""Test cases for the message cycle manager optimization that prevents N+1 queries."""
@pytest.fixture
def mock_application_generate_entity(self):
"""Create a mock application generate entity."""
entity = Mock()
entity.task_id = "test-task-id"
return entity
@pytest.fixture
def message_cycle_manager(self, mock_application_generate_entity):
"""Create a message cycle manager instance."""
task_state = Mock()
return MessageCycleManager(application_generate_entity=mock_application_generate_entity, task_state=task_state)
def test_get_message_event_type_with_message_file(self, message_cycle_manager):
"""Test get_message_event_type returns MESSAGE_FILE when message has files."""
with (
patch("core.app.task_pipeline.message_cycle_manager.Session") as mock_session_class,
patch("core.app.task_pipeline.message_cycle_manager.db", new=SimpleNamespace(engine=Mock())),
):
# Setup mock session and message file
mock_session = Mock()
mock_session_class.return_value.__enter__.return_value = mock_session
mock_message_file = Mock()
# Current implementation uses session.query(...).scalar()
mock_session.query.return_value.scalar.return_value = mock_message_file
# Execute
with current_app.app_context():
result = message_cycle_manager.get_message_event_type("test-message-id")
# Assert
assert result == StreamEvent.MESSAGE_FILE
mock_session.query.return_value.scalar.assert_called_once()
def test_get_message_event_type_without_message_file(self, message_cycle_manager):
"""Test get_message_event_type returns MESSAGE when message has no files."""
with (
patch("core.app.task_pipeline.message_cycle_manager.Session") as mock_session_class,
patch("core.app.task_pipeline.message_cycle_manager.db", new=SimpleNamespace(engine=Mock())),
):
# Setup mock session and no message file
mock_session = Mock()
mock_session_class.return_value.__enter__.return_value = mock_session
# Current implementation uses session.query(...).scalar()
mock_session.query.return_value.scalar.return_value = None
# Execute
with current_app.app_context():
result = message_cycle_manager.get_message_event_type("test-message-id")
# Assert
assert result == StreamEvent.MESSAGE
mock_session.query.return_value.scalar.assert_called_once()
def test_message_to_stream_response_with_precomputed_event_type(self, message_cycle_manager):
"""MessageCycleManager.message_to_stream_response expects a valid event_type; callers should precompute it."""
with (
patch("core.app.task_pipeline.message_cycle_manager.Session") as mock_session_class,
patch("core.app.task_pipeline.message_cycle_manager.db", new=SimpleNamespace(engine=Mock())),
):
# Setup mock session and message file
mock_session = Mock()
mock_session_class.return_value.__enter__.return_value = mock_session
mock_message_file = Mock()
# Current implementation uses session.query(...).scalar()
mock_session.query.return_value.scalar.return_value = mock_message_file
# Execute: compute event type once, then pass to message_to_stream_response
with current_app.app_context():
event_type = message_cycle_manager.get_message_event_type("test-message-id")
result = message_cycle_manager.message_to_stream_response(
answer="Hello world", message_id="test-message-id", event_type=event_type
)
# Assert
assert isinstance(result, MessageStreamResponse)
assert result.answer == "Hello world"
assert result.id == "test-message-id"
assert result.event == StreamEvent.MESSAGE_FILE
mock_session.query.return_value.scalar.assert_called_once()
def test_message_to_stream_response_with_event_type_skips_query(self, message_cycle_manager):
"""Test that message_to_stream_response skips database query when event_type is provided."""
with patch("core.app.task_pipeline.message_cycle_manager.Session") as mock_session_class:
# Execute with event_type provided
result = message_cycle_manager.message_to_stream_response(
answer="Hello world", message_id="test-message-id", event_type=StreamEvent.MESSAGE
)
# Assert
assert isinstance(result, MessageStreamResponse)
assert result.answer == "Hello world"
assert result.id == "test-message-id"
assert result.event == StreamEvent.MESSAGE
# Should not query database when event_type is provided
mock_session_class.assert_not_called()
def test_message_to_stream_response_with_from_variable_selector(self, message_cycle_manager):
"""Test message_to_stream_response with from_variable_selector parameter."""
result = message_cycle_manager.message_to_stream_response(
answer="Hello world",
message_id="test-message-id",
from_variable_selector=["var1", "var2"],
event_type=StreamEvent.MESSAGE,
)
assert isinstance(result, MessageStreamResponse)
assert result.answer == "Hello world"
assert result.id == "test-message-id"
assert result.from_variable_selector == ["var1", "var2"]
assert result.event == StreamEvent.MESSAGE
def test_optimization_usage_example(self, message_cycle_manager):
"""Test the optimization pattern that should be used by callers."""
# Step 1: Get event type once (this queries database)
with (
patch("core.app.task_pipeline.message_cycle_manager.Session") as mock_session_class,
patch("core.app.task_pipeline.message_cycle_manager.db", new=SimpleNamespace(engine=Mock())),
):
mock_session = Mock()
mock_session_class.return_value.__enter__.return_value = mock_session
# Current implementation uses session.query(...).scalar()
mock_session.query.return_value.scalar.return_value = None # No files
with current_app.app_context():
event_type = message_cycle_manager.get_message_event_type("test-message-id")
# Should query database once
mock_session_class.assert_called_once_with(ANY, expire_on_commit=False)
assert event_type == StreamEvent.MESSAGE
# Step 2: Use event_type for multiple calls (no additional queries)
with patch("core.app.task_pipeline.message_cycle_manager.Session") as mock_session_class:
mock_session_class.return_value.__enter__.return_value = Mock()
chunk1_response = message_cycle_manager.message_to_stream_response(
answer="Chunk 1", message_id="test-message-id", event_type=event_type
)
chunk2_response = message_cycle_manager.message_to_stream_response(
answer="Chunk 2", message_id="test-message-id", event_type=event_type
)
# Should not query database again
mock_session_class.assert_not_called()
assert chunk1_response.event == StreamEvent.MESSAGE
assert chunk2_response.event == StreamEvent.MESSAGE
assert chunk1_response.answer == "Chunk 1"
assert chunk2_response.answer == "Chunk 2"

View File

@ -901,6 +901,13 @@ class TestFixedRecursiveCharacterTextSplitter:
# Verify no empty chunks
assert all(len(chunk) > 0 for chunk in result)
def test_double_slash_n(self):
data = "chunk 1\n\nsubchunk 1.\nsubchunk 2.\n\n---\n\nchunk 2\n\nsubchunk 1\nsubchunk 2."
separator = "\\n\\n---\\n\\n"
splitter = FixedRecursiveCharacterTextSplitter(fixed_separator=separator)
chunks = splitter.split_text(data)
assert chunks == ["chunk 1\n\nsubchunk 1.\nsubchunk 2.", "chunk 2\n\nsubchunk 1\nsubchunk 2."]
# ============================================================================
# Test Metadata Preservation

View File

@ -1,3 +1,4 @@
import json
import time
import pytest
@ -46,14 +47,16 @@ def make_start_node(user_inputs, variables):
def test_json_object_valid_schema():
schema = {
"type": "object",
"properties": {
"age": {"type": "number"},
"name": {"type": "string"},
},
"required": ["age"],
}
schema = json.dumps(
{
"type": "object",
"properties": {
"age": {"type": "number"},
"name": {"type": "string"},
},
"required": ["age"],
}
)
variables = [
VariableEntity(
@ -65,7 +68,7 @@ def test_json_object_valid_schema():
)
]
user_inputs = {"profile": {"age": 20, "name": "Tom"}}
user_inputs = {"profile": json.dumps({"age": 20, "name": "Tom"})}
node = make_start_node(user_inputs, variables)
result = node._run()
@ -74,12 +77,23 @@ def test_json_object_valid_schema():
def test_json_object_invalid_json_string():
schema = json.dumps(
{
"type": "object",
"properties": {
"age": {"type": "number"},
"name": {"type": "string"},
},
"required": ["age", "name"],
}
)
variables = [
VariableEntity(
variable="profile",
label="profile",
type=VariableEntityType.JSON_OBJECT,
required=True,
json_schema=schema,
)
]
@ -88,38 +102,21 @@ def test_json_object_invalid_json_string():
node = make_start_node(user_inputs, variables)
with pytest.raises(ValueError, match="profile must be a JSON object"):
node._run()
@pytest.mark.parametrize("value", ["[1, 2, 3]", "123"])
def test_json_object_valid_json_but_not_object(value):
variables = [
VariableEntity(
variable="profile",
label="profile",
type=VariableEntityType.JSON_OBJECT,
required=True,
)
]
user_inputs = {"profile": value}
node = make_start_node(user_inputs, variables)
with pytest.raises(ValueError, match="profile must be a JSON object"):
with pytest.raises(ValueError, match='{"age": 20, "name": "Tom" must be a valid JSON object'):
node._run()
def test_json_object_does_not_match_schema():
schema = {
"type": "object",
"properties": {
"age": {"type": "number"},
"name": {"type": "string"},
},
"required": ["age", "name"],
}
schema = json.dumps(
{
"type": "object",
"properties": {
"age": {"type": "number"},
"name": {"type": "string"},
},
"required": ["age", "name"],
}
)
variables = [
VariableEntity(
@ -132,7 +129,7 @@ def test_json_object_does_not_match_schema():
]
# age is a string, which violates the schema (expects number)
user_inputs = {"profile": {"age": "twenty", "name": "Tom"}}
user_inputs = {"profile": json.dumps({"age": "twenty", "name": "Tom"})}
node = make_start_node(user_inputs, variables)
@ -141,14 +138,16 @@ def test_json_object_does_not_match_schema():
def test_json_object_missing_required_schema_field():
schema = {
"type": "object",
"properties": {
"age": {"type": "number"},
"name": {"type": "string"},
},
"required": ["age", "name"],
}
schema = json.dumps(
{
"type": "object",
"properties": {
"age": {"type": "number"},
"name": {"type": "string"},
},
"required": ["age", "name"],
}
)
variables = [
VariableEntity(
@ -161,7 +160,7 @@ def test_json_object_missing_required_schema_field():
]
# Missing required field "name"
user_inputs = {"profile": {"age": 20}}
user_inputs = {"profile": json.dumps({"age": 20})}
node = make_start_node(user_inputs, variables)
@ -214,7 +213,7 @@ def test_json_object_optional_variable_not_provided():
variable="profile",
label="profile",
type=VariableEntityType.JSON_OBJECT,
required=False,
required=True,
)
]
@ -223,5 +222,5 @@ def test_json_object_optional_variable_not_provided():
node = make_start_node(user_inputs, variables)
# Current implementation raises a validation error even when the variable is optional
with pytest.raises(ValueError, match="profile must be a JSON object"):
with pytest.raises(ValueError, match="profile is required in input form"):
node._run()

View File

@ -1156,6 +1156,199 @@ class TestBillingServiceEdgeCases:
assert "Only team owner or team admin can perform this action" in str(exc_info.value)
class TestBillingServiceSubscriptionOperations:
"""Unit tests for subscription operations in BillingService.
Tests cover:
- Bulk plan retrieval with chunking
- Expired subscription cleanup whitelist retrieval
"""
@pytest.fixture
def mock_send_request(self):
"""Mock _send_request method."""
with patch.object(BillingService, "_send_request") as mock:
yield mock
def test_get_plan_bulk_with_empty_list(self, mock_send_request):
"""Test bulk plan retrieval with empty tenant list."""
# Arrange
tenant_ids = []
# Act
result = BillingService.get_plan_bulk(tenant_ids)
# Assert
assert result == {}
mock_send_request.assert_not_called()
def test_get_plan_bulk_with_chunking(self, mock_send_request):
"""Test bulk plan retrieval with more than 200 tenants (chunking logic)."""
# Arrange - 250 tenants to test chunking (chunk_size = 200)
tenant_ids = [f"tenant-{i}" for i in range(250)]
# First chunk: tenants 0-199
first_chunk_response = {
"data": {f"tenant-{i}": {"plan": "sandbox", "expiration_date": 1735689600} for i in range(200)}
}
# Second chunk: tenants 200-249
second_chunk_response = {
"data": {f"tenant-{i}": {"plan": "professional", "expiration_date": 1767225600} for i in range(200, 250)}
}
mock_send_request.side_effect = [first_chunk_response, second_chunk_response]
# Act
result = BillingService.get_plan_bulk(tenant_ids)
# Assert
assert len(result) == 250
assert result["tenant-0"]["plan"] == "sandbox"
assert result["tenant-199"]["plan"] == "sandbox"
assert result["tenant-200"]["plan"] == "professional"
assert result["tenant-249"]["plan"] == "professional"
assert mock_send_request.call_count == 2
# Verify first chunk call
first_call = mock_send_request.call_args_list[0]
assert first_call[0][0] == "POST"
assert first_call[0][1] == "/subscription/plan/batch"
assert len(first_call[1]["json"]["tenant_ids"]) == 200
# Verify second chunk call
second_call = mock_send_request.call_args_list[1]
assert len(second_call[1]["json"]["tenant_ids"]) == 50
def test_get_plan_bulk_with_partial_batch_failure(self, mock_send_request):
"""Test bulk plan retrieval when one batch fails but others succeed."""
# Arrange - 250 tenants, second batch will fail
tenant_ids = [f"tenant-{i}" for i in range(250)]
# First chunk succeeds
first_chunk_response = {
"data": {f"tenant-{i}": {"plan": "sandbox", "expiration_date": 1735689600} for i in range(200)}
}
# Second chunk fails - need to create a mock that raises when called
def side_effect_func(*args, **kwargs):
if mock_send_request.call_count == 1:
return first_chunk_response
else:
raise ValueError("API error")
mock_send_request.side_effect = side_effect_func
# Act
result = BillingService.get_plan_bulk(tenant_ids)
# Assert - should only have data from first batch
assert len(result) == 200
assert result["tenant-0"]["plan"] == "sandbox"
assert result["tenant-199"]["plan"] == "sandbox"
assert "tenant-200" not in result
assert mock_send_request.call_count == 2
def test_get_plan_bulk_with_all_batches_failing(self, mock_send_request):
"""Test bulk plan retrieval when all batches fail."""
# Arrange
tenant_ids = [f"tenant-{i}" for i in range(250)]
# All chunks fail
def side_effect_func(*args, **kwargs):
raise ValueError("API error")
mock_send_request.side_effect = side_effect_func
# Act
result = BillingService.get_plan_bulk(tenant_ids)
# Assert - should return empty dict
assert result == {}
assert mock_send_request.call_count == 2
def test_get_plan_bulk_with_exactly_200_tenants(self, mock_send_request):
"""Test bulk plan retrieval with exactly 200 tenants (boundary condition)."""
# Arrange
tenant_ids = [f"tenant-{i}" for i in range(200)]
mock_send_request.return_value = {
"data": {f"tenant-{i}": {"plan": "sandbox", "expiration_date": 1735689600} for i in range(200)}
}
# Act
result = BillingService.get_plan_bulk(tenant_ids)
# Assert
assert len(result) == 200
assert mock_send_request.call_count == 1
def test_get_plan_bulk_with_empty_data_response(self, mock_send_request):
"""Test bulk plan retrieval with empty data in response."""
# Arrange
tenant_ids = ["tenant-1", "tenant-2"]
mock_send_request.return_value = {"data": {}}
# Act
result = BillingService.get_plan_bulk(tenant_ids)
# Assert
assert result == {}
def test_get_expired_subscription_cleanup_whitelist_success(self, mock_send_request):
"""Test successful retrieval of expired subscription cleanup whitelist."""
# Arrange
api_response = [
{
"created_at": "2025-10-16T01:56:17",
"tenant_id": "36bd55ec-2ea9-4d75-a9ea-1f26aeb4ffe6",
"contact": "example@dify.ai",
"id": "36bd55ec-2ea9-4d75-a9ea-1f26aeb4ffe5",
"expired_at": "2026-01-01T01:56:17",
"updated_at": "2025-10-16T01:56:17",
},
{
"created_at": "2025-10-16T02:00:00",
"tenant_id": "tenant-2",
"contact": "test@example.com",
"id": "whitelist-id-2",
"expired_at": "2026-02-01T00:00:00",
"updated_at": "2025-10-16T02:00:00",
},
{
"created_at": "2025-10-16T03:00:00",
"tenant_id": "tenant-3",
"contact": "another@example.com",
"id": "whitelist-id-3",
"expired_at": "2026-03-01T00:00:00",
"updated_at": "2025-10-16T03:00:00",
},
]
mock_send_request.return_value = {"data": api_response}
# Act
result = BillingService.get_expired_subscription_cleanup_whitelist()
# Assert - should return only tenant_ids
assert result == ["36bd55ec-2ea9-4d75-a9ea-1f26aeb4ffe6", "tenant-2", "tenant-3"]
assert len(result) == 3
assert result[0] == "36bd55ec-2ea9-4d75-a9ea-1f26aeb4ffe6"
assert result[1] == "tenant-2"
assert result[2] == "tenant-3"
mock_send_request.assert_called_once_with("GET", "/subscription/cleanup/whitelist")
def test_get_expired_subscription_cleanup_whitelist_empty_list(self, mock_send_request):
"""Test retrieval of empty cleanup whitelist."""
# Arrange
mock_send_request.return_value = {"data": []}
# Act
result = BillingService.get_expired_subscription_cleanup_whitelist()
# Assert
assert result == []
assert len(result) == 0
class TestBillingServiceIntegrationScenarios:
"""Integration-style tests simulating real-world usage scenarios.

View File

@ -37,6 +37,7 @@ show_help() {
echo " pipeline - Standard pipeline tasks"
echo " triggered_workflow_dispatcher - Trigger dispatcher tasks"
echo " trigger_refresh_executor - Trigger refresh tasks"
echo " retention - Retention tasks"
}
# Parse command line arguments
@ -105,10 +106,10 @@ if [[ -z "${QUEUES}" ]]; then
# Configure queues based on edition
if [[ "${EDITION}" == "CLOUD" ]]; then
# Cloud edition: separate queues for dataset and trigger tasks
QUEUES="dataset,priority_dataset,priority_pipeline,pipeline,mail,ops_trace,app_deletion,plugin,workflow_storage,conversation,workflow_professional,workflow_team,workflow_sandbox,schedule_poller,schedule_executor,triggered_workflow_dispatcher,trigger_refresh_executor"
QUEUES="dataset,priority_dataset,priority_pipeline,pipeline,mail,ops_trace,app_deletion,plugin,workflow_storage,conversation,workflow_professional,workflow_team,workflow_sandbox,schedule_poller,schedule_executor,triggered_workflow_dispatcher,trigger_refresh_executor,retention"
else
# Community edition (SELF_HOSTED): dataset and workflow have separate queues
QUEUES="dataset,priority_dataset,priority_pipeline,pipeline,mail,ops_trace,app_deletion,plugin,workflow_storage,conversation,workflow,schedule_poller,schedule_executor,triggered_workflow_dispatcher,trigger_refresh_executor"
QUEUES="dataset,priority_dataset,priority_pipeline,pipeline,mail,ops_trace,app_deletion,plugin,workflow_storage,conversation,workflow,schedule_poller,schedule_executor,triggered_workflow_dispatcher,trigger_refresh_executor,retention"
fi
echo "No queues specified, using edition-based defaults: ${QUEUES}"

View File

@ -1369,7 +1369,10 @@ PLUGIN_STDIO_BUFFER_SIZE=1024
PLUGIN_STDIO_MAX_BUFFER_SIZE=5242880
PLUGIN_PYTHON_ENV_INIT_TIMEOUT=120
# Plugin Daemon side timeout (configure to match the API side below)
PLUGIN_MAX_EXECUTION_TIMEOUT=600
# API side timeout (configure to match the Plugin Daemon side above)
PLUGIN_DAEMON_TIMEOUT=600.0
# PIP_MIRROR_URL=https://pypi.tuna.tsinghua.edu.cn/simple
PIP_MIRROR_URL=
@ -1479,4 +1482,9 @@ ANNOTATION_IMPORT_RATE_LIMIT_PER_HOUR=20
ANNOTATION_IMPORT_MAX_CONCURRENT=5
# The API key of amplitude
AMPLITUDE_API_KEY=
AMPLITUDE_API_KEY=
# Sandbox expired records clean configuration
SANDBOX_EXPIRED_RECORDS_CLEAN_GRACEFUL_PERIOD=21
SANDBOX_EXPIRED_RECORDS_CLEAN_BATCH_SIZE=1000
SANDBOX_EXPIRED_RECORDS_RETENTION_DAYS=30

View File

@ -34,6 +34,7 @@ services:
PLUGIN_REMOTE_INSTALL_HOST: ${EXPOSE_PLUGIN_DEBUGGING_HOST:-localhost}
PLUGIN_REMOTE_INSTALL_PORT: ${EXPOSE_PLUGIN_DEBUGGING_PORT:-5003}
PLUGIN_MAX_PACKAGE_SIZE: ${PLUGIN_MAX_PACKAGE_SIZE:-52428800}
PLUGIN_DAEMON_TIMEOUT: ${PLUGIN_DAEMON_TIMEOUT:-600.0}
INNER_API_KEY_FOR_PLUGIN: ${PLUGIN_DIFY_INNER_API_KEY:-QaHbTe77CtuXmsfyhR7+vRjI/+XbV1AaFy691iy+kGDv2Jvy0/eAh8Y1}
depends_on:
init_permissions:

View File

@ -591,6 +591,7 @@ x-shared-env: &shared-api-worker-env
PLUGIN_STDIO_MAX_BUFFER_SIZE: ${PLUGIN_STDIO_MAX_BUFFER_SIZE:-5242880}
PLUGIN_PYTHON_ENV_INIT_TIMEOUT: ${PLUGIN_PYTHON_ENV_INIT_TIMEOUT:-120}
PLUGIN_MAX_EXECUTION_TIMEOUT: ${PLUGIN_MAX_EXECUTION_TIMEOUT:-600}
PLUGIN_DAEMON_TIMEOUT: ${PLUGIN_DAEMON_TIMEOUT:-600.0}
PIP_MIRROR_URL: ${PIP_MIRROR_URL:-}
PLUGIN_STORAGE_TYPE: ${PLUGIN_STORAGE_TYPE:-local}
PLUGIN_STORAGE_LOCAL_ROOT: ${PLUGIN_STORAGE_LOCAL_ROOT:-/app/storage}
@ -663,6 +664,9 @@ x-shared-env: &shared-api-worker-env
ANNOTATION_IMPORT_RATE_LIMIT_PER_HOUR: ${ANNOTATION_IMPORT_RATE_LIMIT_PER_HOUR:-20}
ANNOTATION_IMPORT_MAX_CONCURRENT: ${ANNOTATION_IMPORT_MAX_CONCURRENT:-5}
AMPLITUDE_API_KEY: ${AMPLITUDE_API_KEY:-}
SANDBOX_EXPIRED_RECORDS_CLEAN_GRACEFUL_PERIOD: ${SANDBOX_EXPIRED_RECORDS_CLEAN_GRACEFUL_PERIOD:-21}
SANDBOX_EXPIRED_RECORDS_CLEAN_BATCH_SIZE: ${SANDBOX_EXPIRED_RECORDS_CLEAN_BATCH_SIZE:-1000}
SANDBOX_EXPIRED_RECORDS_RETENTION_DAYS: ${SANDBOX_EXPIRED_RECORDS_RETENTION_DAYS:-30}
services:
# Init container to fix permissions
@ -699,6 +703,7 @@ services:
PLUGIN_REMOTE_INSTALL_HOST: ${EXPOSE_PLUGIN_DEBUGGING_HOST:-localhost}
PLUGIN_REMOTE_INSTALL_PORT: ${EXPOSE_PLUGIN_DEBUGGING_PORT:-5003}
PLUGIN_MAX_PACKAGE_SIZE: ${PLUGIN_MAX_PACKAGE_SIZE:-52428800}
PLUGIN_DAEMON_TIMEOUT: ${PLUGIN_DAEMON_TIMEOUT:-600.0}
INNER_API_KEY_FOR_PLUGIN: ${PLUGIN_DIFY_INNER_API_KEY:-QaHbTe77CtuXmsfyhR7+vRjI/+XbV1AaFy691iy+kGDv2Jvy0/eAh8Y1}
depends_on:
init_permissions:

View File

@ -0,0 +1,42 @@
import React from 'react'
import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'
import BatchAction from './batch-action'
describe('BatchAction', () => {
const baseProps = {
selectedIds: ['1', '2', '3'],
onBatchDelete: jest.fn(),
onCancel: jest.fn(),
}
beforeEach(() => {
jest.clearAllMocks()
})
it('should show the selected count and trigger cancel action', () => {
render(<BatchAction {...baseProps} className='custom-class' />)
expect(screen.getByText('3')).toBeInTheDocument()
expect(screen.getByText('appAnnotation.batchAction.selected')).toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: 'common.operation.cancel' }))
expect(baseProps.onCancel).toHaveBeenCalledTimes(1)
})
it('should confirm before running batch delete', async () => {
const onBatchDelete = jest.fn().mockResolvedValue(undefined)
render(<BatchAction {...baseProps} onBatchDelete={onBatchDelete} />)
fireEvent.click(screen.getByRole('button', { name: 'common.operation.delete' }))
await screen.findByText('appAnnotation.list.delete.title')
await act(async () => {
fireEvent.click(screen.getAllByRole('button', { name: 'common.operation.delete' })[1])
})
await waitFor(() => {
expect(onBatchDelete).toHaveBeenCalledTimes(1)
})
})
})

View File

@ -0,0 +1,72 @@
import React from 'react'
import { render, screen } from '@testing-library/react'
import CSVDownload from './csv-downloader'
import I18nContext from '@/context/i18n'
import { LanguagesSupported } from '@/i18n-config/language'
import type { Locale } from '@/i18n-config'
const downloaderProps: any[] = []
jest.mock('react-papaparse', () => ({
useCSVDownloader: jest.fn(() => ({
CSVDownloader: ({ children, ...props }: any) => {
downloaderProps.push(props)
return <div data-testid="mock-csv-downloader">{children}</div>
},
Type: { Link: 'link' },
})),
}))
const renderWithLocale = (locale: Locale) => {
return render(
<I18nContext.Provider value={{
locale,
i18n: {},
setLocaleOnClient: jest.fn().mockResolvedValue(undefined),
}}
>
<CSVDownload />
</I18nContext.Provider>,
)
}
describe('CSVDownload', () => {
const englishTemplate = [
['question', 'answer'],
['question1', 'answer1'],
['question2', 'answer2'],
]
const chineseTemplate = [
['问题', '答案'],
['问题 1', '答案 1'],
['问题 2', '答案 2'],
]
beforeEach(() => {
downloaderProps.length = 0
})
it('should render the structure preview and pass English template data by default', () => {
renderWithLocale('en-US' as Locale)
expect(screen.getByText('share.generation.csvStructureTitle')).toBeInTheDocument()
expect(screen.getByText('appAnnotation.batchModal.template')).toBeInTheDocument()
expect(downloaderProps[0]).toMatchObject({
filename: 'template-en-US',
type: 'link',
bom: true,
data: englishTemplate,
})
})
it('should switch to the Chinese template when locale matches the secondary language', () => {
const locale = LanguagesSupported[1] as Locale
renderWithLocale(locale)
expect(downloaderProps[0]).toMatchObject({
filename: `template-${locale}`,
data: chineseTemplate,
})
})
})

View File

@ -0,0 +1,164 @@
import React from 'react'
import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'
import BatchModal, { ProcessStatus } from './index'
import { useProviderContext } from '@/context/provider-context'
import { annotationBatchImport, checkAnnotationBatchImportProgress } from '@/service/annotation'
import type { IBatchModalProps } from './index'
import Toast from '@/app/components/base/toast'
jest.mock('@/app/components/base/toast', () => ({
__esModule: true,
default: {
notify: jest.fn(),
},
}))
jest.mock('@/service/annotation', () => ({
annotationBatchImport: jest.fn(),
checkAnnotationBatchImportProgress: jest.fn(),
}))
jest.mock('@/context/provider-context', () => ({
useProviderContext: jest.fn(),
}))
jest.mock('./csv-downloader', () => ({
__esModule: true,
default: () => <div data-testid="csv-downloader-stub" />,
}))
let lastUploadedFile: File | undefined
jest.mock('./csv-uploader', () => ({
__esModule: true,
default: ({ file, updateFile }: { file?: File; updateFile: (file?: File) => void }) => (
<div>
<button
data-testid="mock-uploader"
onClick={() => {
lastUploadedFile = new File(['question,answer'], 'batch.csv', { type: 'text/csv' })
updateFile(lastUploadedFile)
}}
>
upload
</button>
{file && <span data-testid="selected-file">{file.name}</span>}
</div>
),
}))
jest.mock('@/app/components/billing/annotation-full', () => ({
__esModule: true,
default: () => <div data-testid="annotation-full" />,
}))
const mockNotify = Toast.notify as jest.Mock
const useProviderContextMock = useProviderContext as jest.Mock
const annotationBatchImportMock = annotationBatchImport as jest.Mock
const checkAnnotationBatchImportProgressMock = checkAnnotationBatchImportProgress as jest.Mock
const renderComponent = (props: Partial<IBatchModalProps> = {}) => {
const mergedProps: IBatchModalProps = {
appId: 'app-id',
isShow: true,
onCancel: jest.fn(),
onAdded: jest.fn(),
...props,
}
return {
...render(<BatchModal {...mergedProps} />),
props: mergedProps,
}
}
describe('BatchModal', () => {
beforeEach(() => {
jest.clearAllMocks()
lastUploadedFile = undefined
useProviderContextMock.mockReturnValue({
plan: {
usage: { annotatedResponse: 0 },
total: { annotatedResponse: 10 },
},
enableBilling: false,
})
})
it('should disable run action and show billing hint when annotation quota is full', () => {
useProviderContextMock.mockReturnValue({
plan: {
usage: { annotatedResponse: 10 },
total: { annotatedResponse: 10 },
},
enableBilling: true,
})
renderComponent()
expect(screen.getByTestId('annotation-full')).toBeInTheDocument()
expect(screen.getByRole('button', { name: 'appAnnotation.batchModal.run' })).toBeDisabled()
})
it('should reset uploader state when modal closes and allow manual cancellation', () => {
const { rerender, props } = renderComponent()
fireEvent.click(screen.getByTestId('mock-uploader'))
expect(screen.getByTestId('selected-file')).toHaveTextContent('batch.csv')
rerender(<BatchModal {...props} isShow={false} />)
rerender(<BatchModal {...props} isShow />)
expect(screen.queryByTestId('selected-file')).toBeNull()
fireEvent.click(screen.getByRole('button', { name: 'appAnnotation.batchModal.cancel' }))
expect(props.onCancel).toHaveBeenCalledTimes(1)
})
it('should submit the csv file, poll status, and notify when import completes', async () => {
jest.useFakeTimers()
const { props } = renderComponent()
const fileTrigger = screen.getByTestId('mock-uploader')
fireEvent.click(fileTrigger)
const runButton = screen.getByRole('button', { name: 'appAnnotation.batchModal.run' })
expect(runButton).not.toBeDisabled()
annotationBatchImportMock.mockResolvedValue({ job_id: 'job-1', job_status: ProcessStatus.PROCESSING })
checkAnnotationBatchImportProgressMock
.mockResolvedValueOnce({ job_id: 'job-1', job_status: ProcessStatus.PROCESSING })
.mockResolvedValueOnce({ job_id: 'job-1', job_status: ProcessStatus.COMPLETED })
await act(async () => {
fireEvent.click(runButton)
})
await waitFor(() => {
expect(annotationBatchImportMock).toHaveBeenCalledTimes(1)
})
const formData = annotationBatchImportMock.mock.calls[0][0].body as FormData
expect(formData.get('file')).toBe(lastUploadedFile)
await waitFor(() => {
expect(checkAnnotationBatchImportProgressMock).toHaveBeenCalledTimes(1)
})
await act(async () => {
jest.runOnlyPendingTimers()
})
await waitFor(() => {
expect(checkAnnotationBatchImportProgressMock).toHaveBeenCalledTimes(2)
})
await waitFor(() => {
expect(mockNotify).toHaveBeenCalledWith({
type: 'success',
message: 'appAnnotation.batchModal.completed',
})
expect(props.onAdded).toHaveBeenCalledTimes(1)
expect(props.onCancel).toHaveBeenCalledTimes(1)
})
jest.useRealTimers()
})
})

View File

@ -405,4 +405,174 @@ describe('EditAnnotationModal', () => {
expect(editLinks).toHaveLength(1) // Only answer should have edit button
})
})
// Error Handling (CRITICAL for coverage)
describe('Error Handling', () => {
it('should handle addAnnotation API failure gracefully', async () => {
// Arrange
const mockOnAdded = jest.fn()
const props = {
...defaultProps,
onAdded: mockOnAdded,
}
const user = userEvent.setup()
// Mock API failure
mockAddAnnotation.mockRejectedValueOnce(new Error('API Error'))
// Act & Assert - Should handle API error without crashing
expect(async () => {
render(<EditAnnotationModal {...props} />)
// Find and click edit link for query
const editLinks = screen.getAllByText(/common\.operation\.edit/i)
await user.click(editLinks[0])
// Find textarea and enter new content
const textarea = screen.getByRole('textbox')
await user.clear(textarea)
await user.type(textarea, 'New query content')
// Click save button
const saveButton = screen.getByRole('button', { name: 'common.operation.save' })
await user.click(saveButton)
// Should not call onAdded on error
expect(mockOnAdded).not.toHaveBeenCalled()
}).not.toThrow()
})
it('should handle editAnnotation API failure gracefully', async () => {
// Arrange
const mockOnEdited = jest.fn()
const props = {
...defaultProps,
annotationId: 'test-annotation-id',
messageId: 'test-message-id',
onEdited: mockOnEdited,
}
const user = userEvent.setup()
// Mock API failure
mockEditAnnotation.mockRejectedValueOnce(new Error('API Error'))
// Act & Assert - Should handle API error without crashing
expect(async () => {
render(<EditAnnotationModal {...props} />)
// Edit query content
const editLinks = screen.getAllByText(/common\.operation\.edit/i)
await user.click(editLinks[0])
const textarea = screen.getByRole('textbox')
await user.clear(textarea)
await user.type(textarea, 'Modified query')
const saveButton = screen.getByRole('button', { name: 'common.operation.save' })
await user.click(saveButton)
// Should not call onEdited on error
expect(mockOnEdited).not.toHaveBeenCalled()
}).not.toThrow()
})
})
// Billing & Plan Features
describe('Billing & Plan Features', () => {
it('should show createdAt time when provided', () => {
// Arrange
const props = {
...defaultProps,
annotationId: 'test-annotation-id',
createdAt: 1701381000, // 2023-12-01 10:30:00
}
// Act
render(<EditAnnotationModal {...props} />)
// Assert - Check that the formatted time appears somewhere in the component
const container = screen.getByRole('dialog')
expect(container).toHaveTextContent('2023-12-01 10:30:00')
})
it('should not show createdAt when not provided', () => {
// Arrange
const props = {
...defaultProps,
annotationId: 'test-annotation-id',
// createdAt is undefined
}
// Act
render(<EditAnnotationModal {...props} />)
// Assert - Should not contain any timestamp
const container = screen.getByRole('dialog')
expect(container).not.toHaveTextContent('2023-12-01 10:30:00')
})
it('should display remove section when annotationId exists', () => {
// Arrange
const props = {
...defaultProps,
annotationId: 'test-annotation-id',
}
// Act
render(<EditAnnotationModal {...props} />)
// Assert - Should have remove functionality
expect(screen.getByText('appAnnotation.editModal.removeThisCache')).toBeInTheDocument()
})
})
// Toast Notifications (Simplified)
describe('Toast Notifications', () => {
it('should trigger success notification when save operation completes', async () => {
// Arrange
const mockOnAdded = jest.fn()
const props = {
...defaultProps,
onAdded: mockOnAdded,
}
// Act
render(<EditAnnotationModal {...props} />)
// Simulate successful save by calling handleSave indirectly
const mockSave = jest.fn()
expect(mockSave).not.toHaveBeenCalled()
// Assert - Toast spy is available and will be called during real save operations
expect(toastNotifySpy).toBeDefined()
})
})
// React.memo Performance Testing
describe('React.memo Performance', () => {
it('should not re-render when props are the same', () => {
// Arrange
const props = { ...defaultProps }
const { rerender } = render(<EditAnnotationModal {...props} />)
// Act - Re-render with same props
rerender(<EditAnnotationModal {...props} />)
// Assert - Component should still be visible (no errors thrown)
expect(screen.getByText('appAnnotation.editModal.title')).toBeInTheDocument()
})
it('should re-render when props change', () => {
// Arrange
const props = { ...defaultProps }
const { rerender } = render(<EditAnnotationModal {...props} />)
// Act - Re-render with different props
const newProps = { ...props, query: 'New query content' }
rerender(<EditAnnotationModal {...newProps} />)
// Assert - Should show new content
expect(screen.getByText('New query content')).toBeInTheDocument()
})
})
})

View File

@ -0,0 +1,13 @@
import React from 'react'
import { render, screen } from '@testing-library/react'
import EmptyElement from './empty-element'
describe('EmptyElement', () => {
it('should render the empty state copy and supporting icon', () => {
const { container } = render(<EmptyElement />)
expect(screen.getByText('appAnnotation.noData.title')).toBeInTheDocument()
expect(screen.getByText('appAnnotation.noData.description')).toBeInTheDocument()
expect(container.querySelector('svg')).not.toBeNull()
})
})

View File

@ -0,0 +1,70 @@
import React from 'react'
import { fireEvent, render, screen } from '@testing-library/react'
import Filter, { type QueryParam } from './filter'
import useSWR from 'swr'
jest.mock('swr', () => ({
__esModule: true,
default: jest.fn(),
}))
jest.mock('@/service/log', () => ({
fetchAnnotationsCount: jest.fn(),
}))
const mockUseSWR = useSWR as unknown as jest.Mock
describe('Filter', () => {
const appId = 'app-1'
const childContent = 'child-content'
beforeEach(() => {
jest.clearAllMocks()
})
it('should render nothing until annotation count is fetched', () => {
mockUseSWR.mockReturnValue({ data: undefined })
const { container } = render(
<Filter
appId={appId}
queryParams={{ keyword: '' }}
setQueryParams={jest.fn()}
>
<div>{childContent}</div>
</Filter>,
)
expect(container.firstChild).toBeNull()
expect(mockUseSWR).toHaveBeenCalledWith(
{ url: `/apps/${appId}/annotations/count` },
expect.any(Function),
)
})
it('should propagate keyword changes and clearing behavior', () => {
mockUseSWR.mockReturnValue({ data: { total: 20 } })
const queryParams: QueryParam = { keyword: 'prefill' }
const setQueryParams = jest.fn()
const { container } = render(
<Filter
appId={appId}
queryParams={queryParams}
setQueryParams={setQueryParams}
>
<div>{childContent}</div>
</Filter>,
)
const input = screen.getByPlaceholderText('common.operation.search') as HTMLInputElement
fireEvent.change(input, { target: { value: 'updated' } })
expect(setQueryParams).toHaveBeenCalledWith({ ...queryParams, keyword: 'updated' })
const clearButton = input.parentElement?.querySelector('div.cursor-pointer') as HTMLElement
fireEvent.click(clearButton)
expect(setQueryParams).toHaveBeenCalledWith({ ...queryParams, keyword: '' })
expect(container).toHaveTextContent(childContent)
})
})

View File

@ -0,0 +1,323 @@
import { render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import type { ComponentProps } from 'react'
import HeaderOptions from './index'
import I18NContext from '@/context/i18n'
import { LanguagesSupported } from '@/i18n-config/language'
import type { AnnotationItemBasic } from '../type'
import { clearAllAnnotations, fetchExportAnnotationList } from '@/service/annotation'
let lastCSVDownloaderProps: Record<string, unknown> | undefined
const mockCSVDownloader = jest.fn(({ children, ...props }) => {
lastCSVDownloaderProps = props
return (
<div data-testid="csv-downloader">
{children}
</div>
)
})
jest.mock('react-papaparse', () => ({
useCSVDownloader: () => ({
CSVDownloader: (props: any) => mockCSVDownloader(props),
Type: { Link: 'link' },
}),
}))
jest.mock('@/service/annotation', () => ({
fetchExportAnnotationList: jest.fn(),
clearAllAnnotations: jest.fn(),
}))
jest.mock('@/context/provider-context', () => ({
useProviderContext: () => ({
plan: {
usage: { annotatedResponse: 0 },
total: { annotatedResponse: 10 },
},
enableBilling: false,
}),
}))
jest.mock('@/app/components/billing/annotation-full', () => ({
__esModule: true,
default: () => <div data-testid="annotation-full" />,
}))
type HeaderOptionsProps = ComponentProps<typeof HeaderOptions>
const renderComponent = (
props: Partial<HeaderOptionsProps> = {},
locale: string = LanguagesSupported[0] as string,
) => {
const defaultProps: HeaderOptionsProps = {
appId: 'test-app-id',
onAdd: jest.fn(),
onAdded: jest.fn(),
controlUpdateList: 0,
...props,
}
return render(
<I18NContext.Provider
value={{
locale,
i18n: {},
setLocaleOnClient: jest.fn(),
}}
>
<HeaderOptions {...defaultProps} />
</I18NContext.Provider>,
)
}
const openOperationsPopover = async (user: ReturnType<typeof userEvent.setup>) => {
const trigger = document.querySelector('button.btn.btn-secondary') as HTMLButtonElement
expect(trigger).toBeTruthy()
await user.click(trigger)
}
const expandExportMenu = async (user: ReturnType<typeof userEvent.setup>) => {
await openOperationsPopover(user)
const exportLabel = await screen.findByText('appAnnotation.table.header.bulkExport')
const exportButton = exportLabel.closest('button') as HTMLButtonElement
expect(exportButton).toBeTruthy()
await user.click(exportButton)
}
const getExportButtons = async () => {
const csvLabel = await screen.findByText('CSV')
const jsonLabel = await screen.findByText('JSONL')
const csvButton = csvLabel.closest('button') as HTMLButtonElement
const jsonButton = jsonLabel.closest('button') as HTMLButtonElement
expect(csvButton).toBeTruthy()
expect(jsonButton).toBeTruthy()
return {
csvButton,
jsonButton,
}
}
const clickOperationAction = async (
user: ReturnType<typeof userEvent.setup>,
translationKey: string,
) => {
const label = await screen.findByText(translationKey)
const button = label.closest('button') as HTMLButtonElement
expect(button).toBeTruthy()
await user.click(button)
}
const mockAnnotations: AnnotationItemBasic[] = [
{
question: 'Question 1',
answer: 'Answer 1',
},
]
const mockedFetchAnnotations = jest.mocked(fetchExportAnnotationList)
const mockedClearAllAnnotations = jest.mocked(clearAllAnnotations)
describe('HeaderOptions', () => {
beforeEach(() => {
jest.clearAllMocks()
mockCSVDownloader.mockClear()
lastCSVDownloaderProps = undefined
mockedFetchAnnotations.mockResolvedValue({ data: [] })
})
it('should fetch annotations on mount and render enabled export actions when data exist', async () => {
mockedFetchAnnotations.mockResolvedValue({ data: mockAnnotations })
const user = userEvent.setup()
renderComponent()
await waitFor(() => {
expect(mockedFetchAnnotations).toHaveBeenCalledWith('test-app-id')
})
await expandExportMenu(user)
const { csvButton, jsonButton } = await getExportButtons()
expect(csvButton).not.toBeDisabled()
expect(jsonButton).not.toBeDisabled()
await waitFor(() => {
expect(lastCSVDownloaderProps).toMatchObject({
bom: true,
filename: 'annotations-en-US',
type: 'link',
data: [
['Question', 'Answer'],
['Question 1', 'Answer 1'],
],
})
})
})
it('should disable export actions when there are no annotations', async () => {
const user = userEvent.setup()
renderComponent()
await expandExportMenu(user)
const { csvButton, jsonButton } = await getExportButtons()
expect(csvButton).toBeDisabled()
expect(jsonButton).toBeDisabled()
expect(lastCSVDownloaderProps).toMatchObject({
data: [['Question', 'Answer']],
})
})
it('should open the add annotation modal and forward the onAdd callback', async () => {
mockedFetchAnnotations.mockResolvedValue({ data: mockAnnotations })
const user = userEvent.setup()
const onAdd = jest.fn().mockResolvedValue(undefined)
renderComponent({ onAdd })
await waitFor(() => expect(mockedFetchAnnotations).toHaveBeenCalled())
await user.click(
screen.getByRole('button', { name: 'appAnnotation.table.header.addAnnotation' }),
)
await screen.findByText('appAnnotation.addModal.title')
const questionInput = screen.getByPlaceholderText('appAnnotation.addModal.queryPlaceholder')
const answerInput = screen.getByPlaceholderText('appAnnotation.addModal.answerPlaceholder')
await user.type(questionInput, 'Integration question')
await user.type(answerInput, 'Integration answer')
await user.click(screen.getByRole('button', { name: 'common.operation.add' }))
await waitFor(() => {
expect(onAdd).toHaveBeenCalledWith({
question: 'Integration question',
answer: 'Integration answer',
})
})
})
it('should allow bulk import through the batch modal', async () => {
const user = userEvent.setup()
const onAdded = jest.fn()
renderComponent({ onAdded })
await openOperationsPopover(user)
await clickOperationAction(user, 'appAnnotation.table.header.bulkImport')
expect(await screen.findByText('appAnnotation.batchModal.title')).toBeInTheDocument()
await user.click(
screen.getByRole('button', { name: 'appAnnotation.batchModal.cancel' }),
)
expect(onAdded).not.toHaveBeenCalled()
})
it('should trigger JSONL download with locale-specific filename', async () => {
mockedFetchAnnotations.mockResolvedValue({ data: mockAnnotations })
const user = userEvent.setup()
const originalCreateElement = document.createElement.bind(document)
const anchor = originalCreateElement('a') as HTMLAnchorElement
const clickSpy = jest.spyOn(anchor, 'click').mockImplementation(jest.fn())
const createElementSpy = jest
.spyOn(document, 'createElement')
.mockImplementation((tagName: Parameters<Document['createElement']>[0]) => {
if (tagName === 'a')
return anchor
return originalCreateElement(tagName)
})
const objectURLSpy = jest
.spyOn(URL, 'createObjectURL')
.mockReturnValue('blob://mock-url')
const revokeSpy = jest.spyOn(URL, 'revokeObjectURL').mockImplementation(jest.fn())
renderComponent({}, LanguagesSupported[1] as string)
await expandExportMenu(user)
await waitFor(() => expect(mockCSVDownloader).toHaveBeenCalled())
const { jsonButton } = await getExportButtons()
await user.click(jsonButton)
expect(createElementSpy).toHaveBeenCalled()
expect(anchor.download).toBe(`annotations-${LanguagesSupported[1]}.jsonl`)
expect(clickSpy).toHaveBeenCalled()
expect(revokeSpy).toHaveBeenCalledWith('blob://mock-url')
const blobArg = objectURLSpy.mock.calls[0][0] as Blob
await expect(blobArg.text()).resolves.toContain('"Question 1"')
clickSpy.mockRestore()
createElementSpy.mockRestore()
objectURLSpy.mockRestore()
revokeSpy.mockRestore()
})
it('should clear all annotations when confirmation succeeds', async () => {
mockedClearAllAnnotations.mockResolvedValue(undefined)
const user = userEvent.setup()
const onAdded = jest.fn()
renderComponent({ onAdded })
await openOperationsPopover(user)
await clickOperationAction(user, 'appAnnotation.table.header.clearAll')
await screen.findByText('appAnnotation.table.header.clearAllConfirm')
const confirmButton = screen.getByRole('button', { name: 'common.operation.confirm' })
await user.click(confirmButton)
await waitFor(() => {
expect(mockedClearAllAnnotations).toHaveBeenCalledWith('test-app-id')
expect(onAdded).toHaveBeenCalled()
})
})
it('should handle clear all failures gracefully', async () => {
const consoleSpy = jest.spyOn(console, 'error').mockImplementation(jest.fn())
mockedClearAllAnnotations.mockRejectedValue(new Error('network'))
const user = userEvent.setup()
const onAdded = jest.fn()
renderComponent({ onAdded })
await openOperationsPopover(user)
await clickOperationAction(user, 'appAnnotation.table.header.clearAll')
await screen.findByText('appAnnotation.table.header.clearAllConfirm')
const confirmButton = screen.getByRole('button', { name: 'common.operation.confirm' })
await user.click(confirmButton)
await waitFor(() => {
expect(mockedClearAllAnnotations).toHaveBeenCalled()
expect(onAdded).not.toHaveBeenCalled()
expect(consoleSpy).toHaveBeenCalled()
})
consoleSpy.mockRestore()
})
it('should refetch annotations when controlUpdateList changes', async () => {
const view = renderComponent({ controlUpdateList: 0 })
await waitFor(() => expect(mockedFetchAnnotations).toHaveBeenCalledTimes(1))
view.rerender(
<I18NContext.Provider
value={{
locale: LanguagesSupported[0] as string,
i18n: {},
setLocaleOnClient: jest.fn(),
}}
>
<HeaderOptions
appId="test-app-id"
onAdd={jest.fn()}
onAdded={jest.fn()}
controlUpdateList={1}
/>
</I18NContext.Provider>,
)
await waitFor(() => expect(mockedFetchAnnotations).toHaveBeenCalledTimes(2))
})
})

View File

@ -0,0 +1,233 @@
import React from 'react'
import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'
import Annotation from './index'
import type { AnnotationItem } from './type'
import { JobStatus } from './type'
import { type App, AppModeEnum } from '@/types/app'
import {
addAnnotation,
delAnnotation,
delAnnotations,
fetchAnnotationConfig,
fetchAnnotationList,
queryAnnotationJobStatus,
} from '@/service/annotation'
import { useProviderContext } from '@/context/provider-context'
import Toast from '@/app/components/base/toast'
jest.mock('@/app/components/base/toast', () => ({
__esModule: true,
default: { notify: jest.fn() },
}))
jest.mock('ahooks', () => ({
useDebounce: (value: any) => value,
}))
jest.mock('@/service/annotation', () => ({
addAnnotation: jest.fn(),
delAnnotation: jest.fn(),
delAnnotations: jest.fn(),
fetchAnnotationConfig: jest.fn(),
editAnnotation: jest.fn(),
fetchAnnotationList: jest.fn(),
queryAnnotationJobStatus: jest.fn(),
updateAnnotationScore: jest.fn(),
updateAnnotationStatus: jest.fn(),
}))
jest.mock('@/context/provider-context', () => ({
useProviderContext: jest.fn(),
}))
jest.mock('./filter', () => ({ children }: { children: React.ReactNode }) => (
<div data-testid="filter">{children}</div>
))
jest.mock('./empty-element', () => () => <div data-testid="empty-element" />)
jest.mock('./header-opts', () => (props: any) => (
<div data-testid="header-opts">
<button data-testid="trigger-add" onClick={() => props.onAdd({ question: 'new question', answer: 'new answer' })}>
add
</button>
</div>
))
let latestListProps: any
jest.mock('./list', () => (props: any) => {
latestListProps = props
if (!props.list.length)
return <div data-testid="list-empty" />
return (
<div data-testid="list">
<button data-testid="list-view" onClick={() => props.onView(props.list[0])}>view</button>
<button data-testid="list-remove" onClick={() => props.onRemove(props.list[0].id)}>remove</button>
<button data-testid="list-batch-delete" onClick={() => props.onBatchDelete()}>batch-delete</button>
</div>
)
})
jest.mock('./view-annotation-modal', () => (props: any) => {
if (!props.isShow)
return null
return (
<div data-testid="view-modal">
<div>{props.item.question}</div>
<button data-testid="view-modal-remove" onClick={props.onRemove}>remove</button>
<button data-testid="view-modal-close" onClick={props.onHide}>close</button>
</div>
)
})
jest.mock('@/app/components/base/pagination', () => () => <div data-testid="pagination" />)
jest.mock('@/app/components/base/loading', () => () => <div data-testid="loading" />)
jest.mock('@/app/components/base/features/new-feature-panel/annotation-reply/config-param-modal', () => (props: any) => props.isShow ? <div data-testid="config-modal" /> : null)
jest.mock('@/app/components/billing/annotation-full/modal', () => (props: any) => props.show ? <div data-testid="annotation-full-modal" /> : null)
const mockNotify = Toast.notify as jest.Mock
const addAnnotationMock = addAnnotation as jest.Mock
const delAnnotationMock = delAnnotation as jest.Mock
const delAnnotationsMock = delAnnotations as jest.Mock
const fetchAnnotationConfigMock = fetchAnnotationConfig as jest.Mock
const fetchAnnotationListMock = fetchAnnotationList as jest.Mock
const queryAnnotationJobStatusMock = queryAnnotationJobStatus as jest.Mock
const useProviderContextMock = useProviderContext as jest.Mock
const appDetail = {
id: 'app-id',
mode: AppModeEnum.CHAT,
} as App
const createAnnotation = (overrides: Partial<AnnotationItem> = {}): AnnotationItem => ({
id: overrides.id ?? 'annotation-1',
question: overrides.question ?? 'Question 1',
answer: overrides.answer ?? 'Answer 1',
created_at: overrides.created_at ?? 1700000000,
hit_count: overrides.hit_count ?? 0,
})
const renderComponent = () => render(<Annotation appDetail={appDetail} />)
describe('Annotation', () => {
beforeEach(() => {
jest.clearAllMocks()
latestListProps = undefined
fetchAnnotationConfigMock.mockResolvedValue({
id: 'config-id',
enabled: false,
embedding_model: {
embedding_model_name: 'model',
embedding_provider_name: 'provider',
},
score_threshold: 0.5,
})
fetchAnnotationListMock.mockResolvedValue({ data: [], total: 0 })
queryAnnotationJobStatusMock.mockResolvedValue({ job_status: JobStatus.completed })
useProviderContextMock.mockReturnValue({
plan: {
usage: { annotatedResponse: 0 },
total: { annotatedResponse: 10 },
},
enableBilling: false,
})
})
it('should render empty element when no annotations are returned', async () => {
renderComponent()
expect(await screen.findByTestId('empty-element')).toBeInTheDocument()
expect(fetchAnnotationListMock).toHaveBeenCalledWith(appDetail.id, expect.objectContaining({
page: 1,
keyword: '',
}))
})
it('should handle annotation creation and refresh list data', async () => {
const annotation = createAnnotation()
fetchAnnotationListMock.mockResolvedValue({ data: [annotation], total: 1 })
addAnnotationMock.mockResolvedValue(undefined)
renderComponent()
await screen.findByTestId('list')
fireEvent.click(screen.getByTestId('trigger-add'))
await waitFor(() => {
expect(addAnnotationMock).toHaveBeenCalledWith(appDetail.id, { question: 'new question', answer: 'new answer' })
expect(mockNotify).toHaveBeenCalledWith(expect.objectContaining({
message: 'common.api.actionSuccess',
type: 'success',
}))
})
expect(fetchAnnotationListMock).toHaveBeenCalledTimes(2)
})
it('should support viewing items and running batch deletion success flow', async () => {
const annotation = createAnnotation()
fetchAnnotationListMock.mockResolvedValue({ data: [annotation], total: 1 })
delAnnotationsMock.mockResolvedValue(undefined)
delAnnotationMock.mockResolvedValue(undefined)
renderComponent()
await screen.findByTestId('list')
await act(async () => {
latestListProps.onSelectedIdsChange([annotation.id])
})
await waitFor(() => {
expect(latestListProps.selectedIds).toEqual([annotation.id])
})
await act(async () => {
await latestListProps.onBatchDelete()
})
await waitFor(() => {
expect(delAnnotationsMock).toHaveBeenCalledWith(appDetail.id, [annotation.id])
expect(mockNotify).toHaveBeenCalledWith(expect.objectContaining({
type: 'success',
}))
expect(latestListProps.selectedIds).toEqual([])
})
fireEvent.click(screen.getByTestId('list-view'))
expect(screen.getByTestId('view-modal')).toBeInTheDocument()
await act(async () => {
fireEvent.click(screen.getByTestId('view-modal-remove'))
})
await waitFor(() => {
expect(delAnnotationMock).toHaveBeenCalledWith(appDetail.id, annotation.id)
})
})
it('should show an error notification when batch deletion fails', async () => {
const annotation = createAnnotation()
fetchAnnotationListMock.mockResolvedValue({ data: [annotation], total: 1 })
const error = new Error('failed')
delAnnotationsMock.mockRejectedValue(error)
renderComponent()
await screen.findByTestId('list')
await act(async () => {
latestListProps.onSelectedIdsChange([annotation.id])
})
await waitFor(() => {
expect(latestListProps.selectedIds).toEqual([annotation.id])
})
await act(async () => {
await latestListProps.onBatchDelete()
})
await waitFor(() => {
expect(mockNotify).toHaveBeenCalledWith({
type: 'error',
message: error.message,
})
expect(latestListProps.selectedIds).toEqual([annotation.id])
})
})
})

View File

@ -0,0 +1,116 @@
import React from 'react'
import { fireEvent, render, screen, within } from '@testing-library/react'
import List from './list'
import type { AnnotationItem } from './type'
const mockFormatTime = jest.fn(() => 'formatted-time')
jest.mock('@/hooks/use-timestamp', () => ({
__esModule: true,
default: () => ({
formatTime: mockFormatTime,
}),
}))
const createAnnotation = (overrides: Partial<AnnotationItem> = {}): AnnotationItem => ({
id: overrides.id ?? 'annotation-id',
question: overrides.question ?? 'question 1',
answer: overrides.answer ?? 'answer 1',
created_at: overrides.created_at ?? 1700000000,
hit_count: overrides.hit_count ?? 2,
})
const getCheckboxes = (container: HTMLElement) => container.querySelectorAll('[data-testid^="checkbox"]')
describe('List', () => {
beforeEach(() => {
jest.clearAllMocks()
})
it('should render annotation rows and call onView when clicking a row', () => {
const item = createAnnotation()
const onView = jest.fn()
render(
<List
list={[item]}
onView={onView}
onRemove={jest.fn()}
selectedIds={[]}
onSelectedIdsChange={jest.fn()}
onBatchDelete={jest.fn()}
onCancel={jest.fn()}
/>,
)
fireEvent.click(screen.getByText(item.question))
expect(onView).toHaveBeenCalledWith(item)
expect(mockFormatTime).toHaveBeenCalledWith(item.created_at, 'appLog.dateTimeFormat')
})
it('should toggle single and bulk selection states', () => {
const list = [createAnnotation({ id: 'a', question: 'A' }), createAnnotation({ id: 'b', question: 'B' })]
const onSelectedIdsChange = jest.fn()
const { container, rerender } = render(
<List
list={list}
onView={jest.fn()}
onRemove={jest.fn()}
selectedIds={[]}
onSelectedIdsChange={onSelectedIdsChange}
onBatchDelete={jest.fn()}
onCancel={jest.fn()}
/>,
)
const checkboxes = getCheckboxes(container)
fireEvent.click(checkboxes[1])
expect(onSelectedIdsChange).toHaveBeenCalledWith(['a'])
rerender(
<List
list={list}
onView={jest.fn()}
onRemove={jest.fn()}
selectedIds={['a']}
onSelectedIdsChange={onSelectedIdsChange}
onBatchDelete={jest.fn()}
onCancel={jest.fn()}
/>,
)
const updatedCheckboxes = getCheckboxes(container)
fireEvent.click(updatedCheckboxes[1])
expect(onSelectedIdsChange).toHaveBeenCalledWith([])
fireEvent.click(updatedCheckboxes[0])
expect(onSelectedIdsChange).toHaveBeenCalledWith(['a', 'b'])
})
it('should confirm before removing an annotation and expose batch actions', async () => {
const item = createAnnotation({ id: 'to-delete', question: 'Delete me' })
const onRemove = jest.fn()
render(
<List
list={[item]}
onView={jest.fn()}
onRemove={onRemove}
selectedIds={[item.id]}
onSelectedIdsChange={jest.fn()}
onBatchDelete={jest.fn()}
onCancel={jest.fn()}
/>,
)
const row = screen.getByText(item.question).closest('tr') as HTMLTableRowElement
const actionButtons = within(row).getAllByRole('button')
fireEvent.click(actionButtons[1])
expect(await screen.findByText('appDebug.feature.annotation.removeConfirm')).toBeInTheDocument()
const confirmButton = await screen.findByRole('button', { name: 'common.operation.confirm' })
fireEvent.click(confirmButton)
expect(onRemove).toHaveBeenCalledWith(item.id)
expect(screen.getByText('appAnnotation.batchAction.selected')).toBeInTheDocument()
})
})

View File

@ -0,0 +1,158 @@
import React from 'react'
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import ViewAnnotationModal from './index'
import type { AnnotationItem, HitHistoryItem } from '../type'
import { fetchHitHistoryList } from '@/service/annotation'
const mockFormatTime = jest.fn(() => 'formatted-time')
jest.mock('@/hooks/use-timestamp', () => ({
__esModule: true,
default: () => ({
formatTime: mockFormatTime,
}),
}))
jest.mock('@/service/annotation', () => ({
fetchHitHistoryList: jest.fn(),
}))
jest.mock('../edit-annotation-modal/edit-item', () => {
const EditItemType = {
Query: 'query',
Answer: 'answer',
}
return {
__esModule: true,
default: ({ type, content, onSave }: { type: string; content: string; onSave: (value: string) => void }) => (
<div>
<div data-testid={`content-${type}`}>{content}</div>
<button data-testid={`edit-${type}`} onClick={() => onSave(`${type}-updated`)}>edit-{type}</button>
</div>
),
EditItemType,
}
})
const fetchHitHistoryListMock = fetchHitHistoryList as jest.Mock
const createAnnotationItem = (overrides: Partial<AnnotationItem> = {}): AnnotationItem => ({
id: overrides.id ?? 'annotation-id',
question: overrides.question ?? 'question',
answer: overrides.answer ?? 'answer',
created_at: overrides.created_at ?? 1700000000,
hit_count: overrides.hit_count ?? 0,
})
const createHitHistoryItem = (overrides: Partial<HitHistoryItem> = {}): HitHistoryItem => ({
id: overrides.id ?? 'hit-id',
question: overrides.question ?? 'query',
match: overrides.match ?? 'match',
response: overrides.response ?? 'response',
source: overrides.source ?? 'source',
score: overrides.score ?? 0.42,
created_at: overrides.created_at ?? 1700000000,
})
const renderComponent = (props?: Partial<React.ComponentProps<typeof ViewAnnotationModal>>) => {
const item = createAnnotationItem()
const mergedProps: React.ComponentProps<typeof ViewAnnotationModal> = {
appId: 'app-id',
isShow: true,
onHide: jest.fn(),
item,
onSave: jest.fn().mockResolvedValue(undefined),
onRemove: jest.fn().mockResolvedValue(undefined),
...props,
}
return {
...render(<ViewAnnotationModal {...mergedProps} />),
props: mergedProps,
}
}
describe('ViewAnnotationModal', () => {
beforeEach(() => {
jest.clearAllMocks()
fetchHitHistoryListMock.mockResolvedValue({ data: [], total: 0 })
})
it('should render annotation tab and allow saving updated query', async () => {
// Arrange
const { props } = renderComponent()
await waitFor(() => {
expect(fetchHitHistoryListMock).toHaveBeenCalled()
})
// Act
fireEvent.click(screen.getByTestId('edit-query'))
// Assert
await waitFor(() => {
expect(props.onSave).toHaveBeenCalledWith('query-updated', props.item.answer)
})
})
it('should render annotation tab and allow saving updated answer', async () => {
// Arrange
const { props } = renderComponent()
await waitFor(() => {
expect(fetchHitHistoryListMock).toHaveBeenCalled()
})
// Act
fireEvent.click(screen.getByTestId('edit-answer'))
// Assert
await waitFor(() => {
expect(props.onSave).toHaveBeenCalledWith(props.item.question, 'answer-updated')
},
)
})
it('should switch to hit history tab and show no data message', async () => {
// Arrange
const { props } = renderComponent()
await waitFor(() => {
expect(fetchHitHistoryListMock).toHaveBeenCalled()
})
// Act
fireEvent.click(screen.getByText('appAnnotation.viewModal.hitHistory'))
// Assert
expect(await screen.findByText('appAnnotation.viewModal.noHitHistory')).toBeInTheDocument()
expect(mockFormatTime).toHaveBeenCalledWith(props.item.created_at, 'appLog.dateTimeFormat')
})
it('should render hit history entries with pagination badge when data exists', async () => {
const hits = [createHitHistoryItem({ question: 'user input' }), createHitHistoryItem({ id: 'hit-2', question: 'second' })]
fetchHitHistoryListMock.mockResolvedValue({ data: hits, total: 15 })
renderComponent()
fireEvent.click(await screen.findByText('appAnnotation.viewModal.hitHistory'))
expect(await screen.findByText('user input')).toBeInTheDocument()
expect(screen.getByText('15 appAnnotation.viewModal.hits')).toBeInTheDocument()
expect(mockFormatTime).toHaveBeenCalledWith(hits[0].created_at, 'appLog.dateTimeFormat')
})
it('should confirm before removing the annotation and hide on success', async () => {
const { props } = renderComponent()
fireEvent.click(screen.getByText('appAnnotation.editModal.removeThisCache'))
expect(await screen.findByText('appDebug.feature.annotation.removeConfirm')).toBeInTheDocument()
const confirmButton = await screen.findByRole('button', { name: 'common.operation.confirm' })
fireEvent.click(confirmButton)
await waitFor(() => {
expect(props.onRemove).toHaveBeenCalledTimes(1)
expect(props.onHide).toHaveBeenCalledTimes(1)
})
})
})

View File

@ -181,7 +181,7 @@ describe('AccessControlItem', () => {
expect(useAccessControlStore.getState().currentMenu).toBe(AccessMode.ORGANIZATION)
})
it('should render selected styles when the current menu matches the type', () => {
it('should keep current menu when clicking the selected access type', () => {
useAccessControlStore.setState({ currentMenu: AccessMode.ORGANIZATION })
render(
<AccessControlItem type={AccessMode.ORGANIZATION}>
@ -190,8 +190,9 @@ describe('AccessControlItem', () => {
)
const option = screen.getByText('Organization Only').parentElement as HTMLElement
expect(option.className).toContain('border-[1.5px]')
expect(option.className).not.toContain('cursor-pointer')
fireEvent.click(option)
expect(useAccessControlStore.getState().currentMenu).toBe(AccessMode.ORGANIZATION)
})
})

View File

@ -39,13 +39,6 @@ jest.mock('@/app/components/header/account-setting/model-provider-page/model-par
default: () => <div data-testid="model-parameter-modal" />,
}))
jest.mock('@/app/components/base/toast', () => ({
__esModule: true,
default: {
notify: jest.fn(),
},
}))
jest.mock('@/app/components/header/account-setting/model-provider-page/hooks', () => ({
useModelListAndDefaultModelAndCurrentProviderAndModel: jest.fn(),
useCurrentProviderAndModel: jest.fn(),
@ -54,7 +47,7 @@ jest.mock('@/app/components/header/account-setting/model-provider-page/hooks', (
const mockedUseModelListAndDefaultModelAndCurrentProviderAndModel = useModelListAndDefaultModelAndCurrentProviderAndModel as jest.MockedFunction<typeof useModelListAndDefaultModelAndCurrentProviderAndModel>
const mockedUseCurrentProviderAndModel = useCurrentProviderAndModel as jest.MockedFunction<typeof useCurrentProviderAndModel>
const mockToastNotify = Toast.notify as unknown as jest.Mock
let toastNotifySpy: jest.SpyInstance
const baseRetrievalConfig: RetrievalConfig = {
search_method: RETRIEVE_METHOD.semantic,
@ -180,6 +173,7 @@ const createDatasetConfigs = (overrides: Partial<DatasetConfigs> = {}): DatasetC
describe('ConfigContent', () => {
beforeEach(() => {
jest.clearAllMocks()
toastNotifySpy = jest.spyOn(Toast, 'notify').mockImplementation(() => ({}))
mockedUseModelListAndDefaultModelAndCurrentProviderAndModel.mockReturnValue({
modelList: [],
defaultModel: undefined,
@ -192,6 +186,10 @@ describe('ConfigContent', () => {
})
})
afterEach(() => {
toastNotifySpy.mockRestore()
})
// State management
describe('Effects', () => {
it('should normalize oneWay retrieval mode to multiWay', async () => {
@ -336,7 +334,7 @@ describe('ConfigContent', () => {
await user.click(screen.getByText('common.modelProvider.rerankModel.key'))
// Assert
expect(mockToastNotify).toHaveBeenCalledWith({
expect(toastNotifySpy).toHaveBeenCalledWith({
type: 'error',
message: 'workflow.errorMsg.rerankModelRequired',
})
@ -378,7 +376,7 @@ describe('ConfigContent', () => {
await user.click(screen.getByRole('switch'))
// Assert
expect(mockToastNotify).toHaveBeenCalledWith({
expect(toastNotifySpy).toHaveBeenCalledWith({
type: 'error',
message: 'workflow.errorMsg.rerankModelRequired',
})

View File

@ -1,6 +1,5 @@
import * as React from 'react'
import { render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { fireEvent, render, screen, waitFor, within } from '@testing-library/react'
import ParamsConfig from './index'
import ConfigContext from '@/context/debug-configuration'
import type { DatasetConfigs } from '@/models/debug'
@ -12,30 +11,6 @@ import {
useModelListAndDefaultModelAndCurrentProviderAndModel,
} from '@/app/components/header/account-setting/model-provider-page/hooks'
jest.mock('@/app/components/base/modal', () => {
type Props = {
isShow: boolean
children?: React.ReactNode
}
const MockModal = ({ isShow, children }: Props) => {
if (!isShow) return null
return <div role="dialog">{children}</div>
}
return {
__esModule: true,
default: MockModal,
}
})
jest.mock('@/app/components/base/toast', () => ({
__esModule: true,
default: {
notify: jest.fn(),
},
}))
jest.mock('@/app/components/header/account-setting/model-provider-page/hooks', () => ({
useModelListAndDefaultModelAndCurrentProviderAndModel: jest.fn(),
useCurrentProviderAndModel: jest.fn(),
@ -69,7 +44,7 @@ jest.mock('@/app/components/header/account-setting/model-provider-page/model-par
const mockedUseModelListAndDefaultModelAndCurrentProviderAndModel = useModelListAndDefaultModelAndCurrentProviderAndModel as jest.MockedFunction<typeof useModelListAndDefaultModelAndCurrentProviderAndModel>
const mockedUseCurrentProviderAndModel = useCurrentProviderAndModel as jest.MockedFunction<typeof useCurrentProviderAndModel>
const mockToastNotify = Toast.notify as unknown as jest.Mock
let toastNotifySpy: jest.SpyInstance
const createDatasetConfigs = (overrides: Partial<DatasetConfigs> = {}): DatasetConfigs => {
return {
@ -143,6 +118,8 @@ const renderParamsConfig = ({
describe('dataset-config/params-config', () => {
beforeEach(() => {
jest.clearAllMocks()
jest.useRealTimers()
toastNotifySpy = jest.spyOn(Toast, 'notify').mockImplementation(() => ({}))
mockedUseModelListAndDefaultModelAndCurrentProviderAndModel.mockReturnValue({
modelList: [],
defaultModel: undefined,
@ -155,6 +132,10 @@ describe('dataset-config/params-config', () => {
})
})
afterEach(() => {
toastNotifySpy.mockRestore()
})
// Rendering tests (REQUIRED)
describe('Rendering', () => {
it('should disable settings trigger when disabled is true', () => {
@ -170,18 +151,19 @@ describe('dataset-config/params-config', () => {
describe('User Interactions', () => {
it('should open modal and persist changes when save is clicked', async () => {
// Arrange
const user = userEvent.setup()
const { setDatasetConfigsSpy } = renderParamsConfig()
// Act
await user.click(screen.getByRole('button', { name: 'dataset.retrievalSettings' }))
await screen.findByRole('dialog')
fireEvent.click(screen.getByRole('button', { name: 'dataset.retrievalSettings' }))
const dialog = await screen.findByRole('dialog', {}, { timeout: 3000 })
const dialogScope = within(dialog)
// Change top_k via the first number input increment control.
const incrementButtons = screen.getAllByRole('button', { name: 'increment' })
await user.click(incrementButtons[0])
const incrementButtons = dialogScope.getAllByRole('button', { name: 'increment' })
fireEvent.click(incrementButtons[0])
await user.click(screen.getByRole('button', { name: 'common.operation.save' }))
const saveButton = await dialogScope.findByRole('button', { name: 'common.operation.save' })
fireEvent.click(saveButton)
// Assert
expect(setDatasetConfigsSpy).toHaveBeenCalledWith(expect.objectContaining({ top_k: 5 }))
@ -192,25 +174,28 @@ describe('dataset-config/params-config', () => {
it('should discard changes when cancel is clicked', async () => {
// Arrange
const user = userEvent.setup()
const { setDatasetConfigsSpy } = renderParamsConfig()
// Act
await user.click(screen.getByRole('button', { name: 'dataset.retrievalSettings' }))
await screen.findByRole('dialog')
fireEvent.click(screen.getByRole('button', { name: 'dataset.retrievalSettings' }))
const dialog = await screen.findByRole('dialog', {}, { timeout: 3000 })
const dialogScope = within(dialog)
const incrementButtons = screen.getAllByRole('button', { name: 'increment' })
await user.click(incrementButtons[0])
const incrementButtons = dialogScope.getAllByRole('button', { name: 'increment' })
fireEvent.click(incrementButtons[0])
await user.click(screen.getByRole('button', { name: 'common.operation.cancel' }))
const cancelButton = await dialogScope.findByRole('button', { name: 'common.operation.cancel' })
fireEvent.click(cancelButton)
await waitFor(() => {
expect(screen.queryByRole('dialog')).not.toBeInTheDocument()
})
// Re-open and save without changes.
await user.click(screen.getByRole('button', { name: 'dataset.retrievalSettings' }))
await screen.findByRole('dialog')
await user.click(screen.getByRole('button', { name: 'common.operation.save' }))
fireEvent.click(screen.getByRole('button', { name: 'dataset.retrievalSettings' }))
const reopenedDialog = await screen.findByRole('dialog', {}, { timeout: 3000 })
const reopenedScope = within(reopenedDialog)
const reopenedSave = await reopenedScope.findByRole('button', { name: 'common.operation.save' })
fireEvent.click(reopenedSave)
// Assert - should save original top_k rather than the canceled change.
expect(setDatasetConfigsSpy).toHaveBeenCalledWith(expect.objectContaining({ top_k: 4 }))
@ -218,7 +203,6 @@ describe('dataset-config/params-config', () => {
it('should prevent saving when rerank model is required but invalid', async () => {
// Arrange
const user = userEvent.setup()
const { setDatasetConfigsSpy } = renderParamsConfig({
datasetConfigs: createDatasetConfigs({
reranking_enable: true,
@ -228,10 +212,12 @@ describe('dataset-config/params-config', () => {
})
// Act
await user.click(screen.getByRole('button', { name: 'common.operation.save' }))
const dialog = await screen.findByRole('dialog', {}, { timeout: 3000 })
const dialogScope = within(dialog)
fireEvent.click(dialogScope.getByRole('button', { name: 'common.operation.save' }))
// Assert
expect(mockToastNotify).toHaveBeenCalledWith({
expect(toastNotifySpy).toHaveBeenCalledWith({
type: 'error',
message: 'appDebug.datasetConfig.rerankModelRequired',
})

View File

@ -0,0 +1,473 @@
import { render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import SettingsModal from './index'
import { ToastContext } from '@/app/components/base/toast'
import type { DataSet } from '@/models/datasets'
import { ChunkingMode, DataSourceType, DatasetPermission, RerankingModeEnum } from '@/models/datasets'
import { IndexingType } from '@/app/components/datasets/create/step-two'
import { ModelTypeEnum } from '@/app/components/header/account-setting/model-provider-page/declarations'
import { updateDatasetSetting } from '@/service/datasets'
import { fetchMembers } from '@/service/common'
import { RETRIEVE_METHOD, type RetrievalConfig } from '@/types/app'
const mockNotify = jest.fn()
const mockOnCancel = jest.fn()
const mockOnSave = jest.fn()
const mockSetShowAccountSettingModal = jest.fn()
let mockIsWorkspaceDatasetOperator = false
const mockUseModelList = jest.fn()
const mockUseModelListAndDefaultModel = jest.fn()
const mockUseModelListAndDefaultModelAndCurrentProviderAndModel = jest.fn()
const mockUseCurrentProviderAndModel = jest.fn()
const mockCheckShowMultiModalTip = jest.fn()
jest.mock('ky', () => {
const ky = () => ky
ky.extend = () => ky
ky.create = () => ky
return { __esModule: true, default: ky }
})
jest.mock('@/app/components/datasets/create/step-two', () => ({
__esModule: true,
IndexingType: {
QUALIFIED: 'high_quality',
ECONOMICAL: 'economy',
},
}))
jest.mock('@/service/datasets', () => ({
updateDatasetSetting: jest.fn(),
}))
jest.mock('@/service/common', () => ({
fetchMembers: jest.fn(),
}))
jest.mock('@/context/app-context', () => ({
useAppContext: () => ({ isCurrentWorkspaceDatasetOperator: mockIsWorkspaceDatasetOperator }),
useSelector: <T,>(selector: (value: { userProfile: { id: string; name: string; email: string; avatar_url: string } }) => T) => selector({
userProfile: {
id: 'user-1',
name: 'User One',
email: 'user@example.com',
avatar_url: 'avatar.png',
},
}),
}))
jest.mock('@/context/modal-context', () => ({
useModalContext: () => ({
setShowAccountSettingModal: mockSetShowAccountSettingModal,
}),
}))
jest.mock('@/context/i18n', () => ({
useDocLink: () => (path: string) => `https://docs${path}`,
}))
jest.mock('@/context/provider-context', () => ({
useProviderContext: () => ({
modelProviders: [],
textGenerationModelList: [],
supportRetrievalMethods: [
RETRIEVE_METHOD.semantic,
RETRIEVE_METHOD.fullText,
RETRIEVE_METHOD.hybrid,
RETRIEVE_METHOD.keywordSearch,
],
}),
}))
jest.mock('@/app/components/header/account-setting/model-provider-page/hooks', () => ({
__esModule: true,
useModelList: (...args: unknown[]) => mockUseModelList(...args),
useModelListAndDefaultModel: (...args: unknown[]) => mockUseModelListAndDefaultModel(...args),
useModelListAndDefaultModelAndCurrentProviderAndModel: (...args: unknown[]) =>
mockUseModelListAndDefaultModelAndCurrentProviderAndModel(...args),
useCurrentProviderAndModel: (...args: unknown[]) => mockUseCurrentProviderAndModel(...args),
}))
jest.mock('@/app/components/header/account-setting/model-provider-page/model-selector', () => ({
__esModule: true,
default: ({ defaultModel }: { defaultModel?: { provider: string; model: string } }) => (
<div data-testid='model-selector'>
{defaultModel ? `${defaultModel.provider}/${defaultModel.model}` : 'no-model'}
</div>
),
}))
jest.mock('@/app/components/datasets/settings/utils', () => ({
checkShowMultiModalTip: (...args: unknown[]) => mockCheckShowMultiModalTip(...args),
}))
const mockUpdateDatasetSetting = updateDatasetSetting as jest.MockedFunction<typeof updateDatasetSetting>
const mockFetchMembers = fetchMembers as jest.MockedFunction<typeof fetchMembers>
const createRetrievalConfig = (overrides: Partial<RetrievalConfig> = {}): RetrievalConfig => ({
search_method: RETRIEVE_METHOD.semantic,
reranking_enable: false,
reranking_model: {
reranking_provider_name: '',
reranking_model_name: '',
},
top_k: 2,
score_threshold_enabled: false,
score_threshold: 0.5,
reranking_mode: RerankingModeEnum.RerankingModel,
...overrides,
})
const createDataset = (overrides: Partial<DataSet> = {}, retrievalOverrides: Partial<RetrievalConfig> = {}): DataSet => {
const retrievalConfig = createRetrievalConfig(retrievalOverrides)
return {
id: 'dataset-id',
name: 'Test Dataset',
indexing_status: 'completed',
icon_info: {
icon: 'icon',
icon_type: 'emoji',
},
description: 'Description',
permission: DatasetPermission.allTeamMembers,
data_source_type: DataSourceType.FILE,
indexing_technique: IndexingType.QUALIFIED,
author_name: 'Author',
created_by: 'creator',
updated_by: 'updater',
updated_at: 1700000000,
app_count: 0,
doc_form: ChunkingMode.text,
document_count: 0,
total_document_count: 0,
total_available_documents: 0,
word_count: 0,
provider: 'internal',
embedding_model: 'embed-model',
embedding_model_provider: 'embed-provider',
embedding_available: true,
tags: [],
partial_member_list: [],
external_knowledge_info: {
external_knowledge_id: 'ext-id',
external_knowledge_api_id: 'ext-api-id',
external_knowledge_api_name: 'External API',
external_knowledge_api_endpoint: 'https://api.example.com',
},
external_retrieval_model: {
top_k: 2,
score_threshold: 0.5,
score_threshold_enabled: false,
},
built_in_field_enabled: false,
doc_metadata: [],
keyword_number: 10,
pipeline_id: 'pipeline-id',
is_published: false,
runtime_mode: 'general',
enable_api: true,
is_multimodal: false,
...overrides,
retrieval_model_dict: {
...retrievalConfig,
...overrides.retrieval_model_dict,
},
retrieval_model: {
...retrievalConfig,
...overrides.retrieval_model,
},
}
}
const renderWithProviders = (dataset: DataSet) => {
return render(
<ToastContext.Provider value={{ notify: mockNotify, close: jest.fn() }}>
<SettingsModal
currentDataset={dataset}
onCancel={mockOnCancel}
onSave={mockOnSave}
/>
</ToastContext.Provider>,
)
}
describe('SettingsModal', () => {
beforeEach(() => {
jest.clearAllMocks()
mockIsWorkspaceDatasetOperator = false
mockUseModelList.mockImplementation((type: ModelTypeEnum) => {
if (type === ModelTypeEnum.rerank) {
return {
data: [
{
provider: 'rerank-provider',
models: [{ model: 'rerank-model' }],
},
],
}
}
return { data: [{ provider: 'embed-provider', models: [{ model: 'embed-model' }] }] }
})
mockUseModelListAndDefaultModel.mockReturnValue({ modelList: [], defaultModel: null })
mockUseModelListAndDefaultModelAndCurrentProviderAndModel.mockReturnValue({ defaultModel: null, currentModel: null })
mockUseCurrentProviderAndModel.mockReturnValue({ currentProvider: null, currentModel: null })
mockCheckShowMultiModalTip.mockReturnValue(false)
mockFetchMembers.mockResolvedValue({
accounts: [
{
id: 'user-1',
name: 'User One',
email: 'user@example.com',
avatar: 'avatar.png',
avatar_url: 'avatar.png',
status: 'active',
role: 'owner',
},
{
id: 'member-2',
name: 'Member Two',
email: 'member@example.com',
avatar: 'avatar.png',
avatar_url: 'avatar.png',
status: 'active',
role: 'editor',
},
],
})
mockUpdateDatasetSetting.mockResolvedValue(createDataset())
})
it('renders dataset details', async () => {
renderWithProviders(createDataset())
await waitFor(() => expect(mockFetchMembers).toHaveBeenCalled())
expect(screen.getByPlaceholderText('datasetSettings.form.namePlaceholder')).toHaveValue('Test Dataset')
expect(screen.getByPlaceholderText('datasetSettings.form.descPlaceholder')).toHaveValue('Description')
})
it('calls onCancel when cancel is clicked', async () => {
renderWithProviders(createDataset())
await waitFor(() => expect(mockFetchMembers).toHaveBeenCalled())
await userEvent.click(screen.getByRole('button', { name: 'common.operation.cancel' }))
expect(mockOnCancel).toHaveBeenCalledTimes(1)
})
it('shows external knowledge info for external datasets', async () => {
const dataset = createDataset({
provider: 'external',
external_knowledge_info: {
external_knowledge_id: 'ext-id-123',
external_knowledge_api_id: 'ext-api-id-123',
external_knowledge_api_name: 'External Knowledge API',
external_knowledge_api_endpoint: 'https://api.external.com',
},
})
renderWithProviders(dataset)
await waitFor(() => expect(mockFetchMembers).toHaveBeenCalled())
expect(screen.getByText('External Knowledge API')).toBeInTheDocument()
expect(screen.getByText('https://api.external.com')).toBeInTheDocument()
expect(screen.getByText('ext-id-123')).toBeInTheDocument()
})
it('updates name when user types', async () => {
renderWithProviders(createDataset())
await waitFor(() => expect(mockFetchMembers).toHaveBeenCalled())
const nameInput = screen.getByPlaceholderText('datasetSettings.form.namePlaceholder')
await userEvent.clear(nameInput)
await userEvent.type(nameInput, 'New Dataset Name')
expect(nameInput).toHaveValue('New Dataset Name')
})
it('updates description when user types', async () => {
renderWithProviders(createDataset())
await waitFor(() => expect(mockFetchMembers).toHaveBeenCalled())
const descriptionInput = screen.getByPlaceholderText('datasetSettings.form.descPlaceholder')
await userEvent.clear(descriptionInput)
await userEvent.type(descriptionInput, 'New description')
expect(descriptionInput).toHaveValue('New description')
})
it('shows and dismisses retrieval change tip when index method changes', async () => {
const dataset = createDataset({ indexing_technique: IndexingType.ECONOMICAL })
renderWithProviders(dataset)
await waitFor(() => expect(mockFetchMembers).toHaveBeenCalled())
await userEvent.click(screen.getByText('datasetCreation.stepTwo.qualified'))
expect(await screen.findByText('appDebug.datasetConfig.retrieveChangeTip')).toBeInTheDocument()
await userEvent.click(screen.getByLabelText('close-retrieval-change-tip'))
await waitFor(() => {
expect(screen.queryByText('appDebug.datasetConfig.retrieveChangeTip')).not.toBeInTheDocument()
})
})
it('requires dataset name before saving', async () => {
renderWithProviders(createDataset())
await waitFor(() => expect(mockFetchMembers).toHaveBeenCalled())
const nameInput = screen.getByPlaceholderText('datasetSettings.form.namePlaceholder')
await userEvent.clear(nameInput)
await userEvent.click(screen.getByRole('button', { name: 'common.operation.save' }))
expect(mockNotify).toHaveBeenCalledWith(expect.objectContaining({
type: 'error',
message: 'datasetSettings.form.nameError',
}))
expect(mockUpdateDatasetSetting).not.toHaveBeenCalled()
})
it('requires rerank model when reranking is enabled', async () => {
mockUseModelList.mockReturnValue({ data: [] })
const dataset = createDataset({}, createRetrievalConfig({
reranking_enable: true,
reranking_model: {
reranking_provider_name: '',
reranking_model_name: '',
},
}))
renderWithProviders(dataset)
await waitFor(() => expect(mockFetchMembers).toHaveBeenCalled())
await userEvent.click(screen.getByRole('button', { name: 'common.operation.save' }))
expect(mockNotify).toHaveBeenCalledWith(expect.objectContaining({
type: 'error',
message: 'appDebug.datasetConfig.rerankModelRequired',
}))
expect(mockUpdateDatasetSetting).not.toHaveBeenCalled()
})
it('saves internal dataset changes', async () => {
const rerankRetrieval = createRetrievalConfig({
reranking_enable: true,
reranking_model: {
reranking_provider_name: 'rerank-provider',
reranking_model_name: 'rerank-model',
},
})
const dataset = createDataset({
retrieval_model: rerankRetrieval,
retrieval_model_dict: rerankRetrieval,
})
renderWithProviders(dataset)
await waitFor(() => expect(mockFetchMembers).toHaveBeenCalled())
const nameInput = screen.getByPlaceholderText('datasetSettings.form.namePlaceholder')
await userEvent.clear(nameInput)
await userEvent.type(nameInput, 'Updated Internal Dataset')
await userEvent.click(screen.getByRole('button', { name: 'common.operation.save' }))
await waitFor(() => expect(mockUpdateDatasetSetting).toHaveBeenCalled())
expect(mockUpdateDatasetSetting).toHaveBeenCalledWith(expect.objectContaining({
body: expect.objectContaining({
name: 'Updated Internal Dataset',
permission: DatasetPermission.allTeamMembers,
}),
}))
expect(mockNotify).toHaveBeenCalledWith(expect.objectContaining({
type: 'success',
message: 'common.actionMsg.modifiedSuccessfully',
}))
expect(mockOnSave).toHaveBeenCalledWith(expect.objectContaining({
name: 'Updated Internal Dataset',
retrieval_model_dict: expect.objectContaining({
reranking_enable: true,
}),
}))
})
it('saves external dataset with partial members and updated retrieval params', async () => {
const dataset = createDataset({
provider: 'external',
permission: DatasetPermission.partialMembers,
partial_member_list: ['member-2'],
external_retrieval_model: {
top_k: 5,
score_threshold: 0.3,
score_threshold_enabled: true,
},
}, {
score_threshold_enabled: true,
score_threshold: 0.8,
})
renderWithProviders(dataset)
await waitFor(() => expect(mockFetchMembers).toHaveBeenCalled())
await userEvent.click(screen.getByRole('button', { name: 'common.operation.save' }))
await waitFor(() => expect(mockUpdateDatasetSetting).toHaveBeenCalled())
expect(mockUpdateDatasetSetting).toHaveBeenCalledWith(expect.objectContaining({
body: expect.objectContaining({
permission: DatasetPermission.partialMembers,
external_retrieval_model: expect.objectContaining({
top_k: 5,
}),
partial_member_list: [
{
user_id: 'member-2',
role: 'editor',
},
],
}),
}))
expect(mockOnSave).toHaveBeenCalledWith(expect.objectContaining({
retrieval_model_dict: expect.objectContaining({
score_threshold_enabled: true,
score_threshold: 0.8,
}),
}))
})
it('disables save button while saving', async () => {
mockUpdateDatasetSetting.mockImplementation(() => new Promise(resolve => setTimeout(resolve, 100)))
renderWithProviders(createDataset())
await waitFor(() => expect(mockFetchMembers).toHaveBeenCalled())
const saveButton = screen.getByRole('button', { name: 'common.operation.save' })
await userEvent.click(saveButton)
expect(saveButton).toBeDisabled()
})
it('shows error toast when save fails', async () => {
mockUpdateDatasetSetting.mockRejectedValue(new Error('API Error'))
renderWithProviders(createDataset())
await waitFor(() => expect(mockFetchMembers).toHaveBeenCalled())
await userEvent.click(screen.getByRole('button', { name: 'common.operation.save' }))
await waitFor(() => {
expect(mockNotify).toHaveBeenCalledWith(expect.objectContaining({ type: 'error' }))
})
})
})

View File

@ -4,10 +4,8 @@ import { useMount } from 'ahooks'
import { useTranslation } from 'react-i18next'
import { isEqual } from 'lodash-es'
import { RiCloseLine } from '@remixicon/react'
import { ApiConnectionMod } from '@/app/components/base/icons/src/vender/solid/development'
import cn from '@/utils/classnames'
import IndexMethod from '@/app/components/datasets/settings/index-method'
import Divider from '@/app/components/base/divider'
import Button from '@/app/components/base/button'
import Input from '@/app/components/base/input'
import Textarea from '@/app/components/base/textarea'
@ -18,11 +16,7 @@ import { useAppContext } from '@/context/app-context'
import { useModalContext } from '@/context/modal-context'
import { ACCOUNT_SETTING_TAB } from '@/app/components/header/account-setting/constants'
import type { RetrievalConfig } from '@/types/app'
import RetrievalSettings from '@/app/components/datasets/external-knowledge-base/create/RetrievalSettings'
import RetrievalMethodConfig from '@/app/components/datasets/common/retrieval-method-config'
import EconomicalRetrievalMethodConfig from '@/app/components/datasets/common/economical-retrieval-method-config'
import { isReRankModelSelected } from '@/app/components/datasets/common/check-rerank-model'
import { AlertTriangle } from '@/app/components/base/icons/src/vender/solid/alertsAndFeedback'
import PermissionSelector from '@/app/components/datasets/settings/permission-selector'
import ModelSelector from '@/app/components/header/account-setting/model-provider-page/model-selector'
import { useModelList } from '@/app/components/header/account-setting/model-provider-page/hooks'
@ -32,6 +26,7 @@ import type { Member } from '@/models/common'
import { IndexingType } from '@/app/components/datasets/create/step-two'
import { useDocLink } from '@/context/i18n'
import { checkShowMultiModalTip } from '@/app/components/datasets/settings/utils'
import { RetrievalChangeTip, RetrievalSection } from './retrieval-section'
type SettingsModalProps = {
currentDataset: DataSet
@ -298,92 +293,37 @@ const SettingsModal: FC<SettingsModalProps> = ({
)}
{/* Retrieval Method Config */}
{currentDataset?.provider === 'external'
? <>
<div className={rowClass}><Divider /></div>
<div className={rowClass}>
<div className={labelClass}>
<div className='system-sm-semibold text-text-secondary'>{t('datasetSettings.form.retrievalSetting.title')}</div>
</div>
<RetrievalSettings
topK={topK}
scoreThreshold={scoreThreshold}
scoreThresholdEnabled={scoreThresholdEnabled}
onChange={handleSettingsChange}
isInRetrievalSetting={true}
/>
</div>
<div className={rowClass}><Divider /></div>
<div className={rowClass}>
<div className={labelClass}>
<div className='system-sm-semibold text-text-secondary'>{t('datasetSettings.form.externalKnowledgeAPI')}</div>
</div>
<div className='w-full max-w-[480px]'>
<div className='flex h-full items-center gap-1 rounded-lg bg-components-input-bg-normal px-3 py-2'>
<ApiConnectionMod className='h-4 w-4 text-text-secondary' />
<div className='system-sm-medium overflow-hidden text-ellipsis text-text-secondary'>
{currentDataset?.external_knowledge_info.external_knowledge_api_name}
</div>
<div className='system-xs-regular text-text-tertiary'>·</div>
<div className='system-xs-regular text-text-tertiary'>{currentDataset?.external_knowledge_info.external_knowledge_api_endpoint}</div>
</div>
</div>
</div>
<div className={rowClass}>
<div className={labelClass}>
<div className='system-sm-semibold text-text-secondary'>{t('datasetSettings.form.externalKnowledgeID')}</div>
</div>
<div className='w-full max-w-[480px]'>
<div className='flex h-full items-center gap-1 rounded-lg bg-components-input-bg-normal px-3 py-2'>
<div className='system-xs-regular text-text-tertiary'>{currentDataset?.external_knowledge_info.external_knowledge_id}</div>
</div>
</div>
</div>
<div className={rowClass}><Divider /></div>
</>
: <div className={rowClass}>
<div className={cn(labelClass, 'w-auto min-w-[168px]')}>
<div>
<div className='system-sm-semibold text-text-secondary'>{t('datasetSettings.form.retrievalSetting.title')}</div>
<div className='text-xs font-normal leading-[18px] text-text-tertiary'>
<a target='_blank' rel='noopener noreferrer' href={docLink('/guides/knowledge-base/create-knowledge-and-upload-documents/setting-indexing-methods#setting-the-retrieval-setting')} className='text-text-accent'>{t('datasetSettings.form.retrievalSetting.learnMore')}</a>
{t('datasetSettings.form.retrievalSetting.description')}
</div>
</div>
</div>
<div>
{indexMethod === IndexingType.QUALIFIED
? (
<RetrievalMethodConfig
value={retrievalConfig}
onChange={setRetrievalConfig}
showMultiModalTip={showMultiModalTip}
/>
)
: (
<EconomicalRetrievalMethodConfig
value={retrievalConfig}
onChange={setRetrievalConfig}
/>
)}
</div>
</div>}
{isExternal ? (
<RetrievalSection
isExternal
rowClass={rowClass}
labelClass={labelClass}
t={t}
topK={topK}
scoreThreshold={scoreThreshold}
scoreThresholdEnabled={scoreThresholdEnabled}
onExternalSettingChange={handleSettingsChange}
currentDataset={currentDataset}
/>
) : (
<RetrievalSection
isExternal={false}
rowClass={rowClass}
labelClass={labelClass}
t={t}
indexMethod={indexMethod}
retrievalConfig={retrievalConfig}
showMultiModalTip={showMultiModalTip}
onRetrievalConfigChange={setRetrievalConfig}
docLink={docLink}
/>
)}
</div>
{isRetrievalChanged && !isHideChangedTip && (
<div className='absolute bottom-[76px] left-[30px] right-[30px] z-10 flex h-10 items-center justify-between rounded-lg border border-[#FEF0C7] bg-[#FFFAEB] px-3 shadow-lg'>
<div className='flex items-center'>
<AlertTriangle className='mr-1 h-3 w-3 text-[#F79009]' />
<div className='text-xs font-medium leading-[18px] text-gray-700'>{t('appDebug.datasetConfig.retrieveChangeTip')}</div>
</div>
<div className='cursor-pointer p-1' onClick={(e) => {
setIsHideChangedTip(true)
e.stopPropagation()
e.nativeEvent.stopImmediatePropagation()
}}>
<RiCloseLine className='h-4 w-4 text-gray-500' />
</div>
</div>
)}
<RetrievalChangeTip
visible={isRetrievalChanged && !isHideChangedTip}
message={t('appDebug.datasetConfig.retrieveChangeTip')}
onDismiss={() => setIsHideChangedTip(true)}
/>
<div
className='sticky bottom-0 z-[5] flex w-full justify-end border-t border-divider-regular bg-background-section px-6 py-4'

View File

@ -0,0 +1,277 @@
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import type { DataSet } from '@/models/datasets'
import { ChunkingMode, DataSourceType, DatasetPermission, RerankingModeEnum } from '@/models/datasets'
import { RETRIEVE_METHOD, type RetrievalConfig } from '@/types/app'
import { IndexingType } from '@/app/components/datasets/create/step-two'
import { ModelTypeEnum } from '@/app/components/header/account-setting/model-provider-page/declarations'
import { RetrievalChangeTip, RetrievalSection } from './retrieval-section'
const mockUseModelList = jest.fn()
const mockUseModelListAndDefaultModel = jest.fn()
const mockUseModelListAndDefaultModelAndCurrentProviderAndModel = jest.fn()
const mockUseCurrentProviderAndModel = jest.fn()
jest.mock('ky', () => {
const ky = () => ky
ky.extend = () => ky
ky.create = () => ky
return { __esModule: true, default: ky }
})
jest.mock('@/context/provider-context', () => ({
useProviderContext: () => ({
modelProviders: [],
textGenerationModelList: [],
supportRetrievalMethods: [
RETRIEVE_METHOD.semantic,
RETRIEVE_METHOD.fullText,
RETRIEVE_METHOD.hybrid,
RETRIEVE_METHOD.keywordSearch,
],
}),
}))
jest.mock('@/app/components/header/account-setting/model-provider-page/hooks', () => ({
__esModule: true,
useModelListAndDefaultModelAndCurrentProviderAndModel: (...args: unknown[]) =>
mockUseModelListAndDefaultModelAndCurrentProviderAndModel(...args),
useModelListAndDefaultModel: (...args: unknown[]) => mockUseModelListAndDefaultModel(...args),
useModelList: (...args: unknown[]) => mockUseModelList(...args),
useCurrentProviderAndModel: (...args: unknown[]) => mockUseCurrentProviderAndModel(...args),
}))
jest.mock('@/app/components/header/account-setting/model-provider-page/model-selector', () => ({
__esModule: true,
default: ({ defaultModel }: { defaultModel?: { provider: string; model: string } }) => (
<div data-testid='model-selector'>
{defaultModel ? `${defaultModel.provider}/${defaultModel.model}` : 'no-model'}
</div>
),
}))
jest.mock('@/app/components/datasets/create/step-two', () => ({
__esModule: true,
IndexingType: {
QUALIFIED: 'high_quality',
ECONOMICAL: 'economy',
},
}))
const createRetrievalConfig = (overrides: Partial<RetrievalConfig> = {}): RetrievalConfig => ({
search_method: RETRIEVE_METHOD.semantic,
reranking_enable: false,
reranking_model: {
reranking_provider_name: '',
reranking_model_name: '',
},
top_k: 2,
score_threshold_enabled: false,
score_threshold: 0.5,
reranking_mode: RerankingModeEnum.RerankingModel,
...overrides,
})
const createDataset = (overrides: Partial<DataSet> = {}, retrievalOverrides: Partial<RetrievalConfig> = {}): DataSet => {
const retrievalConfig = createRetrievalConfig(retrievalOverrides)
return {
id: 'dataset-id',
name: 'Test Dataset',
indexing_status: 'completed',
icon_info: {
icon: 'icon',
icon_type: 'emoji',
},
description: 'Description',
permission: DatasetPermission.allTeamMembers,
data_source_type: DataSourceType.FILE,
indexing_technique: IndexingType.QUALIFIED,
author_name: 'Author',
created_by: 'creator',
updated_by: 'updater',
updated_at: 1700000000,
app_count: 0,
doc_form: ChunkingMode.text,
document_count: 0,
total_document_count: 0,
total_available_documents: 0,
word_count: 0,
provider: 'internal',
embedding_model: 'embed-model',
embedding_model_provider: 'embed-provider',
embedding_available: true,
tags: [],
partial_member_list: [],
external_knowledge_info: {
external_knowledge_id: 'ext-id',
external_knowledge_api_id: 'ext-api-id',
external_knowledge_api_name: 'External API',
external_knowledge_api_endpoint: 'https://api.example.com',
},
external_retrieval_model: {
top_k: 2,
score_threshold: 0.5,
score_threshold_enabled: false,
},
built_in_field_enabled: false,
doc_metadata: [],
keyword_number: 10,
pipeline_id: 'pipeline-id',
is_published: false,
runtime_mode: 'general',
enable_api: true,
is_multimodal: false,
...overrides,
retrieval_model_dict: {
...retrievalConfig,
...overrides.retrieval_model_dict,
},
retrieval_model: {
...retrievalConfig,
...overrides.retrieval_model,
},
}
}
describe('RetrievalChangeTip', () => {
const defaultProps = {
visible: true,
message: 'Test message',
onDismiss: jest.fn(),
}
beforeEach(() => {
jest.clearAllMocks()
})
it('renders and supports dismiss', async () => {
// Arrange
const onDismiss = jest.fn()
render(<RetrievalChangeTip {...defaultProps} onDismiss={onDismiss} />)
// Act
await userEvent.click(screen.getByRole('button', { name: 'close-retrieval-change-tip' }))
// Assert
expect(screen.getByText('Test message')).toBeInTheDocument()
expect(onDismiss).toHaveBeenCalledTimes(1)
})
it('does not render when hidden', () => {
// Arrange & Act
render(<RetrievalChangeTip {...defaultProps} visible={false} />)
// Assert
expect(screen.queryByText('Test message')).not.toBeInTheDocument()
})
})
describe('RetrievalSection', () => {
const t = (key: string) => key
const rowClass = 'row'
const labelClass = 'label'
beforeEach(() => {
jest.clearAllMocks()
mockUseModelList.mockImplementation((type: ModelTypeEnum) => {
if (type === ModelTypeEnum.rerank)
return { data: [{ provider: 'rerank-provider', models: [{ model: 'rerank-model' }] }] }
return { data: [] }
})
mockUseModelListAndDefaultModel.mockReturnValue({ modelList: [], defaultModel: null })
mockUseModelListAndDefaultModelAndCurrentProviderAndModel.mockReturnValue({ defaultModel: null, currentModel: null })
mockUseCurrentProviderAndModel.mockReturnValue({ currentProvider: null, currentModel: null })
})
it('renders external retrieval details and propagates changes', async () => {
// Arrange
const dataset = createDataset({
provider: 'external',
external_knowledge_info: {
external_knowledge_id: 'ext-id-999',
external_knowledge_api_id: 'ext-api-id-999',
external_knowledge_api_name: 'External API',
external_knowledge_api_endpoint: 'https://api.external.com',
},
})
const handleExternalChange = jest.fn()
// Act
render(
<RetrievalSection
isExternal
rowClass={rowClass}
labelClass={labelClass}
t={t}
topK={3}
scoreThreshold={0.4}
scoreThresholdEnabled
onExternalSettingChange={handleExternalChange}
currentDataset={dataset}
/>,
)
const [topKIncrement] = screen.getAllByLabelText('increment')
await userEvent.click(topKIncrement)
// Assert
expect(screen.getByText('External API')).toBeInTheDocument()
expect(screen.getByText('https://api.external.com')).toBeInTheDocument()
expect(screen.getByText('ext-id-999')).toBeInTheDocument()
expect(handleExternalChange).toHaveBeenCalledWith(expect.objectContaining({ top_k: 4 }))
})
it('renders internal retrieval config with doc link', () => {
// Arrange
const docLink = jest.fn((path: string) => `https://docs.example${path}`)
const retrievalConfig = createRetrievalConfig()
// Act
render(
<RetrievalSection
isExternal={false}
rowClass={rowClass}
labelClass={labelClass}
t={t}
indexMethod={IndexingType.QUALIFIED}
retrievalConfig={retrievalConfig}
showMultiModalTip
onRetrievalConfigChange={jest.fn()}
docLink={docLink}
/>,
)
// Assert
expect(screen.getByText('dataset.retrieval.semantic_search.title')).toBeInTheDocument()
const learnMoreLink = screen.getByRole('link', { name: 'datasetSettings.form.retrievalSetting.learnMore' })
expect(learnMoreLink).toHaveAttribute('href', 'https://docs.example/guides/knowledge-base/create-knowledge-and-upload-documents/setting-indexing-methods#setting-the-retrieval-setting')
expect(docLink).toHaveBeenCalledWith('/guides/knowledge-base/create-knowledge-and-upload-documents/setting-indexing-methods#setting-the-retrieval-setting')
})
it('propagates retrieval config changes for economical indexing', async () => {
// Arrange
const handleRetrievalChange = jest.fn()
// Act
render(
<RetrievalSection
isExternal={false}
rowClass={rowClass}
labelClass={labelClass}
t={t}
indexMethod={IndexingType.ECONOMICAL}
retrievalConfig={createRetrievalConfig()}
showMultiModalTip={false}
onRetrievalConfigChange={handleRetrievalChange}
docLink={path => path}
/>,
)
const [topKIncrement] = screen.getAllByLabelText('increment')
await userEvent.click(topKIncrement)
// Assert
expect(screen.getByText('dataset.retrieval.keyword_search.title')).toBeInTheDocument()
expect(handleRetrievalChange).toHaveBeenCalledWith(expect.objectContaining({
top_k: 3,
}))
})
})

View File

@ -0,0 +1,218 @@
import { RiCloseLine } from '@remixicon/react'
import type { FC } from 'react'
import cn from '@/utils/classnames'
import Divider from '@/app/components/base/divider'
import { ApiConnectionMod } from '@/app/components/base/icons/src/vender/solid/development'
import { AlertTriangle } from '@/app/components/base/icons/src/vender/solid/alertsAndFeedback'
import RetrievalSettings from '@/app/components/datasets/external-knowledge-base/create/RetrievalSettings'
import type { DataSet } from '@/models/datasets'
import { IndexingType } from '@/app/components/datasets/create/step-two'
import type { RetrievalConfig } from '@/types/app'
import RetrievalMethodConfig from '@/app/components/datasets/common/retrieval-method-config'
import EconomicalRetrievalMethodConfig from '@/app/components/datasets/common/economical-retrieval-method-config'
type CommonSectionProps = {
rowClass: string
labelClass: string
t: (key: string, options?: any) => string
}
type ExternalRetrievalSectionProps = CommonSectionProps & {
topK: number
scoreThreshold: number
scoreThresholdEnabled: boolean
onExternalSettingChange: (data: { top_k?: number; score_threshold?: number; score_threshold_enabled?: boolean }) => void
currentDataset: DataSet
}
const ExternalRetrievalSection: FC<ExternalRetrievalSectionProps> = ({
rowClass,
labelClass,
t,
topK,
scoreThreshold,
scoreThresholdEnabled,
onExternalSettingChange,
currentDataset,
}) => (
<>
<div className={rowClass}><Divider /></div>
<div className={rowClass}>
<div className={labelClass}>
<div className='system-sm-semibold text-text-secondary'>{t('datasetSettings.form.retrievalSetting.title')}</div>
</div>
<RetrievalSettings
topK={topK}
scoreThreshold={scoreThreshold}
scoreThresholdEnabled={scoreThresholdEnabled}
onChange={onExternalSettingChange}
isInRetrievalSetting={true}
/>
</div>
<div className={rowClass}><Divider /></div>
<div className={rowClass}>
<div className={labelClass}>
<div className='system-sm-semibold text-text-secondary'>{t('datasetSettings.form.externalKnowledgeAPI')}</div>
</div>
<div className='w-full max-w-[480px]'>
<div className='flex h-full items-center gap-1 rounded-lg bg-components-input-bg-normal px-3 py-2'>
<ApiConnectionMod className='h-4 w-4 text-text-secondary' />
<div className='system-sm-medium overflow-hidden text-ellipsis text-text-secondary'>
{currentDataset?.external_knowledge_info.external_knowledge_api_name}
</div>
<div className='system-xs-regular text-text-tertiary'>·</div>
<div className='system-xs-regular text-text-tertiary'>{currentDataset?.external_knowledge_info.external_knowledge_api_endpoint}</div>
</div>
</div>
</div>
<div className={rowClass}>
<div className={labelClass}>
<div className='system-sm-semibold text-text-secondary'>{t('datasetSettings.form.externalKnowledgeID')}</div>
</div>
<div className='w-full max-w-[480px]'>
<div className='flex h-full items-center gap-1 rounded-lg bg-components-input-bg-normal px-3 py-2'>
<div className='system-xs-regular text-text-tertiary'>{currentDataset?.external_knowledge_info.external_knowledge_id}</div>
</div>
</div>
</div>
<div className={rowClass}><Divider /></div>
</>
)
type InternalRetrievalSectionProps = CommonSectionProps & {
indexMethod: IndexingType
retrievalConfig: RetrievalConfig
showMultiModalTip: boolean
onRetrievalConfigChange: (value: RetrievalConfig) => void
docLink: (path: string) => string
}
const InternalRetrievalSection: FC<InternalRetrievalSectionProps> = ({
rowClass,
labelClass,
t,
indexMethod,
retrievalConfig,
showMultiModalTip,
onRetrievalConfigChange,
docLink,
}) => (
<div className={rowClass}>
<div className={cn(labelClass, 'w-auto min-w-[168px]')}>
<div>
<div className='system-sm-semibold text-text-secondary'>{t('datasetSettings.form.retrievalSetting.title')}</div>
<div className='text-xs font-normal leading-[18px] text-text-tertiary'>
<a target='_blank' rel='noopener noreferrer' href={docLink('/guides/knowledge-base/create-knowledge-and-upload-documents/setting-indexing-methods#setting-the-retrieval-setting')} className='text-text-accent'>{t('datasetSettings.form.retrievalSetting.learnMore')}</a>
{t('datasetSettings.form.retrievalSetting.description')}
</div>
</div>
</div>
<div>
{indexMethod === IndexingType.QUALIFIED
? (
<RetrievalMethodConfig
value={retrievalConfig}
onChange={onRetrievalConfigChange}
showMultiModalTip={showMultiModalTip}
/>
)
: (
<EconomicalRetrievalMethodConfig
value={retrievalConfig}
onChange={onRetrievalConfigChange}
/>
)}
</div>
</div>
)
type RetrievalSectionProps
= | (ExternalRetrievalSectionProps & { isExternal: true })
| (InternalRetrievalSectionProps & { isExternal: false })
export const RetrievalSection: FC<RetrievalSectionProps> = (props) => {
if (props.isExternal) {
const {
rowClass,
labelClass,
t,
topK,
scoreThreshold,
scoreThresholdEnabled,
onExternalSettingChange,
currentDataset,
} = props
return (
<ExternalRetrievalSection
rowClass={rowClass}
labelClass={labelClass}
t={t}
topK={topK}
scoreThreshold={scoreThreshold}
scoreThresholdEnabled={scoreThresholdEnabled}
onExternalSettingChange={onExternalSettingChange}
currentDataset={currentDataset}
/>
)
}
const {
rowClass,
labelClass,
t,
indexMethod,
retrievalConfig,
showMultiModalTip,
onRetrievalConfigChange,
docLink,
} = props
return (
<InternalRetrievalSection
rowClass={rowClass}
labelClass={labelClass}
t={t}
indexMethod={indexMethod}
retrievalConfig={retrievalConfig}
showMultiModalTip={showMultiModalTip}
onRetrievalConfigChange={onRetrievalConfigChange}
docLink={docLink}
/>
)
}
type RetrievalChangeTipProps = {
visible: boolean
message: string
onDismiss: () => void
}
export const RetrievalChangeTip: FC<RetrievalChangeTipProps> = ({
visible,
message,
onDismiss,
}) => {
if (!visible)
return null
return (
<div className='absolute bottom-[76px] left-[30px] right-[30px] z-10 flex h-10 items-center justify-between rounded-lg border border-[#FEF0C7] bg-[#FFFAEB] px-3 shadow-lg'>
<div className='flex items-center'>
<AlertTriangle className='mr-1 h-3 w-3 text-[#F79009]' />
<div className='text-xs font-medium leading-[18px] text-gray-700'>{message}</div>
</div>
<button
type='button'
className='cursor-pointer p-1'
onClick={(event) => {
onDismiss()
event.stopPropagation()
}}
aria-label='close-retrieval-change-tip'
>
<RiCloseLine className='h-4 w-4 text-gray-500' />
</button>
</div>
)
}

View File

@ -18,20 +18,28 @@ const More: FC<MoreProps> = ({
more && (
<>
<div
className='mr-2 max-w-[33.3%] shrink-0 truncate'
className='mr-2 max-w-[25%] shrink-0 truncate'
title={`${t('appLog.detail.timeConsuming')} ${more.latency}${t('appLog.detail.second')}`}
>
{`${t('appLog.detail.timeConsuming')} ${more.latency}${t('appLog.detail.second')}`}
</div>
<div
className='max-w-[33.3%] shrink-0 truncate'
className='mr-2 max-w-[25%] shrink-0 truncate'
title={`${t('appLog.detail.tokenCost')} ${formatNumber(more.tokens)}`}
>
{`${t('appLog.detail.tokenCost')} ${formatNumber(more.tokens)}`}
</div>
{more.tokens_per_second && (
<div
className='mr-2 max-w-[25%] shrink-0 truncate'
title={`${more.tokens_per_second} tokens/s`}
>
{`${more.tokens_per_second} tokens/s`}
</div>
)}
<div className='mx-2 shrink-0'>·</div>
<div
className='max-w-[33.3%] shrink-0 truncate'
className='max-w-[25%] shrink-0 truncate'
title={more.time}
>
{more.time}

View File

@ -318,6 +318,7 @@ export const useChat = (
return player
}
ssePost(
url,
{
@ -393,6 +394,7 @@ export const useChat = (
time: formatTime(newResponseItem.created_at, 'hh:mm A'),
tokens: newResponseItem.answer_tokens + newResponseItem.message_tokens,
latency: newResponseItem.provider_response_latency.toFixed(2),
tokens_per_second: newResponseItem.provider_response_latency > 0 ? (newResponseItem.answer_tokens / newResponseItem.provider_response_latency).toFixed(2) : undefined,
},
// for agent log
conversationId: conversationId.current,

View File

@ -8,6 +8,7 @@ export type MessageMore = {
time: string
tokens: number
latency: number | string
tokens_per_second?: number | string
}
export type FeedbackType = {

View File

@ -0,0 +1,777 @@
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import React from 'react'
import EmptyDatasetCreationModal from './index'
import { createEmptyDataset } from '@/service/datasets'
import { useInvalidDatasetList } from '@/service/knowledge/use-dataset'
// Mock Next.js router
const mockPush = jest.fn()
jest.mock('next/navigation', () => ({
useRouter: () => ({
push: mockPush,
}),
}))
// Mock createEmptyDataset API
jest.mock('@/service/datasets', () => ({
createEmptyDataset: jest.fn(),
}))
// Mock useInvalidDatasetList hook
jest.mock('@/service/knowledge/use-dataset', () => ({
useInvalidDatasetList: jest.fn(),
}))
// Mock ToastContext - need to mock both createContext and useContext from use-context-selector
const mockNotify = jest.fn()
jest.mock('use-context-selector', () => ({
createContext: jest.fn(() => ({
Provider: ({ children }: { children: React.ReactNode }) => children,
})),
useContext: jest.fn(() => ({ notify: mockNotify })),
}))
// Type cast mocked functions
const mockCreateEmptyDataset = createEmptyDataset as jest.MockedFunction<typeof createEmptyDataset>
const mockInvalidDatasetList = jest.fn()
const mockUseInvalidDatasetList = useInvalidDatasetList as jest.MockedFunction<typeof useInvalidDatasetList>
// Test data builder for props
const createDefaultProps = (overrides?: Partial<{ show: boolean; onHide: () => void }>) => ({
show: true,
onHide: jest.fn(),
...overrides,
})
describe('EmptyDatasetCreationModal', () => {
beforeEach(() => {
jest.clearAllMocks()
mockUseInvalidDatasetList.mockReturnValue(mockInvalidDatasetList)
mockCreateEmptyDataset.mockResolvedValue({
id: 'dataset-123',
name: 'Test Dataset',
} as ReturnType<typeof createEmptyDataset> extends Promise<infer T> ? T : never)
})
// ==========================================
// Rendering Tests - Verify component renders correctly
// ==========================================
describe('Rendering', () => {
it('should render without crashing when show is true', () => {
// Arrange
const props = createDefaultProps()
// Act
render(<EmptyDatasetCreationModal {...props} />)
// Assert - Check modal title is rendered
expect(screen.getByText('datasetCreation.stepOne.modal.title')).toBeInTheDocument()
})
it('should render modal with correct elements', () => {
// Arrange
const props = createDefaultProps()
// Act
render(<EmptyDatasetCreationModal {...props} />)
// Assert
expect(screen.getByText('datasetCreation.stepOne.modal.title')).toBeInTheDocument()
expect(screen.getByText('datasetCreation.stepOne.modal.tip')).toBeInTheDocument()
expect(screen.getByText('datasetCreation.stepOne.modal.input')).toBeInTheDocument()
expect(screen.getByPlaceholderText('datasetCreation.stepOne.modal.placeholder')).toBeInTheDocument()
expect(screen.getByText('datasetCreation.stepOne.modal.confirmButton')).toBeInTheDocument()
expect(screen.getByText('datasetCreation.stepOne.modal.cancelButton')).toBeInTheDocument()
})
it('should render input with empty value initially', () => {
// Arrange
const props = createDefaultProps()
// Act
render(<EmptyDatasetCreationModal {...props} />)
// Assert
const input = screen.getByPlaceholderText('datasetCreation.stepOne.modal.placeholder') as HTMLInputElement
expect(input.value).toBe('')
})
it('should not render modal content when show is false', () => {
// Arrange
const props = createDefaultProps({ show: false })
// Act
render(<EmptyDatasetCreationModal {...props} />)
// Assert - Modal should not be visible (check for absence of title)
expect(screen.queryByText('datasetCreation.stepOne.modal.title')).not.toBeInTheDocument()
})
})
// ==========================================
// Props Testing - Verify all prop variations work correctly
// ==========================================
describe('Props', () => {
describe('show prop', () => {
it('should show modal when show is true', () => {
// Arrange & Act
render(<EmptyDatasetCreationModal show={true} onHide={jest.fn()} />)
// Assert
expect(screen.getByText('datasetCreation.stepOne.modal.title')).toBeInTheDocument()
})
it('should hide modal when show is false', () => {
// Arrange & Act
render(<EmptyDatasetCreationModal show={false} onHide={jest.fn()} />)
// Assert
expect(screen.queryByText('datasetCreation.stepOne.modal.title')).not.toBeInTheDocument()
})
it('should toggle visibility when show prop changes', () => {
// Arrange
const onHide = jest.fn()
const { rerender } = render(<EmptyDatasetCreationModal show={false} onHide={onHide} />)
// Act & Assert - Initially hidden
expect(screen.queryByText('datasetCreation.stepOne.modal.title')).not.toBeInTheDocument()
// Act & Assert - Show modal
rerender(<EmptyDatasetCreationModal show={true} onHide={onHide} />)
expect(screen.getByText('datasetCreation.stepOne.modal.title')).toBeInTheDocument()
})
})
describe('onHide prop', () => {
it('should call onHide when cancel button is clicked', () => {
// Arrange
const mockOnHide = jest.fn()
render(<EmptyDatasetCreationModal show={true} onHide={mockOnHide} />)
// Act
const cancelButton = screen.getByText('datasetCreation.stepOne.modal.cancelButton')
fireEvent.click(cancelButton)
// Assert
expect(mockOnHide).toHaveBeenCalledTimes(1)
})
it('should call onHide when close icon is clicked', async () => {
// Arrange
const mockOnHide = jest.fn()
render(<EmptyDatasetCreationModal show={true} onHide={mockOnHide} />)
// Act - Wait for modal to be rendered, then find the close span
// The close span is located in the modalHeader div, next to the title
const titleElement = await screen.findByText('datasetCreation.stepOne.modal.title')
const headerDiv = titleElement.parentElement
const closeButton = headerDiv?.querySelector('span')
expect(closeButton).toBeInTheDocument()
fireEvent.click(closeButton!)
// Assert
expect(mockOnHide).toHaveBeenCalledTimes(1)
})
})
})
// ==========================================
// State Management - Test input state updates
// ==========================================
describe('State Management', () => {
it('should update input value when user types', () => {
// Arrange
const props = createDefaultProps()
render(<EmptyDatasetCreationModal {...props} />)
const input = screen.getByPlaceholderText('datasetCreation.stepOne.modal.placeholder') as HTMLInputElement
// Act
fireEvent.change(input, { target: { value: 'My Dataset' } })
// Assert
expect(input.value).toBe('My Dataset')
})
it('should persist input value when modal is hidden and shown again via rerender', () => {
// Arrange
const onHide = jest.fn()
const { rerender } = render(<EmptyDatasetCreationModal show={true} onHide={onHide} />)
const input = screen.getByPlaceholderText('datasetCreation.stepOne.modal.placeholder') as HTMLInputElement
// Act - Type in input
fireEvent.change(input, { target: { value: 'Test Dataset' } })
expect(input.value).toBe('Test Dataset')
// Hide and show modal via rerender (component is not unmounted, state persists)
rerender(<EmptyDatasetCreationModal show={false} onHide={onHide} />)
rerender(<EmptyDatasetCreationModal show={true} onHide={onHide} />)
// Assert - Input value persists because component state is preserved during rerender
const newInput = screen.getByPlaceholderText('datasetCreation.stepOne.modal.placeholder') as HTMLInputElement
expect(newInput.value).toBe('Test Dataset')
})
it('should handle consecutive input changes', () => {
// Arrange
const props = createDefaultProps()
render(<EmptyDatasetCreationModal {...props} />)
const input = screen.getByPlaceholderText('datasetCreation.stepOne.modal.placeholder') as HTMLInputElement
// Act & Assert
fireEvent.change(input, { target: { value: 'A' } })
expect(input.value).toBe('A')
fireEvent.change(input, { target: { value: 'AB' } })
expect(input.value).toBe('AB')
fireEvent.change(input, { target: { value: 'ABC' } })
expect(input.value).toBe('ABC')
})
})
// ==========================================
// User Interactions - Test event handlers
// ==========================================
describe('User Interactions', () => {
it('should submit form when confirm button is clicked with valid input', async () => {
// Arrange
const mockOnHide = jest.fn()
render(<EmptyDatasetCreationModal show={true} onHide={mockOnHide} />)
const input = screen.getByPlaceholderText('datasetCreation.stepOne.modal.placeholder')
const confirmButton = screen.getByText('datasetCreation.stepOne.modal.confirmButton')
// Act
fireEvent.change(input, { target: { value: 'Valid Dataset Name' } })
fireEvent.click(confirmButton)
// Assert
await waitFor(() => {
expect(mockCreateEmptyDataset).toHaveBeenCalledWith({ name: 'Valid Dataset Name' })
})
})
it('should show error notification when input is empty', async () => {
// Arrange
const props = createDefaultProps()
render(<EmptyDatasetCreationModal {...props} />)
const confirmButton = screen.getByText('datasetCreation.stepOne.modal.confirmButton')
// Act - Click confirm without entering a name
fireEvent.click(confirmButton)
// Assert
await waitFor(() => {
expect(mockNotify).toHaveBeenCalledWith({
type: 'error',
message: 'datasetCreation.stepOne.modal.nameNotEmpty',
})
})
expect(mockCreateEmptyDataset).not.toHaveBeenCalled()
})
it('should show error notification when input exceeds 40 characters', async () => {
// Arrange
const props = createDefaultProps()
render(<EmptyDatasetCreationModal {...props} />)
const input = screen.getByPlaceholderText('datasetCreation.stepOne.modal.placeholder')
const confirmButton = screen.getByText('datasetCreation.stepOne.modal.confirmButton')
// Act - Enter a name longer than 40 characters
const longName = 'A'.repeat(41)
fireEvent.change(input, { target: { value: longName } })
fireEvent.click(confirmButton)
// Assert
await waitFor(() => {
expect(mockNotify).toHaveBeenCalledWith({
type: 'error',
message: 'datasetCreation.stepOne.modal.nameLengthInvalid',
})
})
expect(mockCreateEmptyDataset).not.toHaveBeenCalled()
})
it('should allow exactly 40 characters', async () => {
// Arrange
const mockOnHide = jest.fn()
render(<EmptyDatasetCreationModal show={true} onHide={mockOnHide} />)
const input = screen.getByPlaceholderText('datasetCreation.stepOne.modal.placeholder')
const confirmButton = screen.getByText('datasetCreation.stepOne.modal.confirmButton')
// Act - Enter exactly 40 characters
const exactLengthName = 'A'.repeat(40)
fireEvent.change(input, { target: { value: exactLengthName } })
fireEvent.click(confirmButton)
// Assert
await waitFor(() => {
expect(mockCreateEmptyDataset).toHaveBeenCalledWith({ name: exactLengthName })
})
})
it('should close modal on cancel button click', () => {
// Arrange
const mockOnHide = jest.fn()
render(<EmptyDatasetCreationModal show={true} onHide={mockOnHide} />)
const cancelButton = screen.getByText('datasetCreation.stepOne.modal.cancelButton')
// Act
fireEvent.click(cancelButton)
// Assert
expect(mockOnHide).toHaveBeenCalledTimes(1)
})
})
// ==========================================
// API Calls - Test API interactions
// ==========================================
describe('API Calls', () => {
it('should call createEmptyDataset with correct parameters', async () => {
// Arrange
const mockOnHide = jest.fn()
render(<EmptyDatasetCreationModal show={true} onHide={mockOnHide} />)
const input = screen.getByPlaceholderText('datasetCreation.stepOne.modal.placeholder')
const confirmButton = screen.getByText('datasetCreation.stepOne.modal.confirmButton')
// Act
fireEvent.change(input, { target: { value: 'New Dataset' } })
fireEvent.click(confirmButton)
// Assert
await waitFor(() => {
expect(mockCreateEmptyDataset).toHaveBeenCalledWith({ name: 'New Dataset' })
})
})
it('should call invalidDatasetList after successful creation', async () => {
// Arrange
const mockOnHide = jest.fn()
render(<EmptyDatasetCreationModal show={true} onHide={mockOnHide} />)
const input = screen.getByPlaceholderText('datasetCreation.stepOne.modal.placeholder')
const confirmButton = screen.getByText('datasetCreation.stepOne.modal.confirmButton')
// Act
fireEvent.change(input, { target: { value: 'Test Dataset' } })
fireEvent.click(confirmButton)
// Assert
await waitFor(() => {
expect(mockInvalidDatasetList).toHaveBeenCalled()
})
})
it('should call onHide after successful creation', async () => {
// Arrange
const mockOnHide = jest.fn()
render(<EmptyDatasetCreationModal show={true} onHide={mockOnHide} />)
const input = screen.getByPlaceholderText('datasetCreation.stepOne.modal.placeholder')
const confirmButton = screen.getByText('datasetCreation.stepOne.modal.confirmButton')
// Act
fireEvent.change(input, { target: { value: 'Test Dataset' } })
fireEvent.click(confirmButton)
// Assert
await waitFor(() => {
expect(mockOnHide).toHaveBeenCalled()
})
})
it('should show error notification on API failure', async () => {
// Arrange
mockCreateEmptyDataset.mockRejectedValue(new Error('API Error'))
const mockOnHide = jest.fn()
render(<EmptyDatasetCreationModal show={true} onHide={mockOnHide} />)
const input = screen.getByPlaceholderText('datasetCreation.stepOne.modal.placeholder')
const confirmButton = screen.getByText('datasetCreation.stepOne.modal.confirmButton')
// Act
fireEvent.change(input, { target: { value: 'Test Dataset' } })
fireEvent.click(confirmButton)
// Assert
await waitFor(() => {
expect(mockNotify).toHaveBeenCalledWith({
type: 'error',
message: 'datasetCreation.stepOne.modal.failed',
})
})
})
it('should not call onHide on API failure', async () => {
// Arrange
mockCreateEmptyDataset.mockRejectedValue(new Error('API Error'))
const mockOnHide = jest.fn()
render(<EmptyDatasetCreationModal show={true} onHide={mockOnHide} />)
const input = screen.getByPlaceholderText('datasetCreation.stepOne.modal.placeholder')
const confirmButton = screen.getByText('datasetCreation.stepOne.modal.confirmButton')
// Act
fireEvent.change(input, { target: { value: 'Test Dataset' } })
fireEvent.click(confirmButton)
// Assert - Wait for API call to complete
await waitFor(() => {
expect(mockCreateEmptyDataset).toHaveBeenCalled()
})
// onHide should not be called on failure
expect(mockOnHide).not.toHaveBeenCalled()
})
it('should not invalidate dataset list on API failure', async () => {
// Arrange
mockCreateEmptyDataset.mockRejectedValue(new Error('API Error'))
const props = createDefaultProps()
render(<EmptyDatasetCreationModal {...props} />)
const input = screen.getByPlaceholderText('datasetCreation.stepOne.modal.placeholder')
const confirmButton = screen.getByText('datasetCreation.stepOne.modal.confirmButton')
// Act
fireEvent.change(input, { target: { value: 'Test Dataset' } })
fireEvent.click(confirmButton)
// Assert
await waitFor(() => {
expect(mockNotify).toHaveBeenCalled()
})
expect(mockInvalidDatasetList).not.toHaveBeenCalled()
})
})
// ==========================================
// Router Navigation - Test Next.js router
// ==========================================
describe('Router Navigation', () => {
it('should navigate to dataset documents page after successful creation', async () => {
// Arrange
mockCreateEmptyDataset.mockResolvedValue({
id: 'test-dataset-456',
name: 'Test',
} as ReturnType<typeof createEmptyDataset> extends Promise<infer T> ? T : never)
const mockOnHide = jest.fn()
render(<EmptyDatasetCreationModal show={true} onHide={mockOnHide} />)
const input = screen.getByPlaceholderText('datasetCreation.stepOne.modal.placeholder')
const confirmButton = screen.getByText('datasetCreation.stepOne.modal.confirmButton')
// Act
fireEvent.change(input, { target: { value: 'Test' } })
fireEvent.click(confirmButton)
// Assert
await waitFor(() => {
expect(mockPush).toHaveBeenCalledWith('/datasets/test-dataset-456/documents')
})
})
it('should not navigate on validation error', async () => {
// Arrange
const props = createDefaultProps()
render(<EmptyDatasetCreationModal {...props} />)
const confirmButton = screen.getByText('datasetCreation.stepOne.modal.confirmButton')
// Act - Click confirm with empty input
fireEvent.click(confirmButton)
// Assert
await waitFor(() => {
expect(mockNotify).toHaveBeenCalled()
})
expect(mockPush).not.toHaveBeenCalled()
})
it('should not navigate on API error', async () => {
// Arrange
mockCreateEmptyDataset.mockRejectedValue(new Error('API Error'))
const props = createDefaultProps()
render(<EmptyDatasetCreationModal {...props} />)
const input = screen.getByPlaceholderText('datasetCreation.stepOne.modal.placeholder')
const confirmButton = screen.getByText('datasetCreation.stepOne.modal.confirmButton')
// Act
fireEvent.change(input, { target: { value: 'Test' } })
fireEvent.click(confirmButton)
// Assert
await waitFor(() => {
expect(mockNotify).toHaveBeenCalled()
})
expect(mockPush).not.toHaveBeenCalled()
})
})
// ==========================================
// Edge Cases - Test boundary conditions and error handling
// ==========================================
describe('Edge Cases', () => {
it('should handle whitespace-only input as valid (component behavior)', async () => {
// Arrange
const mockOnHide = jest.fn()
render(<EmptyDatasetCreationModal show={true} onHide={mockOnHide} />)
const input = screen.getByPlaceholderText('datasetCreation.stepOne.modal.placeholder')
const confirmButton = screen.getByText('datasetCreation.stepOne.modal.confirmButton')
// Act - Enter whitespace only
fireEvent.change(input, { target: { value: ' ' } })
fireEvent.click(confirmButton)
// Assert - Current implementation treats whitespace as valid input
await waitFor(() => {
expect(mockCreateEmptyDataset).toHaveBeenCalledWith({ name: ' ' })
})
})
it('should handle special characters in input', async () => {
// Arrange
const mockOnHide = jest.fn()
render(<EmptyDatasetCreationModal show={true} onHide={mockOnHide} />)
const input = screen.getByPlaceholderText('datasetCreation.stepOne.modal.placeholder')
const confirmButton = screen.getByText('datasetCreation.stepOne.modal.confirmButton')
// Act
fireEvent.change(input, { target: { value: 'Test @#$% Dataset!' } })
fireEvent.click(confirmButton)
// Assert
await waitFor(() => {
expect(mockCreateEmptyDataset).toHaveBeenCalledWith({ name: 'Test @#$% Dataset!' })
})
})
it('should handle Unicode characters in input', async () => {
// Arrange
const mockOnHide = jest.fn()
render(<EmptyDatasetCreationModal show={true} onHide={mockOnHide} />)
const input = screen.getByPlaceholderText('datasetCreation.stepOne.modal.placeholder')
const confirmButton = screen.getByText('datasetCreation.stepOne.modal.confirmButton')
// Act
fireEvent.change(input, { target: { value: '数据集测试 🚀' } })
fireEvent.click(confirmButton)
// Assert
await waitFor(() => {
expect(mockCreateEmptyDataset).toHaveBeenCalledWith({ name: '数据集测试 🚀' })
})
})
it('should handle input at exactly 40 character boundary', async () => {
// Arrange
const mockOnHide = jest.fn()
render(<EmptyDatasetCreationModal show={true} onHide={mockOnHide} />)
const input = screen.getByPlaceholderText('datasetCreation.stepOne.modal.placeholder')
const confirmButton = screen.getByText('datasetCreation.stepOne.modal.confirmButton')
// Act - Test boundary: 40 characters is valid
const name40Chars = 'A'.repeat(40)
fireEvent.change(input, { target: { value: name40Chars } })
fireEvent.click(confirmButton)
// Assert
await waitFor(() => {
expect(mockCreateEmptyDataset).toHaveBeenCalledWith({ name: name40Chars })
})
})
it('should reject input at 41 character boundary', async () => {
// Arrange
const props = createDefaultProps()
render(<EmptyDatasetCreationModal {...props} />)
const input = screen.getByPlaceholderText('datasetCreation.stepOne.modal.placeholder')
const confirmButton = screen.getByText('datasetCreation.stepOne.modal.confirmButton')
// Act - Test boundary: 41 characters is invalid
const name41Chars = 'A'.repeat(41)
fireEvent.change(input, { target: { value: name41Chars } })
fireEvent.click(confirmButton)
// Assert
await waitFor(() => {
expect(mockNotify).toHaveBeenCalledWith({
type: 'error',
message: 'datasetCreation.stepOne.modal.nameLengthInvalid',
})
})
expect(mockCreateEmptyDataset).not.toHaveBeenCalled()
})
it('should handle rapid consecutive submits', async () => {
// Arrange
const mockOnHide = jest.fn()
render(<EmptyDatasetCreationModal show={true} onHide={mockOnHide} />)
const input = screen.getByPlaceholderText('datasetCreation.stepOne.modal.placeholder')
const confirmButton = screen.getByText('datasetCreation.stepOne.modal.confirmButton')
// Act - Rapid clicks
fireEvent.change(input, { target: { value: 'Test' } })
fireEvent.click(confirmButton)
fireEvent.click(confirmButton)
fireEvent.click(confirmButton)
// Assert - API will be called multiple times (no debounce in current implementation)
await waitFor(() => {
expect(mockCreateEmptyDataset).toHaveBeenCalled()
})
})
it('should handle input with leading/trailing spaces', async () => {
// Arrange
const mockOnHide = jest.fn()
render(<EmptyDatasetCreationModal show={true} onHide={mockOnHide} />)
const input = screen.getByPlaceholderText('datasetCreation.stepOne.modal.placeholder')
const confirmButton = screen.getByText('datasetCreation.stepOne.modal.confirmButton')
// Act
fireEvent.change(input, { target: { value: ' Dataset Name ' } })
fireEvent.click(confirmButton)
// Assert - Current implementation does not trim spaces
await waitFor(() => {
expect(mockCreateEmptyDataset).toHaveBeenCalledWith({ name: ' Dataset Name ' })
})
})
it('should handle newline characters in input (browser strips newlines)', async () => {
// Arrange
const mockOnHide = jest.fn()
render(<EmptyDatasetCreationModal show={true} onHide={mockOnHide} />)
const input = screen.getByPlaceholderText('datasetCreation.stepOne.modal.placeholder')
const confirmButton = screen.getByText('datasetCreation.stepOne.modal.confirmButton')
// Act
fireEvent.change(input, { target: { value: 'Line1\nLine2' } })
fireEvent.click(confirmButton)
// Assert - HTML input elements strip newline characters (expected browser behavior)
await waitFor(() => {
expect(mockCreateEmptyDataset).toHaveBeenCalledWith({ name: 'Line1Line2' })
})
})
})
// ==========================================
// Validation Tests - Test input validation
// ==========================================
describe('Validation', () => {
it('should not submit when input is empty string', async () => {
// Arrange
const props = createDefaultProps()
render(<EmptyDatasetCreationModal {...props} />)
const confirmButton = screen.getByText('datasetCreation.stepOne.modal.confirmButton')
// Act
fireEvent.click(confirmButton)
// Assert
await waitFor(() => {
expect(mockNotify).toHaveBeenCalledWith({
type: 'error',
message: 'datasetCreation.stepOne.modal.nameNotEmpty',
})
})
})
it('should validate length before calling API', async () => {
// Arrange
const props = createDefaultProps()
render(<EmptyDatasetCreationModal {...props} />)
const input = screen.getByPlaceholderText('datasetCreation.stepOne.modal.placeholder')
const confirmButton = screen.getByText('datasetCreation.stepOne.modal.confirmButton')
// Act
fireEvent.change(input, { target: { value: 'A'.repeat(50) } })
fireEvent.click(confirmButton)
// Assert - Should show error before API call
await waitFor(() => {
expect(mockNotify).toHaveBeenCalledWith({
type: 'error',
message: 'datasetCreation.stepOne.modal.nameLengthInvalid',
})
})
expect(mockCreateEmptyDataset).not.toHaveBeenCalled()
})
it('should validate empty string before length check', async () => {
// Arrange
const props = createDefaultProps()
render(<EmptyDatasetCreationModal {...props} />)
const confirmButton = screen.getByText('datasetCreation.stepOne.modal.confirmButton')
// Act - Don't enter anything
fireEvent.click(confirmButton)
// Assert - Should show empty error, not length error
await waitFor(() => {
expect(mockNotify).toHaveBeenCalledWith({
type: 'error',
message: 'datasetCreation.stepOne.modal.nameNotEmpty',
})
})
})
})
// ==========================================
// Integration Tests - Test complete flows
// ==========================================
describe('Integration', () => {
it('should complete full successful creation flow', async () => {
// Arrange
const mockOnHide = jest.fn()
mockCreateEmptyDataset.mockResolvedValue({
id: 'new-id-789',
name: 'Complete Flow Test',
} as ReturnType<typeof createEmptyDataset> extends Promise<infer T> ? T : never)
render(<EmptyDatasetCreationModal show={true} onHide={mockOnHide} />)
const input = screen.getByPlaceholderText('datasetCreation.stepOne.modal.placeholder')
const confirmButton = screen.getByText('datasetCreation.stepOne.modal.confirmButton')
// Act
fireEvent.change(input, { target: { value: 'Complete Flow Test' } })
fireEvent.click(confirmButton)
// Assert - Verify complete flow
await waitFor(() => {
// 1. API called
expect(mockCreateEmptyDataset).toHaveBeenCalledWith({ name: 'Complete Flow Test' })
// 2. Dataset list invalidated
expect(mockInvalidDatasetList).toHaveBeenCalled()
// 3. Modal closed
expect(mockOnHide).toHaveBeenCalled()
// 4. Navigation happened
expect(mockPush).toHaveBeenCalledWith('/datasets/new-id-789/documents')
})
})
it('should handle error flow correctly', async () => {
// Arrange
const mockOnHide = jest.fn()
mockCreateEmptyDataset.mockRejectedValue(new Error('Server Error'))
render(<EmptyDatasetCreationModal show={true} onHide={mockOnHide} />)
const input = screen.getByPlaceholderText('datasetCreation.stepOne.modal.placeholder')
const confirmButton = screen.getByText('datasetCreation.stepOne.modal.confirmButton')
// Act
fireEvent.change(input, { target: { value: 'Error Test' } })
fireEvent.click(confirmButton)
// Assert - Verify error handling
await waitFor(() => {
// 1. API was called
expect(mockCreateEmptyDataset).toHaveBeenCalled()
// 2. Error notification shown
expect(mockNotify).toHaveBeenCalledWith({
type: 'error',
message: 'datasetCreation.stepOne.modal.failed',
})
})
// 3. These should NOT happen on error
expect(mockInvalidDatasetList).not.toHaveBeenCalled()
expect(mockOnHide).not.toHaveBeenCalled()
expect(mockPush).not.toHaveBeenCalled()
})
})
})

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,596 @@
import { fireEvent, render, screen } from '@testing-library/react'
import React from 'react'
import LanguageSelect from './index'
import type { ILanguageSelectProps } from './index'
import { languages } from '@/i18n-config/language'
// Get supported languages for test assertions
const supportedLanguages = languages.filter(lang => lang.supported)
// Test data builder for props
const createDefaultProps = (overrides?: Partial<ILanguageSelectProps>): ILanguageSelectProps => ({
currentLanguage: 'English',
onSelect: jest.fn(),
disabled: false,
...overrides,
})
describe('LanguageSelect', () => {
beforeEach(() => {
jest.clearAllMocks()
})
// ==========================================
// Rendering Tests - Verify component renders correctly
// ==========================================
describe('Rendering', () => {
it('should render without crashing', () => {
// Arrange
const props = createDefaultProps()
// Act
render(<LanguageSelect {...props} />)
// Assert
expect(screen.getByText('English')).toBeInTheDocument()
})
it('should render current language text', () => {
// Arrange
const props = createDefaultProps({ currentLanguage: 'Chinese Simplified' })
// Act
render(<LanguageSelect {...props} />)
// Assert
expect(screen.getByText('Chinese Simplified')).toBeInTheDocument()
})
it('should render dropdown arrow icon', () => {
// Arrange
const props = createDefaultProps()
// Act
const { container } = render(<LanguageSelect {...props} />)
// Assert - RiArrowDownSLine renders as SVG
const svgIcon = container.querySelector('svg')
expect(svgIcon).toBeInTheDocument()
})
it('should render all supported languages in dropdown when opened', () => {
// Arrange
const props = createDefaultProps()
render(<LanguageSelect {...props} />)
// Act - Click button to open dropdown
const button = screen.getByRole('button')
fireEvent.click(button)
// Assert - All supported languages should be visible
// Use getAllByText because current language appears both in button and dropdown
supportedLanguages.forEach((lang) => {
expect(screen.getAllByText(lang.prompt_name).length).toBeGreaterThanOrEqual(1)
})
})
it('should render check icon for selected language', () => {
// Arrange
const selectedLanguage = 'Japanese'
const props = createDefaultProps({ currentLanguage: selectedLanguage })
render(<LanguageSelect {...props} />)
// Act
const button = screen.getByRole('button')
fireEvent.click(button)
// Assert - The selected language option should have a check icon
const languageOptions = screen.getAllByText(selectedLanguage)
// One in the button, one in the dropdown list
expect(languageOptions.length).toBeGreaterThanOrEqual(1)
})
})
// ==========================================
// Props Testing - Verify all prop variations work correctly
// ==========================================
describe('Props', () => {
describe('currentLanguage prop', () => {
it('should display English when currentLanguage is English', () => {
const props = createDefaultProps({ currentLanguage: 'English' })
render(<LanguageSelect {...props} />)
expect(screen.getByText('English')).toBeInTheDocument()
})
it('should display Chinese Simplified when currentLanguage is Chinese Simplified', () => {
const props = createDefaultProps({ currentLanguage: 'Chinese Simplified' })
render(<LanguageSelect {...props} />)
expect(screen.getByText('Chinese Simplified')).toBeInTheDocument()
})
it('should display Japanese when currentLanguage is Japanese', () => {
const props = createDefaultProps({ currentLanguage: 'Japanese' })
render(<LanguageSelect {...props} />)
expect(screen.getByText('Japanese')).toBeInTheDocument()
})
it.each(supportedLanguages.map(l => l.prompt_name))(
'should display %s as current language',
(language) => {
const props = createDefaultProps({ currentLanguage: language })
render(<LanguageSelect {...props} />)
expect(screen.getByText(language)).toBeInTheDocument()
},
)
})
describe('disabled prop', () => {
it('should have disabled button when disabled is true', () => {
// Arrange
const props = createDefaultProps({ disabled: true })
// Act
render(<LanguageSelect {...props} />)
// Assert
const button = screen.getByRole('button')
expect(button).toBeDisabled()
})
it('should have enabled button when disabled is false', () => {
// Arrange
const props = createDefaultProps({ disabled: false })
// Act
render(<LanguageSelect {...props} />)
// Assert
const button = screen.getByRole('button')
expect(button).not.toBeDisabled()
})
it('should have enabled button when disabled is undefined', () => {
// Arrange
const props = createDefaultProps()
delete (props as Partial<ILanguageSelectProps>).disabled
// Act
render(<LanguageSelect {...props} />)
// Assert
const button = screen.getByRole('button')
expect(button).not.toBeDisabled()
})
it('should apply disabled styling when disabled is true', () => {
// Arrange
const props = createDefaultProps({ disabled: true })
// Act
const { container } = render(<LanguageSelect {...props} />)
// Assert - Check for disabled class on text elements
const disabledTextElement = container.querySelector('.text-components-button-tertiary-text-disabled')
expect(disabledTextElement).toBeInTheDocument()
})
it('should apply cursor-not-allowed styling when disabled', () => {
// Arrange
const props = createDefaultProps({ disabled: true })
// Act
const { container } = render(<LanguageSelect {...props} />)
// Assert
const elementWithCursor = container.querySelector('.cursor-not-allowed')
expect(elementWithCursor).toBeInTheDocument()
})
})
describe('onSelect prop', () => {
it('should be callable as a function', () => {
const mockOnSelect = jest.fn()
const props = createDefaultProps({ onSelect: mockOnSelect })
render(<LanguageSelect {...props} />)
// Open dropdown and click a language
const button = screen.getByRole('button')
fireEvent.click(button)
const germanOption = screen.getByText('German')
fireEvent.click(germanOption)
expect(mockOnSelect).toHaveBeenCalledWith('German')
})
})
})
// ==========================================
// User Interactions - Test event handlers
// ==========================================
describe('User Interactions', () => {
it('should open dropdown when button is clicked', () => {
// Arrange
const props = createDefaultProps()
render(<LanguageSelect {...props} />)
// Act
const button = screen.getByRole('button')
fireEvent.click(button)
// Assert - Check if dropdown content is visible
expect(screen.getAllByText('English').length).toBeGreaterThanOrEqual(1)
})
it('should call onSelect when a language option is clicked', () => {
// Arrange
const mockOnSelect = jest.fn()
const props = createDefaultProps({ onSelect: mockOnSelect })
render(<LanguageSelect {...props} />)
// Act
const button = screen.getByRole('button')
fireEvent.click(button)
const frenchOption = screen.getByText('French')
fireEvent.click(frenchOption)
// Assert
expect(mockOnSelect).toHaveBeenCalledTimes(1)
expect(mockOnSelect).toHaveBeenCalledWith('French')
})
it('should call onSelect with correct language when selecting different languages', () => {
// Arrange
const mockOnSelect = jest.fn()
const props = createDefaultProps({ onSelect: mockOnSelect })
render(<LanguageSelect {...props} />)
// Act & Assert - Test multiple language selections
const testLanguages = ['Korean', 'Spanish', 'Italian']
testLanguages.forEach((lang) => {
mockOnSelect.mockClear()
const button = screen.getByRole('button')
fireEvent.click(button)
const languageOption = screen.getByText(lang)
fireEvent.click(languageOption)
expect(mockOnSelect).toHaveBeenCalledWith(lang)
})
})
it('should not open dropdown when disabled', () => {
// Arrange
const props = createDefaultProps({ disabled: true })
render(<LanguageSelect {...props} />)
// Act
const button = screen.getByRole('button')
fireEvent.click(button)
// Assert - Dropdown should not open, only one instance of the current language should exist
const englishElements = screen.getAllByText('English')
expect(englishElements.length).toBe(1) // Only the button text, not dropdown
})
it('should not call onSelect when component is disabled', () => {
// Arrange
const mockOnSelect = jest.fn()
const props = createDefaultProps({ onSelect: mockOnSelect, disabled: true })
render(<LanguageSelect {...props} />)
// Act
const button = screen.getByRole('button')
fireEvent.click(button)
// Assert
expect(mockOnSelect).not.toHaveBeenCalled()
})
it('should handle rapid consecutive clicks', () => {
// Arrange
const mockOnSelect = jest.fn()
const props = createDefaultProps({ onSelect: mockOnSelect })
render(<LanguageSelect {...props} />)
// Act - Rapid clicks
const button = screen.getByRole('button')
fireEvent.click(button)
fireEvent.click(button)
fireEvent.click(button)
// Assert - Component should not crash
expect(button).toBeInTheDocument()
})
})
// ==========================================
// Component Memoization - Test React.memo behavior
// ==========================================
describe('Memoization', () => {
it('should be wrapped with React.memo', () => {
// Assert - Check component has memo wrapper
expect(LanguageSelect.$$typeof).toBe(Symbol.for('react.memo'))
})
it('should not re-render when props remain the same', () => {
// Arrange
const mockOnSelect = jest.fn()
const props = createDefaultProps({ onSelect: mockOnSelect })
const renderSpy = jest.fn()
// Create a wrapper component to track renders
const TrackedLanguageSelect: React.FC<ILanguageSelectProps> = (trackedProps) => {
renderSpy()
return <LanguageSelect {...trackedProps} />
}
const MemoizedTracked = React.memo(TrackedLanguageSelect)
// Act
const { rerender } = render(<MemoizedTracked {...props} />)
rerender(<MemoizedTracked {...props} />)
// Assert - Should only render once due to same props
expect(renderSpy).toHaveBeenCalledTimes(1)
})
it('should re-render when currentLanguage changes', () => {
// Arrange
const props = createDefaultProps({ currentLanguage: 'English' })
// Act
const { rerender } = render(<LanguageSelect {...props} />)
expect(screen.getByText('English')).toBeInTheDocument()
rerender(<LanguageSelect {...props} currentLanguage="French" />)
// Assert
expect(screen.getByText('French')).toBeInTheDocument()
})
it('should re-render when disabled changes', () => {
// Arrange
const props = createDefaultProps({ disabled: false })
// Act
const { rerender } = render(<LanguageSelect {...props} />)
expect(screen.getByRole('button')).not.toBeDisabled()
rerender(<LanguageSelect {...props} disabled={true} />)
// Assert
expect(screen.getByRole('button')).toBeDisabled()
})
})
// ==========================================
// Edge Cases - Test boundary conditions and error handling
// ==========================================
describe('Edge Cases', () => {
it('should handle empty string as currentLanguage', () => {
// Arrange
const props = createDefaultProps({ currentLanguage: '' })
// Act
render(<LanguageSelect {...props} />)
// Assert - Component should still render
const button = screen.getByRole('button')
expect(button).toBeInTheDocument()
})
it('should handle non-existent language as currentLanguage', () => {
// Arrange
const props = createDefaultProps({ currentLanguage: 'NonExistentLanguage' })
// Act
render(<LanguageSelect {...props} />)
// Assert - Should display the value even if not in list
expect(screen.getByText('NonExistentLanguage')).toBeInTheDocument()
})
it('should handle special characters in language names', () => {
// Arrange - Turkish has special character in prompt_name
const props = createDefaultProps({ currentLanguage: 'Türkçe' })
// Act
render(<LanguageSelect {...props} />)
// Assert
expect(screen.getByText('Türkçe')).toBeInTheDocument()
})
it('should handle very long language names', () => {
// Arrange
const longLanguageName = 'A'.repeat(100)
const props = createDefaultProps({ currentLanguage: longLanguageName })
// Act
render(<LanguageSelect {...props} />)
// Assert - Should not crash and should display the text
expect(screen.getByText(longLanguageName)).toBeInTheDocument()
})
it('should render correct number of language options', () => {
// Arrange
const props = createDefaultProps()
render(<LanguageSelect {...props} />)
// Act
const button = screen.getByRole('button')
fireEvent.click(button)
// Assert - Should show all supported languages
const expectedCount = supportedLanguages.length
// Each language appears in the dropdown (use getAllByText because current language appears twice)
supportedLanguages.forEach((lang) => {
expect(screen.getAllByText(lang.prompt_name).length).toBeGreaterThanOrEqual(1)
})
expect(supportedLanguages.length).toBe(expectedCount)
})
it('should only show supported languages in dropdown', () => {
// Arrange
const props = createDefaultProps()
render(<LanguageSelect {...props} />)
// Act
const button = screen.getByRole('button')
fireEvent.click(button)
// Assert - All displayed languages should be supported
const allLanguages = languages
const unsupportedLanguages = allLanguages.filter(lang => !lang.supported)
unsupportedLanguages.forEach((lang) => {
expect(screen.queryByText(lang.prompt_name)).not.toBeInTheDocument()
})
})
it('should handle undefined onSelect gracefully when clicking', () => {
// Arrange - This tests TypeScript boundary, but runtime should not crash
const props = createDefaultProps()
// Act
render(<LanguageSelect {...props} />)
const button = screen.getByRole('button')
fireEvent.click(button)
const option = screen.getByText('German')
// Assert - Should not throw
expect(() => fireEvent.click(option)).not.toThrow()
})
it('should maintain selection state visually with check icon', () => {
// Arrange
const props = createDefaultProps({ currentLanguage: 'Russian' })
const { container } = render(<LanguageSelect {...props} />)
// Act
const button = screen.getByRole('button')
fireEvent.click(button)
// Assert - Find the check icon (RiCheckLine) in the dropdown
// The selected option should have a check icon next to it
const checkIcons = container.querySelectorAll('svg.text-text-accent')
expect(checkIcons.length).toBeGreaterThanOrEqual(1)
})
})
// ==========================================
// Accessibility - Basic accessibility checks
// ==========================================
describe('Accessibility', () => {
it('should have accessible button element', () => {
// Arrange
const props = createDefaultProps()
// Act
render(<LanguageSelect {...props} />)
// Assert
const button = screen.getByRole('button')
expect(button).toBeInTheDocument()
})
it('should have clickable language options', () => {
// Arrange
const props = createDefaultProps()
render(<LanguageSelect {...props} />)
// Act
const button = screen.getByRole('button')
fireEvent.click(button)
// Assert - Options should be clickable (have cursor-pointer class)
const options = screen.getAllByText(/English|French|German|Japanese/i)
expect(options.length).toBeGreaterThan(0)
})
})
// ==========================================
// Integration with Popover - Test Popover behavior
// ==========================================
describe('Popover Integration', () => {
it('should use manualClose prop on Popover', () => {
// Arrange
const mockOnSelect = jest.fn()
const props = createDefaultProps({ onSelect: mockOnSelect })
// Act
render(<LanguageSelect {...props} />)
const button = screen.getByRole('button')
fireEvent.click(button)
// Assert - Popover should be open
expect(screen.getAllByText('English').length).toBeGreaterThanOrEqual(1)
})
it('should have correct popup z-index class', () => {
// Arrange
const props = createDefaultProps()
const { container } = render(<LanguageSelect {...props} />)
// Act
const button = screen.getByRole('button')
fireEvent.click(button)
// Assert - Check for z-20 class (popupClassName='z-20')
// This is applied to the Popover
expect(container.querySelector('.z-20')).toBeTruthy()
})
})
// ==========================================
// Styling Tests - Verify correct CSS classes applied
// ==========================================
describe('Styling', () => {
it('should apply tertiary button styling', () => {
// Arrange
const props = createDefaultProps()
const { container } = render(<LanguageSelect {...props} />)
// Assert - Check for tertiary button classes (uses ! prefix for important)
expect(container.querySelector('.\\!bg-components-button-tertiary-bg')).toBeInTheDocument()
})
it('should apply hover styling class to options', () => {
// Arrange
const props = createDefaultProps()
const { container } = render(<LanguageSelect {...props} />)
// Act
const button = screen.getByRole('button')
fireEvent.click(button)
// Assert - Options should have hover class
const optionWithHover = container.querySelector('.hover\\:bg-state-base-hover')
expect(optionWithHover).toBeInTheDocument()
})
it('should apply correct text styling to language options', () => {
// Arrange
const props = createDefaultProps()
const { container } = render(<LanguageSelect {...props} />)
// Act
const button = screen.getByRole('button')
fireEvent.click(button)
// Assert - Check for system-sm-medium class on options
const styledOption = container.querySelector('.system-sm-medium')
expect(styledOption).toBeInTheDocument()
})
it('should apply disabled styling to icon when disabled', () => {
// Arrange
const props = createDefaultProps({ disabled: true })
const { container } = render(<LanguageSelect {...props} />)
// Assert - Check for disabled text color on icon
const disabledIcon = container.querySelector('.text-components-button-tertiary-text-disabled')
expect(disabledIcon).toBeInTheDocument()
})
})
})

View File

@ -0,0 +1,803 @@
import { render, screen } from '@testing-library/react'
import React from 'react'
import PreviewItem, { PreviewType } from './index'
import type { IPreviewItemProps } from './index'
// Test data builder for props
const createDefaultProps = (overrides?: Partial<IPreviewItemProps>): IPreviewItemProps => ({
type: PreviewType.TEXT,
index: 1,
content: 'Test content',
...overrides,
})
const createQAProps = (overrides?: Partial<IPreviewItemProps>): IPreviewItemProps => ({
type: PreviewType.QA,
index: 1,
qa: {
question: 'Test question',
answer: 'Test answer',
},
...overrides,
})
describe('PreviewItem', () => {
beforeEach(() => {
jest.clearAllMocks()
})
// ==========================================
// Rendering Tests - Verify component renders correctly
// ==========================================
describe('Rendering', () => {
it('should render without crashing', () => {
// Arrange
const props = createDefaultProps()
// Act
render(<PreviewItem {...props} />)
// Assert
expect(screen.getByText('Test content')).toBeInTheDocument()
})
it('should render with TEXT type', () => {
// Arrange
const props = createDefaultProps({ content: 'Sample text content' })
// Act
render(<PreviewItem {...props} />)
// Assert
expect(screen.getByText('Sample text content')).toBeInTheDocument()
})
it('should render with QA type', () => {
// Arrange
const props = createQAProps()
// Act
render(<PreviewItem {...props} />)
// Assert
expect(screen.getByText('Q')).toBeInTheDocument()
expect(screen.getByText('A')).toBeInTheDocument()
expect(screen.getByText('Test question')).toBeInTheDocument()
expect(screen.getByText('Test answer')).toBeInTheDocument()
})
it('should render sharp icon (#) with formatted index', () => {
// Arrange
const props = createDefaultProps({ index: 5 })
// Act
const { container } = render(<PreviewItem {...props} />)
// Assert - Index should be padded to 3 digits
expect(screen.getByText('005')).toBeInTheDocument()
// Sharp icon SVG should exist
const svgElements = container.querySelectorAll('svg')
expect(svgElements.length).toBeGreaterThanOrEqual(1)
})
it('should render character count for TEXT type', () => {
// Arrange
const content = 'Hello World' // 11 characters
const props = createDefaultProps({ content })
// Act
render(<PreviewItem {...props} />)
// Assert - Shows character count with translation key
expect(screen.getByText(/11/)).toBeInTheDocument()
expect(screen.getByText(/datasetCreation.stepTwo.characters/)).toBeInTheDocument()
})
it('should render character count for QA type', () => {
// Arrange
const props = createQAProps({
qa: {
question: 'Hello', // 5 characters
answer: 'World', // 5 characters - total 10
},
})
// Act
render(<PreviewItem {...props} />)
// Assert - Shows combined character count
expect(screen.getByText(/10/)).toBeInTheDocument()
})
it('should render text icon SVG', () => {
// Arrange
const props = createDefaultProps()
// Act
const { container } = render(<PreviewItem {...props} />)
// Assert - Should have SVG icons
const svgElements = container.querySelectorAll('svg')
expect(svgElements.length).toBe(2) // Sharp icon and text icon
})
})
// ==========================================
// Props Testing - Verify all prop variations work correctly
// ==========================================
describe('Props', () => {
describe('type prop', () => {
it('should render TEXT content when type is TEXT', () => {
// Arrange
const props = createDefaultProps({ type: PreviewType.TEXT, content: 'Text mode content' })
// Act
render(<PreviewItem {...props} />)
// Assert
expect(screen.getByText('Text mode content')).toBeInTheDocument()
expect(screen.queryByText('Q')).not.toBeInTheDocument()
expect(screen.queryByText('A')).not.toBeInTheDocument()
})
it('should render QA content when type is QA', () => {
// Arrange
const props = createQAProps({
type: PreviewType.QA,
qa: { question: 'My question', answer: 'My answer' },
})
// Act
render(<PreviewItem {...props} />)
// Assert
expect(screen.getByText('Q')).toBeInTheDocument()
expect(screen.getByText('A')).toBeInTheDocument()
expect(screen.getByText('My question')).toBeInTheDocument()
expect(screen.getByText('My answer')).toBeInTheDocument()
})
it('should use TEXT as default type when type is "text"', () => {
// Arrange
const props = createDefaultProps({ type: 'text' as PreviewType, content: 'Default type content' })
// Act
render(<PreviewItem {...props} />)
// Assert
expect(screen.getByText('Default type content')).toBeInTheDocument()
})
it('should use QA type when type is "QA"', () => {
// Arrange
const props = createQAProps({ type: 'QA' as PreviewType })
// Act
render(<PreviewItem {...props} />)
// Assert
expect(screen.getByText('Q')).toBeInTheDocument()
expect(screen.getByText('A')).toBeInTheDocument()
})
})
describe('index prop', () => {
it.each([
[1, '001'],
[5, '005'],
[10, '010'],
[99, '099'],
[100, '100'],
[999, '999'],
[1000, '1000'],
])('should format index %i as %s', (index, expected) => {
// Arrange
const props = createDefaultProps({ index })
// Act
render(<PreviewItem {...props} />)
// Assert
expect(screen.getByText(expected)).toBeInTheDocument()
})
it('should handle index 0', () => {
// Arrange
const props = createDefaultProps({ index: 0 })
// Act
render(<PreviewItem {...props} />)
// Assert
expect(screen.getByText('000')).toBeInTheDocument()
})
it('should handle large index numbers', () => {
// Arrange
const props = createDefaultProps({ index: 12345 })
// Act
render(<PreviewItem {...props} />)
// Assert
expect(screen.getByText('12345')).toBeInTheDocument()
})
})
describe('content prop', () => {
it('should render content when provided', () => {
// Arrange
const props = createDefaultProps({ content: 'Custom content here' })
// Act
render(<PreviewItem {...props} />)
// Assert
expect(screen.getByText('Custom content here')).toBeInTheDocument()
})
it('should handle multiline content', () => {
// Arrange
const multilineContent = 'Line 1\nLine 2\nLine 3'
const props = createDefaultProps({ content: multilineContent })
// Act
const { container } = render(<PreviewItem {...props} />)
// Assert - Check content is rendered (multiline text is in pre-line div)
const contentDiv = container.querySelector('[style*="white-space: pre-line"]')
expect(contentDiv?.textContent).toContain('Line 1')
expect(contentDiv?.textContent).toContain('Line 2')
expect(contentDiv?.textContent).toContain('Line 3')
})
it('should preserve whitespace with pre-line style', () => {
// Arrange
const props = createDefaultProps({ content: 'Text with spaces' })
// Act
const { container } = render(<PreviewItem {...props} />)
// Assert - Check for whiteSpace: pre-line style
const contentDiv = container.querySelector('[style*="white-space: pre-line"]')
expect(contentDiv).toBeInTheDocument()
})
})
describe('qa prop', () => {
it('should render question and answer when qa is provided', () => {
// Arrange
const props = createQAProps({
qa: {
question: 'What is testing?',
answer: 'Testing is verification.',
},
})
// Act
render(<PreviewItem {...props} />)
// Assert
expect(screen.getByText('What is testing?')).toBeInTheDocument()
expect(screen.getByText('Testing is verification.')).toBeInTheDocument()
})
it('should render Q and A labels', () => {
// Arrange
const props = createQAProps()
// Act
render(<PreviewItem {...props} />)
// Assert
expect(screen.getByText('Q')).toBeInTheDocument()
expect(screen.getByText('A')).toBeInTheDocument()
})
it('should handle multiline question', () => {
// Arrange
const props = createQAProps({
qa: {
question: 'Question line 1\nQuestion line 2',
answer: 'Answer',
},
})
// Act
const { container } = render(<PreviewItem {...props} />)
// Assert - Check content is in pre-line div
const preLineDivs = container.querySelectorAll('[style*="white-space: pre-line"]')
const questionDiv = Array.from(preLineDivs).find(div => div.textContent?.includes('Question line 1'))
expect(questionDiv).toBeTruthy()
expect(questionDiv?.textContent).toContain('Question line 2')
})
it('should handle multiline answer', () => {
// Arrange
const props = createQAProps({
qa: {
question: 'Question',
answer: 'Answer line 1\nAnswer line 2',
},
})
// Act
const { container } = render(<PreviewItem {...props} />)
// Assert - Check content is in pre-line div
const preLineDivs = container.querySelectorAll('[style*="white-space: pre-line"]')
const answerDiv = Array.from(preLineDivs).find(div => div.textContent?.includes('Answer line 1'))
expect(answerDiv).toBeTruthy()
expect(answerDiv?.textContent).toContain('Answer line 2')
})
})
})
// ==========================================
// Component Memoization - Test React.memo behavior
// ==========================================
describe('Memoization', () => {
it('should be wrapped with React.memo', () => {
// Assert - Check component has memo wrapper
expect(PreviewItem.$$typeof).toBe(Symbol.for('react.memo'))
})
it('should not re-render when props remain the same', () => {
// Arrange
const props = createDefaultProps()
const renderSpy = jest.fn()
// Create a wrapper component to track renders
const TrackedPreviewItem: React.FC<IPreviewItemProps> = (trackedProps) => {
renderSpy()
return <PreviewItem {...trackedProps} />
}
const MemoizedTracked = React.memo(TrackedPreviewItem)
// Act
const { rerender } = render(<MemoizedTracked {...props} />)
rerender(<MemoizedTracked {...props} />)
// Assert - Should only render once due to same props
expect(renderSpy).toHaveBeenCalledTimes(1)
})
it('should re-render when content changes', () => {
// Arrange
const props = createDefaultProps({ content: 'Initial content' })
// Act
const { rerender } = render(<PreviewItem {...props} />)
expect(screen.getByText('Initial content')).toBeInTheDocument()
rerender(<PreviewItem {...props} content="Updated content" />)
// Assert
expect(screen.getByText('Updated content')).toBeInTheDocument()
})
it('should re-render when index changes', () => {
// Arrange
const props = createDefaultProps({ index: 1 })
// Act
const { rerender } = render(<PreviewItem {...props} />)
expect(screen.getByText('001')).toBeInTheDocument()
rerender(<PreviewItem {...props} index={99} />)
// Assert
expect(screen.getByText('099')).toBeInTheDocument()
})
it('should re-render when type changes', () => {
// Arrange
const props = createDefaultProps({ type: PreviewType.TEXT, content: 'Text content' })
// Act
const { rerender } = render(<PreviewItem {...props} />)
expect(screen.getByText('Text content')).toBeInTheDocument()
expect(screen.queryByText('Q')).not.toBeInTheDocument()
rerender(<PreviewItem type={PreviewType.QA} index={1} qa={{ question: 'Q1', answer: 'A1' }} />)
// Assert
expect(screen.getByText('Q')).toBeInTheDocument()
expect(screen.getByText('A')).toBeInTheDocument()
})
it('should re-render when qa prop changes', () => {
// Arrange
const props = createQAProps({
qa: { question: 'Original question', answer: 'Original answer' },
})
// Act
const { rerender } = render(<PreviewItem {...props} />)
expect(screen.getByText('Original question')).toBeInTheDocument()
rerender(<PreviewItem {...props} qa={{ question: 'New question', answer: 'New answer' }} />)
// Assert
expect(screen.getByText('New question')).toBeInTheDocument()
expect(screen.getByText('New answer')).toBeInTheDocument()
})
})
// ==========================================
// Edge Cases - Test boundary conditions and error handling
// ==========================================
describe('Edge Cases', () => {
describe('Empty/Undefined values', () => {
it('should handle undefined content gracefully', () => {
// Arrange
const props = createDefaultProps({ content: undefined })
// Act
render(<PreviewItem {...props} />)
// Assert - Should show 0 characters (use more specific text match)
expect(screen.getByText(/^0 datasetCreation/)).toBeInTheDocument()
})
it('should handle empty string content', () => {
// Arrange
const props = createDefaultProps({ content: '' })
// Act
render(<PreviewItem {...props} />)
// Assert - Should show 0 characters (use more specific text match)
expect(screen.getByText(/^0 datasetCreation/)).toBeInTheDocument()
})
it('should handle undefined qa gracefully', () => {
// Arrange
const props: IPreviewItemProps = {
type: PreviewType.QA,
index: 1,
qa: undefined,
}
// Act
render(<PreviewItem {...props} />)
// Assert - Should render Q and A labels but with empty content
expect(screen.getByText('Q')).toBeInTheDocument()
expect(screen.getByText('A')).toBeInTheDocument()
// Character count should be 0 (use more specific text match)
expect(screen.getByText(/^0 datasetCreation/)).toBeInTheDocument()
})
it('should handle undefined question in qa', () => {
// Arrange
const props: IPreviewItemProps = {
type: PreviewType.QA,
index: 1,
qa: {
question: undefined as unknown as string,
answer: 'Only answer',
},
}
// Act
render(<PreviewItem {...props} />)
// Assert
expect(screen.getByText('Only answer')).toBeInTheDocument()
})
it('should handle undefined answer in qa', () => {
// Arrange
const props: IPreviewItemProps = {
type: PreviewType.QA,
index: 1,
qa: {
question: 'Only question',
answer: undefined as unknown as string,
},
}
// Act
render(<PreviewItem {...props} />)
// Assert
expect(screen.getByText('Only question')).toBeInTheDocument()
})
it('should handle empty question and answer strings', () => {
// Arrange
const props = createQAProps({
qa: { question: '', answer: '' },
})
// Act
render(<PreviewItem {...props} />)
// Assert - Should show 0 characters (use more specific text match)
expect(screen.getByText(/^0 datasetCreation/)).toBeInTheDocument()
expect(screen.getByText('Q')).toBeInTheDocument()
expect(screen.getByText('A')).toBeInTheDocument()
})
})
describe('Character count calculation', () => {
it('should calculate correct character count for TEXT type', () => {
// Arrange - 'Test' has 4 characters
const props = createDefaultProps({ content: 'Test' })
// Act
render(<PreviewItem {...props} />)
// Assert
expect(screen.getByText(/4/)).toBeInTheDocument()
})
it('should calculate correct character count for QA type (question + answer)', () => {
// Arrange - 'ABC' (3) + 'DEFGH' (5) = 8 characters
const props = createQAProps({
qa: { question: 'ABC', answer: 'DEFGH' },
})
// Act
render(<PreviewItem {...props} />)
// Assert
expect(screen.getByText(/8/)).toBeInTheDocument()
})
it('should count special characters correctly', () => {
// Arrange - Content with special characters
const props = createDefaultProps({ content: '你好世界' }) // 4 Chinese characters
// Act
render(<PreviewItem {...props} />)
// Assert
expect(screen.getByText(/4/)).toBeInTheDocument()
})
it('should count newlines in character count', () => {
// Arrange - 'a\nb' has 3 characters
const props = createDefaultProps({ content: 'a\nb' })
// Act
render(<PreviewItem {...props} />)
// Assert
expect(screen.getByText(/3/)).toBeInTheDocument()
})
it('should count spaces in character count', () => {
// Arrange - 'a b' has 3 characters
const props = createDefaultProps({ content: 'a b' })
// Act
render(<PreviewItem {...props} />)
// Assert
expect(screen.getByText(/3/)).toBeInTheDocument()
})
})
describe('Boundary conditions', () => {
it('should handle very long content', () => {
// Arrange
const longContent = 'A'.repeat(10000)
const props = createDefaultProps({ content: longContent })
// Act
render(<PreviewItem {...props} />)
// Assert - Should show correct character count
expect(screen.getByText(/10000/)).toBeInTheDocument()
})
it('should handle very long index', () => {
// Arrange
const props = createDefaultProps({ index: 999999999 })
// Act
render(<PreviewItem {...props} />)
// Assert
expect(screen.getByText('999999999')).toBeInTheDocument()
})
it('should handle negative index', () => {
// Arrange
const props = createDefaultProps({ index: -1 })
// Act
render(<PreviewItem {...props} />)
// Assert - padStart pads from the start, so -1 becomes 0-1
expect(screen.getByText('0-1')).toBeInTheDocument()
})
it('should handle content with only whitespace', () => {
// Arrange
const props = createDefaultProps({ content: ' ' }) // 3 spaces
// Act
render(<PreviewItem {...props} />)
// Assert
expect(screen.getByText(/3/)).toBeInTheDocument()
})
it('should handle content with HTML-like characters', () => {
// Arrange
const props = createDefaultProps({ content: '<div>Test</div>' })
// Act
render(<PreviewItem {...props} />)
// Assert - Should render as text, not HTML
expect(screen.getByText('<div>Test</div>')).toBeInTheDocument()
})
it('should handle content with emojis', () => {
// Arrange - Emojis can have complex character lengths
const props = createDefaultProps({ content: '😀👍' })
// Act
render(<PreviewItem {...props} />)
// Assert - Emoji length depends on JS string length
expect(screen.getByText('😀👍')).toBeInTheDocument()
})
})
describe('Type edge cases', () => {
it('should ignore qa prop when type is TEXT', () => {
// Arrange - Both content and qa provided, but type is TEXT
const props: IPreviewItemProps = {
type: PreviewType.TEXT,
index: 1,
content: 'Text content',
qa: { question: 'Should not show', answer: 'Also should not show' },
}
// Act
render(<PreviewItem {...props} />)
// Assert
expect(screen.getByText('Text content')).toBeInTheDocument()
expect(screen.queryByText('Should not show')).not.toBeInTheDocument()
expect(screen.queryByText('Also should not show')).not.toBeInTheDocument()
})
it('should use content length for TEXT type even when qa is provided', () => {
// Arrange
const props: IPreviewItemProps = {
type: PreviewType.TEXT,
index: 1,
content: 'Hi', // 2 characters
qa: { question: 'Question', answer: 'Answer' }, // Would be 14 characters if used
}
// Act
render(<PreviewItem {...props} />)
// Assert - Should show 2, not 14
expect(screen.getByText(/2/)).toBeInTheDocument()
})
it('should ignore content prop when type is QA', () => {
// Arrange
const props: IPreviewItemProps = {
type: PreviewType.QA,
index: 1,
content: 'Should not display',
qa: { question: 'Q text', answer: 'A text' },
}
// Act
render(<PreviewItem {...props} />)
// Assert
expect(screen.queryByText('Should not display')).not.toBeInTheDocument()
expect(screen.getByText('Q text')).toBeInTheDocument()
expect(screen.getByText('A text')).toBeInTheDocument()
})
})
})
// ==========================================
// PreviewType Enum - Test exported enum values
// ==========================================
describe('PreviewType Enum', () => {
it('should have TEXT value as "text"', () => {
expect(PreviewType.TEXT).toBe('text')
})
it('should have QA value as "QA"', () => {
expect(PreviewType.QA).toBe('QA')
})
})
// ==========================================
// Styling Tests - Verify correct CSS classes applied
// ==========================================
describe('Styling', () => {
it('should have rounded container with gray background', () => {
// Arrange
const props = createDefaultProps()
// Act
const { container } = render(<PreviewItem {...props} />)
// Assert
const rootDiv = container.firstChild as HTMLElement
expect(rootDiv).toHaveClass('rounded-xl', 'bg-gray-50', 'p-4')
})
it('should have proper header styling', () => {
// Arrange
const props = createDefaultProps()
// Act
const { container } = render(<PreviewItem {...props} />)
// Assert - Check header div styling
const headerDiv = container.querySelector('.flex.h-5.items-center.justify-between')
expect(headerDiv).toBeInTheDocument()
})
it('should have index badge styling', () => {
// Arrange
const props = createDefaultProps()
// Act
const { container } = render(<PreviewItem {...props} />)
// Assert
const indexBadge = container.querySelector('.border.border-gray-200')
expect(indexBadge).toBeInTheDocument()
expect(indexBadge).toHaveClass('rounded-md', 'italic', 'font-medium')
})
it('should have content area with line-clamp', () => {
// Arrange
const props = createDefaultProps()
// Act
const { container } = render(<PreviewItem {...props} />)
// Assert
const contentArea = container.querySelector('.line-clamp-6')
expect(contentArea).toBeInTheDocument()
expect(contentArea).toHaveClass('max-h-[120px]', 'overflow-hidden')
})
it('should have Q/A labels with gray color', () => {
// Arrange
const props = createQAProps()
// Act
const { container } = render(<PreviewItem {...props} />)
// Assert
const labels = container.querySelectorAll('.text-gray-400')
expect(labels.length).toBeGreaterThanOrEqual(2) // Q and A labels
})
})
// ==========================================
// i18n Translation - Test translation integration
// ==========================================
describe('i18n Translation', () => {
it('should use translation key for characters label', () => {
// Arrange
const props = createDefaultProps({ content: 'Test' })
// Act
render(<PreviewItem {...props} />)
// Assert - The mock returns the key as-is
expect(screen.getByText(/datasetCreation.stepTwo.characters/)).toBeInTheDocument()
})
})
})

View File

@ -0,0 +1,84 @@
import React from 'react'
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { Command } from 'cmdk'
import CommandSelector from './command-selector'
import type { ActionItem } from './actions/types'
jest.mock('next/navigation', () => ({
usePathname: () => '/app',
}))
const slashCommandsMock = [{
name: 'zen',
description: 'Zen mode',
mode: 'direct',
isAvailable: () => true,
}]
jest.mock('./actions/commands/registry', () => ({
slashCommandRegistry: {
getAvailableCommands: () => slashCommandsMock,
},
}))
const createActions = (): Record<string, ActionItem> => ({
app: {
key: '@app',
shortcut: '@app',
title: 'Apps',
search: jest.fn(),
description: '',
} as ActionItem,
plugin: {
key: '@plugin',
shortcut: '@plugin',
title: 'Plugins',
search: jest.fn(),
description: '',
} as ActionItem,
})
describe('CommandSelector', () => {
test('should list contextual search actions and notify selection', async () => {
const actions = createActions()
const onSelect = jest.fn()
render(
<Command>
<CommandSelector
actions={actions}
onCommandSelect={onSelect}
searchFilter='app'
originalQuery='@app'
/>
</Command>,
)
const actionButton = screen.getByText('app.gotoAnything.actions.searchApplicationsDesc')
await userEvent.click(actionButton)
expect(onSelect).toHaveBeenCalledWith('@app')
})
test('should render slash commands when query starts with slash', async () => {
const actions = createActions()
const onSelect = jest.fn()
render(
<Command>
<CommandSelector
actions={actions}
onCommandSelect={onSelect}
searchFilter='zen'
originalQuery='/zen'
/>
</Command>,
)
const slashItem = await screen.findByText('app.gotoAnything.actions.zenDesc')
await userEvent.click(slashItem)
expect(onSelect).toHaveBeenCalledWith('/zen')
})
})

View File

@ -0,0 +1,58 @@
import React from 'react'
import { render, screen, waitFor } from '@testing-library/react'
import { GotoAnythingProvider, useGotoAnythingContext } from './context'
let pathnameMock = '/'
jest.mock('next/navigation', () => ({
usePathname: () => pathnameMock,
}))
let isWorkflowPageMock = false
jest.mock('../workflow/constants', () => ({
isInWorkflowPage: () => isWorkflowPageMock,
}))
const ContextConsumer = () => {
const { isWorkflowPage, isRagPipelinePage } = useGotoAnythingContext()
return (
<div data-testid="status">
{String(isWorkflowPage)}|{String(isRagPipelinePage)}
</div>
)
}
describe('GotoAnythingProvider', () => {
beforeEach(() => {
isWorkflowPageMock = false
pathnameMock = '/'
})
test('should set workflow page flag when workflow path detected', async () => {
isWorkflowPageMock = true
pathnameMock = '/app/123/workflow'
render(
<GotoAnythingProvider>
<ContextConsumer />
</GotoAnythingProvider>,
)
await waitFor(() => {
expect(screen.getByTestId('status')).toHaveTextContent('true|false')
})
})
test('should detect RAG pipeline path based on pathname', async () => {
pathnameMock = '/datasets/abc/pipeline'
render(
<GotoAnythingProvider>
<ContextConsumer />
</GotoAnythingProvider>,
)
await waitFor(() => {
expect(screen.getByTestId('status')).toHaveTextContent('false|true')
})
})
})

View File

@ -0,0 +1,173 @@
import React from 'react'
import { act, render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import GotoAnything from './index'
import type { ActionItem, SearchResult } from './actions/types'
const routerPush = jest.fn()
jest.mock('next/navigation', () => ({
useRouter: () => ({
push: routerPush,
}),
usePathname: () => '/',
}))
const keyPressHandlers: Record<string, (event: any) => void> = {}
jest.mock('ahooks', () => ({
useDebounce: (value: any) => value,
useKeyPress: (keys: string | string[], handler: (event: any) => void) => {
const keyList = Array.isArray(keys) ? keys : [keys]
keyList.forEach((key) => {
keyPressHandlers[key] = handler
})
},
}))
const triggerKeyPress = (combo: string) => {
const handler = keyPressHandlers[combo]
if (handler) {
act(() => {
handler({ preventDefault: jest.fn(), target: document.body })
})
}
}
let mockQueryResult = { data: [] as SearchResult[], isLoading: false, isError: false, error: null as Error | null }
jest.mock('@tanstack/react-query', () => ({
useQuery: () => mockQueryResult,
}))
jest.mock('@/context/i18n', () => ({
useGetLanguage: () => 'en_US',
}))
const contextValue = { isWorkflowPage: false, isRagPipelinePage: false }
jest.mock('./context', () => ({
useGotoAnythingContext: () => contextValue,
GotoAnythingProvider: ({ children }: { children: React.ReactNode }) => <>{children}</>,
}))
const createActionItem = (key: ActionItem['key'], shortcut: string): ActionItem => ({
key,
shortcut,
title: `${key} title`,
description: `${key} desc`,
action: jest.fn(),
search: jest.fn(),
})
const actionsMock = {
slash: createActionItem('/', '/'),
app: createActionItem('@app', '@app'),
plugin: createActionItem('@plugin', '@plugin'),
}
const createActionsMock = jest.fn(() => actionsMock)
const matchActionMock = jest.fn(() => undefined)
const searchAnythingMock = jest.fn(async () => mockQueryResult.data)
jest.mock('./actions', () => ({
__esModule: true,
createActions: () => createActionsMock(),
matchAction: () => matchActionMock(),
searchAnything: () => searchAnythingMock(),
}))
jest.mock('./actions/commands', () => ({
SlashCommandProvider: () => null,
}))
jest.mock('./actions/commands/registry', () => ({
slashCommandRegistry: {
findCommand: () => null,
getAvailableCommands: () => [],
getAllCommands: () => [],
},
}))
jest.mock('@/app/components/workflow/utils/common', () => ({
getKeyboardKeyCodeBySystem: () => 'ctrl',
isEventTargetInputArea: () => false,
isMac: () => false,
}))
jest.mock('@/app/components/workflow/utils/node-navigation', () => ({
selectWorkflowNode: jest.fn(),
}))
jest.mock('../plugins/install-plugin/install-from-marketplace', () => (props: { manifest?: { name?: string }, onClose: () => void }) => (
<div data-testid="install-modal">
<span>{props.manifest?.name}</span>
<button onClick={props.onClose}>close</button>
</div>
))
describe('GotoAnything', () => {
beforeEach(() => {
routerPush.mockClear()
Object.keys(keyPressHandlers).forEach(key => delete keyPressHandlers[key])
mockQueryResult = { data: [], isLoading: false, isError: false, error: null }
matchActionMock.mockReset()
searchAnythingMock.mockClear()
})
it('should open modal via shortcut and navigate to selected result', async () => {
mockQueryResult = {
data: [{
id: 'app-1',
type: 'app',
title: 'Sample App',
description: 'desc',
path: '/apps/1',
icon: <div data-testid="icon">🧩</div>,
data: {},
} as any],
isLoading: false,
isError: false,
error: null,
}
render(<GotoAnything />)
triggerKeyPress('ctrl.k')
const input = await screen.findByPlaceholderText('app.gotoAnything.searchPlaceholder')
await userEvent.type(input, 'app')
const result = await screen.findByText('Sample App')
await userEvent.click(result)
expect(routerPush).toHaveBeenCalledWith('/apps/1')
})
it('should open plugin installer when selecting plugin result', async () => {
mockQueryResult = {
data: [{
id: 'plugin-1',
type: 'plugin',
title: 'Plugin Item',
description: 'desc',
path: '',
icon: <div />,
data: {
name: 'Plugin Item',
latest_package_identifier: 'pkg',
},
} as any],
isLoading: false,
isError: false,
error: null,
}
render(<GotoAnything />)
triggerKeyPress('ctrl.k')
const input = await screen.findByPlaceholderText('app.gotoAnything.searchPlaceholder')
await userEvent.type(input, 'plugin')
const pluginItem = await screen.findByText('Plugin Item')
await userEvent.click(pluginItem)
expect(await screen.findByTestId('install-modal')).toHaveTextContent('Plugin Item')
})
})

View File

@ -4,6 +4,7 @@ import React, { useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { produce } from 'immer'
import type { Emoji, WorkflowToolProviderOutputParameter, WorkflowToolProviderParameter, WorkflowToolProviderRequest } from '../types'
import { buildWorkflowOutputParameters } from './utils'
import cn from '@/utils/classnames'
import Drawer from '@/app/components/base/drawer-plus'
import Input from '@/app/components/base/input'
@ -47,7 +48,9 @@ const WorkflowToolAsModal: FC<Props> = ({
const [name, setName] = useState(payload.name)
const [description, setDescription] = useState(payload.description)
const [parameters, setParameters] = useState<WorkflowToolProviderParameter[]>(payload.parameters)
const outputParameters = useMemo<WorkflowToolProviderOutputParameter[]>(() => payload.outputParameters, [payload.outputParameters])
const rawOutputParameters = payload.outputParameters
const outputSchema = payload.tool?.output_schema
const outputParameters = useMemo<WorkflowToolProviderOutputParameter[]>(() => buildWorkflowOutputParameters(rawOutputParameters, outputSchema), [rawOutputParameters, outputSchema])
const reservedOutputParameters: WorkflowToolProviderOutputParameter[] = [
{
name: 'text',

View File

@ -0,0 +1,47 @@
import { VarType } from '@/app/components/workflow/types'
import type { WorkflowToolProviderOutputParameter, WorkflowToolProviderOutputSchema } from '../types'
import { buildWorkflowOutputParameters } from './utils'
describe('buildWorkflowOutputParameters', () => {
it('returns provided output parameters when array input exists', () => {
const params: WorkflowToolProviderOutputParameter[] = [
{ name: 'text', description: 'final text', type: VarType.string },
]
const result = buildWorkflowOutputParameters(params, null)
expect(result).toBe(params)
})
it('derives parameters from schema when explicit array missing', () => {
const schema: WorkflowToolProviderOutputSchema = {
type: 'object',
properties: {
answer: {
type: VarType.string,
description: 'AI answer',
},
attachments: {
type: VarType.arrayFile,
description: 'Supporting files',
},
unknown: {
type: 'custom',
description: 'Unsupported type',
},
},
}
const result = buildWorkflowOutputParameters(undefined, schema)
expect(result).toEqual([
{ name: 'answer', description: 'AI answer', type: VarType.string },
{ name: 'attachments', description: 'Supporting files', type: VarType.arrayFile },
{ name: 'unknown', description: 'Unsupported type', type: undefined },
])
})
it('returns empty array when no source information is provided', () => {
expect(buildWorkflowOutputParameters(null, null)).toEqual([])
})
})

View File

@ -0,0 +1,28 @@
import type { WorkflowToolProviderOutputParameter, WorkflowToolProviderOutputSchema } from '../types'
import { VarType } from '@/app/components/workflow/types'
const validVarTypes = new Set<string>(Object.values(VarType))
const normalizeVarType = (type?: string): VarType | undefined => {
if (!type)
return undefined
return validVarTypes.has(type) ? type as VarType : undefined
}
export const buildWorkflowOutputParameters = (
outputParameters: WorkflowToolProviderOutputParameter[] | null | undefined,
outputSchema?: WorkflowToolProviderOutputSchema | null,
): WorkflowToolProviderOutputParameter[] => {
if (Array.isArray(outputParameters))
return outputParameters
if (!outputSchema?.properties)
return []
return Object.entries(outputSchema.properties).map(([name, schema]) => ({
name,
description: schema.description,
type: normalizeVarType(schema.type),
}))
}

View File

@ -39,7 +39,7 @@ describe('ChatVariableTrigger', () => {
render(<ChatVariableTrigger />)
// Assert
expect(screen.queryByTestId('chat-variable-button')).not.toBeInTheDocument()
expect(screen.queryByRole('button', { name: 'ChatVariableButton' })).not.toBeInTheDocument()
})
})
@ -54,7 +54,7 @@ describe('ChatVariableTrigger', () => {
render(<ChatVariableTrigger />)
// Assert
expect(screen.getByTestId('chat-variable-button')).toBeEnabled()
expect(screen.getByRole('button', { name: 'ChatVariableButton' })).toBeEnabled()
})
it('should render disabled ChatVariableButton when nodes are read-only', () => {
@ -66,7 +66,7 @@ describe('ChatVariableTrigger', () => {
render(<ChatVariableTrigger />)
// Assert
expect(screen.getByTestId('chat-variable-button')).toBeDisabled()
expect(screen.getByRole('button', { name: 'ChatVariableButton' })).toBeDisabled()
})
})
})

View File

@ -1,6 +1,9 @@
import type { ReactElement } from 'react'
import { render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { Plan } from '@/app/components/billing/type'
import type { AppPublisherProps } from '@/app/components/app/app-publisher'
import { ToastContext } from '@/app/components/base/toast'
import { BlockEnum, InputVarType } from '@/app/components/workflow/types'
import FeaturesTrigger from './features-trigger'
@ -10,7 +13,6 @@ const mockUseNodesReadOnly = jest.fn()
const mockUseChecklist = jest.fn()
const mockUseChecklistBeforePublish = jest.fn()
const mockUseNodesSyncDraft = jest.fn()
const mockUseToastContext = jest.fn()
const mockUseFeatures = jest.fn()
const mockUseProviderContext = jest.fn()
const mockUseNodes = jest.fn()
@ -45,8 +47,6 @@ const mockWorkflowStore = {
setState: mockWorkflowStoreSetState,
}
let capturedAppPublisherProps: Record<string, unknown> | null = null
jest.mock('@/app/components/workflow/hooks', () => ({
__esModule: true,
useChecklist: (...args: unknown[]) => mockUseChecklist(...args),
@ -75,11 +75,6 @@ jest.mock('@/app/components/base/features/hooks', () => ({
useFeatures: (selector: (state: Record<string, unknown>) => unknown) => mockUseFeatures(selector),
}))
jest.mock('@/app/components/base/toast', () => ({
__esModule: true,
useToastContext: () => mockUseToastContext(),
}))
jest.mock('@/context/provider-context', () => ({
__esModule: true,
useProviderContext: () => mockUseProviderContext(),
@ -97,14 +92,33 @@ jest.mock('reactflow', () => ({
jest.mock('@/app/components/app/app-publisher', () => ({
__esModule: true,
default: (props: Record<string, unknown>) => {
capturedAppPublisherProps = props
default: (props: AppPublisherProps) => {
const inputs = props.inputs ?? []
return (
<div
data-testid='app-publisher'
data-disabled={String(Boolean(props.disabled))}
data-publish-disabled={String(Boolean(props.publishDisabled))}
/>
data-start-node-limit-exceeded={String(Boolean(props.startNodeLimitExceeded))}
data-has-trigger-node={String(Boolean(props.hasTriggerNode))}
data-inputs={JSON.stringify(inputs)}
>
<button type="button" onClick={() => { props.onRefreshData?.() }}>
publisher-refresh
</button>
<button type="button" onClick={() => { props.onToggle?.(true) }}>
publisher-toggle-on
</button>
<button type="button" onClick={() => { props.onToggle?.(false) }}>
publisher-toggle-off
</button>
<button type="button" onClick={() => { Promise.resolve(props.onPublish?.()).catch(() => undefined) }}>
publisher-publish
</button>
<button type="button" onClick={() => { Promise.resolve(props.onPublish?.({ title: 'Test title', releaseNotes: 'Test notes' })).catch(() => undefined) }}>
publisher-publish-with-params
</button>
</div>
)
},
}))
@ -147,10 +161,17 @@ const createProviderContext = ({
isFetchedPlan,
})
const renderWithToast = (ui: ReactElement) => {
return render(
<ToastContext.Provider value={{ notify: mockNotify, close: jest.fn() }}>
{ui}
</ToastContext.Provider>,
)
}
describe('FeaturesTrigger', () => {
beforeEach(() => {
jest.clearAllMocks()
capturedAppPublisherProps = null
workflowStoreState = {
showFeaturesPanel: false,
isRestoring: false,
@ -165,7 +186,6 @@ describe('FeaturesTrigger', () => {
mockUseChecklistBeforePublish.mockReturnValue({ handleCheckBeforePublish: mockHandleCheckBeforePublish })
mockHandleCheckBeforePublish.mockResolvedValue(true)
mockUseNodesSyncDraft.mockReturnValue({ handleSyncWorkflowDraft: mockHandleSyncWorkflowDraft })
mockUseToastContext.mockReturnValue({ notify: mockNotify })
mockUseFeatures.mockImplementation((selector: (state: Record<string, unknown>) => unknown) => selector({ features: { file: {} } }))
mockUseProviderContext.mockReturnValue(createProviderContext({}))
mockUseNodes.mockReturnValue([])
@ -182,7 +202,7 @@ describe('FeaturesTrigger', () => {
mockUseIsChatMode.mockReturnValue(false)
// Act
render(<FeaturesTrigger />)
renderWithToast(<FeaturesTrigger />)
// Assert
expect(screen.queryByRole('button', { name: /workflow\.common\.features/i })).not.toBeInTheDocument()
@ -193,7 +213,7 @@ describe('FeaturesTrigger', () => {
mockUseIsChatMode.mockReturnValue(true)
// Act
render(<FeaturesTrigger />)
renderWithToast(<FeaturesTrigger />)
// Assert
expect(screen.getByRole('button', { name: /workflow\.common\.features/i })).toBeInTheDocument()
@ -205,7 +225,7 @@ describe('FeaturesTrigger', () => {
mockUseTheme.mockReturnValue({ theme: 'dark' })
// Act
render(<FeaturesTrigger />)
renderWithToast(<FeaturesTrigger />)
// Assert
expect(screen.getByRole('button', { name: /workflow\.common\.features/i })).toHaveClass('rounded-lg')
@ -220,7 +240,7 @@ describe('FeaturesTrigger', () => {
mockUseIsChatMode.mockReturnValue(true)
mockUseNodesReadOnly.mockReturnValue({ nodesReadOnly: false, getNodesReadOnly: () => false })
render(<FeaturesTrigger />)
renderWithToast(<FeaturesTrigger />)
// Act
await user.click(screen.getByRole('button', { name: /workflow\.common\.features/i }))
@ -242,7 +262,7 @@ describe('FeaturesTrigger', () => {
isRestoring: false,
}
render(<FeaturesTrigger />)
renderWithToast(<FeaturesTrigger />)
// Act
await user.click(screen.getByRole('button', { name: /workflow\.common\.features/i }))
@ -260,10 +280,9 @@ describe('FeaturesTrigger', () => {
mockUseNodes.mockReturnValue([])
// Act
render(<FeaturesTrigger />)
renderWithToast(<FeaturesTrigger />)
// Assert
expect(capturedAppPublisherProps?.disabled).toBe(true)
expect(screen.getByTestId('app-publisher')).toHaveAttribute('data-disabled', 'true')
})
})
@ -280,10 +299,15 @@ describe('FeaturesTrigger', () => {
])
// Act
render(<FeaturesTrigger />)
renderWithToast(<FeaturesTrigger />)
// Assert
const inputs = (capturedAppPublisherProps?.inputs as unknown as Array<{ type?: string; variable?: string }>) || []
const inputs = JSON.parse(screen.getByTestId('app-publisher').getAttribute('data-inputs') ?? '[]') as Array<{
type?: string
variable?: string
required?: boolean
label?: string
}>
expect(inputs).toContainEqual({
type: InputVarType.files,
variable: '__image',
@ -302,51 +326,49 @@ describe('FeaturesTrigger', () => {
])
// Act
render(<FeaturesTrigger />)
renderWithToast(<FeaturesTrigger />)
// Assert
expect(capturedAppPublisherProps?.startNodeLimitExceeded).toBe(true)
expect(capturedAppPublisherProps?.publishDisabled).toBe(true)
expect(capturedAppPublisherProps?.hasTriggerNode).toBe(true)
const publisher = screen.getByTestId('app-publisher')
expect(publisher).toHaveAttribute('data-start-node-limit-exceeded', 'true')
expect(publisher).toHaveAttribute('data-publish-disabled', 'true')
expect(publisher).toHaveAttribute('data-has-trigger-node', 'true')
})
})
// Verifies callbacks wired from AppPublisher to stores and draft syncing.
describe('Callbacks', () => {
it('should set toolPublished when AppPublisher refreshes data', () => {
it('should set toolPublished when AppPublisher refreshes data', async () => {
// Arrange
render(<FeaturesTrigger />)
const refresh = capturedAppPublisherProps?.onRefreshData as unknown as (() => void) | undefined
expect(refresh).toBeDefined()
const user = userEvent.setup()
renderWithToast(<FeaturesTrigger />)
// Act
refresh?.()
await user.click(screen.getByRole('button', { name: 'publisher-refresh' }))
// Assert
expect(mockWorkflowStoreSetState).toHaveBeenCalledWith({ toolPublished: true })
})
it('should sync workflow draft when AppPublisher toggles on', () => {
it('should sync workflow draft when AppPublisher toggles on', async () => {
// Arrange
render(<FeaturesTrigger />)
const onToggle = capturedAppPublisherProps?.onToggle as unknown as ((state: boolean) => void) | undefined
expect(onToggle).toBeDefined()
const user = userEvent.setup()
renderWithToast(<FeaturesTrigger />)
// Act
onToggle?.(true)
await user.click(screen.getByRole('button', { name: 'publisher-toggle-on' }))
// Assert
expect(mockHandleSyncWorkflowDraft).toHaveBeenCalledWith(true)
})
it('should not sync workflow draft when AppPublisher toggles off', () => {
it('should not sync workflow draft when AppPublisher toggles off', async () => {
// Arrange
render(<FeaturesTrigger />)
const onToggle = capturedAppPublisherProps?.onToggle as unknown as ((state: boolean) => void) | undefined
expect(onToggle).toBeDefined()
const user = userEvent.setup()
renderWithToast(<FeaturesTrigger />)
// Act
onToggle?.(false)
await user.click(screen.getByRole('button', { name: 'publisher-toggle-off' }))
// Assert
expect(mockHandleSyncWorkflowDraft).not.toHaveBeenCalled()
@ -357,61 +379,62 @@ describe('FeaturesTrigger', () => {
describe('Publishing', () => {
it('should notify error and reject publish when checklist has warning nodes', async () => {
// Arrange
const user = userEvent.setup()
mockUseChecklist.mockReturnValue([{ id: 'warning' }])
render(<FeaturesTrigger />)
const onPublish = capturedAppPublisherProps?.onPublish as unknown as (() => Promise<void>) | undefined
expect(onPublish).toBeDefined()
renderWithToast(<FeaturesTrigger />)
// Act
await expect(onPublish?.()).rejects.toThrow('Checklist has unresolved items')
await user.click(screen.getByRole('button', { name: 'publisher-publish' }))
// Assert
expect(mockNotify).toHaveBeenCalledWith({ type: 'error', message: 'workflow.panel.checklistTip' })
await waitFor(() => {
expect(mockNotify).toHaveBeenCalledWith({ type: 'error', message: 'workflow.panel.checklistTip' })
})
expect(mockPublishWorkflow).not.toHaveBeenCalled()
})
it('should reject publish when checklist before publish fails', async () => {
// Arrange
const user = userEvent.setup()
mockHandleCheckBeforePublish.mockResolvedValue(false)
render(<FeaturesTrigger />)
const onPublish = capturedAppPublisherProps?.onPublish as unknown as (() => Promise<void>) | undefined
expect(onPublish).toBeDefined()
renderWithToast(<FeaturesTrigger />)
// Act & Assert
await expect(onPublish?.()).rejects.toThrow('Checklist failed')
await user.click(screen.getByRole('button', { name: 'publisher-publish' }))
await waitFor(() => {
expect(mockHandleCheckBeforePublish).toHaveBeenCalled()
})
expect(mockPublishWorkflow).not.toHaveBeenCalled()
})
it('should publish workflow and update related stores when validation passes', async () => {
// Arrange
const user = userEvent.setup()
mockUseNodes.mockReturnValue([
{ id: 'start', data: { type: BlockEnum.Start } },
])
mockUseEdges.mockReturnValue([
{ source: 'start' },
])
render(<FeaturesTrigger />)
const onPublish = capturedAppPublisherProps?.onPublish as unknown as (() => Promise<void>) | undefined
expect(onPublish).toBeDefined()
renderWithToast(<FeaturesTrigger />)
// Act
await onPublish?.()
await user.click(screen.getByRole('button', { name: 'publisher-publish' }))
// Assert
expect(mockPublishWorkflow).toHaveBeenCalledWith({
url: '/apps/app-id/workflows/publish',
title: '',
releaseNotes: '',
})
expect(mockUpdatePublishedWorkflow).toHaveBeenCalledWith('app-id')
expect(mockInvalidateAppTriggers).toHaveBeenCalledWith('app-id')
expect(mockSetPublishedAt).toHaveBeenCalledWith('2024-01-01T00:00:00Z')
expect(mockSetLastPublishedHasUserInput).toHaveBeenCalledWith(true)
expect(mockResetWorkflowVersionHistory).toHaveBeenCalled()
expect(mockNotify).toHaveBeenCalledWith({ type: 'success', message: 'common.api.actionSuccess' })
await waitFor(() => {
expect(mockPublishWorkflow).toHaveBeenCalledWith({
url: '/apps/app-id/workflows/publish',
title: '',
releaseNotes: '',
})
expect(mockUpdatePublishedWorkflow).toHaveBeenCalledWith('app-id')
expect(mockInvalidateAppTriggers).toHaveBeenCalledWith('app-id')
expect(mockSetPublishedAt).toHaveBeenCalledWith('2024-01-01T00:00:00Z')
expect(mockSetLastPublishedHasUserInput).toHaveBeenCalledWith(true)
expect(mockResetWorkflowVersionHistory).toHaveBeenCalled()
expect(mockNotify).toHaveBeenCalledWith({ type: 'success', message: 'common.api.actionSuccess' })
expect(mockFetchAppDetail).toHaveBeenCalledWith({ url: '/apps', id: 'app-id' })
expect(mockSetAppDetail).toHaveBeenCalled()
})
@ -419,34 +442,32 @@ describe('FeaturesTrigger', () => {
it('should pass publish params to workflow publish mutation', async () => {
// Arrange
render(<FeaturesTrigger />)
const onPublish = capturedAppPublisherProps?.onPublish as unknown as ((params: { title: string; releaseNotes: string }) => Promise<void>) | undefined
expect(onPublish).toBeDefined()
const user = userEvent.setup()
renderWithToast(<FeaturesTrigger />)
// Act
await onPublish?.({ title: 'Test title', releaseNotes: 'Test notes' })
await user.click(screen.getByRole('button', { name: 'publisher-publish-with-params' }))
// Assert
expect(mockPublishWorkflow).toHaveBeenCalledWith({
url: '/apps/app-id/workflows/publish',
title: 'Test title',
releaseNotes: 'Test notes',
await waitFor(() => {
expect(mockPublishWorkflow).toHaveBeenCalledWith({
url: '/apps/app-id/workflows/publish',
title: 'Test title',
releaseNotes: 'Test notes',
})
})
})
it('should log error when app detail refresh fails after publish', async () => {
// Arrange
const user = userEvent.setup()
const consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation(() => undefined)
mockFetchAppDetail.mockRejectedValue(new Error('fetch failed'))
render(<FeaturesTrigger />)
const onPublish = capturedAppPublisherProps?.onPublish as unknown as (() => Promise<void>) | undefined
expect(onPublish).toBeDefined()
renderWithToast(<FeaturesTrigger />)
// Act
await onPublish?.()
await user.click(screen.getByRole('button', { name: 'publisher-publish' }))
// Assert
await waitFor(() => {

View File

@ -1,16 +1,14 @@
import { render } from '@testing-library/react'
import { render, screen } from '@testing-library/react'
import type { App } from '@/types/app'
import { AppModeEnum } from '@/types/app'
import type { HeaderProps } from '@/app/components/workflow/header'
import WorkflowHeader from './index'
import { fetchWorkflowRunHistory } from '@/service/workflow'
const mockUseAppStoreSelector = jest.fn()
const mockSetCurrentLogItem = jest.fn()
const mockSetShowMessageLogModal = jest.fn()
const mockResetWorkflowVersionHistory = jest.fn()
let capturedHeaderProps: HeaderProps | null = null
let appDetail: App
jest.mock('ky', () => ({
@ -39,8 +37,31 @@ jest.mock('@/app/components/app/store', () => ({
jest.mock('@/app/components/workflow/header', () => ({
__esModule: true,
default: (props: HeaderProps) => {
capturedHeaderProps = props
return <div data-testid='workflow-header' />
const historyFetcher = props.normal?.runAndHistoryProps?.viewHistoryProps?.historyFetcher
const hasHistoryFetcher = typeof historyFetcher === 'function'
return (
<div
data-testid='workflow-header'
data-show-run={String(Boolean(props.normal?.runAndHistoryProps?.showRunButton))}
data-show-preview={String(Boolean(props.normal?.runAndHistoryProps?.showPreviewButton))}
data-history-url={props.normal?.runAndHistoryProps?.viewHistoryProps?.historyUrl ?? ''}
data-has-history-fetcher={String(hasHistoryFetcher)}
>
<button
type="button"
onClick={() => props.normal?.runAndHistoryProps?.viewHistoryProps?.onClearLogAndMessageModal?.()}
>
clear-history
</button>
<button
type="button"
onClick={() => props.restoring?.onRestoreSettled?.()}
>
restore-settled
</button>
</div>
)
},
}))
@ -57,7 +78,6 @@ jest.mock('@/service/use-workflow', () => ({
describe('WorkflowHeader', () => {
beforeEach(() => {
jest.clearAllMocks()
capturedHeaderProps = null
appDetail = { id: 'app-id', mode: AppModeEnum.COMPLETION } as unknown as App
mockUseAppStoreSelector.mockImplementation(selector => selector({
@ -74,7 +94,7 @@ describe('WorkflowHeader', () => {
render(<WorkflowHeader />)
// Assert
expect(capturedHeaderProps).not.toBeNull()
expect(screen.getByTestId('workflow-header')).toBeInTheDocument()
})
})
@ -93,10 +113,11 @@ describe('WorkflowHeader', () => {
render(<WorkflowHeader />)
// Assert
expect(capturedHeaderProps?.normal?.runAndHistoryProps?.showRunButton).toBe(false)
expect(capturedHeaderProps?.normal?.runAndHistoryProps?.showPreviewButton).toBe(true)
expect(capturedHeaderProps?.normal?.runAndHistoryProps?.viewHistoryProps?.historyUrl).toBe('/apps/app-id/advanced-chat/workflow-runs')
expect(capturedHeaderProps?.normal?.runAndHistoryProps?.viewHistoryProps?.historyFetcher).toBe(fetchWorkflowRunHistory)
const header = screen.getByTestId('workflow-header')
expect(header).toHaveAttribute('data-show-run', 'false')
expect(header).toHaveAttribute('data-show-preview', 'true')
expect(header).toHaveAttribute('data-history-url', '/apps/app-id/advanced-chat/workflow-runs')
expect(header).toHaveAttribute('data-has-history-fetcher', 'true')
})
it('should configure run mode when app is not in advanced chat mode', () => {
@ -112,9 +133,11 @@ describe('WorkflowHeader', () => {
render(<WorkflowHeader />)
// Assert
expect(capturedHeaderProps?.normal?.runAndHistoryProps?.showRunButton).toBe(true)
expect(capturedHeaderProps?.normal?.runAndHistoryProps?.showPreviewButton).toBe(false)
expect(capturedHeaderProps?.normal?.runAndHistoryProps?.viewHistoryProps?.historyUrl).toBe('/apps/app-id/workflow-runs')
const header = screen.getByTestId('workflow-header')
expect(header).toHaveAttribute('data-show-run', 'true')
expect(header).toHaveAttribute('data-show-preview', 'false')
expect(header).toHaveAttribute('data-history-url', '/apps/app-id/workflow-runs')
expect(header).toHaveAttribute('data-has-history-fetcher', 'true')
})
})
@ -124,11 +147,8 @@ describe('WorkflowHeader', () => {
// Arrange
render(<WorkflowHeader />)
const clear = capturedHeaderProps?.normal?.runAndHistoryProps?.viewHistoryProps?.onClearLogAndMessageModal
expect(clear).toBeDefined()
// Act
clear?.()
screen.getByRole('button', { name: 'clear-history' }).click()
// Assert
expect(mockSetCurrentLogItem).toHaveBeenCalledWith()
@ -143,7 +163,8 @@ describe('WorkflowHeader', () => {
render(<WorkflowHeader />)
// Assert
expect(capturedHeaderProps?.restoring?.onRestoreSettled).toBe(mockResetWorkflowVersionHistory)
screen.getByRole('button', { name: 'restore-settled' }).click()
expect(mockResetWorkflowVersionHistory).toHaveBeenCalled()
})
})
})

View File

@ -5,6 +5,7 @@
@import '../../themes/dark.css';
@import "../../themes/manual-light.css";
@import "../../themes/manual-dark.css";
@import "./monaco-sticky-fix.css";
@import "../components/base/button/index.css";
@import "../components/base/action-button/index.css";

View File

@ -0,0 +1,16 @@
/* Ensures Monaco sticky header and other sticky headers remain visible in dark mode */
html[data-theme="dark"] .monaco-editor .sticky-widget {
background-color: var(--color-components-sticky-header-bg) !important;
border-bottom: 1px solid var(--color-components-sticky-header-border) !important;
box-shadow: var(--vscode-editorStickyScroll-shadow) 0 4px 2px -2px !important;
}
html[data-theme="dark"] .monaco-editor .sticky-line-content:hover {
background-color: var(--color-components-sticky-header-bg-hover) !important;
}
/* Fallback: any app sticky header using input-bg variables should use the sticky header bg when sticky */
html[data-theme="dark"] .sticky, html[data-theme="dark"] .is-sticky {
background-color: var(--color-components-sticky-header-bg) !important;
border-bottom: 1px solid var(--color-components-sticky-header-border) !important;
}

View File

@ -1,9 +1,31 @@
import '@testing-library/jest-dom'
import { cleanup } from '@testing-library/react'
import { mockAnimationsApi } from 'jsdom-testing-mocks'
// Mock Web Animations API for Headless UI
mockAnimationsApi()
// Suppress act() warnings from @headlessui/react internal Transition component
// These warnings are caused by Headless UI's internal async state updates, not our code
const originalConsoleError = console.error
console.error = (...args: unknown[]) => {
// Check all arguments for the Headless UI TransitionRootFn act warning
const fullMessage = args.map(arg => (typeof arg === 'string' ? arg : '')).join(' ')
if (fullMessage.includes('TransitionRootFn') && fullMessage.includes('not wrapped in act'))
return
originalConsoleError.apply(console, args)
}
// Fix for @headlessui/react compatibility with happy-dom
// headlessui tries to override focus properties which may be read-only in happy-dom
if (typeof window !== 'undefined') {
// Provide a minimal animations API polyfill before @headlessui/react boots
if (typeof Element !== 'undefined' && !Element.prototype.getAnimations)
Element.prototype.getAnimations = () => []
if (!document.getAnimations)
document.getAnimations = () => []
const ensureWritable = (target: object, prop: string) => {
const descriptor = Object.getOwnPropertyDescriptor(target, prop)
if (descriptor && !descriptor.writable) {

View File

@ -201,6 +201,7 @@
"globals": "^15.15.0",
"husky": "^9.1.7",
"jest": "^29.7.0",
"jsdom-testing-mocks": "^1.16.0",
"knip": "^5.66.1",
"lint-staged": "^15.5.2",
"lodash": "^4.17.21",

View File

@ -515,6 +515,9 @@ importers:
jest:
specifier: ^29.7.0
version: 29.7.0(@types/node@18.15.0)(ts-node@10.9.2(@types/node@18.15.0)(typescript@5.9.3))
jsdom-testing-mocks:
specifier: ^1.16.0
version: 1.16.0
knip:
specifier: ^5.66.1
version: 5.72.0(@types/node@18.15.0)(typescript@5.9.3)
@ -4190,6 +4193,9 @@ packages:
resolution: {integrity: sha512-aVNobHnJqLiUelTaHat9DZ1qM2w0C0Eym4LPI/3JxOnSokGVdsl1T1kN7TFvsEAD8G47A6VKQ0TVHqbBnYMJlQ==}
engines: {node: '>=12.0.0'}
bezier-easing@2.1.0:
resolution: {integrity: sha512-gbIqZ/eslnUFC1tjEvtz0sgx+xTK20wDnYMIA27VA04R7w6xxXQPZDbibjA9DTWZRA2CXtwHykkVzlCaAJAZig==}
big.js@5.2.2:
resolution: {integrity: sha512-vyL2OymJxmarO8gxMr0mhChsO9QGwhynfuu4+MHTAW6czfq9humCB7rKpUjDd9YUiDPU4mzpyupFSvOClAwbmQ==}
@ -4660,6 +4666,9 @@ packages:
webpack:
optional: true
css-mediaquery@0.1.2:
resolution: {integrity: sha512-COtn4EROW5dBGlE/4PiKnh6rZpAPxDeFLaEEwt4i10jpDMFt2EhQGS79QmmrO+iKCHv0PU/HrOWEhijFd1x99Q==}
css-select@4.3.0:
resolution: {integrity: sha512-wPpOYtnsVontu2mODhA19JrqWxNsfdatRKd64kmpRbQgh1KtItko5sTnEpPdpSaJszTOhEMlF/RPz28qj4HqhQ==}
@ -6317,6 +6326,10 @@ packages:
resolution: {integrity: sha512-F9GQ+F1ZU6qvSrZV8fNFpjDNf614YzR2eF6S0+XbDjAcUI28FSoXnYZFjQmb1kFx3rrJb5PnxUH3/Yti6fcM+g==}
engines: {node: '>=12.0.0'}
jsdom-testing-mocks@1.16.0:
resolution: {integrity: sha512-wLrulXiLpjmcUYOYGEvz4XARkrmdVpyxzdBl9IAMbQ+ib2/UhUTRCn49McdNfXLff2ysGBUms49ZKX0LR1Q0gg==}
engines: {node: '>=14'}
jsesc@3.0.2:
resolution: {integrity: sha512-xKqzzWXDttJuOcawBt4KnKHHIf5oQ/Cxax+0PWFG+DFDgHNAdi+TXECADI+RYiFUMmx8792xsMbbgXj4CwnP4g==}
engines: {node: '>=6'}
@ -13070,6 +13083,8 @@ snapshots:
dependencies:
open: 8.4.2
bezier-easing@2.1.0: {}
big.js@5.2.2: {}
binary-extensions@2.3.0: {}
@ -13577,6 +13592,8 @@ snapshots:
optionalDependencies:
webpack: 5.103.0(esbuild@0.25.0)(uglify-js@3.19.3)
css-mediaquery@0.1.2: {}
css-select@4.3.0:
dependencies:
boolbase: 1.0.0
@ -15682,6 +15699,11 @@ snapshots:
jsdoc-type-pratt-parser@5.4.0: {}
jsdom-testing-mocks@1.16.0:
dependencies:
bezier-easing: 2.1.0
css-mediaquery: 0.1.2
jsesc@3.0.2: {}
jsesc@3.1.0: {}

View File

@ -6,6 +6,18 @@ html[data-theme="dark"] {
--color-components-input-bg-active: rgb(255 255 255 / 0.05);
--color-components-input-border-active: #747481;
--color-components-input-border-destructive: #f97066;
/* Sticky header / Monaco editor sticky scroll colors (dark mode) */
/* Use solid panel background to ensure visibility when elements become sticky */
--color-components-sticky-header-bg: var(--color-components-panel-bg);
--color-components-sticky-header-bg-hover: var(--color-components-panel-on-panel-item-bg-hover);
--color-components-sticky-header-border: var(--color-components-panel-border);
/* Override Monaco/VSCode CSS variables for sticky scroll so the sticky header is opaque */
--vscode-editorStickyScroll-background: var(--color-components-sticky-header-bg);
--vscode-editorStickyScrollHover-background: var(--color-components-sticky-header-bg-hover);
--vscode-editorStickyScroll-border: var(--color-components-sticky-header-border);
--vscode-editorStickyScroll-shadow: rgba(0, 0, 0, 0.6);
--color-components-input-text-filled: #f4f4f5;
--color-components-input-bg-destructive: rgb(255 255 255 / 0.01);
--color-components-input-bg-disabled: rgb(255 255 255 / 0.03);