Merge remote-tracking branch 'myori/main' into p332

This commit is contained in:
hjlarry 2025-12-18 15:17:58 +08:00
commit 65453fb7e4
91 changed files with 18263 additions and 224 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

6
.github/CODEOWNERS vendored
View File

@ -6,6 +6,12 @@
* @crazywoola @laipz8200 @Yeuoly
# CODEOWNERS file
.github/CODEOWNERS @laipz8200 @crazywoola
# Docs
docs/ @crazywoola
# Backend (default owner, more specific rules below will override)
api/ @QuantumGhost

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,166 @@ 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 totals = {
lines: { covered: 0, total: 0 },
statements: { covered: 0, total: 0 },
branches: { covered: 0, total: 0 },
functions: { covered: 0, total: 0 },
};
const fileSummaries = [];
if (hasSummary) {
const summary = JSON.parse(fs.readFileSync(summaryPath, 'utf8'));
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 (hasFinal) {
const coverage = JSON.parse(fs.readFileSync(finalPath, 'utf8'));
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>');
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

@ -696,3 +696,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")
@ -1289,6 +1289,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,

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

@ -18,34 +18,20 @@ This module provides the interface for invoking and authenticating various model
- Model provider display
![image-20231210143654461](./docs/en_US/images/index/image-20231210143654461.png)
Displays a list of all supported providers, including provider names, icons, supported model types list, predefined model list, configuration method, and credentials form rules, etc. For detailed rule design, see: [Schema](./docs/en_US/schema.md).
Displays a list of all supported providers, including provider names, icons, supported model types list, predefined model list, configuration method, and credentials form rules, etc.
- Selectable model list display
![image-20231210144229650](./docs/en_US/images/index/image-20231210144229650.png)
After configuring provider/model credentials, the dropdown (application orchestration interface/default model) allows viewing of the available LLM list. Greyed out items represent predefined model lists from providers without configured credentials, facilitating user review of supported models.
In addition, this list also returns configurable parameter information and rules for LLM, as shown below:
![image-20231210144814617](./docs/en_US/images/index/image-20231210144814617.png)
These parameters are all defined in the backend, allowing different settings for various parameters supported by different models, as detailed in: [Schema](./docs/en_US/schema.md#ParameterRule).
In addition, this list also returns configurable parameter information and rules for LLM. These parameters are all defined in the backend, allowing different settings for various parameters supported by different models.
- Provider/model credential authentication
![image-20231210151548521](./docs/en_US/images/index/image-20231210151548521.png)
![image-20231210151628992](./docs/en_US/images/index/image-20231210151628992.png)
The provider list returns configuration information for the credentials form, which can be authenticated through Runtime's interface. The first image above is a provider credential DEMO, and the second is a model credential DEMO.
The provider list returns configuration information for the credentials form, which can be authenticated through Runtime's interface.
## Structure
![](./docs/en_US/images/index/image-20231210165243632.png)
Model Runtime is divided into three layers:
- The outermost layer is the factory method
@ -60,9 +46,6 @@ Model Runtime is divided into three layers:
It offers direct invocation of various model types, predefined model configuration information, getting predefined/remote model lists, model credential authentication methods. Different models provide additional special methods, like LLM's pre-computed tokens method, cost information obtaining method, etc., **allowing horizontal expansion** for different models under the same provider (within supported model types).
## Next Steps
## Documentation
- Add new provider configuration: [Link](./docs/en_US/provider_scale_out.md)
- Add new models for existing providers: [Link](./docs/en_US/provider_scale_out.md#AddModel)
- View YAML configuration rules: [Link](./docs/en_US/schema.md)
- Implement interface methods: [Link](./docs/en_US/interfaces.md)
For detailed documentation on how to add new providers or models, please refer to the [Dify documentation](https://docs.dify.ai/).

View File

@ -18,34 +18,20 @@
- 模型供应商展示
![image-20231210143654461](./docs/zh_Hans/images/index/image-20231210143654461.png)
展示所有已支持的供应商列表,除了返回供应商名称、图标之外,还提供了支持的模型类型列表,预定义模型列表、配置方式以及配置凭据的表单规则等等,规则设计详见:[Schema](./docs/zh_Hans/schema.md)。
展示所有已支持的供应商列表,除了返回供应商名称、图标之外,还提供了支持的模型类型列表,预定义模型列表、配置方式以及配置凭据的表单规则等等。
- 可选择的模型列表展示
![image-20231210144229650](./docs/zh_Hans/images/index/image-20231210144229650.png)
配置供应商/模型凭据后,可在此下拉(应用编排界面/默认模型)查看可用的 LLM 列表,其中灰色的为未配置凭据供应商的预定义模型列表,方便用户查看已支持的模型。
配置供应商/模型凭据后,可在此下拉(应用编排界面/默认模型)查看可用的 LLM 列表,其中灰色的为未配置凭据供应商的预定义模型列表,方便用户查看已支持的模型。
除此之外,该列表还返回了 LLM 可配置的参数信息和规则,如下图:
![image-20231210144814617](./docs/zh_Hans/images/index/image-20231210144814617.png)
这里的参数均为后端定义,相比之前只有 5 种固定参数,这里可为不同模型设置所支持的各种参数,详见:[Schema](./docs/zh_Hans/schema.md#ParameterRule)。
除此之外,该列表还返回了 LLM 可配置的参数信息和规则。这里的参数均为后端定义,相比之前只有 5 种固定参数,这里可为不同模型设置所支持的各种参数。
- 供应商/模型凭据鉴权
![image-20231210151548521](./docs/zh_Hans/images/index/image-20231210151548521.png)
![image-20231210151628992](./docs/zh_Hans/images/index/image-20231210151628992.png)
供应商列表返回了凭据表单的配置信息,可通过 Runtime 提供的接口对凭据进行鉴权,上图 1 为供应商凭据 DEMO上图 2 为模型凭据 DEMO。
供应商列表返回了凭据表单的配置信息,可通过 Runtime 提供的接口对凭据进行鉴权。
## 结构
![](./docs/zh_Hans/images/index/image-20231210165243632.png)
Model Runtime 分三层:
- 最外层为工厂方法
@ -59,8 +45,7 @@ Model Runtime 分三层:
对于供应商/模型凭据,有两种情况
- 如 OpenAI 这类中心化供应商,需要定义如**api_key**这类的鉴权凭据
- 如[**Xinference**](https://github.com/xorbitsai/inference)这类本地部署的供应商,需要定义如**server_url**这类的地址凭据,有时候还需要定义**model_uid**之类的模型类型凭据,就像下面这样,当在供应商层定义了这些凭据后,就可以在前端页面上直接展示,无需修改前端逻辑。
![Alt text](docs/zh_Hans/images/index/image.png)
- 如[**Xinference**](https://github.com/xorbitsai/inference)这类本地部署的供应商,需要定义如**server_url**这类的地址凭据,有时候还需要定义**model_uid**之类的模型类型凭据。当在供应商层定义了这些凭据后,就可以在前端页面上直接展示,无需修改前端逻辑。
当配置好凭据后,就可以通过 DifyRuntime 的外部接口直接获取到对应供应商所需要的**Schema**(凭据表单规则),从而在可以在不修改前端逻辑的情况下,提供新的供应商/模型的支持。
@ -74,20 +59,6 @@ Model Runtime 分三层:
- 模型凭据 (**在供应商层定义**):这是一类不经常变动,一般在配置好后就不会再变动的参数,如 **api_key**、**server_url** 等。在 DifyRuntime 中,他们的参数名一般为**credentials: dict[str, any]**Provider 层的 credentials 会直接被传递到这一层,不需要再单独定义。
## 下一步
## 文档
### [增加新的供应商配置 👈🏻](./docs/zh_Hans/provider_scale_out.md)
当添加后,这里将会出现一个新的供应商
![Alt text](docs/zh_Hans/images/index/image-1.png)
### [为已存在的供应商新增模型 👈🏻](./docs/zh_Hans/provider_scale_out.md#%E5%A2%9E%E5%8A%A0%E6%A8%A1%E5%9E%8B)
当添加后,对应供应商的模型列表中将会出现一个新的预定义模型供用户选择,如 GPT-3.5 GPT-4 ChatGLM3-6b 等,而对于支持自定义模型的供应商,则不需要新增模型。
![Alt text](docs/zh_Hans/images/index/image-2.png)
### [接口的具体实现 👈🏻](./docs/zh_Hans/interfaces.md)
你可以在这里找到你想要查看的接口的具体实现,以及接口的参数和返回值的具体含义。
有关如何添加新供应商或模型的详细文档,请参阅 [Dify 文档](https://docs.dify.ai/)。

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

@ -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

@ -87,15 +87,16 @@ class OpenDALStorage(BaseStorage):
if not self.exists(path):
raise FileNotFoundError("Path not found")
all_files = self.op.scan(path=path)
# Use the new OpenDAL 0.46.0+ API with recursive listing
lister = self.op.list(path, recursive=True)
if files and directories:
logger.debug("files and directories on %s scanned", path)
return [f.path for f in all_files]
return [entry.path for entry in lister]
if files:
logger.debug("files on %s scanned", path)
return [f.path for f in all_files if not f.path.endswith("/")]
return [entry.path for entry in lister if not entry.metadata.is_dir]
elif directories:
logger.debug("directories on %s scanned", path)
return [f.path for f in all_files if f.path.endswith("/")]
return [entry.path for entry in lister if entry.metadata.is_dir]
else:
raise ValueError("At least one of files or directories must be True")

View File

@ -12,7 +12,7 @@ dependencies = [
"bs4~=0.0.1",
"cachetools~=5.3.0",
"celery~=5.5.2",
"chardet~=5.1.0",
"charset-normalizer>=3.4.4",
"flask~=3.1.2",
"flask-compress>=1.17,<1.18",
"flask-cors~=6.0.0",
@ -32,6 +32,7 @@ dependencies = [
"httpx[socks]~=0.27.0",
"jieba==0.42.1",
"json-repair>=0.41.1",
"jsonschema>=4.25.1",
"langfuse~=2.51.3",
"langsmith~=0.1.77",
"markdown~=3.5.1",

View File

@ -4,8 +4,9 @@ from collections.abc import Sequence
from typing import Literal
import httpx
from pydantic import BaseModel, ValidationError
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
@ -17,8 +18,10 @@ from models import Account, TenantAccountJoin, TenantAccountRole
logger = logging.getLogger(__name__)
class TenantPlanInfo(BaseModel):
plan: CloudPlan
class SubscriptionPlan(TypedDict):
"""Tenant subscriptionplan information."""
plan: str
expiration_date: int
@ -290,3 +293,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

@ -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

@ -1380,7 +1380,7 @@ dependencies = [
{ name = "bs4" },
{ name = "cachetools" },
{ name = "celery" },
{ name = "chardet" },
{ name = "charset-normalizer" },
{ name = "croniter" },
{ name = "flask" },
{ name = "flask-compress" },
@ -1403,6 +1403,7 @@ dependencies = [
{ name = "httpx-sse" },
{ name = "jieba" },
{ name = "json-repair" },
{ name = "jsonschema" },
{ name = "langfuse" },
{ name = "langsmith" },
{ name = "litellm" },
@ -1577,7 +1578,7 @@ requires-dist = [
{ name = "bs4", specifier = "~=0.0.1" },
{ name = "cachetools", specifier = "~=5.3.0" },
{ name = "celery", specifier = "~=5.5.2" },
{ name = "chardet", specifier = "~=5.1.0" },
{ name = "charset-normalizer", specifier = ">=3.4.4" },
{ name = "croniter", specifier = ">=6.0.0" },
{ name = "flask", specifier = "~=3.1.2" },
{ name = "flask-compress", specifier = ">=1.17,<1.18" },
@ -1600,6 +1601,7 @@ requires-dist = [
{ name = "httpx-sse", specifier = "~=0.4.0" },
{ name = "jieba", specifier = "==0.42.1" },
{ name = "json-repair", specifier = ">=0.41.1" },
{ name = "jsonschema", specifier = ">=4.25.1" },
{ name = "langfuse", specifier = "~=2.51.3" },
{ name = "langsmith", specifier = "~=0.1.77" },
{ name = "litellm", specifier = "==1.77.1" },

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=
@ -1485,4 +1488,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}
@ -667,6 +668,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
@ -703,6 +707,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

@ -61,14 +61,14 @@
<p align="center">
<a href="https://trendshift.io/repositories/2152" target="_blank"><img src="https://trendshift.io/api/badge/repositories/2152" alt="langgenius%2Fdify | Trendshift" style="width: 250px; height: 55px;" width="250" height="55"/></a>
</p>
Dify est une plateforme de développement d'applications LLM open source. Son interface intuitive combine un flux de travail d'IA, un pipeline RAG, des capacités d'agent, une gestion de modèles, des fonctionnalités d'observabilité, et plus encore, vous permettant de passer rapidement du prototype à la production. Voici une liste des fonctionnalités principales:
Dify est une plateforme de développement d'applications LLM open source. Sa interface intuitive combine un flux de travail d'IA, un pipeline RAG, des capacités d'agent, une gestion de modèles, des fonctionnalités d'observabilité, et plus encore, vous permettant de passer rapidement du prototype à la production. Voici une liste des fonctionnalités principales:
</br> </br>
**1. Flux de travail** :
Construisez et testez des flux de travail d'IA puissants sur un canevas visuel, en utilisant toutes les fonctionnalités suivantes et plus encore.
**2. Prise en charge complète des modèles** :
Intégration transparente avec des centaines de LLM propriétaires / open source provenant de dizaines de fournisseurs d'inférence et de solutions auto-hébergées, couvrant GPT, Mistral, Llama3, et tous les modèles compatibles avec l'API OpenAI. Une liste complète des fournisseurs de modèles pris en charge se trouve [ici](https://docs.dify.ai/getting-started/readme/model-providers).
Intégration transparente avec des centaines de LLM propriétaires / open source offerts par dizaines de fournisseurs d'inférence et de solutions auto-hébergées, couvrant GPT, Mistral, Llama3, et tous les modèles compatibles avec l'API OpenAI. Une liste complète des fournisseurs de modèles pris en charge se trouve [ici](https://docs.dify.ai/getting-started/readme/model-providers).
![providers-v5](https://github.com/langgenius/dify/assets/13230914/5a17bdbe-097a-4100-8363-40255b70f6e3)
@ -79,7 +79,7 @@ Interface intuitive pour créer des prompts, comparer les performances des modè
Des capacités RAG étendues qui couvrent tout, de l'ingestion de documents à la récupération, avec un support prêt à l'emploi pour l'extraction de texte à partir de PDF, PPT et autres formats de document courants.
**5. Capacités d'agent** :
Vous pouvez définir des agents basés sur l'appel de fonction LLM ou ReAct, et ajouter des outils pré-construits ou personnalisés pour l'agent. Dify fournit plus de 50 outils intégrés pour les agents d'IA, tels que la recherche Google, DALL·E, Stable Diffusion et WolframAlpha.
Vous pouvez définir des agents basés sur l'appel de fonctions LLM ou ReAct, et ajouter des outils pré-construits ou personnalisés pour l'agent. Dify fournit plus de 50 outils intégrés pour les agents d'IA, tels que la recherche Google, DALL·E, Stable Diffusion et WolframAlpha.
**6. LLMOps** :
Surveillez et analysez les journaux d'application et les performances au fil du temps. Vous pouvez continuellement améliorer les prompts, les ensembles de données et les modèles en fonction des données de production et des annotations.

View File

@ -2,12 +2,6 @@ import React from 'react'
import { fireEvent, render, screen } from '@testing-library/react'
import EditItem, { EditItemType } from './index'
jest.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string) => key,
}),
}))
describe('AddAnnotationModal/EditItem', () => {
test('should render query inputs with user avatar and placeholder strings', () => {
render(

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,388 @@
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import AccessControl from './index'
import AccessControlDialog from './access-control-dialog'
import AccessControlItem from './access-control-item'
import AddMemberOrGroupDialog from './add-member-or-group-pop'
import SpecificGroupsOrMembers from './specific-groups-or-members'
import useAccessControlStore from '@/context/access-control-store'
import { useGlobalPublicStore } from '@/context/global-public-context'
import type { AccessControlAccount, AccessControlGroup, Subject } from '@/models/access-control'
import { AccessMode, SubjectType } from '@/models/access-control'
import Toast from '../../base/toast'
import { defaultSystemFeatures } from '@/types/feature'
import type { App } from '@/types/app'
const mockUseAppWhiteListSubjects = jest.fn()
const mockUseSearchForWhiteListCandidates = jest.fn()
const mockMutateAsync = jest.fn()
const mockUseUpdateAccessMode = jest.fn(() => ({
isPending: false,
mutateAsync: mockMutateAsync,
}))
jest.mock('@/context/app-context', () => ({
useSelector: <T,>(selector: (value: { userProfile: { email: string; id?: string; name?: string; avatar?: string; avatar_url?: string; is_password_set?: boolean } }) => T) => selector({
userProfile: {
id: 'current-user',
name: 'Current User',
email: 'member@example.com',
avatar: '',
avatar_url: '',
is_password_set: true,
},
}),
}))
jest.mock('@/service/common', () => ({
fetchCurrentWorkspace: jest.fn(),
fetchLangGeniusVersion: jest.fn(),
fetchUserProfile: jest.fn(),
getSystemFeatures: jest.fn(),
}))
jest.mock('@/service/access-control', () => ({
useAppWhiteListSubjects: (...args: unknown[]) => mockUseAppWhiteListSubjects(...args),
useSearchForWhiteListCandidates: (...args: unknown[]) => mockUseSearchForWhiteListCandidates(...args),
useUpdateAccessMode: () => mockUseUpdateAccessMode(),
}))
jest.mock('@headlessui/react', () => {
const DialogComponent: any = ({ children, className, ...rest }: any) => (
<div role="dialog" className={className} {...rest}>{children}</div>
)
DialogComponent.Panel = ({ children, className, ...rest }: any) => (
<div className={className} {...rest}>{children}</div>
)
const DialogTitle = ({ children, className, ...rest }: any) => (
<div className={className} {...rest}>{children}</div>
)
const DialogDescription = ({ children, className, ...rest }: any) => (
<div className={className} {...rest}>{children}</div>
)
const TransitionChild = ({ children }: any) => (
<>{typeof children === 'function' ? children({}) : children}</>
)
const Transition = ({ show = true, children }: any) => (
show ? <>{typeof children === 'function' ? children({}) : children}</> : null
)
Transition.Child = TransitionChild
return {
Dialog: DialogComponent,
Transition,
DialogTitle,
Description: DialogDescription,
}
})
jest.mock('ahooks', () => {
const actual = jest.requireActual('ahooks')
return {
...actual,
useDebounce: (value: unknown) => value,
}
})
const createGroup = (overrides: Partial<AccessControlGroup> = {}): AccessControlGroup => ({
id: 'group-1',
name: 'Group One',
groupSize: 5,
...overrides,
} as AccessControlGroup)
const createMember = (overrides: Partial<AccessControlAccount> = {}): AccessControlAccount => ({
id: 'member-1',
name: 'Member One',
email: 'member@example.com',
avatar: '',
avatarUrl: '',
...overrides,
} as AccessControlAccount)
const baseGroup = createGroup()
const baseMember = createMember()
const groupSubject: Subject = {
subjectId: baseGroup.id,
subjectType: SubjectType.GROUP,
groupData: baseGroup,
} as Subject
const memberSubject: Subject = {
subjectId: baseMember.id,
subjectType: SubjectType.ACCOUNT,
accountData: baseMember,
} as Subject
const resetAccessControlStore = () => {
useAccessControlStore.setState({
appId: '',
specificGroups: [],
specificMembers: [],
currentMenu: AccessMode.SPECIFIC_GROUPS_MEMBERS,
selectedGroupsForBreadcrumb: [],
})
}
const resetGlobalStore = () => {
useGlobalPublicStore.setState({
systemFeatures: defaultSystemFeatures,
isGlobalPending: false,
})
}
beforeAll(() => {
class MockIntersectionObserver {
observe = jest.fn(() => undefined)
disconnect = jest.fn(() => undefined)
unobserve = jest.fn(() => undefined)
}
// @ts-expect-error jsdom does not implement IntersectionObserver
globalThis.IntersectionObserver = MockIntersectionObserver
})
beforeEach(() => {
jest.clearAllMocks()
resetAccessControlStore()
resetGlobalStore()
mockMutateAsync.mockResolvedValue(undefined)
mockUseUpdateAccessMode.mockReturnValue({
isPending: false,
mutateAsync: mockMutateAsync,
})
mockUseAppWhiteListSubjects.mockReturnValue({
isPending: false,
data: {
groups: [baseGroup],
members: [baseMember],
},
})
mockUseSearchForWhiteListCandidates.mockReturnValue({
isLoading: false,
isFetchingNextPage: false,
fetchNextPage: jest.fn(),
data: { pages: [{ currPage: 1, subjects: [groupSubject, memberSubject], hasMore: false }] },
})
})
// AccessControlItem handles selected vs. unselected styling and click state updates
describe('AccessControlItem', () => {
it('should update current menu when selecting a different access type', () => {
useAccessControlStore.setState({ currentMenu: AccessMode.PUBLIC })
render(
<AccessControlItem type={AccessMode.ORGANIZATION}>
<span>Organization Only</span>
</AccessControlItem>,
)
const option = screen.getByText('Organization Only').parentElement as HTMLElement
expect(option).toHaveClass('cursor-pointer')
fireEvent.click(option)
expect(useAccessControlStore.getState().currentMenu).toBe(AccessMode.ORGANIZATION)
})
it('should render selected styles when the current menu matches the type', () => {
useAccessControlStore.setState({ currentMenu: AccessMode.ORGANIZATION })
render(
<AccessControlItem type={AccessMode.ORGANIZATION}>
<span>Organization Only</span>
</AccessControlItem>,
)
const option = screen.getByText('Organization Only').parentElement as HTMLElement
expect(option.className).toContain('border-[1.5px]')
expect(option.className).not.toContain('cursor-pointer')
})
})
// AccessControlDialog renders a headless UI dialog with a manual close control
describe('AccessControlDialog', () => {
it('should render dialog content when visible', () => {
render(
<AccessControlDialog show className="custom-dialog">
<div>Dialog Content</div>
</AccessControlDialog>,
)
expect(screen.getByRole('dialog')).toBeInTheDocument()
expect(screen.getByText('Dialog Content')).toBeInTheDocument()
})
it('should trigger onClose when clicking the close control', async () => {
const handleClose = jest.fn()
const { container } = render(
<AccessControlDialog show onClose={handleClose}>
<div>Dialog Content</div>
</AccessControlDialog>,
)
const closeButton = container.querySelector('.absolute.right-5.top-5') as HTMLElement
fireEvent.click(closeButton)
await waitFor(() => {
expect(handleClose).toHaveBeenCalledTimes(1)
})
})
})
// SpecificGroupsOrMembers syncs store state with fetched data and supports removals
describe('SpecificGroupsOrMembers', () => {
it('should render collapsed view when not in specific selection mode', () => {
useAccessControlStore.setState({ currentMenu: AccessMode.ORGANIZATION })
render(<SpecificGroupsOrMembers />)
expect(screen.getByText('app.accessControlDialog.accessItems.specific')).toBeInTheDocument()
expect(screen.queryByText(baseGroup.name)).not.toBeInTheDocument()
})
it('should show loading state while pending', async () => {
useAccessControlStore.setState({ appId: 'app-1', currentMenu: AccessMode.SPECIFIC_GROUPS_MEMBERS })
mockUseAppWhiteListSubjects.mockReturnValue({
isPending: true,
data: undefined,
})
const { container } = render(<SpecificGroupsOrMembers />)
await waitFor(() => {
expect(container.querySelector('.spin-animation')).toBeInTheDocument()
})
})
it('should render fetched groups and members and support removal', async () => {
useAccessControlStore.setState({ appId: 'app-1', currentMenu: AccessMode.SPECIFIC_GROUPS_MEMBERS })
render(<SpecificGroupsOrMembers />)
await waitFor(() => {
expect(screen.getByText(baseGroup.name)).toBeInTheDocument()
expect(screen.getByText(baseMember.name)).toBeInTheDocument()
})
const groupItem = screen.getByText(baseGroup.name).closest('div')
const groupRemove = groupItem?.querySelector('.h-4.w-4.cursor-pointer') as HTMLElement
fireEvent.click(groupRemove)
await waitFor(() => {
expect(screen.queryByText(baseGroup.name)).not.toBeInTheDocument()
})
const memberItem = screen.getByText(baseMember.name).closest('div')
const memberRemove = memberItem?.querySelector('.h-4.w-4.cursor-pointer') as HTMLElement
fireEvent.click(memberRemove)
await waitFor(() => {
expect(screen.queryByText(baseMember.name)).not.toBeInTheDocument()
})
})
})
// AddMemberOrGroupDialog renders search results and updates store selections
describe('AddMemberOrGroupDialog', () => {
it('should open search popover and display candidates', async () => {
const user = userEvent.setup()
render(<AddMemberOrGroupDialog />)
await user.click(screen.getByText('common.operation.add'))
expect(screen.getByPlaceholderText('app.accessControlDialog.operateGroupAndMember.searchPlaceholder')).toBeInTheDocument()
expect(screen.getByText(baseGroup.name)).toBeInTheDocument()
expect(screen.getByText(baseMember.name)).toBeInTheDocument()
})
it('should allow selecting members and expanding groups', async () => {
const user = userEvent.setup()
render(<AddMemberOrGroupDialog />)
await user.click(screen.getByText('common.operation.add'))
const expandButton = screen.getByText('app.accessControlDialog.operateGroupAndMember.expand')
await user.click(expandButton)
expect(useAccessControlStore.getState().selectedGroupsForBreadcrumb).toEqual([baseGroup])
const memberLabel = screen.getByText(baseMember.name)
const memberCheckbox = memberLabel.parentElement?.previousElementSibling as HTMLElement
fireEvent.click(memberCheckbox)
expect(useAccessControlStore.getState().specificMembers).toEqual([baseMember])
})
it('should show empty state when no candidates are returned', async () => {
mockUseSearchForWhiteListCandidates.mockReturnValue({
isLoading: false,
isFetchingNextPage: false,
fetchNextPage: jest.fn(),
data: { pages: [] },
})
const user = userEvent.setup()
render(<AddMemberOrGroupDialog />)
await user.click(screen.getByText('common.operation.add'))
expect(screen.getByText('app.accessControlDialog.operateGroupAndMember.noResult')).toBeInTheDocument()
})
})
// AccessControl integrates dialog, selection items, and confirm flow
describe('AccessControl', () => {
it('should initialize menu from app and call update on confirm', async () => {
const onClose = jest.fn()
const onConfirm = jest.fn()
const toastSpy = jest.spyOn(Toast, 'notify').mockReturnValue({})
useAccessControlStore.setState({
specificGroups: [baseGroup],
specificMembers: [baseMember],
})
const app = {
id: 'app-id-1',
access_mode: AccessMode.SPECIFIC_GROUPS_MEMBERS,
} as App
render(
<AccessControl
app={app}
onClose={onClose}
onConfirm={onConfirm}
/>,
)
await waitFor(() => {
expect(useAccessControlStore.getState().currentMenu).toBe(AccessMode.SPECIFIC_GROUPS_MEMBERS)
})
fireEvent.click(screen.getByText('common.operation.confirm'))
await waitFor(() => {
expect(mockMutateAsync).toHaveBeenCalledWith({
appId: app.id,
accessMode: AccessMode.SPECIFIC_GROUPS_MEMBERS,
subjects: [
{ subjectId: baseGroup.id, subjectType: SubjectType.GROUP },
{ subjectId: baseMember.id, subjectType: SubjectType.ACCOUNT },
],
})
expect(toastSpy).toHaveBeenCalled()
expect(onConfirm).toHaveBeenCalled()
})
})
it('should expose the external members tip when SSO is disabled', () => {
const app = {
id: 'app-id-2',
access_mode: AccessMode.PUBLIC,
} as App
render(
<AccessControl
app={app}
onClose={jest.fn()}
/>,
)
expect(screen.getByText('app.accessControlDialog.accessItems.external')).toBeInTheDocument()
expect(screen.getByText('app.accessControlDialog.accessItems.anyone')).toBeInTheDocument()
})
})

View File

@ -32,7 +32,7 @@ export default function AddMemberOrGroupDialog() {
const anchorRef = useRef<HTMLDivElement>(null)
useEffect(() => {
const hasMore = data?.pages?.[0].hasMore ?? false
const hasMore = data?.pages?.[0]?.hasMore ?? false
let observer: IntersectionObserver | undefined
if (anchorRef.current) {
observer = new IntersectionObserver((entries) => {

View File

@ -0,0 +1,106 @@
import React from 'react'
import { act, fireEvent, render, screen } from '@testing-library/react'
import AgentSetting from './index'
import { MAX_ITERATIONS_NUM } from '@/config'
import type { AgentConfig } from '@/models/debug'
jest.mock('ahooks', () => {
const actual = jest.requireActual('ahooks')
return {
...actual,
useClickAway: jest.fn(),
}
})
jest.mock('react-slider', () => (props: { className?: string; min?: number; max?: number; value: number; onChange: (value: number) => void }) => (
<input
type="range"
className={props.className}
min={props.min}
max={props.max}
value={props.value}
onChange={e => props.onChange(Number(e.target.value))}
/>
))
const basePayload = {
enabled: true,
strategy: 'react',
max_iteration: 5,
tools: [],
}
const renderModal = (props?: Partial<React.ComponentProps<typeof AgentSetting>>) => {
const onCancel = jest.fn()
const onSave = jest.fn()
const utils = render(
<AgentSetting
isChatModel
payload={basePayload as AgentConfig}
isFunctionCall={false}
onCancel={onCancel}
onSave={onSave}
{...props}
/>,
)
return { ...utils, onCancel, onSave }
}
describe('AgentSetting', () => {
test('should render agent mode description and default prompt section when not function call', () => {
renderModal()
expect(screen.getByText('appDebug.agent.agentMode')).toBeInTheDocument()
expect(screen.getByText('appDebug.agent.agentModeType.ReACT')).toBeInTheDocument()
expect(screen.getByText('tools.builtInPromptTitle')).toBeInTheDocument()
})
test('should display function call mode when isFunctionCall true', () => {
renderModal({ isFunctionCall: true })
expect(screen.getByText('appDebug.agent.agentModeType.functionCall')).toBeInTheDocument()
expect(screen.queryByText('tools.builtInPromptTitle')).not.toBeInTheDocument()
})
test('should update iteration via slider and number input', () => {
const { container } = renderModal()
const slider = container.querySelector('.slider') as HTMLInputElement
const numberInput = screen.getByRole('spinbutton')
fireEvent.change(slider, { target: { value: '7' } })
expect(screen.getAllByDisplayValue('7')).toHaveLength(2)
fireEvent.change(numberInput, { target: { value: '2' } })
expect(screen.getAllByDisplayValue('2')).toHaveLength(2)
})
test('should clamp iteration value within min/max range', () => {
renderModal()
const numberInput = screen.getByRole('spinbutton')
fireEvent.change(numberInput, { target: { value: '0' } })
expect(screen.getAllByDisplayValue('1')).toHaveLength(2)
fireEvent.change(numberInput, { target: { value: '999' } })
expect(screen.getAllByDisplayValue(String(MAX_ITERATIONS_NUM))).toHaveLength(2)
})
test('should call onCancel when cancel button clicked', () => {
const { onCancel } = renderModal()
fireEvent.click(screen.getByRole('button', { name: 'common.operation.cancel' }))
expect(onCancel).toHaveBeenCalled()
})
test('should call onSave with updated payload', async () => {
const { onSave } = renderModal()
const numberInput = screen.getByRole('spinbutton')
fireEvent.change(numberInput, { target: { value: '6' } })
await act(async () => {
fireEvent.click(screen.getByRole('button', { name: 'common.operation.save' }))
})
expect(onSave).toHaveBeenCalledWith(expect.objectContaining({ max_iteration: 6 }))
})
})

View File

@ -0,0 +1,21 @@
import React from 'react'
import { render, screen } from '@testing-library/react'
import ItemPanel from './item-panel'
describe('AgentSetting/ItemPanel', () => {
test('should render icon, name, and children content', () => {
render(
<ItemPanel
className="custom"
icon={<span>icon</span>}
name="Panel name"
description="More info"
children={<div>child content</div>}
/>,
)
expect(screen.getByText('Panel name')).toBeInTheDocument()
expect(screen.getByText('child content')).toBeInTheDocument()
expect(screen.getByText('icon')).toBeInTheDocument()
})
})

View File

@ -0,0 +1,466 @@
import type {
PropsWithChildren,
} from 'react'
import React, {
useEffect,
useMemo,
useState,
} from 'react'
import { act, render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import AgentTools from './index'
import ConfigContext from '@/context/debug-configuration'
import type { AgentTool } from '@/types/app'
import { CollectionType, type Tool, type ToolParameter } from '@/app/components/tools/types'
import type { ToolWithProvider } from '@/app/components/workflow/types'
import type { ToolDefaultValue } from '@/app/components/workflow/block-selector/types'
import type { ModelConfig } from '@/models/debug'
import { ModelModeType } from '@/types/app'
import {
DEFAULT_AGENT_SETTING,
DEFAULT_CHAT_PROMPT_CONFIG,
DEFAULT_COMPLETION_PROMPT_CONFIG,
} from '@/config'
import copy from 'copy-to-clipboard'
import type ToolPickerType from '@/app/components/workflow/block-selector/tool-picker'
import type SettingBuiltInToolType from './setting-built-in-tool'
const formattingDispatcherMock = jest.fn()
jest.mock('@/app/components/app/configuration/debug/hooks', () => ({
useFormattingChangedDispatcher: () => formattingDispatcherMock,
}))
let pluginInstallHandler: ((names: string[]) => void) | null = null
const subscribeMock = jest.fn((event: string, handler: any) => {
if (event === 'plugin:install:success')
pluginInstallHandler = handler
})
jest.mock('@/context/mitt-context', () => ({
useMittContextSelector: (selector: any) => selector({
useSubscribe: subscribeMock,
}),
}))
let builtInTools: ToolWithProvider[] = []
let customTools: ToolWithProvider[] = []
let workflowTools: ToolWithProvider[] = []
let mcpTools: ToolWithProvider[] = []
jest.mock('@/service/use-tools', () => ({
useAllBuiltInTools: () => ({ data: builtInTools }),
useAllCustomTools: () => ({ data: customTools }),
useAllWorkflowTools: () => ({ data: workflowTools }),
useAllMCPTools: () => ({ data: mcpTools }),
}))
type ToolPickerProps = React.ComponentProps<typeof ToolPickerType>
let singleToolSelection: ToolDefaultValue | null = null
let multipleToolSelection: ToolDefaultValue[] = []
const ToolPickerMock = (props: ToolPickerProps) => (
<div data-testid="tool-picker">
<div>{props.trigger}</div>
<button
type="button"
onClick={() => singleToolSelection && props.onSelect(singleToolSelection)}
>
pick-single
</button>
<button
type="button"
onClick={() => props.onSelectMultiple(multipleToolSelection)}
>
pick-multiple
</button>
</div>
)
jest.mock('@/app/components/workflow/block-selector/tool-picker', () => ({
__esModule: true,
default: (props: ToolPickerProps) => <ToolPickerMock {...props} />,
}))
type SettingBuiltInToolProps = React.ComponentProps<typeof SettingBuiltInToolType>
let latestSettingPanelProps: SettingBuiltInToolProps | null = null
let settingPanelSavePayload: Record<string, any> = {}
let settingPanelCredentialId = 'credential-from-panel'
const SettingBuiltInToolMock = (props: SettingBuiltInToolProps) => {
latestSettingPanelProps = props
return (
<div data-testid="setting-built-in-tool">
<span>{props.toolName}</span>
<button type="button" onClick={() => props.onSave?.(settingPanelSavePayload)}>save-from-panel</button>
<button type="button" onClick={() => props.onAuthorizationItemClick?.(settingPanelCredentialId)}>auth-from-panel</button>
<button type="button" onClick={props.onHide}>close-panel</button>
</div>
)
}
jest.mock('./setting-built-in-tool', () => ({
__esModule: true,
default: (props: SettingBuiltInToolProps) => <SettingBuiltInToolMock {...props} />,
}))
jest.mock('copy-to-clipboard')
const copyMock = copy as jest.Mock
const createToolParameter = (overrides?: Partial<ToolParameter>): ToolParameter => ({
name: 'api_key',
label: {
en_US: 'API Key',
zh_Hans: 'API Key',
},
human_description: {
en_US: 'desc',
zh_Hans: 'desc',
},
type: 'string',
form: 'config',
llm_description: '',
required: true,
multiple: false,
default: 'default',
...overrides,
})
const createToolDefinition = (overrides?: Partial<Tool>): Tool => ({
name: 'search',
author: 'tester',
label: {
en_US: 'Search',
zh_Hans: 'Search',
},
description: {
en_US: 'desc',
zh_Hans: 'desc',
},
parameters: [createToolParameter()],
labels: [],
output_schema: {},
...overrides,
})
const createCollection = (overrides?: Partial<ToolWithProvider>): ToolWithProvider => ({
id: overrides?.id || 'provider-1',
name: overrides?.name || 'vendor/provider-1',
author: 'tester',
description: {
en_US: 'desc',
zh_Hans: 'desc',
},
icon: 'https://example.com/icon.png',
label: {
en_US: 'Provider Label',
zh_Hans: 'Provider Label',
},
type: overrides?.type || CollectionType.builtIn,
team_credentials: {},
is_team_authorization: true,
allow_delete: true,
labels: [],
tools: overrides?.tools || [createToolDefinition()],
meta: {
version: '1.0.0',
},
...overrides,
})
const createAgentTool = (overrides?: Partial<AgentTool>): AgentTool => ({
provider_id: overrides?.provider_id || 'provider-1',
provider_type: overrides?.provider_type || CollectionType.builtIn,
provider_name: overrides?.provider_name || 'vendor/provider-1',
tool_name: overrides?.tool_name || 'search',
tool_label: overrides?.tool_label || 'Search Tool',
tool_parameters: overrides?.tool_parameters || { api_key: 'key' },
enabled: overrides?.enabled ?? true,
...overrides,
})
const createModelConfig = (tools: AgentTool[]): ModelConfig => ({
provider: 'OPENAI',
model_id: 'gpt-3.5-turbo',
mode: ModelModeType.chat,
configs: {
prompt_template: '',
prompt_variables: [],
},
chat_prompt_config: DEFAULT_CHAT_PROMPT_CONFIG,
completion_prompt_config: DEFAULT_COMPLETION_PROMPT_CONFIG,
opening_statement: '',
more_like_this: null,
suggested_questions: [],
suggested_questions_after_answer: null,
speech_to_text: null,
text_to_speech: null,
file_upload: null,
retriever_resource: null,
sensitive_word_avoidance: null,
annotation_reply: null,
external_data_tools: [],
system_parameters: {
audio_file_size_limit: 0,
file_size_limit: 0,
image_file_size_limit: 0,
video_file_size_limit: 0,
workflow_file_upload_limit: 0,
},
dataSets: [],
agentConfig: {
...DEFAULT_AGENT_SETTING,
tools,
},
})
const renderAgentTools = (initialTools?: AgentTool[]) => {
const tools = initialTools ?? [createAgentTool()]
const modelConfigRef = { current: createModelConfig(tools) }
const Wrapper = ({ children }: PropsWithChildren) => {
const [modelConfig, setModelConfig] = useState<ModelConfig>(modelConfigRef.current)
useEffect(() => {
modelConfigRef.current = modelConfig
}, [modelConfig])
const value = useMemo(() => ({
modelConfig,
setModelConfig,
}), [modelConfig])
return (
<ConfigContext.Provider value={value as any}>
{children}
</ConfigContext.Provider>
)
}
const renderResult = render(
<Wrapper>
<AgentTools />
</Wrapper>,
)
return {
...renderResult,
getModelConfig: () => modelConfigRef.current,
}
}
const hoverInfoIcon = async (rowIndex = 0) => {
const rows = document.querySelectorAll('.group')
const infoTrigger = rows.item(rowIndex)?.querySelector('[data-testid="tool-info-tooltip"]')
if (!infoTrigger)
throw new Error('Info trigger not found')
await userEvent.hover(infoTrigger as HTMLElement)
}
describe('AgentTools', () => {
beforeEach(() => {
jest.clearAllMocks()
builtInTools = [
createCollection(),
createCollection({
id: 'provider-2',
name: 'vendor/provider-2',
tools: [createToolDefinition({
name: 'translate',
label: {
en_US: 'Translate',
zh_Hans: 'Translate',
},
})],
}),
createCollection({
id: 'provider-3',
name: 'vendor/provider-3',
tools: [createToolDefinition({
name: 'summarize',
label: {
en_US: 'Summary',
zh_Hans: 'Summary',
},
})],
}),
]
customTools = []
workflowTools = []
mcpTools = []
singleToolSelection = {
provider_id: 'provider-3',
provider_type: CollectionType.builtIn,
provider_name: 'vendor/provider-3',
tool_name: 'summarize',
tool_label: 'Summary Tool',
tool_description: 'desc',
title: 'Summary Tool',
is_team_authorization: true,
params: { api_key: 'picker-value' },
paramSchemas: [],
output_schema: {},
}
multipleToolSelection = [
{
provider_id: 'provider-2',
provider_type: CollectionType.builtIn,
provider_name: 'vendor/provider-2',
tool_name: 'translate',
tool_label: 'Translate Tool',
tool_description: 'desc',
title: 'Translate Tool',
is_team_authorization: true,
params: { api_key: 'multi-a' },
paramSchemas: [],
output_schema: {},
},
{
provider_id: 'provider-3',
provider_type: CollectionType.builtIn,
provider_name: 'vendor/provider-3',
tool_name: 'summarize',
tool_label: 'Summary Tool',
tool_description: 'desc',
title: 'Summary Tool',
is_team_authorization: true,
params: { api_key: 'multi-b' },
paramSchemas: [],
output_schema: {},
},
]
latestSettingPanelProps = null
settingPanelSavePayload = {}
settingPanelCredentialId = 'credential-from-panel'
pluginInstallHandler = null
})
test('should show enabled count and provider information', () => {
renderAgentTools([
createAgentTool(),
createAgentTool({
provider_id: 'provider-2',
provider_name: 'vendor/provider-2',
tool_name: 'translate',
tool_label: 'Translate Tool',
enabled: false,
}),
])
const enabledText = screen.getByText(content => content.includes('appDebug.agent.tools.enabled'))
expect(enabledText).toHaveTextContent('1/2')
expect(screen.getByText('provider-1')).toBeInTheDocument()
expect(screen.getByText('Translate Tool')).toBeInTheDocument()
})
test('should copy tool name from tooltip action', async () => {
renderAgentTools()
await hoverInfoIcon()
const copyButton = await screen.findByText('tools.copyToolName')
await userEvent.click(copyButton)
expect(copyMock).toHaveBeenCalledWith('search')
})
test('should toggle tool enabled state via switch', async () => {
const { getModelConfig } = renderAgentTools()
const switchButton = screen.getByRole('switch')
await userEvent.click(switchButton)
await waitFor(() => {
const tools = getModelConfig().agentConfig.tools as Array<{ tool_name?: string; enabled?: boolean }>
const toggledTool = tools.find(tool => tool.tool_name === 'search')
expect(toggledTool?.enabled).toBe(false)
})
expect(formattingDispatcherMock).toHaveBeenCalled()
})
test('should remove tool when delete action is clicked', async () => {
const { getModelConfig } = renderAgentTools()
const deleteButton = screen.getByTestId('delete-removed-tool')
if (!deleteButton)
throw new Error('Delete button not found')
await userEvent.click(deleteButton)
await waitFor(() => {
expect(getModelConfig().agentConfig.tools).toHaveLength(0)
})
expect(formattingDispatcherMock).toHaveBeenCalled()
})
test('should add a tool when ToolPicker selects one', async () => {
const { getModelConfig } = renderAgentTools([])
const addSingleButton = screen.getByRole('button', { name: 'pick-single' })
await userEvent.click(addSingleButton)
await waitFor(() => {
expect(screen.getByText('Summary Tool')).toBeInTheDocument()
})
expect(getModelConfig().agentConfig.tools).toHaveLength(1)
})
test('should append multiple selected tools at once', async () => {
const { getModelConfig } = renderAgentTools([])
await userEvent.click(screen.getByRole('button', { name: 'pick-multiple' }))
await waitFor(() => {
expect(screen.getByText('Translate Tool')).toBeInTheDocument()
expect(screen.getAllByText('Summary Tool')).toHaveLength(1)
})
expect(getModelConfig().agentConfig.tools).toHaveLength(2)
})
test('should open settings panel for not authorized tool', async () => {
renderAgentTools([
createAgentTool({
notAuthor: true,
}),
])
const notAuthorizedButton = screen.getByRole('button', { name: /tools.notAuthorized/ })
await userEvent.click(notAuthorizedButton)
expect(screen.getByTestId('setting-built-in-tool')).toBeInTheDocument()
expect(latestSettingPanelProps?.toolName).toBe('search')
})
test('should persist tool parameters when SettingBuiltInTool saves values', async () => {
const { getModelConfig } = renderAgentTools([
createAgentTool({
notAuthor: true,
}),
])
await userEvent.click(screen.getByRole('button', { name: /tools.notAuthorized/ }))
settingPanelSavePayload = { api_key: 'updated' }
await userEvent.click(screen.getByRole('button', { name: 'save-from-panel' }))
await waitFor(() => {
expect((getModelConfig().agentConfig.tools[0] as { tool_parameters: Record<string, any> }).tool_parameters).toEqual({ api_key: 'updated' })
})
})
test('should update credential id when authorization selection changes', async () => {
const { getModelConfig } = renderAgentTools([
createAgentTool({
notAuthor: true,
}),
])
await userEvent.click(screen.getByRole('button', { name: /tools.notAuthorized/ }))
settingPanelCredentialId = 'credential-123'
await userEvent.click(screen.getByRole('button', { name: 'auth-from-panel' }))
await waitFor(() => {
expect((getModelConfig().agentConfig.tools[0] as { credential_id: string }).credential_id).toBe('credential-123')
})
expect(formattingDispatcherMock).toHaveBeenCalled()
})
test('should reinstate deleted tools after plugin install success event', async () => {
const { getModelConfig } = renderAgentTools([
createAgentTool({
provider_id: 'provider-1',
provider_name: 'vendor/provider-1',
tool_name: 'search',
tool_label: 'Search Tool',
isDeleted: true,
}),
])
if (!pluginInstallHandler)
throw new Error('Plugin handler not registered')
await act(async () => {
pluginInstallHandler?.(['provider-1'])
})
await waitFor(() => {
expect((getModelConfig().agentConfig.tools[0] as { isDeleted: boolean }).isDeleted).toBe(false)
})
})
})

View File

@ -217,7 +217,7 @@ const AgentTools: FC = () => {
}
>
<div className='h-4 w-4'>
<div className='ml-0.5 hidden group-hover:inline-block'>
<div className='ml-0.5 hidden group-hover:inline-block' data-testid='tool-info-tooltip'>
<RiInformation2Line className='h-4 w-4 text-text-tertiary' />
</div>
</div>
@ -277,6 +277,7 @@ const AgentTools: FC = () => {
}}
onMouseOver={() => setIsDeleting(index)}
onMouseLeave={() => setIsDeleting(-1)}
data-testid='delete-removed-tool'
>
<RiDeleteBinLine className='h-4 w-4' />
</div>

View File

@ -0,0 +1,248 @@
import React from 'react'
import { render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import SettingBuiltInTool from './setting-built-in-tool'
import I18n from '@/context/i18n'
import { CollectionType, type Tool, type ToolParameter } from '@/app/components/tools/types'
const fetchModelToolList = jest.fn()
const fetchBuiltInToolList = jest.fn()
const fetchCustomToolList = jest.fn()
const fetchWorkflowToolList = jest.fn()
jest.mock('@/service/tools', () => ({
fetchModelToolList: (collectionName: string) => fetchModelToolList(collectionName),
fetchBuiltInToolList: (collectionName: string) => fetchBuiltInToolList(collectionName),
fetchCustomToolList: (collectionName: string) => fetchCustomToolList(collectionName),
fetchWorkflowToolList: (appId: string) => fetchWorkflowToolList(appId),
}))
type MockFormProps = {
value: Record<string, any>
onChange: (val: Record<string, any>) => void
}
let nextFormValue: Record<string, any> = {}
const FormMock = ({ value, onChange }: MockFormProps) => {
return (
<div data-testid="mock-form">
<div data-testid="form-value">{JSON.stringify(value)}</div>
<button
type="button"
onClick={() => onChange({ ...value, ...nextFormValue })}
>
update-form
</button>
</div>
)
}
jest.mock('@/app/components/header/account-setting/model-provider-page/model-modal/Form', () => ({
__esModule: true,
default: (props: MockFormProps) => <FormMock {...props} />,
}))
let pluginAuthClickValue = 'credential-from-plugin'
jest.mock('@/app/components/plugins/plugin-auth', () => ({
AuthCategory: { tool: 'tool' },
PluginAuthInAgent: (props: { onAuthorizationItemClick?: (id: string) => void }) => (
<div data-testid="plugin-auth">
<button type="button" onClick={() => props.onAuthorizationItemClick?.(pluginAuthClickValue)}>
choose-plugin-credential
</button>
</div>
),
}))
jest.mock('@/app/components/plugins/readme-panel/entrance', () => ({
ReadmeEntrance: ({ className }: { className?: string }) => <div className={className}>readme</div>,
}))
const createParameter = (overrides?: Partial<ToolParameter>): ToolParameter => ({
name: 'settingParam',
label: {
en_US: 'Setting Param',
zh_Hans: 'Setting Param',
},
human_description: {
en_US: 'desc',
zh_Hans: 'desc',
},
type: 'string',
form: 'config',
llm_description: '',
required: true,
multiple: false,
default: '',
...overrides,
})
const createTool = (overrides?: Partial<Tool>): Tool => ({
name: 'search',
author: 'tester',
label: {
en_US: 'Search Tool',
zh_Hans: 'Search Tool',
},
description: {
en_US: 'tool description',
zh_Hans: 'tool description',
},
parameters: [
createParameter({
name: 'infoParam',
label: {
en_US: 'Info Param',
zh_Hans: 'Info Param',
},
form: 'llm',
required: false,
}),
createParameter(),
],
labels: [],
output_schema: {},
...overrides,
})
const baseCollection = {
id: 'provider-1',
name: 'vendor/provider-1',
author: 'tester',
description: {
en_US: 'desc',
zh_Hans: 'desc',
},
icon: 'https://example.com/icon.png',
label: {
en_US: 'Provider Label',
zh_Hans: 'Provider Label',
},
type: CollectionType.builtIn,
team_credentials: {},
is_team_authorization: true,
allow_delete: true,
labels: [],
tools: [createTool()],
}
const renderComponent = (props?: Partial<React.ComponentProps<typeof SettingBuiltInTool>>) => {
const onHide = jest.fn()
const onSave = jest.fn()
const onAuthorizationItemClick = jest.fn()
const utils = render(
<I18n.Provider value={{ locale: 'en-US', i18n: {}, setLocaleOnClient: jest.fn() as any }}>
<SettingBuiltInTool
collection={baseCollection as any}
toolName="search"
isModel
setting={{ settingParam: 'value' }}
onHide={onHide}
onSave={onSave}
onAuthorizationItemClick={onAuthorizationItemClick}
{...props}
/>
</I18n.Provider>,
)
return {
...utils,
onHide,
onSave,
onAuthorizationItemClick,
}
}
describe('SettingBuiltInTool', () => {
beforeEach(() => {
jest.clearAllMocks()
nextFormValue = {}
pluginAuthClickValue = 'credential-from-plugin'
})
test('should fetch tool list when collection has no tools', async () => {
fetchModelToolList.mockResolvedValueOnce([createTool()])
renderComponent({
collection: {
...baseCollection,
tools: [],
},
})
await waitFor(() => {
expect(fetchModelToolList).toHaveBeenCalledTimes(1)
expect(fetchModelToolList).toHaveBeenCalledWith('vendor/provider-1')
})
expect(await screen.findByText('Search Tool')).toBeInTheDocument()
})
test('should switch between info and setting tabs', async () => {
renderComponent()
await waitFor(() => {
expect(screen.getByTestId('mock-form')).toBeInTheDocument()
})
await userEvent.click(screen.getByText('tools.setBuiltInTools.parameters'))
expect(screen.getByText('Info Param')).toBeInTheDocument()
await userEvent.click(screen.getByText('tools.setBuiltInTools.setting'))
expect(screen.getByTestId('mock-form')).toBeInTheDocument()
})
test('should call onSave with updated values when save button clicked', async () => {
const { onSave } = renderComponent()
await waitFor(() => expect(screen.getByTestId('mock-form')).toBeInTheDocument())
nextFormValue = { settingParam: 'updated' }
await userEvent.click(screen.getByRole('button', { name: 'update-form' }))
await userEvent.click(screen.getByRole('button', { name: 'common.operation.save' }))
expect(onSave).toHaveBeenCalledWith(expect.objectContaining({ settingParam: 'updated' }))
})
test('should keep save disabled until required field provided', async () => {
renderComponent({
setting: {},
})
await waitFor(() => expect(screen.getByTestId('mock-form')).toBeInTheDocument())
const saveButton = screen.getByRole('button', { name: 'common.operation.save' })
expect(saveButton).toBeDisabled()
nextFormValue = { settingParam: 'filled' }
await userEvent.click(screen.getByRole('button', { name: 'update-form' }))
expect(saveButton).not.toBeDisabled()
})
test('should call onHide when cancel button is pressed', async () => {
const { onHide } = renderComponent()
await waitFor(() => expect(screen.getByTestId('mock-form')).toBeInTheDocument())
await userEvent.click(screen.getByRole('button', { name: 'common.operation.cancel' }))
expect(onHide).toHaveBeenCalled()
})
test('should trigger authorization callback from plugin auth section', async () => {
const { onAuthorizationItemClick } = renderComponent()
await userEvent.click(screen.getByRole('button', { name: 'choose-plugin-credential' }))
expect(onAuthorizationItemClick).toHaveBeenCalledWith('credential-from-plugin')
})
test('should call onHide when back button is clicked', async () => {
const { onHide } = renderComponent({
showBackButton: true,
})
await userEvent.click(screen.getByText('plugin.detailPanel.operation.back'))
expect(onHide).toHaveBeenCalled()
})
test('should load workflow tools when workflow collection is provided', async () => {
fetchWorkflowToolList.mockResolvedValueOnce([createTool({
name: 'workflow-tool',
})])
renderComponent({
collection: {
...baseCollection,
type: CollectionType.workflow,
tools: [],
id: 'workflow-1',
} as any,
isBuiltIn: false,
isModel: false,
})
await waitFor(() => {
expect(fetchWorkflowToolList).toHaveBeenCalledWith('workflow-1')
})
})
})

View File

@ -0,0 +1,878 @@
import React from 'react'
import { act, render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import AssistantTypePicker from './index'
import type { AgentConfig } from '@/models/debug'
import { AgentStrategy } from '@/types/app'
// Type definition for AgentSetting props
type AgentSettingProps = {
isChatModel: boolean
payload: AgentConfig
isFunctionCall: boolean
onCancel: () => void
onSave: (payload: AgentConfig) => void
}
// Track mock calls for props validation
let mockAgentSettingProps: AgentSettingProps | null = null
// Mock AgentSetting component (complex modal with external hooks)
jest.mock('../agent/agent-setting', () => {
return function MockAgentSetting(props: AgentSettingProps) {
mockAgentSettingProps = props
return (
<div data-testid="agent-setting-modal">
<button onClick={() => props.onSave({ max_iteration: 5 } as AgentConfig)}>Save</button>
<button onClick={props.onCancel}>Cancel</button>
</div>
)
}
})
// Test utilities
const defaultAgentConfig: AgentConfig = {
enabled: true,
max_iteration: 3,
strategy: AgentStrategy.functionCall,
tools: [],
}
const defaultProps = {
value: 'chat',
disabled: false,
onChange: jest.fn(),
isFunctionCall: true,
isChatModel: true,
agentConfig: defaultAgentConfig,
onAgentSettingChange: jest.fn(),
}
const renderComponent = (props: Partial<React.ComponentProps<typeof AssistantTypePicker>> = {}) => {
const mergedProps = { ...defaultProps, ...props }
return render(<AssistantTypePicker {...mergedProps} />)
}
// Helper to get option element by description (which is unique per option)
const getOptionByDescription = (descriptionRegex: RegExp) => {
const description = screen.getByText(descriptionRegex)
return description.parentElement as HTMLElement
}
describe('AssistantTypePicker', () => {
beforeEach(() => {
jest.clearAllMocks()
mockAgentSettingProps = null
})
// Rendering tests (REQUIRED)
describe('Rendering', () => {
it('should render without crashing', () => {
// Arrange & Act
renderComponent()
// Assert
expect(screen.getByText(/chatAssistant.name/i)).toBeInTheDocument()
})
it('should render chat assistant by default when value is "chat"', () => {
// Arrange & Act
renderComponent({ value: 'chat' })
// Assert
expect(screen.getByText(/chatAssistant.name/i)).toBeInTheDocument()
})
it('should render agent assistant when value is "agent"', () => {
// Arrange & Act
renderComponent({ value: 'agent' })
// Assert
expect(screen.getByText(/agentAssistant.name/i)).toBeInTheDocument()
})
})
// Props tests (REQUIRED)
describe('Props', () => {
it('should use provided value prop', () => {
// Arrange & Act
renderComponent({ value: 'agent' })
// Assert
expect(screen.getByText(/agentAssistant.name/i)).toBeInTheDocument()
})
it('should handle agentConfig prop', () => {
// Arrange
const customAgentConfig: AgentConfig = {
enabled: true,
max_iteration: 10,
strategy: AgentStrategy.react,
tools: [],
}
// Act
expect(() => {
renderComponent({ agentConfig: customAgentConfig })
}).not.toThrow()
// Assert
expect(screen.getByText(/chatAssistant.name/i)).toBeInTheDocument()
})
it('should handle undefined agentConfig prop', () => {
// Arrange & Act
expect(() => {
renderComponent({ agentConfig: undefined })
}).not.toThrow()
// Assert
expect(screen.getByText(/chatAssistant.name/i)).toBeInTheDocument()
})
})
// User Interactions
describe('User Interactions', () => {
it('should open dropdown when clicking trigger', async () => {
// Arrange
const user = userEvent.setup()
renderComponent()
// Act
const trigger = screen.getByText(/chatAssistant.name/i).closest('div')
await user.click(trigger!)
// Assert - Both options should be visible
await waitFor(() => {
const chatOptions = screen.getAllByText(/chatAssistant.name/i)
const agentOptions = screen.getAllByText(/agentAssistant.name/i)
expect(chatOptions.length).toBeGreaterThan(1)
expect(agentOptions.length).toBeGreaterThan(0)
})
})
it('should call onChange when selecting chat assistant', async () => {
// Arrange
const user = userEvent.setup()
const onChange = jest.fn()
renderComponent({ value: 'agent', onChange })
// Act - Open dropdown
const trigger = screen.getByText(/agentAssistant.name/i)
await user.click(trigger)
// Wait for dropdown to open and find chat option
await waitFor(() => {
expect(screen.getByText(/chatAssistant.description/i)).toBeInTheDocument()
})
// Find and click the chat option by its unique description
const chatOption = getOptionByDescription(/chatAssistant.description/i)
await user.click(chatOption)
// Assert
expect(onChange).toHaveBeenCalledWith('chat')
})
it('should call onChange when selecting agent assistant', async () => {
// Arrange
const user = userEvent.setup()
const onChange = jest.fn()
renderComponent({ value: 'chat', onChange })
// Act - Open dropdown
const trigger = screen.getByText(/chatAssistant.name/i)
await user.click(trigger)
// Wait for dropdown to open and click agent option
await waitFor(() => {
expect(screen.getByText(/agentAssistant.description/i)).toBeInTheDocument()
})
const agentOption = getOptionByDescription(/agentAssistant.description/i)
await user.click(agentOption)
// Assert
expect(onChange).toHaveBeenCalledWith('agent')
})
it('should close dropdown when selecting chat assistant', async () => {
// Arrange
const user = userEvent.setup()
renderComponent({ value: 'agent' })
// Act - Open dropdown
const trigger = screen.getByText(/agentAssistant.name/i)
await user.click(trigger)
// Wait for dropdown and select chat
await waitFor(() => {
expect(screen.getByText(/chatAssistant.description/i)).toBeInTheDocument()
})
const chatOption = getOptionByDescription(/chatAssistant.description/i)
await user.click(chatOption)
// Assert - Dropdown should close (descriptions should not be visible)
await waitFor(() => {
expect(screen.queryByText(/chatAssistant.description/i)).not.toBeInTheDocument()
})
})
it('should not close dropdown when selecting agent assistant', async () => {
// Arrange
const user = userEvent.setup()
renderComponent({ value: 'chat' })
// Act - Open dropdown
const trigger = screen.getByText(/chatAssistant.name/i).closest('div')
await user.click(trigger!)
// Wait for dropdown and select agent
await waitFor(() => {
const agentOptions = screen.getAllByText(/agentAssistant.name/i)
expect(agentOptions.length).toBeGreaterThan(0)
})
const agentOptions = screen.getAllByText(/agentAssistant.name/i)
await user.click(agentOptions[0].closest('div')!)
// Assert - Dropdown should remain open (agent settings should be visible)
await waitFor(() => {
expect(screen.getByText(/agent.setting.name/i)).toBeInTheDocument()
})
})
it('should not call onChange when clicking same value', async () => {
// Arrange
const user = userEvent.setup()
const onChange = jest.fn()
renderComponent({ value: 'chat', onChange })
// Act - Open dropdown
const trigger = screen.getByText(/chatAssistant.name/i).closest('div')
await user.click(trigger!)
// Wait for dropdown and click same option
await waitFor(() => {
const chatOptions = screen.getAllByText(/chatAssistant.name/i)
expect(chatOptions.length).toBeGreaterThan(1)
})
const chatOptions = screen.getAllByText(/chatAssistant.name/i)
await user.click(chatOptions[1].closest('div')!)
// Assert
expect(onChange).not.toHaveBeenCalled()
})
})
// Disabled state
describe('Disabled State', () => {
it('should not respond to clicks when disabled', async () => {
// Arrange
const user = userEvent.setup()
const onChange = jest.fn()
renderComponent({ disabled: true, onChange })
// Act - Open dropdown (dropdown can still open when disabled)
const trigger = screen.getByText(/chatAssistant.name/i).closest('div')
await user.click(trigger!)
// Wait for dropdown to open
await waitFor(() => {
expect(screen.getByText(/agentAssistant.description/i)).toBeInTheDocument()
})
// Act - Try to click an option
const agentOption = getOptionByDescription(/agentAssistant.description/i)
await user.click(agentOption)
// Assert - onChange should not be called (options are disabled)
expect(onChange).not.toHaveBeenCalled()
})
it('should not show agent config UI when disabled', async () => {
// Arrange
const user = userEvent.setup()
renderComponent({ value: 'agent', disabled: true })
// Act - Open dropdown
const trigger = screen.getByText(/agentAssistant.name/i).closest('div')
await user.click(trigger!)
// Assert - Agent settings option should not be visible
await waitFor(() => {
expect(screen.queryByText(/agent.setting.name/i)).not.toBeInTheDocument()
})
})
it('should show agent config UI when not disabled', async () => {
// Arrange
const user = userEvent.setup()
renderComponent({ value: 'agent', disabled: false })
// Act - Open dropdown
const trigger = screen.getByText(/agentAssistant.name/i).closest('div')
await user.click(trigger!)
// Assert - Agent settings option should be visible
await waitFor(() => {
expect(screen.getByText(/agent.setting.name/i)).toBeInTheDocument()
})
})
})
// Agent Settings Modal
describe('Agent Settings Modal', () => {
it('should open agent settings modal when clicking agent config UI', async () => {
// Arrange
const user = userEvent.setup()
renderComponent({ value: 'agent', disabled: false })
// Act - Open dropdown
const trigger = screen.getByText(/agentAssistant.name/i).closest('div')
await user.click(trigger!)
// Click agent settings
await waitFor(() => {
expect(screen.getByText(/agent.setting.name/i)).toBeInTheDocument()
})
const agentSettingsTrigger = screen.getByText(/agent.setting.name/i).closest('div')
await user.click(agentSettingsTrigger!)
// Assert
await waitFor(() => {
expect(screen.getByTestId('agent-setting-modal')).toBeInTheDocument()
})
})
it('should not open agent settings when value is not agent', async () => {
// Arrange
const user = userEvent.setup()
renderComponent({ value: 'chat', disabled: false })
// Act - Open dropdown
const trigger = screen.getByText(/chatAssistant.name/i).closest('div')
await user.click(trigger!)
// Wait for dropdown to open
await waitFor(() => {
expect(screen.getByText(/chatAssistant.description/i)).toBeInTheDocument()
})
// Assert - Agent settings modal should not appear (value is 'chat')
expect(screen.queryByTestId('agent-setting-modal')).not.toBeInTheDocument()
})
it('should call onAgentSettingChange when saving agent settings', async () => {
// Arrange
const user = userEvent.setup()
const onAgentSettingChange = jest.fn()
renderComponent({ value: 'agent', disabled: false, onAgentSettingChange })
// Act - Open dropdown and agent settings
const trigger = screen.getByText(/agentAssistant.name/i).closest('div')
await user.click(trigger!)
await waitFor(() => {
expect(screen.getByText(/agent.setting.name/i)).toBeInTheDocument()
})
const agentSettingsTrigger = screen.getByText(/agent.setting.name/i).closest('div')
await user.click(agentSettingsTrigger!)
// Wait for modal and click save
await waitFor(() => {
expect(screen.getByTestId('agent-setting-modal')).toBeInTheDocument()
})
const saveButton = screen.getByText('Save')
await user.click(saveButton)
// Assert
expect(onAgentSettingChange).toHaveBeenCalledWith({ max_iteration: 5 })
})
it('should close modal when saving agent settings', async () => {
// Arrange
const user = userEvent.setup()
renderComponent({ value: 'agent', disabled: false })
// Act - Open dropdown, agent settings, and save
const trigger = screen.getByText(/agentAssistant.name/i).closest('div')
await user.click(trigger!)
await waitFor(() => {
expect(screen.getByText(/agent.setting.name/i)).toBeInTheDocument()
})
const agentSettingsTrigger = screen.getByText(/agent.setting.name/i).closest('div')
await user.click(agentSettingsTrigger!)
await waitFor(() => {
expect(screen.getByTestId('agent-setting-modal')).toBeInTheDocument()
})
const saveButton = screen.getByText('Save')
await user.click(saveButton)
// Assert
await waitFor(() => {
expect(screen.queryByTestId('agent-setting-modal')).not.toBeInTheDocument()
})
})
it('should close modal when canceling agent settings', async () => {
// Arrange
const user = userEvent.setup()
const onAgentSettingChange = jest.fn()
renderComponent({ value: 'agent', disabled: false, onAgentSettingChange })
// Act - Open dropdown, agent settings, and cancel
const trigger = screen.getByText(/agentAssistant.name/i).closest('div')
await user.click(trigger!)
await waitFor(() => {
expect(screen.getByText(/agent.setting.name/i)).toBeInTheDocument()
})
const agentSettingsTrigger = screen.getByText(/agent.setting.name/i).closest('div')
await user.click(agentSettingsTrigger!)
await waitFor(() => {
expect(screen.getByTestId('agent-setting-modal')).toBeInTheDocument()
})
const cancelButton = screen.getByText('Cancel')
await user.click(cancelButton)
// Assert
await waitFor(() => {
expect(screen.queryByTestId('agent-setting-modal')).not.toBeInTheDocument()
})
expect(onAgentSettingChange).not.toHaveBeenCalled()
})
it('should close dropdown when opening agent settings', async () => {
// Arrange
const user = userEvent.setup()
renderComponent({ value: 'agent', disabled: false })
// Act - Open dropdown and agent settings
const trigger = screen.getByText(/agentAssistant.name/i).closest('div')
await user.click(trigger!)
await waitFor(() => {
expect(screen.getByText(/agent.setting.name/i)).toBeInTheDocument()
})
const agentSettingsTrigger = screen.getByText(/agent.setting.name/i).closest('div')
await user.click(agentSettingsTrigger!)
// Assert - Modal should be open and dropdown should close
await waitFor(() => {
expect(screen.getByTestId('agent-setting-modal')).toBeInTheDocument()
})
// The dropdown should be closed (agent settings description should not be visible)
await waitFor(() => {
const descriptions = screen.queryAllByText(/agent.setting.description/i)
expect(descriptions.length).toBe(0)
})
})
})
// Edge Cases (REQUIRED)
describe('Edge Cases', () => {
it('should handle rapid toggle clicks', async () => {
// Arrange
const user = userEvent.setup()
renderComponent()
// Act
const trigger = screen.getByText(/chatAssistant.name/i).closest('div')
await user.click(trigger!)
await user.click(trigger!)
await user.click(trigger!)
// Assert - Should not crash
expect(trigger).toBeInTheDocument()
})
it('should handle multiple rapid selection changes', async () => {
// Arrange
const user = userEvent.setup()
const onChange = jest.fn()
renderComponent({ value: 'chat', onChange })
// Act - Open and select agent
const trigger = screen.getByText(/chatAssistant.name/i)
await user.click(trigger)
await waitFor(() => {
expect(screen.getByText(/agentAssistant.description/i)).toBeInTheDocument()
})
// Click agent option - this stays open because value is 'agent'
const agentOption = getOptionByDescription(/agentAssistant.description/i)
await user.click(agentOption)
// Assert - onChange should have been called once to switch to agent
await waitFor(() => {
expect(onChange).toHaveBeenCalledTimes(1)
})
expect(onChange).toHaveBeenCalledWith('agent')
})
it('should handle missing callback functions gracefully', async () => {
// Arrange
const user = userEvent.setup()
// Act & Assert - Should not crash
expect(() => {
renderComponent({
onChange: undefined!,
onAgentSettingChange: undefined!,
})
}).not.toThrow()
const trigger = screen.getByText(/chatAssistant.name/i).closest('div')
await user.click(trigger!)
})
it('should handle empty agentConfig', async () => {
// Arrange & Act
expect(() => {
renderComponent({ agentConfig: {} as AgentConfig })
}).not.toThrow()
// Assert
expect(screen.getByText(/chatAssistant.name/i)).toBeInTheDocument()
})
describe('should render with different prop combinations', () => {
const combinations = [
{ value: 'chat' as const, disabled: true, isFunctionCall: true, isChatModel: true },
{ value: 'agent' as const, disabled: false, isFunctionCall: false, isChatModel: false },
{ value: 'agent' as const, disabled: true, isFunctionCall: true, isChatModel: false },
{ value: 'chat' as const, disabled: false, isFunctionCall: false, isChatModel: true },
]
it.each(combinations)(
'value=$value, disabled=$disabled, isFunctionCall=$isFunctionCall, isChatModel=$isChatModel',
(combo) => {
// Arrange & Act
renderComponent(combo)
// Assert
const expectedText = combo.value === 'agent' ? 'agentAssistant.name' : 'chatAssistant.name'
expect(screen.getByText(new RegExp(expectedText, 'i'))).toBeInTheDocument()
},
)
})
})
// Accessibility
describe('Accessibility', () => {
it('should render interactive dropdown items', async () => {
// Arrange
const user = userEvent.setup()
renderComponent()
// Act - Open dropdown
const trigger = screen.getByText(/chatAssistant.name/i)
await user.click(trigger)
// Assert - Both options should be visible and clickable
await waitFor(() => {
expect(screen.getByText(/chatAssistant.description/i)).toBeInTheDocument()
expect(screen.getByText(/agentAssistant.description/i)).toBeInTheDocument()
})
// Verify we can interact with option elements using helper function
const chatOption = getOptionByDescription(/chatAssistant.description/i)
const agentOption = getOptionByDescription(/agentAssistant.description/i)
expect(chatOption).toBeInTheDocument()
expect(agentOption).toBeInTheDocument()
})
})
// SelectItem Component
describe('SelectItem Component', () => {
it('should show checked state for selected option', async () => {
// Arrange
const user = userEvent.setup()
renderComponent({ value: 'chat' })
// Act - Open dropdown
const trigger = screen.getByText(/chatAssistant.name/i)
await user.click(trigger)
// Assert - Both options should be visible with radio components
await waitFor(() => {
expect(screen.getByText(/chatAssistant.description/i)).toBeInTheDocument()
expect(screen.getByText(/agentAssistant.description/i)).toBeInTheDocument()
})
// The SelectItem components render with different visual states
// based on isChecked prop - we verify both options are rendered
const chatOption = getOptionByDescription(/chatAssistant.description/i)
const agentOption = getOptionByDescription(/agentAssistant.description/i)
expect(chatOption).toBeInTheDocument()
expect(agentOption).toBeInTheDocument()
})
it('should render description text', async () => {
// Arrange
const user = userEvent.setup()
renderComponent()
// Act - Open dropdown
const trigger = screen.getByText(/chatAssistant.name/i).closest('div')
await user.click(trigger!)
// Assert - Descriptions should be visible
await waitFor(() => {
expect(screen.getByText(/chatAssistant.description/i)).toBeInTheDocument()
expect(screen.getByText(/agentAssistant.description/i)).toBeInTheDocument()
})
})
it('should show Radio component for each option', async () => {
// Arrange
const user = userEvent.setup()
renderComponent()
// Act - Open dropdown
const trigger = screen.getByText(/chatAssistant.name/i)
await user.click(trigger)
// Assert - Radio components should be present (both options visible)
await waitFor(() => {
expect(screen.getByText(/chatAssistant.description/i)).toBeInTheDocument()
expect(screen.getByText(/agentAssistant.description/i)).toBeInTheDocument()
})
})
})
// Props Validation for AgentSetting
describe('AgentSetting Props', () => {
it('should pass isFunctionCall and isChatModel props to AgentSetting', async () => {
// Arrange
const user = userEvent.setup()
renderComponent({
value: 'agent',
isFunctionCall: true,
isChatModel: false,
})
// Act - Open dropdown and trigger AgentSetting
const trigger = screen.getByText(/agentAssistant.name/i)
await user.click(trigger)
await waitFor(() => {
expect(screen.getByText(/agent.setting.name/i)).toBeInTheDocument()
})
const agentSettingsTrigger = screen.getByText(/agent.setting.name/i)
await user.click(agentSettingsTrigger)
// Assert - Verify AgentSetting receives correct props
await waitFor(() => {
expect(screen.getByTestId('agent-setting-modal')).toBeInTheDocument()
})
expect(mockAgentSettingProps).not.toBeNull()
expect(mockAgentSettingProps!.isFunctionCall).toBe(true)
expect(mockAgentSettingProps!.isChatModel).toBe(false)
})
it('should pass agentConfig payload to AgentSetting', async () => {
// Arrange
const user = userEvent.setup()
const customConfig: AgentConfig = {
enabled: true,
max_iteration: 10,
strategy: AgentStrategy.react,
tools: [],
}
renderComponent({
value: 'agent',
agentConfig: customConfig,
})
// Act - Open AgentSetting
const trigger = screen.getByText(/agentAssistant.name/i)
await user.click(trigger)
await waitFor(() => {
expect(screen.getByText(/agent.setting.name/i)).toBeInTheDocument()
})
const agentSettingsTrigger = screen.getByText(/agent.setting.name/i)
await user.click(agentSettingsTrigger)
// Assert - Verify payload was passed
await waitFor(() => {
expect(screen.getByTestId('agent-setting-modal')).toBeInTheDocument()
})
expect(mockAgentSettingProps).not.toBeNull()
expect(mockAgentSettingProps!.payload).toEqual(customConfig)
})
})
// Keyboard Navigation
describe('Keyboard Navigation', () => {
it('should support closing dropdown with Escape key', async () => {
// Arrange
const user = userEvent.setup()
renderComponent()
// Act - Open dropdown
const trigger = screen.getByText(/chatAssistant.name/i)
await user.click(trigger)
await waitFor(() => {
expect(screen.getByText(/chatAssistant.description/i)).toBeInTheDocument()
})
// Press Escape
await user.keyboard('{Escape}')
// Assert - Dropdown should close
await waitFor(() => {
expect(screen.queryByText(/chatAssistant.description/i)).not.toBeInTheDocument()
})
})
it('should allow keyboard focus on trigger element', () => {
// Arrange
renderComponent()
// Act - Get trigger and verify it can receive focus
const trigger = screen.getByText(/chatAssistant.name/i)
// Assert - Element should be focusable
expect(trigger).toBeInTheDocument()
expect(trigger.parentElement).toBeInTheDocument()
})
it('should allow keyboard focus on dropdown options', async () => {
// Arrange
const user = userEvent.setup()
renderComponent()
// Act - Open dropdown
const trigger = screen.getByText(/chatAssistant.name/i)
await user.click(trigger)
await waitFor(() => {
expect(screen.getByText(/chatAssistant.description/i)).toBeInTheDocument()
})
// Get options
const chatOption = getOptionByDescription(/chatAssistant.description/i)
const agentOption = getOptionByDescription(/agentAssistant.description/i)
// Assert - Options should be focusable
expect(chatOption).toBeInTheDocument()
expect(agentOption).toBeInTheDocument()
// Verify options can receive focus
act(() => {
chatOption.focus()
})
expect(document.activeElement).toBe(chatOption)
})
it('should maintain keyboard accessibility for all interactive elements', async () => {
// Arrange
const user = userEvent.setup()
renderComponent({ value: 'agent' })
// Act - Open dropdown
const trigger = screen.getByText(/agentAssistant.name/i)
await user.click(trigger)
// Assert - Agent settings button should be focusable
await waitFor(() => {
expect(screen.getByText(/agent.setting.name/i)).toBeInTheDocument()
})
const agentSettings = screen.getByText(/agent.setting.name/i)
expect(agentSettings).toBeInTheDocument()
})
})
// ARIA Attributes
describe('ARIA Attributes', () => {
it('should have proper ARIA state for dropdown', async () => {
// Arrange
const user = userEvent.setup()
const { container } = renderComponent()
// Act - Check initial state
const portalContainer = container.querySelector('[data-state]')
expect(portalContainer).toHaveAttribute('data-state', 'closed')
// Open dropdown
const trigger = screen.getByText(/chatAssistant.name/i)
await user.click(trigger)
// Assert - State should change to open
await waitFor(() => {
const openPortal = container.querySelector('[data-state="open"]')
expect(openPortal).toBeInTheDocument()
})
})
it('should have proper data-state attribute', () => {
// Arrange & Act
const { container } = renderComponent()
// Assert - Portal should have data-state for accessibility
const portalContainer = container.querySelector('[data-state]')
expect(portalContainer).toBeInTheDocument()
expect(portalContainer).toHaveAttribute('data-state')
// Should start in closed state
expect(portalContainer).toHaveAttribute('data-state', 'closed')
})
it('should maintain accessible structure for screen readers', () => {
// Arrange & Act
renderComponent({ value: 'chat' })
// Assert - Text content should be accessible
expect(screen.getByText(/chatAssistant.name/i)).toBeInTheDocument()
// Icons should have proper structure
const { container } = renderComponent()
const icons = container.querySelectorAll('svg')
expect(icons.length).toBeGreaterThan(0)
})
it('should provide context through text labels', async () => {
// Arrange
const user = userEvent.setup()
renderComponent()
// Act - Open dropdown
const trigger = screen.getByText(/chatAssistant.name/i)
await user.click(trigger)
// Assert - All options should have descriptive text
await waitFor(() => {
expect(screen.getByText(/chatAssistant.description/i)).toBeInTheDocument()
expect(screen.getByText(/agentAssistant.description/i)).toBeInTheDocument()
})
// Title text should be visible
expect(screen.getByText(/assistantType.name/i)).toBeInTheDocument()
})
})
})

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,392 @@
import { render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import ConfigContent from './config-content'
import type { DataSet } from '@/models/datasets'
import { ChunkingMode, DataSourceType, DatasetPermission, RerankingModeEnum, WeightedScoreEnum } from '@/models/datasets'
import type { DatasetConfigs } from '@/models/debug'
import { RETRIEVE_METHOD, RETRIEVE_TYPE } from '@/types/app'
import type { RetrievalConfig } from '@/types/app'
import Toast from '@/app/components/base/toast'
import type { IndexingType } from '@/app/components/datasets/create/step-two'
import {
useCurrentProviderAndModel,
useModelListAndDefaultModelAndCurrentProviderAndModel,
} from '@/app/components/header/account-setting/model-provider-page/hooks'
jest.mock('@/app/components/header/account-setting/model-provider-page/model-selector', () => {
type Props = {
defaultModel?: { provider: string; model: string }
onSelect?: (model: { provider: string; model: string }) => void
}
const MockModelSelector = ({ defaultModel, onSelect }: Props) => (
<button
type="button"
onClick={() => onSelect?.(defaultModel ?? { provider: 'mock-provider', model: 'mock-model' })}
>
Mock ModelSelector
</button>
)
return {
__esModule: true,
default: MockModelSelector,
}
})
jest.mock('@/app/components/header/account-setting/model-provider-page/model-parameter-modal', () => ({
__esModule: true,
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(),
}))
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
const baseRetrievalConfig: RetrievalConfig = {
search_method: RETRIEVE_METHOD.semantic,
reranking_enable: false,
reranking_model: {
reranking_provider_name: 'provider',
reranking_model_name: 'rerank-model',
},
top_k: 4,
score_threshold_enabled: false,
score_threshold: 0,
}
const defaultIndexingTechnique: IndexingType = 'high_quality' as IndexingType
const createDataset = (overrides: Partial<DataSet> = {}): DataSet => {
const {
retrieval_model,
retrieval_model_dict,
icon_info,
...restOverrides
} = overrides
const resolvedRetrievalModelDict = {
...baseRetrievalConfig,
...retrieval_model_dict,
}
const resolvedRetrievalModel = {
...baseRetrievalConfig,
...(retrieval_model ?? retrieval_model_dict),
}
const defaultIconInfo = {
icon: '📘',
icon_type: 'emoji',
icon_background: '#FFEAD5',
icon_url: '',
}
const resolvedIconInfo = ('icon_info' in overrides)
? icon_info
: defaultIconInfo
return {
id: 'dataset-id',
name: 'Dataset Name',
indexing_status: 'completed',
icon_info: resolvedIconInfo as DataSet['icon_info'],
description: 'A test dataset',
permission: DatasetPermission.onlyMe,
data_source_type: DataSourceType.FILE,
indexing_technique: defaultIndexingTechnique,
author_name: 'author',
created_by: 'creator',
updated_by: 'updater',
updated_at: 0,
app_count: 0,
doc_form: ChunkingMode.text,
document_count: 0,
total_document_count: 0,
total_available_documents: 0,
word_count: 0,
provider: 'dify',
embedding_model: 'text-embedding',
embedding_model_provider: 'openai',
embedding_available: true,
retrieval_model_dict: resolvedRetrievalModelDict,
retrieval_model: resolvedRetrievalModel,
tags: [],
external_knowledge_info: {
external_knowledge_id: 'external-id',
external_knowledge_api_id: 'api-id',
external_knowledge_api_name: 'api-name',
external_knowledge_api_endpoint: 'https://endpoint',
},
external_retrieval_model: {
top_k: 2,
score_threshold: 0.5,
score_threshold_enabled: true,
},
built_in_field_enabled: true,
doc_metadata: [],
keyword_number: 3,
pipeline_id: 'pipeline-id',
is_published: true,
runtime_mode: 'general',
enable_api: true,
is_multimodal: false,
...restOverrides,
}
}
const createDatasetConfigs = (overrides: Partial<DatasetConfigs> = {}): DatasetConfigs => {
return {
retrieval_model: RETRIEVE_TYPE.multiWay,
reranking_model: {
reranking_provider_name: '',
reranking_model_name: '',
},
top_k: 4,
score_threshold_enabled: false,
score_threshold: 0,
datasets: {
datasets: [],
},
reranking_mode: RerankingModeEnum.WeightedScore,
weights: {
weight_type: WeightedScoreEnum.Customized,
vector_setting: {
vector_weight: 0.5,
embedding_provider_name: 'openai',
embedding_model_name: 'text-embedding',
},
keyword_setting: {
keyword_weight: 0.5,
},
},
reranking_enable: false,
...overrides,
}
}
describe('ConfigContent', () => {
beforeEach(() => {
jest.clearAllMocks()
mockedUseModelListAndDefaultModelAndCurrentProviderAndModel.mockReturnValue({
modelList: [],
defaultModel: undefined,
currentProvider: undefined,
currentModel: undefined,
})
mockedUseCurrentProviderAndModel.mockReturnValue({
currentProvider: undefined,
currentModel: undefined,
})
})
// State management
describe('Effects', () => {
it('should normalize oneWay retrieval mode to multiWay', async () => {
// Arrange
const onChange = jest.fn<void, [DatasetConfigs, boolean?]>()
const datasetConfigs = createDatasetConfigs({ retrieval_model: RETRIEVE_TYPE.oneWay })
// Act
render(<ConfigContent datasetConfigs={datasetConfigs} onChange={onChange} />)
// Assert
await waitFor(() => {
expect(onChange).toHaveBeenCalled()
})
const [nextConfigs] = onChange.mock.calls[0]
expect(nextConfigs.retrieval_model).toBe(RETRIEVE_TYPE.multiWay)
})
})
// Rendering tests (REQUIRED)
describe('Rendering', () => {
it('should render weighted score panel when datasets are high-quality and consistent', () => {
// Arrange
const onChange = jest.fn<void, [DatasetConfigs, boolean?]>()
const datasetConfigs = createDatasetConfigs({
reranking_mode: RerankingModeEnum.WeightedScore,
})
const selectedDatasets: DataSet[] = [
createDataset({
indexing_technique: 'high_quality' as IndexingType,
provider: 'dify',
embedding_model: 'text-embedding',
embedding_model_provider: 'openai',
retrieval_model_dict: {
...baseRetrievalConfig,
search_method: RETRIEVE_METHOD.semantic,
},
}),
]
// Act
render(
<ConfigContent
datasetConfigs={datasetConfigs}
onChange={onChange}
selectedDatasets={selectedDatasets}
/>,
)
// Assert
expect(screen.getByText('dataset.weightedScore.title')).toBeInTheDocument()
expect(screen.getByText('common.modelProvider.rerankModel.key')).toBeInTheDocument()
expect(screen.getByText('dataset.weightedScore.semantic')).toBeInTheDocument()
expect(screen.getByText('dataset.weightedScore.keyword')).toBeInTheDocument()
})
})
// User interactions
describe('User Interactions', () => {
it('should update weights when user changes weighted score slider', async () => {
// Arrange
const user = userEvent.setup()
const onChange = jest.fn<void, [DatasetConfigs, boolean?]>()
const datasetConfigs = createDatasetConfigs({
reranking_mode: RerankingModeEnum.WeightedScore,
weights: {
weight_type: WeightedScoreEnum.Customized,
vector_setting: {
vector_weight: 0.5,
embedding_provider_name: 'openai',
embedding_model_name: 'text-embedding',
},
keyword_setting: {
keyword_weight: 0.5,
},
},
})
const selectedDatasets: DataSet[] = [
createDataset({
indexing_technique: 'high_quality' as IndexingType,
provider: 'dify',
embedding_model: 'text-embedding',
embedding_model_provider: 'openai',
retrieval_model_dict: {
...baseRetrievalConfig,
search_method: RETRIEVE_METHOD.semantic,
},
}),
]
// Act
render(
<ConfigContent
datasetConfigs={datasetConfigs}
onChange={onChange}
selectedDatasets={selectedDatasets}
/>,
)
const weightedScoreSlider = screen.getAllByRole('slider')
.find(slider => slider.getAttribute('aria-valuemax') === '1')
expect(weightedScoreSlider).toBeDefined()
await user.click(weightedScoreSlider!)
const callsBefore = onChange.mock.calls.length
await user.keyboard('{ArrowRight}')
// Assert
expect(onChange.mock.calls.length).toBeGreaterThan(callsBefore)
const [nextConfigs] = onChange.mock.calls.at(-1) ?? []
expect(nextConfigs?.weights?.vector_setting.vector_weight).toBeCloseTo(0.6, 5)
expect(nextConfigs?.weights?.keyword_setting.keyword_weight).toBeCloseTo(0.4, 5)
})
it('should warn when switching to rerank model mode without a valid model', async () => {
// Arrange
const user = userEvent.setup()
const onChange = jest.fn<void, [DatasetConfigs, boolean?]>()
const datasetConfigs = createDatasetConfigs({
reranking_mode: RerankingModeEnum.WeightedScore,
})
const selectedDatasets: DataSet[] = [
createDataset({
indexing_technique: 'high_quality' as IndexingType,
provider: 'dify',
embedding_model: 'text-embedding',
embedding_model_provider: 'openai',
retrieval_model_dict: {
...baseRetrievalConfig,
search_method: RETRIEVE_METHOD.semantic,
},
}),
]
// Act
render(
<ConfigContent
datasetConfigs={datasetConfigs}
onChange={onChange}
selectedDatasets={selectedDatasets}
/>,
)
await user.click(screen.getByText('common.modelProvider.rerankModel.key'))
// Assert
expect(mockToastNotify).toHaveBeenCalledWith({
type: 'error',
message: 'workflow.errorMsg.rerankModelRequired',
})
expect(onChange).toHaveBeenCalledWith(
expect.objectContaining({
reranking_mode: RerankingModeEnum.RerankingModel,
}),
)
})
it('should warn when enabling rerank without a valid model in manual toggle mode', async () => {
// Arrange
const user = userEvent.setup()
const onChange = jest.fn<void, [DatasetConfigs, boolean?]>()
const datasetConfigs = createDatasetConfigs({
reranking_enable: false,
})
const selectedDatasets: DataSet[] = [
createDataset({
indexing_technique: 'economy' as IndexingType,
provider: 'dify',
embedding_model: 'text-embedding',
embedding_model_provider: 'openai',
retrieval_model_dict: {
...baseRetrievalConfig,
search_method: RETRIEVE_METHOD.semantic,
},
}),
]
// Act
render(
<ConfigContent
datasetConfigs={datasetConfigs}
onChange={onChange}
selectedDatasets={selectedDatasets}
/>,
)
await user.click(screen.getByRole('switch'))
// Assert
expect(mockToastNotify).toHaveBeenCalledWith({
type: 'error',
message: 'workflow.errorMsg.rerankModelRequired',
})
expect(onChange).toHaveBeenCalledWith(
expect.objectContaining({
reranking_enable: true,
}),
)
})
})
})

View File

@ -0,0 +1,242 @@
import * as React from 'react'
import { render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import ParamsConfig from './index'
import ConfigContext from '@/context/debug-configuration'
import type { DatasetConfigs } from '@/models/debug'
import { RerankingModeEnum } from '@/models/datasets'
import { RETRIEVE_TYPE } from '@/types/app'
import Toast from '@/app/components/base/toast'
import {
useCurrentProviderAndModel,
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(),
}))
jest.mock('@/app/components/header/account-setting/model-provider-page/model-selector', () => {
type Props = {
defaultModel?: { provider: string; model: string }
onSelect?: (model: { provider: string; model: string }) => void
}
const MockModelSelector = ({ defaultModel, onSelect }: Props) => (
<button
type="button"
onClick={() => onSelect?.(defaultModel ?? { provider: 'mock-provider', model: 'mock-model' })}
>
Mock ModelSelector
</button>
)
return {
__esModule: true,
default: MockModelSelector,
}
})
jest.mock('@/app/components/header/account-setting/model-provider-page/model-parameter-modal', () => ({
__esModule: true,
default: () => <div data-testid="model-parameter-modal" />,
}))
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
const createDatasetConfigs = (overrides: Partial<DatasetConfigs> = {}): DatasetConfigs => {
return {
retrieval_model: RETRIEVE_TYPE.multiWay,
reranking_model: {
reranking_provider_name: 'provider',
reranking_model_name: 'rerank-model',
},
top_k: 4,
score_threshold_enabled: false,
score_threshold: 0,
datasets: {
datasets: [],
},
reranking_enable: false,
reranking_mode: RerankingModeEnum.RerankingModel,
...overrides,
}
}
const renderParamsConfig = ({
datasetConfigs = createDatasetConfigs(),
initialModalOpen = false,
disabled,
}: {
datasetConfigs?: DatasetConfigs
initialModalOpen?: boolean
disabled?: boolean
} = {}) => {
const setDatasetConfigsSpy = jest.fn<void, [DatasetConfigs]>()
const setModalOpenSpy = jest.fn<void, [boolean]>()
const Wrapper = ({ children }: { children: React.ReactNode }) => {
const [datasetConfigsState, setDatasetConfigsState] = React.useState(datasetConfigs)
const [modalOpen, setModalOpen] = React.useState(initialModalOpen)
const contextValue = {
datasetConfigs: datasetConfigsState,
setDatasetConfigs: (next: DatasetConfigs) => {
setDatasetConfigsSpy(next)
setDatasetConfigsState(next)
},
rerankSettingModalOpen: modalOpen,
setRerankSettingModalOpen: (open: boolean) => {
setModalOpenSpy(open)
setModalOpen(open)
},
} as unknown as React.ComponentProps<typeof ConfigContext.Provider>['value']
return (
<ConfigContext.Provider value={contextValue}>
{children}
</ConfigContext.Provider>
)
}
render(
<ParamsConfig
disabled={disabled}
selectedDatasets={[]}
/>,
{ wrapper: Wrapper },
)
return {
setDatasetConfigsSpy,
setModalOpenSpy,
}
}
describe('dataset-config/params-config', () => {
beforeEach(() => {
jest.clearAllMocks()
mockedUseModelListAndDefaultModelAndCurrentProviderAndModel.mockReturnValue({
modelList: [],
defaultModel: undefined,
currentProvider: undefined,
currentModel: undefined,
})
mockedUseCurrentProviderAndModel.mockReturnValue({
currentProvider: undefined,
currentModel: undefined,
})
})
// Rendering tests (REQUIRED)
describe('Rendering', () => {
it('should disable settings trigger when disabled is true', () => {
// Arrange
renderParamsConfig({ disabled: true })
// Assert
expect(screen.getByRole('button', { name: 'dataset.retrievalSettings' })).toBeDisabled()
})
})
// User Interactions
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')
// Change top_k via the first number input increment control.
const incrementButtons = screen.getAllByRole('button', { name: 'increment' })
await user.click(incrementButtons[0])
await user.click(screen.getByRole('button', { name: 'common.operation.save' }))
// Assert
expect(setDatasetConfigsSpy).toHaveBeenCalledWith(expect.objectContaining({ top_k: 5 }))
await waitFor(() => {
expect(screen.queryByRole('dialog')).not.toBeInTheDocument()
})
})
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')
const incrementButtons = screen.getAllByRole('button', { name: 'increment' })
await user.click(incrementButtons[0])
await user.click(screen.getByRole('button', { name: 'common.operation.cancel' }))
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' }))
// Assert - should save original top_k rather than the canceled change.
expect(setDatasetConfigsSpy).toHaveBeenCalledWith(expect.objectContaining({ top_k: 4 }))
})
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,
reranking_mode: RerankingModeEnum.RerankingModel,
}),
initialModalOpen: true,
})
// Act
await user.click(screen.getByRole('button', { name: 'common.operation.save' }))
// Assert
expect(mockToastNotify).toHaveBeenCalledWith({
type: 'error',
message: 'appDebug.datasetConfig.rerankModelRequired',
})
expect(setDatasetConfigsSpy).not.toHaveBeenCalled()
expect(screen.getByRole('dialog')).toBeInTheDocument()
})
})
})

View File

@ -0,0 +1,81 @@
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import WeightedScore from './weighted-score'
describe('WeightedScore', () => {
beforeEach(() => {
jest.clearAllMocks()
})
// Rendering tests (REQUIRED)
describe('Rendering', () => {
it('should render semantic and keyword weights', () => {
// Arrange
const onChange = jest.fn<void, [{ value: number[] }]>()
const value = { value: [0.3, 0.7] }
// Act
render(<WeightedScore value={value} onChange={onChange} />)
// Assert
expect(screen.getByText('dataset.weightedScore.semantic')).toBeInTheDocument()
expect(screen.getByText('dataset.weightedScore.keyword')).toBeInTheDocument()
expect(screen.getByText('0.3')).toBeInTheDocument()
expect(screen.getByText('0.7')).toBeInTheDocument()
})
it('should format a weight of 1 as 1.0', () => {
// Arrange
const onChange = jest.fn<void, [{ value: number[] }]>()
const value = { value: [1, 0] }
// Act
render(<WeightedScore value={value} onChange={onChange} />)
// Assert
expect(screen.getByText('1.0')).toBeInTheDocument()
expect(screen.getByText('0')).toBeInTheDocument()
})
})
// User Interactions
describe('User Interactions', () => {
it('should emit complementary weights when the slider value changes', async () => {
// Arrange
const onChange = jest.fn<void, [{ value: number[] }]>()
const value = { value: [0.5, 0.5] }
const user = userEvent.setup()
render(<WeightedScore value={value} onChange={onChange} />)
// Act
await user.tab()
const slider = screen.getByRole('slider')
expect(slider).toHaveFocus()
const callsBefore = onChange.mock.calls.length
await user.keyboard('{ArrowRight}')
// Assert
expect(onChange.mock.calls.length).toBeGreaterThan(callsBefore)
const lastCall = onChange.mock.calls.at(-1)?.[0]
expect(lastCall?.value[0]).toBeCloseTo(0.6, 5)
expect(lastCall?.value[1]).toBeCloseTo(0.4, 5)
})
it('should not call onChange when readonly is true', async () => {
// Arrange
const onChange = jest.fn<void, [{ value: number[] }]>()
const value = { value: [0.5, 0.5] }
const user = userEvent.setup()
render(<WeightedScore value={value} onChange={onChange} readonly />)
// Act
await user.tab()
const slider = screen.getByRole('slider')
expect(slider).toHaveFocus()
await user.keyboard('{ArrowRight}')
// Assert
expect(onChange).not.toHaveBeenCalled()
})
})
})

File diff suppressed because it is too large Load Diff

View File

@ -26,7 +26,7 @@ jest.mock('./app-list', () => {
})
jest.mock('ahooks', () => ({
useKeyPress: jest.fn((key: string, callback: () => void) => {
useKeyPress: jest.fn((_key: string, _callback: () => void) => {
// Mock implementation for testing
return jest.fn()
}),
@ -67,7 +67,7 @@ describe('CreateAppTemplateDialog', () => {
})
it('should not render create from blank button when onCreateFromBlank is not provided', () => {
const { onCreateFromBlank, ...propsWithoutOnCreate } = defaultProps
const { onCreateFromBlank: _onCreateFromBlank, ...propsWithoutOnCreate } = defaultProps
render(<CreateAppTemplateDialog {...propsWithoutOnCreate} show={true} />)
@ -259,7 +259,7 @@ describe('CreateAppTemplateDialog', () => {
})
it('should handle missing optional onCreateFromBlank prop', () => {
const { onCreateFromBlank, ...propsWithoutOnCreate } = defaultProps
const { onCreateFromBlank: _onCreateFromBlank, ...propsWithoutOnCreate } = defaultProps
expect(() => {
render(<CreateAppTemplateDialog {...propsWithoutOnCreate} show={true} />)

View File

@ -0,0 +1,144 @@
import React from 'react'
import { fireEvent, render, screen, within } from '@testing-library/react'
import AppTypeSelector, { AppTypeIcon, AppTypeLabel } from './index'
import { AppModeEnum } from '@/types/app'
jest.mock('react-i18next')
describe('AppTypeSelector', () => {
beforeEach(() => {
jest.clearAllMocks()
})
// Covers default rendering and the closed dropdown state.
describe('Rendering', () => {
it('should render "all types" trigger when no types selected', () => {
render(<AppTypeSelector value={[]} onChange={jest.fn()} />)
expect(screen.getByText('app.typeSelector.all')).toBeInTheDocument()
expect(screen.queryByRole('tooltip')).not.toBeInTheDocument()
})
})
// Covers prop-driven trigger variants (empty, single, multiple).
describe('Props', () => {
it('should render selected type label and clear button when a single type is selected', () => {
render(<AppTypeSelector value={[AppModeEnum.CHAT]} onChange={jest.fn()} />)
expect(screen.getByText('app.typeSelector.chatbot')).toBeInTheDocument()
expect(screen.getByRole('button', { name: 'common.operation.clear' })).toBeInTheDocument()
})
it('should render icon-only trigger when multiple types are selected', () => {
render(<AppTypeSelector value={[AppModeEnum.CHAT, AppModeEnum.WORKFLOW]} onChange={jest.fn()} />)
expect(screen.queryByText('app.typeSelector.all')).not.toBeInTheDocument()
expect(screen.queryByText('app.typeSelector.chatbot')).not.toBeInTheDocument()
expect(screen.queryByText('app.typeSelector.workflow')).not.toBeInTheDocument()
expect(screen.getByRole('button', { name: 'common.operation.clear' })).toBeInTheDocument()
})
})
// Covers opening/closing the dropdown and selection updates.
describe('User interactions', () => {
it('should toggle option list when clicking the trigger', () => {
render(<AppTypeSelector value={[]} onChange={jest.fn()} />)
expect(screen.queryByRole('tooltip')).not.toBeInTheDocument()
fireEvent.click(screen.getByText('app.typeSelector.all'))
expect(screen.getByRole('tooltip')).toBeInTheDocument()
fireEvent.click(screen.getByText('app.typeSelector.all'))
expect(screen.queryByRole('tooltip')).not.toBeInTheDocument()
})
it('should call onChange with added type when selecting an unselected item', () => {
const onChange = jest.fn()
render(<AppTypeSelector value={[]} onChange={onChange} />)
fireEvent.click(screen.getByText('app.typeSelector.all'))
fireEvent.click(within(screen.getByRole('tooltip')).getByText('app.typeSelector.workflow'))
expect(onChange).toHaveBeenCalledWith([AppModeEnum.WORKFLOW])
})
it('should call onChange with removed type when selecting an already-selected item', () => {
const onChange = jest.fn()
render(<AppTypeSelector value={[AppModeEnum.WORKFLOW]} onChange={onChange} />)
fireEvent.click(screen.getByText('app.typeSelector.workflow'))
fireEvent.click(within(screen.getByRole('tooltip')).getByText('app.typeSelector.workflow'))
expect(onChange).toHaveBeenCalledWith([])
})
it('should call onChange with appended type when selecting an additional item', () => {
const onChange = jest.fn()
render(<AppTypeSelector value={[AppModeEnum.CHAT]} onChange={onChange} />)
fireEvent.click(screen.getByText('app.typeSelector.chatbot'))
fireEvent.click(within(screen.getByRole('tooltip')).getByText('app.typeSelector.agent'))
expect(onChange).toHaveBeenCalledWith([AppModeEnum.CHAT, AppModeEnum.AGENT_CHAT])
})
it('should clear selection without opening the dropdown when clicking clear button', () => {
const onChange = jest.fn()
render(<AppTypeSelector value={[AppModeEnum.CHAT]} onChange={onChange} />)
fireEvent.click(screen.getByRole('button', { name: 'common.operation.clear' }))
expect(onChange).toHaveBeenCalledWith([])
expect(screen.queryByRole('tooltip')).not.toBeInTheDocument()
})
})
})
describe('AppTypeLabel', () => {
beforeEach(() => {
jest.clearAllMocks()
})
// Covers label mapping for each supported app type.
it.each([
[AppModeEnum.CHAT, 'app.typeSelector.chatbot'],
[AppModeEnum.AGENT_CHAT, 'app.typeSelector.agent'],
[AppModeEnum.COMPLETION, 'app.typeSelector.completion'],
[AppModeEnum.ADVANCED_CHAT, 'app.typeSelector.advanced'],
[AppModeEnum.WORKFLOW, 'app.typeSelector.workflow'],
] as const)('should render label %s for type %s', (_type, expectedLabel) => {
render(<AppTypeLabel type={_type} />)
expect(screen.getByText(expectedLabel)).toBeInTheDocument()
})
// Covers fallback behavior for unexpected app mode values.
it('should render empty label for unknown type', () => {
const { container } = render(<AppTypeLabel type={'unknown' as AppModeEnum} />)
expect(container.textContent).toBe('')
})
})
describe('AppTypeIcon', () => {
beforeEach(() => {
jest.clearAllMocks()
})
// Covers icon rendering for each supported app type.
it.each([
[AppModeEnum.CHAT],
[AppModeEnum.AGENT_CHAT],
[AppModeEnum.COMPLETION],
[AppModeEnum.ADVANCED_CHAT],
[AppModeEnum.WORKFLOW],
] as const)('should render icon for type %s', (type) => {
const { container } = render(<AppTypeIcon type={type} />)
expect(container.querySelector('svg')).toBeInTheDocument()
})
// Covers fallback behavior for unexpected app mode values.
it('should render nothing for unknown type', () => {
const { container } = render(<AppTypeIcon type={'unknown' as AppModeEnum} />)
expect(container.firstChild).toBeNull()
})
})

View File

@ -20,6 +20,7 @@ const allTypes: AppModeEnum[] = [AppModeEnum.WORKFLOW, AppModeEnum.ADVANCED_CHAT
const AppTypeSelector = ({ value, onChange }: AppSelectorProps) => {
const [open, setOpen] = useState(false)
const { t } = useTranslation()
return (
<PortalToFollowElem
@ -37,12 +38,21 @@ const AppTypeSelector = ({ value, onChange }: AppSelectorProps) => {
'flex cursor-pointer items-center justify-between space-x-1 rounded-md px-2 hover:bg-state-base-hover',
)}>
<AppTypeSelectTrigger values={value} />
{value && value.length > 0 && <div className='h-4 w-4' onClick={(e) => {
e.stopPropagation()
onChange([])
}}>
<RiCloseCircleFill className='h-3.5 w-3.5 cursor-pointer text-text-quaternary hover:text-text-tertiary' />
</div>}
{value && value.length > 0 && (
<button
type="button"
aria-label={t('common.operation.clear')}
className="group h-4 w-4"
onClick={(e) => {
e.stopPropagation()
onChange([])
}}
>
<RiCloseCircleFill
className="h-3.5 w-3.5 text-text-quaternary group-hover:text-text-tertiary"
/>
</button>
)}
</div>
</PortalToFollowElemTrigger>
<PortalToFollowElemContent className='z-[1002]'>

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

@ -8,6 +8,7 @@ import {
isMermaidCodeComplete,
prepareMermaidCode,
processSvgForTheme,
sanitizeMermaidCode,
svgToBase64,
waitForDOMElement,
} from './utils'
@ -71,7 +72,7 @@ const initMermaid = () => {
const config: MermaidConfig = {
startOnLoad: false,
fontFamily: 'sans-serif',
securityLevel: 'loose',
securityLevel: 'strict',
flowchart: {
htmlLabels: true,
useMaxWidth: true,
@ -267,6 +268,8 @@ const Flowchart = (props: FlowchartProps) => {
finalCode = prepareMermaidCode(primitiveCode, look)
}
finalCode = sanitizeMermaidCode(finalCode)
// Step 2: Render chart
const svgGraph = await renderMermaidChart(finalCode, look)
@ -297,9 +300,9 @@ const Flowchart = (props: FlowchartProps) => {
const configureMermaid = useCallback((primitiveCode: string) => {
if (typeof window !== 'undefined' && isInitialized) {
const themeVars = THEMES[currentTheme]
const config: any = {
const config: MermaidConfig = {
startOnLoad: false,
securityLevel: 'loose',
securityLevel: 'strict',
fontFamily: 'sans-serif',
maxTextSize: 50000,
gantt: {
@ -325,7 +328,8 @@ const Flowchart = (props: FlowchartProps) => {
config.theme = currentTheme === 'dark' ? 'dark' : 'neutral'
if (isFlowchart) {
config.flowchart = {
type FlowchartConfigWithRanker = NonNullable<MermaidConfig['flowchart']> & { ranker?: string }
const flowchartConfig: FlowchartConfigWithRanker = {
htmlLabels: true,
useMaxWidth: true,
nodeSpacing: 60,
@ -333,6 +337,7 @@ const Flowchart = (props: FlowchartProps) => {
curve: 'linear',
ranker: 'tight-tree',
}
config.flowchart = flowchartConfig as unknown as MermaidConfig['flowchart']
}
if (currentTheme === 'dark') {
@ -531,7 +536,7 @@ const Flowchart = (props: FlowchartProps) => {
{isLoading && !svgString && (
<div className='px-[26px] py-4'>
<LoadingAnim type='text'/>
<LoadingAnim type='text' />
<div className="mt-2 text-sm text-gray-500">
{t('common.wait_for_completion', 'Waiting for diagram code to complete...')}
</div>
@ -564,7 +569,7 @@ const Flowchart = (props: FlowchartProps) => {
{errMsg && (
<div className={themeClasses.errorMessage}>
<div className="flex items-center">
<ExclamationTriangleIcon className={themeClasses.errorIcon}/>
<ExclamationTriangleIcon className={themeClasses.errorIcon} />
<span className="ml-2">{errMsg}</span>
</div>
</div>

View File

@ -1,4 +1,4 @@
import { cleanUpSvgCode } from './utils'
import { cleanUpSvgCode, prepareMermaidCode, sanitizeMermaidCode } from './utils'
describe('cleanUpSvgCode', () => {
it('replaces old-style <br> tags with the new style', () => {
@ -6,3 +6,54 @@ describe('cleanUpSvgCode', () => {
expect(result).toEqual('<br/>test<br/>')
})
})
describe('sanitizeMermaidCode', () => {
it('removes click directives to prevent link/callback injection', () => {
const unsafeProtocol = ['java', 'script:'].join('')
const input = [
'gantt',
'title Demo',
'section S1',
'Task 1 :a1, 2020-01-01, 1d',
`click A href "${unsafeProtocol}alert(location.href)"`,
'click B call callback()',
].join('\n')
const result = sanitizeMermaidCode(input)
expect(result).toContain('gantt')
expect(result).toContain('Task 1')
expect(result).not.toContain('click A')
expect(result).not.toContain('click B')
expect(result).not.toContain(unsafeProtocol)
})
it('removes Mermaid init directives to prevent config overrides', () => {
const input = [
'%%{init: {"securityLevel":"loose"}}%%',
'graph TD',
'A-->B',
].join('\n')
const result = sanitizeMermaidCode(input)
expect(result).toEqual(['graph TD', 'A-->B'].join('\n'))
})
})
describe('prepareMermaidCode', () => {
it('sanitizes click directives in flowcharts', () => {
const unsafeProtocol = ['java', 'script:'].join('')
const input = [
'graph TD',
'A[Click]-->B',
`click A href "${unsafeProtocol}alert(1)"`,
].join('\n')
const result = prepareMermaidCode(input, 'classic')
expect(result).toContain('graph TD')
expect(result).not.toContain('click ')
expect(result).not.toContain(unsafeProtocol)
})
})

View File

@ -2,6 +2,28 @@ export function cleanUpSvgCode(svgCode: string): string {
return svgCode.replaceAll('<br>', '<br/>')
}
export const sanitizeMermaidCode = (mermaidCode: string): string => {
if (!mermaidCode || typeof mermaidCode !== 'string')
return ''
return mermaidCode
.split('\n')
.filter((line) => {
const trimmed = line.trimStart()
// Mermaid directives can override config; treat as untrusted in chat context.
if (trimmed.startsWith('%%{'))
return false
// Mermaid click directives can create JS callbacks/links inside rendered SVG.
if (trimmed.startsWith('click '))
return false
return true
})
.join('\n')
}
/**
* Prepares mermaid code for rendering by sanitizing common syntax issues.
* @param {string} mermaidCode - The mermaid code to prepare
@ -12,10 +34,7 @@ export const prepareMermaidCode = (mermaidCode: string, style: 'classic' | 'hand
if (!mermaidCode || typeof mermaidCode !== 'string')
return ''
let code = mermaidCode.trim()
// Security: Sanitize against javascript: protocol in click events (XSS vector)
code = code.replace(/(\bclick\s+\w+\s+")javascript:[^"]*(")/g, '$1#$2')
let code = sanitizeMermaidCode(mermaidCode.trim())
// Convenience: Basic BR replacement. This is a common and safe operation.
code = code.replace(/<br\s*\/?>/g, '\n')

View File

@ -1,11 +1,9 @@
import { render, screen } from '@testing-library/react'
import AnnotationFull from './index'
let mockUsageProps: { className?: string } | null = null
jest.mock('./usage', () => ({
__esModule: true,
default: (props: { className?: string }) => {
mockUsageProps = props
return (
<div data-testid='usage-component' data-classname={props.className ?? ''}>
usage
@ -14,11 +12,9 @@ jest.mock('./usage', () => ({
},
}))
let mockUpgradeBtnProps: { loc?: string } | null = null
jest.mock('../upgrade-btn', () => ({
__esModule: true,
default: (props: { loc?: string }) => {
mockUpgradeBtnProps = props
return (
<button type='button' data-testid='upgrade-btn'>
{props.loc}
@ -30,8 +26,6 @@ jest.mock('../upgrade-btn', () => ({
describe('AnnotationFull', () => {
beforeEach(() => {
jest.clearAllMocks()
mockUsageProps = null
mockUpgradeBtnProps = null
})
// Rendering marketing copy with action button

View File

@ -1,11 +1,9 @@
import { fireEvent, render, screen } from '@testing-library/react'
import AnnotationFullModal from './modal'
let mockUsageProps: { className?: string } | null = null
jest.mock('./usage', () => ({
__esModule: true,
default: (props: { className?: string }) => {
mockUsageProps = props
return (
<div data-testid='usage-component' data-classname={props.className ?? ''}>
usage
@ -59,7 +57,6 @@ jest.mock('../../base/modal', () => ({
describe('AnnotationFullModal', () => {
beforeEach(() => {
jest.clearAllMocks()
mockUsageProps = null
mockUpgradeBtnProps = null
mockModalProps = null
})

View File

@ -0,0 +1,625 @@
import { render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import UpgradeBtn from './index'
// ✅ Import real project components (DO NOT mock these)
// PremiumBadge, Button, SparklesSoft are all base components
// ✅ Mock i18n with actual translations instead of returning keys
jest.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string) => {
const translations: Record<string, string> = {
'billing.upgradeBtn.encourage': 'Upgrade to Pro',
'billing.upgradeBtn.encourageShort': 'Upgrade',
'billing.upgradeBtn.plain': 'Upgrade Plan',
'custom.label.key': 'Custom Label',
'custom.key': 'Custom Text',
'custom.short.key': 'Short Custom',
'custom.all': 'All Custom Props',
}
return translations[key] || key
},
}),
}))
// ✅ Mock external dependencies only
const mockSetShowPricingModal = jest.fn()
jest.mock('@/context/modal-context', () => ({
useModalContext: () => ({
setShowPricingModal: mockSetShowPricingModal,
}),
}))
// Mock gtag for tracking tests
let mockGtag: jest.Mock | undefined
describe('UpgradeBtn', () => {
beforeEach(() => {
jest.clearAllMocks()
mockGtag = jest.fn()
;(window as any).gtag = mockGtag
})
afterEach(() => {
delete (window as any).gtag
})
// Rendering tests (REQUIRED)
describe('Rendering', () => {
it('should render without crashing with default props', () => {
// Act
render(<UpgradeBtn />)
// Assert - should render with default text
expect(screen.getByText(/upgrade to pro/i)).toBeInTheDocument()
})
it('should render premium badge by default', () => {
// Act
render(<UpgradeBtn />)
// Assert - PremiumBadge renders with text content
expect(screen.getByText(/upgrade to pro/i)).toBeInTheDocument()
})
it('should render plain button when isPlain is true', () => {
// Act
render(<UpgradeBtn isPlain />)
// Assert - Button should be rendered with plain text
const button = screen.getByRole('button')
expect(button).toBeInTheDocument()
expect(screen.getByText(/upgrade plan/i)).toBeInTheDocument()
})
it('should render short text when isShort is true', () => {
// Act
render(<UpgradeBtn isShort />)
// Assert
expect(screen.getByText(/^upgrade$/i)).toBeInTheDocument()
})
it('should render custom label when labelKey is provided', () => {
// Act
render(<UpgradeBtn labelKey="custom.label.key" />)
// Assert
expect(screen.getByText(/custom label/i)).toBeInTheDocument()
})
it('should render custom label in plain button when labelKey is provided with isPlain', () => {
// Act
render(<UpgradeBtn isPlain labelKey="custom.label.key" />)
// Assert
const button = screen.getByRole('button')
expect(button).toBeInTheDocument()
expect(screen.getByText(/custom label/i)).toBeInTheDocument()
})
})
// Props tests (REQUIRED)
describe('Props', () => {
it('should apply custom className to premium badge', () => {
// Arrange
const customClass = 'custom-upgrade-btn'
// Act
const { container } = render(<UpgradeBtn className={customClass} />)
// Assert - Check the root element has the custom class
const rootElement = container.firstChild as HTMLElement
expect(rootElement).toHaveClass(customClass)
})
it('should apply custom className to plain button', () => {
// Arrange
const customClass = 'custom-button-class'
// Act
render(<UpgradeBtn isPlain className={customClass} />)
// Assert
const button = screen.getByRole('button')
expect(button).toHaveClass(customClass)
})
it('should apply custom style to premium badge', () => {
// Arrange
const customStyle = { backgroundColor: 'red', padding: '10px' }
// Act
const { container } = render(<UpgradeBtn style={customStyle} />)
// Assert
const rootElement = container.firstChild as HTMLElement
expect(rootElement).toHaveStyle(customStyle)
})
it('should apply custom style to plain button', () => {
// Arrange
const customStyle = { backgroundColor: 'blue', margin: '5px' }
// Act
render(<UpgradeBtn isPlain style={customStyle} />)
// Assert
const button = screen.getByRole('button')
expect(button).toHaveStyle(customStyle)
})
it('should render with size "s"', () => {
// Act
render(<UpgradeBtn size="s" />)
// Assert - Component renders successfully with size prop
expect(screen.getByText(/upgrade to pro/i)).toBeInTheDocument()
})
it('should render with size "m" by default', () => {
// Act
render(<UpgradeBtn />)
// Assert - Component renders successfully
expect(screen.getByText(/upgrade to pro/i)).toBeInTheDocument()
})
it('should render with size "custom"', () => {
// Act
render(<UpgradeBtn size="custom" />)
// Assert - Component renders successfully with custom size
expect(screen.getByText(/upgrade to pro/i)).toBeInTheDocument()
})
})
// User Interactions
describe('User Interactions', () => {
it('should call custom onClick when provided and premium badge is clicked', async () => {
// Arrange
const user = userEvent.setup()
const handleClick = jest.fn()
// Act
render(<UpgradeBtn onClick={handleClick} />)
const badge = screen.getByText(/upgrade to pro/i).closest('div')
await user.click(badge!)
// Assert
expect(handleClick).toHaveBeenCalledTimes(1)
expect(mockSetShowPricingModal).not.toHaveBeenCalled()
})
it('should call custom onClick when provided and plain button is clicked', async () => {
// Arrange
const user = userEvent.setup()
const handleClick = jest.fn()
// Act
render(<UpgradeBtn isPlain onClick={handleClick} />)
const button = screen.getByRole('button')
await user.click(button)
// Assert
expect(handleClick).toHaveBeenCalledTimes(1)
expect(mockSetShowPricingModal).not.toHaveBeenCalled()
})
it('should open pricing modal when no custom onClick is provided and premium badge is clicked', async () => {
// Arrange
const user = userEvent.setup()
// Act
render(<UpgradeBtn />)
const badge = screen.getByText(/upgrade to pro/i).closest('div')
await user.click(badge!)
// Assert
expect(mockSetShowPricingModal).toHaveBeenCalledTimes(1)
})
it('should open pricing modal when no custom onClick is provided and plain button is clicked', async () => {
// Arrange
const user = userEvent.setup()
// Act
render(<UpgradeBtn isPlain />)
const button = screen.getByRole('button')
await user.click(button)
// Assert
expect(mockSetShowPricingModal).toHaveBeenCalledTimes(1)
})
it('should track gtag event when loc is provided and badge is clicked', async () => {
// Arrange
const user = userEvent.setup()
const loc = 'header-navigation'
// Act
render(<UpgradeBtn loc={loc} />)
const badge = screen.getByText(/upgrade to pro/i).closest('div')
await user.click(badge!)
// Assert
expect(mockGtag).toHaveBeenCalledTimes(1)
expect(mockGtag).toHaveBeenCalledWith('event', 'click_upgrade_btn', {
loc,
})
})
it('should track gtag event when loc is provided and plain button is clicked', async () => {
// Arrange
const user = userEvent.setup()
const loc = 'footer-section'
// Act
render(<UpgradeBtn isPlain loc={loc} />)
const button = screen.getByRole('button')
await user.click(button)
// Assert
expect(mockGtag).toHaveBeenCalledTimes(1)
expect(mockGtag).toHaveBeenCalledWith('event', 'click_upgrade_btn', {
loc,
})
})
it('should not track gtag event when loc is not provided', async () => {
// Arrange
const user = userEvent.setup()
// Act
render(<UpgradeBtn />)
const badge = screen.getByText(/upgrade to pro/i).closest('div')
await user.click(badge!)
// Assert
expect(mockGtag).not.toHaveBeenCalled()
})
it('should not track gtag event when gtag is not available', async () => {
// Arrange
const user = userEvent.setup()
delete (window as any).gtag
// Act
render(<UpgradeBtn loc="test-location" />)
const badge = screen.getByText(/upgrade to pro/i).closest('div')
await user.click(badge!)
// Assert - should not throw error
expect(mockGtag).not.toHaveBeenCalled()
})
it('should call both custom onClick and track gtag when both are provided', async () => {
// Arrange
const user = userEvent.setup()
const handleClick = jest.fn()
const loc = 'settings-page'
// Act
render(<UpgradeBtn onClick={handleClick} loc={loc} />)
const badge = screen.getByText(/upgrade to pro/i).closest('div')
await user.click(badge!)
// Assert
expect(handleClick).toHaveBeenCalledTimes(1)
expect(mockGtag).toHaveBeenCalledTimes(1)
expect(mockGtag).toHaveBeenCalledWith('event', 'click_upgrade_btn', {
loc,
})
})
})
// Edge Cases (REQUIRED)
describe('Edge Cases', () => {
it('should handle undefined className', () => {
// Act
render(<UpgradeBtn className={undefined} />)
// Assert - should render without error
expect(screen.getByText(/upgrade to pro/i)).toBeInTheDocument()
})
it('should handle undefined style', () => {
// Act
render(<UpgradeBtn style={undefined} />)
// Assert - should render without error
expect(screen.getByText(/upgrade to pro/i)).toBeInTheDocument()
})
it('should handle undefined onClick', async () => {
// Arrange
const user = userEvent.setup()
// Act
render(<UpgradeBtn onClick={undefined} />)
const badge = screen.getByText(/upgrade to pro/i).closest('div')
await user.click(badge!)
// Assert - should fall back to setShowPricingModal
expect(mockSetShowPricingModal).toHaveBeenCalledTimes(1)
})
it('should handle undefined loc', async () => {
// Arrange
const user = userEvent.setup()
// Act
render(<UpgradeBtn loc={undefined} />)
const badge = screen.getByText(/upgrade to pro/i).closest('div')
await user.click(badge!)
// Assert - should not attempt to track gtag
expect(mockGtag).not.toHaveBeenCalled()
})
it('should handle undefined labelKey', () => {
// Act
render(<UpgradeBtn labelKey={undefined} />)
// Assert - should use default label
expect(screen.getByText(/upgrade to pro/i)).toBeInTheDocument()
})
it('should handle empty string className', () => {
// Act
render(<UpgradeBtn className="" />)
// Assert
expect(screen.getByText(/upgrade to pro/i)).toBeInTheDocument()
})
it('should handle empty string loc', async () => {
// Arrange
const user = userEvent.setup()
// Act
render(<UpgradeBtn loc="" />)
const badge = screen.getByText(/upgrade to pro/i).closest('div')
await user.click(badge!)
// Assert - empty loc should not trigger gtag
expect(mockGtag).not.toHaveBeenCalled()
})
it('should handle empty string labelKey', () => {
// Act
render(<UpgradeBtn labelKey="" />)
// Assert - empty labelKey is falsy, so it falls back to default label
expect(screen.getByText(/upgrade to pro/i)).toBeInTheDocument()
})
})
// Prop Combinations
describe('Prop Combinations', () => {
it('should handle isPlain with isShort', () => {
// Act
render(<UpgradeBtn isPlain isShort />)
// Assert - isShort should not affect plain button text
expect(screen.getByText(/upgrade plan/i)).toBeInTheDocument()
})
it('should handle isPlain with custom labelKey', () => {
// Act
render(<UpgradeBtn isPlain labelKey="custom.key" />)
// Assert - labelKey should override plain text
expect(screen.getByText(/custom text/i)).toBeInTheDocument()
expect(screen.queryByText(/upgrade plan/i)).not.toBeInTheDocument()
})
it('should handle isShort with custom labelKey', () => {
// Act
render(<UpgradeBtn isShort labelKey="custom.short.key" />)
// Assert - labelKey should override isShort behavior
expect(screen.getByText(/short custom/i)).toBeInTheDocument()
expect(screen.queryByText(/^upgrade$/i)).not.toBeInTheDocument()
})
it('should handle all custom props together', async () => {
// Arrange
const user = userEvent.setup()
const handleClick = jest.fn()
const customStyle = { margin: '10px' }
const customClass = 'all-custom'
// Act
const { container } = render(
<UpgradeBtn
className={customClass}
style={customStyle}
size="s"
isShort
onClick={handleClick}
loc="test-loc"
labelKey="custom.all"
/>,
)
const badge = screen.getByText(/all custom props/i).closest('div')
await user.click(badge!)
// Assert
const rootElement = container.firstChild as HTMLElement
expect(rootElement).toHaveClass(customClass)
expect(rootElement).toHaveStyle(customStyle)
expect(screen.getByText(/all custom props/i)).toBeInTheDocument()
expect(handleClick).toHaveBeenCalledTimes(1)
expect(mockGtag).toHaveBeenCalledWith('event', 'click_upgrade_btn', {
loc: 'test-loc',
})
})
})
// Accessibility Tests
describe('Accessibility', () => {
it('should be keyboard accessible with plain button', async () => {
// Arrange
const user = userEvent.setup()
const handleClick = jest.fn()
// Act
render(<UpgradeBtn isPlain onClick={handleClick} />)
const button = screen.getByRole('button')
// Tab to button
await user.tab()
expect(button).toHaveFocus()
// Press Enter
await user.keyboard('{Enter}')
// Assert
expect(handleClick).toHaveBeenCalledTimes(1)
})
it('should be keyboard accessible with Space key', async () => {
// Arrange
const user = userEvent.setup()
const handleClick = jest.fn()
// Act
render(<UpgradeBtn isPlain onClick={handleClick} />)
// Tab to button and press Space
await user.tab()
await user.keyboard(' ')
// Assert
expect(handleClick).toHaveBeenCalledTimes(1)
})
it('should be clickable for premium badge variant', async () => {
// Arrange
const user = userEvent.setup()
const handleClick = jest.fn()
// Act
render(<UpgradeBtn onClick={handleClick} />)
const badge = screen.getByText(/upgrade to pro/i).closest('div')
// Click badge
await user.click(badge!)
// Assert
expect(handleClick).toHaveBeenCalledTimes(1)
})
it('should have proper button role when isPlain is true', () => {
// Act
render(<UpgradeBtn isPlain />)
// Assert - Plain button should have button role
const button = screen.getByRole('button')
expect(button).toBeInTheDocument()
})
})
// Performance Tests
describe('Performance', () => {
it('should not rerender when props do not change', () => {
// Arrange
const { rerender } = render(<UpgradeBtn loc="test" />)
const firstRender = screen.getByText(/upgrade to pro/i)
// Act - Rerender with same props
rerender(<UpgradeBtn loc="test" />)
// Assert - Component should still be in document
expect(firstRender).toBeInTheDocument()
expect(screen.getByText(/upgrade to pro/i)).toBe(firstRender)
})
it('should rerender when props change', () => {
// Arrange
const { rerender } = render(<UpgradeBtn labelKey="custom.key" />)
expect(screen.getByText(/custom text/i)).toBeInTheDocument()
// Act - Rerender with different labelKey
rerender(<UpgradeBtn labelKey="custom.label.key" />)
// Assert - Should show new label
expect(screen.getByText(/custom label/i)).toBeInTheDocument()
expect(screen.queryByText(/custom text/i)).not.toBeInTheDocument()
})
it('should handle rapid rerenders efficiently', () => {
// Arrange
const { rerender } = render(<UpgradeBtn />)
// Act - Multiple rapid rerenders
for (let i = 0; i < 10; i++)
rerender(<UpgradeBtn />)
// Assert - Component should still render correctly
expect(screen.getByText(/upgrade to pro/i)).toBeInTheDocument()
})
it('should be memoized with React.memo', () => {
// Arrange
const TestWrapper = ({ children }: { children: React.ReactNode }) => <div>{children}</div>
const { rerender } = render(
<TestWrapper>
<UpgradeBtn />
</TestWrapper>,
)
const firstElement = screen.getByText(/upgrade to pro/i)
// Act - Rerender parent with same props
rerender(
<TestWrapper>
<UpgradeBtn />
</TestWrapper>,
)
// Assert - Element reference should be stable due to memo
expect(screen.getByText(/upgrade to pro/i)).toBe(firstElement)
})
})
// Integration Tests
describe('Integration', () => {
it('should work with modal context for pricing modal', async () => {
// Arrange
const user = userEvent.setup()
// Act
render(<UpgradeBtn />)
const badge = screen.getByText(/upgrade to pro/i).closest('div')
await user.click(badge!)
// Assert
await waitFor(() => {
expect(mockSetShowPricingModal).toHaveBeenCalledTimes(1)
})
})
it('should integrate onClick with analytics tracking', async () => {
// Arrange
const user = userEvent.setup()
const handleClick = jest.fn()
// Act
render(<UpgradeBtn onClick={handleClick} loc="integration-test" />)
const badge = screen.getByText(/upgrade to pro/i).closest('div')
await user.click(badge!)
// Assert - Both onClick and gtag should be called
await waitFor(() => {
expect(handleClick).toHaveBeenCalledTimes(1)
expect(mockGtag).toHaveBeenCalledWith('event', 'click_upgrade_btn', {
loc: 'integration-test',
})
})
})
})
})

View File

@ -0,0 +1,873 @@
import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'
import FilePreview from './index'
import type { CustomFile as File } from '@/models/datasets'
import { fetchFilePreview } from '@/service/common'
// Mock the fetchFilePreview service
jest.mock('@/service/common', () => ({
fetchFilePreview: jest.fn(),
}))
const mockFetchFilePreview = fetchFilePreview as jest.MockedFunction<typeof fetchFilePreview>
// Factory function to create mock file objects
const createMockFile = (overrides: Partial<File> = {}): File => {
const file = new window.File(['test content'], 'test-file.txt', {
type: 'text/plain',
}) as File
return Object.assign(file, {
id: 'file-123',
extension: 'txt',
mime_type: 'text/plain',
created_by: 'user-1',
created_at: Date.now(),
...overrides,
})
}
// Helper to render FilePreview with default props
const renderFilePreview = (props: Partial<{ file?: File; hidePreview: () => void }> = {}) => {
const defaultProps = {
file: createMockFile(),
hidePreview: jest.fn(),
...props,
}
return {
...render(<FilePreview {...defaultProps} />),
props: defaultProps,
}
}
// Helper to find the loading spinner element
const findLoadingSpinner = (container: HTMLElement) => {
return container.querySelector('.spin-animation')
}
// ============================================================================
// FilePreview Component Tests
// ============================================================================
describe('FilePreview', () => {
beforeEach(() => {
jest.clearAllMocks()
// Default successful API response
mockFetchFilePreview.mockResolvedValue({ content: 'Preview content here' })
})
// --------------------------------------------------------------------------
// Rendering Tests - Verify component renders properly
// --------------------------------------------------------------------------
describe('Rendering', () => {
it('should render without crashing', async () => {
// Arrange & Act
renderFilePreview()
// Assert
await waitFor(() => {
expect(screen.getByText('datasetCreation.stepOne.filePreview')).toBeInTheDocument()
})
})
it('should render file preview header', async () => {
// Arrange & Act
renderFilePreview()
// Assert
expect(screen.getByText('datasetCreation.stepOne.filePreview')).toBeInTheDocument()
})
it('should render close button with XMarkIcon', async () => {
// Arrange & Act
const { container } = renderFilePreview()
// Assert
const closeButton = container.querySelector('.cursor-pointer')
expect(closeButton).toBeInTheDocument()
const xMarkIcon = closeButton?.querySelector('svg')
expect(xMarkIcon).toBeInTheDocument()
})
it('should render file name without extension', async () => {
// Arrange
const file = createMockFile({ name: 'document.pdf' })
// Act
renderFilePreview({ file })
// Assert
await waitFor(() => {
expect(screen.getByText('document')).toBeInTheDocument()
})
})
it('should render file extension', async () => {
// Arrange
const file = createMockFile({ extension: 'pdf' })
// Act
renderFilePreview({ file })
// Assert
expect(screen.getByText('.pdf')).toBeInTheDocument()
})
it('should apply correct CSS classes to container', async () => {
// Arrange & Act
const { container } = renderFilePreview()
// Assert
const wrapper = container.firstChild as HTMLElement
expect(wrapper).toHaveClass('h-full')
})
})
// --------------------------------------------------------------------------
// Loading State Tests
// --------------------------------------------------------------------------
describe('Loading State', () => {
it('should show loading indicator initially', async () => {
// Arrange - Delay API response to keep loading state
mockFetchFilePreview.mockImplementation(
() => new Promise(resolve => setTimeout(() => resolve({ content: 'test' }), 100)),
)
// Act
const { container } = renderFilePreview()
// Assert - Loading should be visible initially (using spin-animation class)
const loadingElement = findLoadingSpinner(container)
expect(loadingElement).toBeInTheDocument()
})
it('should hide loading indicator after content loads', async () => {
// Arrange
mockFetchFilePreview.mockResolvedValue({ content: 'Loaded content' })
// Act
const { container } = renderFilePreview()
// Assert
await waitFor(() => {
expect(screen.getByText('Loaded content')).toBeInTheDocument()
})
// Loading should be gone
const loadingElement = findLoadingSpinner(container)
expect(loadingElement).not.toBeInTheDocument()
})
it('should show loading when file changes', async () => {
// Arrange
const file1 = createMockFile({ id: 'file-1', name: 'file1.txt' })
const file2 = createMockFile({ id: 'file-2', name: 'file2.txt' })
let resolveFirst: (value: { content: string }) => void
let resolveSecond: (value: { content: string }) => void
mockFetchFilePreview
.mockImplementationOnce(() => new Promise((resolve) => { resolveFirst = resolve }))
.mockImplementationOnce(() => new Promise((resolve) => { resolveSecond = resolve }))
// Act - Initial render
const { rerender, container } = render(
<FilePreview file={file1} hidePreview={jest.fn()} />,
)
// First file loading - spinner should be visible
expect(findLoadingSpinner(container)).toBeInTheDocument()
// Resolve first file
await act(async () => {
resolveFirst({ content: 'Content 1' })
})
await waitFor(() => {
expect(screen.getByText('Content 1')).toBeInTheDocument()
})
// Rerender with new file
rerender(<FilePreview file={file2} hidePreview={jest.fn()} />)
// Should show loading again
await waitFor(() => {
expect(findLoadingSpinner(container)).toBeInTheDocument()
})
// Resolve second file
await act(async () => {
resolveSecond({ content: 'Content 2' })
})
await waitFor(() => {
expect(screen.getByText('Content 2')).toBeInTheDocument()
})
})
})
// --------------------------------------------------------------------------
// API Call Tests
// --------------------------------------------------------------------------
describe('API Calls', () => {
it('should call fetchFilePreview with correct fileID', async () => {
// Arrange
const file = createMockFile({ id: 'test-file-id' })
// Act
renderFilePreview({ file })
// Assert
await waitFor(() => {
expect(mockFetchFilePreview).toHaveBeenCalledWith({ fileID: 'test-file-id' })
})
})
it('should not call fetchFilePreview when file is undefined', async () => {
// Arrange & Act
renderFilePreview({ file: undefined })
// Assert
expect(mockFetchFilePreview).not.toHaveBeenCalled()
})
it('should not call fetchFilePreview when file has no id', async () => {
// Arrange
const file = createMockFile({ id: undefined })
// Act
renderFilePreview({ file })
// Assert
expect(mockFetchFilePreview).not.toHaveBeenCalled()
})
it('should call fetchFilePreview again when file changes', async () => {
// Arrange
const file1 = createMockFile({ id: 'file-1' })
const file2 = createMockFile({ id: 'file-2' })
// Act
const { rerender } = render(
<FilePreview file={file1} hidePreview={jest.fn()} />,
)
await waitFor(() => {
expect(mockFetchFilePreview).toHaveBeenCalledWith({ fileID: 'file-1' })
})
rerender(<FilePreview file={file2} hidePreview={jest.fn()} />)
// Assert
await waitFor(() => {
expect(mockFetchFilePreview).toHaveBeenCalledWith({ fileID: 'file-2' })
expect(mockFetchFilePreview).toHaveBeenCalledTimes(2)
})
})
it('should handle API success and display content', async () => {
// Arrange
mockFetchFilePreview.mockResolvedValue({ content: 'File preview content from API' })
// Act
renderFilePreview()
// Assert
await waitFor(() => {
expect(screen.getByText('File preview content from API')).toBeInTheDocument()
})
})
it('should handle API error gracefully', async () => {
// Arrange
mockFetchFilePreview.mockRejectedValue(new Error('Network error'))
// Act
const { container } = renderFilePreview()
// Assert - Component should not crash, loading may persist
await waitFor(() => {
expect(container.firstChild).toBeInTheDocument()
})
// No error thrown, component still rendered
expect(screen.getByText('datasetCreation.stepOne.filePreview')).toBeInTheDocument()
})
it('should handle empty content response', async () => {
// Arrange
mockFetchFilePreview.mockResolvedValue({ content: '' })
// Act
const { container } = renderFilePreview()
// Assert - Should still render without loading
await waitFor(() => {
const loadingElement = findLoadingSpinner(container)
expect(loadingElement).not.toBeInTheDocument()
})
})
})
// --------------------------------------------------------------------------
// User Interactions Tests
// --------------------------------------------------------------------------
describe('User Interactions', () => {
it('should call hidePreview when close button is clicked', async () => {
// Arrange
const hidePreview = jest.fn()
const { container } = renderFilePreview({ hidePreview })
// Act
const closeButton = container.querySelector('.cursor-pointer') as HTMLElement
fireEvent.click(closeButton)
// Assert
expect(hidePreview).toHaveBeenCalledTimes(1)
})
it('should call hidePreview with event object when clicked', async () => {
// Arrange
const hidePreview = jest.fn()
const { container } = renderFilePreview({ hidePreview })
// Act
const closeButton = container.querySelector('.cursor-pointer') as HTMLElement
fireEvent.click(closeButton)
// Assert - onClick receives the event object
expect(hidePreview).toHaveBeenCalled()
expect(hidePreview.mock.calls[0][0]).toBeDefined()
})
it('should handle multiple clicks on close button', async () => {
// Arrange
const hidePreview = jest.fn()
const { container } = renderFilePreview({ hidePreview })
// Act
const closeButton = container.querySelector('.cursor-pointer') as HTMLElement
fireEvent.click(closeButton)
fireEvent.click(closeButton)
fireEvent.click(closeButton)
// Assert
expect(hidePreview).toHaveBeenCalledTimes(3)
})
})
// --------------------------------------------------------------------------
// State Management Tests
// --------------------------------------------------------------------------
describe('State Management', () => {
it('should initialize with loading state true', async () => {
// Arrange - Keep loading indefinitely (never resolves)
mockFetchFilePreview.mockImplementation(() => new Promise(() => { /* intentionally empty */ }))
// Act
const { container } = renderFilePreview()
// Assert
const loadingElement = findLoadingSpinner(container)
expect(loadingElement).toBeInTheDocument()
})
it('should update previewContent state after successful fetch', async () => {
// Arrange
mockFetchFilePreview.mockResolvedValue({ content: 'New preview content' })
// Act
renderFilePreview()
// Assert
await waitFor(() => {
expect(screen.getByText('New preview content')).toBeInTheDocument()
})
})
it('should reset loading to true when file changes', async () => {
// Arrange
const file1 = createMockFile({ id: 'file-1' })
const file2 = createMockFile({ id: 'file-2' })
mockFetchFilePreview
.mockResolvedValueOnce({ content: 'Content 1' })
.mockImplementationOnce(() => new Promise(() => { /* never resolves */ }))
// Act
const { rerender, container } = render(
<FilePreview file={file1} hidePreview={jest.fn()} />,
)
await waitFor(() => {
expect(screen.getByText('Content 1')).toBeInTheDocument()
})
// Change file
rerender(<FilePreview file={file2} hidePreview={jest.fn()} />)
// Assert - Loading should be shown again
await waitFor(() => {
const loadingElement = findLoadingSpinner(container)
expect(loadingElement).toBeInTheDocument()
})
})
it('should preserve content until new content loads', async () => {
// Arrange
const file1 = createMockFile({ id: 'file-1' })
const file2 = createMockFile({ id: 'file-2' })
let resolveSecond: (value: { content: string }) => void
mockFetchFilePreview
.mockResolvedValueOnce({ content: 'Content 1' })
.mockImplementationOnce(() => new Promise((resolve) => { resolveSecond = resolve }))
// Act
const { rerender } = render(
<FilePreview file={file1} hidePreview={jest.fn()} />,
)
await waitFor(() => {
expect(screen.getByText('Content 1')).toBeInTheDocument()
})
// Change file - loading should replace content
rerender(<FilePreview file={file2} hidePreview={jest.fn()} />)
// Resolve second fetch
await act(async () => {
resolveSecond({ content: 'Content 2' })
})
await waitFor(() => {
expect(screen.getByText('Content 2')).toBeInTheDocument()
expect(screen.queryByText('Content 1')).not.toBeInTheDocument()
})
})
})
// --------------------------------------------------------------------------
// Props Testing
// --------------------------------------------------------------------------
describe('Props', () => {
describe('file prop', () => {
it('should render correctly with file prop', async () => {
// Arrange
const file = createMockFile({ name: 'my-document.pdf', extension: 'pdf' })
// Act
renderFilePreview({ file })
// Assert
expect(screen.getByText('my-document')).toBeInTheDocument()
expect(screen.getByText('.pdf')).toBeInTheDocument()
})
it('should render correctly without file prop', async () => {
// Arrange & Act
renderFilePreview({ file: undefined })
// Assert - Header should still render
expect(screen.getByText('datasetCreation.stepOne.filePreview')).toBeInTheDocument()
})
it('should handle file with multiple dots in name', async () => {
// Arrange
const file = createMockFile({ name: 'my.document.v2.pdf' })
// Act
renderFilePreview({ file })
// Assert - Should join all parts except last with comma
expect(screen.getByText('my,document,v2')).toBeInTheDocument()
})
it('should handle file with no extension in name', async () => {
// Arrange
const file = createMockFile({ name: 'README' })
// Act
const { container } = renderFilePreview({ file })
// Assert - getFileName returns empty for single segment, but component still renders
const fileNameElement = container.querySelector('.fileName')
expect(fileNameElement).toBeInTheDocument()
// The first span (file name) should be empty
const fileNameSpan = fileNameElement?.querySelector('span:first-child')
expect(fileNameSpan?.textContent).toBe('')
})
it('should handle file with empty name', async () => {
// Arrange
const file = createMockFile({ name: '' })
// Act
const { container } = renderFilePreview({ file })
// Assert - Should not crash
expect(container.firstChild).toBeInTheDocument()
})
})
describe('hidePreview prop', () => {
it('should accept hidePreview callback', async () => {
// Arrange
const hidePreview = jest.fn()
// Act
renderFilePreview({ hidePreview })
// Assert - No errors thrown
expect(screen.getByText('datasetCreation.stepOne.filePreview')).toBeInTheDocument()
})
})
})
// --------------------------------------------------------------------------
// Edge Cases Tests
// --------------------------------------------------------------------------
describe('Edge Cases', () => {
it('should handle file with undefined id', async () => {
// Arrange
const file = createMockFile({ id: undefined })
// Act
const { container } = renderFilePreview({ file })
// Assert - Should not call API, remain in loading state
expect(mockFetchFilePreview).not.toHaveBeenCalled()
expect(container.firstChild).toBeInTheDocument()
})
it('should handle file with empty string id', async () => {
// Arrange
const file = createMockFile({ id: '' })
// Act
renderFilePreview({ file })
// Assert - Empty string is falsy, should not call API
expect(mockFetchFilePreview).not.toHaveBeenCalled()
})
it('should handle very long file names', async () => {
// Arrange
const longName = `${'a'.repeat(200)}.pdf`
const file = createMockFile({ name: longName })
// Act
renderFilePreview({ file })
// Assert
expect(screen.getByText('a'.repeat(200))).toBeInTheDocument()
})
it('should handle file with special characters in name', async () => {
// Arrange
const file = createMockFile({ name: 'file-with_special@#$%.txt' })
// Act
renderFilePreview({ file })
// Assert
expect(screen.getByText('file-with_special@#$%')).toBeInTheDocument()
})
it('should handle very long preview content', async () => {
// Arrange
const longContent = 'x'.repeat(10000)
mockFetchFilePreview.mockResolvedValue({ content: longContent })
// Act
renderFilePreview()
// Assert
await waitFor(() => {
expect(screen.getByText(longContent)).toBeInTheDocument()
})
})
it('should handle preview content with special characters safely', async () => {
// Arrange
const specialContent = '<script>alert("xss")</script>\n\t& < > "'
mockFetchFilePreview.mockResolvedValue({ content: specialContent })
// Act
const { container } = renderFilePreview()
// Assert - Should render as text, not execute scripts
await waitFor(() => {
const contentDiv = container.querySelector('.fileContent')
expect(contentDiv).toBeInTheDocument()
// Content is escaped by React, so HTML entities are displayed
expect(contentDiv?.textContent).toContain('alert')
})
})
it('should handle preview content with unicode', async () => {
// Arrange
const unicodeContent = '中文内容 🚀 émojis & spëcîal çhàrs'
mockFetchFilePreview.mockResolvedValue({ content: unicodeContent })
// Act
renderFilePreview()
// Assert
await waitFor(() => {
expect(screen.getByText(unicodeContent)).toBeInTheDocument()
})
})
it('should handle preview content with newlines', async () => {
// Arrange
const multilineContent = 'Line 1\nLine 2\nLine 3'
mockFetchFilePreview.mockResolvedValue({ content: multilineContent })
// Act
const { container } = renderFilePreview()
// Assert - Content should be in the DOM
await waitFor(() => {
const contentDiv = container.querySelector('.fileContent')
expect(contentDiv).toBeInTheDocument()
expect(contentDiv?.textContent).toContain('Line 1')
expect(contentDiv?.textContent).toContain('Line 2')
expect(contentDiv?.textContent).toContain('Line 3')
})
})
it('should handle null content from API', async () => {
// Arrange
mockFetchFilePreview.mockResolvedValue({ content: null as unknown as string })
// Act
const { container } = renderFilePreview()
// Assert - Should not crash
await waitFor(() => {
expect(container.firstChild).toBeInTheDocument()
})
})
})
// --------------------------------------------------------------------------
// Side Effects and Cleanup Tests
// --------------------------------------------------------------------------
describe('Side Effects and Cleanup', () => {
it('should trigger effect when file prop changes', async () => {
// Arrange
const file1 = createMockFile({ id: 'file-1' })
const file2 = createMockFile({ id: 'file-2' })
// Act
const { rerender } = render(
<FilePreview file={file1} hidePreview={jest.fn()} />,
)
await waitFor(() => {
expect(mockFetchFilePreview).toHaveBeenCalledTimes(1)
})
rerender(<FilePreview file={file2} hidePreview={jest.fn()} />)
// Assert
await waitFor(() => {
expect(mockFetchFilePreview).toHaveBeenCalledTimes(2)
})
})
it('should not trigger effect when hidePreview changes', async () => {
// Arrange
const file = createMockFile()
const hidePreview1 = jest.fn()
const hidePreview2 = jest.fn()
// Act
const { rerender } = render(
<FilePreview file={file} hidePreview={hidePreview1} />,
)
await waitFor(() => {
expect(mockFetchFilePreview).toHaveBeenCalledTimes(1)
})
rerender(<FilePreview file={file} hidePreview={hidePreview2} />)
// Assert - Should not call API again (file didn't change)
// Note: This depends on useEffect dependency array only including [file]
await waitFor(() => {
expect(mockFetchFilePreview).toHaveBeenCalledTimes(1)
})
})
it('should handle rapid file changes', async () => {
// Arrange
const files = Array.from({ length: 5 }, (_, i) =>
createMockFile({ id: `file-${i}` }),
)
// Act
const { rerender } = render(
<FilePreview file={files[0]} hidePreview={jest.fn()} />,
)
// Rapidly change files
for (let i = 1; i < files.length; i++)
rerender(<FilePreview file={files[i]} hidePreview={jest.fn()} />)
// Assert - Should have called API for each file
await waitFor(() => {
expect(mockFetchFilePreview).toHaveBeenCalledTimes(5)
})
})
it('should handle unmount during loading', async () => {
// Arrange
mockFetchFilePreview.mockImplementation(
() => new Promise(resolve => setTimeout(() => resolve({ content: 'delayed' }), 1000)),
)
// Act
const { unmount } = renderFilePreview()
// Unmount before API resolves
unmount()
// Assert - No errors should be thrown (React handles state updates on unmounted)
expect(true).toBe(true)
})
it('should handle file changing from defined to undefined', async () => {
// Arrange
const file = createMockFile()
// Act
const { rerender, container } = render(
<FilePreview file={file} hidePreview={jest.fn()} />,
)
await waitFor(() => {
expect(mockFetchFilePreview).toHaveBeenCalledTimes(1)
})
rerender(<FilePreview file={undefined} hidePreview={jest.fn()} />)
// Assert - Should not crash, API should not be called again
expect(container.firstChild).toBeInTheDocument()
expect(mockFetchFilePreview).toHaveBeenCalledTimes(1)
})
})
// --------------------------------------------------------------------------
// getFileName Helper Tests
// --------------------------------------------------------------------------
describe('getFileName Helper', () => {
it('should extract name without extension for simple filename', async () => {
// Arrange
const file = createMockFile({ name: 'document.pdf' })
// Act
renderFilePreview({ file })
// Assert
expect(screen.getByText('document')).toBeInTheDocument()
})
it('should handle filename with multiple dots', async () => {
// Arrange
const file = createMockFile({ name: 'file.name.with.dots.txt' })
// Act
renderFilePreview({ file })
// Assert - Should join all parts except last with comma
expect(screen.getByText('file,name,with,dots')).toBeInTheDocument()
})
it('should return empty for filename without dot', async () => {
// Arrange
const file = createMockFile({ name: 'nodotfile' })
// Act
const { container } = renderFilePreview({ file })
// Assert - slice(0, -1) on single element array returns empty
const fileNameElement = container.querySelector('.fileName')
const firstSpan = fileNameElement?.querySelector('span:first-child')
expect(firstSpan?.textContent).toBe('')
})
it('should return empty string when file is undefined', async () => {
// Arrange & Act
const { container } = renderFilePreview({ file: undefined })
// Assert - File name area should have empty first span
const fileNameElement = container.querySelector('.system-xs-medium')
expect(fileNameElement).toBeInTheDocument()
})
})
// --------------------------------------------------------------------------
// Accessibility Tests
// --------------------------------------------------------------------------
describe('Accessibility', () => {
it('should have clickable close button with visual indicator', async () => {
// Arrange & Act
const { container } = renderFilePreview()
// Assert
const closeButton = container.querySelector('.cursor-pointer')
expect(closeButton).toBeInTheDocument()
expect(closeButton).toHaveClass('cursor-pointer')
})
it('should have proper heading structure', async () => {
// Arrange & Act
renderFilePreview()
// Assert
expect(screen.getByText('datasetCreation.stepOne.filePreview')).toBeInTheDocument()
})
})
// --------------------------------------------------------------------------
// Error Handling Tests
// --------------------------------------------------------------------------
describe('Error Handling', () => {
it('should not crash on API network error', async () => {
// Arrange
mockFetchFilePreview.mockRejectedValue(new Error('Network Error'))
// Act
const { container } = renderFilePreview()
// Assert - Component should still render
await waitFor(() => {
expect(container.firstChild).toBeInTheDocument()
})
})
it('should not crash on API timeout', async () => {
// Arrange
mockFetchFilePreview.mockRejectedValue(new Error('Timeout'))
// Act
const { container } = renderFilePreview()
// Assert
await waitFor(() => {
expect(container.firstChild).toBeInTheDocument()
})
})
it('should not crash on malformed API response', async () => {
// Arrange
mockFetchFilePreview.mockResolvedValue({} as { content: string })
// Act
const { container } = renderFilePreview()
// Assert
await waitFor(() => {
expect(container.firstChild).toBeInTheDocument()
})
})
})
})

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,844 @@
import { render, screen } from '@testing-library/react'
import StepThree from './index'
import type { FullDocumentDetail, IconInfo, createDocumentResponse } from '@/models/datasets'
// Mock the EmbeddingProcess component since it has complex async logic
jest.mock('../embedding-process', () => ({
__esModule: true,
default: jest.fn(({ datasetId, batchId, documents, indexingType, retrievalMethod }) => (
<div data-testid="embedding-process">
<span data-testid="ep-dataset-id">{datasetId}</span>
<span data-testid="ep-batch-id">{batchId}</span>
<span data-testid="ep-documents-count">{documents?.length ?? 0}</span>
<span data-testid="ep-indexing-type">{indexingType}</span>
<span data-testid="ep-retrieval-method">{retrievalMethod}</span>
</div>
)),
}))
// Mock useBreakpoints hook
let mockMediaType = 'pc'
jest.mock('@/hooks/use-breakpoints', () => ({
__esModule: true,
MediaType: {
mobile: 'mobile',
tablet: 'tablet',
pc: 'pc',
},
default: jest.fn(() => mockMediaType),
}))
// Mock useDocLink hook
jest.mock('@/context/i18n', () => ({
useDocLink: () => (path?: string) => `https://docs.dify.ai/en-US${path || ''}`,
}))
// Factory function to create mock IconInfo
const createMockIconInfo = (overrides: Partial<IconInfo> = {}): IconInfo => ({
icon: '📙',
icon_type: 'emoji',
icon_background: '#FFF4ED',
icon_url: '',
...overrides,
})
// Factory function to create mock FullDocumentDetail
const createMockDocument = (overrides: Partial<FullDocumentDetail> = {}): FullDocumentDetail => ({
id: 'doc-123',
name: 'test-document.txt',
data_source_type: 'upload_file',
data_source_info: {
upload_file: {
id: 'file-123',
name: 'test-document.txt',
extension: 'txt',
mime_type: 'text/plain',
size: 1024,
created_by: 'user-1',
created_at: Date.now(),
},
},
batch: 'batch-123',
created_api_request_id: 'request-123',
processing_started_at: Date.now(),
parsing_completed_at: Date.now(),
cleaning_completed_at: Date.now(),
splitting_completed_at: Date.now(),
tokens: 100,
indexing_latency: 5000,
completed_at: Date.now(),
paused_by: '',
paused_at: 0,
stopped_at: 0,
indexing_status: 'completed',
disabled_at: 0,
...overrides,
} as FullDocumentDetail)
// Factory function to create mock createDocumentResponse
const createMockCreationCache = (overrides: Partial<createDocumentResponse> = {}): createDocumentResponse => ({
dataset: {
id: 'dataset-123',
name: 'Test Dataset',
icon_info: createMockIconInfo(),
indexing_technique: 'high_quality',
retrieval_model_dict: {
search_method: 'semantic_search',
},
} as createDocumentResponse['dataset'],
batch: 'batch-123',
documents: [createMockDocument()] as createDocumentResponse['documents'],
...overrides,
})
// Helper to render StepThree with default props
const renderStepThree = (props: Partial<Parameters<typeof StepThree>[0]> = {}) => {
const defaultProps = {
...props,
}
return render(<StepThree {...defaultProps} />)
}
// ============================================================================
// StepThree Component Tests
// ============================================================================
describe('StepThree', () => {
beforeEach(() => {
jest.clearAllMocks()
mockMediaType = 'pc'
})
// --------------------------------------------------------------------------
// Rendering Tests - Verify component renders properly
// --------------------------------------------------------------------------
describe('Rendering', () => {
it('should render without crashing', () => {
// Arrange & Act
renderStepThree()
// Assert
expect(screen.getByTestId('embedding-process')).toBeInTheDocument()
})
it('should render with creation title when datasetId is not provided', () => {
// Arrange & Act
renderStepThree()
// Assert
expect(screen.getByText('datasetCreation.stepThree.creationTitle')).toBeInTheDocument()
expect(screen.getByText('datasetCreation.stepThree.creationContent')).toBeInTheDocument()
})
it('should render with addition title when datasetId is provided', () => {
// Arrange & Act
renderStepThree({
datasetId: 'existing-dataset-123',
datasetName: 'Existing Dataset',
})
// Assert
expect(screen.getByText('datasetCreation.stepThree.additionTitle')).toBeInTheDocument()
expect(screen.queryByText('datasetCreation.stepThree.creationTitle')).not.toBeInTheDocument()
})
it('should render label text in creation mode', () => {
// Arrange & Act
renderStepThree()
// Assert
expect(screen.getByText('datasetCreation.stepThree.label')).toBeInTheDocument()
})
it('should render side tip panel on desktop', () => {
// Arrange
mockMediaType = 'pc'
// Act
renderStepThree()
// Assert
expect(screen.getByText('datasetCreation.stepThree.sideTipTitle')).toBeInTheDocument()
expect(screen.getByText('datasetCreation.stepThree.sideTipContent')).toBeInTheDocument()
expect(screen.getByText('datasetPipeline.addDocuments.stepThree.learnMore')).toBeInTheDocument()
})
it('should not render side tip panel on mobile', () => {
// Arrange
mockMediaType = 'mobile'
// Act
renderStepThree()
// Assert
expect(screen.queryByText('datasetCreation.stepThree.sideTipTitle')).not.toBeInTheDocument()
expect(screen.queryByText('datasetCreation.stepThree.sideTipContent')).not.toBeInTheDocument()
})
it('should render EmbeddingProcess component', () => {
// Arrange & Act
renderStepThree()
// Assert
expect(screen.getByTestId('embedding-process')).toBeInTheDocument()
})
it('should render documentation link with correct href on desktop', () => {
// Arrange
mockMediaType = 'pc'
// Act
renderStepThree()
// Assert
const link = screen.getByText('datasetPipeline.addDocuments.stepThree.learnMore')
expect(link).toHaveAttribute('href', 'https://docs.dify.ai/en-US/guides/knowledge-base/integrate-knowledge-within-application')
expect(link).toHaveAttribute('target', '_blank')
expect(link).toHaveAttribute('rel', 'noreferrer noopener')
})
it('should apply correct container classes', () => {
// Arrange & Act
const { container } = renderStepThree()
// Assert
const outerDiv = container.firstChild as HTMLElement
expect(outerDiv).toHaveClass('flex', 'h-full', 'max-h-full', 'w-full', 'justify-center', 'overflow-y-auto')
})
})
// --------------------------------------------------------------------------
// Props Testing - Test all prop variations
// --------------------------------------------------------------------------
describe('Props', () => {
describe('datasetId prop', () => {
it('should render creation mode when datasetId is undefined', () => {
// Arrange & Act
renderStepThree({ datasetId: undefined })
// Assert
expect(screen.getByText('datasetCreation.stepThree.creationTitle')).toBeInTheDocument()
})
it('should render addition mode when datasetId is provided', () => {
// Arrange & Act
renderStepThree({ datasetId: 'dataset-123' })
// Assert
expect(screen.getByText('datasetCreation.stepThree.additionTitle')).toBeInTheDocument()
})
it('should pass datasetId to EmbeddingProcess', () => {
// Arrange
const datasetId = 'my-dataset-id'
// Act
renderStepThree({ datasetId })
// Assert
expect(screen.getByTestId('ep-dataset-id')).toHaveTextContent(datasetId)
})
it('should use creationCache dataset id when datasetId is not provided', () => {
// Arrange
const creationCache = createMockCreationCache()
// Act
renderStepThree({ creationCache })
// Assert
expect(screen.getByTestId('ep-dataset-id')).toHaveTextContent('dataset-123')
})
})
describe('datasetName prop', () => {
it('should display datasetName in creation mode', () => {
// Arrange & Act
renderStepThree({ datasetName: 'My Custom Dataset' })
// Assert
expect(screen.getByText('My Custom Dataset')).toBeInTheDocument()
})
it('should display datasetName in addition mode description', () => {
// Arrange & Act
renderStepThree({
datasetId: 'dataset-123',
datasetName: 'Existing Dataset Name',
})
// Assert - Check the text contains the dataset name (in the description)
const description = screen.getByText(/datasetCreation.stepThree.additionP1.*Existing Dataset Name.*datasetCreation.stepThree.additionP2/i)
expect(description).toBeInTheDocument()
})
it('should fallback to creationCache dataset name when datasetName is not provided', () => {
// Arrange
const creationCache = createMockCreationCache()
creationCache.dataset!.name = 'Cache Dataset Name'
// Act
renderStepThree({ creationCache })
// Assert
expect(screen.getByText('Cache Dataset Name')).toBeInTheDocument()
})
})
describe('indexingType prop', () => {
it('should pass indexingType to EmbeddingProcess', () => {
// Arrange & Act
renderStepThree({ indexingType: 'high_quality' })
// Assert
expect(screen.getByTestId('ep-indexing-type')).toHaveTextContent('high_quality')
})
it('should use creationCache indexing_technique when indexingType is not provided', () => {
// Arrange
const creationCache = createMockCreationCache()
creationCache.dataset!.indexing_technique = 'economy' as any
// Act
renderStepThree({ creationCache })
// Assert
expect(screen.getByTestId('ep-indexing-type')).toHaveTextContent('economy')
})
it('should prefer creationCache indexing_technique over indexingType prop', () => {
// Arrange
const creationCache = createMockCreationCache()
creationCache.dataset!.indexing_technique = 'cache_technique' as any
// Act
renderStepThree({ creationCache, indexingType: 'prop_technique' })
// Assert - creationCache takes precedence
expect(screen.getByTestId('ep-indexing-type')).toHaveTextContent('cache_technique')
})
})
describe('retrievalMethod prop', () => {
it('should pass retrievalMethod to EmbeddingProcess', () => {
// Arrange & Act
renderStepThree({ retrievalMethod: 'semantic_search' })
// Assert
expect(screen.getByTestId('ep-retrieval-method')).toHaveTextContent('semantic_search')
})
it('should use creationCache retrieval method when retrievalMethod is not provided', () => {
// Arrange
const creationCache = createMockCreationCache()
creationCache.dataset!.retrieval_model_dict = { search_method: 'full_text_search' } as any
// Act
renderStepThree({ creationCache })
// Assert
expect(screen.getByTestId('ep-retrieval-method')).toHaveTextContent('full_text_search')
})
})
describe('creationCache prop', () => {
it('should pass batchId from creationCache to EmbeddingProcess', () => {
// Arrange
const creationCache = createMockCreationCache()
creationCache.batch = 'custom-batch-123'
// Act
renderStepThree({ creationCache })
// Assert
expect(screen.getByTestId('ep-batch-id')).toHaveTextContent('custom-batch-123')
})
it('should pass documents from creationCache to EmbeddingProcess', () => {
// Arrange
const creationCache = createMockCreationCache()
creationCache.documents = [createMockDocument(), createMockDocument(), createMockDocument()] as any
// Act
renderStepThree({ creationCache })
// Assert
expect(screen.getByTestId('ep-documents-count')).toHaveTextContent('3')
})
it('should use icon_info from creationCache dataset', () => {
// Arrange
const creationCache = createMockCreationCache()
creationCache.dataset!.icon_info = createMockIconInfo({
icon: '🚀',
icon_background: '#FF0000',
})
// Act
const { container } = renderStepThree({ creationCache })
// Assert - Check AppIcon component receives correct props
const appIcon = container.querySelector('span[style*="background"]')
expect(appIcon).toBeInTheDocument()
})
it('should handle undefined creationCache', () => {
// Arrange & Act
renderStepThree({ creationCache: undefined })
// Assert - Should not crash, use fallback values
expect(screen.getByTestId('ep-dataset-id')).toHaveTextContent('')
expect(screen.getByTestId('ep-batch-id')).toHaveTextContent('')
})
it('should handle creationCache with undefined dataset', () => {
// Arrange
const creationCache: createDocumentResponse = {
dataset: undefined,
batch: 'batch-123',
documents: [],
}
// Act
renderStepThree({ creationCache })
// Assert - Should use default icon info
expect(screen.getByTestId('embedding-process')).toBeInTheDocument()
})
})
})
// --------------------------------------------------------------------------
// Edge Cases Tests - Test null, undefined, empty values and boundaries
// --------------------------------------------------------------------------
describe('Edge Cases', () => {
it('should handle all props being undefined', () => {
// Arrange & Act
renderStepThree({
datasetId: undefined,
datasetName: undefined,
indexingType: undefined,
retrievalMethod: undefined,
creationCache: undefined,
})
// Assert - Should render creation mode with fallbacks
expect(screen.getByText('datasetCreation.stepThree.creationTitle')).toBeInTheDocument()
expect(screen.getByTestId('embedding-process')).toBeInTheDocument()
})
it('should handle empty string datasetId', () => {
// Arrange & Act
renderStepThree({ datasetId: '' })
// Assert - Empty string is falsy, should show creation mode
expect(screen.getByText('datasetCreation.stepThree.creationTitle')).toBeInTheDocument()
})
it('should handle empty string datasetName', () => {
// Arrange & Act
renderStepThree({ datasetName: '' })
// Assert - Should not crash
expect(screen.getByTestId('embedding-process')).toBeInTheDocument()
})
it('should handle empty documents array in creationCache', () => {
// Arrange
const creationCache = createMockCreationCache()
creationCache.documents = []
// Act
renderStepThree({ creationCache })
// Assert
expect(screen.getByTestId('ep-documents-count')).toHaveTextContent('0')
})
it('should handle creationCache with missing icon_info', () => {
// Arrange
const creationCache = createMockCreationCache()
creationCache.dataset!.icon_info = undefined as any
// Act
renderStepThree({ creationCache })
// Assert - Should use default icon info
expect(screen.getByTestId('embedding-process')).toBeInTheDocument()
})
it('should handle very long datasetName', () => {
// Arrange
const longName = 'A'.repeat(500)
// Act
renderStepThree({ datasetName: longName })
// Assert - Should render without crashing
expect(screen.getByText(longName)).toBeInTheDocument()
})
it('should handle special characters in datasetName', () => {
// Arrange
const specialName = 'Dataset <script>alert("xss")</script> & "quotes" \'apostrophe\''
// Act
renderStepThree({ datasetName: specialName })
// Assert - Should render safely as text
expect(screen.getByText(specialName)).toBeInTheDocument()
})
it('should handle unicode characters in datasetName', () => {
// Arrange
const unicodeName = '数据集名称 🚀 émojis & spëcîal çhàrs'
// Act
renderStepThree({ datasetName: unicodeName })
// Assert
expect(screen.getByText(unicodeName)).toBeInTheDocument()
})
it('should handle creationCache with null dataset name', () => {
// Arrange
const creationCache = createMockCreationCache()
creationCache.dataset!.name = null as any
// Act
const { container } = renderStepThree({ creationCache })
// Assert - Should not crash
expect(container.firstChild).toBeInTheDocument()
})
})
// --------------------------------------------------------------------------
// Conditional Rendering Tests - Test mode switching behavior
// --------------------------------------------------------------------------
describe('Conditional Rendering', () => {
describe('Creation Mode (no datasetId)', () => {
it('should show AppIcon component', () => {
// Arrange & Act
const { container } = renderStepThree()
// Assert - AppIcon should be rendered
const appIcon = container.querySelector('span')
expect(appIcon).toBeInTheDocument()
})
it('should show Divider component', () => {
// Arrange & Act
const { container } = renderStepThree()
// Assert - Divider should be rendered (it adds hr with specific classes)
const dividers = container.querySelectorAll('[class*="divider"]')
expect(dividers.length).toBeGreaterThan(0)
})
it('should show dataset name input area', () => {
// Arrange
const datasetName = 'Test Dataset Name'
// Act
renderStepThree({ datasetName })
// Assert
expect(screen.getByText(datasetName)).toBeInTheDocument()
})
})
describe('Addition Mode (with datasetId)', () => {
it('should not show AppIcon component', () => {
// Arrange & Act
renderStepThree({ datasetId: 'dataset-123' })
// Assert - Creation section should not be rendered
expect(screen.queryByText('datasetCreation.stepThree.label')).not.toBeInTheDocument()
})
it('should show addition description with dataset name', () => {
// Arrange & Act
renderStepThree({
datasetId: 'dataset-123',
datasetName: 'My Dataset',
})
// Assert - Description should include dataset name
expect(screen.getByText(/datasetCreation.stepThree.additionP1/)).toBeInTheDocument()
})
})
describe('Mobile vs Desktop', () => {
it('should show side panel on tablet', () => {
// Arrange
mockMediaType = 'tablet'
// Act
renderStepThree()
// Assert - Tablet is not mobile, should show side panel
expect(screen.getByText('datasetCreation.stepThree.sideTipTitle')).toBeInTheDocument()
})
it('should not show side panel on mobile', () => {
// Arrange
mockMediaType = 'mobile'
// Act
renderStepThree()
// Assert
expect(screen.queryByText('datasetCreation.stepThree.sideTipTitle')).not.toBeInTheDocument()
})
it('should render EmbeddingProcess on mobile', () => {
// Arrange
mockMediaType = 'mobile'
// Act
renderStepThree()
// Assert - Main content should still be rendered
expect(screen.getByTestId('embedding-process')).toBeInTheDocument()
})
})
})
// --------------------------------------------------------------------------
// EmbeddingProcess Integration Tests - Verify correct props are passed
// --------------------------------------------------------------------------
describe('EmbeddingProcess Integration', () => {
it('should pass correct datasetId to EmbeddingProcess with datasetId prop', () => {
// Arrange & Act
renderStepThree({ datasetId: 'direct-dataset-id' })
// Assert
expect(screen.getByTestId('ep-dataset-id')).toHaveTextContent('direct-dataset-id')
})
it('should pass creationCache dataset id when datasetId prop is undefined', () => {
// Arrange
const creationCache = createMockCreationCache()
creationCache.dataset!.id = 'cache-dataset-id'
// Act
renderStepThree({ creationCache })
// Assert
expect(screen.getByTestId('ep-dataset-id')).toHaveTextContent('cache-dataset-id')
})
it('should pass empty string for datasetId when both sources are undefined', () => {
// Arrange & Act
renderStepThree()
// Assert
expect(screen.getByTestId('ep-dataset-id')).toHaveTextContent('')
})
it('should pass batchId from creationCache', () => {
// Arrange
const creationCache = createMockCreationCache()
creationCache.batch = 'test-batch-456'
// Act
renderStepThree({ creationCache })
// Assert
expect(screen.getByTestId('ep-batch-id')).toHaveTextContent('test-batch-456')
})
it('should pass empty string for batchId when creationCache is undefined', () => {
// Arrange & Act
renderStepThree()
// Assert
expect(screen.getByTestId('ep-batch-id')).toHaveTextContent('')
})
it('should prefer datasetId prop over creationCache dataset id', () => {
// Arrange
const creationCache = createMockCreationCache()
creationCache.dataset!.id = 'cache-id'
// Act
renderStepThree({ datasetId: 'prop-id', creationCache })
// Assert - datasetId prop takes precedence
expect(screen.getByTestId('ep-dataset-id')).toHaveTextContent('prop-id')
})
})
// --------------------------------------------------------------------------
// Icon Rendering Tests - Verify AppIcon behavior
// --------------------------------------------------------------------------
describe('Icon Rendering', () => {
it('should use default icon info when creationCache is undefined', () => {
// Arrange & Act
const { container } = renderStepThree()
// Assert - Default background color should be applied
const appIcon = container.querySelector('span[style*="background"]')
if (appIcon)
expect(appIcon).toHaveStyle({ background: '#FFF4ED' })
})
it('should use icon_info from creationCache when available', () => {
// Arrange
const creationCache = createMockCreationCache()
creationCache.dataset!.icon_info = {
icon: '🎉',
icon_type: 'emoji',
icon_background: '#00FF00',
icon_url: '',
}
// Act
const { container } = renderStepThree({ creationCache })
// Assert - Custom background color should be applied
const appIcon = container.querySelector('span[style*="background"]')
if (appIcon)
expect(appIcon).toHaveStyle({ background: '#00FF00' })
})
it('should use default icon when creationCache dataset icon_info is undefined', () => {
// Arrange
const creationCache = createMockCreationCache()
delete (creationCache.dataset as any).icon_info
// Act
const { container } = renderStepThree({ creationCache })
// Assert - Component should still render with default icon
expect(container.firstChild).toBeInTheDocument()
})
})
// --------------------------------------------------------------------------
// Layout Tests - Verify correct CSS classes and structure
// --------------------------------------------------------------------------
describe('Layout', () => {
it('should have correct outer container classes', () => {
// Arrange & Act
const { container } = renderStepThree()
// Assert
const outerDiv = container.firstChild as HTMLElement
expect(outerDiv).toHaveClass('flex')
expect(outerDiv).toHaveClass('h-full')
expect(outerDiv).toHaveClass('justify-center')
})
it('should have correct inner container classes', () => {
// Arrange & Act
const { container } = renderStepThree()
// Assert
const innerDiv = container.querySelector('.max-w-\\[960px\\]')
expect(innerDiv).toBeInTheDocument()
expect(innerDiv).toHaveClass('shrink-0', 'grow')
})
it('should have content wrapper with correct max width', () => {
// Arrange & Act
const { container } = renderStepThree()
// Assert
const contentWrapper = container.querySelector('.max-w-\\[640px\\]')
expect(contentWrapper).toBeInTheDocument()
})
it('should have side tip panel with correct width on desktop', () => {
// Arrange
mockMediaType = 'pc'
// Act
const { container } = renderStepThree()
// Assert
const sidePanel = container.querySelector('.w-\\[328px\\]')
expect(sidePanel).toBeInTheDocument()
})
})
// --------------------------------------------------------------------------
// Accessibility Tests - Verify accessibility features
// --------------------------------------------------------------------------
describe('Accessibility', () => {
it('should have correct link attributes for external documentation link', () => {
// Arrange
mockMediaType = 'pc'
// Act
renderStepThree()
// Assert
const link = screen.getByText('datasetPipeline.addDocuments.stepThree.learnMore')
expect(link.tagName).toBe('A')
expect(link).toHaveAttribute('target', '_blank')
expect(link).toHaveAttribute('rel', 'noreferrer noopener')
})
it('should have semantic heading structure in creation mode', () => {
// Arrange & Act
renderStepThree()
// Assert
const title = screen.getByText('datasetCreation.stepThree.creationTitle')
expect(title).toBeInTheDocument()
expect(title.className).toContain('title-2xl-semi-bold')
})
it('should have semantic heading structure in addition mode', () => {
// Arrange & Act
renderStepThree({ datasetId: 'dataset-123' })
// Assert
const title = screen.getByText('datasetCreation.stepThree.additionTitle')
expect(title).toBeInTheDocument()
expect(title.className).toContain('title-2xl-semi-bold')
})
})
// --------------------------------------------------------------------------
// Side Panel Tests - Verify side panel behavior
// --------------------------------------------------------------------------
describe('Side Panel', () => {
it('should render RiBookOpenLine icon in side panel', () => {
// Arrange
mockMediaType = 'pc'
// Act
const { container } = renderStepThree()
// Assert - Icon should be present in side panel
const iconContainer = container.querySelector('.size-10')
expect(iconContainer).toBeInTheDocument()
})
it('should have correct side panel section background', () => {
// Arrange
mockMediaType = 'pc'
// Act
const { container } = renderStepThree()
// Assert
const sidePanel = container.querySelector('.bg-background-section')
expect(sidePanel).toBeInTheDocument()
})
it('should have correct padding for side panel', () => {
// Arrange
mockMediaType = 'pc'
// Act
const { container } = renderStepThree()
// Assert
const sidePanelWrapper = container.querySelector('.pr-8')
expect(sidePanelWrapper).toBeInTheDocument()
})
})
})

View File

@ -0,0 +1,735 @@
import { render, screen } from '@testing-library/react'
import { Stepper, type StepperProps } from './index'
import { type Step, StepperStep, type StepperStepProps } from './step'
// Test data factory for creating steps
const createStep = (overrides: Partial<Step> = {}): Step => ({
name: 'Test Step',
...overrides,
})
const createSteps = (count: number, namePrefix = 'Step'): Step[] =>
Array.from({ length: count }, (_, i) => createStep({ name: `${namePrefix} ${i + 1}` }))
// Helper to render Stepper with default props
const renderStepper = (props: Partial<StepperProps> = {}) => {
const defaultProps: StepperProps = {
steps: createSteps(3),
activeIndex: 0,
...props,
}
return render(<Stepper {...defaultProps} />)
}
// Helper to render StepperStep with default props
const renderStepperStep = (props: Partial<StepperStepProps> = {}) => {
const defaultProps: StepperStepProps = {
name: 'Test Step',
index: 0,
activeIndex: 0,
...props,
}
return render(<StepperStep {...defaultProps} />)
}
// ============================================================================
// Stepper Component Tests
// ============================================================================
describe('Stepper', () => {
beforeEach(() => {
jest.clearAllMocks()
})
// --------------------------------------------------------------------------
// Rendering Tests - Verify component renders properly with various inputs
// --------------------------------------------------------------------------
describe('Rendering', () => {
it('should render without crashing', () => {
// Arrange & Act
renderStepper()
// Assert
expect(screen.getByText('Step 1')).toBeInTheDocument()
})
it('should render all step names', () => {
// Arrange
const steps = createSteps(3, 'Custom Step')
// Act
renderStepper({ steps })
// Assert
expect(screen.getByText('Custom Step 1')).toBeInTheDocument()
expect(screen.getByText('Custom Step 2')).toBeInTheDocument()
expect(screen.getByText('Custom Step 3')).toBeInTheDocument()
})
it('should render dividers between steps', () => {
// Arrange
const steps = createSteps(3)
// Act
const { container } = renderStepper({ steps })
// Assert - Should have 2 dividers for 3 steps
const dividers = container.querySelectorAll('.bg-divider-deep')
expect(dividers.length).toBe(2)
})
it('should not render divider after last step', () => {
// Arrange
const steps = createSteps(2)
// Act
const { container } = renderStepper({ steps })
// Assert - Should have 1 divider for 2 steps
const dividers = container.querySelectorAll('.bg-divider-deep')
expect(dividers.length).toBe(1)
})
it('should render with flex container layout', () => {
// Arrange & Act
const { container } = renderStepper()
// Assert
const wrapper = container.firstChild as HTMLElement
expect(wrapper).toHaveClass('flex', 'items-center', 'gap-3')
})
})
// --------------------------------------------------------------------------
// Props Testing - Test all prop variations and combinations
// --------------------------------------------------------------------------
describe('Props', () => {
describe('steps prop', () => {
it('should render correct number of steps', () => {
// Arrange
const steps = createSteps(5)
// Act
renderStepper({ steps })
// Assert
expect(screen.getByText('Step 1')).toBeInTheDocument()
expect(screen.getByText('Step 2')).toBeInTheDocument()
expect(screen.getByText('Step 3')).toBeInTheDocument()
expect(screen.getByText('Step 4')).toBeInTheDocument()
expect(screen.getByText('Step 5')).toBeInTheDocument()
})
it('should handle single step correctly', () => {
// Arrange
const steps = [createStep({ name: 'Only Step' })]
// Act
const { container } = renderStepper({ steps, activeIndex: 0 })
// Assert
expect(screen.getByText('Only Step')).toBeInTheDocument()
// No dividers for single step
const dividers = container.querySelectorAll('.bg-divider-deep')
expect(dividers.length).toBe(0)
})
it('should handle steps with long names', () => {
// Arrange
const longName = 'This is a very long step name that might overflow'
const steps = [createStep({ name: longName })]
// Act
renderStepper({ steps, activeIndex: 0 })
// Assert
expect(screen.getByText(longName)).toBeInTheDocument()
})
it('should handle steps with special characters', () => {
// Arrange
const steps = [
createStep({ name: 'Step & Configuration' }),
createStep({ name: 'Step <Preview>' }),
createStep({ name: 'Step "Complete"' }),
]
// Act
renderStepper({ steps, activeIndex: 0 })
// Assert
expect(screen.getByText('Step & Configuration')).toBeInTheDocument()
expect(screen.getByText('Step <Preview>')).toBeInTheDocument()
expect(screen.getByText('Step "Complete"')).toBeInTheDocument()
})
})
describe('activeIndex prop', () => {
it('should highlight first step when activeIndex is 0', () => {
// Arrange & Act
renderStepper({ activeIndex: 0 })
// Assert - First step should show "STEP 1" label
expect(screen.getByText('STEP 1')).toBeInTheDocument()
})
it('should highlight second step when activeIndex is 1', () => {
// Arrange & Act
renderStepper({ activeIndex: 1 })
// Assert - Second step should show "STEP 2" label
expect(screen.getByText('STEP 2')).toBeInTheDocument()
})
it('should highlight last step when activeIndex equals steps length - 1', () => {
// Arrange
const steps = createSteps(3)
// Act
renderStepper({ steps, activeIndex: 2 })
// Assert - Third step should show "STEP 3" label
expect(screen.getByText('STEP 3')).toBeInTheDocument()
})
it('should show completed steps with number only (no STEP prefix)', () => {
// Arrange
const steps = createSteps(3)
// Act
renderStepper({ steps, activeIndex: 2 })
// Assert - Completed steps show just the number
expect(screen.getByText('1')).toBeInTheDocument()
expect(screen.getByText('2')).toBeInTheDocument()
expect(screen.getByText('STEP 3')).toBeInTheDocument()
})
it('should show disabled steps with number only (no STEP prefix)', () => {
// Arrange
const steps = createSteps(3)
// Act
renderStepper({ steps, activeIndex: 0 })
// Assert - Disabled steps show just the number
expect(screen.getByText('STEP 1')).toBeInTheDocument()
expect(screen.getByText('2')).toBeInTheDocument()
expect(screen.getByText('3')).toBeInTheDocument()
})
})
})
// --------------------------------------------------------------------------
// Edge Cases - Test boundary conditions and unexpected inputs
// --------------------------------------------------------------------------
describe('Edge Cases', () => {
it('should handle empty steps array', () => {
// Arrange & Act
const { container } = renderStepper({ steps: [] })
// Assert - Container should render but be empty
expect(container.firstChild).toBeInTheDocument()
expect(container.firstChild?.childNodes.length).toBe(0)
})
it('should handle activeIndex greater than steps length', () => {
// Arrange
const steps = createSteps(2)
// Act - activeIndex 5 is beyond array bounds
renderStepper({ steps, activeIndex: 5 })
// Assert - All steps should render as completed (since activeIndex > all indices)
expect(screen.getByText('1')).toBeInTheDocument()
expect(screen.getByText('2')).toBeInTheDocument()
})
it('should handle negative activeIndex', () => {
// Arrange
const steps = createSteps(2)
// Act - negative activeIndex
renderStepper({ steps, activeIndex: -1 })
// Assert - All steps should render as disabled (since activeIndex < all indices)
expect(screen.getByText('1')).toBeInTheDocument()
expect(screen.getByText('2')).toBeInTheDocument()
})
it('should handle large number of steps', () => {
// Arrange
const steps = createSteps(10)
// Act
const { container } = renderStepper({ steps, activeIndex: 5 })
// Assert
expect(screen.getByText('STEP 6')).toBeInTheDocument()
// Should have 9 dividers for 10 steps
const dividers = container.querySelectorAll('.bg-divider-deep')
expect(dividers.length).toBe(9)
})
it('should handle steps with empty name', () => {
// Arrange
const steps = [createStep({ name: '' })]
// Act
const { container } = renderStepper({ steps, activeIndex: 0 })
// Assert - Should still render the step structure
expect(screen.getByText('STEP 1')).toBeInTheDocument()
expect(container.firstChild).toBeInTheDocument()
})
})
// --------------------------------------------------------------------------
// Integration - Test step state combinations
// --------------------------------------------------------------------------
describe('Step States', () => {
it('should render mixed states: completed, active, disabled', () => {
// Arrange
const steps = createSteps(5)
// Act
renderStepper({ steps, activeIndex: 2 })
// Assert
// Steps 1-2 are completed (show number only)
expect(screen.getByText('1')).toBeInTheDocument()
expect(screen.getByText('2')).toBeInTheDocument()
// Step 3 is active (shows STEP prefix)
expect(screen.getByText('STEP 3')).toBeInTheDocument()
// Steps 4-5 are disabled (show number only)
expect(screen.getByText('4')).toBeInTheDocument()
expect(screen.getByText('5')).toBeInTheDocument()
})
it('should transition through all states correctly', () => {
// Arrange
const steps = createSteps(3)
// Act & Assert - Step 1 active
const { rerender } = render(<Stepper steps={steps} activeIndex={0} />)
expect(screen.getByText('STEP 1')).toBeInTheDocument()
// Step 2 active
rerender(<Stepper steps={steps} activeIndex={1} />)
expect(screen.getByText('1')).toBeInTheDocument()
expect(screen.getByText('STEP 2')).toBeInTheDocument()
// Step 3 active
rerender(<Stepper steps={steps} activeIndex={2} />)
expect(screen.getByText('1')).toBeInTheDocument()
expect(screen.getByText('2')).toBeInTheDocument()
expect(screen.getByText('STEP 3')).toBeInTheDocument()
})
})
})
// ============================================================================
// StepperStep Component Tests
// ============================================================================
describe('StepperStep', () => {
beforeEach(() => {
jest.clearAllMocks()
})
// --------------------------------------------------------------------------
// Rendering Tests
// --------------------------------------------------------------------------
describe('Rendering', () => {
it('should render without crashing', () => {
// Arrange & Act
renderStepperStep()
// Assert
expect(screen.getByText('Test Step')).toBeInTheDocument()
})
it('should render step name', () => {
// Arrange & Act
renderStepperStep({ name: 'Configure Dataset' })
// Assert
expect(screen.getByText('Configure Dataset')).toBeInTheDocument()
})
it('should render with flex container layout', () => {
// Arrange & Act
const { container } = renderStepperStep()
// Assert
const wrapper = container.firstChild as HTMLElement
expect(wrapper).toHaveClass('flex', 'items-center', 'gap-2')
})
})
// --------------------------------------------------------------------------
// Active State Tests
// --------------------------------------------------------------------------
describe('Active State', () => {
it('should show STEP prefix when active', () => {
// Arrange & Act
renderStepperStep({ index: 0, activeIndex: 0 })
// Assert
expect(screen.getByText('STEP 1')).toBeInTheDocument()
})
it('should apply active styles to label container', () => {
// Arrange & Act
const { container } = renderStepperStep({ index: 0, activeIndex: 0 })
// Assert
const labelContainer = container.querySelector('.bg-state-accent-solid')
expect(labelContainer).toBeInTheDocument()
expect(labelContainer).toHaveClass('px-2')
})
it('should apply active text color to label', () => {
// Arrange & Act
const { container } = renderStepperStep({ index: 0, activeIndex: 0 })
// Assert
const label = container.querySelector('.text-text-primary-on-surface')
expect(label).toBeInTheDocument()
})
it('should apply accent text color to name when active', () => {
// Arrange & Act
const { container } = renderStepperStep({ index: 0, activeIndex: 0 })
// Assert
const nameElement = container.querySelector('.text-text-accent')
expect(nameElement).toBeInTheDocument()
expect(nameElement).toHaveClass('system-xs-semibold-uppercase')
})
it('should calculate active correctly for different indices', () => {
// Test index 1 with activeIndex 1
const { rerender } = render(
<StepperStep name="Step" index={1} activeIndex={1} />,
)
expect(screen.getByText('STEP 2')).toBeInTheDocument()
// Test index 5 with activeIndex 5
rerender(<StepperStep name="Step" index={5} activeIndex={5} />)
expect(screen.getByText('STEP 6')).toBeInTheDocument()
})
})
// --------------------------------------------------------------------------
// Completed State Tests (index < activeIndex)
// --------------------------------------------------------------------------
describe('Completed State', () => {
it('should show number only when completed (not active)', () => {
// Arrange & Act
renderStepperStep({ index: 0, activeIndex: 1 })
// Assert
expect(screen.getByText('1')).toBeInTheDocument()
expect(screen.queryByText('STEP 1')).not.toBeInTheDocument()
})
it('should apply completed styles to label container', () => {
// Arrange & Act
const { container } = renderStepperStep({ index: 0, activeIndex: 1 })
// Assert
const labelContainer = container.querySelector('.border-text-quaternary')
expect(labelContainer).toBeInTheDocument()
expect(labelContainer).toHaveClass('w-5')
})
it('should apply tertiary text color to label when completed', () => {
// Arrange & Act
const { container } = renderStepperStep({ index: 0, activeIndex: 1 })
// Assert
const label = container.querySelector('.text-text-tertiary')
expect(label).toBeInTheDocument()
})
it('should apply tertiary text color to name when completed', () => {
// Arrange & Act
const { container } = renderStepperStep({ index: 0, activeIndex: 2 })
// Assert
const nameElements = container.querySelectorAll('.text-text-tertiary')
expect(nameElements.length).toBeGreaterThan(0)
})
})
// --------------------------------------------------------------------------
// Disabled State Tests (index > activeIndex)
// --------------------------------------------------------------------------
describe('Disabled State', () => {
it('should show number only when disabled', () => {
// Arrange & Act
renderStepperStep({ index: 2, activeIndex: 0 })
// Assert
expect(screen.getByText('3')).toBeInTheDocument()
expect(screen.queryByText('STEP 3')).not.toBeInTheDocument()
})
it('should apply disabled styles to label container', () => {
// Arrange & Act
const { container } = renderStepperStep({ index: 2, activeIndex: 0 })
// Assert
const labelContainer = container.querySelector('.border-divider-deep')
expect(labelContainer).toBeInTheDocument()
expect(labelContainer).toHaveClass('w-5')
})
it('should apply quaternary text color to label when disabled', () => {
// Arrange & Act
const { container } = renderStepperStep({ index: 2, activeIndex: 0 })
// Assert
const label = container.querySelector('.text-text-quaternary')
expect(label).toBeInTheDocument()
})
it('should apply quaternary text color to name when disabled', () => {
// Arrange & Act
const { container } = renderStepperStep({ index: 2, activeIndex: 0 })
// Assert
const nameElements = container.querySelectorAll('.text-text-quaternary')
expect(nameElements.length).toBeGreaterThan(0)
})
})
// --------------------------------------------------------------------------
// Props Testing
// --------------------------------------------------------------------------
describe('Props', () => {
describe('name prop', () => {
it('should render provided name', () => {
// Arrange & Act
renderStepperStep({ name: 'Custom Name' })
// Assert
expect(screen.getByText('Custom Name')).toBeInTheDocument()
})
it('should handle empty name', () => {
// Arrange & Act
const { container } = renderStepperStep({ name: '' })
// Assert - Label should still render
expect(screen.getByText('STEP 1')).toBeInTheDocument()
expect(container.firstChild).toBeInTheDocument()
})
it('should handle name with whitespace', () => {
// Arrange & Act
renderStepperStep({ name: ' Padded Name ' })
// Assert
expect(screen.getByText('Padded Name')).toBeInTheDocument()
})
})
describe('index prop', () => {
it('should display correct 1-based number for index 0', () => {
// Arrange & Act
renderStepperStep({ index: 0, activeIndex: 0 })
// Assert
expect(screen.getByText('STEP 1')).toBeInTheDocument()
})
it('should display correct 1-based number for index 9', () => {
// Arrange & Act
renderStepperStep({ index: 9, activeIndex: 9 })
// Assert
expect(screen.getByText('STEP 10')).toBeInTheDocument()
})
it('should handle large index values', () => {
// Arrange & Act
renderStepperStep({ index: 99, activeIndex: 99 })
// Assert
expect(screen.getByText('STEP 100')).toBeInTheDocument()
})
})
describe('activeIndex prop', () => {
it('should determine state based on activeIndex comparison', () => {
// Active: index === activeIndex
const { rerender } = render(
<StepperStep name="Step" index={1} activeIndex={1} />,
)
expect(screen.getByText('STEP 2')).toBeInTheDocument()
// Completed: index < activeIndex
rerender(<StepperStep name="Step" index={1} activeIndex={2} />)
expect(screen.getByText('2')).toBeInTheDocument()
// Disabled: index > activeIndex
rerender(<StepperStep name="Step" index={1} activeIndex={0} />)
expect(screen.getByText('2')).toBeInTheDocument()
})
})
})
// --------------------------------------------------------------------------
// Edge Cases
// --------------------------------------------------------------------------
describe('Edge Cases', () => {
it('should handle zero index correctly', () => {
// Arrange & Act
renderStepperStep({ index: 0, activeIndex: 0 })
// Assert
expect(screen.getByText('STEP 1')).toBeInTheDocument()
})
it('should handle negative activeIndex', () => {
// Arrange & Act
renderStepperStep({ index: 0, activeIndex: -1 })
// Assert - Step should be disabled (index > activeIndex)
expect(screen.getByText('1')).toBeInTheDocument()
})
it('should handle equal boundary (index equals activeIndex)', () => {
// Arrange & Act
renderStepperStep({ index: 5, activeIndex: 5 })
// Assert - Should be active
expect(screen.getByText('STEP 6')).toBeInTheDocument()
})
it('should handle name with HTML-like content safely', () => {
// Arrange & Act
renderStepperStep({ name: '<script>alert("xss")</script>' })
// Assert - Should render as text, not execute
expect(screen.getByText('<script>alert("xss")</script>')).toBeInTheDocument()
})
it('should handle name with unicode characters', () => {
// Arrange & Act
renderStepperStep({ name: 'Step 数据 🚀' })
// Assert
expect(screen.getByText('Step 数据 🚀')).toBeInTheDocument()
})
})
// --------------------------------------------------------------------------
// Style Classes Verification
// --------------------------------------------------------------------------
describe('Style Classes', () => {
it('should apply correct typography classes to label', () => {
// Arrange & Act
const { container } = renderStepperStep()
// Assert
const label = container.querySelector('.system-2xs-semibold-uppercase')
expect(label).toBeInTheDocument()
})
it('should apply correct typography classes to name', () => {
// Arrange & Act
const { container } = renderStepperStep()
// Assert
const name = container.querySelector('.system-xs-medium-uppercase')
expect(name).toBeInTheDocument()
})
it('should have rounded pill shape for label container', () => {
// Arrange & Act
const { container } = renderStepperStep()
// Assert
const labelContainer = container.querySelector('.rounded-3xl')
expect(labelContainer).toBeInTheDocument()
})
it('should apply h-5 height to label container', () => {
// Arrange & Act
const { container } = renderStepperStep()
// Assert
const labelContainer = container.querySelector('.h-5')
expect(labelContainer).toBeInTheDocument()
})
})
})
// ============================================================================
// Integration Tests - Stepper and StepperStep working together
// ============================================================================
describe('Stepper Integration', () => {
beforeEach(() => {
jest.clearAllMocks()
})
it('should pass correct props to each StepperStep', () => {
// Arrange
const steps = [
createStep({ name: 'First' }),
createStep({ name: 'Second' }),
createStep({ name: 'Third' }),
]
// Act
renderStepper({ steps, activeIndex: 1 })
// Assert - Each step receives correct index and displays correctly
expect(screen.getByText('1')).toBeInTheDocument() // Completed
expect(screen.getByText('First')).toBeInTheDocument()
expect(screen.getByText('STEP 2')).toBeInTheDocument() // Active
expect(screen.getByText('Second')).toBeInTheDocument()
expect(screen.getByText('3')).toBeInTheDocument() // Disabled
expect(screen.getByText('Third')).toBeInTheDocument()
})
it('should maintain correct visual hierarchy across steps', () => {
// Arrange
const steps = createSteps(4)
// Act
const { container } = renderStepper({ steps, activeIndex: 2 })
// Assert - Check visual hierarchy
// Completed steps (0, 1) have border-text-quaternary
const completedLabels = container.querySelectorAll('.border-text-quaternary')
expect(completedLabels.length).toBe(2)
// Active step has bg-state-accent-solid
const activeLabel = container.querySelector('.bg-state-accent-solid')
expect(activeLabel).toBeInTheDocument()
// Disabled step (3) has border-divider-deep
const disabledLabels = container.querySelectorAll('.border-divider-deep')
expect(disabledLabels.length).toBe(1)
})
it('should render correctly with dynamic step updates', () => {
// Arrange
const initialSteps = createSteps(2)
// Act
const { rerender } = render(<Stepper steps={initialSteps} activeIndex={0} />)
expect(screen.getByText('Step 1')).toBeInTheDocument()
expect(screen.getByText('Step 2')).toBeInTheDocument()
// Update with more steps
const updatedSteps = createSteps(4)
rerender(<Stepper steps={updatedSteps} activeIndex={2} />)
// Assert
expect(screen.getByText('STEP 3')).toBeInTheDocument()
expect(screen.getByText('Step 4')).toBeInTheDocument()
})
})

View File

@ -0,0 +1,738 @@
import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'
import StopEmbeddingModal from './index'
// Helper type for component props
type StopEmbeddingModalProps = {
show: boolean
onConfirm: () => void
onHide: () => void
}
// Helper to render StopEmbeddingModal with default props
const renderStopEmbeddingModal = (props: Partial<StopEmbeddingModalProps> = {}) => {
const defaultProps: StopEmbeddingModalProps = {
show: true,
onConfirm: jest.fn(),
onHide: jest.fn(),
...props,
}
return {
...render(<StopEmbeddingModal {...defaultProps} />),
props: defaultProps,
}
}
// ============================================================================
// StopEmbeddingModal Component Tests
// ============================================================================
describe('StopEmbeddingModal', () => {
// Suppress Headless UI warnings in tests
// These warnings are from the library's internal behavior, not our code
let consoleWarnSpy: jest.SpyInstance
let consoleErrorSpy: jest.SpyInstance
beforeAll(() => {
consoleWarnSpy = jest.spyOn(console, 'warn').mockImplementation(jest.fn())
consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation(jest.fn())
})
afterAll(() => {
consoleWarnSpy.mockRestore()
consoleErrorSpy.mockRestore()
})
beforeEach(() => {
jest.clearAllMocks()
})
// --------------------------------------------------------------------------
// Rendering Tests - Verify component renders properly
// --------------------------------------------------------------------------
describe('Rendering', () => {
it('should render without crashing when show is true', () => {
// Arrange & Act
renderStopEmbeddingModal({ show: true })
// Assert
expect(screen.getByText('datasetCreation.stepThree.modelTitle')).toBeInTheDocument()
})
it('should render modal title', () => {
// Arrange & Act
renderStopEmbeddingModal({ show: true })
// Assert
expect(screen.getByText('datasetCreation.stepThree.modelTitle')).toBeInTheDocument()
})
it('should render modal content', () => {
// Arrange & Act
renderStopEmbeddingModal({ show: true })
// Assert
expect(screen.getByText('datasetCreation.stepThree.modelContent')).toBeInTheDocument()
})
it('should render confirm button with correct text', () => {
// Arrange & Act
renderStopEmbeddingModal({ show: true })
// Assert
expect(screen.getByText('datasetCreation.stepThree.modelButtonConfirm')).toBeInTheDocument()
})
it('should render cancel button with correct text', () => {
// Arrange & Act
renderStopEmbeddingModal({ show: true })
// Assert
expect(screen.getByText('datasetCreation.stepThree.modelButtonCancel')).toBeInTheDocument()
})
it('should not render modal content when show is false', () => {
// Arrange & Act
renderStopEmbeddingModal({ show: false })
// Assert
expect(screen.queryByText('datasetCreation.stepThree.modelTitle')).not.toBeInTheDocument()
})
it('should render buttons in correct order (cancel first, then confirm)', () => {
// Arrange & Act
renderStopEmbeddingModal({ show: true })
// Assert - Due to flex-row-reverse, confirm appears first visually but cancel is first in DOM
const buttons = screen.getAllByRole('button')
expect(buttons).toHaveLength(2)
})
it('should render confirm button with primary variant styling', () => {
// Arrange & Act
renderStopEmbeddingModal({ show: true })
// Assert
const confirmButton = screen.getByText('datasetCreation.stepThree.modelButtonConfirm')
expect(confirmButton).toHaveClass('ml-2', 'w-24')
})
it('should render cancel button with default styling', () => {
// Arrange & Act
renderStopEmbeddingModal({ show: true })
// Assert
const cancelButton = screen.getByText('datasetCreation.stepThree.modelButtonCancel')
expect(cancelButton).toHaveClass('w-24')
})
it('should render all modal elements', () => {
// Arrange & Act
renderStopEmbeddingModal({ show: true })
// Assert - Modal should contain title, content, and buttons
expect(screen.getByText('datasetCreation.stepThree.modelTitle')).toBeInTheDocument()
expect(screen.getByText('datasetCreation.stepThree.modelContent')).toBeInTheDocument()
expect(screen.getByText('datasetCreation.stepThree.modelButtonConfirm')).toBeInTheDocument()
expect(screen.getByText('datasetCreation.stepThree.modelButtonCancel')).toBeInTheDocument()
})
})
// --------------------------------------------------------------------------
// Props Testing - Test all prop variations
// --------------------------------------------------------------------------
describe('Props', () => {
describe('show prop', () => {
it('should show modal when show is true', () => {
// Arrange & Act
renderStopEmbeddingModal({ show: true })
// Assert
expect(screen.getByText('datasetCreation.stepThree.modelTitle')).toBeInTheDocument()
})
it('should hide modal when show is false', () => {
// Arrange & Act
renderStopEmbeddingModal({ show: false })
// Assert
expect(screen.queryByText('datasetCreation.stepThree.modelTitle')).not.toBeInTheDocument()
})
it('should use default value false when show is not provided', () => {
// Arrange & Act
const onConfirm = jest.fn()
const onHide = jest.fn()
render(<StopEmbeddingModal onConfirm={onConfirm} onHide={onHide} show={false} />)
// Assert
expect(screen.queryByText('datasetCreation.stepThree.modelTitle')).not.toBeInTheDocument()
})
it('should toggle visibility when show prop changes to true', async () => {
// Arrange
const onConfirm = jest.fn()
const onHide = jest.fn()
// Act - Initially hidden
const { rerender } = render(
<StopEmbeddingModal show={false} onConfirm={onConfirm} onHide={onHide} />,
)
expect(screen.queryByText('datasetCreation.stepThree.modelTitle')).not.toBeInTheDocument()
// Act - Show modal
await act(async () => {
rerender(<StopEmbeddingModal show={true} onConfirm={onConfirm} onHide={onHide} />)
})
// Assert - Modal should be visible
await waitFor(() => {
expect(screen.getByText('datasetCreation.stepThree.modelTitle')).toBeInTheDocument()
})
})
})
describe('onConfirm prop', () => {
it('should accept onConfirm callback function', () => {
// Arrange
const onConfirm = jest.fn()
// Act
renderStopEmbeddingModal({ onConfirm })
// Assert - No errors thrown
expect(screen.getByText('datasetCreation.stepThree.modelTitle')).toBeInTheDocument()
})
})
describe('onHide prop', () => {
it('should accept onHide callback function', () => {
// Arrange
const onHide = jest.fn()
// Act
renderStopEmbeddingModal({ onHide })
// Assert - No errors thrown
expect(screen.getByText('datasetCreation.stepThree.modelTitle')).toBeInTheDocument()
})
})
})
// --------------------------------------------------------------------------
// User Interactions Tests - Test click events and event handlers
// --------------------------------------------------------------------------
describe('User Interactions', () => {
describe('Confirm Button', () => {
it('should call onConfirm when confirm button is clicked', async () => {
// Arrange
const onConfirm = jest.fn()
const onHide = jest.fn()
renderStopEmbeddingModal({ onConfirm, onHide })
// Act
const confirmButton = screen.getByText('datasetCreation.stepThree.modelButtonConfirm')
await act(async () => {
fireEvent.click(confirmButton)
})
// Assert
expect(onConfirm).toHaveBeenCalledTimes(1)
})
it('should call onHide when confirm button is clicked', async () => {
// Arrange
const onConfirm = jest.fn()
const onHide = jest.fn()
renderStopEmbeddingModal({ onConfirm, onHide })
// Act
const confirmButton = screen.getByText('datasetCreation.stepThree.modelButtonConfirm')
await act(async () => {
fireEvent.click(confirmButton)
})
// Assert
expect(onHide).toHaveBeenCalledTimes(1)
})
it('should call both onConfirm and onHide in correct order when confirm button is clicked', async () => {
// Arrange
const callOrder: string[] = []
const onConfirm = jest.fn(() => callOrder.push('confirm'))
const onHide = jest.fn(() => callOrder.push('hide'))
renderStopEmbeddingModal({ onConfirm, onHide })
// Act
const confirmButton = screen.getByText('datasetCreation.stepThree.modelButtonConfirm')
await act(async () => {
fireEvent.click(confirmButton)
})
// Assert - onConfirm should be called before onHide
expect(callOrder).toEqual(['confirm', 'hide'])
})
it('should handle multiple clicks on confirm button', async () => {
// Arrange
const onConfirm = jest.fn()
const onHide = jest.fn()
renderStopEmbeddingModal({ onConfirm, onHide })
// Act
const confirmButton = screen.getByText('datasetCreation.stepThree.modelButtonConfirm')
await act(async () => {
fireEvent.click(confirmButton)
fireEvent.click(confirmButton)
fireEvent.click(confirmButton)
})
// Assert
expect(onConfirm).toHaveBeenCalledTimes(3)
expect(onHide).toHaveBeenCalledTimes(3)
})
})
describe('Cancel Button', () => {
it('should call onHide when cancel button is clicked', async () => {
// Arrange
const onConfirm = jest.fn()
const onHide = jest.fn()
renderStopEmbeddingModal({ onConfirm, onHide })
// Act
const cancelButton = screen.getByText('datasetCreation.stepThree.modelButtonCancel')
await act(async () => {
fireEvent.click(cancelButton)
})
// Assert
expect(onHide).toHaveBeenCalledTimes(1)
})
it('should not call onConfirm when cancel button is clicked', async () => {
// Arrange
const onConfirm = jest.fn()
const onHide = jest.fn()
renderStopEmbeddingModal({ onConfirm, onHide })
// Act
const cancelButton = screen.getByText('datasetCreation.stepThree.modelButtonCancel')
await act(async () => {
fireEvent.click(cancelButton)
})
// Assert
expect(onConfirm).not.toHaveBeenCalled()
})
it('should handle multiple clicks on cancel button', async () => {
// Arrange
const onConfirm = jest.fn()
const onHide = jest.fn()
renderStopEmbeddingModal({ onConfirm, onHide })
// Act
const cancelButton = screen.getByText('datasetCreation.stepThree.modelButtonCancel')
await act(async () => {
fireEvent.click(cancelButton)
fireEvent.click(cancelButton)
})
// Assert
expect(onHide).toHaveBeenCalledTimes(2)
expect(onConfirm).not.toHaveBeenCalled()
})
})
describe('Close Icon', () => {
it('should call onHide when close span is clicked', async () => {
// Arrange
const onConfirm = jest.fn()
const onHide = jest.fn()
const { container } = renderStopEmbeddingModal({ onConfirm, onHide })
// Act - Find the close span (it should be the span with onClick handler)
const spans = container.querySelectorAll('span')
const closeSpan = Array.from(spans).find(span =>
span.className && span.getAttribute('class')?.includes('close'),
)
if (closeSpan) {
await act(async () => {
fireEvent.click(closeSpan)
})
// Assert
expect(onHide).toHaveBeenCalledTimes(1)
}
else {
// If no close span found with class, just verify the modal renders
expect(screen.getByText('datasetCreation.stepThree.modelTitle')).toBeInTheDocument()
}
})
it('should not call onConfirm when close span is clicked', async () => {
// Arrange
const onConfirm = jest.fn()
const onHide = jest.fn()
const { container } = renderStopEmbeddingModal({ onConfirm, onHide })
// Act
const spans = container.querySelectorAll('span')
const closeSpan = Array.from(spans).find(span =>
span.className && span.getAttribute('class')?.includes('close'),
)
if (closeSpan) {
await act(async () => {
fireEvent.click(closeSpan)
})
// Assert
expect(onConfirm).not.toHaveBeenCalled()
}
})
})
describe('Different Close Methods', () => {
it('should distinguish between confirm and cancel actions', async () => {
// Arrange
const onConfirm = jest.fn()
const onHide = jest.fn()
renderStopEmbeddingModal({ onConfirm, onHide })
// Act - Click cancel
const cancelButton = screen.getByText('datasetCreation.stepThree.modelButtonCancel')
await act(async () => {
fireEvent.click(cancelButton)
})
// Assert
expect(onConfirm).not.toHaveBeenCalled()
expect(onHide).toHaveBeenCalledTimes(1)
// Reset
jest.clearAllMocks()
// Act - Click confirm
const confirmButton = screen.getByText('datasetCreation.stepThree.modelButtonConfirm')
await act(async () => {
fireEvent.click(confirmButton)
})
// Assert
expect(onConfirm).toHaveBeenCalledTimes(1)
expect(onHide).toHaveBeenCalledTimes(1)
})
})
})
// --------------------------------------------------------------------------
// Edge Cases Tests - Test null, undefined, empty values and boundaries
// --------------------------------------------------------------------------
describe('Edge Cases', () => {
it('should handle rapid confirm button clicks', async () => {
// Arrange
const onConfirm = jest.fn()
const onHide = jest.fn()
renderStopEmbeddingModal({ onConfirm, onHide })
// Act - Rapid clicks
const confirmButton = screen.getByText('datasetCreation.stepThree.modelButtonConfirm')
await act(async () => {
for (let i = 0; i < 10; i++)
fireEvent.click(confirmButton)
})
// Assert
expect(onConfirm).toHaveBeenCalledTimes(10)
expect(onHide).toHaveBeenCalledTimes(10)
})
it('should handle rapid cancel button clicks', async () => {
// Arrange
const onConfirm = jest.fn()
const onHide = jest.fn()
renderStopEmbeddingModal({ onConfirm, onHide })
// Act - Rapid clicks
const cancelButton = screen.getByText('datasetCreation.stepThree.modelButtonCancel')
await act(async () => {
for (let i = 0; i < 10; i++)
fireEvent.click(cancelButton)
})
// Assert
expect(onHide).toHaveBeenCalledTimes(10)
expect(onConfirm).not.toHaveBeenCalled()
})
it('should handle callbacks being replaced', async () => {
// Arrange
const onConfirm1 = jest.fn()
const onHide1 = jest.fn()
const onConfirm2 = jest.fn()
const onHide2 = jest.fn()
// Act
const { rerender } = render(
<StopEmbeddingModal show={true} onConfirm={onConfirm1} onHide={onHide1} />,
)
// Replace callbacks
await act(async () => {
rerender(<StopEmbeddingModal show={true} onConfirm={onConfirm2} onHide={onHide2} />)
})
// Click confirm with new callbacks
const confirmButton = screen.getByText('datasetCreation.stepThree.modelButtonConfirm')
await act(async () => {
fireEvent.click(confirmButton)
})
// Assert - New callbacks should be called
expect(onConfirm1).not.toHaveBeenCalled()
expect(onHide1).not.toHaveBeenCalled()
expect(onConfirm2).toHaveBeenCalledTimes(1)
expect(onHide2).toHaveBeenCalledTimes(1)
})
it('should render with all required props', () => {
// Arrange & Act
render(
<StopEmbeddingModal
show={true}
onConfirm={jest.fn()}
onHide={jest.fn()}
/>,
)
// Assert
expect(screen.getByText('datasetCreation.stepThree.modelTitle')).toBeInTheDocument()
expect(screen.getByText('datasetCreation.stepThree.modelContent')).toBeInTheDocument()
})
})
// --------------------------------------------------------------------------
// Layout and Styling Tests - Verify correct structure
// --------------------------------------------------------------------------
describe('Layout and Styling', () => {
it('should have buttons container with flex-row-reverse', () => {
// Arrange & Act
renderStopEmbeddingModal({ show: true })
// Assert
const buttons = screen.getAllByRole('button')
expect(buttons[0].closest('div')).toHaveClass('flex', 'flex-row-reverse')
})
it('should render title and content elements', () => {
// Arrange & Act
renderStopEmbeddingModal({ show: true })
// Assert
expect(screen.getByText('datasetCreation.stepThree.modelTitle')).toBeInTheDocument()
expect(screen.getByText('datasetCreation.stepThree.modelContent')).toBeInTheDocument()
})
it('should render two buttons', () => {
// Arrange & Act
renderStopEmbeddingModal({ show: true })
// Assert
const buttons = screen.getAllByRole('button')
expect(buttons).toHaveLength(2)
})
})
// --------------------------------------------------------------------------
// submit Function Tests - Test the internal submit function behavior
// --------------------------------------------------------------------------
describe('submit Function', () => {
it('should execute onConfirm first then onHide', async () => {
// Arrange
let confirmTime = 0
let hideTime = 0
let counter = 0
const onConfirm = jest.fn(() => {
confirmTime = ++counter
})
const onHide = jest.fn(() => {
hideTime = ++counter
})
renderStopEmbeddingModal({ onConfirm, onHide })
// Act
const confirmButton = screen.getByText('datasetCreation.stepThree.modelButtonConfirm')
await act(async () => {
fireEvent.click(confirmButton)
})
// Assert
expect(confirmTime).toBe(1)
expect(hideTime).toBe(2)
})
it('should call both callbacks exactly once per click', async () => {
// Arrange
const onConfirm = jest.fn()
const onHide = jest.fn()
renderStopEmbeddingModal({ onConfirm, onHide })
// Act
const confirmButton = screen.getByText('datasetCreation.stepThree.modelButtonConfirm')
await act(async () => {
fireEvent.click(confirmButton)
})
// Assert
expect(onConfirm).toHaveBeenCalledTimes(1)
expect(onHide).toHaveBeenCalledTimes(1)
})
it('should pass no arguments to onConfirm', async () => {
// Arrange
const onConfirm = jest.fn()
const onHide = jest.fn()
renderStopEmbeddingModal({ onConfirm, onHide })
// Act
const confirmButton = screen.getByText('datasetCreation.stepThree.modelButtonConfirm')
await act(async () => {
fireEvent.click(confirmButton)
})
// Assert
expect(onConfirm).toHaveBeenCalledWith()
})
it('should pass no arguments to onHide when called from submit', async () => {
// Arrange
const onConfirm = jest.fn()
const onHide = jest.fn()
renderStopEmbeddingModal({ onConfirm, onHide })
// Act
const confirmButton = screen.getByText('datasetCreation.stepThree.modelButtonConfirm')
await act(async () => {
fireEvent.click(confirmButton)
})
// Assert
expect(onHide).toHaveBeenCalledWith()
})
})
// --------------------------------------------------------------------------
// Modal Integration Tests - Verify Modal component integration
// --------------------------------------------------------------------------
describe('Modal Integration', () => {
it('should pass show prop to Modal as isShow', async () => {
// Arrange & Act
const { rerender } = render(
<StopEmbeddingModal show={true} onConfirm={jest.fn()} onHide={jest.fn()} />,
)
// Assert - Modal should be visible
expect(screen.getByText('datasetCreation.stepThree.modelTitle')).toBeInTheDocument()
// Act - Hide modal
await act(async () => {
rerender(<StopEmbeddingModal show={false} onConfirm={jest.fn()} onHide={jest.fn()} />)
})
// Assert - Modal should transition to hidden (wait for transition)
await waitFor(() => {
expect(screen.queryByText('datasetCreation.stepThree.modelTitle')).not.toBeInTheDocument()
}, { timeout: 3000 })
})
})
// --------------------------------------------------------------------------
// Accessibility Tests
// --------------------------------------------------------------------------
describe('Accessibility', () => {
it('should have buttons that are focusable', () => {
// Arrange & Act
renderStopEmbeddingModal({ show: true })
// Assert
const buttons = screen.getAllByRole('button')
buttons.forEach((button) => {
expect(button).not.toHaveAttribute('tabindex', '-1')
})
})
it('should have semantic button elements', () => {
// Arrange & Act
renderStopEmbeddingModal({ show: true })
// Assert
const buttons = screen.getAllByRole('button')
expect(buttons).toHaveLength(2)
})
it('should have accessible text content', () => {
// Arrange & Act
renderStopEmbeddingModal({ show: true })
// Assert
expect(screen.getByText('datasetCreation.stepThree.modelTitle')).toBeVisible()
expect(screen.getByText('datasetCreation.stepThree.modelContent')).toBeVisible()
expect(screen.getByText('datasetCreation.stepThree.modelButtonConfirm')).toBeVisible()
expect(screen.getByText('datasetCreation.stepThree.modelButtonCancel')).toBeVisible()
})
})
// --------------------------------------------------------------------------
// Component Lifecycle Tests
// --------------------------------------------------------------------------
describe('Component Lifecycle', () => {
it('should unmount cleanly', () => {
// Arrange
const onConfirm = jest.fn()
const onHide = jest.fn()
const { unmount } = renderStopEmbeddingModal({ onConfirm, onHide })
// Act & Assert - Should not throw
expect(() => unmount()).not.toThrow()
})
it('should not call callbacks after unmount', () => {
// Arrange
const onConfirm = jest.fn()
const onHide = jest.fn()
const { unmount } = renderStopEmbeddingModal({ onConfirm, onHide })
// Act
unmount()
// Assert - No callbacks should be called after unmount
expect(onConfirm).not.toHaveBeenCalled()
expect(onHide).not.toHaveBeenCalled()
})
it('should re-render correctly when props update', async () => {
// Arrange
const onConfirm1 = jest.fn()
const onHide1 = jest.fn()
const onConfirm2 = jest.fn()
const onHide2 = jest.fn()
// Act - Initial render
const { rerender } = render(
<StopEmbeddingModal show={true} onConfirm={onConfirm1} onHide={onHide1} />,
)
// Verify initial render
expect(screen.getByText('datasetCreation.stepThree.modelTitle')).toBeInTheDocument()
// Update props
await act(async () => {
rerender(<StopEmbeddingModal show={true} onConfirm={onConfirm2} onHide={onHide2} />)
})
// Assert - Still renders correctly
expect(screen.getByText('datasetCreation.stepThree.modelTitle')).toBeInTheDocument()
})
})
})

View File

@ -0,0 +1,539 @@
import { render, screen } from '@testing-library/react'
import { TopBar, type TopBarProps } from './index'
// Mock next/link to capture href values
jest.mock('next/link', () => {
return ({ children, href, replace, className }: { children: React.ReactNode; href: string; replace?: boolean; className?: string }) => (
<a href={href} data-replace={replace} className={className} data-testid="back-link">
{children}
</a>
)
})
// Helper to render TopBar with default props
const renderTopBar = (props: Partial<TopBarProps> = {}) => {
const defaultProps: TopBarProps = {
activeIndex: 0,
...props,
}
return {
...render(<TopBar {...defaultProps} />),
props: defaultProps,
}
}
// ============================================================================
// TopBar Component Tests
// ============================================================================
describe('TopBar', () => {
beforeEach(() => {
jest.clearAllMocks()
})
// --------------------------------------------------------------------------
// Rendering Tests - Verify component renders properly
// --------------------------------------------------------------------------
describe('Rendering', () => {
it('should render without crashing', () => {
// Arrange & Act
renderTopBar()
// Assert
expect(screen.getByTestId('back-link')).toBeInTheDocument()
})
it('should render back link with arrow icon', () => {
// Arrange & Act
const { container } = renderTopBar()
// Assert
const backLink = screen.getByTestId('back-link')
expect(backLink).toBeInTheDocument()
// Check for the arrow icon (svg element)
const arrowIcon = container.querySelector('svg')
expect(arrowIcon).toBeInTheDocument()
})
it('should render fallback route text', () => {
// Arrange & Act
renderTopBar()
// Assert
expect(screen.getByText('datasetCreation.steps.header.fallbackRoute')).toBeInTheDocument()
})
it('should render Stepper component with 3 steps', () => {
// Arrange & Act
renderTopBar({ activeIndex: 0 })
// Assert - Check for step translations
expect(screen.getByText('datasetCreation.steps.one')).toBeInTheDocument()
expect(screen.getByText('datasetCreation.steps.two')).toBeInTheDocument()
expect(screen.getByText('datasetCreation.steps.three')).toBeInTheDocument()
})
it('should apply default container classes', () => {
// Arrange & Act
const { container } = renderTopBar()
// Assert
const wrapper = container.firstChild as HTMLElement
expect(wrapper).toHaveClass('relative')
expect(wrapper).toHaveClass('flex')
expect(wrapper).toHaveClass('h-[52px]')
expect(wrapper).toHaveClass('shrink-0')
expect(wrapper).toHaveClass('items-center')
expect(wrapper).toHaveClass('justify-between')
expect(wrapper).toHaveClass('border-b')
expect(wrapper).toHaveClass('border-b-divider-subtle')
})
})
// --------------------------------------------------------------------------
// Props Testing - Test all prop variations
// --------------------------------------------------------------------------
describe('Props', () => {
describe('className prop', () => {
it('should apply custom className when provided', () => {
// Arrange & Act
const { container } = renderTopBar({ className: 'custom-class' })
// Assert
const wrapper = container.firstChild as HTMLElement
expect(wrapper).toHaveClass('custom-class')
})
it('should merge custom className with default classes', () => {
// Arrange & Act
const { container } = renderTopBar({ className: 'my-custom-class another-class' })
// Assert
const wrapper = container.firstChild as HTMLElement
expect(wrapper).toHaveClass('relative')
expect(wrapper).toHaveClass('flex')
expect(wrapper).toHaveClass('my-custom-class')
expect(wrapper).toHaveClass('another-class')
})
it('should render correctly without className', () => {
// Arrange & Act
const { container } = renderTopBar({ className: undefined })
// Assert
const wrapper = container.firstChild as HTMLElement
expect(wrapper).toHaveClass('relative')
expect(wrapper).toHaveClass('flex')
})
it('should handle empty string className', () => {
// Arrange & Act
const { container } = renderTopBar({ className: '' })
// Assert
const wrapper = container.firstChild as HTMLElement
expect(wrapper).toHaveClass('relative')
})
})
describe('datasetId prop', () => {
it('should set fallback route to /datasets when datasetId is undefined', () => {
// Arrange & Act
renderTopBar({ datasetId: undefined })
// Assert
const backLink = screen.getByTestId('back-link')
expect(backLink).toHaveAttribute('href', '/datasets')
})
it('should set fallback route to /datasets/:id/documents when datasetId is provided', () => {
// Arrange & Act
renderTopBar({ datasetId: 'dataset-123' })
// Assert
const backLink = screen.getByTestId('back-link')
expect(backLink).toHaveAttribute('href', '/datasets/dataset-123/documents')
})
it('should handle various datasetId formats', () => {
// Arrange & Act
renderTopBar({ datasetId: 'abc-def-ghi-123' })
// Assert
const backLink = screen.getByTestId('back-link')
expect(backLink).toHaveAttribute('href', '/datasets/abc-def-ghi-123/documents')
})
it('should handle empty string datasetId', () => {
// Arrange & Act
renderTopBar({ datasetId: '' })
// Assert - Empty string is falsy, so fallback to /datasets
const backLink = screen.getByTestId('back-link')
expect(backLink).toHaveAttribute('href', '/datasets')
})
})
describe('activeIndex prop', () => {
it('should pass activeIndex to Stepper component (index 0)', () => {
// Arrange & Act
const { container } = renderTopBar({ activeIndex: 0 })
// Assert - First step should be active (has specific styling)
const steps = container.querySelectorAll('[class*="system-2xs-semibold-uppercase"]')
expect(steps.length).toBeGreaterThan(0)
})
it('should pass activeIndex to Stepper component (index 1)', () => {
// Arrange & Act
renderTopBar({ activeIndex: 1 })
// Assert - Stepper is rendered with correct props
expect(screen.getByText('datasetCreation.steps.one')).toBeInTheDocument()
expect(screen.getByText('datasetCreation.steps.two')).toBeInTheDocument()
})
it('should pass activeIndex to Stepper component (index 2)', () => {
// Arrange & Act
renderTopBar({ activeIndex: 2 })
// Assert
expect(screen.getByText('datasetCreation.steps.three')).toBeInTheDocument()
})
it('should handle edge case activeIndex of -1', () => {
// Arrange & Act
const { container } = renderTopBar({ activeIndex: -1 })
// Assert - Component should render without crashing
expect(container.firstChild).toBeInTheDocument()
})
it('should handle edge case activeIndex beyond steps length', () => {
// Arrange & Act
const { container } = renderTopBar({ activeIndex: 10 })
// Assert - Component should render without crashing
expect(container.firstChild).toBeInTheDocument()
})
})
})
// --------------------------------------------------------------------------
// Memoization Tests - Test useMemo logic and dependencies
// --------------------------------------------------------------------------
describe('Memoization Logic', () => {
it('should compute fallbackRoute based on datasetId', () => {
// Arrange & Act - With datasetId
const { rerender } = render(<TopBar activeIndex={0} datasetId="test-id" />)
// Assert
expect(screen.getByTestId('back-link')).toHaveAttribute('href', '/datasets/test-id/documents')
// Act - Rerender with different datasetId
rerender(<TopBar activeIndex={0} datasetId="new-id" />)
// Assert - Route should update
expect(screen.getByTestId('back-link')).toHaveAttribute('href', '/datasets/new-id/documents')
})
it('should update fallbackRoute when datasetId changes from undefined to defined', () => {
// Arrange
const { rerender } = render(<TopBar activeIndex={0} />)
expect(screen.getByTestId('back-link')).toHaveAttribute('href', '/datasets')
// Act
rerender(<TopBar activeIndex={0} datasetId="new-dataset" />)
// Assert
expect(screen.getByTestId('back-link')).toHaveAttribute('href', '/datasets/new-dataset/documents')
})
it('should update fallbackRoute when datasetId changes from defined to undefined', () => {
// Arrange
const { rerender } = render(<TopBar activeIndex={0} datasetId="existing-id" />)
expect(screen.getByTestId('back-link')).toHaveAttribute('href', '/datasets/existing-id/documents')
// Act
rerender(<TopBar activeIndex={0} datasetId={undefined} />)
// Assert
expect(screen.getByTestId('back-link')).toHaveAttribute('href', '/datasets')
})
it('should not change fallbackRoute when activeIndex changes but datasetId stays same', () => {
// Arrange
const { rerender } = render(<TopBar activeIndex={0} datasetId="stable-id" />)
const initialHref = screen.getByTestId('back-link').getAttribute('href')
// Act
rerender(<TopBar activeIndex={1} datasetId="stable-id" />)
// Assert - href should remain the same
expect(screen.getByTestId('back-link')).toHaveAttribute('href', initialHref)
})
it('should not change fallbackRoute when className changes but datasetId stays same', () => {
// Arrange
const { rerender } = render(<TopBar activeIndex={0} datasetId="stable-id" className="class-1" />)
const initialHref = screen.getByTestId('back-link').getAttribute('href')
// Act
rerender(<TopBar activeIndex={0} datasetId="stable-id" className="class-2" />)
// Assert - href should remain the same
expect(screen.getByTestId('back-link')).toHaveAttribute('href', initialHref)
})
})
// --------------------------------------------------------------------------
// Link Component Tests
// --------------------------------------------------------------------------
describe('Link Component', () => {
it('should render Link with replace prop', () => {
// Arrange & Act
renderTopBar()
// Assert
const backLink = screen.getByTestId('back-link')
expect(backLink).toHaveAttribute('data-replace', 'true')
})
it('should render Link with correct classes', () => {
// Arrange & Act
renderTopBar()
// Assert
const backLink = screen.getByTestId('back-link')
expect(backLink).toHaveClass('inline-flex')
expect(backLink).toHaveClass('h-12')
expect(backLink).toHaveClass('items-center')
expect(backLink).toHaveClass('justify-start')
expect(backLink).toHaveClass('gap-1')
expect(backLink).toHaveClass('py-2')
expect(backLink).toHaveClass('pl-2')
expect(backLink).toHaveClass('pr-6')
})
})
// --------------------------------------------------------------------------
// STEP_T_MAP Tests - Verify step translations
// --------------------------------------------------------------------------
describe('STEP_T_MAP Translations', () => {
it('should render step one translation', () => {
// Arrange & Act
renderTopBar({ activeIndex: 0 })
// Assert
expect(screen.getByText('datasetCreation.steps.one')).toBeInTheDocument()
})
it('should render step two translation', () => {
// Arrange & Act
renderTopBar({ activeIndex: 1 })
// Assert
expect(screen.getByText('datasetCreation.steps.two')).toBeInTheDocument()
})
it('should render step three translation', () => {
// Arrange & Act
renderTopBar({ activeIndex: 2 })
// Assert
expect(screen.getByText('datasetCreation.steps.three')).toBeInTheDocument()
})
it('should render all three step translations', () => {
// Arrange & Act
renderTopBar({ activeIndex: 0 })
// Assert
expect(screen.getByText('datasetCreation.steps.one')).toBeInTheDocument()
expect(screen.getByText('datasetCreation.steps.two')).toBeInTheDocument()
expect(screen.getByText('datasetCreation.steps.three')).toBeInTheDocument()
})
})
// --------------------------------------------------------------------------
// Edge Cases and Error Handling Tests
// --------------------------------------------------------------------------
describe('Edge Cases', () => {
it('should handle special characters in datasetId', () => {
// Arrange & Act
renderTopBar({ datasetId: 'dataset-with-special_chars.123' })
// Assert
const backLink = screen.getByTestId('back-link')
expect(backLink).toHaveAttribute('href', '/datasets/dataset-with-special_chars.123/documents')
})
it('should handle very long datasetId', () => {
// Arrange
const longId = 'a'.repeat(100)
// Act
renderTopBar({ datasetId: longId })
// Assert
const backLink = screen.getByTestId('back-link')
expect(backLink).toHaveAttribute('href', `/datasets/${longId}/documents`)
})
it('should handle UUID format datasetId', () => {
// Arrange
const uuid = '550e8400-e29b-41d4-a716-446655440000'
// Act
renderTopBar({ datasetId: uuid })
// Assert
const backLink = screen.getByTestId('back-link')
expect(backLink).toHaveAttribute('href', `/datasets/${uuid}/documents`)
})
it('should handle whitespace in className', () => {
// Arrange & Act
const { container } = renderTopBar({ className: ' spaced-class ' })
// Assert - classNames utility handles whitespace
const wrapper = container.firstChild as HTMLElement
expect(wrapper).toBeInTheDocument()
})
it('should render correctly with all props provided', () => {
// Arrange & Act
const { container } = renderTopBar({
className: 'custom-class',
datasetId: 'full-props-id',
activeIndex: 2,
})
// Assert
const wrapper = container.firstChild as HTMLElement
expect(wrapper).toHaveClass('custom-class')
expect(screen.getByTestId('back-link')).toHaveAttribute('href', '/datasets/full-props-id/documents')
})
it('should render correctly with minimal props (only activeIndex)', () => {
// Arrange & Act
const { container } = renderTopBar({ activeIndex: 0 })
// Assert
expect(container.firstChild).toBeInTheDocument()
expect(screen.getByTestId('back-link')).toHaveAttribute('href', '/datasets')
})
})
// --------------------------------------------------------------------------
// Stepper Integration Tests
// --------------------------------------------------------------------------
describe('Stepper Integration', () => {
it('should pass steps array with correct structure to Stepper', () => {
// Arrange & Act
renderTopBar({ activeIndex: 0 })
// Assert - All step names should be rendered
const stepOne = screen.getByText('datasetCreation.steps.one')
const stepTwo = screen.getByText('datasetCreation.steps.two')
const stepThree = screen.getByText('datasetCreation.steps.three')
expect(stepOne).toBeInTheDocument()
expect(stepTwo).toBeInTheDocument()
expect(stepThree).toBeInTheDocument()
})
it('should render Stepper in centered position', () => {
// Arrange & Act
const { container } = renderTopBar({ activeIndex: 0 })
// Assert - Check for centered positioning classes
const centeredContainer = container.querySelector('.absolute.left-1\\/2.top-1\\/2.-translate-x-1\\/2.-translate-y-1\\/2')
expect(centeredContainer).toBeInTheDocument()
})
it('should render step dividers between steps', () => {
// Arrange & Act
const { container } = renderTopBar({ activeIndex: 0 })
// Assert - Check for dividers (h-px w-4 bg-divider-deep)
const dividers = container.querySelectorAll('.h-px.w-4.bg-divider-deep')
expect(dividers.length).toBe(2) // 2 dividers between 3 steps
})
})
// --------------------------------------------------------------------------
// Accessibility Tests
// --------------------------------------------------------------------------
describe('Accessibility', () => {
it('should have accessible back link', () => {
// Arrange & Act
renderTopBar()
// Assert
const backLink = screen.getByTestId('back-link')
expect(backLink).toBeInTheDocument()
// Link should have visible text
expect(screen.getByText('datasetCreation.steps.header.fallbackRoute')).toBeInTheDocument()
})
it('should have visible arrow icon in back link', () => {
// Arrange & Act
const { container } = renderTopBar()
// Assert - Arrow icon should be visible
const arrowIcon = container.querySelector('svg')
expect(arrowIcon).toBeInTheDocument()
expect(arrowIcon).toHaveClass('text-text-primary')
})
})
// --------------------------------------------------------------------------
// Re-render Tests
// --------------------------------------------------------------------------
describe('Re-render Behavior', () => {
it('should update activeIndex on re-render', () => {
// Arrange
const { rerender, container } = render(<TopBar activeIndex={0} />)
// Initial check
expect(container.firstChild).toBeInTheDocument()
// Act - Update activeIndex
rerender(<TopBar activeIndex={1} />)
// Assert - Component should still render
expect(container.firstChild).toBeInTheDocument()
})
it('should update className on re-render', () => {
// Arrange
const { rerender, container } = render(<TopBar activeIndex={0} className="initial-class" />)
const wrapper = container.firstChild as HTMLElement
expect(wrapper).toHaveClass('initial-class')
// Act
rerender(<TopBar activeIndex={0} className="updated-class" />)
// Assert
expect(wrapper).toHaveClass('updated-class')
expect(wrapper).not.toHaveClass('initial-class')
})
it('should handle multiple rapid re-renders', () => {
// Arrange
const { rerender, container } = render(<TopBar activeIndex={0} />)
// Act - Multiple rapid re-renders
rerender(<TopBar activeIndex={1} />)
rerender(<TopBar activeIndex={2} />)
rerender(<TopBar activeIndex={0} datasetId="new-id" />)
rerender(<TopBar activeIndex={1} datasetId="another-id" className="new-class" />)
// Assert - Component should be stable
expect(container.firstChild).toBeInTheDocument()
const wrapper = container.firstChild as HTMLElement
expect(wrapper).toHaveClass('new-class')
expect(screen.getByTestId('back-link')).toHaveAttribute('href', '/datasets/another-id/documents')
})
})
})

View File

@ -0,0 +1,555 @@
import { fireEvent, render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import Input from './base/input'
import Header from './base/header'
import CrawledResult from './base/crawled-result'
import CrawledResultItem from './base/crawled-result-item'
import type { CrawlResultItem } from '@/models/datasets'
// ============================================================================
// Test Data Factories
// ============================================================================
const createCrawlResultItem = (overrides: Partial<CrawlResultItem> = {}): CrawlResultItem => ({
title: 'Test Page Title',
markdown: '# Test Content',
description: 'Test description',
source_url: 'https://example.com/page',
...overrides,
})
// ============================================================================
// Input Component Tests
// ============================================================================
describe('Input', () => {
beforeEach(() => {
jest.clearAllMocks()
})
const createInputProps = (overrides: Partial<Parameters<typeof Input>[0]> = {}) => ({
value: '',
onChange: jest.fn(),
...overrides,
})
describe('Rendering', () => {
it('should render text input by default', () => {
const props = createInputProps()
render(<Input {...props} />)
const input = screen.getByRole('textbox')
expect(input).toBeInTheDocument()
expect(input).toHaveAttribute('type', 'text')
})
it('should render number input when isNumber is true', () => {
const props = createInputProps({ isNumber: true, value: 0 })
render(<Input {...props} />)
const input = screen.getByRole('spinbutton')
expect(input).toBeInTheDocument()
expect(input).toHaveAttribute('type', 'number')
expect(input).toHaveAttribute('min', '0')
})
it('should render with placeholder', () => {
const props = createInputProps({ placeholder: 'Enter URL' })
render(<Input {...props} />)
expect(screen.getByPlaceholderText('Enter URL')).toBeInTheDocument()
})
it('should render with initial value', () => {
const props = createInputProps({ value: 'test value' })
render(<Input {...props} />)
expect(screen.getByDisplayValue('test value')).toBeInTheDocument()
})
})
describe('Text Input Behavior', () => {
it('should call onChange with string value for text input', async () => {
const onChange = jest.fn()
const props = createInputProps({ onChange })
render(<Input {...props} />)
const input = screen.getByRole('textbox')
await userEvent.type(input, 'hello')
expect(onChange).toHaveBeenCalledWith('h')
expect(onChange).toHaveBeenCalledWith('e')
expect(onChange).toHaveBeenCalledWith('l')
expect(onChange).toHaveBeenCalledWith('l')
expect(onChange).toHaveBeenCalledWith('o')
})
})
describe('Number Input Behavior', () => {
it('should call onChange with parsed integer for number input', () => {
const onChange = jest.fn()
const props = createInputProps({ isNumber: true, onChange, value: 0 })
render(<Input {...props} />)
const input = screen.getByRole('spinbutton')
fireEvent.change(input, { target: { value: '42' } })
expect(onChange).toHaveBeenCalledWith(42)
})
it('should call onChange with empty string when input is NaN', () => {
const onChange = jest.fn()
const props = createInputProps({ isNumber: true, onChange, value: 0 })
render(<Input {...props} />)
const input = screen.getByRole('spinbutton')
fireEvent.change(input, { target: { value: 'abc' } })
expect(onChange).toHaveBeenCalledWith('')
})
it('should call onChange with empty string when input is empty', () => {
const onChange = jest.fn()
const props = createInputProps({ isNumber: true, onChange, value: 5 })
render(<Input {...props} />)
const input = screen.getByRole('spinbutton')
fireEvent.change(input, { target: { value: '' } })
expect(onChange).toHaveBeenCalledWith('')
})
it('should clamp negative values to MIN_VALUE (0)', () => {
const onChange = jest.fn()
const props = createInputProps({ isNumber: true, onChange, value: 0 })
render(<Input {...props} />)
const input = screen.getByRole('spinbutton')
fireEvent.change(input, { target: { value: '-5' } })
expect(onChange).toHaveBeenCalledWith(0)
})
it('should handle decimal input by parsing as integer', () => {
const onChange = jest.fn()
const props = createInputProps({ isNumber: true, onChange, value: 0 })
render(<Input {...props} />)
const input = screen.getByRole('spinbutton')
fireEvent.change(input, { target: { value: '3.7' } })
expect(onChange).toHaveBeenCalledWith(3)
})
})
describe('Component Memoization', () => {
it('should be wrapped with React.memo', () => {
expect(Input.$$typeof).toBeDefined()
})
})
})
// ============================================================================
// Header Component Tests
// ============================================================================
describe('Header', () => {
const createHeaderProps = (overrides: Partial<Parameters<typeof Header>[0]> = {}) => ({
title: 'Test Title',
docTitle: 'Documentation',
docLink: 'https://docs.example.com',
...overrides,
})
describe('Rendering', () => {
it('should render title', () => {
const props = createHeaderProps()
render(<Header {...props} />)
expect(screen.getByText('Test Title')).toBeInTheDocument()
})
it('should render doc link', () => {
const props = createHeaderProps()
render(<Header {...props} />)
const link = screen.getByRole('link')
expect(link).toHaveAttribute('href', 'https://docs.example.com')
expect(link).toHaveAttribute('target', '_blank')
})
it('should render button text when not in pipeline', () => {
const props = createHeaderProps({ buttonText: 'Configure' })
render(<Header {...props} />)
expect(screen.getByText('Configure')).toBeInTheDocument()
})
it('should not render button text when in pipeline', () => {
const props = createHeaderProps({ isInPipeline: true, buttonText: 'Configure' })
render(<Header {...props} />)
expect(screen.queryByText('Configure')).not.toBeInTheDocument()
})
})
describe('isInPipeline Prop', () => {
it('should apply pipeline styles when isInPipeline is true', () => {
const props = createHeaderProps({ isInPipeline: true })
render(<Header {...props} />)
const titleElement = screen.getByText('Test Title')
expect(titleElement).toHaveClass('system-sm-semibold')
})
it('should apply default styles when isInPipeline is false', () => {
const props = createHeaderProps({ isInPipeline: false })
render(<Header {...props} />)
const titleElement = screen.getByText('Test Title')
expect(titleElement).toHaveClass('system-md-semibold')
})
it('should apply compact button styles when isInPipeline is true', () => {
const props = createHeaderProps({ isInPipeline: true })
render(<Header {...props} />)
const button = screen.getByRole('button')
expect(button).toHaveClass('size-6')
expect(button).toHaveClass('px-1')
})
it('should apply default button styles when isInPipeline is false', () => {
const props = createHeaderProps({ isInPipeline: false })
render(<Header {...props} />)
const button = screen.getByRole('button')
expect(button).toHaveClass('gap-x-0.5')
expect(button).toHaveClass('px-1.5')
})
})
describe('User Interactions', () => {
it('should call onClickConfiguration when button is clicked', async () => {
const onClickConfiguration = jest.fn()
const props = createHeaderProps({ onClickConfiguration })
render(<Header {...props} />)
await userEvent.click(screen.getByRole('button'))
expect(onClickConfiguration).toHaveBeenCalledTimes(1)
})
})
describe('Component Memoization', () => {
it('should be wrapped with React.memo', () => {
expect(Header.$$typeof).toBeDefined()
})
})
})
// ============================================================================
// CrawledResultItem Component Tests
// ============================================================================
describe('CrawledResultItem', () => {
const createItemProps = (overrides: Partial<Parameters<typeof CrawledResultItem>[0]> = {}) => ({
payload: createCrawlResultItem(),
isChecked: false,
isPreview: false,
onCheckChange: jest.fn(),
onPreview: jest.fn(),
testId: 'test-item',
...overrides,
})
describe('Rendering', () => {
it('should render title and source URL', () => {
const props = createItemProps({
payload: createCrawlResultItem({
title: 'My Page',
source_url: 'https://mysite.com',
}),
})
render(<CrawledResultItem {...props} />)
expect(screen.getByText('My Page')).toBeInTheDocument()
expect(screen.getByText('https://mysite.com')).toBeInTheDocument()
})
it('should render checkbox (custom Checkbox component)', () => {
const props = createItemProps()
render(<CrawledResultItem {...props} />)
// Find checkbox by data-testid
const checkbox = screen.getByTestId('checkbox-test-item')
expect(checkbox).toBeInTheDocument()
})
it('should render preview button', () => {
const props = createItemProps()
render(<CrawledResultItem {...props} />)
expect(screen.getByText('datasetCreation.stepOne.website.preview')).toBeInTheDocument()
})
})
describe('Checkbox Behavior', () => {
it('should call onCheckChange with true when unchecked item is clicked', async () => {
const onCheckChange = jest.fn()
const props = createItemProps({ isChecked: false, onCheckChange })
render(<CrawledResultItem {...props} />)
const checkbox = screen.getByTestId('checkbox-test-item')
await userEvent.click(checkbox)
expect(onCheckChange).toHaveBeenCalledWith(true)
})
it('should call onCheckChange with false when checked item is clicked', async () => {
const onCheckChange = jest.fn()
const props = createItemProps({ isChecked: true, onCheckChange })
render(<CrawledResultItem {...props} />)
const checkbox = screen.getByTestId('checkbox-test-item')
await userEvent.click(checkbox)
expect(onCheckChange).toHaveBeenCalledWith(false)
})
})
describe('Preview Behavior', () => {
it('should call onPreview when preview button is clicked', async () => {
const onPreview = jest.fn()
const props = createItemProps({ onPreview })
render(<CrawledResultItem {...props} />)
await userEvent.click(screen.getByText('datasetCreation.stepOne.website.preview'))
expect(onPreview).toHaveBeenCalledTimes(1)
})
it('should apply active style when isPreview is true', () => {
const props = createItemProps({ isPreview: true })
const { container } = render(<CrawledResultItem {...props} />)
const wrapper = container.firstChild
expect(wrapper).toHaveClass('bg-state-base-active')
})
it('should not apply active style when isPreview is false', () => {
const props = createItemProps({ isPreview: false })
const { container } = render(<CrawledResultItem {...props} />)
const wrapper = container.firstChild
expect(wrapper).not.toHaveClass('bg-state-base-active')
})
})
describe('Component Memoization', () => {
it('should be wrapped with React.memo', () => {
expect(CrawledResultItem.$$typeof).toBeDefined()
})
})
})
// ============================================================================
// CrawledResult Component Tests
// ============================================================================
describe('CrawledResult', () => {
const createResultProps = (overrides: Partial<Parameters<typeof CrawledResult>[0]> = {}) => ({
list: [
createCrawlResultItem({ source_url: 'https://page1.com', title: 'Page 1' }),
createCrawlResultItem({ source_url: 'https://page2.com', title: 'Page 2' }),
createCrawlResultItem({ source_url: 'https://page3.com', title: 'Page 3' }),
],
checkedList: [],
onSelectedChange: jest.fn(),
onPreview: jest.fn(),
usedTime: 2.5,
...overrides,
})
// Helper functions to get checkboxes by data-testid
const getSelectAllCheckbox = () => screen.getByTestId('checkbox-select-all')
const getItemCheckbox = (index: number) => screen.getByTestId(`checkbox-item-${index}`)
describe('Rendering', () => {
it('should render all items in list', () => {
const props = createResultProps()
render(<CrawledResult {...props} />)
expect(screen.getByText('Page 1')).toBeInTheDocument()
expect(screen.getByText('Page 2')).toBeInTheDocument()
expect(screen.getByText('Page 3')).toBeInTheDocument()
})
it('should render time info', () => {
const props = createResultProps({ usedTime: 3.456 })
render(<CrawledResult {...props} />)
// The component uses i18n, so we check for the key pattern
expect(screen.getByText(/scrapTimeInfo/)).toBeInTheDocument()
})
it('should render select all checkbox', () => {
const props = createResultProps()
render(<CrawledResult {...props} />)
expect(screen.getByText('datasetCreation.stepOne.website.selectAll')).toBeInTheDocument()
})
it('should render reset all when all items are checked', () => {
const list = [
createCrawlResultItem({ source_url: 'https://page1.com' }),
createCrawlResultItem({ source_url: 'https://page2.com' }),
]
const props = createResultProps({ list, checkedList: list })
render(<CrawledResult {...props} />)
expect(screen.getByText('datasetCreation.stepOne.website.resetAll')).toBeInTheDocument()
})
})
describe('Select All / Deselect All', () => {
it('should call onSelectedChange with all items when select all is clicked', async () => {
const onSelectedChange = jest.fn()
const list = [
createCrawlResultItem({ source_url: 'https://page1.com' }),
createCrawlResultItem({ source_url: 'https://page2.com' }),
]
const props = createResultProps({ list, checkedList: [], onSelectedChange })
render(<CrawledResult {...props} />)
await userEvent.click(getSelectAllCheckbox())
expect(onSelectedChange).toHaveBeenCalledWith(list)
})
it('should call onSelectedChange with empty array when reset all is clicked', async () => {
const onSelectedChange = jest.fn()
const list = [
createCrawlResultItem({ source_url: 'https://page1.com' }),
createCrawlResultItem({ source_url: 'https://page2.com' }),
]
const props = createResultProps({ list, checkedList: list, onSelectedChange })
render(<CrawledResult {...props} />)
await userEvent.click(getSelectAllCheckbox())
expect(onSelectedChange).toHaveBeenCalledWith([])
})
})
describe('Individual Item Selection', () => {
it('should add item to checkedList when unchecked item is checked', async () => {
const onSelectedChange = jest.fn()
const list = [
createCrawlResultItem({ source_url: 'https://page1.com', title: 'Page 1' }),
createCrawlResultItem({ source_url: 'https://page2.com', title: 'Page 2' }),
]
const props = createResultProps({ list, checkedList: [], onSelectedChange })
render(<CrawledResult {...props} />)
await userEvent.click(getItemCheckbox(0))
expect(onSelectedChange).toHaveBeenCalledWith([list[0]])
})
it('should remove item from checkedList when checked item is unchecked', async () => {
const onSelectedChange = jest.fn()
const list = [
createCrawlResultItem({ source_url: 'https://page1.com', title: 'Page 1' }),
createCrawlResultItem({ source_url: 'https://page2.com', title: 'Page 2' }),
]
const props = createResultProps({ list, checkedList: [list[0]], onSelectedChange })
render(<CrawledResult {...props} />)
await userEvent.click(getItemCheckbox(0))
expect(onSelectedChange).toHaveBeenCalledWith([])
})
it('should preserve other checked items when unchecking one item', async () => {
const onSelectedChange = jest.fn()
const list = [
createCrawlResultItem({ source_url: 'https://page1.com', title: 'Page 1' }),
createCrawlResultItem({ source_url: 'https://page2.com', title: 'Page 2' }),
createCrawlResultItem({ source_url: 'https://page3.com', title: 'Page 3' }),
]
const props = createResultProps({ list, checkedList: [list[0], list[1]], onSelectedChange })
render(<CrawledResult {...props} />)
// Click the first item's checkbox to uncheck it
await userEvent.click(getItemCheckbox(0))
expect(onSelectedChange).toHaveBeenCalledWith([list[1]])
})
})
describe('Preview Behavior', () => {
it('should call onPreview with correct item when preview is clicked', async () => {
const onPreview = jest.fn()
const list = [
createCrawlResultItem({ source_url: 'https://page1.com', title: 'Page 1' }),
createCrawlResultItem({ source_url: 'https://page2.com', title: 'Page 2' }),
]
const props = createResultProps({ list, onPreview })
render(<CrawledResult {...props} />)
// Click preview on second item
const previewButtons = screen.getAllByText('datasetCreation.stepOne.website.preview')
await userEvent.click(previewButtons[1])
expect(onPreview).toHaveBeenCalledWith(list[1])
})
it('should track preview index correctly', async () => {
const onPreview = jest.fn()
const list = [
createCrawlResultItem({ source_url: 'https://page1.com', title: 'Page 1' }),
createCrawlResultItem({ source_url: 'https://page2.com', title: 'Page 2' }),
]
const props = createResultProps({ list, onPreview })
render(<CrawledResult {...props} />)
// Click preview on first item
const previewButtons = screen.getAllByText('datasetCreation.stepOne.website.preview')
await userEvent.click(previewButtons[0])
expect(onPreview).toHaveBeenCalledWith(list[0])
})
})
describe('Component Memoization', () => {
it('should be wrapped with React.memo', () => {
expect(CrawledResult.$$typeof).toBeDefined()
})
})
describe('Edge Cases', () => {
it('should handle empty list', () => {
const props = createResultProps({ list: [], checkedList: [] })
render(<CrawledResult {...props} />)
// Should still render the header with resetAll (empty list = all checked)
expect(screen.getByText('datasetCreation.stepOne.website.resetAll')).toBeInTheDocument()
})
it('should handle className prop', () => {
const props = createResultProps({ className: 'custom-class' })
const { container } = render(<CrawledResult {...props} />)
expect(container.firstChild).toHaveClass('custom-class')
})
})
})

View File

@ -12,6 +12,7 @@ type Props = {
label: string
labelClassName?: string
tooltip?: string
testId?: string
}
const CheckboxWithLabel: FC<Props> = ({
@ -21,10 +22,11 @@ const CheckboxWithLabel: FC<Props> = ({
label,
labelClassName,
tooltip,
testId,
}) => {
return (
<label className={cn(className, 'flex h-7 items-center space-x-2')}>
<Checkbox checked={isChecked} onCheck={() => onChange(!isChecked)} />
<Checkbox checked={isChecked} onCheck={() => onChange(!isChecked)} id={testId} />
<div className={cn('text-sm font-normal text-text-secondary', labelClassName)}>{label}</div>
{tooltip && (
<Tooltip

View File

@ -13,6 +13,7 @@ type Props = {
isPreview: boolean
onCheckChange: (checked: boolean) => void
onPreview: () => void
testId?: string
}
const CrawledResultItem: FC<Props> = ({
@ -21,6 +22,7 @@ const CrawledResultItem: FC<Props> = ({
isChecked,
onCheckChange,
onPreview,
testId,
}) => {
const { t } = useTranslation()
@ -31,7 +33,7 @@ const CrawledResultItem: FC<Props> = ({
<div className={cn(isPreview ? 'bg-state-base-active' : 'group hover:bg-state-base-hover', 'cursor-pointer rounded-lg p-2')}>
<div className='relative flex'>
<div className='flex h-5 items-center'>
<Checkbox className='mr-2 shrink-0' checked={isChecked} onCheck={handleCheckChange} />
<Checkbox className='mr-2 shrink-0' checked={isChecked} onCheck={handleCheckChange} id={testId} />
</div>
<div className='flex min-w-0 grow flex-col'>
<div

View File

@ -61,8 +61,10 @@ const CrawledResult: FC<Props> = ({
<div className='flex h-[34px] items-center justify-between px-4'>
<CheckboxWithLabel
isChecked={isCheckAll}
onChange={handleCheckedAll} label={isCheckAll ? t(`${I18N_PREFIX}.resetAll`) : t(`${I18N_PREFIX}.selectAll`)}
onChange={handleCheckedAll}
label={isCheckAll ? t(`${I18N_PREFIX}.resetAll`) : t(`${I18N_PREFIX}.selectAll`)}
labelClassName='system-[13px] leading-[16px] font-medium text-text-secondary'
testId='select-all'
/>
<div className='text-xs text-text-tertiary'>
{t(`${I18N_PREFIX}.scrapTimeInfo`, {
@ -80,6 +82,7 @@ const CrawledResult: FC<Props> = ({
payload={item}
isChecked={checkedList.some(checkedItem => checkedItem.source_url === item.source_url)}
onCheckChange={handleItemCheckChange(item)}
testId={`item-${index}`}
/>
))}
</div>

View File

@ -0,0 +1,396 @@
import { fireEvent, render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import UrlInput from './base/url-input'
// Mock doc link context
jest.mock('@/context/i18n', () => ({
useDocLink: () => () => 'https://docs.example.com',
}))
// ============================================================================
// UrlInput Component Tests
// ============================================================================
describe('UrlInput', () => {
beforeEach(() => {
jest.clearAllMocks()
})
// Helper to create default props for UrlInput
const createUrlInputProps = (overrides: Partial<Parameters<typeof UrlInput>[0]> = {}) => ({
isRunning: false,
onRun: jest.fn(),
...overrides,
})
// --------------------------------------------------------------------------
// Rendering Tests
// --------------------------------------------------------------------------
describe('Rendering', () => {
it('should render without crashing', () => {
// Arrange
const props = createUrlInputProps()
// Act
render(<UrlInput {...props} />)
// Assert
expect(screen.getByRole('textbox')).toBeInTheDocument()
expect(screen.getByRole('button', { name: /run/i })).toBeInTheDocument()
})
it('should render input with placeholder from docLink', () => {
// Arrange
const props = createUrlInputProps()
// Act
render(<UrlInput {...props} />)
// Assert
const input = screen.getByRole('textbox')
expect(input).toHaveAttribute('placeholder', 'https://docs.example.com')
})
it('should render run button with correct text when not running', () => {
// Arrange
const props = createUrlInputProps({ isRunning: false })
// Act
render(<UrlInput {...props} />)
// Assert
expect(screen.getByRole('button', { name: /run/i })).toBeInTheDocument()
})
it('should render button without text when running', () => {
// Arrange
const props = createUrlInputProps({ isRunning: true })
// Act
render(<UrlInput {...props} />)
// Assert - find button by data-testid when in loading state
const runButton = screen.getByTestId('url-input-run-button')
expect(runButton).toBeInTheDocument()
// Button text should be empty when running
expect(runButton).not.toHaveTextContent(/run/i)
})
it('should show loading state on button when running', () => {
// Arrange
const onRun = jest.fn()
const props = createUrlInputProps({ isRunning: true, onRun })
// Act
render(<UrlInput {...props} />)
// Assert - find button by data-testid when in loading state
const runButton = screen.getByTestId('url-input-run-button')
expect(runButton).toBeInTheDocument()
// Verify button is empty (loading state removes text)
expect(runButton).not.toHaveTextContent(/run/i)
// Verify clicking doesn't trigger onRun when loading
fireEvent.click(runButton)
expect(onRun).not.toHaveBeenCalled()
})
})
// --------------------------------------------------------------------------
// User Input Tests
// --------------------------------------------------------------------------
describe('User Input', () => {
it('should update URL value when user types', async () => {
// Arrange
const props = createUrlInputProps()
// Act
render(<UrlInput {...props} />)
const input = screen.getByRole('textbox')
await userEvent.type(input, 'https://test.com')
// Assert
expect(input).toHaveValue('https://test.com')
})
it('should handle URL input clearing', async () => {
// Arrange
const props = createUrlInputProps()
// Act
render(<UrlInput {...props} />)
const input = screen.getByRole('textbox')
await userEvent.type(input, 'https://test.com')
await userEvent.clear(input)
// Assert
expect(input).toHaveValue('')
})
it('should handle special characters in URL', async () => {
// Arrange
const props = createUrlInputProps()
// Act
render(<UrlInput {...props} />)
const input = screen.getByRole('textbox')
await userEvent.type(input, 'https://example.com/path?query=value&foo=bar')
// Assert
expect(input).toHaveValue('https://example.com/path?query=value&foo=bar')
})
})
// --------------------------------------------------------------------------
// Button Click Tests
// --------------------------------------------------------------------------
describe('Button Click', () => {
it('should call onRun with URL when button is clicked', async () => {
// Arrange
const onRun = jest.fn()
const props = createUrlInputProps({ onRun })
// Act
render(<UrlInput {...props} />)
const input = screen.getByRole('textbox')
await userEvent.type(input, 'https://run-test.com')
await userEvent.click(screen.getByRole('button', { name: /run/i }))
// Assert
expect(onRun).toHaveBeenCalledWith('https://run-test.com')
expect(onRun).toHaveBeenCalledTimes(1)
})
it('should call onRun with empty string if no URL entered', async () => {
// Arrange
const onRun = jest.fn()
const props = createUrlInputProps({ onRun })
// Act
render(<UrlInput {...props} />)
await userEvent.click(screen.getByRole('button', { name: /run/i }))
// Assert
expect(onRun).toHaveBeenCalledWith('')
})
it('should not call onRun when isRunning is true', async () => {
// Arrange
const onRun = jest.fn()
const props = createUrlInputProps({ onRun, isRunning: true })
// Act
render(<UrlInput {...props} />)
const runButton = screen.getByTestId('url-input-run-button')
fireEvent.click(runButton)
// Assert
expect(onRun).not.toHaveBeenCalled()
})
it('should not call onRun when already running', async () => {
// Arrange
const onRun = jest.fn()
// First render with isRunning=false, type URL, then rerender with isRunning=true
const { rerender } = render(<UrlInput isRunning={false} onRun={onRun} />)
const input = screen.getByRole('textbox')
await userEvent.type(input, 'https://test.com')
// Rerender with isRunning=true to simulate a running state
rerender(<UrlInput isRunning={true} onRun={onRun} />)
// Find and click the button by data-testid (loading state has no text)
const runButton = screen.getByTestId('url-input-run-button')
fireEvent.click(runButton)
// Assert - onRun should not be called due to early return at line 28
expect(onRun).not.toHaveBeenCalled()
})
it('should prevent multiple clicks when already running', async () => {
// Arrange
const onRun = jest.fn()
const props = createUrlInputProps({ onRun, isRunning: true })
// Act
render(<UrlInput {...props} />)
const runButton = screen.getByTestId('url-input-run-button')
fireEvent.click(runButton)
fireEvent.click(runButton)
fireEvent.click(runButton)
// Assert
expect(onRun).not.toHaveBeenCalled()
})
})
// --------------------------------------------------------------------------
// Props Tests
// --------------------------------------------------------------------------
describe('Props', () => {
it('should respond to isRunning prop change', () => {
// Arrange
const props = createUrlInputProps({ isRunning: false })
// Act
const { rerender } = render(<UrlInput {...props} />)
expect(screen.getByRole('button', { name: /run/i })).toBeInTheDocument()
// Change isRunning to true
rerender(<UrlInput {...props} isRunning={true} />)
// Assert - find button by data-testid and verify it's now in loading state
const runButton = screen.getByTestId('url-input-run-button')
expect(runButton).toBeInTheDocument()
// When loading, the button text should be empty
expect(runButton).not.toHaveTextContent(/run/i)
})
it('should call updated onRun callback after prop change', async () => {
// Arrange
const onRun1 = jest.fn()
const onRun2 = jest.fn()
// Act
const { rerender } = render(<UrlInput isRunning={false} onRun={onRun1} />)
const input = screen.getByRole('textbox')
await userEvent.type(input, 'https://first.com')
// Change onRun callback
rerender(<UrlInput isRunning={false} onRun={onRun2} />)
await userEvent.click(screen.getByRole('button', { name: /run/i }))
// Assert - new callback should be called
expect(onRun1).not.toHaveBeenCalled()
expect(onRun2).toHaveBeenCalledWith('https://first.com')
})
})
// --------------------------------------------------------------------------
// Callback Stability Tests
// --------------------------------------------------------------------------
describe('Callback Stability', () => {
it('should use memoized handleUrlChange callback', async () => {
// Arrange
const props = createUrlInputProps()
// Act
const { rerender } = render(<UrlInput {...props} />)
const input = screen.getByRole('textbox')
await userEvent.type(input, 'a')
// Rerender with same props
rerender(<UrlInput {...props} />)
await userEvent.type(input, 'b')
// Assert - input should work correctly across rerenders
expect(input).toHaveValue('ab')
})
it('should maintain URL state across rerenders', async () => {
// Arrange
const props = createUrlInputProps()
// Act
const { rerender } = render(<UrlInput {...props} />)
const input = screen.getByRole('textbox')
await userEvent.type(input, 'https://stable.com')
// Rerender
rerender(<UrlInput {...props} />)
// Assert - URL should be maintained
expect(input).toHaveValue('https://stable.com')
})
})
// --------------------------------------------------------------------------
// Component Memoization Tests
// --------------------------------------------------------------------------
describe('Component Memoization', () => {
it('should be wrapped with React.memo', () => {
// Assert
expect(UrlInput.$$typeof).toBeDefined()
})
})
// --------------------------------------------------------------------------
// Edge Cases Tests
// --------------------------------------------------------------------------
describe('Edge Cases', () => {
it('should handle very long URLs', async () => {
// Arrange
const props = createUrlInputProps()
const longUrl = `https://example.com/${'a'.repeat(1000)}`
// Act
render(<UrlInput {...props} />)
const input = screen.getByRole('textbox')
await userEvent.type(input, longUrl)
// Assert
expect(input).toHaveValue(longUrl)
})
it('should handle URLs with unicode characters', async () => {
// Arrange
const props = createUrlInputProps()
const unicodeUrl = 'https://example.com/路径/测试'
// Act
render(<UrlInput {...props} />)
const input = screen.getByRole('textbox')
await userEvent.type(input, unicodeUrl)
// Assert
expect(input).toHaveValue(unicodeUrl)
})
it('should handle rapid typing', async () => {
// Arrange
const props = createUrlInputProps()
// Act
render(<UrlInput {...props} />)
const input = screen.getByRole('textbox')
await userEvent.type(input, 'https://rapid.com', { delay: 1 })
// Assert
expect(input).toHaveValue('https://rapid.com')
})
it('should handle keyboard enter to trigger run', async () => {
// Arrange - Note: This tests if the button can be activated via keyboard
const onRun = jest.fn()
const props = createUrlInputProps({ onRun })
// Act
render(<UrlInput {...props} />)
const input = screen.getByRole('textbox')
await userEvent.type(input, 'https://enter.com')
// Focus button and press enter
const button = screen.getByRole('button', { name: /run/i })
button.focus()
await userEvent.keyboard('{Enter}')
// Assert
expect(onRun).toHaveBeenCalledWith('https://enter.com')
})
it('should handle empty URL submission', async () => {
// Arrange
const onRun = jest.fn()
const props = createUrlInputProps({ onRun })
// Act
render(<UrlInput {...props} />)
await userEvent.click(screen.getByRole('button', { name: /run/i }))
// Assert - should call with empty string
expect(onRun).toHaveBeenCalledWith('')
})
})
})

View File

@ -41,6 +41,7 @@ const UrlInput: FC<Props> = ({
onClick={handleOnRun}
className='ml-2'
loading={isRunning}
data-testid='url-input-run-button'
>
{!isRunning ? t(`${I18N_PREFIX}.run`) : ''}
</Button>

File diff suppressed because it is too large Load Diff

View File

@ -37,6 +37,7 @@ const Options: FC<Props> = ({
isChecked={payload.crawl_sub_pages}
onChange={handleChange('crawl_sub_pages')}
labelClassName='text-[13px] leading-[16px] font-medium text-text-secondary'
testId='crawl-sub-pages'
/>
<CheckboxWithLabel
label={t(`${I18N_PREFIX}.useSitemap`)}
@ -44,6 +45,7 @@ const Options: FC<Props> = ({
onChange={handleChange('use_sitemap')}
tooltip={t(`${I18N_PREFIX}.useSitemapTooltip`) as string}
labelClassName='text-[13px] leading-[16px] font-medium text-text-secondary'
testId='use-sitemap'
/>
<div className='flex justify-between space-x-4'>
<Field

File diff suppressed because it is too large Load Diff

View File

@ -37,6 +37,7 @@ const Options: FC<Props> = ({
isChecked={payload.crawl_sub_pages}
onChange={handleChange('crawl_sub_pages')}
labelClassName='text-[13px] leading-[16px] font-medium text-text-secondary'
testId='crawl-sub-pages'
/>
<div className='flex justify-between space-x-4'>
<Field
@ -78,6 +79,7 @@ const Options: FC<Props> = ({
isChecked={payload.only_main_content}
onChange={handleChange('only_main_content')}
labelClassName='text-[13px] leading-[16px] font-medium text-text-secondary'
testId='only-main-content'
/>
</div>
)

View File

@ -4,13 +4,6 @@ import PipelineSettings from './index'
import { DatasourceType } from '@/models/pipeline'
import type { PipelineExecutionLogResponse } from '@/models/pipeline'
// Mock i18n
jest.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string) => key,
}),
}))
// Mock Next.js router
const mockPush = jest.fn()
const mockBack = jest.fn()

View File

@ -4,13 +4,6 @@ import ProcessDocuments from './index'
import { PipelineInputVarType } from '@/models/pipeline'
import type { RAGPipelineVariable } from '@/models/pipeline'
// Mock i18n
jest.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string) => key,
}),
}))
// Mock dataset detail context - required for useInputVariables hook
const mockPipelineId = 'pipeline-123'
jest.mock('@/context/dataset-detail', () => ({

View File

@ -3,13 +3,6 @@ import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import StatusItem from './index'
import type { DocumentDisplayStatus } from '@/models/datasets'
// Mock i18n - required for translation
jest.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string) => key,
}),
}))
// Mock ToastContext - required to verify notifications
const mockNotify = jest.fn()
jest.mock('use-context-selector', () => ({

View File

@ -0,0 +1,578 @@
import React from 'react'
import { act, fireEvent, render, screen } from '@testing-library/react'
import type { UsagePlanInfo } from '@/app/components/billing/type'
import { Plan } from '@/app/components/billing/type'
import { createMockPlan, createMockPlanTotal, createMockPlanUsage } from '@/__mocks__/provider-context'
import { AppModeEnum } from '@/types/app'
import CreateAppModal from './index'
import type { CreateAppModalProps } from './index'
let mockTranslationOverrides: Record<string, string | undefined> = {}
jest.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string, options?: Record<string, unknown>) => {
const override = mockTranslationOverrides[key]
if (override !== undefined)
return override
if (options?.returnObjects)
return [`${key}-feature-1`, `${key}-feature-2`]
if (options)
return `${key}:${JSON.stringify(options)}`
return key
},
i18n: {
language: 'en',
changeLanguage: jest.fn(),
},
}),
Trans: ({ children }: { children?: React.ReactNode }) => children,
initReactI18next: {
type: '3rdParty',
init: jest.fn(),
},
}))
// ky is an ESM-only package; mock it to keep Jest (CJS) specs running.
jest.mock('ky', () => ({
__esModule: true,
default: {
create: () => ({
extend: () => async () => new Response(),
}),
},
}))
// Avoid heavy emoji dataset initialization during unit tests.
jest.mock('emoji-mart', () => ({
init: jest.fn(),
SearchIndex: { search: jest.fn().mockResolvedValue([]) },
}))
jest.mock('@emoji-mart/data', () => ({
__esModule: true,
default: {
categories: [
{ id: 'people', emojis: ['😀'] },
],
},
}))
jest.mock('next/navigation', () => ({
useParams: () => ({}),
}))
jest.mock('@/context/app-context', () => ({
useAppContext: () => ({
userProfile: { email: 'test@example.com' },
langGeniusVersionInfo: { current_version: '0.0.0' },
}),
}))
const createPlanInfo = (buildApps: number): UsagePlanInfo => ({
vectorSpace: 0,
buildApps,
teamMembers: 0,
annotatedResponse: 0,
documentsUploadQuota: 0,
apiRateLimit: 0,
triggerEvents: 0,
})
let mockEnableBilling = false
let mockPlanType: Plan = Plan.team
let mockUsagePlanInfo: UsagePlanInfo = createPlanInfo(1)
let mockTotalPlanInfo: UsagePlanInfo = createPlanInfo(10)
jest.mock('@/context/provider-context', () => ({
useProviderContext: () => {
const withPlan = createMockPlan(mockPlanType)
const withUsage = createMockPlanUsage(mockUsagePlanInfo, withPlan)
const withTotal = createMockPlanTotal(mockTotalPlanInfo, withUsage)
return { ...withTotal, enableBilling: mockEnableBilling }
},
}))
type ConfirmPayload = Parameters<CreateAppModalProps['onConfirm']>[0]
const setup = (overrides: Partial<CreateAppModalProps> = {}) => {
const onConfirm = jest.fn<Promise<void>, [ConfirmPayload]>().mockResolvedValue(undefined)
const onHide = jest.fn<void, []>()
const props: CreateAppModalProps = {
show: true,
isEditModal: false,
appName: 'Test App',
appDescription: 'Test description',
appIconType: 'emoji',
appIcon: '🤖',
appIconBackground: '#FFEAD5',
appIconUrl: null,
appMode: AppModeEnum.CHAT,
appUseIconAsAnswerIcon: false,
max_active_requests: null,
onConfirm,
confirmDisabled: false,
onHide,
...overrides,
}
render(<CreateAppModal {...props} />)
return { onConfirm, onHide }
}
const getAppIconTrigger = (): HTMLElement => {
const nameInput = screen.getByPlaceholderText('app.newApp.appNamePlaceholder')
const iconRow = nameInput.parentElement?.parentElement
const iconTrigger = iconRow?.firstElementChild
if (!(iconTrigger instanceof HTMLElement))
throw new Error('Failed to locate app icon trigger')
return iconTrigger
}
describe('CreateAppModal', () => {
beforeEach(() => {
jest.clearAllMocks()
mockTranslationOverrides = {}
mockEnableBilling = false
mockPlanType = Plan.team
mockUsagePlanInfo = createPlanInfo(1)
mockTotalPlanInfo = createPlanInfo(10)
})
// The title and form sections vary based on the modal mode (create vs edit).
describe('Rendering', () => {
test('should render create title and actions when creating', () => {
setup({ appName: 'My App', isEditModal: false })
expect(screen.getByText('explore.appCustomize.title:{"name":"My App"}')).toBeInTheDocument()
expect(screen.getByRole('button', { name: 'common.operation.create' })).toBeInTheDocument()
expect(screen.getByRole('button', { name: 'common.operation.cancel' })).toBeInTheDocument()
})
test('should render edit-only fields when editing a chat app', () => {
setup({ isEditModal: true, appMode: AppModeEnum.CHAT, max_active_requests: 5 })
expect(screen.getByText('app.editAppTitle')).toBeInTheDocument()
expect(screen.getByRole('button', { name: 'common.operation.save' })).toBeInTheDocument()
expect(screen.getByRole('switch')).toBeInTheDocument()
expect((screen.getByRole('spinbutton') as HTMLInputElement).value).toBe('5')
})
test.each([AppModeEnum.ADVANCED_CHAT, AppModeEnum.AGENT_CHAT])('should render answer icon switch when editing %s app', (mode) => {
setup({ isEditModal: true, appMode: mode })
expect(screen.getByRole('switch')).toBeInTheDocument()
})
test('should not render answer icon switch when editing a non-chat app', () => {
setup({ isEditModal: true, appMode: AppModeEnum.COMPLETION })
expect(screen.queryByRole('switch')).not.toBeInTheDocument()
})
test('should not render modal content when hidden', () => {
setup({ show: false })
expect(screen.queryByRole('button', { name: 'common.operation.create' })).not.toBeInTheDocument()
})
})
// Disabled states prevent submission and reflect parent-driven props.
describe('Props', () => {
test('should disable confirm action when confirmDisabled is true', () => {
setup({ confirmDisabled: true })
expect(screen.getByRole('button', { name: 'common.operation.create' })).toBeDisabled()
})
test('should disable confirm action when appName is empty', () => {
setup({ appName: ' ' })
expect(screen.getByRole('button', { name: 'common.operation.create' })).toBeDisabled()
})
})
// Defensive coverage for falsy input values and translation edge cases.
describe('Edge Cases', () => {
test('should default description to empty string when appDescription is empty', () => {
setup({ appDescription: '' })
expect((screen.getByPlaceholderText('app.newApp.appDescriptionPlaceholder') as HTMLTextAreaElement).value).toBe('')
})
test('should fall back to empty placeholders when translations return empty string', () => {
mockTranslationOverrides = {
'app.newApp.appNamePlaceholder': '',
'app.newApp.appDescriptionPlaceholder': '',
}
setup()
expect((screen.getByDisplayValue('Test App') as HTMLInputElement).placeholder).toBe('')
expect((screen.getByDisplayValue('Test description') as HTMLTextAreaElement).placeholder).toBe('')
})
})
// The modal should close from user-initiated cancellation actions.
describe('User Interactions', () => {
test('should call onHide when cancel button is clicked', () => {
const { onConfirm, onHide } = setup()
fireEvent.click(screen.getByRole('button', { name: 'common.operation.cancel' }))
expect(onHide).toHaveBeenCalledTimes(1)
expect(onConfirm).not.toHaveBeenCalled()
})
test('should call onHide when pressing Escape while visible', () => {
const { onHide } = setup()
fireEvent.keyDown(window, { key: 'Escape', keyCode: 27 })
expect(onHide).toHaveBeenCalledTimes(1)
})
test('should not call onHide when pressing Escape while hidden', () => {
const { onHide } = setup({ show: false })
fireEvent.keyDown(window, { key: 'Escape', keyCode: 27 })
expect(onHide).not.toHaveBeenCalled()
})
})
// When billing limits are reached, the modal blocks app creation and shows quota guidance.
describe('Quota Gating', () => {
test('should show AppsFull and disable create when apps quota is reached', () => {
mockEnableBilling = true
mockPlanType = Plan.team
mockUsagePlanInfo = createPlanInfo(10)
mockTotalPlanInfo = createPlanInfo(10)
setup({ isEditModal: false })
expect(screen.getByText('billing.apps.fullTip2')).toBeInTheDocument()
expect(screen.getByRole('button', { name: 'common.operation.create' })).toBeDisabled()
})
test('should allow saving when apps quota is reached in edit mode', () => {
mockEnableBilling = true
mockPlanType = Plan.team
mockUsagePlanInfo = createPlanInfo(10)
mockTotalPlanInfo = createPlanInfo(10)
setup({ isEditModal: true })
expect(screen.queryByText('billing.apps.fullTip2')).not.toBeInTheDocument()
expect(screen.getByRole('button', { name: 'common.operation.save' })).toBeEnabled()
})
})
// Shortcut handlers are important for power users and must respect gating rules.
describe('Keyboard Shortcuts', () => {
beforeEach(() => {
jest.useFakeTimers()
})
afterEach(() => {
jest.useRealTimers()
})
test.each([
['meta+enter', { metaKey: true }],
['ctrl+enter', { ctrlKey: true }],
])('should submit when %s is pressed while visible', (_, modifier) => {
const { onConfirm, onHide } = setup()
fireEvent.keyDown(window, { key: 'Enter', keyCode: 13, ...modifier })
act(() => {
jest.advanceTimersByTime(300)
})
expect(onConfirm).toHaveBeenCalledTimes(1)
expect(onHide).toHaveBeenCalledTimes(1)
})
test('should not submit when modal is hidden', () => {
const { onConfirm, onHide } = setup({ show: false })
fireEvent.keyDown(window, { key: 'Enter', keyCode: 13, metaKey: true })
act(() => {
jest.advanceTimersByTime(300)
})
expect(onConfirm).not.toHaveBeenCalled()
expect(onHide).not.toHaveBeenCalled()
})
test('should not submit when apps quota is reached in create mode', () => {
mockEnableBilling = true
mockPlanType = Plan.team
mockUsagePlanInfo = createPlanInfo(10)
mockTotalPlanInfo = createPlanInfo(10)
const { onConfirm, onHide } = setup({ isEditModal: false })
fireEvent.keyDown(window, { key: 'Enter', keyCode: 13, metaKey: true })
act(() => {
jest.advanceTimersByTime(300)
})
expect(onConfirm).not.toHaveBeenCalled()
expect(onHide).not.toHaveBeenCalled()
})
test('should submit when apps quota is reached in edit mode', () => {
mockEnableBilling = true
mockPlanType = Plan.team
mockUsagePlanInfo = createPlanInfo(10)
mockTotalPlanInfo = createPlanInfo(10)
const { onConfirm, onHide } = setup({ isEditModal: true })
fireEvent.keyDown(window, { key: 'Enter', keyCode: 13, metaKey: true })
act(() => {
jest.advanceTimersByTime(300)
})
expect(onConfirm).toHaveBeenCalledTimes(1)
expect(onHide).toHaveBeenCalledTimes(1)
})
test('should not submit when name is empty', () => {
const { onConfirm, onHide } = setup({ appName: ' ' })
fireEvent.keyDown(window, { key: 'Enter', keyCode: 13, metaKey: true })
act(() => {
jest.advanceTimersByTime(300)
})
expect(onConfirm).not.toHaveBeenCalled()
expect(onHide).not.toHaveBeenCalled()
})
})
// The app icon picker is a key user flow for customizing metadata.
describe('App Icon Picker', () => {
test('should open and close the picker when cancel is clicked', () => {
setup({
appIconType: 'image',
appIcon: 'file-123',
appIconUrl: 'https://example.com/icon.png',
})
fireEvent.click(getAppIconTrigger())
expect(screen.getByRole('button', { name: 'app.iconPicker.cancel' })).toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: 'app.iconPicker.cancel' }))
expect(screen.queryByRole('button', { name: 'app.iconPicker.cancel' })).not.toBeInTheDocument()
})
test('should update icon payload when selecting emoji and confirming', () => {
jest.useFakeTimers()
try {
const { onConfirm } = setup({
appIconType: 'image',
appIcon: 'file-123',
appIconUrl: 'https://example.com/icon.png',
})
fireEvent.click(getAppIconTrigger())
const emoji = document.querySelector('em-emoji[id="😀"]')
if (!(emoji instanceof HTMLElement))
throw new Error('Failed to locate emoji option in icon picker')
fireEvent.click(emoji)
fireEvent.click(screen.getByRole('button', { name: 'app.iconPicker.ok' }))
fireEvent.click(screen.getByRole('button', { name: 'common.operation.create' }))
act(() => {
jest.advanceTimersByTime(300)
})
expect(onConfirm).toHaveBeenCalledTimes(1)
const payload = onConfirm.mock.calls[0][0]
expect(payload).toMatchObject({
icon_type: 'emoji',
icon: '😀',
icon_background: '#FFEAD5',
})
}
finally {
jest.useRealTimers()
}
})
test('should reset emoji icon to initial props when picker is cancelled', () => {
setup({
appIconType: 'emoji',
appIcon: '🤖',
appIconBackground: '#FFEAD5',
})
expect(document.querySelector('em-emoji[id="🤖"]')).toBeInTheDocument()
fireEvent.click(getAppIconTrigger())
const emoji = document.querySelector('em-emoji[id="😀"]')
if (!(emoji instanceof HTMLElement))
throw new Error('Failed to locate emoji option in icon picker')
fireEvent.click(emoji)
fireEvent.click(screen.getByRole('button', { name: 'app.iconPicker.ok' }))
expect(screen.queryByRole('button', { name: 'app.iconPicker.cancel' })).not.toBeInTheDocument()
expect(document.querySelector('em-emoji[id="😀"]')).toBeInTheDocument()
fireEvent.click(getAppIconTrigger())
fireEvent.click(screen.getByRole('button', { name: 'app.iconPicker.cancel' }))
expect(screen.queryByRole('button', { name: 'app.iconPicker.cancel' })).not.toBeInTheDocument()
expect(document.querySelector('em-emoji[id="🤖"]')).toBeInTheDocument()
})
})
// Submitting uses a debounced handler and builds a payload from current form state.
describe('Submitting', () => {
beforeEach(() => {
jest.useFakeTimers()
})
afterEach(() => {
jest.useRealTimers()
})
test('should call onConfirm with emoji payload and hide when create is clicked', () => {
const { onConfirm, onHide } = setup({
appName: 'My App',
appDescription: 'My description',
appIconType: 'emoji',
appIcon: '😀',
appIconBackground: '#000000',
})
fireEvent.click(screen.getByRole('button', { name: 'common.operation.create' }))
act(() => {
jest.advanceTimersByTime(300)
})
expect(onConfirm).toHaveBeenCalledTimes(1)
expect(onHide).toHaveBeenCalledTimes(1)
const payload = onConfirm.mock.calls[0][0]
expect(payload).toMatchObject({
name: 'My App',
icon_type: 'emoji',
icon: '😀',
icon_background: '#000000',
description: 'My description',
use_icon_as_answer_icon: false,
})
expect(payload).not.toHaveProperty('max_active_requests')
})
test('should include updated description when textarea is changed before submitting', () => {
const { onConfirm } = setup({ appDescription: 'Old description' })
fireEvent.change(screen.getByPlaceholderText('app.newApp.appDescriptionPlaceholder'), { target: { value: 'Updated description' } })
fireEvent.click(screen.getByRole('button', { name: 'common.operation.create' }))
act(() => {
jest.advanceTimersByTime(300)
})
expect(onConfirm).toHaveBeenCalledTimes(1)
expect(onConfirm.mock.calls[0][0]).toMatchObject({ description: 'Updated description' })
})
test('should omit icon_background when submitting with image icon', () => {
const { onConfirm } = setup({
appIconType: 'image',
appIcon: 'file-123',
appIconUrl: 'https://example.com/icon.png',
appIconBackground: null,
})
fireEvent.click(screen.getByRole('button', { name: 'common.operation.create' }))
act(() => {
jest.advanceTimersByTime(300)
})
const payload = onConfirm.mock.calls[0][0]
expect(payload).toMatchObject({
icon_type: 'image',
icon: 'file-123',
})
expect(payload.icon_background).toBeUndefined()
})
test('should include max_active_requests and updated answer icon when saving', () => {
const { onConfirm } = setup({
isEditModal: true,
appMode: AppModeEnum.CHAT,
appUseIconAsAnswerIcon: false,
max_active_requests: 3,
})
fireEvent.click(screen.getByRole('switch'))
fireEvent.change(screen.getByRole('spinbutton'), { target: { value: '12' } })
fireEvent.click(screen.getByRole('button', { name: 'common.operation.save' }))
act(() => {
jest.advanceTimersByTime(300)
})
const payload = onConfirm.mock.calls[0][0]
expect(payload).toMatchObject({
use_icon_as_answer_icon: true,
max_active_requests: 12,
})
})
test('should omit max_active_requests when input is empty', () => {
const { onConfirm } = setup({ isEditModal: true, max_active_requests: null })
fireEvent.click(screen.getByRole('button', { name: 'common.operation.save' }))
act(() => {
jest.advanceTimersByTime(300)
})
const payload = onConfirm.mock.calls[0][0]
expect(payload.max_active_requests).toBeUndefined()
})
test('should omit max_active_requests when input is not a number', () => {
const { onConfirm } = setup({ isEditModal: true, max_active_requests: null })
fireEvent.change(screen.getByRole('spinbutton'), { target: { value: 'abc' } })
fireEvent.click(screen.getByRole('button', { name: 'common.operation.save' }))
act(() => {
jest.advanceTimersByTime(300)
})
const payload = onConfirm.mock.calls[0][0]
expect(payload.max_active_requests).toBeUndefined()
})
test('should show toast error and not submit when name becomes empty before debounced submit runs', () => {
const { onConfirm, onHide } = setup({ appName: 'My App' })
fireEvent.click(screen.getByRole('button', { name: 'common.operation.create' }))
fireEvent.change(screen.getByPlaceholderText('app.newApp.appNamePlaceholder'), { target: { value: ' ' } })
act(() => {
jest.advanceTimersByTime(300)
})
expect(screen.getByText('explore.appCustomize.nameRequired')).toBeInTheDocument()
act(() => {
jest.advanceTimersByTime(6000)
})
expect(screen.queryByText('explore.appCustomize.nameRequired')).not.toBeInTheDocument()
expect(onConfirm).not.toHaveBeenCalled()
expect(onHide).not.toHaveBeenCalled()
})
})
})

View File

@ -0,0 +1,738 @@
import { render, screen, waitFor } from '@testing-library/react'
import { AppModeEnum } from '@/types/app'
import { AccessMode } from '@/models/access-control'
// Mock external dependencies BEFORE imports
jest.mock('use-context-selector', () => ({
useContext: jest.fn(),
createContext: jest.fn(() => ({})),
}))
jest.mock('@/context/web-app-context', () => ({
useWebAppStore: jest.fn(),
}))
jest.mock('@/service/access-control', () => ({
useGetUserCanAccessApp: jest.fn(),
}))
jest.mock('@/service/use-explore', () => ({
useGetInstalledAppAccessModeByAppId: jest.fn(),
useGetInstalledAppParams: jest.fn(),
useGetInstalledAppMeta: jest.fn(),
}))
import { useContext } from 'use-context-selector'
import InstalledApp from './index'
import { useWebAppStore } from '@/context/web-app-context'
import { useGetUserCanAccessApp } from '@/service/access-control'
import { useGetInstalledAppAccessModeByAppId, useGetInstalledAppMeta, useGetInstalledAppParams } from '@/service/use-explore'
import type { InstalledApp as InstalledAppType } from '@/models/explore'
/**
* Mock child components for unit testing
*
* RATIONALE FOR MOCKING:
* - TextGenerationApp: 648 lines, complex batch processing, task management, file uploads
* - ChatWithHistory: 576-line custom hook, complex conversation/history management, 30+ context values
*
* These components are too complex to test as real components. Using real components would:
* 1. Require mocking dozens of their dependencies (services, contexts, hooks)
* 2. Make tests fragile and coupled to child component implementation details
* 3. Violate the principle of testing one component in isolation
*
* For a container component like InstalledApp, its responsibility is to:
* - Correctly route to the appropriate child component based on app mode
* - Pass the correct props to child components
* - Handle loading/error states before rendering children
*
* The internal logic of ChatWithHistory and TextGenerationApp should be tested
* in their own dedicated test files.
*/
jest.mock('@/app/components/share/text-generation', () => ({
__esModule: true,
default: ({ isInstalledApp, installedAppInfo, isWorkflow }: {
isInstalledApp?: boolean
installedAppInfo?: InstalledAppType
isWorkflow?: boolean
}) => (
<div data-testid="text-generation-app">
Text Generation App
{isWorkflow && ' (Workflow)'}
{isInstalledApp && ` - ${installedAppInfo?.id}`}
</div>
),
}))
jest.mock('@/app/components/base/chat/chat-with-history', () => ({
__esModule: true,
default: ({ installedAppInfo, className }: {
installedAppInfo?: InstalledAppType
className?: string
}) => (
<div data-testid="chat-with-history" className={className}>
Chat With History - {installedAppInfo?.id}
</div>
),
}))
describe('InstalledApp', () => {
const mockUpdateAppInfo = jest.fn()
const mockUpdateWebAppAccessMode = jest.fn()
const mockUpdateAppParams = jest.fn()
const mockUpdateWebAppMeta = jest.fn()
const mockUpdateUserCanAccessApp = jest.fn()
const mockInstalledApp = {
id: 'installed-app-123',
app: {
id: 'app-123',
name: 'Test App',
mode: AppModeEnum.CHAT,
icon_type: 'emoji' as const,
icon: '🚀',
icon_background: '#FFFFFF',
icon_url: '',
description: 'Test description',
use_icon_as_answer_icon: false,
},
uninstallable: true,
is_pinned: false,
}
const mockAppParams = {
user_input_form: [],
file_upload: { image: { enabled: false, number_limits: 0, transfer_methods: [] } },
system_parameters: {},
}
const mockAppMeta = {
tool_icons: {},
}
const mockWebAppAccessMode = {
accessMode: AccessMode.PUBLIC,
}
const mockUserCanAccessApp = {
result: true,
}
beforeEach(() => {
jest.clearAllMocks()
// Mock useContext
;(useContext as jest.Mock).mockReturnValue({
installedApps: [mockInstalledApp],
isFetchingInstalledApps: false,
})
// Mock useWebAppStore
;(useWebAppStore as unknown as jest.Mock).mockImplementation((
selector: (state: {
updateAppInfo: jest.Mock
updateWebAppAccessMode: jest.Mock
updateAppParams: jest.Mock
updateWebAppMeta: jest.Mock
updateUserCanAccessApp: jest.Mock
}) => unknown,
) => {
const state = {
updateAppInfo: mockUpdateAppInfo,
updateWebAppAccessMode: mockUpdateWebAppAccessMode,
updateAppParams: mockUpdateAppParams,
updateWebAppMeta: mockUpdateWebAppMeta,
updateUserCanAccessApp: mockUpdateUserCanAccessApp,
}
return selector(state)
})
// Mock service hooks with default success states
;(useGetInstalledAppAccessModeByAppId as jest.Mock).mockReturnValue({
isFetching: false,
data: mockWebAppAccessMode,
error: null,
})
;(useGetInstalledAppParams as jest.Mock).mockReturnValue({
isFetching: false,
data: mockAppParams,
error: null,
})
;(useGetInstalledAppMeta as jest.Mock).mockReturnValue({
isFetching: false,
data: mockAppMeta,
error: null,
})
;(useGetUserCanAccessApp as jest.Mock).mockReturnValue({
data: mockUserCanAccessApp,
error: null,
})
})
describe('Rendering', () => {
it('should render without crashing', () => {
render(<InstalledApp id="installed-app-123" />)
expect(screen.getByTestId('chat-with-history')).toBeInTheDocument()
})
it('should render loading state when fetching app params', () => {
;(useGetInstalledAppParams as jest.Mock).mockReturnValue({
isFetching: true,
data: null,
error: null,
})
const { container } = render(<InstalledApp id="installed-app-123" />)
const svg = container.querySelector('svg.spin-animation')
expect(svg).toBeInTheDocument()
})
it('should render loading state when fetching app meta', () => {
;(useGetInstalledAppMeta as jest.Mock).mockReturnValue({
isFetching: true,
data: null,
error: null,
})
const { container } = render(<InstalledApp id="installed-app-123" />)
const svg = container.querySelector('svg.spin-animation')
expect(svg).toBeInTheDocument()
})
it('should render loading state when fetching web app access mode', () => {
;(useGetInstalledAppAccessModeByAppId as jest.Mock).mockReturnValue({
isFetching: true,
data: null,
error: null,
})
const { container } = render(<InstalledApp id="installed-app-123" />)
const svg = container.querySelector('svg.spin-animation')
expect(svg).toBeInTheDocument()
})
it('should render loading state when fetching installed apps', () => {
;(useContext as jest.Mock).mockReturnValue({
installedApps: [mockInstalledApp],
isFetchingInstalledApps: true,
})
const { container } = render(<InstalledApp id="installed-app-123" />)
const svg = container.querySelector('svg.spin-animation')
expect(svg).toBeInTheDocument()
})
it('should render app not found (404) when installedApp does not exist', () => {
;(useContext as jest.Mock).mockReturnValue({
installedApps: [],
isFetchingInstalledApps: false,
})
render(<InstalledApp id="nonexistent-app" />)
expect(screen.getByText(/404/)).toBeInTheDocument()
})
})
describe('Error States', () => {
it('should render error when app params fails to load', () => {
const error = new Error('Failed to load app params')
;(useGetInstalledAppParams as jest.Mock).mockReturnValue({
isFetching: false,
data: null,
error,
})
render(<InstalledApp id="installed-app-123" />)
expect(screen.getByText(/Failed to load app params/)).toBeInTheDocument()
})
it('should render error when app meta fails to load', () => {
const error = new Error('Failed to load app meta')
;(useGetInstalledAppMeta as jest.Mock).mockReturnValue({
isFetching: false,
data: null,
error,
})
render(<InstalledApp id="installed-app-123" />)
expect(screen.getByText(/Failed to load app meta/)).toBeInTheDocument()
})
it('should render error when web app access mode fails to load', () => {
const error = new Error('Failed to load access mode')
;(useGetInstalledAppAccessModeByAppId as jest.Mock).mockReturnValue({
isFetching: false,
data: null,
error,
})
render(<InstalledApp id="installed-app-123" />)
expect(screen.getByText(/Failed to load access mode/)).toBeInTheDocument()
})
it('should render error when user access check fails', () => {
const error = new Error('Failed to check user access')
;(useGetUserCanAccessApp as jest.Mock).mockReturnValue({
data: null,
error,
})
render(<InstalledApp id="installed-app-123" />)
expect(screen.getByText(/Failed to check user access/)).toBeInTheDocument()
})
it('should render no permission (403) when user cannot access app', () => {
;(useGetUserCanAccessApp as jest.Mock).mockReturnValue({
data: { result: false },
error: null,
})
render(<InstalledApp id="installed-app-123" />)
expect(screen.getByText(/403/)).toBeInTheDocument()
expect(screen.getByText(/no permission/i)).toBeInTheDocument()
})
})
describe('App Mode Rendering', () => {
it('should render ChatWithHistory for CHAT mode', () => {
render(<InstalledApp id="installed-app-123" />)
expect(screen.getByTestId('chat-with-history')).toBeInTheDocument()
expect(screen.queryByTestId('text-generation-app')).not.toBeInTheDocument()
})
it('should render ChatWithHistory for ADVANCED_CHAT mode', () => {
const advancedChatApp = {
...mockInstalledApp,
app: {
...mockInstalledApp.app,
mode: AppModeEnum.ADVANCED_CHAT,
},
}
;(useContext as jest.Mock).mockReturnValue({
installedApps: [advancedChatApp],
isFetchingInstalledApps: false,
})
render(<InstalledApp id="installed-app-123" />)
expect(screen.getByTestId('chat-with-history')).toBeInTheDocument()
expect(screen.queryByTestId('text-generation-app')).not.toBeInTheDocument()
})
it('should render ChatWithHistory for AGENT_CHAT mode', () => {
const agentChatApp = {
...mockInstalledApp,
app: {
...mockInstalledApp.app,
mode: AppModeEnum.AGENT_CHAT,
},
}
;(useContext as jest.Mock).mockReturnValue({
installedApps: [agentChatApp],
isFetchingInstalledApps: false,
})
render(<InstalledApp id="installed-app-123" />)
expect(screen.getByTestId('chat-with-history')).toBeInTheDocument()
expect(screen.queryByTestId('text-generation-app')).not.toBeInTheDocument()
})
it('should render TextGenerationApp for COMPLETION mode', () => {
const completionApp = {
...mockInstalledApp,
app: {
...mockInstalledApp.app,
mode: AppModeEnum.COMPLETION,
},
}
;(useContext as jest.Mock).mockReturnValue({
installedApps: [completionApp],
isFetchingInstalledApps: false,
})
render(<InstalledApp id="installed-app-123" />)
expect(screen.getByTestId('text-generation-app')).toBeInTheDocument()
expect(screen.getByText(/Text Generation App/)).toBeInTheDocument()
expect(screen.queryByText(/Workflow/)).not.toBeInTheDocument()
})
it('should render TextGenerationApp with workflow flag for WORKFLOW mode', () => {
const workflowApp = {
...mockInstalledApp,
app: {
...mockInstalledApp.app,
mode: AppModeEnum.WORKFLOW,
},
}
;(useContext as jest.Mock).mockReturnValue({
installedApps: [workflowApp],
isFetchingInstalledApps: false,
})
render(<InstalledApp id="installed-app-123" />)
expect(screen.getByTestId('text-generation-app')).toBeInTheDocument()
expect(screen.getByText(/Workflow/)).toBeInTheDocument()
})
})
describe('Props', () => {
it('should use id prop to find installed app', () => {
const app1 = { ...mockInstalledApp, id: 'app-1' }
const app2 = { ...mockInstalledApp, id: 'app-2' }
;(useContext as jest.Mock).mockReturnValue({
installedApps: [app1, app2],
isFetchingInstalledApps: false,
})
render(<InstalledApp id="app-2" />)
expect(screen.getByText(/app-2/)).toBeInTheDocument()
})
it('should handle id that does not match any installed app', () => {
render(<InstalledApp id="nonexistent-id" />)
expect(screen.getByText(/404/)).toBeInTheDocument()
})
})
describe('Effects', () => {
it('should update app info when installedApp is available', async () => {
render(<InstalledApp id="installed-app-123" />)
await waitFor(() => {
expect(mockUpdateAppInfo).toHaveBeenCalledWith(
expect.objectContaining({
app_id: 'installed-app-123',
site: expect.objectContaining({
title: 'Test App',
icon_type: 'emoji',
icon: '🚀',
icon_background: '#FFFFFF',
icon_url: '',
prompt_public: false,
copyright: '',
show_workflow_steps: true,
use_icon_as_answer_icon: false,
}),
plan: 'basic',
custom_config: null,
}),
)
})
})
it('should update app info to null when installedApp is not found', async () => {
;(useContext as jest.Mock).mockReturnValue({
installedApps: [],
isFetchingInstalledApps: false,
})
render(<InstalledApp id="nonexistent-app" />)
await waitFor(() => {
expect(mockUpdateAppInfo).toHaveBeenCalledWith(null)
})
})
it('should update app params when data is available', async () => {
render(<InstalledApp id="installed-app-123" />)
await waitFor(() => {
expect(mockUpdateAppParams).toHaveBeenCalledWith(mockAppParams)
})
})
it('should update app meta when data is available', async () => {
render(<InstalledApp id="installed-app-123" />)
await waitFor(() => {
expect(mockUpdateWebAppMeta).toHaveBeenCalledWith(mockAppMeta)
})
})
it('should update web app access mode when data is available', async () => {
render(<InstalledApp id="installed-app-123" />)
await waitFor(() => {
expect(mockUpdateWebAppAccessMode).toHaveBeenCalledWith(AccessMode.PUBLIC)
})
})
it('should update user can access app when data is available', async () => {
render(<InstalledApp id="installed-app-123" />)
await waitFor(() => {
expect(mockUpdateUserCanAccessApp).toHaveBeenCalledWith(true)
})
})
it('should update user can access app to false when result is false', async () => {
;(useGetUserCanAccessApp as jest.Mock).mockReturnValue({
data: { result: false },
error: null,
})
render(<InstalledApp id="installed-app-123" />)
await waitFor(() => {
expect(mockUpdateUserCanAccessApp).toHaveBeenCalledWith(false)
})
})
it('should update user can access app to false when data is null', async () => {
;(useGetUserCanAccessApp as jest.Mock).mockReturnValue({
data: null,
error: null,
})
render(<InstalledApp id="installed-app-123" />)
await waitFor(() => {
expect(mockUpdateUserCanAccessApp).toHaveBeenCalledWith(false)
})
})
it('should not update app params when data is null', async () => {
;(useGetInstalledAppParams as jest.Mock).mockReturnValue({
isFetching: false,
data: null,
error: null,
})
render(<InstalledApp id="installed-app-123" />)
await waitFor(() => {
expect(mockUpdateAppInfo).toHaveBeenCalled()
})
expect(mockUpdateAppParams).not.toHaveBeenCalled()
})
it('should not update app meta when data is null', async () => {
;(useGetInstalledAppMeta as jest.Mock).mockReturnValue({
isFetching: false,
data: null,
error: null,
})
render(<InstalledApp id="installed-app-123" />)
await waitFor(() => {
expect(mockUpdateAppInfo).toHaveBeenCalled()
})
expect(mockUpdateWebAppMeta).not.toHaveBeenCalled()
})
it('should not update access mode when data is null', async () => {
;(useGetInstalledAppAccessModeByAppId as jest.Mock).mockReturnValue({
isFetching: false,
data: null,
error: null,
})
render(<InstalledApp id="installed-app-123" />)
await waitFor(() => {
expect(mockUpdateAppInfo).toHaveBeenCalled()
})
expect(mockUpdateWebAppAccessMode).not.toHaveBeenCalled()
})
})
describe('Edge Cases', () => {
it('should handle empty installedApps array', () => {
;(useContext as jest.Mock).mockReturnValue({
installedApps: [],
isFetchingInstalledApps: false,
})
render(<InstalledApp id="installed-app-123" />)
expect(screen.getByText(/404/)).toBeInTheDocument()
})
it('should handle multiple installed apps and find the correct one', () => {
const otherApp = {
...mockInstalledApp,
id: 'other-app-id',
app: {
...mockInstalledApp.app,
name: 'Other App',
},
}
;(useContext as jest.Mock).mockReturnValue({
installedApps: [otherApp, mockInstalledApp],
isFetchingInstalledApps: false,
})
render(<InstalledApp id="installed-app-123" />)
// Should find and render the correct app
expect(screen.getByTestId('chat-with-history')).toBeInTheDocument()
expect(screen.getByText(/installed-app-123/)).toBeInTheDocument()
})
it('should apply correct CSS classes to container', () => {
const { container } = render(<InstalledApp id="installed-app-123" />)
const mainDiv = container.firstChild as HTMLElement
expect(mainDiv).toHaveClass('h-full', 'bg-background-default', 'py-2', 'pl-0', 'pr-2', 'sm:p-2')
})
it('should apply correct CSS classes to ChatWithHistory', () => {
render(<InstalledApp id="installed-app-123" />)
const chatComponent = screen.getByTestId('chat-with-history')
expect(chatComponent).toHaveClass('overflow-hidden', 'rounded-2xl', 'shadow-md')
})
it('should handle rapid id prop changes', async () => {
const app1 = { ...mockInstalledApp, id: 'app-1' }
const app2 = { ...mockInstalledApp, id: 'app-2' }
;(useContext as jest.Mock).mockReturnValue({
installedApps: [app1, app2],
isFetchingInstalledApps: false,
})
const { rerender } = render(<InstalledApp id="app-1" />)
expect(screen.getByText(/app-1/)).toBeInTheDocument()
rerender(<InstalledApp id="app-2" />)
expect(screen.getByText(/app-2/)).toBeInTheDocument()
})
it('should call service hooks with correct appId', () => {
render(<InstalledApp id="installed-app-123" />)
expect(useGetInstalledAppAccessModeByAppId).toHaveBeenCalledWith('installed-app-123')
expect(useGetInstalledAppParams).toHaveBeenCalledWith('installed-app-123')
expect(useGetInstalledAppMeta).toHaveBeenCalledWith('installed-app-123')
expect(useGetUserCanAccessApp).toHaveBeenCalledWith({
appId: 'app-123',
isInstalledApp: true,
})
})
it('should call service hooks with null when installedApp is not found', () => {
;(useContext as jest.Mock).mockReturnValue({
installedApps: [],
isFetchingInstalledApps: false,
})
render(<InstalledApp id="nonexistent-app" />)
expect(useGetInstalledAppAccessModeByAppId).toHaveBeenCalledWith(null)
expect(useGetInstalledAppParams).toHaveBeenCalledWith(null)
expect(useGetInstalledAppMeta).toHaveBeenCalledWith(null)
expect(useGetUserCanAccessApp).toHaveBeenCalledWith({
appId: undefined,
isInstalledApp: true,
})
})
})
describe('Component Memoization', () => {
it('should be wrapped with React.memo', () => {
// React.memo wraps the component with a special $$typeof symbol
const componentType = (InstalledApp as React.MemoExoticComponent<typeof InstalledApp>).$$typeof
expect(componentType).toBeDefined()
})
it('should re-render when props change', () => {
const { rerender } = render(<InstalledApp id="installed-app-123" />)
expect(screen.getByText(/installed-app-123/)).toBeInTheDocument()
// Change to a different app
const differentApp = {
...mockInstalledApp,
id: 'different-app-456',
app: {
...mockInstalledApp.app,
name: 'Different App',
},
}
;(useContext as jest.Mock).mockReturnValue({
installedApps: [differentApp],
isFetchingInstalledApps: false,
})
rerender(<InstalledApp id="different-app-456" />)
expect(screen.getByText(/different-app-456/)).toBeInTheDocument()
})
it('should maintain component stability across re-renders with same props', () => {
const { rerender } = render(<InstalledApp id="installed-app-123" />)
const initialCallCount = mockUpdateAppInfo.mock.calls.length
// Rerender with same props - useEffect may still run due to dependencies
rerender(<InstalledApp id="installed-app-123" />)
// Component should render successfully
expect(screen.getByTestId('chat-with-history')).toBeInTheDocument()
// Mock calls might increase due to useEffect, but component should be stable
expect(mockUpdateAppInfo.mock.calls.length).toBeGreaterThanOrEqual(initialCallCount)
})
})
describe('Render Priority', () => {
it('should show error before loading state', () => {
;(useGetInstalledAppParams as jest.Mock).mockReturnValue({
isFetching: true,
data: null,
error: new Error('Some error'),
})
render(<InstalledApp id="installed-app-123" />)
// Error should take precedence over loading
expect(screen.getByText(/Some error/)).toBeInTheDocument()
})
it('should show error before permission check', () => {
;(useGetInstalledAppParams as jest.Mock).mockReturnValue({
isFetching: false,
data: null,
error: new Error('Params error'),
})
;(useGetUserCanAccessApp as jest.Mock).mockReturnValue({
data: { result: false },
error: null,
})
render(<InstalledApp id="installed-app-123" />)
// Error should take precedence over permission
expect(screen.getByText(/Params error/)).toBeInTheDocument()
expect(screen.queryByText(/403/)).not.toBeInTheDocument()
})
it('should show permission error before 404', () => {
;(useContext as jest.Mock).mockReturnValue({
installedApps: [],
isFetchingInstalledApps: false,
})
;(useGetUserCanAccessApp as jest.Mock).mockReturnValue({
data: { result: false },
error: null,
})
render(<InstalledApp id="nonexistent-app" />)
// Permission should take precedence over 404
expect(screen.getByText(/403/)).toBeInTheDocument()
expect(screen.queryByText(/404/)).not.toBeInTheDocument()
})
it('should show loading before 404', () => {
;(useContext as jest.Mock).mockReturnValue({
installedApps: [],
isFetchingInstalledApps: false,
})
;(useGetInstalledAppParams as jest.Mock).mockReturnValue({
isFetching: true,
data: null,
error: null,
})
const { container } = render(<InstalledApp id="nonexistent-app" />)
// Loading should take precedence over 404
const svg = container.querySelector('svg.spin-animation')
expect(svg).toBeInTheDocument()
expect(screen.queryByText(/404/)).not.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

@ -0,0 +1,72 @@
import { render, screen } from '@testing-library/react'
import ChatVariableTrigger from './chat-variable-trigger'
const mockUseNodesReadOnly = jest.fn()
const mockUseIsChatMode = jest.fn()
jest.mock('@/app/components/workflow/hooks', () => ({
__esModule: true,
useNodesReadOnly: () => mockUseNodesReadOnly(),
}))
jest.mock('../../hooks', () => ({
__esModule: true,
useIsChatMode: () => mockUseIsChatMode(),
}))
jest.mock('@/app/components/workflow/header/chat-variable-button', () => ({
__esModule: true,
default: ({ disabled }: { disabled: boolean }) => (
<button data-testid='chat-variable-button' type='button' disabled={disabled}>
ChatVariableButton
</button>
),
}))
describe('ChatVariableTrigger', () => {
beforeEach(() => {
jest.clearAllMocks()
})
// Verifies conditional rendering when chat mode is off.
describe('Rendering', () => {
it('should not render when not in chat mode', () => {
// Arrange
mockUseIsChatMode.mockReturnValue(false)
mockUseNodesReadOnly.mockReturnValue({ nodesReadOnly: false })
// Act
render(<ChatVariableTrigger />)
// Assert
expect(screen.queryByTestId('chat-variable-button')).not.toBeInTheDocument()
})
})
// Verifies the disabled state reflects read-only nodes.
describe('Props', () => {
it('should render enabled ChatVariableButton when nodes are editable', () => {
// Arrange
mockUseIsChatMode.mockReturnValue(true)
mockUseNodesReadOnly.mockReturnValue({ nodesReadOnly: false })
// Act
render(<ChatVariableTrigger />)
// Assert
expect(screen.getByTestId('chat-variable-button')).toBeEnabled()
})
it('should render disabled ChatVariableButton when nodes are read-only', () => {
// Arrange
mockUseIsChatMode.mockReturnValue(true)
mockUseNodesReadOnly.mockReturnValue({ nodesReadOnly: true })
// Act
render(<ChatVariableTrigger />)
// Assert
expect(screen.getByTestId('chat-variable-button')).toBeDisabled()
})
})
})

View File

@ -0,0 +1,458 @@
import { render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { Plan } from '@/app/components/billing/type'
import { BlockEnum, InputVarType } from '@/app/components/workflow/types'
import FeaturesTrigger from './features-trigger'
const mockUseIsChatMode = jest.fn()
const mockUseTheme = jest.fn()
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()
const mockUseEdges = jest.fn()
const mockUseAppStoreSelector = jest.fn()
const mockNotify = jest.fn()
const mockHandleCheckBeforePublish = jest.fn()
const mockHandleSyncWorkflowDraft = jest.fn()
const mockPublishWorkflow = jest.fn()
const mockUpdatePublishedWorkflow = jest.fn()
const mockResetWorkflowVersionHistory = jest.fn()
const mockInvalidateAppTriggers = jest.fn()
const mockFetchAppDetail = jest.fn()
const mockSetAppDetail = jest.fn()
const mockSetPublishedAt = jest.fn()
const mockSetLastPublishedHasUserInput = jest.fn()
const mockWorkflowStoreSetState = jest.fn()
const mockWorkflowStoreSetShowFeaturesPanel = jest.fn()
let workflowStoreState = {
showFeaturesPanel: false,
isRestoring: false,
setShowFeaturesPanel: mockWorkflowStoreSetShowFeaturesPanel,
setPublishedAt: mockSetPublishedAt,
setLastPublishedHasUserInput: mockSetLastPublishedHasUserInput,
}
const mockWorkflowStore = {
getState: () => workflowStoreState,
setState: mockWorkflowStoreSetState,
}
let capturedAppPublisherProps: Record<string, unknown> | null = null
jest.mock('@/app/components/workflow/hooks', () => ({
__esModule: true,
useChecklist: (...args: unknown[]) => mockUseChecklist(...args),
useChecklistBeforePublish: () => mockUseChecklistBeforePublish(),
useNodesReadOnly: () => mockUseNodesReadOnly(),
useNodesSyncDraft: () => mockUseNodesSyncDraft(),
useIsChatMode: () => mockUseIsChatMode(),
}))
jest.mock('@/app/components/workflow/store', () => ({
__esModule: true,
useStore: (selector: (state: Record<string, unknown>) => unknown) => {
const state: Record<string, unknown> = {
publishedAt: null,
draftUpdatedAt: null,
toolPublished: false,
lastPublishedHasUserInput: false,
}
return selector(state)
},
useWorkflowStore: () => mockWorkflowStore,
}))
jest.mock('@/app/components/base/features/hooks', () => ({
__esModule: true,
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(),
}))
jest.mock('@/app/components/workflow/store/workflow/use-nodes', () => ({
__esModule: true,
default: () => mockUseNodes(),
}))
jest.mock('reactflow', () => ({
__esModule: true,
useEdges: () => mockUseEdges(),
}))
jest.mock('@/app/components/app/app-publisher', () => ({
__esModule: true,
default: (props: Record<string, unknown>) => {
capturedAppPublisherProps = props
return (
<div
data-testid='app-publisher'
data-disabled={String(Boolean(props.disabled))}
data-publish-disabled={String(Boolean(props.publishDisabled))}
/>
)
},
}))
jest.mock('@/service/use-workflow', () => ({
__esModule: true,
useInvalidateAppWorkflow: () => mockUpdatePublishedWorkflow,
usePublishWorkflow: () => ({ mutateAsync: mockPublishWorkflow }),
useResetWorkflowVersionHistory: () => mockResetWorkflowVersionHistory,
}))
jest.mock('@/service/use-tools', () => ({
__esModule: true,
useInvalidateAppTriggers: () => mockInvalidateAppTriggers,
}))
jest.mock('@/service/apps', () => ({
__esModule: true,
fetchAppDetail: (...args: unknown[]) => mockFetchAppDetail(...args),
}))
jest.mock('@/hooks/use-theme', () => ({
__esModule: true,
default: () => mockUseTheme(),
}))
jest.mock('@/app/components/app/store', () => ({
__esModule: true,
useStore: (selector: (state: { appDetail?: { id: string }; setAppDetail: typeof mockSetAppDetail }) => unknown) => mockUseAppStoreSelector(selector),
}))
const createProviderContext = ({
type = Plan.sandbox,
isFetchedPlan = true,
}: {
type?: Plan
isFetchedPlan?: boolean
}) => ({
plan: { type },
isFetchedPlan,
})
describe('FeaturesTrigger', () => {
beforeEach(() => {
jest.clearAllMocks()
capturedAppPublisherProps = null
workflowStoreState = {
showFeaturesPanel: false,
isRestoring: false,
setShowFeaturesPanel: mockWorkflowStoreSetShowFeaturesPanel,
setPublishedAt: mockSetPublishedAt,
setLastPublishedHasUserInput: mockSetLastPublishedHasUserInput,
}
mockUseTheme.mockReturnValue({ theme: 'light' })
mockUseNodesReadOnly.mockReturnValue({ nodesReadOnly: false, getNodesReadOnly: () => false })
mockUseChecklist.mockReturnValue([])
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([])
mockUseEdges.mockReturnValue([])
mockUseAppStoreSelector.mockImplementation(selector => selector({ appDetail: { id: 'app-id' }, setAppDetail: mockSetAppDetail }))
mockFetchAppDetail.mockResolvedValue({ id: 'app-id' })
mockPublishWorkflow.mockResolvedValue({ created_at: '2024-01-01T00:00:00Z' })
})
// Verifies the feature toggle button only appears in chatflow mode.
describe('Rendering', () => {
it('should not render the features button when not in chat mode', () => {
// Arrange
mockUseIsChatMode.mockReturnValue(false)
// Act
render(<FeaturesTrigger />)
// Assert
expect(screen.queryByRole('button', { name: /workflow\.common\.features/i })).not.toBeInTheDocument()
})
it('should render the features button when in chat mode', () => {
// Arrange
mockUseIsChatMode.mockReturnValue(true)
// Act
render(<FeaturesTrigger />)
// Assert
expect(screen.getByRole('button', { name: /workflow\.common\.features/i })).toBeInTheDocument()
})
it('should apply dark theme styling when theme is dark', () => {
// Arrange
mockUseIsChatMode.mockReturnValue(true)
mockUseTheme.mockReturnValue({ theme: 'dark' })
// Act
render(<FeaturesTrigger />)
// Assert
expect(screen.getByRole('button', { name: /workflow\.common\.features/i })).toHaveClass('rounded-lg')
})
})
// Verifies user clicks toggle the features panel visibility.
describe('User Interactions', () => {
it('should toggle features panel when clicked and nodes are editable', async () => {
// Arrange
const user = userEvent.setup()
mockUseIsChatMode.mockReturnValue(true)
mockUseNodesReadOnly.mockReturnValue({ nodesReadOnly: false, getNodesReadOnly: () => false })
render(<FeaturesTrigger />)
// Act
await user.click(screen.getByRole('button', { name: /workflow\.common\.features/i }))
// Assert
expect(mockWorkflowStoreSetShowFeaturesPanel).toHaveBeenCalledWith(true)
})
})
// Covers read-only gating that prevents toggling unless restoring.
describe('Edge Cases', () => {
it('should not toggle features panel when nodes are read-only and not restoring', async () => {
// Arrange
const user = userEvent.setup()
mockUseIsChatMode.mockReturnValue(true)
mockUseNodesReadOnly.mockReturnValue({ nodesReadOnly: true, getNodesReadOnly: () => true })
workflowStoreState = {
...workflowStoreState,
isRestoring: false,
}
render(<FeaturesTrigger />)
// Act
await user.click(screen.getByRole('button', { name: /workflow\.common\.features/i }))
// Assert
expect(mockWorkflowStoreSetShowFeaturesPanel).not.toHaveBeenCalled()
})
})
// Verifies the publisher reflects the presence of workflow nodes.
describe('Props', () => {
it('should disable AppPublisher when there are no workflow nodes', () => {
// Arrange
mockUseIsChatMode.mockReturnValue(false)
mockUseNodes.mockReturnValue([])
// Act
render(<FeaturesTrigger />)
// Assert
expect(capturedAppPublisherProps?.disabled).toBe(true)
expect(screen.getByTestId('app-publisher')).toHaveAttribute('data-disabled', 'true')
})
})
// Verifies derived props passed into AppPublisher (variables, limits, and triggers).
describe('Computed Props', () => {
it('should append image input when file image upload is enabled', () => {
// Arrange
mockUseFeatures.mockImplementation((selector: (state: Record<string, unknown>) => unknown) => selector({
features: { file: { image: { enabled: true } } },
}))
mockUseNodes.mockReturnValue([
{ id: 'start', data: { type: BlockEnum.Start } },
])
// Act
render(<FeaturesTrigger />)
// Assert
const inputs = (capturedAppPublisherProps?.inputs as unknown as Array<{ type?: string; variable?: string }>) || []
expect(inputs).toContainEqual({
type: InputVarType.files,
variable: '__image',
required: false,
label: 'files',
})
})
it('should set startNodeLimitExceeded when sandbox entry limit is exceeded', () => {
// Arrange
mockUseNodes.mockReturnValue([
{ id: 'start', data: { type: BlockEnum.Start } },
{ id: 'trigger-1', data: { type: BlockEnum.TriggerWebhook } },
{ id: 'trigger-2', data: { type: BlockEnum.TriggerSchedule } },
{ id: 'end', data: { type: BlockEnum.End } },
])
// Act
render(<FeaturesTrigger />)
// Assert
expect(capturedAppPublisherProps?.startNodeLimitExceeded).toBe(true)
expect(capturedAppPublisherProps?.publishDisabled).toBe(true)
expect(capturedAppPublisherProps?.hasTriggerNode).toBe(true)
})
})
// Verifies callbacks wired from AppPublisher to stores and draft syncing.
describe('Callbacks', () => {
it('should set toolPublished when AppPublisher refreshes data', () => {
// Arrange
render(<FeaturesTrigger />)
const refresh = capturedAppPublisherProps?.onRefreshData as unknown as (() => void) | undefined
expect(refresh).toBeDefined()
// Act
refresh?.()
// Assert
expect(mockWorkflowStoreSetState).toHaveBeenCalledWith({ toolPublished: true })
})
it('should sync workflow draft when AppPublisher toggles on', () => {
// Arrange
render(<FeaturesTrigger />)
const onToggle = capturedAppPublisherProps?.onToggle as unknown as ((state: boolean) => void) | undefined
expect(onToggle).toBeDefined()
// Act
onToggle?.(true)
// Assert
expect(mockHandleSyncWorkflowDraft).toHaveBeenCalledWith(true)
})
it('should not sync workflow draft when AppPublisher toggles off', () => {
// Arrange
render(<FeaturesTrigger />)
const onToggle = capturedAppPublisherProps?.onToggle as unknown as ((state: boolean) => void) | undefined
expect(onToggle).toBeDefined()
// Act
onToggle?.(false)
// Assert
expect(mockHandleSyncWorkflowDraft).not.toHaveBeenCalled()
})
})
// Verifies publishing behavior across warnings, validation, and success.
describe('Publishing', () => {
it('should notify error and reject publish when checklist has warning nodes', async () => {
// Arrange
mockUseChecklist.mockReturnValue([{ id: 'warning' }])
render(<FeaturesTrigger />)
const onPublish = capturedAppPublisherProps?.onPublish as unknown as (() => Promise<void>) | undefined
expect(onPublish).toBeDefined()
// Act
await expect(onPublish?.()).rejects.toThrow('Checklist has unresolved items')
// Assert
expect(mockNotify).toHaveBeenCalledWith({ type: 'error', message: 'workflow.panel.checklistTip' })
})
it('should reject publish when checklist before publish fails', async () => {
// Arrange
mockHandleCheckBeforePublish.mockResolvedValue(false)
render(<FeaturesTrigger />)
const onPublish = capturedAppPublisherProps?.onPublish as unknown as (() => Promise<void>) | undefined
expect(onPublish).toBeDefined()
// Act & Assert
await expect(onPublish?.()).rejects.toThrow('Checklist failed')
})
it('should publish workflow and update related stores when validation passes', async () => {
// Arrange
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()
// Act
await onPublish?.()
// 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(mockFetchAppDetail).toHaveBeenCalledWith({ url: '/apps', id: 'app-id' })
expect(mockSetAppDetail).toHaveBeenCalled()
})
})
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()
// Act
await onPublish?.({ title: 'Test title', releaseNotes: 'Test notes' })
// Assert
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 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()
// Act
await onPublish?.()
// Assert
await waitFor(() => {
expect(consoleErrorSpy).toHaveBeenCalled()
})
consoleErrorSpy.mockRestore()
})
})
})

View File

@ -0,0 +1,149 @@
import { render } 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', () => ({
__esModule: true,
default: {
create: () => ({
extend: () => async () => ({
status: 200,
headers: new Headers(),
json: async () => ({}),
blob: async () => new Blob(),
clone: () => ({
status: 200,
json: async () => ({}),
}),
}),
}),
},
}))
jest.mock('@/app/components/app/store', () => ({
__esModule: true,
useStore: (selector: (state: { appDetail?: App; setCurrentLogItem: typeof mockSetCurrentLogItem; setShowMessageLogModal: typeof mockSetShowMessageLogModal }) => unknown) => mockUseAppStoreSelector(selector),
}))
jest.mock('@/app/components/workflow/header', () => ({
__esModule: true,
default: (props: HeaderProps) => {
capturedHeaderProps = props
return <div data-testid='workflow-header' />
},
}))
jest.mock('@/service/workflow', () => ({
__esModule: true,
fetchWorkflowRunHistory: jest.fn(),
}))
jest.mock('@/service/use-workflow', () => ({
__esModule: true,
useResetWorkflowVersionHistory: () => mockResetWorkflowVersionHistory,
}))
describe('WorkflowHeader', () => {
beforeEach(() => {
jest.clearAllMocks()
capturedHeaderProps = null
appDetail = { id: 'app-id', mode: AppModeEnum.COMPLETION } as unknown as App
mockUseAppStoreSelector.mockImplementation(selector => selector({
appDetail,
setCurrentLogItem: mockSetCurrentLogItem,
setShowMessageLogModal: mockSetShowMessageLogModal,
}))
})
// Verifies the wrapper renders the workflow header shell.
describe('Rendering', () => {
it('should render without crashing', () => {
// Act
render(<WorkflowHeader />)
// Assert
expect(capturedHeaderProps).not.toBeNull()
})
})
// Verifies chat mode affects which primary action is shown in the header.
describe('Props', () => {
it('should configure preview mode when app is in advanced chat mode', () => {
// Arrange
appDetail = { id: 'app-id', mode: AppModeEnum.ADVANCED_CHAT } as unknown as App
mockUseAppStoreSelector.mockImplementation(selector => selector({
appDetail,
setCurrentLogItem: mockSetCurrentLogItem,
setShowMessageLogModal: mockSetShowMessageLogModal,
}))
// Act
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)
})
it('should configure run mode when app is not in advanced chat mode', () => {
// Arrange
appDetail = { id: 'app-id', mode: AppModeEnum.COMPLETION } as unknown as App
mockUseAppStoreSelector.mockImplementation(selector => selector({
appDetail,
setCurrentLogItem: mockSetCurrentLogItem,
setShowMessageLogModal: mockSetShowMessageLogModal,
}))
// Act
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')
})
})
// Verifies callbacks clear log state as expected.
describe('User Interactions', () => {
it('should clear log and close message modal when clearing history modal state', () => {
// Arrange
render(<WorkflowHeader />)
const clear = capturedHeaderProps?.normal?.runAndHistoryProps?.viewHistoryProps?.onClearLogAndMessageModal
expect(clear).toBeDefined()
// Act
clear?.()
// Assert
expect(mockSetCurrentLogItem).toHaveBeenCalledWith()
expect(mockSetShowMessageLogModal).toHaveBeenCalledWith(false)
})
})
// Ensures restoring callback is wired to reset version history.
describe('Edge Cases', () => {
it('should use resetWorkflowVersionHistory as restore settled handler', () => {
// Act
render(<WorkflowHeader />)
// Assert
expect(capturedHeaderProps?.restoring?.onRestoreSettled).toBe(mockResetWorkflowVersionHistory)
})
})
})

View File

@ -204,7 +204,7 @@ const AllTools = ({
}, [onSelect])
return (
<div className={cn('min-w-[400px] max-w-[500px]', className)}>
<div className={cn('max-w-[500px]', className)}>
<div className='flex items-center justify-between border-b border-divider-subtle px-3'>
<div className='flex h-8 items-center space-x-1'>
{

View File

@ -134,7 +134,7 @@ const Blocks = ({
}, [groups, onSelect, t, store])
return (
<div className='max-h-[480px] min-w-[400px] max-w-[500px] overflow-y-auto p-1'>
<div className='max-h-[480px] max-w-[500px] overflow-y-auto p-1'>
{
isEmpty && (
<div className='flex h-[22px] items-center px-3 text-xs font-medium text-text-tertiary'>{t('workflow.tabs.noResult')}</div>

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

@ -4,6 +4,13 @@ import { cleanup } from '@testing-library/react'
// 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

@ -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);