Merge branch 'feat/model-plugins-implementing' into deploy/dev

This commit is contained in:
yyh
2026-03-16 18:01:31 +08:00
126 changed files with 4690 additions and 701 deletions

View File

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

View File

@ -6,6 +6,9 @@ on:
base_sha:
required: false
type: string
diff_range_mode:
required: false
type: string
head_sha:
required: false
type: string
@ -89,6 +92,7 @@ jobs:
- name: Check app/components diff coverage
env:
BASE_SHA: ${{ inputs.base_sha }}
DIFF_RANGE_MODE: ${{ inputs.diff_range_mode }}
HEAD_SHA: ${{ inputs.head_sha }}
run: node ./scripts/check-components-diff-coverage.mjs

View File

@ -737,24 +737,25 @@ SANDBOX_EXPIRED_RECORDS_RETENTION_DAYS=30
SANDBOX_EXPIRED_RECORDS_CLEAN_TASK_LOCK_TTL=90000
# Redis URL used for PubSub between API and
# Redis URL used for event bus between API and
# celery worker
# defaults to url constructed from `REDIS_*`
# configurations
PUBSUB_REDIS_URL=
# Pub/sub channel type for streaming events.
# valid options are:
EVENT_BUS_REDIS_URL=
# Event transport type. Options are:
#
# - pubsub: for normal Pub/Sub
# - sharded: for sharded Pub/Sub
# - pubsub: normal Pub/Sub (at-most-once)
# - sharded: sharded Pub/Sub (at-most-once)
# - streams: Redis Streams (at-least-once, recommended to avoid subscriber races)
#
# It's highly recommended to use sharded Pub/Sub AND redis cluster
# for large deployments.
PUBSUB_REDIS_CHANNEL_TYPE=pubsub
# Whether to use Redis cluster mode while running
# PubSub.
# Note: Before enabling 'streams' in production, estimate your expected event volume and retention needs.
# Configure Redis memory limits and stream trimming appropriately (e.g., MAXLEN and key expiry) to reduce
# the risk of data loss from Redis auto-eviction under memory pressure.
# Also accepts ENV: EVENT_BUS_REDIS_CHANNEL_TYPE.
EVENT_BUS_REDIS_CHANNEL_TYPE=pubsub
# Whether to use Redis cluster mode while use redis as event bus.
# It's highly recommended to enable this for large deployments.
PUBSUB_REDIS_USE_CLUSTERS=false
EVENT_BUS_REDIS_USE_CLUSTERS=false
# Whether to Enable human input timeout check task
ENABLE_HUMAN_INPUT_TIMEOUT_TASK=true

View File

@ -41,10 +41,10 @@ class RedisPubSubConfig(BaseSettings, RedisConfigDefaultsMixin):
)
PUBSUB_REDIS_USE_CLUSTERS: bool = Field(
validation_alias=AliasChoices("EVENT_BUS_REDIS_CLUSTERS", "PUBSUB_REDIS_USE_CLUSTERS"),
validation_alias=AliasChoices("EVENT_BUS_REDIS_USE_CLUSTERS", "PUBSUB_REDIS_USE_CLUSTERS"),
description=(
"Enable Redis Cluster mode for pub/sub or streams transport. Recommended for large deployments. "
"Also accepts ENV: EVENT_BUS_REDIS_CLUSTERS."
"Also accepts ENV: EVENT_BUS_REDIS_USE_CLUSTERS."
),
default=False,
)

View File

@ -55,15 +55,31 @@ def build_protected_resource_metadata_discovery_urls(
"""
urls = []
parsed_server_url = urlparse(server_url)
base_url = f"{parsed_server_url.scheme}://{parsed_server_url.netloc}"
path = parsed_server_url.path.rstrip("/")
# First priority: URL from WWW-Authenticate header
if www_auth_resource_metadata_url:
urls.append(www_auth_resource_metadata_url)
parsed_metadata_url = urlparse(www_auth_resource_metadata_url)
normalized_metadata_url = None
if parsed_metadata_url.scheme and parsed_metadata_url.netloc:
normalized_metadata_url = www_auth_resource_metadata_url
elif not parsed_metadata_url.scheme and parsed_metadata_url.netloc:
normalized_metadata_url = f"{parsed_server_url.scheme}:{www_auth_resource_metadata_url}"
elif (
not parsed_metadata_url.scheme
and not parsed_metadata_url.netloc
and parsed_metadata_url.path.startswith("/")
):
first_segment = parsed_metadata_url.path.lstrip("/").split("/", 1)[0]
if first_segment == ".well-known" or "." not in first_segment:
normalized_metadata_url = urljoin(base_url, parsed_metadata_url.path)
if normalized_metadata_url:
urls.append(normalized_metadata_url)
# Fallback: construct from server URL
parsed = urlparse(server_url)
base_url = f"{parsed.scheme}://{parsed.netloc}"
path = parsed.path.rstrip("/")
# Priority 2: With path insertion (e.g., /.well-known/oauth-protected-resource/public/mcp)
if path:
path_url = f"{base_url}/.well-known/oauth-protected-resource{path}"

View File

@ -8,6 +8,8 @@ from collections.abc import Mapping, Sequence
from datetime import datetime, timedelta
from typing import Annotated, Any, ClassVar, Literal, Self
import bleach
import markdown
from pydantic import BaseModel, Field, field_validator, model_validator
from dify_graph.entities.base_node_data import BaseNodeData
@ -58,6 +60,39 @@ class EmailDeliveryConfig(BaseModel):
"""Configuration for email delivery method."""
URL_PLACEHOLDER: ClassVar[str] = "{{#url#}}"
_SUBJECT_NEWLINE_PATTERN: ClassVar[re.Pattern[str]] = re.compile(r"[\r\n]+")
_ALLOWED_HTML_TAGS: ClassVar[list[str]] = [
"a",
"blockquote",
"br",
"code",
"em",
"h1",
"h2",
"h3",
"h4",
"h5",
"h6",
"hr",
"li",
"ol",
"p",
"pre",
"strong",
"table",
"tbody",
"td",
"th",
"thead",
"tr",
"ul",
]
_ALLOWED_HTML_ATTRIBUTES: ClassVar[dict[str, list[str]]] = {
"a": ["href", "title"],
"td": ["align"],
"th": ["align"],
}
_ALLOWED_PROTOCOLS: ClassVar[list[str]] = ["http", "https", "mailto"]
recipients: EmailRecipients
@ -98,6 +133,43 @@ class EmailDeliveryConfig(BaseModel):
return templated_body
return variable_pool.convert_template(templated_body).text
@classmethod
def render_markdown_body(cls, body: str) -> str:
"""Render markdown to safe HTML for email delivery."""
sanitized_markdown = bleach.clean(
body,
tags=[],
attributes={},
strip=True,
strip_comments=True,
)
rendered_html = markdown.markdown(
sanitized_markdown,
extensions=["nl2br", "tables"],
extension_configs={"tables": {"use_align_attribute": True}},
)
return bleach.clean(
rendered_html,
tags=cls._ALLOWED_HTML_TAGS,
attributes=cls._ALLOWED_HTML_ATTRIBUTES,
protocols=cls._ALLOWED_PROTOCOLS,
strip=True,
strip_comments=True,
)
@classmethod
def sanitize_subject(cls, subject: str) -> str:
"""Sanitize email subject to plain text and prevent CRLF injection."""
sanitized_subject = bleach.clean(
subject,
tags=[],
attributes={},
strip=True,
strip_comments=True,
)
sanitized_subject = cls._SUBJECT_NEWLINE_PATTERN.sub(" ", sanitized_subject)
return " ".join(sanitized_subject.split())
class _DeliveryMethodBase(BaseModel):
"""Base delivery method configuration."""

View File

@ -40,7 +40,7 @@ dependencies = [
"numpy~=1.26.4",
"openpyxl~=3.1.5",
"opik~=1.10.37",
"litellm==1.82.2", # Pinned to avoid madoka dependency issue
"litellm==1.82.2", # Pinned to avoid madoka dependency issue
"opentelemetry-api==1.28.0",
"opentelemetry-distro==0.49b0",
"opentelemetry-exporter-otlp==1.28.0",
@ -91,6 +91,7 @@ dependencies = [
"apscheduler>=3.11.0",
"weave>=0.52.16",
"fastopenapi[flask]>=0.7.0",
"bleach~=6.2.0",
]
# Before adding new dependency, consider place it in
# alphabet order (a-z) and suitable group.
@ -251,10 +252,7 @@ ignore_errors = true
[tool.pyrefly]
project-includes = ["."]
project-excludes = [
".venv",
"migrations/",
]
project-excludes = [".venv", "migrations/"]
python-platform = "linux"
python-version = "3.11.0"
infer-with-first-use = false

View File

@ -155,13 +155,15 @@ class EmailDeliveryTestHandler:
context=context,
recipient_email=recipient_email,
)
subject = render_email_template(method.config.subject, substitutions)
subject_template = render_email_template(method.config.subject, substitutions)
subject = EmailDeliveryConfig.sanitize_subject(subject_template)
templated_body = EmailDeliveryConfig.render_body_template(
body=method.config.body,
url=substitutions.get("form_link"),
variable_pool=context.variable_pool,
)
body = render_email_template(templated_body, substitutions)
body = EmailDeliveryConfig.render_markdown_body(body)
mail.send(
to=recipient_email,

View File

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

View File

@ -186,7 +186,7 @@ class DifyTestContainers:
# Start Dify Plugin Daemon container for plugin management
# Dify Plugin Daemon provides plugin lifecycle management and execution
logger.info("Initializing Dify Plugin Daemon container...")
self.dify_plugin_daemon = DockerContainer(image="langgenius/dify-plugin-daemon:0.3.0-local").with_network(
self.dify_plugin_daemon = DockerContainer(image="langgenius/dify-plugin-daemon:0.5.4-local").with_network(
self.network
)
self.dify_plugin_daemon.with_exposed_ports(5002)

View File

@ -801,6 +801,27 @@ class TestAuthOrchestration:
urls = build_protected_resource_metadata_discovery_urls(None, "https://api.example.com")
assert urls == ["https://api.example.com/.well-known/oauth-protected-resource"]
def test_build_protected_resource_metadata_discovery_urls_with_relative_hint(self):
urls = build_protected_resource_metadata_discovery_urls(
"/.well-known/oauth-protected-resource/tenant/mcp",
"https://api.example.com/tenant/mcp",
)
assert urls == [
"https://api.example.com/.well-known/oauth-protected-resource/tenant/mcp",
"https://api.example.com/.well-known/oauth-protected-resource",
]
def test_build_protected_resource_metadata_discovery_urls_ignores_scheme_less_hint(self):
urls = build_protected_resource_metadata_discovery_urls(
"/openapi-mcp.cn-hangzhou.aliyuncs.com/.well-known/oauth-protected-resource/tenant/mcp",
"https://openapi-mcp.cn-hangzhou.aliyuncs.com/tenant/mcp",
)
assert urls == [
"https://openapi-mcp.cn-hangzhou.aliyuncs.com/.well-known/oauth-protected-resource/tenant/mcp",
"https://openapi-mcp.cn-hangzhou.aliyuncs.com/.well-known/oauth-protected-resource",
]
def test_build_oauth_authorization_server_metadata_discovery_urls(self):
# Case 1: with auth_server_url
urls = build_oauth_authorization_server_metadata_discovery_urls(

View File

@ -14,3 +14,64 @@ def test_render_body_template_replaces_variable_values():
result = config.render_body_template(body=config.body, url="https://example.com", variable_pool=variable_pool)
assert result == "Hello World https://example.com"
def test_render_markdown_body_renders_markdown_to_html():
rendered = EmailDeliveryConfig.render_markdown_body("**Bold** and [link](https://example.com)")
assert "<strong>Bold</strong>" in rendered
assert '<a href="https://example.com">link</a>' in rendered
def test_render_markdown_body_sanitizes_unsafe_html():
rendered = EmailDeliveryConfig.render_markdown_body(
'<script>alert("xss")</script><a href="javascript:alert(1)" onclick="alert(2)">Click</a>'
)
assert "<script" not in rendered
assert "<a" not in rendered
assert "onclick" not in rendered
assert "javascript:" not in rendered
assert "Click" in rendered
def test_render_markdown_body_sanitizes_markdown_link_with_javascript_href():
rendered = EmailDeliveryConfig.render_markdown_body("[bad](javascript:alert(1)) and [ok](https://example.com)")
assert "javascript:" not in rendered
assert "<a>bad</a>" in rendered
assert '<a href="https://example.com">ok</a>' in rendered
def test_render_markdown_body_does_not_allow_raw_html_tags():
rendered = EmailDeliveryConfig.render_markdown_body("<b>raw html</b> and **markdown**")
assert "<b>" not in rendered
assert "raw html" in rendered
assert "<strong>markdown</strong>" in rendered
def test_render_markdown_body_supports_table_syntax():
rendered = EmailDeliveryConfig.render_markdown_body("| h1 | h2 |\n| --- | ---: |\n| v1 | v2 |")
assert "<table>" in rendered
assert "<thead>" in rendered
assert "<tbody>" in rendered
assert 'align="right"' in rendered
assert "style=" not in rendered
def test_sanitize_subject_removes_crlf():
sanitized = EmailDeliveryConfig.sanitize_subject("Notice\r\nBCC:attacker@example.com")
assert "\r" not in sanitized
assert "\n" not in sanitized
assert sanitized == "Notice BCC:attacker@example.com"
def test_sanitize_subject_removes_html_tags():
sanitized = EmailDeliveryConfig.sanitize_subject("<b>Alert</b><img src=x onerror=1>")
assert "<" not in sanitized
assert ">" not in sanitized
assert sanitized == "Alert"

View File

@ -207,6 +207,45 @@ class TestEmailDeliveryTestHandler:
assert kwargs["to"] == "test@example.com"
assert "RENDERED_Subj" in kwargs["subject"]
def test_send_test_sanitizes_subject(self, monkeypatch):
monkeypatch.setattr(
service_module.FeatureService,
"get_features",
lambda _id: SimpleNamespace(human_input_email_delivery_enabled=True),
)
monkeypatch.setattr(service_module.mail, "is_inited", lambda: True)
mock_mail_send = MagicMock()
monkeypatch.setattr(service_module.mail, "send", mock_mail_send)
monkeypatch.setattr(
service_module,
"render_email_template",
lambda template, substitutions: template.replace("{{ recipient_email }}", substitutions["recipient_email"]),
)
handler = EmailDeliveryTestHandler(session_factory=MagicMock())
handler._resolve_recipients = MagicMock(return_value=["test@example.com"])
context = DeliveryTestContext(
tenant_id="t1",
app_id="a1",
node_id="n1",
node_title="title",
rendered_content="content",
recipients=[DeliveryTestEmailRecipient(email="test@example.com", form_token="token123")],
)
method = EmailDeliveryMethod(
config=EmailDeliveryConfig(
recipients=EmailRecipients(whole_workspace=False, items=[]),
subject="<b>Notice</b>\r\nBCC:{{ recipient_email }}",
body="Body",
)
)
handler.send_test(context=context, method=method)
_, kwargs = mock_mail_send.call_args
assert kwargs["subject"] == "Notice BCC:test@example.com"
def test_resolve_recipients(self):
handler = EmailDeliveryTestHandler(session_factory=MagicMock())

View File

@ -120,4 +120,37 @@ def test_dispatch_human_input_email_task_replaces_body_variables(monkeypatch: py
session_factory=lambda: _DummySession(form),
)
assert mail.sent[0]["html"] == "Body OK"
assert mail.sent[0]["html"] == "<p>Body OK</p>"
@pytest.mark.parametrize("line_break", ["\r\n", "\r", "\n"])
def test_dispatch_human_input_email_task_sanitizes_subject(
monkeypatch: pytest.MonkeyPatch,
line_break: str,
):
mail = _DummyMail()
form = SimpleNamespace(id="form-1", tenant_id="tenant-1", workflow_run_id=None)
job = task_module._EmailDeliveryJob(
form_id="form-1",
subject=f"Notice{line_break}BCC:attacker@example.com <b>Alert</b>",
body="Body",
form_content="content",
recipients=[task_module._EmailRecipient(email="user@example.com", token="token-1")],
)
monkeypatch.setattr(task_module, "mail", mail)
monkeypatch.setattr(
task_module.FeatureService,
"get_features",
lambda _tenant_id: SimpleNamespace(human_input_email_delivery_enabled=True),
)
monkeypatch.setattr(task_module, "_load_email_jobs", lambda _session, _form: [job])
monkeypatch.setattr(task_module, "_load_variable_pool", lambda _workflow_run_id: None)
task_module.dispatch_human_input_email_task(
form_id="form-1",
node_title="Approve",
session_factory=lambda: _DummySession(form),
)
assert mail.sent[0]["subject"] == "Notice BCC:attacker@example.com Alert"

14
api/uv.lock generated
View File

@ -658,6 +658,18 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/b3/cc/38b6f87170908bd8aaf9e412b021d17e85f690abe00edf50192f1a4566b9/billiard-4.2.3-py3-none-any.whl", hash = "sha256:989e9b688e3abf153f307b68a1328dfacfb954e30a4f920005654e276c69236b", size = 87042, upload-time = "2025-11-16T17:47:29.005Z" },
]
[[package]]
name = "bleach"
version = "6.2.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "webencodings" },
]
sdist = { url = "https://files.pythonhosted.org/packages/76/9a/0e33f5054c54d349ea62c277191c020c2d6ef1d65ab2cb1993f91ec846d1/bleach-6.2.0.tar.gz", hash = "sha256:123e894118b8a599fd80d3ec1a6d4cc7ce4e5882b1317a7e1ba69b56e95f991f", size = 203083, upload-time = "2024-10-29T18:30:40.477Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/fc/55/96142937f66150805c25c4d0f31ee4132fd33497753400734f9dfdcbdc66/bleach-6.2.0-py3-none-any.whl", hash = "sha256:117d9c6097a7c3d22fd578fcd8d35ff1e125df6736f554da4e432fdd63f31e5e", size = 163406, upload-time = "2024-10-29T18:30:38.186Z" },
]
[[package]]
name = "blinker"
version = "1.9.0"
@ -1529,6 +1541,7 @@ dependencies = [
{ name = "arize-phoenix-otel" },
{ name = "azure-identity" },
{ name = "beautifulsoup4" },
{ name = "bleach" },
{ name = "boto3" },
{ name = "bs4" },
{ name = "cachetools" },
@ -1730,6 +1743,7 @@ requires-dist = [
{ name = "arize-phoenix-otel", specifier = "~=0.15.0" },
{ name = "azure-identity", specifier = "==1.25.3" },
{ name = "beautifulsoup4", specifier = "==4.14.3" },
{ name = "bleach", specifier = "~=6.2.0" },
{ name = "boto3", specifier = "==1.42.68" },
{ name = "bs4", specifier = "~=0.0.1" },
{ name = "cachetools", specifier = "~=5.3.0" },

View File

@ -1546,24 +1546,25 @@ SANDBOX_EXPIRED_RECORDS_CLEAN_BATCH_MAX_INTERVAL=200
SANDBOX_EXPIRED_RECORDS_RETENTION_DAYS=30
# Redis URL used for PubSub between API and
# Redis URL used for event bus between API and
# celery worker
# defaults to url constructed from `REDIS_*`
# configurations
PUBSUB_REDIS_URL=
# Pub/sub channel type for streaming events.
# valid options are:
EVENT_BUS_REDIS_URL=
# Event transport type. Options are:
#
# - pubsub: for normal Pub/Sub
# - sharded: for sharded Pub/Sub
# - pubsub: normal Pub/Sub (at-most-once)
# - sharded: sharded Pub/Sub (at-most-once)
# - streams: Redis Streams (at-least-once, recommended to avoid subscriber races)
#
# It's highly recommended to use sharded Pub/Sub AND redis cluster
# for large deployments.
PUBSUB_REDIS_CHANNEL_TYPE=pubsub
# Whether to use Redis cluster mode while running
# PubSub.
# Note: Before enabling 'streams' in production, estimate your expected event volume and retention needs.
# Configure Redis memory limits and stream trimming appropriately (e.g., MAXLEN and key expiry) to reduce
# the risk of data loss from Redis auto-eviction under memory pressure.
# Also accepts ENV: EVENT_BUS_REDIS_CHANNEL_TYPE.
EVENT_BUS_REDIS_CHANNEL_TYPE=pubsub
# Whether to use Redis cluster mode while use redis as event bus.
# It's highly recommended to enable this for large deployments.
PUBSUB_REDIS_USE_CLUSTERS=false
EVENT_BUS_REDIS_USE_CLUSTERS=false
# Whether to Enable human input timeout check task
ENABLE_HUMAN_INPUT_TIMEOUT_TASK=true

View File

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

View File

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

View File

@ -699,9 +699,9 @@ x-shared-env: &shared-api-worker-env
SANDBOX_EXPIRED_RECORDS_CLEAN_BATCH_SIZE: ${SANDBOX_EXPIRED_RECORDS_CLEAN_BATCH_SIZE:-1000}
SANDBOX_EXPIRED_RECORDS_CLEAN_BATCH_MAX_INTERVAL: ${SANDBOX_EXPIRED_RECORDS_CLEAN_BATCH_MAX_INTERVAL:-200}
SANDBOX_EXPIRED_RECORDS_RETENTION_DAYS: ${SANDBOX_EXPIRED_RECORDS_RETENTION_DAYS:-30}
PUBSUB_REDIS_URL: ${PUBSUB_REDIS_URL:-}
PUBSUB_REDIS_CHANNEL_TYPE: ${PUBSUB_REDIS_CHANNEL_TYPE:-pubsub}
PUBSUB_REDIS_USE_CLUSTERS: ${PUBSUB_REDIS_USE_CLUSTERS:-false}
EVENT_BUS_REDIS_URL: ${EVENT_BUS_REDIS_URL:-}
EVENT_BUS_REDIS_CHANNEL_TYPE: ${EVENT_BUS_REDIS_CHANNEL_TYPE:-pubsub}
EVENT_BUS_REDIS_USE_CLUSTERS: ${EVENT_BUS_REDIS_USE_CLUSTERS:-false}
ENABLE_HUMAN_INPUT_TIMEOUT_TASK: ${ENABLE_HUMAN_INPUT_TIMEOUT_TASK:-true}
HUMAN_INPUT_TIMEOUT_TASK_INTERVAL: ${HUMAN_INPUT_TIMEOUT_TASK_INTERVAL:-1}
SANDBOX_EXPIRED_RECORDS_CLEAN_TASK_LOCK_TTL: ${SANDBOX_EXPIRED_RECORDS_CLEAN_TASK_LOCK_TTL:-90000}
@ -976,7 +976,7 @@ services:
# plugin daemon
plugin_daemon:
image: langgenius/dify-plugin-daemon:0.5.3-local
image: langgenius/dify-plugin-daemon:0.5.4-local
restart: always
environment:
# Use the shared environment variables.

View File

@ -1,4 +1,5 @@
import {
buildGitDiffRevisionArgs,
getChangedBranchCoverage,
getChangedStatementCoverage,
getIgnoredChangedLinesFromSource,
@ -7,6 +8,11 @@ import {
} from '../scripts/check-components-diff-coverage-lib.mjs'
describe('check-components-diff-coverage helpers', () => {
it('should build exact and merge-base git diff revision args', () => {
expect(buildGitDiffRevisionArgs('base-sha', 'head-sha', 'exact')).toEqual(['base-sha', 'head-sha'])
expect(buildGitDiffRevisionArgs('base-sha', 'head-sha')).toEqual(['base-sha...head-sha'])
})
it('should parse changed line maps from unified diffs', () => {
const diff = [
'diff --git a/web/app/components/share/a.ts b/web/app/components/share/a.ts',
@ -79,6 +85,23 @@ describe('check-components-diff-coverage helpers', () => {
})
})
it('should report the first changed line inside a multi-line uncovered statement', () => {
const entry = {
s: { 0: 0 },
statementMap: {
0: { start: { line: 10 }, end: { line: 14 } },
},
}
const coverage = getChangedStatementCoverage(entry, new Set([13, 14]))
expect(coverage).toEqual({
covered: 0,
total: 1,
uncoveredLines: [13],
})
})
it('should fail changed lines when a source file has no coverage entry', () => {
const coverage = getChangedStatementCoverage(undefined, new Set([42, 43]))
@ -118,6 +141,36 @@ describe('check-components-diff-coverage helpers', () => {
})
})
it('should report the first changed line inside a multi-line uncovered branch arm', () => {
const entry = {
b: {
0: [0, 0],
},
branchMap: {
0: {
line: 30,
loc: { start: { line: 30 }, end: { line: 35 } },
locations: [
{ start: { line: 31 }, end: { line: 34 } },
{ start: { line: 35 }, end: { line: 38 } },
],
type: 'if',
},
},
}
const coverage = getChangedBranchCoverage(entry, new Set([33]))
expect(coverage).toEqual({
covered: 0,
total: 2,
uncoveredBranches: [
{ armIndex: 0, line: 33 },
{ armIndex: 1, line: 35 },
],
})
})
it('should ignore changed lines with valid pragma reasons and report invalid pragmas', () => {
const sourceCode = [
'const a = 1',

View File

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

View File

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

View File

@ -419,6 +419,21 @@ describe('ModelParameterTrigger', () => {
expect(screen.getByTestId('tooltip')).toHaveAttribute('data-content', 'common.modelProvider.selector.disabled')
})
it('should apply expanded and warning styles when the trigger is open for a non-active status', () => {
const { unmount } = renderComponent()
const triggerContent = capturedModalProps?.renderTrigger({
open: true,
currentProvider: { provider: 'openai' },
currentModel: { model: 'gpt-3.5-turbo', status: ModelStatusEnum.noConfigure },
})
unmount()
const { container } = render(<>{triggerContent}</>)
expect(container.firstChild).toHaveClass('bg-state-base-hover')
expect(container.firstChild).toHaveClass('!bg-[#FFFAEB]')
})
})
describe('edge cases', () => {

View File

@ -4,7 +4,6 @@ import type { AppIconSelection } from '../../base/app-icon-picker'
import { RiArrowRightLine, RiArrowRightSLine, RiExchange2Fill } from '@remixicon/react'
import { useDebounceFn, useKeyPress } from 'ahooks'
import Image from 'next/image'
import { useRouter } from 'next/navigation'
import { useCallback, useEffect, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
@ -389,7 +388,7 @@ function AppScreenShot({ mode, show }: { mode: AppModeEnum, show: boolean }) {
<source media="(resolution: 1x)" srcSet={`${basePath}/screenshots/${theme}/${modeToImageMap[mode]}.png`} />
<source media="(resolution: 2x)" srcSet={`${basePath}/screenshots/${theme}/${modeToImageMap[mode]}@2x.png`} />
<source media="(resolution: 3x)" srcSet={`${basePath}/screenshots/${theme}/${modeToImageMap[mode]}@3x.png`} />
<Image
<img
className={show ? '' : 'hidden'}
src={`${basePath}/screenshots/${theme}/${modeToImageMap[mode]}.png`}
alt="App Screen Shot"

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -3,7 +3,7 @@ import userEvent from '@testing-library/user-event'
import { describe, expect, it, vi } from 'vitest'
import CredentialSelector from '../index'
// Mock CredentialIcon since it's likely a complex component or uses next/image
// Mock CredentialIcon since it's likely a complex component.
vi.mock('@/app/components/datasets/common/credential-icon', () => ({
CredentialIcon: ({ name }: { name: string }) => <div data-testid="credential-icon">{name}</div>,
}))

View File

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

View File

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

View File

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

View File

@ -0,0 +1,75 @@
import type { WorkflowNodesMap } from '@/app/components/base/prompt-editor/types'
import { renderHook } from '@testing-library/react'
import { BlockEnum } from '@/app/components/workflow/types'
import { useLlmModelPluginInstalled } from './use-llm-model-plugin-installed'
let mockModelProviders: Array<{ provider: string }> = []
vi.mock('@/context/provider-context', () => ({
useProviderContextSelector: <T>(selector: (state: { modelProviders: Array<{ provider: string }> }) => T): T =>
selector({ modelProviders: mockModelProviders }),
}))
const createWorkflowNodesMap = (node: Record<string, unknown>): WorkflowNodesMap =>
({
target: {
title: 'Target',
type: BlockEnum.Start,
...node,
},
} as unknown as WorkflowNodesMap)
describe('useLlmModelPluginInstalled', () => {
beforeEach(() => {
vi.clearAllMocks()
mockModelProviders = []
})
it('should return true when the node is missing', () => {
const { result } = renderHook(() => useLlmModelPluginInstalled('target', undefined))
expect(result.current).toBe(true)
})
it('should return true when the node is not an LLM node', () => {
const workflowNodesMap = createWorkflowNodesMap({
id: 'target',
type: BlockEnum.Start,
})
const { result } = renderHook(() => useLlmModelPluginInstalled('target', workflowNodesMap))
expect(result.current).toBe(true)
})
it('should return true when the matching model plugin is installed', () => {
mockModelProviders = [
{ provider: 'langgenius/openai/openai' },
{ provider: 'langgenius/anthropic/claude' },
]
const workflowNodesMap = createWorkflowNodesMap({
id: 'target',
type: BlockEnum.LLM,
modelProvider: 'langgenius/openai/gpt-4.1',
})
const { result } = renderHook(() => useLlmModelPluginInstalled('target', workflowNodesMap))
expect(result.current).toBe(true)
})
it('should return false when the matching model plugin is not installed', () => {
mockModelProviders = [
{ provider: 'langgenius/anthropic/claude' },
]
const workflowNodesMap = createWorkflowNodesMap({
id: 'target',
type: BlockEnum.LLM,
modelProvider: 'langgenius/openai/gpt-4.1',
})
const { result } = renderHook(() => useLlmModelPluginInstalled('target', workflowNodesMap))
expect(result.current).toBe(false)
})
})

View File

@ -0,0 +1,57 @@
import type { ComponentType, InputHTMLAttributes } from 'react'
import { render, screen } from '@testing-library/react'
const mockNotify = vi.fn()
type AutosizeInputProps = InputHTMLAttributes<HTMLInputElement> & {
inputClassName?: string
}
const MockAutosizeInput: ComponentType<AutosizeInputProps> = ({ inputClassName, ...props }) => (
<input data-testid="autosize-input" className={inputClassName} {...props} />
)
describe('TagInput autosize interop', () => {
afterEach(() => {
vi.clearAllMocks()
vi.resetModules()
})
it('should support a namespace-style default export from react-18-input-autosize', async () => {
vi.doMock('@/app/components/base/toast/context', () => ({
useToastContext: () => ({
notify: mockNotify,
}),
}))
vi.doMock('react-18-input-autosize', () => ({
default: {
default: MockAutosizeInput,
},
}))
const { default: TagInput } = await import('../index')
render(<TagInput items={[]} onChange={vi.fn()} />)
expect(screen.getByTestId('autosize-input')).toBeInTheDocument()
expect(screen.getByRole('textbox')).toBeInTheDocument()
})
it('should support a direct default export from react-18-input-autosize', async () => {
vi.doMock('@/app/components/base/toast/context', () => ({
useToastContext: () => ({
notify: mockNotify,
}),
}))
vi.doMock('react-18-input-autosize', () => ({
default: MockAutosizeInput,
}))
const { default: TagInput } = await import('../index')
render(<TagInput items={[]} onChange={vi.fn()} />)
expect(screen.getByTestId('autosize-input')).toBeInTheDocument()
expect(screen.getByRole('textbox')).toBeInTheDocument()
})
})

View File

@ -0,0 +1,113 @@
import { NumberField as BaseNumberField } from '@base-ui/react/number-field'
import { render, screen } from '@testing-library/react'
import {
NumberField,
NumberFieldControls,
NumberFieldDecrement,
NumberFieldGroup,
NumberFieldIncrement,
NumberFieldInput,
NumberFieldUnit,
} from '../index'
describe('NumberField wrapper', () => {
describe('Exports', () => {
it('should map NumberField to the matching base primitive root', () => {
expect(NumberField).toBe(BaseNumberField.Root)
})
})
describe('Variants', () => {
it('should apply regular variant classes and forward className to group and input', () => {
render(
<NumberField defaultValue={12}>
<NumberFieldGroup size="regular" className="custom-group" data-testid="group">
<NumberFieldInput
aria-label="Regular amount"
placeholder="Regular placeholder"
size="regular"
className="custom-input"
/>
</NumberFieldGroup>
</NumberField>,
)
const group = screen.getByTestId('group')
const input = screen.getByRole('textbox', { name: 'Regular amount' })
expect(group).toHaveClass('radius-md')
expect(group).toHaveClass('custom-group')
expect(input).toHaveAttribute('placeholder', 'Regular placeholder')
expect(input).toHaveClass('px-3')
expect(input).toHaveClass('py-[7px]')
expect(input).toHaveClass('custom-input')
})
it('should apply large variant classes to grouped parts when large size is provided', () => {
render(
<NumberField defaultValue={24}>
<NumberFieldGroup size="large" data-testid="group">
<NumberFieldInput aria-label="Large amount" size="large" />
<NumberFieldUnit size="large">ms</NumberFieldUnit>
<NumberFieldControls>
<NumberFieldIncrement aria-label="Increment amount" size="large" />
<NumberFieldDecrement aria-label="Decrement amount" size="large" />
</NumberFieldControls>
</NumberFieldGroup>
</NumberField>,
)
const group = screen.getByTestId('group')
const input = screen.getByRole('textbox', { name: 'Large amount' })
const unit = screen.getByText('ms')
const increment = screen.getByRole('button', { name: 'Increment amount' })
const decrement = screen.getByRole('button', { name: 'Decrement amount' })
expect(group).toHaveClass('radius-lg')
expect(input).toHaveClass('px-4')
expect(input).toHaveClass('py-2')
expect(unit).toHaveClass('flex')
expect(unit).toHaveClass('items-center')
expect(unit).toHaveClass('pr-2.5')
expect(increment).toHaveClass('pt-1.5')
expect(decrement).toHaveClass('pb-1.5')
})
})
describe('Passthrough props', () => {
it('should forward passthrough props and custom classes to controls and buttons', () => {
render(
<NumberField defaultValue={8}>
<NumberFieldGroup size="regular">
<NumberFieldInput aria-label="Amount" size="regular" />
<NumberFieldControls className="custom-controls" data-testid="controls">
<NumberFieldIncrement
aria-label="Increment"
size="regular"
className="custom-increment"
data-track-id="increment-track"
/>
<NumberFieldDecrement
aria-label="Decrement"
size="regular"
className="custom-decrement"
data-track-id="decrement-track"
/>
</NumberFieldControls>
</NumberFieldGroup>
</NumberField>,
)
const controls = screen.getByTestId('controls')
const increment = screen.getByRole('button', { name: 'Increment' })
const decrement = screen.getByRole('button', { name: 'Decrement' })
expect(controls).toHaveClass('border-l')
expect(controls).toHaveClass('custom-controls')
expect(increment).toHaveClass('custom-increment')
expect(increment).toHaveAttribute('data-track-id', 'increment-track')
expect(decrement).toHaveClass('custom-decrement')
expect(decrement).toHaveAttribute('data-track-id', 'decrement-track')
})
})
})

View File

@ -0,0 +1,211 @@
'use client'
import type { VariantProps } from 'class-variance-authority'
import { NumberField as BaseNumberField } from '@base-ui/react/number-field'
import { cva } from 'class-variance-authority'
import * as React from 'react'
import { cn } from '@/utils/classnames'
export const NumberField = BaseNumberField.Root
export const numberFieldGroupVariants = cva(
[
'group/number-field flex w-full min-w-0 items-stretch overflow-hidden border border-transparent bg-components-input-bg-normal text-components-input-text-filled shadow-none outline-none transition-[background-color,border-color,box-shadow]',
'hover:border-components-input-border-hover hover:bg-components-input-bg-hover',
'data-[focused]:border-components-input-border-active data-[focused]:bg-components-input-bg-active data-[focused]:shadow-xs',
'data-[disabled]:cursor-not-allowed data-[disabled]:border-transparent data-[disabled]:bg-components-input-bg-disabled data-[disabled]:text-components-input-text-filled-disabled',
'data-[disabled]:hover:border-transparent data-[disabled]:hover:bg-components-input-bg-disabled',
'data-[readonly]:shadow-none motion-reduce:transition-none',
],
{
variants: {
size: {
regular: 'radius-md',
large: 'radius-lg',
},
},
defaultVariants: {
size: 'regular',
},
},
)
type NumberFieldGroupProps = React.ComponentPropsWithoutRef<typeof BaseNumberField.Group> & VariantProps<typeof numberFieldGroupVariants>
export function NumberFieldGroup({
className,
size = 'regular',
...props
}: NumberFieldGroupProps) {
return (
<BaseNumberField.Group
className={cn(numberFieldGroupVariants({ size }), className)}
{...props}
/>
)
}
export const numberFieldInputVariants = cva(
[
'w-0 min-w-0 flex-1 appearance-none border-0 bg-transparent text-components-input-text-filled caret-primary-600 outline-none',
'placeholder:text-components-input-text-placeholder',
'disabled:cursor-not-allowed disabled:text-components-input-text-filled-disabled disabled:placeholder:text-components-input-text-disabled',
'data-[readonly]:cursor-default',
],
{
variants: {
size: {
regular: 'px-3 py-[7px] system-sm-regular',
large: 'px-4 py-2 system-md-regular',
},
},
defaultVariants: {
size: 'regular',
},
},
)
type NumberFieldInputProps = Omit<React.ComponentPropsWithoutRef<typeof BaseNumberField.Input>, 'size'> & VariantProps<typeof numberFieldInputVariants>
export function NumberFieldInput({
className,
size = 'regular',
...props
}: NumberFieldInputProps) {
return (
<BaseNumberField.Input
className={cn(numberFieldInputVariants({ size }), className)}
{...props}
/>
)
}
export const numberFieldUnitVariants = cva(
'flex shrink-0 items-center self-stretch text-text-tertiary system-sm-regular',
{
variants: {
size: {
regular: 'pr-2',
large: 'pr-2.5',
},
},
defaultVariants: {
size: 'regular',
},
},
)
type NumberFieldUnitProps = React.HTMLAttributes<HTMLSpanElement> & VariantProps<typeof numberFieldUnitVariants>
export function NumberFieldUnit({
className,
size = 'regular',
...props
}: NumberFieldUnitProps) {
return (
<span
className={cn(numberFieldUnitVariants({ size }), className)}
{...props}
/>
)
}
export const numberFieldControlsVariants = cva(
'flex shrink-0 flex-col items-stretch border-l border-divider-subtle bg-transparent text-text-tertiary',
)
type NumberFieldControlsProps = React.HTMLAttributes<HTMLDivElement>
export function NumberFieldControls({
className,
...props
}: NumberFieldControlsProps) {
return (
<div
className={cn(numberFieldControlsVariants(), className)}
{...props}
/>
)
}
export const numberFieldControlButtonVariants = cva(
[
'flex items-center justify-center px-1.5 text-text-tertiary outline-none transition-colors',
'hover:bg-components-input-bg-hover focus-visible:bg-components-input-bg-hover',
'disabled:cursor-not-allowed disabled:hover:bg-transparent',
'group-data-[disabled]/number-field:cursor-not-allowed group-data-[disabled]/number-field:hover:bg-transparent',
'group-data-[readonly]/number-field:cursor-default group-data-[readonly]/number-field:hover:bg-transparent',
'motion-reduce:transition-none',
],
{
variants: {
size: {
regular: '',
large: '',
},
direction: {
increment: '',
decrement: '',
},
},
compoundVariants: [
{
size: 'regular',
direction: 'increment',
className: 'pt-1',
},
{
size: 'regular',
direction: 'decrement',
className: 'pb-1',
},
{
size: 'large',
direction: 'increment',
className: 'pt-1.5',
},
{
size: 'large',
direction: 'decrement',
className: 'pb-1.5',
},
],
defaultVariants: {
size: 'regular',
direction: 'increment',
},
},
)
type NumberFieldButtonVariantProps = Omit<
VariantProps<typeof numberFieldControlButtonVariants>,
'direction'
>
type NumberFieldButtonProps = React.ComponentPropsWithoutRef<typeof BaseNumberField.Increment> & NumberFieldButtonVariantProps
export function NumberFieldIncrement({
className,
size = 'regular',
...props
}: NumberFieldButtonProps) {
return (
<BaseNumberField.Increment
className={cn(numberFieldControlButtonVariants({ size, direction: 'increment' }), className)}
{...props}
/>
)
}
export function NumberFieldDecrement({
className,
size = 'regular',
...props
}: NumberFieldButtonProps) {
return (
<BaseNumberField.Decrement
className={cn(numberFieldControlButtonVariants({ size, direction: 'decrement' }), className)}
{...props}
/>
)
}

View File

@ -0,0 +1,93 @@
import type { ReactNode } from 'react'
import type { Mock } from 'vitest'
import type { UsagePlanInfo } from '../../type'
import { render } from '@testing-library/react'
import { useAppContext } from '@/context/app-context'
import { useGetPricingPageLanguage } from '@/context/i18n'
import { useProviderContext } from '@/context/provider-context'
import { Plan } from '../../type'
import Pricing from '../index'
type DialogProps = {
children: ReactNode
open?: boolean
onOpenChange?: (open: boolean) => void
}
let latestOnOpenChange: DialogProps['onOpenChange']
vi.mock('@/app/components/base/ui/dialog', () => ({
Dialog: ({ children, onOpenChange }: DialogProps) => {
latestOnOpenChange = onOpenChange
return <div data-testid="dialog">{children}</div>
},
DialogContent: ({ children, className }: { children: ReactNode, className?: string }) => (
<div className={className}>{children}</div>
),
}))
vi.mock('../header', () => ({
default: ({ onClose }: { onClose: () => void }) => (
<button data-testid="pricing-header-close" onClick={onClose}>close</button>
),
}))
vi.mock('../plan-switcher', () => ({
default: () => <div>plan-switcher</div>,
}))
vi.mock('../plans', () => ({
default: () => <div>plans</div>,
}))
vi.mock('../footer', () => ({
default: () => <div>footer</div>,
}))
vi.mock('@/context/app-context', () => ({
useAppContext: vi.fn(),
}))
vi.mock('@/context/provider-context', () => ({
useProviderContext: vi.fn(),
}))
vi.mock('@/context/i18n', () => ({
useGetPricingPageLanguage: vi.fn(),
}))
const buildUsage = (): UsagePlanInfo => ({
buildApps: 0,
teamMembers: 0,
annotatedResponse: 0,
documentsUploadQuota: 0,
apiRateLimit: 0,
triggerEvents: 0,
vectorSpace: 0,
})
describe('Pricing dialog lifecycle', () => {
beforeEach(() => {
vi.clearAllMocks()
latestOnOpenChange = undefined
;(useAppContext as Mock).mockReturnValue({ isCurrentWorkspaceManager: true })
;(useProviderContext as Mock).mockReturnValue({
plan: {
type: Plan.sandbox,
usage: buildUsage(),
total: buildUsage(),
},
})
;(useGetPricingPageLanguage as Mock).mockReturnValue('en')
})
it('should only call onCancel when the dialog requests closing', () => {
const onCancel = vi.fn()
render(<Pricing onCancel={onCancel} />)
latestOnOpenChange?.(true)
latestOnOpenChange?.(false)
expect(onCancel).toHaveBeenCalledTimes(1)
})
})

View File

@ -0,0 +1,24 @@
.instrumentSerif {
font-family: "Instrument Serif", serif;
font-style: italic;
}
@font-face {
font-family: "Instrument Serif";
font-style: italic;
font-weight: 400;
font-display: swap;
src: url("./InstrumentSerif-Italic-Latin.woff2") format("woff2");
unicode-range:
U+0000-00FF,
U+0100-024F,
U+0259,
U+0300-036F,
U+1E00-1EFF,
U+2010-205E,
U+20A0-20CF,
U+2113,
U+2212,
U+2C60-2C7F,
U+A720-A7FF;
}

View File

@ -1,8 +1,9 @@
import * as React from 'react'
import { useTranslation } from 'react-i18next'
import { DialogDescription, DialogTitle } from '@/app/components/base/ui/dialog'
import { cn } from '@/utils/classnames'
import Button from '../../base/button'
import DifyLogo from '../../base/logo/dify-logo'
import styles from './header.module.css'
type HeaderProps = {
onClose: () => void
@ -20,13 +21,18 @@ const Header = ({
<div className="py-[5px]">
<DifyLogo className="h-[27px] w-[60px]" />
</div>
<DialogTitle className="m-0 bg-billing-plan-title-bg bg-clip-text px-1.5 font-instrument text-[37px] italic leading-[1.2] text-transparent">
<span
className={cn(
'bg-billing-plan-title-bg bg-clip-text px-1.5 text-[37px] leading-[1.2] text-transparent',
styles.instrumentSerif,
)}
>
{t('plansCommon.title.plans', { ns: 'billing' })}
</DialogTitle>
</span>
</div>
<DialogDescription className="m-0 text-text-tertiary system-sm-regular">
<p className="text-text-tertiary system-sm-regular">
{t('plansCommon.title.description', { ns: 'billing' })}
</DialogDescription>
</p>
<Button
variant="secondary"
className="absolute bottom-[40.5px] right-[-18px] z-10 size-9 rounded-full p-2"

View File

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

View File

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

View File

@ -1,7 +1,6 @@
'use client'
import type { FC } from 'react'
import type { RetrievalConfig } from '@/types/app'
import Image from 'next/image'
import * as React from 'react'
import { useCallback, useMemo } from 'react'
@ -215,11 +214,11 @@ const RetrievalParamConfig: FC<Props> = ({
isChosen={value.reranking_mode === option.value}
onChosen={() => handleChangeRerankMode(option.value)}
icon={(
<Image
<img
src={
option.value === RerankingModeEnum.WeightedScore
? ProgressIndicator
: Reranking
? ProgressIndicator.src
: Reranking.src
}
alt=""
/>

View File

@ -20,14 +20,6 @@ vi.mock('next/navigation', () => ({
useRouter: () => mockRouter,
}))
// Override global next/image auto-mock: test asserts on data-testid="next-image"
vi.mock('next/image', () => ({
default: ({ src, alt, className }: { src: string, alt: string, className?: string }) => (
// eslint-disable-next-line next/no-img-element
<img src={src} alt={alt} className={className} data-testid="next-image" />
),
}))
// Mock API service
const mockFetchIndexingStatusBatch = vi.fn()
vi.mock('@/service/datasets', () => ({
@ -979,9 +971,9 @@ describe('RuleDetail', () => {
})
it('should render correct icon for indexing type', () => {
render(<RuleDetail indexingType="high_quality" />)
const { container } = render(<RuleDetail indexingType="high_quality" />)
const images = screen.getAllByTestId('next-image')
const images = container.querySelectorAll('img')
expect(images.length).toBeGreaterThan(0)
})
})

View File

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

View File

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

View File

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

View File

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

View File

@ -6,7 +6,6 @@ import {
RiAlertFill,
RiSearchEyeLine,
} from '@remixicon/react'
import Image from 'next/image'
import { useTranslation } from 'react-i18next'
import Button from '@/app/components/base/button'
import Checkbox from '@/app/components/base/checkbox'
@ -97,7 +96,7 @@ export const GeneralChunkingOptions: FC<GeneralChunkingOptionsProps> = ({
<OptionCard
className="mb-2 bg-background-section"
title={t('stepTwo.general', { ns: 'datasetCreation' })}
icon={<Image width={20} height={20} src={SettingCog} alt={t('stepTwo.general', { ns: 'datasetCreation' })} />}
icon={<img width={20} height={20} src={SettingCog.src} alt={t('stepTwo.general', { ns: 'datasetCreation' })} />}
activeHeaderClassName="bg-dataset-option-card-blue-gradient"
description={t('stepTwo.generalTip', { ns: 'datasetCreation' })}
isActive={isActive}

View File

@ -3,7 +3,6 @@
import type { FC } from 'react'
import type { DefaultModel, Model } from '@/app/components/header/account-setting/model-provider-page/declarations'
import type { RetrievalConfig } from '@/types/app'
import Image from 'next/image'
import Link from 'next/link'
import { useTranslation } from 'react-i18next'
import Badge from '@/app/components/base/badge'
@ -98,7 +97,7 @@ export const IndexingModeSection: FC<IndexingModeSectionProps> = ({
</div>
)}
description={t('stepTwo.qualifiedTip', { ns: 'datasetCreation' })}
icon={<Image src={indexMethodIcon.high_quality} alt="" />}
icon={<img src={indexMethodIcon.high_quality} alt="" />}
isActive={!hasSetIndexType && indexType === IndexingType.QUALIFIED}
disabled={hasSetIndexType}
onSwitched={() => onIndexTypeChange(IndexingType.QUALIFIED)}
@ -143,7 +142,7 @@ export const IndexingModeSection: FC<IndexingModeSectionProps> = ({
className="h-full"
title={t('stepTwo.economical', { ns: 'datasetCreation' })}
description={t('stepTwo.economicalTip', { ns: 'datasetCreation' })}
icon={<Image src={indexMethodIcon.economical} alt="" />}
icon={<img src={indexMethodIcon.economical} alt="" />}
isActive={!hasSetIndexType && indexType === IndexingType.ECONOMICAL}
disabled={hasSetIndexType || docForm !== ChunkingMode.text}
onSwitched={() => onIndexTypeChange(IndexingType.ECONOMICAL)}

View File

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

View File

@ -4,7 +4,6 @@ import type { FC } from 'react'
import type { ParentChildConfig } from '../hooks'
import type { ParentMode, PreProcessingRule, SummaryIndexSetting as SummaryIndexSettingType } from '@/models/datasets'
import { RiSearchEyeLine } from '@remixicon/react'
import Image from 'next/image'
import { useTranslation } from 'react-i18next'
import Button from '@/app/components/base/button'
import Checkbox from '@/app/components/base/checkbox'
@ -118,7 +117,7 @@ export const ParentChildOptions: FC<ParentChildOptionsProps> = ({
</div>
<RadioCard
className="mt-1"
icon={<Image src={Note} alt="" />}
icon={<img src={Note.src} alt="" />}
title={t('stepTwo.paragraph', { ns: 'datasetCreation' })}
description={t('stepTwo.paragraphTip', { ns: 'datasetCreation' })}
isChosen={parentChildConfig.chunkForContext === 'paragraph'}
@ -140,7 +139,7 @@ export const ParentChildOptions: FC<ParentChildOptionsProps> = ({
/>
<RadioCard
className="mt-2"
icon={<Image src={FileList} alt="" />}
icon={<img src={FileList.src} alt="" />}
title={t('stepTwo.fullDoc', { ns: 'datasetCreation' })}
description={t('stepTwo.fullDocTip', { ns: 'datasetCreation' })}
onChosen={() => onChunkForContextChange('full-doc')}

View File

@ -1,5 +1,5 @@
import type { CrawlOptions, CrawlResultItem } from '@/models/datasets'
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { beforeEach, describe, expect, it, vi } from 'vitest'
@ -55,6 +55,21 @@ const createMockCrawlResultItem = (overrides: Partial<CrawlResultItem> = {}): Cr
...overrides,
})
const createDeferred = <T,>() => {
let resolve!: (value: T | PromiseLike<T>) => void
let reject!: (reason?: unknown) => void
const promise = new Promise<T>((res, rej) => {
resolve = res
reject = rej
})
return {
promise,
resolve,
reject,
}
}
// FireCrawl Component Tests
describe('FireCrawl', () => {
@ -217,7 +232,7 @@ describe('FireCrawl', () => {
await user.click(runButton)
await waitFor(() => {
expect(mockCreateFirecrawlTask).toHaveBeenCalled()
expect(mockOnCheckedCrawlResultChange).toHaveBeenCalledWith([])
})
})
@ -241,7 +256,7 @@ describe('FireCrawl', () => {
await user.click(runButton)
await waitFor(() => {
expect(mockCreateFirecrawlTask).toHaveBeenCalled()
expect(mockOnCheckedCrawlResultChange).toHaveBeenCalledWith([])
})
})
})
@ -277,6 +292,10 @@ describe('FireCrawl', () => {
}),
})
})
await waitFor(() => {
expect(mockOnCheckedCrawlResultChange).toHaveBeenCalledWith([])
})
})
it('should call onJobIdChange with job_id from API response', async () => {
@ -301,6 +320,10 @@ describe('FireCrawl', () => {
await waitFor(() => {
expect(mockOnJobIdChange).toHaveBeenCalledWith('my-job-123')
})
await waitFor(() => {
expect(mockOnCheckedCrawlResultChange).toHaveBeenCalledWith([])
})
})
it('should remove empty max_depth from crawlOptions before sending to API', async () => {
@ -334,11 +357,23 @@ describe('FireCrawl', () => {
}),
})
})
await waitFor(() => {
expect(mockOnCheckedCrawlResultChange).toHaveBeenCalledWith([])
})
})
it('should show loading state while running', async () => {
const user = userEvent.setup()
mockCreateFirecrawlTask.mockImplementation(() => new Promise(() => {})) // Never resolves
const createTaskDeferred = createDeferred<{ job_id: string }>()
mockCreateFirecrawlTask.mockImplementation(() => createTaskDeferred.promise)
mockCheckFirecrawlTaskStatus.mockResolvedValueOnce({
status: 'completed',
data: [],
total: 0,
current: 0,
time_consuming: 1,
})
render(<FireCrawl {...defaultProps} />)
@ -352,6 +387,14 @@ describe('FireCrawl', () => {
await waitFor(() => {
expect(runButton).not.toHaveTextContent(/run/i)
})
await act(async () => {
createTaskDeferred.resolve({ job_id: 'test-job-id' })
})
await waitFor(() => {
expect(mockOnCheckedCrawlResultChange).toHaveBeenCalledWith([])
})
})
})
@ -656,7 +699,7 @@ describe('FireCrawl', () => {
await waitFor(() => {
// Total should be capped to limit (5)
expect(mockCheckFirecrawlTaskStatus).toHaveBeenCalled()
expect(mockOnCheckedCrawlResultChange).toHaveBeenCalledWith([])
})
})
})

View File

@ -2,7 +2,7 @@
import type { FC } from 'react'
import type { CrawlOptions, CrawlResultItem } from '@/models/datasets'
import * as React from 'react'
import { useCallback, useEffect, useState } from 'react'
import { useCallback, useEffect, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import Toast from '@/app/components/base/toast'
import { ACCOUNT_SETTING_TAB } from '@/app/components/header/account-setting/constants'
@ -35,6 +35,22 @@ enum Step {
finished = 'finished',
}
type CrawlState = {
current: number
total: number
data: CrawlResultItem[]
time_consuming: number | string
}
type CrawlFinishedResult = {
isCancelled?: boolean
isError: boolean
errorMessage?: string
data: Partial<CrawlState> & {
data: CrawlResultItem[]
}
}
const FireCrawl: FC<Props> = ({
onPreview,
checkedCrawlResult,
@ -46,10 +62,16 @@ const FireCrawl: FC<Props> = ({
const { t } = useTranslation()
const [step, setStep] = useState<Step>(Step.init)
const [controlFoldOptions, setControlFoldOptions] = useState<number>(0)
const isMountedRef = useRef(true)
useEffect(() => {
if (step !== Step.init)
setControlFoldOptions(Date.now())
}, [step])
useEffect(() => {
return () => {
isMountedRef.current = false
}
}, [])
const setShowAccountSettingModal = useModalContextSelector(s => s.setShowAccountSettingModal)
const handleSetting = useCallback(() => {
setShowAccountSettingModal({
@ -85,16 +107,19 @@ const FireCrawl: FC<Props> = ({
const isInit = step === Step.init
const isCrawlFinished = step === Step.finished
const isRunning = step === Step.running
const [crawlResult, setCrawlResult] = useState<{
current: number
total: number
data: CrawlResultItem[]
time_consuming: number | string
} | undefined>(undefined)
const [crawlResult, setCrawlResult] = useState<CrawlState | undefined>(undefined)
const [crawlErrorMessage, setCrawlErrorMessage] = useState('')
const showError = isCrawlFinished && crawlErrorMessage
const waitForCrawlFinished = useCallback(async (jobId: string) => {
const waitForCrawlFinished = useCallback(async (jobId: string): Promise<CrawlFinishedResult> => {
const cancelledResult: CrawlFinishedResult = {
isCancelled: true,
isError: false,
data: {
data: [],
},
}
try {
const res = await checkFirecrawlTaskStatus(jobId) as any
if (res.status === 'completed') {
@ -104,7 +129,7 @@ const FireCrawl: FC<Props> = ({
...res,
total: Math.min(res.total, Number.parseFloat(crawlOptions.limit as string)),
},
}
} satisfies CrawlFinishedResult
}
if (res.status === 'error' || !res.status) {
// can't get the error message from the firecrawl api
@ -114,12 +139,14 @@ const FireCrawl: FC<Props> = ({
data: {
data: [],
},
}
} satisfies CrawlFinishedResult
}
res.data = res.data.map((item: any) => ({
...item,
content: item.markdown,
}))
if (!isMountedRef.current)
return cancelledResult
// update the progress
setCrawlResult({
...res,
@ -127,17 +154,21 @@ const FireCrawl: FC<Props> = ({
})
onCheckedCrawlResultChange(res.data || []) // default select the crawl result
await sleep(2500)
if (!isMountedRef.current)
return cancelledResult
return await waitForCrawlFinished(jobId)
}
catch (e: any) {
const errorBody = await e.json()
if (!isMountedRef.current)
return cancelledResult
const errorBody = typeof e?.json === 'function' ? await e.json() : undefined
return {
isError: true,
errorMessage: errorBody.message,
errorMessage: errorBody?.message,
data: {
data: [],
},
}
} satisfies CrawlFinishedResult
}
}, [crawlOptions.limit, onCheckedCrawlResultChange])
@ -162,24 +193,31 @@ const FireCrawl: FC<Props> = ({
url,
options: passToServerCrawlOptions,
}) as any
if (!isMountedRef.current)
return
const jobId = res.job_id
onJobIdChange(jobId)
const { isError, data, errorMessage } = await waitForCrawlFinished(jobId)
const { isCancelled, isError, data, errorMessage } = await waitForCrawlFinished(jobId)
if (isCancelled || !isMountedRef.current)
return
if (isError) {
setCrawlErrorMessage(errorMessage || t(`${I18N_PREFIX}.unknownError`, { ns: 'datasetCreation' }))
}
else {
setCrawlResult(data)
setCrawlResult(data as CrawlState)
onCheckedCrawlResultChange(data.data || []) // default select the crawl result
setCrawlErrorMessage('')
}
}
catch (e) {
if (!isMountedRef.current)
return
setCrawlErrorMessage(t(`${I18N_PREFIX}.unknownError`, { ns: 'datasetCreation' })!)
console.log(e)
}
finally {
setStep(Step.finished)
if (isMountedRef.current)
setStep(Step.finished)
}
}, [checkValid, crawlOptions, onJobIdChange, t, waitForCrawlFinished, onCheckedCrawlResultChange])

View File

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

View File

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

View File

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

View File

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

View File

@ -14,7 +14,6 @@ import {
RiEqualizer2Line,
RiPlayCircleLine,
} from '@remixicon/react'
import Image from 'next/image'
import * as React from 'react'
import { useCallback, useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
@ -178,7 +177,7 @@ const QueryInput = ({
}, [text, externalRetrievalSettings, externalKnowledgeBaseHitTestingMutation, onUpdateList, setExternalHitResult])
const retrievalMethod = isEconomy ? RETRIEVE_METHOD.keywordSearch : retrievalConfig.search_method
const icon = <Image className="size-3.5 text-util-colors-purple-purple-600" src={getIcon(retrievalMethod)} alt="" />
const icon = <img className="size-3.5 text-util-colors-purple-purple-600" src={getIcon(retrievalMethod)} alt="" />
const TextAreaComp = useMemo(() => {
return (
<Textarea

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -0,0 +1,57 @@
import type { ReactNode } from 'react'
import { render } from '@testing-library/react'
import { ToastContext } from '@/app/components/base/toast/context'
import { useAppContext } from '@/context/app-context'
import EditWorkspaceModal from './index'
type DialogProps = {
children: ReactNode
open?: boolean
onOpenChange?: (open: boolean) => void
}
let latestOnOpenChange: DialogProps['onOpenChange']
vi.mock('@/app/components/base/ui/dialog', () => ({
Dialog: ({ children, onOpenChange }: DialogProps) => {
latestOnOpenChange = onOpenChange
return <div data-testid="dialog">{children}</div>
},
DialogCloseButton: ({ ...props }: Record<string, unknown>) => <button {...props} />,
DialogContent: ({ children, className }: { children: ReactNode, className?: string }) => (
<div className={className}>{children}</div>
),
DialogTitle: ({ children, className }: { children: ReactNode, className?: string }) => (
<div className={className}>{children}</div>
),
}))
vi.mock('@/context/app-context', () => ({
useAppContext: vi.fn(),
}))
describe('EditWorkspaceModal dialog lifecycle', () => {
beforeEach(() => {
vi.clearAllMocks()
latestOnOpenChange = undefined
vi.mocked(useAppContext).mockReturnValue({
currentWorkspace: { name: 'Test Workspace' },
isCurrentWorkspaceOwner: true,
} as never)
})
it('should only call onCancel when the dialog requests closing', () => {
const onCancel = vi.fn()
render(
<ToastContext.Provider value={{ notify: vi.fn(), close: vi.fn() }}>
<EditWorkspaceModal onCancel={onCancel} />
</ToastContext.Provider>,
)
latestOnOpenChange?.(true)
latestOnOpenChange?.(false)
expect(onCancel).toHaveBeenCalledTimes(1)
})
})

View File

@ -120,6 +120,21 @@ describe('EditWorkspaceModal', () => {
expect(screen.getByTestId('edit-workspace-error')).toBeInTheDocument()
})
it('should not submit when the form is submitted while save is disabled', async () => {
renderModal()
const saveButton = screen.getByTestId('edit-workspace-save')
const form = saveButton.closest('form')
expect(saveButton).toBeDisabled()
expect(form).not.toBeNull()
fireEvent.submit(form!)
expect(updateWorkspaceInfo).not.toHaveBeenCalled()
expect(mockNotify).not.toHaveBeenCalled()
})
it('should disable confirm button for non-owners', async () => {
vi.mocked(useAppContext).mockReturnValue({
currentWorkspace: { name: 'Test Workspace' } as ICurrentWorkspace,

View File

@ -0,0 +1,42 @@
import type { ReactNode } from 'react'
import { render } from '@testing-library/react'
import MenuDialog from './menu-dialog'
type DialogProps = {
children: ReactNode
open?: boolean
onOpenChange?: (open: boolean) => void
}
let latestOnOpenChange: DialogProps['onOpenChange']
vi.mock('@/app/components/base/ui/dialog', () => ({
Dialog: ({ children, onOpenChange }: DialogProps) => {
latestOnOpenChange = onOpenChange
return <div data-testid="dialog">{children}</div>
},
DialogContent: ({ children, className }: { children: ReactNode, className?: string }) => (
<div className={className}>{children}</div>
),
}))
describe('MenuDialog dialog lifecycle', () => {
beforeEach(() => {
vi.clearAllMocks()
latestOnOpenChange = undefined
})
it('should only call onClose when the dialog requests closing', () => {
const onClose = vi.fn()
render(
<MenuDialog show={true} onClose={onClose}>
<div>Content</div>
</MenuDialog>,
)
latestOnOpenChange?.(true)
latestOnOpenChange?.(false)
expect(onClose).toHaveBeenCalledTimes(1)
})
})

View File

@ -100,6 +100,42 @@ describe('deriveModelStatus', () => {
).toBe('api-key-unavailable')
})
it('should return credits-exhausted when model status is quota exceeded', () => {
expect(
deriveModelStatus(
'text-embedding-3-large',
'openai',
createModelProvider(),
createModelItem({ status: ModelStatusEnum.quotaExceeded }),
createCredentialState({ priority: 'apiKey' }),
),
).toBe('credits-exhausted')
})
it('should return api-key-unavailable when model status is credential removed', () => {
expect(
deriveModelStatus(
'text-embedding-3-large',
'openai',
createModelProvider(),
createModelItem({ status: ModelStatusEnum.credentialRemoved }),
createCredentialState({ priority: 'apiKey' }),
),
).toBe('api-key-unavailable')
})
it('should return incompatible when model status is no-permission', () => {
expect(
deriveModelStatus(
'text-embedding-3-large',
'openai',
createModelProvider(),
createModelItem({ status: ModelStatusEnum.noPermission }),
createCredentialState({ priority: 'apiKey' }),
),
).toBe('incompatible')
})
it('should return active when model and credential state are available', () => {
expect(
deriveModelStatus('text-embedding-3-large', 'openai', createModelProvider(), createModelItem(), createCredentialState()),

View File

@ -0,0 +1,89 @@
import { render, screen } from '@testing-library/react'
import {
CurrentSystemQuotaTypeEnum,
CustomConfigurationStatusEnum,
QuotaUnitEnum,
} from './declarations'
import ModelProviderPage from './index'
const mockQuotaConfig = {
quota_type: CurrentSystemQuotaTypeEnum.free,
quota_unit: QuotaUnitEnum.times,
quota_limit: 100,
quota_used: 1,
last_used: 0,
is_valid: true,
}
vi.mock('@/config', () => ({
IS_CLOUD_EDITION: false,
}))
vi.mock('@/context/global-public-context', () => ({
useSystemFeaturesQuery: () => ({
data: {
enable_marketplace: false,
},
}),
}))
vi.mock('@/context/provider-context', () => ({
useProviderContext: () => ({
modelProviders: [{
provider: 'openai',
label: { en_US: 'OpenAI' },
custom_configuration: { status: CustomConfigurationStatusEnum.active },
system_configuration: {
enabled: false,
current_quota_type: CurrentSystemQuotaTypeEnum.free,
quota_configurations: [mockQuotaConfig],
},
}],
}),
}))
vi.mock('./hooks', () => ({
useDefaultModel: () => ({ data: null, isLoading: false }),
}))
vi.mock('./provider-added-card', () => ({
default: () => <div data-testid="provider-card" />,
}))
vi.mock('./provider-added-card/quota-panel', () => ({
default: () => <div data-testid="quota-panel" />,
}))
vi.mock('./system-model-selector', () => ({
default: () => <div data-testid="system-model-selector" />,
}))
vi.mock('./install-from-marketplace', () => ({
default: () => <div data-testid="install-from-marketplace" />,
}))
vi.mock('@tanstack/react-query', async (importOriginal) => {
const actual = await importOriginal<typeof import('@tanstack/react-query')>()
return {
...actual,
useQuery: () => ({ data: undefined }),
}
})
vi.mock('@/service/client', () => ({
consoleQuery: {
plugins: {
checkInstalled: { queryOptions: () => ({}) },
latestVersions: { queryOptions: () => ({}) },
},
},
}))
describe('ModelProviderPage non-cloud branch', () => {
it('should skip the quota panel when cloud edition is disabled', () => {
render(<ModelProviderPage searchText="" />)
expect(screen.getByTestId('system-model-selector')).toBeInTheDocument()
expect(screen.queryByTestId('quota-panel')).not.toBeInTheDocument()
})
})

View File

@ -61,8 +61,15 @@ describe('InstallFromMarketplace', () => {
it('should collapse when clicked', () => {
render(<InstallFromMarketplace providers={mockProviders} searchText="" />)
fireEvent.click(screen.getByText('common.modelProvider.installProvider'))
const toggle = screen.getByRole('button', { name: /common\.modelProvider\.installProvider/ })
fireEvent.click(toggle)
expect(screen.queryByTestId('plugin-list')).not.toBeInTheDocument()
expect(toggle).toHaveAttribute('aria-expanded', 'false')
fireEvent.click(toggle)
expect(toggle).toHaveAttribute('aria-expanded', 'true')
expect(screen.getByTestId('plugin-list')).toBeInTheDocument()
})
it('should show loading state', () => {

View File

@ -0,0 +1,271 @@
import type { ReactNode } from 'react'
import type { Credential, ModelProvider } from '../declarations'
import { act, render, screen } from '@testing-library/react'
import { ConfigurationMethodEnum, ModelModalModeEnum } from '../declarations'
import ModelModal from './index'
type DialogProps = {
children: ReactNode
onOpenChange?: (open: boolean) => void
}
type AlertDialogProps = {
children: ReactNode
onOpenChange?: (open: boolean) => void
}
let mockLanguage = 'en_US'
let latestDialogOnOpenChange: DialogProps['onOpenChange']
let latestAlertDialogOnOpenChange: AlertDialogProps['onOpenChange']
let mockAvailableCredentials: Credential[] | undefined = []
let mockDeleteCredentialId: string | null = null
const mockCloseConfirmDelete = vi.fn()
const mockHandleConfirmDelete = vi.fn()
vi.mock('@/app/components/base/form/form-scenarios/auth', () => ({
default: () => <div data-testid="auth-form" />,
}))
vi.mock('../model-auth', () => ({
CredentialSelector: ({ credentials }: { credentials: Credential[] }) => <div>{`credentials:${credentials.length}`}</div>,
}))
vi.mock('@/app/components/base/ui/dialog', () => ({
Dialog: ({ children, onOpenChange }: DialogProps) => {
latestDialogOnOpenChange = onOpenChange
return <div>{children}</div>
},
DialogContent: ({ children }: { children: ReactNode }) => <div>{children}</div>,
DialogCloseButton: () => <button type="button">close</button>,
}))
vi.mock('@/app/components/base/ui/alert-dialog', () => ({
AlertDialog: ({ children, onOpenChange }: AlertDialogProps) => {
latestAlertDialogOnOpenChange = onOpenChange
return <div>{children}</div>
},
AlertDialogActions: ({ children }: { children: ReactNode }) => <div>{children}</div>,
AlertDialogCancelButton: ({ children }: { children: ReactNode }) => <button type="button">{children}</button>,
AlertDialogConfirmButton: ({ children, onClick }: { children: ReactNode, onClick?: () => void }) => <button type="button" onClick={onClick}>{children}</button>,
AlertDialogContent: ({ children }: { children: ReactNode }) => <div>{children}</div>,
AlertDialogTitle: ({ children }: { children: ReactNode }) => <div>{children}</div>,
}))
vi.mock('../model-auth/hooks', () => ({
useCredentialData: () => ({
isLoading: false,
credentialData: {
credentials: {},
available_credentials: mockAvailableCredentials,
},
}),
useAuth: () => ({
handleSaveCredential: vi.fn(),
handleConfirmDelete: mockHandleConfirmDelete,
deleteCredentialId: mockDeleteCredentialId,
closeConfirmDelete: mockCloseConfirmDelete,
openConfirmDelete: vi.fn(),
doingAction: false,
handleActiveCredential: vi.fn(),
}),
useModelFormSchemas: () => ({
formSchemas: [],
formValues: {},
modelNameAndTypeFormSchemas: [],
modelNameAndTypeFormValues: {},
}),
}))
vi.mock('@/context/app-context', () => ({
useAppContext: () => ({
isCurrentWorkspaceManager: true,
}),
}))
vi.mock('@/hooks/use-i18n', () => ({
useRenderI18nObject: () => (value: Record<string, string>) => value[mockLanguage] || value.en_US,
}))
vi.mock('../hooks', () => ({
useLanguage: () => mockLanguage,
}))
const createProvider = (overrides: Partial<ModelProvider> = {}): ModelProvider => ({
provider: 'openai',
label: { en_US: 'OpenAI', zh_Hans: 'OpenAI' },
help: {
title: { en_US: 'Help', zh_Hans: '帮助' },
url: { en_US: 'https://example.com', zh_Hans: 'https://example.cn' },
},
icon_small: { en_US: '', zh_Hans: '' },
supported_model_types: [],
configurate_methods: [],
provider_credential_schema: { credential_form_schemas: [] },
model_credential_schema: {
model: { label: { en_US: 'Model', zh_Hans: '模型' }, placeholder: { en_US: 'Select', zh_Hans: '选择' } },
credential_form_schemas: [],
},
custom_configuration: {
status: 'active',
available_credentials: [],
custom_models: [],
can_added_models: [],
},
system_configuration: {
enabled: true,
current_quota_type: 'trial',
quota_configurations: [],
},
allow_custom_token: true,
...overrides,
} as unknown as ModelProvider)
describe('ModelModal dialog branches', () => {
beforeEach(() => {
vi.clearAllMocks()
mockLanguage = 'en_US'
latestDialogOnOpenChange = undefined
latestAlertDialogOnOpenChange = undefined
mockAvailableCredentials = []
mockDeleteCredentialId = null
})
it('should only cancel when the dialog reports it has closed', () => {
const onCancel = vi.fn()
render(
<ModelModal
provider={createProvider()}
configurateMethod={ConfigurationMethodEnum.predefinedModel}
onCancel={onCancel}
onSave={vi.fn()}
onRemove={vi.fn()}
/>,
)
act(() => {
latestDialogOnOpenChange?.(true)
latestDialogOnOpenChange?.(false)
})
expect(onCancel).toHaveBeenCalledTimes(1)
})
it('should only close the confirm dialog when the alert dialog closes', () => {
mockDeleteCredentialId = 'cred-1'
render(
<ModelModal
provider={createProvider()}
configurateMethod={ConfigurationMethodEnum.predefinedModel}
onCancel={vi.fn()}
onSave={vi.fn()}
onRemove={vi.fn()}
/>,
)
act(() => {
latestAlertDialogOnOpenChange?.(true)
latestAlertDialogOnOpenChange?.(false)
})
expect(mockCloseConfirmDelete).toHaveBeenCalledTimes(1)
})
it('should pass an empty credential list to the selector when no credentials are available', () => {
mockAvailableCredentials = undefined
render(
<ModelModal
provider={createProvider()}
configurateMethod={ConfigurationMethodEnum.predefinedModel}
mode={ModelModalModeEnum.addCustomModelToModelList}
onCancel={vi.fn()}
onSave={vi.fn()}
onRemove={vi.fn()}
/>,
)
expect(screen.getByText('credentials:0')).toBeInTheDocument()
})
it('should hide the help link when provider help is missing', () => {
render(
<ModelModal
provider={createProvider({ help: undefined })}
configurateMethod={ConfigurationMethodEnum.predefinedModel}
onCancel={vi.fn()}
onSave={vi.fn()}
onRemove={vi.fn()}
/>,
)
expect(screen.queryByRole('link', { name: 'Help' })).not.toBeInTheDocument()
})
it('should prevent navigation when help text exists without a help url', () => {
mockLanguage = 'zh_Hans'
render(
<ModelModal
provider={createProvider({
help: {
title: { en_US: 'English Help' },
url: '' as unknown as ModelProvider['help']['url'],
} as ModelProvider['help'],
})}
configurateMethod={ConfigurationMethodEnum.predefinedModel}
onCancel={vi.fn()}
onSave={vi.fn()}
onRemove={vi.fn()}
/>,
)
const link = screen.getByText('English Help').closest('a')
const clickEvent = new MouseEvent('click', { bubbles: true, cancelable: true })
expect(link).not.toBeNull()
link!.dispatchEvent(clickEvent)
expect(clickEvent.defaultPrevented).toBe(true)
})
it('should fall back to localized and english help urls when titles are missing', () => {
mockLanguage = 'zh_Hans'
const { rerender } = render(
<ModelModal
provider={createProvider({
help: {
url: { zh_Hans: 'https://example.cn', en_US: 'https://example.com' },
} as ModelProvider['help'],
})}
configurateMethod={ConfigurationMethodEnum.predefinedModel}
onCancel={vi.fn()}
onSave={vi.fn()}
onRemove={vi.fn()}
/>,
)
expect(screen.getByRole('link', { name: 'https://example.cn' })).toHaveAttribute('href', 'https://example.cn')
rerender(
<ModelModal
provider={createProvider({
help: {
url: { en_US: 'https://example.com' },
} as ModelProvider['help'],
})}
configurateMethod={ConfigurationMethodEnum.predefinedModel}
onCancel={vi.fn()}
onSave={vi.fn()}
onRemove={vi.fn()}
/>,
)
const link = screen.getByRole('link', { name: 'https://example.com' })
const clickEvent = new MouseEvent('click', { bubbles: true, cancelable: true })
link.dispatchEvent(clickEvent)
expect(link).toHaveAttribute('href', 'https://example.com')
expect(clickEvent.defaultPrevented).toBe(false)
})
})

View File

@ -1,4 +1,4 @@
import { fireEvent, render, screen } from '@testing-library/react'
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import ModelParameterModal from './index'
let isAPIKeySet = true
@ -77,9 +77,10 @@ vi.mock('./parameter-item', () => ({
}))
vi.mock('../model-selector', () => ({
default: ({ onSelect }: { onSelect: (value: { provider: string, model: string }) => void }) => (
default: ({ onHide, onSelect }: { onHide: () => void, onSelect: (value: { provider: string, model: string }) => void }) => (
<div data-testid="model-selector">
<button onClick={() => onSelect({ provider: 'openai', model: 'gpt-4.1' })}>Select GPT-4.1</button>
<button onClick={onHide}>hide</button>
</div>
),
}))
@ -231,4 +232,67 @@ describe('ModelParameterModal', () => {
expect(screen.queryByTestId('param-temperature')).not.toBeInTheDocument()
expect(screen.getByTestId('model-selector')).toBeInTheDocument()
})
it('should support custom triggers, workflow mode, and missing default model values', async () => {
render(
<ModelParameterModal
{...defaultProps}
provider=""
modelId=""
isInWorkflow
renderTrigger={({ open }) => <span>{open ? 'Custom Open' : 'Custom Closed'}</span>}
/>,
)
fireEvent.click(screen.getByText('Custom Closed'))
expect(screen.getByText('Custom Open')).toBeInTheDocument()
expect(screen.getByTestId('model-selector')).toBeInTheDocument()
fireEvent.click(screen.getByText('hide'))
await waitFor(() => {
expect(screen.queryByTestId('model-selector')).not.toBeInTheDocument()
})
})
it('should append the stop parameter in advanced mode and show the single-model debug label', () => {
render(
<ModelParameterModal
{...defaultProps}
isAdvancedMode
debugWithMultipleModel
/>,
)
fireEvent.click(screen.getByText('Open Settings'))
expect(screen.getByTestId('param-stop')).toBeInTheDocument()
expect(screen.getByText(/debugAsSingleModel/i)).toBeInTheDocument()
})
it('should render the empty loading fallback when rules resolve to an empty list', () => {
parameterRules = []
isRulesLoading = true
render(<ModelParameterModal {...defaultProps} />)
fireEvent.click(screen.getByText('Open Settings'))
expect(screen.getByRole('status')).toBeInTheDocument()
expect(screen.queryByTestId('param-temperature')).not.toBeInTheDocument()
})
it('should support custom trigger placement outside workflow mode', () => {
render(
<ModelParameterModal
{...defaultProps}
renderTrigger={({ open }) => <span>{open ? 'Popup Open' : 'Popup Closed'}</span>}
/>,
)
fireEvent.click(screen.getByText('Popup Closed'))
expect(screen.getByText('Popup Open')).toBeInTheDocument()
expect(screen.getByTestId('model-selector')).toBeInTheDocument()
})
})

View File

@ -0,0 +1,48 @@
import type { ReactNode } from 'react'
import { fireEvent, render, screen } from '@testing-library/react'
import ParameterItem from './parameter-item'
vi.mock('../hooks', () => ({
useLanguage: () => 'en_US',
}))
vi.mock('@/app/components/base/ui/select', () => ({
Select: ({ children, onValueChange }: { children: ReactNode, onValueChange: (value: string | undefined) => void }) => (
<div>
<button type="button" onClick={() => onValueChange('updated')}>select-updated</button>
<button type="button" onClick={() => onValueChange(undefined)}>select-empty</button>
{children}
</div>
),
SelectContent: ({ children }: { children: ReactNode }) => <div>{children}</div>,
SelectItem: ({ children }: { children: ReactNode }) => <div>{children}</div>,
SelectTrigger: ({ children }: { children: ReactNode }) => <div>{children}</div>,
SelectValue: () => <div>SelectValue</div>,
}))
describe('ParameterItem select mode', () => {
it('should propagate both explicit and empty select values', () => {
const onChange = vi.fn()
render(
<ParameterItem
parameterRule={{
name: 'format',
label: { en_US: 'Format', zh_Hans: 'Format' },
type: 'string',
options: ['json', 'text'],
required: false,
help: { en_US: 'Help', zh_Hans: 'Help' },
}}
value="json"
onChange={onChange}
/>,
)
fireEvent.click(screen.getByRole('button', { name: 'select-updated' }))
fireEvent.click(screen.getByRole('button', { name: 'select-empty' }))
expect(onChange).toHaveBeenNthCalledWith(1, 'updated')
expect(onChange).toHaveBeenNthCalledWith(2, undefined)
})
})

View File

@ -0,0 +1,77 @@
import type { ReactNode } from 'react'
import { act, fireEvent, render, screen } from '@testing-library/react'
import ModelSelector from './index'
type PopoverProps = {
children: ReactNode
onOpenChange?: (open: boolean) => void
}
let latestOnOpenChange: PopoverProps['onOpenChange']
vi.mock('../hooks', () => ({
useCurrentProviderAndModel: () => ({
currentProvider: undefined,
currentModel: undefined,
}),
}))
vi.mock('@/app/components/base/ui/popover', () => ({
Popover: ({ children, onOpenChange }: PopoverProps) => {
latestOnOpenChange = onOpenChange
return <div>{children}</div>
},
PopoverTrigger: ({ render }: { render: ReactNode }) => <>{render}</>,
PopoverContent: ({ children }: { children: ReactNode }) => <div>{children}</div>,
}))
vi.mock('./model-selector-trigger', () => ({
default: ({ open, readonly }: { open: boolean, readonly?: boolean }) => (
<span>
{open ? 'open' : 'closed'}
-
{readonly ? 'readonly' : 'editable'}
</span>
),
}))
vi.mock('./popup', () => ({
default: ({ onHide }: { onHide: () => void }) => (
<div data-testid="popup">
<button type="button" onClick={onHide}>hide-popup</button>
</div>
),
}))
describe('ModelSelector popover branches', () => {
beforeEach(() => {
vi.clearAllMocks()
latestOnOpenChange = undefined
})
it('should open and close through popover callbacks when editable', () => {
const onHide = vi.fn()
render(<ModelSelector modelList={[]} onHide={onHide} />)
act(() => {
latestOnOpenChange?.(true)
})
expect(screen.getByText('open-editable')).toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: 'hide-popup' }))
expect(screen.getByText('closed-editable')).toBeInTheDocument()
expect(onHide).toHaveBeenCalledTimes(1)
})
it('should ignore popover open changes when readonly', () => {
render(<ModelSelector modelList={[]} readonly />)
act(() => {
latestOnOpenChange?.(true)
})
expect(screen.getByText('closed-readonly')).toBeInTheDocument()
})
})

View File

@ -12,12 +12,13 @@ import PopupItem from './popup-item'
const mockUpdateModelList = vi.hoisted(() => vi.fn())
const mockUpdateModelProviders = vi.hoisted(() => vi.fn())
const mockUseLanguage = vi.hoisted(() => vi.fn(() => 'en_US'))
vi.mock('../hooks', async () => {
const actual = await vi.importActual<typeof import('../hooks')>('../hooks')
return {
...actual,
useLanguage: () => 'en_US',
useLanguage: mockUseLanguage,
useUpdateModelList: () => mockUpdateModelList,
useUpdateModelProviders: () => mockUpdateModelProviders,
}
@ -43,6 +44,12 @@ vi.mock('@/app/components/base/tooltip', () => ({
default: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
}))
vi.mock('@/app/components/base/ui/popover', () => ({
Popover: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
PopoverTrigger: ({ render }: { render: React.ReactNode }) => <>{render}</>,
PopoverContent: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
}))
const mockCredentialPanelState = vi.hoisted(() => vi.fn())
vi.mock('../provider-added-card/use-credential-panel-state', () => ({
useCredentialPanelState: mockCredentialPanelState,
@ -56,7 +63,7 @@ vi.mock('../provider-added-card/use-change-provider-priority', () => ({
}))
vi.mock('../provider-added-card/model-auth-dropdown/dropdown-content', () => ({
default: () => null,
default: ({ onClose }: { onClose: () => void }) => <button type="button" onClick={onClose}>close dropdown</button>,
}))
const mockSetShowModelModal = vi.hoisted(() => vi.fn())
@ -110,6 +117,7 @@ const makeProvider = (overrides: Record<string, unknown> = {}) => ({
describe('PopupItem', () => {
beforeEach(() => {
vi.clearAllMocks()
mockUseLanguage.mockReturnValue('en_US')
mockUseProviderContext.mockReturnValue({
modelProviders: [makeProvider()],
})
@ -215,6 +223,24 @@ describe('PopupItem', () => {
expect(screen.getByText('GPT-4')).toBeInTheDocument()
})
it('should fall back to english labels when the current language is unavailable', () => {
mockUseLanguage.mockReturnValue('zh_Hans')
render(
<PopupItem
model={makeModel({
label: { en_US: 'OpenAI only' } as Model['label'],
models: [makeModelItem({ label: { en_US: 'GPT-4 only' } as ModelItem['label'] })],
})}
onSelect={vi.fn()}
onHide={vi.fn()}
/>,
)
expect(screen.getByText('OpenAI only')).toBeInTheDocument()
expect(screen.getByText('GPT-4 only')).toBeInTheDocument()
})
it('should toggle collapsed state when clicking provider header', () => {
render(<PopupItem model={makeModel()} onSelect={vi.fn()} onHide={vi.fn()} />)
@ -235,6 +261,24 @@ describe('PopupItem', () => {
expect(screen.getByText('my-api-key')).toBeInTheDocument()
})
it('should render the inactive credential badge when the api key is not active', () => {
mockCredentialPanelState.mockReturnValue({
variant: 'api-inactive',
priority: 'apiKey',
supportsCredits: false,
showPrioritySwitcher: false,
hasCredentials: true,
isCreditsExhausted: false,
credentialName: 'stale-key',
credits: 200,
})
render(<PopupItem model={makeModel()} onSelect={vi.fn()} onHide={vi.fn()} />)
expect(screen.getByText('stale-key')).toBeInTheDocument()
expect(document.querySelector('.bg-components-badge-status-light-error-bg')).not.toBeNull()
})
it('should show configure required when no credential name', () => {
mockUseProviderContext.mockReturnValue({
modelProviders: [makeProvider({
@ -306,4 +350,14 @@ describe('PopupItem', () => {
expect(screen.getByText(/modelProvider\.selector\.creditsExhausted/)).toBeInTheDocument()
})
it('should close the dropdown through dropdown content callbacks', () => {
const onHide = vi.fn()
render(<PopupItem model={makeModel()} onSelect={vi.fn()} onHide={onHide} />)
fireEvent.click(screen.getByRole('button', { name: 'close dropdown' }))
expect(onHide).toHaveBeenCalled()
})
})

View File

@ -553,4 +553,60 @@ describe('Popup', () => {
})
expect(mockRefreshPluginList).toHaveBeenCalled()
})
it('should skip install requests when marketplace plugins are still loading', async () => {
mockMarketplacePlugins.current = [
{ plugin_id: 'langgenius/openai', latest_package_identifier: 'langgenius/openai:1.0.0' },
]
mockMarketplacePlugins.isLoading = true
render(
<Popup
modelList={[]}
onSelect={vi.fn()}
onHide={vi.fn()}
/>,
)
fireEvent.click(screen.getAllByText(/common\.modelProvider\.selector\.install/)[0])
await waitFor(() => {
expect(mockInstallMutateAsync).not.toHaveBeenCalled()
})
})
it('should skip install requests when the marketplace plugin cannot be found', async () => {
mockMarketplacePlugins.current = []
render(
<Popup
modelList={[]}
onSelect={vi.fn()}
onHide={vi.fn()}
/>,
)
fireEvent.click(screen.getAllByText(/common\.modelProvider\.selector\.install/)[0])
await waitFor(() => {
expect(mockInstallMutateAsync).not.toHaveBeenCalled()
})
})
it('should sort the selected provider to the top when a default model is provided', () => {
render(
<Popup
defaultModel={{ provider: 'anthropic', model: 'claude-3' }}
modelList={[
makeModel({ provider: 'openai', label: { en_US: 'OpenAI', zh_Hans: 'OpenAI' } }),
makeModel({ provider: 'anthropic', label: { en_US: 'Anthropic', zh_Hans: 'Anthropic' } }),
]}
onSelect={vi.fn()}
onHide={vi.fn()}
/>,
)
const providerLabels = screen.getAllByText(/openai|anthropic/)
expect(providerLabels[0]).toHaveTextContent('anthropic')
})
})

View File

@ -1,16 +1,41 @@
import { render, screen } from '@testing-library/react'
import type { ReactNode } from 'react'
import { fireEvent, render, screen } from '@testing-library/react'
import CreditsExhaustedAlert from './credits-exhausted-alert'
const mockTrialCredits = { credits: 0, totalCredits: 10_000, isExhausted: true, isLoading: false, nextCreditResetDate: undefined }
const mockSetShowPricingModal = vi.fn()
vi.mock('react-i18next', async (importOriginal) => {
const actual = await importOriginal<typeof import('react-i18next')>()
return {
...actual,
Trans: ({
i18nKey,
components,
}: {
i18nKey?: string
components: { upgradeLink: ReactNode }
}) => (
<>
{i18nKey}
{components.upgradeLink}
</>
),
}
})
vi.mock('../use-trial-credits', () => ({
useTrialCredits: () => mockTrialCredits,
}))
vi.mock('@/context/modal-context', () => ({
useModalContextSelector: () => mockSetShowPricingModal,
}))
describe('CreditsExhaustedAlert', () => {
beforeEach(() => {
vi.clearAllMocks()
Object.assign(mockTrialCredits, { credits: 0 })
Object.assign(mockTrialCredits, { credits: 0, totalCredits: 10_000 })
})
// Without API key fallback
@ -59,5 +84,21 @@ describe('CreditsExhaustedAlert', () => {
expect(screen.getByText(/9,800/)).toBeInTheDocument()
expect(screen.getByText(/10,000/)).toBeInTheDocument()
})
it('should cap progress at 100 percent when total credits are zero', () => {
Object.assign(mockTrialCredits, { credits: 0, totalCredits: 0 })
const { container } = render(<CreditsExhaustedAlert hasApiKeyFallback={false} />)
expect(container.querySelector('.bg-components-progress-error-progress')).toHaveStyle({ width: '100%' })
})
it('should open the pricing modal when the upgrade link is clicked', () => {
const { container } = render(<CreditsExhaustedAlert hasApiKeyFallback={false} />)
fireEvent.click(container.querySelector('button') as HTMLButtonElement)
expect(mockSetShowPricingModal).toHaveBeenCalledTimes(1)
})
})
})

View File

@ -0,0 +1,152 @@
import type { ReactNode } from 'react'
import type { ModelProvider } from '../../declarations'
import type { CredentialPanelState } from '../use-credential-panel-state'
import { act, fireEvent, render, screen } from '@testing-library/react'
import DropdownContent from './dropdown-content'
type AlertDialogProps = {
children: ReactNode
onOpenChange?: (open: boolean) => void
}
let latestOnOpenChange: AlertDialogProps['onOpenChange']
const mockOpenConfirmDelete = vi.fn()
const mockCloseConfirmDelete = vi.fn()
const mockHandleConfirmDelete = vi.fn()
const mockHandleOpenModal = vi.fn()
vi.mock('../../model-auth/hooks', () => ({
useAuth: () => ({
openConfirmDelete: mockOpenConfirmDelete,
closeConfirmDelete: mockCloseConfirmDelete,
doingAction: false,
handleConfirmDelete: mockHandleConfirmDelete,
deleteCredentialId: 'cred-1',
handleOpenModal: mockHandleOpenModal,
}),
}))
vi.mock('./use-activate-credential', () => ({
useActivateCredential: () => ({
selectedCredentialId: 'cred-1',
isActivating: false,
activate: vi.fn(),
}),
}))
vi.mock('@/app/components/base/ui/alert-dialog', () => ({
AlertDialog: ({ children, onOpenChange }: AlertDialogProps) => {
latestOnOpenChange = onOpenChange
return <div>{children}</div>
},
AlertDialogActions: ({ children }: { children: ReactNode }) => <div>{children}</div>,
AlertDialogCancelButton: ({ children }: { children: ReactNode }) => <button type="button">{children}</button>,
AlertDialogConfirmButton: ({ children, onClick }: { children: ReactNode, onClick?: () => void }) => <button type="button" onClick={onClick}>{children}</button>,
AlertDialogContent: ({ children }: { children: ReactNode }) => <div>{children}</div>,
AlertDialogDescription: () => <div />,
AlertDialogTitle: ({ children }: { children: ReactNode }) => <div>{children}</div>,
}))
vi.mock('./api-key-section', () => ({
default: ({ credentials, onDelete }: { credentials: unknown[], onDelete: (credential?: unknown) => void }) => (
<div>
<span>{`credentials:${credentials.length}`}</span>
<button type="button" onClick={() => onDelete(undefined)}>delete-undefined</button>
</div>
),
}))
vi.mock('./credits-exhausted-alert', () => ({
default: () => <div>credits alert</div>,
}))
vi.mock('./credits-fallback-alert', () => ({
default: () => <div>fallback alert</div>,
}))
vi.mock('./usage-priority-section', () => ({
default: () => <div>priority section</div>,
}))
const createProvider = (overrides: Partial<ModelProvider> = {}): ModelProvider => ({
provider: 'test',
custom_configuration: {
available_credentials: undefined,
},
system_configuration: {
enabled: true,
quota_configurations: [],
current_quota_type: 'trial',
},
configurate_methods: [],
supported_model_types: [],
...overrides,
} as unknown as ModelProvider)
const createState = (overrides: Partial<CredentialPanelState> = {}): CredentialPanelState => ({
variant: 'api-active',
priority: 'apiKey',
supportsCredits: true,
showPrioritySwitcher: false,
hasCredentials: false,
isCreditsExhausted: false,
credentialName: undefined,
credits: 0,
...overrides,
})
describe('DropdownContent dialog branches', () => {
beforeEach(() => {
vi.clearAllMocks()
latestOnOpenChange = undefined
})
it('should fall back to an empty credential list when the provider has no credentials', () => {
render(
<DropdownContent
provider={createProvider()}
state={createState()}
isChangingPriority={false}
onChangePriority={vi.fn()}
onClose={vi.fn()}
/>,
)
expect(screen.getByText('credentials:0')).toBeInTheDocument()
})
it('should ignore delete requests without a credential payload', () => {
render(
<DropdownContent
provider={createProvider()}
state={createState()}
isChangingPriority={false}
onChangePriority={vi.fn()}
onClose={vi.fn()}
/>,
)
fireEvent.click(screen.getByRole('button', { name: 'delete-undefined' }))
expect(mockOpenConfirmDelete).not.toHaveBeenCalled()
})
it('should only close the confirm dialog when the alert dialog reports closed', () => {
render(
<DropdownContent
provider={createProvider()}
state={createState()}
isChangingPriority={false}
onChangePriority={vi.fn()}
onClose={vi.fn()}
/>,
)
act(() => {
latestOnOpenChange?.(true)
latestOnOpenChange?.(false)
})
expect(mockCloseConfirmDelete).toHaveBeenCalledTimes(1)
})
})

View File

@ -223,4 +223,42 @@ describe('ProviderCardActions', () => {
expect(mockSetTargetVersion).not.toHaveBeenCalled()
expect(mockHandleUpdate).toHaveBeenCalledWith()
})
it('should fall back to the detail name when declaration metadata is missing', () => {
render(
<ProviderCardActions
detail={createDetail({
declaration: undefined,
})}
/>,
)
expect(mockGetMarketplaceUrl).toHaveBeenCalledWith('/plugins//provider-plugin', {
language: 'en-US',
theme: 'light',
})
})
it('should leave the detail url empty when a GitHub plugin has no repo or the source is unsupported', () => {
const { rerender } = render(
<ProviderCardActions
detail={createDetail({
source: PluginSource.github,
meta: undefined,
})}
/>,
)
expect(screen.getByTestId('operation-dropdown')).toHaveAttribute('data-detail-url', '')
rerender(
<ProviderCardActions
detail={createDetail({
source: PluginSource.local,
})}
/>,
)
expect(screen.getByTestId('operation-dropdown')).toHaveAttribute('data-detail-url', '')
})
})

View File

@ -12,7 +12,7 @@ let mockWorkspaceData: {
next_credit_reset_date: '2024-12-31',
}
let mockWorkspaceIsPending = false
let mockTrialModels: string[] = ['langgenius/openai/openai']
let mockTrialModels: string[] | undefined = ['langgenius/openai/openai']
let mockPlugins = [{
plugin_id: 'langgenius/openai',
latest_package_identifier: 'openai@1.0.0',
@ -39,9 +39,7 @@ vi.mock('@/service/use-common', () => ({
vi.mock('@/context/global-public-context', () => ({
useSystemFeaturesQuery: () => ({
data: {
trial_models: mockTrialModels,
},
data: mockTrialModels ? { trial_models: mockTrialModels } : undefined,
}),
}))
@ -149,4 +147,37 @@ describe('QuotaPanel', () => {
expect(screen.queryByText('install modal')).not.toBeInTheDocument()
})
})
it('should tolerate missing trial model configuration', () => {
mockTrialModels = undefined
render(<QuotaPanel providers={mockProviders} />)
expect(screen.queryByText('openai')).not.toBeInTheDocument()
})
it('should render installed custom providers without opening the install modal', () => {
render(<QuotaPanel providers={mockProviders} />)
expect(screen.getByLabelText(/modelAPI/)).toBeInTheDocument()
fireEvent.click(screen.getByText('openai'))
expect(screen.queryByText('install modal')).not.toBeInTheDocument()
})
it('should show the supported-model tooltip for installed non-custom providers', () => {
render(
<QuotaPanel providers={[
{
provider: 'langgenius/openai/openai',
preferred_provider_type: 'system',
custom_configuration: { available_credentials: [] },
},
] as unknown as ModelProvider[]}
/>,
)
expect(screen.getByLabelText(/modelSupported/)).toBeInTheDocument()
})
})

View File

@ -172,6 +172,24 @@ describe('useCredentialPanelState', () => {
expect(result.current.variant).toBe('api-unavailable')
})
it('should return api-required-configure when credentials exist but the current credential is incomplete', () => {
mockTrialCredits.isExhausted = true
mockTrialCredits.credits = 0
const provider = createProvider({
preferred_provider_type: PreferredProviderTypeEnum.custom,
custom_configuration: {
status: CustomConfigurationStatusEnum.active,
current_credential_id: 'cred-1',
current_credential_name: undefined,
available_credentials: [{ credential_id: 'cred-1', credential_name: 'Bad Key' }],
},
})
const { result } = renderHook(() => useCredentialPanelState(provider))
expect(result.current.variant).toBe('api-required-configure')
})
})
// apiKeyOnly priority

View File

@ -74,6 +74,26 @@ describe('ProviderIcon', () => {
expect(screen.getByTestId('openai-icon')).toBeInTheDocument()
})
it('should apply custom className to special provider wrappers', () => {
const { rerender, container } = render(
<ProviderIcon
provider={createProvider({ provider: 'langgenius/anthropic/anthropic' })}
className="custom-wrapper"
/>,
)
expect(container.firstChild).toHaveClass('custom-wrapper')
rerender(
<ProviderIcon
provider={createProvider({ provider: 'langgenius/openai/openai' })}
className="custom-wrapper"
/>,
)
expect(container.firstChild).toHaveClass('custom-wrapper')
})
it('should render generic provider with image and label', () => {
const provider = createProvider({ label: { en_US: 'Custom', zh_Hans: '自定义' } })
render(<ProviderIcon provider={provider} />)
@ -94,4 +114,19 @@ describe('ProviderIcon', () => {
const img = screen.getByAltText('provider-icon') as HTMLImageElement
expect(img.src).toBe('https://example.com/dark.png')
})
it('should fall back to localized labels when available', () => {
const mockLang = vi.mocked(useLanguage)
mockLang.mockReturnValue('zh_Hans')
render(
<ProviderIcon
provider={createProvider({
label: { en_US: 'Custom', zh_Hans: '自定义' },
})}
/>,
)
expect(screen.getByText('自定义')).toBeInTheDocument()
})
})

View File

@ -115,6 +115,12 @@ describe('SystemModel', () => {
expect(screen.getByRole('button', { name: /system model settings/i })).toBeDisabled()
})
it('should render the primary button variant when configuration is required', () => {
render(<SystemModel {...defaultProps} notConfigured />)
expect(screen.getByRole('button', { name: /system model settings/i })).toHaveClass('btn-primary')
})
it('should close dialog when cancel is clicked', async () => {
render(<SystemModel {...defaultProps} />)
fireEvent.click(screen.getByRole('button', { name: /system model settings/i }))
@ -151,6 +157,27 @@ describe('SystemModel', () => {
})
})
it('should keep the dialog open when saving does not succeed', async () => {
mockUpdateDefaultModel.mockResolvedValueOnce({ result: 'failed' })
render(<SystemModel {...defaultProps} />)
fireEvent.click(screen.getByRole('button', { name: /system model settings/i }))
await waitFor(() => {
expect(screen.getByRole('button', { name: /save/i })).toBeInTheDocument()
})
fireEvent.click(screen.getByRole('button', { name: /save/i }))
await waitFor(() => {
expect(mockUpdateDefaultModel).toHaveBeenCalledTimes(1)
})
expect(screen.getByRole('button', { name: /save/i })).toBeInTheDocument()
expect(mockNotify).not.toHaveBeenCalled()
expect(mockInvalidateDefaultModel).not.toHaveBeenCalled()
expect(mockUpdateModelList).not.toHaveBeenCalled()
})
it('should disable save when user is not workspace manager', async () => {
mockIsCurrentWorkspaceManager = false
render(<SystemModel {...defaultProps} />)

View File

@ -1,6 +1,5 @@
import type { Form, ValidateValue } from '../key-validator/declarations'
import type { PluginProvider } from '@/models/common'
import Image from 'next/image'
import { useTranslation } from 'react-i18next'
import { useToastContext } from '@/app/components/base/toast/context'
import { useAppContext } from '@/context/app-context'
@ -64,7 +63,7 @@ const SerpapiPlugin = ({
return (
<KeyValidator
type="serpapi"
title={<Image alt="serpapi logo" src={SerpapiLogo} width={64} />}
title={<img alt="serpapi logo" src={SerpapiLogo.src} width={64} />}
status={plugin.credentials?.api_key ? 'success' : 'add'}
forms={forms}
keyFrom={{

View File

@ -1,7 +1,17 @@
import type { TagKey } from '../constants'
import type { Plugin } from '../types'
import { describe, expect, it } from 'vitest'
import { API_PREFIX, MARKETPLACE_API_PREFIX } from '@/config'
import { PluginCategoryEnum } from '../types'
import { getValidCategoryKeys, getValidTagKeys } from '../utils'
import { getPluginCardIconUrl, getValidCategoryKeys, getValidTagKeys } from '../utils'
const createPlugin = (overrides: Partial<Pick<Plugin, 'from' | 'name' | 'org' | 'type'>> = {}): Pick<Plugin, 'from' | 'name' | 'org' | 'type'> => ({
from: 'github',
name: 'demo-plugin',
org: 'langgenius',
type: 'plugin',
...overrides,
})
describe('plugins/utils', () => {
describe('getValidTagKeys', () => {
@ -47,4 +57,31 @@ describe('plugins/utils', () => {
expect(getValidCategoryKeys('')).toBeUndefined()
})
})
describe('getPluginCardIconUrl', () => {
it('returns an empty string when icon is missing', () => {
expect(getPluginCardIconUrl(createPlugin(), undefined, 'tenant-1')).toBe('')
})
it('returns absolute urls and root-relative urls as-is', () => {
expect(getPluginCardIconUrl(createPlugin(), 'https://example.com/icon.png', 'tenant-1')).toBe('https://example.com/icon.png')
expect(getPluginCardIconUrl(createPlugin(), '/icons/demo.png', 'tenant-1')).toBe('/icons/demo.png')
})
it('builds the marketplace icon url for plugins and bundles', () => {
expect(getPluginCardIconUrl(createPlugin({ from: 'marketplace' }), 'icon.png', 'tenant-1'))
.toBe(`${MARKETPLACE_API_PREFIX}/plugins/langgenius/demo-plugin/icon`)
expect(getPluginCardIconUrl(createPlugin({ from: 'marketplace', type: 'bundle' }), 'icon.png', 'tenant-1'))
.toBe(`${MARKETPLACE_API_PREFIX}/bundles/langgenius/demo-plugin/icon`)
})
it('falls back to the raw icon when tenant id is missing for non-marketplace plugins', () => {
expect(getPluginCardIconUrl(createPlugin(), 'icon.png', '')).toBe('icon.png')
})
it('builds the workspace icon url for tenant-scoped plugins', () => {
expect(getPluginCardIconUrl(createPlugin(), 'icon.png', 'tenant-1'))
.toBe(`${API_PREFIX}/workspaces/current/plugin/icon?tenant_id=tenant-1&filename=icon.png`)
})
})
})

View File

@ -0,0 +1,128 @@
import type { PluginDetail } from './types'
import { useQuery } from '@tanstack/react-query'
import { renderHook } from '@testing-library/react'
import { consoleQuery } from '@/service/client'
import { usePluginsWithLatestVersion } from './hooks'
import { PluginSource } from './types'
vi.mock('@tanstack/react-query', () => ({
useQuery: vi.fn(),
}))
vi.mock('@/service/client', () => ({
consoleQuery: {
plugins: {
latestVersions: {
queryOptions: vi.fn((options: unknown) => options),
},
},
},
}))
const createPlugin = (overrides: Partial<PluginDetail> = {}): PluginDetail => ({
id: 'plugin-1',
created_at: '2026-01-01T00:00:00Z',
updated_at: '2026-01-01T00:00:00Z',
name: 'demo-plugin',
plugin_id: 'plugin-1',
plugin_unique_identifier: 'plugin-1@1.0.0',
declaration: {} as PluginDetail['declaration'],
installation_id: 'installation-1',
tenant_id: 'tenant-1',
endpoints_setups: 0,
endpoints_active: 0,
version: '1.0.0',
latest_version: '1.0.0',
latest_unique_identifier: 'plugin-1@1.0.0',
source: PluginSource.marketplace,
meta: undefined,
status: 'active',
deprecated_reason: '',
alternative_plugin_id: '',
...overrides,
})
describe('usePluginsWithLatestVersion', () => {
beforeEach(() => {
vi.clearAllMocks()
vi.mocked(useQuery).mockReturnValue({ data: undefined } as never)
})
it('should disable latest-version querying when there are no marketplace plugins', () => {
const plugins = [
createPlugin({ plugin_id: 'github-plugin', source: PluginSource.github }),
]
const { result } = renderHook(() => usePluginsWithLatestVersion(plugins))
expect(consoleQuery.plugins.latestVersions.queryOptions).toHaveBeenCalledWith({
input: { body: { plugin_ids: [] } },
enabled: false,
})
expect(result.current).toEqual(plugins)
})
it('should return the original plugins when version data is unavailable', () => {
const plugins = [createPlugin()]
const { result } = renderHook(() => usePluginsWithLatestVersion(plugins))
expect(result.current).toEqual(plugins)
})
it('should keep plugins unchanged when a plugin has no matching latest version', () => {
const plugins = [createPlugin()]
vi.mocked(useQuery).mockReturnValue({
data: { versions: {} },
} as never)
const { result } = renderHook(() => usePluginsWithLatestVersion(plugins))
expect(result.current).toEqual(plugins)
})
it('should merge latest version fields for marketplace plugins with version data', () => {
const plugins = [
createPlugin(),
createPlugin({
id: 'plugin-2',
plugin_id: 'plugin-2',
plugin_unique_identifier: 'plugin-2@1.0.0',
latest_version: '1.0.0',
latest_unique_identifier: 'plugin-2@1.0.0',
source: PluginSource.github,
}),
]
vi.mocked(useQuery).mockReturnValue({
data: {
versions: {
'plugin-1': {
version: '1.1.0',
unique_identifier: 'plugin-1@1.1.0',
status: 'deleted',
deprecated_reason: 'replaced',
alternative_plugin_id: 'plugin-3',
},
},
},
} as never)
const { result } = renderHook(() => usePluginsWithLatestVersion(plugins))
expect(consoleQuery.plugins.latestVersions.queryOptions).toHaveBeenCalledWith({
input: { body: { plugin_ids: ['plugin-1'] } },
enabled: true,
})
expect(result.current).toEqual([
expect.objectContaining({
plugin_id: 'plugin-1',
latest_version: '1.1.0',
latest_unique_identifier: 'plugin-1@1.1.0',
status: 'deleted',
deprecated_reason: 'replaced',
alternative_plugin_id: 'plugin-3',
}),
plugins[1],
])
})
})

View File

@ -267,6 +267,34 @@ describe('useInstallMultiState', () => {
})
})
it('should fall back to latest_version when marketplace plugin version is missing', async () => {
mockMarketplaceData = {
data: {
list: [{
plugin: {
plugin_id: 'test-org/plugin-0',
org: 'test-org',
name: 'Test Plugin 0',
version: '',
latest_version: '2.0.0',
},
version: {
unique_identifier: 'plugin-0-uid',
},
}],
},
}
const params = createDefaultParams({
allPlugins: [createMarketplaceDependency(0)] as Dependency[],
})
const { result } = renderHook(() => useInstallMultiState(params))
await waitFor(() => {
expect(result.current.plugins[0]?.version).toBe('2.0.0')
})
})
it('should resolve marketplace dependency from organization and plugin fields', async () => {
mockMarketplaceData = createMarketplaceApiData([0])
@ -293,6 +321,44 @@ describe('useInstallMultiState', () => {
// ==================== Error Handling ====================
describe('Error Handling', () => {
it('should mark marketplace index as error when identifier misses plugin and version parts', async () => {
const params = createDefaultParams({
allPlugins: [
{
type: 'marketplace',
value: {
marketplace_plugin_unique_identifier: 'invalid-identifier',
version: '1.0.0',
},
} as GitHubItemAndMarketPlaceDependency,
] as Dependency[],
})
const { result } = renderHook(() => useInstallMultiState(params))
await waitFor(() => {
expect(result.current.errorIndexes).toContain(0)
})
})
it('should mark marketplace index as error when identifier has an empty plugin segment', async () => {
const params = createDefaultParams({
allPlugins: [
{
type: 'marketplace',
value: {
marketplace_plugin_unique_identifier: 'test-org/:1.0.0',
version: '1.0.0',
},
} as GitHubItemAndMarketPlaceDependency,
] as Dependency[],
})
const { result } = renderHook(() => useInstallMultiState(params))
await waitFor(() => {
expect(result.current.errorIndexes).toContain(0)
})
})
it('should mark marketplace index as error when identifier is missing', async () => {
const params = createDefaultParams({
allPlugins: [
@ -344,6 +410,19 @@ describe('useInstallMultiState', () => {
expect(result.current.errorIndexes).not.toContain(0)
})
})
it('should ignore marketplace requests whose dsl index cannot be mapped', () => {
const duplicatedMarketplaceDependency = createMarketplaceDependency(0)
const allPlugins = [duplicatedMarketplaceDependency] as Dependency[]
allPlugins.filter = vi.fn(() => [duplicatedMarketplaceDependency, duplicatedMarketplaceDependency]) as typeof allPlugins.filter
const params = createDefaultParams({ allPlugins })
const { result } = renderHook(() => useInstallMultiState(params))
expect(result.current.plugins).toHaveLength(1)
expect(result.current.errorIndexes).toEqual([])
})
})
// ==================== Loaded All Data Notification ====================

View File

@ -1,4 +1,3 @@
import Image from 'next/image'
import * as React from 'react'
import useTheme from '@/hooks/use-theme'
import { basePath } from '@/utils/var'
@ -11,7 +10,7 @@ const PipelineScreenShot = () => {
<source media="(resolution: 1x)" srcSet={`${basePath}/screenshots/${theme}/Pipeline.png`} />
<source media="(resolution: 2x)" srcSet={`${basePath}/screenshots/${theme}/Pipeline@2x.png`} />
<source media="(resolution: 3x)" srcSet={`${basePath}/screenshots/${theme}/Pipeline@3x.png`} />
<Image
<img
src={`${basePath}/screenshots/${theme}/Pipeline.png`}
alt="Pipeline Screenshot"
width={692}

View File

@ -0,0 +1,131 @@
import type { ReactNode } from 'react'
import { fireEvent, render, screen } from '@testing-library/react'
import { BlockEnum } from '../../types'
import WorkflowChecklist from './index'
let mockChecklistItems = [
{
id: 'plugin-1',
type: BlockEnum.Tool,
title: 'Missing Plugin',
errorMessages: [],
canNavigate: false,
isPluginMissing: true,
},
{
id: 'node-1',
type: BlockEnum.LLM,
title: 'Broken Node',
errorMessages: ['Needs configuration'],
canNavigate: true,
isPluginMissing: false,
},
]
const mockHandleNodeSelect = vi.fn()
type PopoverProps = {
children: ReactNode
open?: boolean
onOpenChange?: (open: boolean) => void
}
let latestOnOpenChange: PopoverProps['onOpenChange']
vi.mock('reactflow', () => ({
useEdges: () => [],
}))
vi.mock('@/app/components/workflow/store/workflow/use-nodes', () => ({
default: () => [],
}))
vi.mock('../../hooks', () => ({
useChecklist: () => mockChecklistItems,
useNodesInteractions: () => ({
handleNodeSelect: mockHandleNodeSelect,
}),
}))
vi.mock('@/app/components/base/ui/popover', () => ({
Popover: ({ children, onOpenChange }: PopoverProps) => {
latestOnOpenChange = onOpenChange
return <div data-testid="popover">{children}</div>
},
PopoverTrigger: ({ render }: { render: ReactNode }) => <>{render}</>,
PopoverContent: ({ children }: { children: ReactNode }) => <div>{children}</div>,
PopoverClose: ({ children, className }: { children: ReactNode, className?: string }) => <button className={className}>{children}</button>,
}))
vi.mock('./plugin-group', () => ({
ChecklistPluginGroup: ({ items }: { items: Array<{ title: string }> }) => <div data-testid="plugin-group">{items.map(item => item.title).join(',')}</div>,
}))
vi.mock('./node-group', () => ({
ChecklistNodeGroup: ({ item, onItemClick }: { item: { title: string }, onItemClick: (item: { title: string }) => void }) => (
<button data-testid={`node-group-${item.title}`} onClick={() => onItemClick(item)}>
{item.title}
</button>
),
}))
describe('WorkflowChecklist', () => {
beforeEach(() => {
vi.clearAllMocks()
latestOnOpenChange = undefined
mockChecklistItems = [
{
id: 'plugin-1',
type: BlockEnum.Tool,
title: 'Missing Plugin',
errorMessages: [],
canNavigate: false,
isPluginMissing: true,
},
{
id: 'node-1',
type: BlockEnum.LLM,
title: 'Broken Node',
errorMessages: ['Needs configuration'],
canNavigate: true,
isPluginMissing: false,
},
]
})
it('should split checklist items into plugin and node groups and delegate clicks to node selection by default', () => {
render(<WorkflowChecklist disabled={false} />)
expect(screen.getByText('2')).toBeInTheDocument()
expect(screen.getByTestId('plugin-group')).toHaveTextContent('Missing Plugin')
fireEvent.click(screen.getByTestId('node-group-Broken Node'))
expect(mockHandleNodeSelect).toHaveBeenCalledWith('node-1')
})
it('should use the custom item click handler when provided', () => {
const onItemClick = vi.fn()
render(<WorkflowChecklist disabled={false} onItemClick={onItemClick} />)
fireEvent.click(screen.getByTestId('node-group-Broken Node'))
expect(onItemClick).toHaveBeenCalledWith(expect.objectContaining({ id: 'node-1' }))
expect(mockHandleNodeSelect).not.toHaveBeenCalled()
})
it('should render the resolved state when there are no checklist warnings', () => {
mockChecklistItems = []
render(<WorkflowChecklist disabled={false} />)
expect(screen.getByText(/checklistResolved/i)).toBeInTheDocument()
})
it('should ignore popover open changes when the checklist is disabled', () => {
render(<WorkflowChecklist disabled={true} />)
latestOnOpenChange?.(true)
expect(screen.getByText('2').closest('button')).toBeDisabled()
})
})

View File

@ -0,0 +1,61 @@
import { fireEvent, render, screen } from '@testing-library/react'
import { BlockEnum } from '../../types'
import { ChecklistNodeGroup } from './node-group'
vi.mock('../../block-icon', () => ({
default: () => <div data-testid="block-icon" />,
}))
vi.mock('./item-indicator', () => ({
ItemIndicator: () => <div data-testid="item-indicator" />,
}))
const createItem = (overrides: Record<string, unknown> = {}) => ({
id: 'node-1',
type: BlockEnum.LLM,
title: 'Broken Node',
errorMessages: ['Needs configuration'],
canNavigate: true,
disableGoTo: false,
unConnected: false,
...overrides,
})
describe('ChecklistNodeGroup', () => {
it('should render errors and the connection warning, and allow navigation when go-to is enabled', () => {
const onItemClick = vi.fn()
render(
<ChecklistNodeGroup
item={createItem({ unConnected: true }) as never}
showGoTo={true}
onItemClick={onItemClick}
/>,
)
expect(screen.getByText('Needs configuration')).toBeInTheDocument()
expect(screen.getByText(/needConnectTip/i)).toBeInTheDocument()
expect(screen.getAllByText(/goToFix/i)).toHaveLength(2)
fireEvent.click(screen.getByText('Needs configuration'))
expect(onItemClick).toHaveBeenCalledWith(expect.objectContaining({ id: 'node-1' }))
})
it('should not allow navigation when go-to is disabled', () => {
const onItemClick = vi.fn()
render(
<ChecklistNodeGroup
item={createItem({ disableGoTo: true }) as never}
showGoTo={true}
onItemClick={onItemClick}
/>,
)
fireEvent.click(screen.getByText('Needs configuration'))
expect(onItemClick).not.toHaveBeenCalled()
expect(screen.queryByText(/goToFix/i)).not.toBeInTheDocument()
})
})

View File

@ -76,4 +76,21 @@ describe('ChecklistPluginGroup', () => {
fireEvent.click(installButton)
expect(usePluginDependencyStore.getState().dependencies).toEqual([])
})
it('should omit the version when the marketplace identifier does not include one', () => {
renderInPopover([createChecklistItem({ pluginUniqueIdentifier: 'langgenius/test-plugin@sha256' })])
fireEvent.click(getInstallButton())
expect(usePluginDependencyStore.getState().dependencies).toEqual([
{
type: 'marketplace',
value: {
marketplace_plugin_unique_identifier: 'langgenius/test-plugin@sha256',
plugin_unique_identifier: 'langgenius/test-plugin@sha256',
version: undefined,
},
},
])
})
})

View File

@ -0,0 +1,150 @@
import type { ReactNode } from 'react'
import { fireEvent, render, screen } from '@testing-library/react'
import * as React from 'react'
import { WorkflowRunningStatus } from '@/app/components/workflow/types'
import RunMode from './run-mode'
import { TriggerType } from './test-run-menu'
const mockHandleWorkflowStartRunInWorkflow = vi.fn()
const mockHandleWorkflowTriggerScheduleRunInWorkflow = vi.fn()
const mockHandleWorkflowTriggerWebhookRunInWorkflow = vi.fn()
const mockHandleWorkflowTriggerPluginRunInWorkflow = vi.fn()
const mockHandleWorkflowRunAllTriggersInWorkflow = vi.fn()
const mockHandleStopRun = vi.fn()
const mockNotify = vi.fn()
const mockTrackEvent = vi.fn()
let mockWarningNodes: Array<{ id: string }> = []
let mockWorkflowRunningData: { result: { status: WorkflowRunningStatus }, task_id: string } | undefined
let mockIsListening = false
let mockDynamicOptions = [
{ type: TriggerType.UserInput, nodeId: 'start-node' },
]
vi.mock('@/app/components/workflow/hooks', () => ({
useWorkflowStartRun: () => ({
handleWorkflowStartRunInWorkflow: mockHandleWorkflowStartRunInWorkflow,
handleWorkflowTriggerScheduleRunInWorkflow: mockHandleWorkflowTriggerScheduleRunInWorkflow,
handleWorkflowTriggerWebhookRunInWorkflow: mockHandleWorkflowTriggerWebhookRunInWorkflow,
handleWorkflowTriggerPluginRunInWorkflow: mockHandleWorkflowTriggerPluginRunInWorkflow,
handleWorkflowRunAllTriggersInWorkflow: mockHandleWorkflowRunAllTriggersInWorkflow,
}),
useWorkflowRun: () => ({
handleStopRun: mockHandleStopRun,
}),
useWorkflowRunValidation: () => ({
warningNodes: mockWarningNodes,
}),
}))
vi.mock('@/app/components/workflow/store', () => ({
useStore: (selector: (state: { workflowRunningData?: unknown, isListening: boolean }) => unknown) =>
selector({ workflowRunningData: mockWorkflowRunningData, isListening: mockIsListening }),
}))
vi.mock('../hooks/use-dynamic-test-run-options', () => ({
useDynamicTestRunOptions: () => mockDynamicOptions,
}))
vi.mock('@/app/components/base/toast/context', () => ({
useToastContext: () => ({
notify: mockNotify,
}),
}))
vi.mock('@/app/components/base/amplitude', () => ({
trackEvent: (...args: unknown[]) => mockTrackEvent(...args),
}))
vi.mock('@/context/event-emitter', () => ({
useEventEmitterContextContext: () => ({
eventEmitter: {
useSubscription: vi.fn(),
},
}),
}))
vi.mock('@/app/components/workflow/shortcuts-name', () => ({
default: () => <span data-testid="shortcuts-name">Shortcut</span>,
}))
vi.mock('@/app/components/base/icons/src/vender/line/mediaAndDevices', () => ({
StopCircle: () => <span data-testid="stop-circle" />,
}))
vi.mock('./test-run-menu', async (importOriginal) => {
const actual = await importOriginal<typeof import('./test-run-menu')>()
return {
...actual,
default: React.forwardRef(({ children, options, onSelect }: { children: ReactNode, options: Array<{ type: TriggerType, nodeId?: string, relatedNodeIds?: string[] }>, onSelect: (option: { type: TriggerType, nodeId?: string, relatedNodeIds?: string[] }) => void }, ref) => {
React.useImperativeHandle(ref, () => ({
toggle: vi.fn(),
}))
return (
<div>
<button data-testid="trigger-option" onClick={() => onSelect(options[0])}>
Trigger option
</button>
{children}
</div>
)
}),
}
})
describe('RunMode', () => {
beforeEach(() => {
vi.clearAllMocks()
mockWarningNodes = []
mockWorkflowRunningData = undefined
mockIsListening = false
mockDynamicOptions = [
{ type: TriggerType.UserInput, nodeId: 'start-node' },
]
})
it('should render the run trigger and start the workflow when a valid trigger is selected', () => {
render(<RunMode />)
expect(screen.getByText(/run/i)).toBeInTheDocument()
fireEvent.click(screen.getByTestId('trigger-option'))
expect(mockHandleWorkflowStartRunInWorkflow).toHaveBeenCalledTimes(1)
expect(mockTrackEvent).toHaveBeenCalledWith('app_start_action_time', { action_type: 'user_input' })
})
it('should show an error toast instead of running when the selected trigger has checklist warnings', () => {
mockWarningNodes = [{ id: 'start-node' }]
render(<RunMode />)
fireEvent.click(screen.getByTestId('trigger-option'))
expect(mockNotify).toHaveBeenCalledWith({
type: 'error',
message: 'workflow.panel.checklistTip',
})
expect(mockHandleWorkflowStartRunInWorkflow).not.toHaveBeenCalled()
})
it('should render the running state and stop the workflow when it is already running', () => {
mockWorkflowRunningData = {
result: { status: WorkflowRunningStatus.Running },
task_id: 'task-1',
}
render(<RunMode />)
expect(screen.getByText(/running/i)).toBeInTheDocument()
fireEvent.click(screen.getByTestId('stop-circle').closest('button') as HTMLButtonElement)
expect(mockHandleStopRun).toHaveBeenCalledWith('task-1')
})
it('should render the listening label when the workflow is listening', () => {
mockIsListening = true
render(<RunMode />)
expect(screen.getByText(/listening/i)).toBeInTheDocument()
})
})

View File

@ -0,0 +1,253 @@
import type { CommonNodeType } from '../../types'
import { act } from '@testing-library/react'
import { CollectionType } from '@/app/components/tools/types'
import { renderWorkflowHook } from '../../__tests__/workflow-test-env'
import { BlockEnum } from '../../types'
import { useNodePluginInstallation } from '../use-node-plugin-installation'
const mockBuiltInTools = vi.fn()
const mockCustomTools = vi.fn()
const mockWorkflowTools = vi.fn()
const mockMcpTools = vi.fn()
const mockInvalidToolsByType = vi.fn()
const mockTriggerPlugins = vi.fn()
const mockInvalidateTriggers = vi.fn()
const mockInvalidDataSourceList = vi.fn()
vi.mock('@/service/use-tools', () => ({
useAllBuiltInTools: (enabled: boolean) => mockBuiltInTools(enabled),
useAllCustomTools: (enabled: boolean) => mockCustomTools(enabled),
useAllWorkflowTools: (enabled: boolean) => mockWorkflowTools(enabled),
useAllMCPTools: (enabled: boolean) => mockMcpTools(enabled),
useInvalidToolsByType: (providerType?: string) => mockInvalidToolsByType(providerType),
}))
vi.mock('@/service/use-triggers', () => ({
useAllTriggerPlugins: (enabled: boolean) => mockTriggerPlugins(enabled),
useInvalidateAllTriggerPlugins: () => mockInvalidateTriggers,
}))
vi.mock('@/service/use-pipeline', () => ({
useInvalidDataSourceList: () => mockInvalidDataSourceList,
}))
const makeToolNode = (overrides: Partial<CommonNodeType> = {}) => ({
type: BlockEnum.Tool,
title: 'Tool node',
desc: '',
provider_type: CollectionType.builtIn,
provider_id: 'search',
provider_name: 'search',
plugin_id: 'plugin-search',
plugin_unique_identifier: 'plugin-search@1.0.0',
...overrides,
}) as CommonNodeType
const makeTriggerNode = (overrides: Partial<CommonNodeType> = {}) => ({
type: BlockEnum.TriggerPlugin,
title: 'Trigger node',
desc: '',
provider_id: 'trigger-provider',
provider_name: 'trigger-provider',
plugin_id: 'trigger-plugin',
plugin_unique_identifier: 'trigger-plugin@1.0.0',
...overrides,
}) as CommonNodeType
const makeDataSourceNode = (overrides: Partial<CommonNodeType> = {}) => ({
type: BlockEnum.DataSource,
title: 'Data source node',
desc: '',
provider_name: 'knowledge-provider',
plugin_id: 'knowledge-plugin',
plugin_unique_identifier: 'knowledge-plugin@1.0.0',
...overrides,
}) as CommonNodeType
const matchedTool = {
plugin_id: 'plugin-search',
provider: 'search',
name: 'search',
plugin_unique_identifier: 'plugin-search@1.0.0',
}
const matchedTriggerProvider = {
id: 'trigger-provider',
name: 'trigger-provider',
plugin_id: 'trigger-plugin',
}
const matchedDataSource = {
provider: 'knowledge-provider',
plugin_id: 'knowledge-plugin',
plugin_unique_identifier: 'knowledge-plugin@1.0.0',
}
describe('useNodePluginInstallation', () => {
beforeEach(() => {
vi.clearAllMocks()
mockBuiltInTools.mockReturnValue({ data: undefined, isLoading: false })
mockCustomTools.mockReturnValue({ data: undefined, isLoading: false })
mockWorkflowTools.mockReturnValue({ data: undefined, isLoading: false })
mockMcpTools.mockReturnValue({ data: undefined, isLoading: false })
mockInvalidToolsByType.mockReturnValue(undefined)
mockTriggerPlugins.mockReturnValue({ data: undefined, isLoading: false })
mockInvalidateTriggers.mockReset()
mockInvalidDataSourceList.mockReset()
})
it('should return the noop installation state for non plugin-dependent nodes', () => {
const { result } = renderWorkflowHook(() =>
useNodePluginInstallation({
type: BlockEnum.LLM,
title: 'LLM',
desc: '',
} as CommonNodeType),
)
expect(result.current).toEqual({
isChecking: false,
isMissing: false,
uniqueIdentifier: undefined,
canInstall: false,
onInstallSuccess: expect.any(Function),
shouldDim: false,
})
})
it('should report loading and invalidate built-in tools while the collection is resolving', () => {
const invalidateTools = vi.fn()
mockBuiltInTools.mockReturnValue({ data: undefined, isLoading: true })
mockInvalidToolsByType.mockReturnValue(invalidateTools)
const { result } = renderWorkflowHook(() => useNodePluginInstallation(makeToolNode()))
expect(mockBuiltInTools).toHaveBeenCalledWith(true)
expect(result.current.isChecking).toBe(true)
expect(result.current.isMissing).toBe(false)
expect(result.current.uniqueIdentifier).toBe('plugin-search@1.0.0')
expect(result.current.canInstall).toBe(true)
expect(result.current.shouldDim).toBe(true)
act(() => {
result.current.onInstallSuccess()
})
expect(invalidateTools).toHaveBeenCalled()
})
it.each([
[CollectionType.custom, mockCustomTools],
[CollectionType.workflow, mockWorkflowTools],
[CollectionType.mcp, mockMcpTools],
])('should resolve matched %s tool collections without dimming', (providerType, hookMock) => {
hookMock.mockReturnValue({ data: [matchedTool], isLoading: false })
const { result } = renderWorkflowHook(() =>
useNodePluginInstallation(makeToolNode({ provider_type: providerType })),
)
expect(result.current.isChecking).toBe(false)
expect(result.current.isMissing).toBe(false)
expect(result.current.shouldDim).toBe(false)
})
it('should keep unknown tool collection types installable without collection state', () => {
const { result } = renderWorkflowHook(() =>
useNodePluginInstallation(makeToolNode({
provider_type: 'unknown' as CollectionType,
plugin_unique_identifier: undefined,
plugin_id: undefined,
provider_id: 'legacy-provider',
})),
)
expect(result.current.isChecking).toBe(false)
expect(result.current.isMissing).toBe(false)
expect(result.current.uniqueIdentifier).toBe('legacy-provider')
expect(result.current.canInstall).toBe(false)
expect(result.current.shouldDim).toBe(false)
})
it('should flag missing trigger plugins and invalidate trigger data after installation', () => {
mockTriggerPlugins.mockReturnValue({ data: [matchedTriggerProvider], isLoading: false })
const { result } = renderWorkflowHook(() =>
useNodePluginInstallation(makeTriggerNode({
provider_id: 'missing-trigger',
provider_name: 'missing-trigger',
plugin_id: 'missing-trigger',
})),
)
expect(mockTriggerPlugins).toHaveBeenCalledWith(true)
expect(result.current.isChecking).toBe(false)
expect(result.current.isMissing).toBe(true)
expect(result.current.shouldDim).toBe(true)
act(() => {
result.current.onInstallSuccess()
})
expect(mockInvalidateTriggers).toHaveBeenCalled()
})
it('should treat the trigger plugin list as still loading when it has not resolved yet', () => {
mockTriggerPlugins.mockReturnValue({ data: undefined, isLoading: true })
const { result } = renderWorkflowHook(() =>
useNodePluginInstallation(makeTriggerNode({ plugin_unique_identifier: undefined, plugin_id: 'trigger-plugin' })),
)
expect(result.current.isChecking).toBe(true)
expect(result.current.isMissing).toBe(false)
expect(result.current.uniqueIdentifier).toBe('trigger-plugin')
expect(result.current.canInstall).toBe(false)
expect(result.current.shouldDim).toBe(true)
})
it('should track missing and matched data source providers based on workflow store state', () => {
const missingRender = renderWorkflowHook(
() => useNodePluginInstallation(makeDataSourceNode({
provider_name: 'missing-provider',
plugin_id: 'missing-plugin',
plugin_unique_identifier: 'missing-plugin@1.0.0',
})),
{
initialStoreState: {
dataSourceList: [matchedDataSource] as never,
},
},
)
expect(missingRender.result.current.isChecking).toBe(false)
expect(missingRender.result.current.isMissing).toBe(true)
expect(missingRender.result.current.shouldDim).toBe(true)
const matchedRender = renderWorkflowHook(
() => useNodePluginInstallation(makeDataSourceNode()),
{
initialStoreState: {
dataSourceList: [matchedDataSource] as never,
},
},
)
expect(matchedRender.result.current.isMissing).toBe(false)
expect(matchedRender.result.current.shouldDim).toBe(false)
act(() => {
matchedRender.result.current.onInstallSuccess()
})
expect(mockInvalidDataSourceList).toHaveBeenCalled()
})
it('should keep data sources in checking state before the list is loaded', () => {
const { result } = renderWorkflowHook(() => useNodePluginInstallation(makeDataSourceNode()))
expect(result.current.isChecking).toBe(true)
expect(result.current.isMissing).toBe(false)
expect(result.current.shouldDim).toBe(true)
})
})

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