Compare commits

..

57 Commits

Author SHA1 Message Date
93f9004898 Merge branch 'main' into deploy/dev 2026-04-08 23:37:39 +08:00
56fd708cf6 update 2026-04-08 23:35:13 +08:00
6234776ae3 Revert "try pass nonce to next theme"
This reverts commit 731adab593.
2026-04-08 23:34:46 +08:00
731adab593 try pass nonce to next theme 2026-04-08 22:25:32 +08:00
ccfc8c6f15 chore: align prompt editor var checks with use-check-list checks (#34715)
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
2026-04-08 13:29:07 +00:00
4fb3fab82d fix: add backward-compatible query param for decode_plugin_from_ident… (#34720) 2026-04-08 13:28:37 +00:00
3cea0dfb07 fix: fix import error (#34728) 2026-04-08 13:27:53 +00:00
0d6db3a3f3 chore(i18n): sync translations with en-US (#34745)
Co-authored-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com>
2026-04-08 12:10:37 +00:00
d06ce2ef78 revert 2026-04-08 19:51:56 +08:00
3d5a81bd30 chore(i18n): sync translations with en-US (#34742)
Co-authored-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com>
2026-04-08 11:30:47 +00:00
abcf4a5730 try disable csp 2026-04-08 19:06:15 +08:00
5b3616aa33 Revert "try disable csp for test"
This reverts commit 19ab594c72.
2026-04-08 19:05:33 +08:00
yyh
208604a3a8 fix(ci): repair i18n bridge and translation workflow (#34738) 2026-04-08 11:05:13 +00:00
19ab594c72 try disable csp for test 2026-04-08 18:55:05 +08:00
b64e930771 Merge branch 'main' into deploy/dev 2026-04-08 18:50:29 +08:00
63bfba0bdb fix: update how ky handle error (#34735) 2026-04-08 10:38:33 +00:00
9948a51b14 test: add unit tests for access control components to enhance coverage and reliability (#34722)
Co-authored-by: CodingOnStar <hanxujiang@dify.com>
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
2026-04-08 08:50:57 +00:00
0e0bb3582f feat(web): add ALLOW_INLINE_STYLES env var to opt-in inline CSS in Markdown rendering (#34719)
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-08 08:38:24 +00:00
40bca2ad9c Merge branch 'main' into deploy/dev 2026-04-08 16:08:54 +08:00
546062d2cd chore: remove raw vite deps (#34726) 2026-04-08 07:49:53 +00:00
aad0b3c157 build: include vinext in docker build (#34535)
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
Co-authored-by: yyh <92089059+lyzno1@users.noreply.github.com>
Co-authored-by: yyh <yuanyouhuilyz@gmail.com>
2026-04-08 07:26:39 +00:00
ef7dc9eabb Merge branch 'feat/new-biliing-quota' into deploy/dev 2026-04-08 15:02:50 +08:00
ae01a5d137 fix: unit test mock 2026-04-08 14:42:52 +08:00
ad6670ebcc fix: correct quota info response 2026-04-08 14:23:57 +08:00
8ca0917044 Merge branch 'main' into feat/new-biliing-quota 2026-04-08 13:39:24 +08:00
b2861e019b fix: merge error 2026-04-02 18:16:31 +08:00
cad9936c0a Merge branch 'fix/ps-not-send' into deploy/dev 2026-04-02 17:55:04 +08:00
8c0b596ced Merge branch 'chore-debug-partnerstack' into deploy/dev 2026-04-02 17:54:06 +08:00
65e434cf06 chore: add debug 2026-04-02 17:53:52 +08:00
12a0f85b72 feat: clear api 2026-04-02 17:52:55 +08:00
1fdb653875 feat: debug partnerstack 2026-04-02 17:18:25 +08:00
4ba8c71962 feat: debug partnerstack 2026-04-02 17:17:40 +08:00
1f1c74099f Merge branch 'fix/ps-not-send' into deploy/dev 2026-04-02 12:53:28 +08:00
359007848d chore: remove save binded cookie 2026-04-02 12:53:07 +08:00
43fedac47b Merge branch 'fix/ps-not-send' into deploy/dev 2026-04-02 11:23:20 +08:00
20ddc9c48a fix: url query change record cookie 2026-04-02 11:22:46 +08:00
a91c1a2af0 Merge branch 'refactor-enhance-billing-info-guard' into deploy/dev 2026-04-02 11:02:00 +08:00
b3870524d4 fix usage get 2026-04-02 09:52:52 +08:00
919c080452 chore: update comments 2026-04-01 10:35:34 +08:00
4653ed7ead refactor: enhance billing info response handling 2026-03-31 18:23:32 +08:00
c543188434 fix linter 2026-03-31 15:22:51 +08:00
f319a9e42f fix test case 2026-03-31 15:22:43 +08:00
58241a89a5 fix linter 2026-03-31 14:59:54 +08:00
422bf3506e rebuild quota service 2026-03-31 14:59:45 +08:00
6e745f9e9b fix linter 2026-03-31 09:49:24 +08:00
4e50d55339 fix comment 2026-03-31 09:49:09 +08:00
b95cdabe26 [autofix.ci] apply automated fixes 2026-03-30 08:45:37 +00:00
daa47c25bb Merge branch 'feat/new-biliing-quota' of github.com:langgenius/dify into feat/new-biliing-quota 2026-03-30 16:43:13 +08:00
f1bcd6d715 add test case for quota and billing service 2026-03-30 16:41:56 +08:00
8643ff43f5 Merge branch 'main' into feat/new-biliing-quota 2026-03-30 15:57:49 +08:00
c5f30a47f0 Merge remote-tracking branch 'origin/main' into feat/new-biliing-quota 2026-03-30 15:26:38 +08:00
37d438fa19 Merge remote-tracking branch 'origin/main' into feat/new-biliing-quota 2026-03-27 16:26:09 +08:00
9503803997 Merge remote-tracking branch 'origin/main' into feat/new-biliing-quota 2026-03-23 09:27:39 +08:00
d6476f5434 Merge remote-tracking branch 'origin/main' into feat/new-biliing-quota 2026-03-20 15:17:27 +08:00
80b4633e8f fix style check and test 2026-03-20 14:58:31 +08:00
3888969af3 [autofix.ci] apply automated fixes 2026-03-20 05:45:30 +00:00
658ac15589 use new quota system 2026-03-20 13:29:22 +08:00
223 changed files with 10535 additions and 1241 deletions

View File

@ -0,0 +1,82 @@
import { execFileSync } from 'node:child_process'
import fs from 'node:fs'
import path from 'node:path'
const repoRoot = process.cwd()
const baseSha = process.env.BASE_SHA || ''
const headSha = process.env.HEAD_SHA || ''
const files = (process.env.CHANGED_FILES || '').split(/\s+/).filter(Boolean)
const outputPath = process.env.I18N_CHANGES_OUTPUT_PATH || '/tmp/i18n-changes.json'
const englishPath = fileStem => path.join(repoRoot, 'web', 'i18n', 'en-US', `${fileStem}.json`)
const readCurrentJson = (fileStem) => {
const filePath = englishPath(fileStem)
if (!fs.existsSync(filePath))
return null
return JSON.parse(fs.readFileSync(filePath, 'utf8'))
}
const readBaseJson = (fileStem) => {
if (!baseSha)
return null
try {
const relativePath = `web/i18n/en-US/${fileStem}.json`
const content = execFileSync('git', ['show', `${baseSha}:${relativePath}`], { encoding: 'utf8' })
return JSON.parse(content)
}
catch {
return null
}
}
const compareJson = (beforeValue, afterValue) => JSON.stringify(beforeValue) === JSON.stringify(afterValue)
const changes = {}
for (const fileStem of files) {
const currentJson = readCurrentJson(fileStem)
const beforeJson = readBaseJson(fileStem) || {}
const afterJson = currentJson || {}
const added = {}
const updated = {}
const deleted = []
for (const [key, value] of Object.entries(afterJson)) {
if (!(key in beforeJson)) {
added[key] = value
continue
}
if (!compareJson(beforeJson[key], value)) {
updated[key] = {
before: beforeJson[key],
after: value,
}
}
}
for (const key of Object.keys(beforeJson)) {
if (!(key in afterJson))
deleted.push(key)
}
changes[fileStem] = {
fileDeleted: currentJson === null,
added,
updated,
deleted,
}
}
fs.writeFileSync(
outputPath,
JSON.stringify({
baseSha,
headSha,
files,
changes,
})
)

View File

@ -68,89 +68,7 @@ jobs:
" web/i18n-config/languages.ts | sed 's/[[:space:]]*$//')
generate_changes_json() {
node <<'NODE'
const { execFileSync } = require('node:child_process')
const fs = require('node:fs')
const path = require('node:path')
const repoRoot = process.cwd()
const baseSha = process.env.BASE_SHA || ''
const headSha = process.env.HEAD_SHA || ''
const files = (process.env.CHANGED_FILES || '').split(/\s+/).filter(Boolean)
const englishPath = fileStem => path.join(repoRoot, 'web', 'i18n', 'en-US', `${fileStem}.json`)
const readCurrentJson = (fileStem) => {
const filePath = englishPath(fileStem)
if (!fs.existsSync(filePath))
return null
return JSON.parse(fs.readFileSync(filePath, 'utf8'))
}
const readBaseJson = (fileStem) => {
if (!baseSha)
return null
try {
const relativePath = `web/i18n/en-US/${fileStem}.json`
const content = execFileSync('git', ['show', `${baseSha}:${relativePath}`], { encoding: 'utf8' })
return JSON.parse(content)
}
catch (error) {
return null
}
}
const compareJson = (beforeValue, afterValue) => JSON.stringify(beforeValue) === JSON.stringify(afterValue)
const changes = {}
for (const fileStem of files) {
const currentJson = readCurrentJson(fileStem)
const beforeJson = readBaseJson(fileStem) || {}
const afterJson = currentJson || {}
const added = {}
const updated = {}
const deleted = []
for (const [key, value] of Object.entries(afterJson)) {
if (!(key in beforeJson)) {
added[key] = value
continue
}
if (!compareJson(beforeJson[key], value)) {
updated[key] = {
before: beforeJson[key],
after: value,
}
}
}
for (const key of Object.keys(beforeJson)) {
if (!(key in afterJson))
deleted.push(key)
}
changes[fileStem] = {
fileDeleted: currentJson === null,
added,
updated,
deleted,
}
}
fs.writeFileSync(
'/tmp/i18n-changes.json',
JSON.stringify({
baseSha,
headSha,
files,
changes,
})
)
NODE
node .github/scripts/generate-i18n-changes.mjs
}
if [ "${{ github.event_name }}" = "repository_dispatch" ]; then
@ -270,7 +188,7 @@ jobs:
Tool rules:
- Use Read for repository files.
- Use Edit for JSON updates.
- Use Bash only for `pnpm`.
- Use Bash only for `vp`.
- Do not use Bash for `git`, `gh`, or branch management.
Required execution plan:
@ -292,7 +210,7 @@ jobs:
- Read the current English JSON file for any file that still exists so wording, placeholders, and surrounding terminology stay accurate.
- If `Structured change set available` is `false`, treat this as a scoped full sync and use the current English files plus scoped checks as the source of truth.
4. Run a scoped pre-check before editing:
- `pnpm --dir ${{ github.workspace }}/web run i18n:check ${{ steps.context.outputs.FILE_ARGS }} ${{ steps.context.outputs.LANG_ARGS }}`
- `vp run dify-web#i18n:check ${{ steps.context.outputs.FILE_ARGS }} ${{ steps.context.outputs.LANG_ARGS }}`
- Use this command as the source of truth for missing and extra keys inside the current scope.
5. Apply translations.
- For every target language and scoped file:
@ -300,19 +218,19 @@ jobs:
- If the locale file does not exist yet, create it with `Write` and then continue with `Edit` as needed.
- ADD missing keys.
- UPDATE stale translations when the English value changed.
- DELETE removed keys. Prefer `pnpm --dir ${{ github.workspace }}/web run i18n:check ${{ steps.context.outputs.FILE_ARGS }} ${{ steps.context.outputs.LANG_ARGS }} --auto-remove` for extra keys so deletions stay in scope.
- DELETE removed keys. Prefer `vp run dify-web#i18n:check ${{ steps.context.outputs.FILE_ARGS }} ${{ steps.context.outputs.LANG_ARGS }} --auto-remove` for extra keys so deletions stay in scope.
- Preserve placeholders exactly: `{{variable}}`, `${variable}`, HTML tags, component tags, and variable names.
- Match the existing terminology and register used by each locale.
- Prefer one Edit per file when stable, but prioritize correctness over batching.
6. Verify only the edited files.
- Run `pnpm --dir ${{ github.workspace }}/web lint:fix --quiet -- <relative edited i18n file paths>`
- Run `pnpm --dir ${{ github.workspace }}/web run i18n:check ${{ steps.context.outputs.FILE_ARGS }} ${{ steps.context.outputs.LANG_ARGS }}`
- Run `vp run dify-web#lint:fix --quiet -- <relative edited i18n file paths under web/>`
- Run `vp run dify-web#i18n:check ${{ steps.context.outputs.FILE_ARGS }} ${{ steps.context.outputs.LANG_ARGS }}`
- If verification fails, fix the remaining problems before continuing.
7. Stop after the scoped locale files are updated and verification passes.
- Do not create branches, commits, or pull requests.
claude_args: |
--max-turns 120
--allowedTools "Read,Write,Edit,Bash(pnpm *),Bash(pnpm:*),Glob,Grep"
--allowedTools "Read,Write,Edit,Bash(vp *),Bash(vp:*),Glob,Grep"
- name: Prepare branch metadata
id: pr_meta
@ -354,6 +272,7 @@ jobs:
- name: Create or update translation PR
if: steps.pr_meta.outputs.has_changes == 'true'
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
BRANCH_NAME: ${{ steps.pr_meta.outputs.branch_name }}
FILES_IN_SCOPE: ${{ steps.context.outputs.CHANGED_FILES }}
TARGET_LANGS: ${{ steps.context.outputs.TARGET_LANGS }}
@ -402,8 +321,8 @@ jobs:
'',
'## Verification',
'',
`- \`pnpm --dir web run i18n:check --file ${process.env.FILES_IN_SCOPE} --lang ${process.env.TARGET_LANGS}\``,
`- \`pnpm --dir web lint:fix --quiet -- <edited i18n files>\``,
`- \`vp run dify-web#i18n:check --file ${process.env.FILES_IN_SCOPE} --lang ${process.env.TARGET_LANGS}\``,
`- \`vp run dify-web#lint:fix --quiet -- <edited i18n files under web/>\``,
'',
'## Notes',
'',

View File

@ -42,88 +42,7 @@ jobs:
fi
export BASE_SHA HEAD_SHA CHANGED_FILES
node <<'NODE'
const { execFileSync } = require('node:child_process')
const fs = require('node:fs')
const path = require('node:path')
const repoRoot = process.cwd()
const baseSha = process.env.BASE_SHA || ''
const headSha = process.env.HEAD_SHA || ''
const files = (process.env.CHANGED_FILES || '').split(/\s+/).filter(Boolean)
const englishPath = fileStem => path.join(repoRoot, 'web', 'i18n', 'en-US', `${fileStem}.json`)
const readCurrentJson = (fileStem) => {
const filePath = englishPath(fileStem)
if (!fs.existsSync(filePath))
return null
return JSON.parse(fs.readFileSync(filePath, 'utf8'))
}
const readBaseJson = (fileStem) => {
if (!baseSha)
return null
try {
const relativePath = `web/i18n/en-US/${fileStem}.json`
const content = execFileSync('git', ['show', `${baseSha}:${relativePath}`], { encoding: 'utf8' })
return JSON.parse(content)
}
catch (error) {
return null
}
}
const compareJson = (beforeValue, afterValue) => JSON.stringify(beforeValue) === JSON.stringify(afterValue)
const changes = {}
for (const fileStem of files) {
const beforeJson = readBaseJson(fileStem) || {}
const afterJson = readCurrentJson(fileStem) || {}
const added = {}
const updated = {}
const deleted = []
for (const [key, value] of Object.entries(afterJson)) {
if (!(key in beforeJson)) {
added[key] = value
continue
}
if (!compareJson(beforeJson[key], value)) {
updated[key] = {
before: beforeJson[key],
after: value,
}
}
}
for (const key of Object.keys(beforeJson)) {
if (!(key in afterJson))
deleted.push(key)
}
changes[fileStem] = {
fileDeleted: readCurrentJson(fileStem) === null,
added,
updated,
deleted,
}
}
fs.writeFileSync(
'/tmp/i18n-changes.json',
JSON.stringify({
baseSha,
headSha,
files,
changes,
})
)
NODE
node .github/scripts/generate-i18n-changes.mjs
if [ -n "$CHANGED_FILES" ]; then
echo "has_changes=true" >> "$GITHUB_OUTPUT"

View File

@ -81,8 +81,8 @@ if $web_modified; then
if $web_ts_modified; then
echo "Running TypeScript type-check:tsgo"
if ! pnpm run type-check:tsgo; then
echo "Type check failed. Please run 'pnpm run type-check:tsgo' to fix the errors."
if ! npm run type-check:tsgo; then
echo "Type check failed. Please run 'npm run type-check:tsgo' to fix the errors."
exit 1
fi
else
@ -90,8 +90,8 @@ if $web_modified; then
fi
echo "Running knip"
if ! pnpm run knip; then
echo "Knip check failed. Please run 'pnpm run knip' to fix the errors."
if ! npm run knip; then
echo "Knip check failed. Please run 'npm run knip' to fix the errors."
exit 1
fi

View File

@ -1,4 +1,6 @@
import base64
import json
from datetime import UTC, datetime, timedelta
from typing import Literal
from flask import request
@ -9,6 +11,7 @@ from werkzeug.exceptions import BadRequest
from controllers.console import console_ns
from controllers.console.wraps import account_initialization_required, only_edition_cloud, setup_required
from enums.cloud_plan import CloudPlan
from extensions.ext_redis import redis_client
from libs.login import current_account_with_tenant, login_required
from services.billing_service import BillingService
@ -84,3 +87,39 @@ class PartnerTenants(Resource):
raise BadRequest("Invalid partner information")
return BillingService.sync_partner_tenants_bindings(current_user.id, decoded_partner_key, click_id)
_DEBUG_KEY = "billing:debug"
_DEBUG_TTL = timedelta(days=7)
class DebugDataPayload(BaseModel):
type: str = Field(..., min_length=1, description="Data type key")
data: str = Field(..., min_length=1, description="Data value to append")
@console_ns.route("/billing/debug/data")
class DebugData(Resource):
def post(self):
body = DebugDataPayload.model_validate(request.get_json(force=True))
item = json.dumps({
"type": body.type,
"data": body.data,
"createTime": datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%SZ"),
})
redis_client.lpush(_DEBUG_KEY, item)
redis_client.expire(_DEBUG_KEY, _DEBUG_TTL)
return {"result": "ok"}, 201
def get(self):
recent = request.args.get("recent", 10, type=int)
items = redis_client.lrange(_DEBUG_KEY, 0, recent - 1)
return {
"data": [
json.loads(item.decode("utf-8") if isinstance(item, bytes) else item) for item in items
]
}
def delete(self):
redis_client.delete(_DEBUG_KEY)
return {"result": "ok"}

View File

@ -209,7 +209,10 @@ class PluginInstaller(BasePluginClient):
"GET",
f"plugin/{tenant_id}/management/decode/from_identifier",
PluginDecodeResponse,
params={"plugin_unique_identifier": plugin_unique_identifier},
params={
"plugin_unique_identifier": plugin_unique_identifier,
"PluginUniqueIdentifier": plugin_unique_identifier, # compat with daemon <= 0.5.4
},
)
def fetch_plugin_installation_by_ids(

View File

@ -4,7 +4,7 @@ from graphon.entities.base_node_data import BaseNodeData
from graphon.enums import NodeType
from pydantic import BaseModel
from core.rag.entities import WeightedScoreConfig
from core.rag.entities.retrieval_settings import WeightedScoreConfig
from core.rag.index_processor.index_processor_base import SummaryIndexSettingDict
from core.rag.retrieval.retrieval_methods import RetrievalMethod
from core.workflow.nodes.knowledge_index import KNOWLEDGE_INDEX_NODE_TYPE

View File

@ -1,56 +1,17 @@
import logging
from dataclasses import dataclass
from enum import StrEnum, auto
logger = logging.getLogger(__name__)
@dataclass
class QuotaCharge:
"""
Result of a quota consumption operation.
Attributes:
success: Whether the quota charge succeeded
charge_id: UUID for refund, or None if failed/disabled
"""
success: bool
charge_id: str | None
_quota_type: "QuotaType"
def refund(self) -> None:
"""
Refund this quota charge.
Safe to call even if charge failed or was disabled.
This method guarantees no exceptions will be raised.
"""
if self.charge_id:
self._quota_type.refund(self.charge_id)
logger.info("Refunded quota for %s with charge_id: %s", self._quota_type.value, self.charge_id)
class QuotaType(StrEnum):
"""
Supported quota types for tenant feature usage.
Add additional types here whenever new billable features become available.
"""
# Trigger execution quota
TRIGGER = auto()
# Workflow execution quota
WORKFLOW = auto()
UNLIMITED = auto()
@property
def billing_key(self) -> str:
"""
Get the billing key for the feature.
"""
match self:
case QuotaType.TRIGGER:
return "trigger_event"
@ -58,152 +19,3 @@ class QuotaType(StrEnum):
return "api_rate_limit"
case _:
raise ValueError(f"Invalid quota type: {self}")
def consume(self, tenant_id: str, amount: int = 1) -> QuotaCharge:
"""
Consume quota for the feature.
Args:
tenant_id: The tenant identifier
amount: Amount to consume (default: 1)
Returns:
QuotaCharge with success status and charge_id for refund
Raises:
QuotaExceededError: When quota is insufficient
"""
from configs import dify_config
from services.billing_service import BillingService
from services.errors.app import QuotaExceededError
if not dify_config.BILLING_ENABLED:
logger.debug("Billing disabled, allowing request for %s", tenant_id)
return QuotaCharge(success=True, charge_id=None, _quota_type=self)
logger.info("Consuming %d %s quota for tenant %s", amount, self.value, tenant_id)
if amount <= 0:
raise ValueError("Amount to consume must be greater than 0")
try:
response = BillingService.update_tenant_feature_plan_usage(tenant_id, self.billing_key, delta=amount)
if response.get("result") != "success":
logger.warning(
"Failed to consume quota for %s, feature %s details: %s",
tenant_id,
self.value,
response.get("detail"),
)
raise QuotaExceededError(feature=self.value, tenant_id=tenant_id, required=amount)
charge_id = response.get("history_id")
logger.debug(
"Successfully consumed %d %s quota for tenant %s, charge_id: %s",
amount,
self.value,
tenant_id,
charge_id,
)
return QuotaCharge(success=True, charge_id=charge_id, _quota_type=self)
except QuotaExceededError:
raise
except Exception:
# fail-safe: allow request on billing errors
logger.exception("Failed to consume quota for %s, feature %s", tenant_id, self.value)
return unlimited()
def check(self, tenant_id: str, amount: int = 1) -> bool:
"""
Check if tenant has sufficient quota without consuming.
Args:
tenant_id: The tenant identifier
amount: Amount to check (default: 1)
Returns:
True if quota is sufficient, False otherwise
"""
from configs import dify_config
if not dify_config.BILLING_ENABLED:
return True
if amount <= 0:
raise ValueError("Amount to check must be greater than 0")
try:
remaining = self.get_remaining(tenant_id)
return remaining >= amount if remaining != -1 else True
except Exception:
logger.exception("Failed to check quota for %s, feature %s", tenant_id, self.value)
# fail-safe: allow request on billing errors
return True
def refund(self, charge_id: str) -> None:
"""
Refund quota using charge_id from consume().
This method guarantees no exceptions will be raised.
All errors are logged but silently handled.
Args:
charge_id: The UUID returned from consume()
"""
try:
from configs import dify_config
from services.billing_service import BillingService
if not dify_config.BILLING_ENABLED:
return
if not charge_id:
logger.warning("Cannot refund: charge_id is empty")
return
logger.info("Refunding %s quota with charge_id: %s", self.value, charge_id)
response = BillingService.refund_tenant_feature_plan_usage(charge_id)
if response.get("result") == "success":
logger.debug("Successfully refunded %s quota, charge_id: %s", self.value, charge_id)
else:
logger.warning("Refund failed for charge_id: %s", charge_id)
except Exception:
# Catch ALL exceptions - refund must never fail
logger.exception("Failed to refund quota for charge_id: %s", charge_id)
# Don't raise - refund is best-effort and must be silent
def get_remaining(self, tenant_id: str) -> int:
"""
Get remaining quota for the tenant.
Args:
tenant_id: The tenant identifier
Returns:
Remaining quota amount
"""
from services.billing_service import BillingService
try:
usage_info = BillingService.get_tenant_feature_plan_usage(tenant_id, self.billing_key)
# Assuming the API returns a dict with 'remaining' or 'limit' and 'used'
if isinstance(usage_info, dict):
return usage_info.get("remaining", 0)
# If it returns a simple number, treat it as remaining
return int(usage_info) if usage_info else 0
except Exception:
logger.exception("Failed to get remaining quota for %s, feature %s", tenant_id, self.value)
return -1
def unlimited() -> QuotaCharge:
"""
Return a quota charge for unlimited quota.
This is useful for features that are not subject to quota limits, such as the UNLIMITED quota type.
"""
return QuotaCharge(success=True, charge_id=None, _quota_type=QuotaType.UNLIMITED)

View File

@ -18,12 +18,13 @@ from core.app.features.rate_limiting import RateLimit
from core.app.features.rate_limiting.rate_limit import rate_limit_context
from core.app.layers.pause_state_persist_layer import PauseStateLayerConfig
from core.db import session_factory
from enums.quota_type import QuotaType, unlimited
from enums.quota_type import QuotaType
from extensions.otel import AppGenerateHandler, trace_span
from models.model import Account, App, AppMode, EndUser
from models.workflow import Workflow, WorkflowRun
from services.errors.app import QuotaExceededError, WorkflowIdFormatError, WorkflowNotFoundError
from services.errors.llm import InvokeRateLimitError
from services.quota_service import QuotaService, unlimited
from services.workflow_service import WorkflowService
from tasks.app_generate.workflow_execute_task import AppExecutionParams, workflow_based_app_execution_task
@ -106,7 +107,7 @@ class AppGenerateService:
quota_charge = unlimited()
if dify_config.BILLING_ENABLED:
try:
quota_charge = QuotaType.WORKFLOW.consume(app_model.tenant_id)
quota_charge = QuotaService.reserve(QuotaType.WORKFLOW, app_model.tenant_id)
except QuotaExceededError:
raise InvokeRateLimitError(f"Workflow execution quota limit reached for tenant {app_model.tenant_id}")
@ -116,6 +117,7 @@ class AppGenerateService:
request_id = RateLimit.gen_request_key()
try:
request_id = rate_limit.enter(request_id)
quota_charge.commit()
effective_mode = (
AppMode.AGENT_CHAT if app_model.is_agent and app_model.mode != AppMode.AGENT_CHAT else app_model.mode
)

View File

@ -22,6 +22,7 @@ from models.trigger import WorkflowTriggerLog, WorkflowTriggerLogDict
from models.workflow import Workflow
from repositories.sqlalchemy_workflow_trigger_log_repository import SQLAlchemyWorkflowTriggerLogRepository
from services.errors.app import QuotaExceededError, WorkflowNotFoundError, WorkflowQuotaLimitError
from services.quota_service import QuotaService, unlimited
from services.workflow.entities import AsyncTriggerResponse, TriggerData, WorkflowTaskData
from services.workflow.queue_dispatcher import QueueDispatcherManager, QueuePriority
from services.workflow_service import WorkflowService
@ -131,9 +132,10 @@ class AsyncWorkflowService:
trigger_log = trigger_log_repo.create(trigger_log)
session.commit()
# 7. Check and consume quota
# 7. Reserve quota (commit after successful dispatch)
quota_charge = unlimited()
try:
QuotaType.WORKFLOW.consume(trigger_data.tenant_id)
quota_charge = QuotaService.reserve(QuotaType.WORKFLOW, trigger_data.tenant_id)
except QuotaExceededError as e:
# Update trigger log status
trigger_log.status = WorkflowTriggerStatus.RATE_LIMITED
@ -153,13 +155,18 @@ class AsyncWorkflowService:
# 9. Dispatch to appropriate queue
task_data_dict = task_data.model_dump(mode="json")
task: AsyncResult[Any] | None = None
if queue_name == QueuePriority.PROFESSIONAL:
task = execute_workflow_professional.delay(task_data_dict)
elif queue_name == QueuePriority.TEAM:
task = execute_workflow_team.delay(task_data_dict)
else: # SANDBOX
task = execute_workflow_sandbox.delay(task_data_dict)
try:
task: AsyncResult[Any] | None = None
if queue_name == QueuePriority.PROFESSIONAL:
task = execute_workflow_professional.delay(task_data_dict)
elif queue_name == QueuePriority.TEAM:
task = execute_workflow_team.delay(task_data_dict)
else: # SANDBOX
task = execute_workflow_sandbox.delay(task_data_dict)
quota_charge.commit()
except Exception:
quota_charge.refund()
raise
# 10. Update trigger log with task info
trigger_log.status = WorkflowTriggerStatus.QUEUED

View File

@ -32,6 +32,102 @@ class SubscriptionPlan(TypedDict):
expiration_date: int
class QuotaReserveResult(TypedDict):
reservation_id: str
available: int
reserved: int
class QuotaCommitResult(TypedDict):
available: int
reserved: int
refunded: int
class QuotaReleaseResult(TypedDict):
available: int
reserved: int
released: int
_quota_reserve_adapter = TypeAdapter(QuotaReserveResult)
_quota_commit_adapter = TypeAdapter(QuotaCommitResult)
_quota_release_adapter = TypeAdapter(QuotaReleaseResult)
class _BillingQuota(TypedDict):
size: int
limit: int
class _VectorSpaceQuota(TypedDict):
size: float
limit: int
class _KnowledgeRateLimit(TypedDict):
# NOTE (hj24):
# 1. Return for sandbox users but is null for other plans, it's defined but never used.
# 2. Keep it for compatibility for now, can be deprecated in future versions.
size: NotRequired[int]
# NOTE END
limit: int
class _BillingSubscription(TypedDict):
plan: str
interval: str
education: bool
class BillingInfo(TypedDict):
"""Response of /subscription/info.
NOTE (hj24):
- Fields not listed here (e.g. trigger_event, api_rate_limit) are stripped by TypeAdapter.validate_python()
- To ensure the precision, billing may convert fields like int as str, be careful when use TypeAdapter:
1. validate_python in non-strict mode will coerce it to the expected type
2. In strict mode, it will raise ValidationError
3. To preserve compatibility, always keep non-strict mode here and avoid strict mode
"""
enabled: bool
subscription: _BillingSubscription
members: _BillingQuota
apps: _BillingQuota
vector_space: _VectorSpaceQuota
knowledge_rate_limit: _KnowledgeRateLimit
documents_upload_quota: _BillingQuota
annotation_quota_limit: _BillingQuota
docs_processing: str
can_replace_logo: bool
model_load_balancing_enabled: bool
knowledge_pipeline_publish_enabled: bool
next_credit_reset_date: NotRequired[int]
_billing_info_adapter = TypeAdapter(BillingInfo)
class _TenantFeatureQuota(TypedDict):
usage: int
limit: int
reset_date: NotRequired[int]
class TenantFeatureQuotaInfo(TypedDict):
"""Response of /quota/info.
NOTE (hj24):
- Same convention as BillingInfo: billing may return int fields as str,
always keep non-strict mode to auto-coerce.
"""
trigger_event: _TenantFeatureQuota
api_rate_limit: _TenantFeatureQuota
_tenant_feature_quota_info_adapter = TypeAdapter(TenantFeatureQuotaInfo)
class _BillingQuota(TypedDict):
size: int
limit: int
@ -149,11 +245,63 @@ class BillingService:
@classmethod
def get_tenant_feature_plan_usage_info(cls, tenant_id: str):
"""Deprecated: Use get_quota_info instead."""
params = {"tenant_id": tenant_id}
usage_info = cls._send_request("GET", "/tenant-feature-usage/info", params=params)
return usage_info
@classmethod
def get_quota_info(cls, tenant_id: str) -> TenantFeatureQuotaInfo:
params = {"tenant_id": tenant_id}
return _tenant_feature_quota_info_adapter.validate_python(
cls._send_request("GET", "/quota/info", params=params)
)
@classmethod
def quota_reserve(
cls, tenant_id: str, feature_key: str, request_id: str, amount: int = 1, meta: dict | None = None
) -> QuotaReserveResult:
"""Reserve quota before task execution."""
payload: dict = {
"tenant_id": tenant_id,
"feature_key": feature_key,
"request_id": request_id,
"amount": amount,
}
if meta:
payload["meta"] = meta
return _quota_reserve_adapter.validate_python(cls._send_request("POST", "/quota/reserve", json=payload))
@classmethod
def quota_commit(
cls, tenant_id: str, feature_key: str, reservation_id: str, actual_amount: int, meta: dict | None = None
) -> QuotaCommitResult:
"""Commit a reservation with actual consumption."""
payload: dict = {
"tenant_id": tenant_id,
"feature_key": feature_key,
"reservation_id": reservation_id,
"actual_amount": actual_amount,
}
if meta:
payload["meta"] = meta
return _quota_commit_adapter.validate_python(cls._send_request("POST", "/quota/commit", json=payload))
@classmethod
def quota_release(cls, tenant_id: str, feature_key: str, reservation_id: str) -> QuotaReleaseResult:
"""Release a reservation (cancel, return frozen quota)."""
return _quota_release_adapter.validate_python(
cls._send_request(
"POST",
"/quota/release",
json={
"tenant_id": tenant_id,
"feature_key": feature_key,
"reservation_id": reservation_id,
},
)
)
@classmethod
def get_knowledge_rate_limit(cls, tenant_id: str) -> KnowledgeRateLimitDict:
params = {"tenant_id": tenant_id}

View File

@ -281,7 +281,7 @@ class FeatureService:
def _fulfill_params_from_billing_api(cls, features: FeatureModel, tenant_id: str):
billing_info = BillingService.get_info(tenant_id)
features_usage_info = BillingService.get_tenant_feature_plan_usage_info(tenant_id)
features_usage_info = BillingService.get_quota_info(tenant_id)
features.billing.enabled = billing_info["enabled"]
features.billing.subscription.plan = billing_info["subscription"]["plan"]

View File

@ -0,0 +1,233 @@
from __future__ import annotations
import logging
import uuid
from dataclasses import dataclass, field
from typing import TYPE_CHECKING
from configs import dify_config
if TYPE_CHECKING:
from enums.quota_type import QuotaType
logger = logging.getLogger(__name__)
@dataclass
class QuotaCharge:
"""
Result of a quota reservation (Reserve phase).
Lifecycle:
charge = QuotaService.consume(QuotaType.TRIGGER, tenant_id)
try:
do_work()
charge.commit() # Confirm consumption
except:
charge.refund() # Release frozen quota
If neither commit() nor refund() is called, the billing system's
cleanup CronJob will auto-release the reservation within ~75 seconds.
"""
success: bool
charge_id: str | None # reservation_id
_quota_type: QuotaType
_tenant_id: str | None = None
_feature_key: str | None = None
_amount: int = 0
_committed: bool = field(default=False, repr=False)
def commit(self, actual_amount: int | None = None) -> None:
"""
Confirm the consumption with actual amount.
Args:
actual_amount: Actual amount consumed. Defaults to the reserved amount.
If less than reserved, the difference is refunded automatically.
"""
if self._committed or not self.charge_id or not self._tenant_id or not self._feature_key:
return
try:
from services.billing_service import BillingService
amount = actual_amount if actual_amount is not None else self._amount
BillingService.quota_commit(
tenant_id=self._tenant_id,
feature_key=self._feature_key,
reservation_id=self.charge_id,
actual_amount=amount,
)
self._committed = True
logger.debug(
"Committed %s quota for tenant %s, reservation_id: %s, amount: %d",
self._quota_type,
self._tenant_id,
self.charge_id,
amount,
)
except Exception:
logger.exception("Failed to commit quota, reservation_id: %s", self.charge_id)
def refund(self) -> None:
"""
Release the reserved quota (cancel the charge).
Safe to call even if:
- charge failed or was disabled (charge_id is None)
- already committed (Release after Commit is a no-op)
- already refunded (idempotent)
This method guarantees no exceptions will be raised.
"""
if not self.charge_id or not self._tenant_id or not self._feature_key:
return
QuotaService.release(self._quota_type, self.charge_id, self._tenant_id, self._feature_key)
def unlimited() -> QuotaCharge:
from enums.quota_type import QuotaType
return QuotaCharge(success=True, charge_id=None, _quota_type=QuotaType.UNLIMITED)
class QuotaService:
"""Orchestrates quota reserve / commit / release lifecycle via BillingService."""
@staticmethod
def consume(quota_type: QuotaType, tenant_id: str, amount: int = 1) -> QuotaCharge:
"""
Reserve + immediate Commit (one-shot mode).
The returned QuotaCharge supports .refund() which calls Release.
For two-phase usage (e.g. streaming), use reserve() directly.
"""
charge = QuotaService.reserve(quota_type, tenant_id, amount)
if charge.success and charge.charge_id:
charge.commit()
return charge
@staticmethod
def reserve(quota_type: QuotaType, tenant_id: str, amount: int = 1) -> QuotaCharge:
"""
Reserve quota before task execution (Reserve phase only).
The caller MUST call charge.commit() after the task succeeds,
or charge.refund() if the task fails.
Raises:
QuotaExceededError: When quota is insufficient
"""
from services.billing_service import BillingService
from services.errors.app import QuotaExceededError
if not dify_config.BILLING_ENABLED:
logger.debug("Billing disabled, allowing request for %s", tenant_id)
return QuotaCharge(success=True, charge_id=None, _quota_type=quota_type)
logger.info("Reserving %d %s quota for tenant %s", amount, quota_type.value, tenant_id)
if amount <= 0:
raise ValueError("Amount to reserve must be greater than 0")
request_id = str(uuid.uuid4())
feature_key = quota_type.billing_key
try:
reserve_resp = BillingService.quota_reserve(
tenant_id=tenant_id,
feature_key=feature_key,
request_id=request_id,
amount=amount,
)
reservation_id = reserve_resp.get("reservation_id")
if not reservation_id:
logger.warning(
"Reserve returned no reservation_id for %s, feature %s, response: %s",
tenant_id,
quota_type.value,
reserve_resp,
)
raise QuotaExceededError(feature=quota_type.value, tenant_id=tenant_id, required=amount)
logger.debug(
"Reserved %d %s quota for tenant %s, reservation_id: %s",
amount,
quota_type.value,
tenant_id,
reservation_id,
)
return QuotaCharge(
success=True,
charge_id=reservation_id,
_quota_type=quota_type,
_tenant_id=tenant_id,
_feature_key=feature_key,
_amount=amount,
)
except QuotaExceededError:
raise
except ValueError:
raise
except Exception:
logger.exception("Failed to reserve quota for %s, feature %s", tenant_id, quota_type.value)
return unlimited()
@staticmethod
def check(quota_type: QuotaType, tenant_id: str, amount: int = 1) -> bool:
if not dify_config.BILLING_ENABLED:
return True
if amount <= 0:
raise ValueError("Amount to check must be greater than 0")
try:
remaining = QuotaService.get_remaining(quota_type, tenant_id)
return remaining >= amount if remaining != -1 else True
except Exception:
logger.exception("Failed to check quota for %s, feature %s", tenant_id, quota_type.value)
return True
@staticmethod
def release(quota_type: QuotaType, reservation_id: str, tenant_id: str, feature_key: str) -> None:
"""Release a reservation. Guarantees no exceptions."""
try:
from services.billing_service import BillingService
if not dify_config.BILLING_ENABLED:
return
if not reservation_id:
return
logger.info("Releasing %s quota, reservation_id: %s", quota_type.value, reservation_id)
BillingService.quota_release(
tenant_id=tenant_id,
feature_key=feature_key,
reservation_id=reservation_id,
)
except Exception:
logger.exception("Failed to release quota, reservation_id: %s", reservation_id)
@staticmethod
def get_remaining(quota_type: QuotaType, tenant_id: str) -> int:
from services.billing_service import BillingService
try:
usage_info = BillingService.get_quota_info(tenant_id)
if isinstance(usage_info, dict):
feature_info = usage_info.get(quota_type.billing_key, {})
if isinstance(feature_info, dict):
limit = feature_info.get("limit", 0)
usage = feature_info.get("usage", 0)
if limit == -1:
return -1
return max(0, limit - usage)
return 0
except Exception:
logger.exception("Failed to get remaining quota for %s, feature %s", tenant_id, quota_type.value)
return -1

View File

@ -38,6 +38,7 @@ from models.workflow import Workflow
from services.async_workflow_service import AsyncWorkflowService
from services.end_user_service import EndUserService
from services.errors.app import QuotaExceededError
from services.quota_service import QuotaService
from services.trigger.app_trigger_service import AppTriggerService
from services.workflow.entities import WebhookTriggerData
@ -802,9 +803,9 @@ class WebhookService:
user_id=None,
)
# consume quota before triggering workflow execution
# reserve quota before triggering workflow execution
try:
QuotaType.TRIGGER.consume(webhook_trigger.tenant_id)
quota_charge = QuotaService.reserve(QuotaType.TRIGGER, webhook_trigger.tenant_id)
except QuotaExceededError:
AppTriggerService.mark_tenant_triggers_rate_limited(webhook_trigger.tenant_id)
logger.info(
@ -815,11 +816,16 @@ class WebhookService:
raise
# Trigger workflow execution asynchronously
AsyncWorkflowService.trigger_workflow_async(
session,
end_user,
trigger_data,
)
try:
AsyncWorkflowService.trigger_workflow_async(
session,
end_user,
trigger_data,
)
quota_charge.commit()
except Exception:
quota_charge.refund()
raise
except Exception:
logger.exception("Failed to trigger workflow for webhook %s", webhook_trigger.webhook_id)

View File

@ -28,7 +28,7 @@ from core.trigger.entities.entities import TriggerProviderEntity
from core.trigger.provider import PluginTriggerProviderController
from core.trigger.trigger_manager import TriggerManager
from core.workflow.nodes.trigger_plugin.entities import TriggerEventNodeData
from enums.quota_type import QuotaType, unlimited
from enums.quota_type import QuotaType
from models.enums import (
AppTriggerType,
CreatorUserRole,
@ -42,6 +42,7 @@ from models.workflow import Workflow, WorkflowAppLog, WorkflowAppLogCreatedFrom,
from services.async_workflow_service import AsyncWorkflowService
from services.end_user_service import EndUserService
from services.errors.app import QuotaExceededError
from services.quota_service import QuotaService, unlimited
from services.trigger.app_trigger_service import AppTriggerService
from services.trigger.trigger_provider_service import TriggerProviderService
from services.trigger.trigger_request_service import TriggerHttpRequestCachingService
@ -298,10 +299,10 @@ def dispatch_triggered_workflow(
icon_dark_filename=trigger_entity.identity.icon_dark or "",
)
# consume quota before invoking trigger
# reserve quota before invoking trigger
quota_charge = unlimited()
try:
quota_charge = QuotaType.TRIGGER.consume(subscription.tenant_id)
quota_charge = QuotaService.reserve(QuotaType.TRIGGER, subscription.tenant_id)
except QuotaExceededError:
AppTriggerService.mark_tenant_triggers_rate_limited(subscription.tenant_id)
logger.info(
@ -387,6 +388,7 @@ def dispatch_triggered_workflow(
raise ValueError(f"End user not found for app {plugin_trigger.app_id}")
AsyncWorkflowService.trigger_workflow_async(session=session, user=end_user, trigger_data=trigger_data)
quota_charge.commit()
dispatched_count += 1
logger.info(
"Triggered workflow for app %s with trigger event %s",

View File

@ -8,10 +8,11 @@ from core.workflow.nodes.trigger_schedule.exc import (
ScheduleNotFoundError,
TenantOwnerNotFoundError,
)
from enums.quota_type import QuotaType, unlimited
from enums.quota_type import QuotaType
from models.trigger import WorkflowSchedulePlan
from services.async_workflow_service import AsyncWorkflowService
from services.errors.app import QuotaExceededError
from services.quota_service import QuotaService, unlimited
from services.trigger.app_trigger_service import AppTriggerService
from services.trigger.schedule_service import ScheduleService
from services.workflow.entities import ScheduleTriggerData
@ -43,7 +44,7 @@ def run_schedule_trigger(schedule_id: str) -> None:
quota_charge = unlimited()
try:
quota_charge = QuotaType.TRIGGER.consume(schedule.tenant_id)
quota_charge = QuotaService.reserve(QuotaType.TRIGGER, schedule.tenant_id)
except QuotaExceededError:
AppTriggerService.mark_tenant_triggers_rate_limited(schedule.tenant_id)
logger.info("Tenant %s rate limited, skipping schedule trigger %s", schedule.tenant_id, schedule_id)
@ -61,6 +62,7 @@ def run_schedule_trigger(schedule_id: str) -> None:
tenant_id=schedule.tenant_id,
),
)
quota_charge.commit()
logger.info("Schedule %s triggered workflow: %s", schedule_id, response.workflow_trigger_log_id)
except Exception as e:
quota_charge.refund()

View File

@ -36,12 +36,19 @@ class TestAppGenerateService:
) as mock_message_based_generator,
patch("services.account_service.FeatureService", autospec=True) as mock_account_feature_service,
patch("services.app_generate_service.dify_config", autospec=True) as mock_dify_config,
patch("services.quota_service.dify_config", autospec=True) as mock_quota_dify_config,
patch("configs.dify_config", autospec=True) as mock_global_dify_config,
):
# Setup default mock returns for billing service
mock_billing_service.update_tenant_feature_plan_usage.return_value = {
"result": "success",
"history_id": "test_history_id",
mock_billing_service.quota_reserve.return_value = {
"reservation_id": "test-reservation-id",
"available": 100,
"reserved": 1,
}
mock_billing_service.quota_commit.return_value = {
"available": 99,
"reserved": 0,
"refunded": 0,
}
# Setup default mock returns for workflow service
@ -101,6 +108,8 @@ class TestAppGenerateService:
mock_dify_config.APP_DEFAULT_ACTIVE_REQUESTS = 100
mock_dify_config.APP_DAILY_RATE_LIMIT = 1000
mock_quota_dify_config.BILLING_ENABLED = False
mock_global_dify_config.BILLING_ENABLED = False
mock_global_dify_config.APP_MAX_ACTIVE_REQUESTS = 100
mock_global_dify_config.APP_DAILY_RATE_LIMIT = 1000
@ -118,6 +127,7 @@ class TestAppGenerateService:
"message_based_generator": mock_message_based_generator,
"account_feature_service": mock_account_feature_service,
"dify_config": mock_dify_config,
"quota_dify_config": mock_quota_dify_config,
"global_dify_config": mock_global_dify_config,
}
@ -465,6 +475,7 @@ class TestAppGenerateService:
# Set BILLING_ENABLED to True for this test
mock_external_service_dependencies["dify_config"].BILLING_ENABLED = True
mock_external_service_dependencies["quota_dify_config"].BILLING_ENABLED = True
mock_external_service_dependencies["global_dify_config"].BILLING_ENABLED = True
# Setup test arguments
@ -478,8 +489,10 @@ class TestAppGenerateService:
# Verify the result
assert result == ["test_response"]
# Verify billing service was called to consume quota
mock_external_service_dependencies["billing_service"].update_tenant_feature_plan_usage.assert_called_once()
# Verify billing two-phase quota (reserve + commit)
billing = mock_external_service_dependencies["billing_service"]
billing.quota_reserve.assert_called_once()
billing.quota_commit.assert_called_once()
def test_generate_with_invalid_app_mode(
self, db_session_with_containers: Session, mock_external_service_dependencies

View File

@ -602,9 +602,9 @@ def test_schedule_trigger_creates_trigger_log(
)
# Mock quota to avoid rate limiting
from enums import quota_type
from services import quota_service
monkeypatch.setattr(quota_type.QuotaType.TRIGGER, "consume", lambda _tenant_id: quota_type.unlimited())
monkeypatch.setattr(quota_service.QuotaService, "reserve", lambda *_args, **_kwargs: quota_service.unlimited())
# Execute schedule trigger
workflow_schedule_tasks.run_schedule_trigger(plan.id)

View File

View File

@ -0,0 +1,349 @@
"""Unit tests for QuotaType, QuotaService, and QuotaCharge."""
from unittest.mock import patch
import pytest
from enums.quota_type import QuotaType
from services.quota_service import QuotaCharge, QuotaService, unlimited
class TestQuotaType:
def test_billing_key_trigger(self):
assert QuotaType.TRIGGER.billing_key == "trigger_event"
def test_billing_key_workflow(self):
assert QuotaType.WORKFLOW.billing_key == "api_rate_limit"
def test_billing_key_unlimited_raises(self):
with pytest.raises(ValueError, match="Invalid quota type"):
_ = QuotaType.UNLIMITED.billing_key
class TestQuotaService:
def test_reserve_billing_disabled(self):
with (
patch("services.quota_service.dify_config") as mock_cfg,
patch("services.billing_service.BillingService"),
):
mock_cfg.BILLING_ENABLED = False
charge = QuotaService.reserve(QuotaType.TRIGGER, "t1")
assert charge.success is True
assert charge.charge_id is None
def test_reserve_zero_amount_raises(self):
with patch("services.quota_service.dify_config") as mock_cfg:
mock_cfg.BILLING_ENABLED = True
with pytest.raises(ValueError, match="greater than 0"):
QuotaService.reserve(QuotaType.TRIGGER, "t1", amount=0)
def test_reserve_success(self):
with (
patch("services.quota_service.dify_config") as mock_cfg,
patch("services.billing_service.BillingService") as mock_bs,
):
mock_cfg.BILLING_ENABLED = True
mock_bs.quota_reserve.return_value = {"reservation_id": "rid-1", "available": 99}
charge = QuotaService.reserve(QuotaType.TRIGGER, "t1", amount=1)
assert charge.success is True
assert charge.charge_id == "rid-1"
assert charge._tenant_id == "t1"
assert charge._feature_key == "trigger_event"
assert charge._amount == 1
mock_bs.quota_reserve.assert_called_once()
def test_reserve_no_reservation_id_raises(self):
from services.errors.app import QuotaExceededError
with (
patch("services.quota_service.dify_config") as mock_cfg,
patch("services.billing_service.BillingService") as mock_bs,
):
mock_cfg.BILLING_ENABLED = True
mock_bs.quota_reserve.return_value = {}
with pytest.raises(QuotaExceededError):
QuotaService.reserve(QuotaType.TRIGGER, "t1")
def test_reserve_quota_exceeded_propagates(self):
from services.errors.app import QuotaExceededError
with (
patch("services.quota_service.dify_config") as mock_cfg,
patch("services.billing_service.BillingService") as mock_bs,
):
mock_cfg.BILLING_ENABLED = True
mock_bs.quota_reserve.side_effect = QuotaExceededError(feature="trigger", tenant_id="t1", required=1)
with pytest.raises(QuotaExceededError):
QuotaService.reserve(QuotaType.TRIGGER, "t1")
def test_reserve_api_exception_returns_unlimited(self):
with (
patch("services.quota_service.dify_config") as mock_cfg,
patch("services.billing_service.BillingService") as mock_bs,
):
mock_cfg.BILLING_ENABLED = True
mock_bs.quota_reserve.side_effect = RuntimeError("network")
charge = QuotaService.reserve(QuotaType.TRIGGER, "t1")
assert charge.success is True
assert charge.charge_id is None
def test_consume_calls_reserve_and_commit(self):
with (
patch("services.quota_service.dify_config") as mock_cfg,
patch("services.billing_service.BillingService") as mock_bs,
):
mock_cfg.BILLING_ENABLED = True
mock_bs.quota_reserve.return_value = {"reservation_id": "rid-c"}
mock_bs.quota_commit.return_value = {}
charge = QuotaService.consume(QuotaType.TRIGGER, "t1")
assert charge.success is True
mock_bs.quota_commit.assert_called_once()
def test_check_billing_disabled(self):
with patch("services.quota_service.dify_config") as mock_cfg:
mock_cfg.BILLING_ENABLED = False
assert QuotaService.check(QuotaType.TRIGGER, "t1") is True
def test_check_zero_amount_raises(self):
with patch("services.quota_service.dify_config") as mock_cfg:
mock_cfg.BILLING_ENABLED = True
with pytest.raises(ValueError, match="greater than 0"):
QuotaService.check(QuotaType.TRIGGER, "t1", amount=0)
def test_check_sufficient_quota(self):
with (
patch("services.quota_service.dify_config") as mock_cfg,
patch.object(QuotaService, "get_remaining", return_value=100),
):
mock_cfg.BILLING_ENABLED = True
assert QuotaService.check(QuotaType.TRIGGER, "t1", amount=50) is True
def test_check_insufficient_quota(self):
with (
patch("services.quota_service.dify_config") as mock_cfg,
patch.object(QuotaService, "get_remaining", return_value=5),
):
mock_cfg.BILLING_ENABLED = True
assert QuotaService.check(QuotaType.TRIGGER, "t1", amount=10) is False
def test_check_unlimited_quota(self):
with (
patch("services.quota_service.dify_config") as mock_cfg,
patch.object(QuotaService, "get_remaining", return_value=-1),
):
mock_cfg.BILLING_ENABLED = True
assert QuotaService.check(QuotaType.TRIGGER, "t1", amount=999) is True
def test_check_exception_returns_true(self):
with (
patch("services.quota_service.dify_config") as mock_cfg,
patch.object(QuotaService, "get_remaining", side_effect=RuntimeError),
):
mock_cfg.BILLING_ENABLED = True
assert QuotaService.check(QuotaType.TRIGGER, "t1") is True
def test_release_billing_disabled(self):
with (
patch("services.quota_service.dify_config") as mock_cfg,
patch("services.billing_service.BillingService") as mock_bs,
):
mock_cfg.BILLING_ENABLED = False
QuotaService.release(QuotaType.TRIGGER, "rid-1", "t1", "trigger_event")
mock_bs.quota_release.assert_not_called()
def test_release_empty_reservation(self):
with (
patch("services.quota_service.dify_config") as mock_cfg,
patch("services.billing_service.BillingService") as mock_bs,
):
mock_cfg.BILLING_ENABLED = True
QuotaService.release(QuotaType.TRIGGER, "", "t1", "trigger_event")
mock_bs.quota_release.assert_not_called()
def test_release_success(self):
with (
patch("services.quota_service.dify_config") as mock_cfg,
patch("services.billing_service.BillingService") as mock_bs,
):
mock_cfg.BILLING_ENABLED = True
mock_bs.quota_release.return_value = {}
QuotaService.release(QuotaType.TRIGGER, "rid-1", "t1", "trigger_event")
mock_bs.quota_release.assert_called_once_with(
tenant_id="t1", feature_key="trigger_event", reservation_id="rid-1"
)
def test_release_exception_swallowed(self):
with (
patch("services.quota_service.dify_config") as mock_cfg,
patch("services.billing_service.BillingService") as mock_bs,
):
mock_cfg.BILLING_ENABLED = True
mock_bs.quota_release.side_effect = RuntimeError("fail")
QuotaService.release(QuotaType.TRIGGER, "rid-1", "t1", "trigger_event")
def test_get_remaining_normal(self):
with patch("services.billing_service.BillingService") as mock_bs:
mock_bs.get_quota_info.return_value = {"trigger_event": {"limit": 100, "usage": 30}}
assert QuotaService.get_remaining(QuotaType.TRIGGER, "t1") == 70
def test_get_remaining_unlimited(self):
with patch("services.billing_service.BillingService") as mock_bs:
mock_bs.get_quota_info.return_value = {"trigger_event": {"limit": -1, "usage": 0}}
assert QuotaService.get_remaining(QuotaType.TRIGGER, "t1") == -1
def test_get_remaining_over_limit_returns_zero(self):
with patch("services.billing_service.BillingService") as mock_bs:
mock_bs.get_quota_info.return_value = {"trigger_event": {"limit": 10, "usage": 15}}
assert QuotaService.get_remaining(QuotaType.TRIGGER, "t1") == 0
def test_get_remaining_exception_returns_neg1(self):
with patch("services.billing_service.BillingService") as mock_bs:
mock_bs.get_quota_info.side_effect = RuntimeError
assert QuotaService.get_remaining(QuotaType.TRIGGER, "t1") == -1
def test_get_remaining_empty_response(self):
with patch("services.billing_service.BillingService") as mock_bs:
mock_bs.get_quota_info.return_value = {}
assert QuotaService.get_remaining(QuotaType.TRIGGER, "t1") == 0
def test_get_remaining_non_dict_response(self):
with patch("services.billing_service.BillingService") as mock_bs:
mock_bs.get_quota_info.return_value = "invalid"
assert QuotaService.get_remaining(QuotaType.TRIGGER, "t1") == 0
def test_get_remaining_feature_not_in_response(self):
with patch("services.billing_service.BillingService") as mock_bs:
mock_bs.get_quota_info.return_value = {"other_feature": {"limit": 100, "usage": 0}}
remaining = QuotaService.get_remaining(QuotaType.TRIGGER, "t1")
assert remaining == 0
def test_get_remaining_non_dict_feature_info(self):
with patch("services.billing_service.BillingService") as mock_bs:
mock_bs.get_quota_info.return_value = {"trigger_event": "not_a_dict"}
assert QuotaService.get_remaining(QuotaType.TRIGGER, "t1") == 0
class TestQuotaCharge:
def test_commit_success(self):
with patch("services.billing_service.BillingService") as mock_bs:
mock_bs.quota_commit.return_value = {}
charge = QuotaCharge(
success=True,
charge_id="rid-1",
_quota_type=QuotaType.TRIGGER,
_tenant_id="t1",
_feature_key="trigger_event",
_amount=1,
)
charge.commit()
mock_bs.quota_commit.assert_called_once_with(
tenant_id="t1",
feature_key="trigger_event",
reservation_id="rid-1",
actual_amount=1,
)
assert charge._committed is True
def test_commit_with_actual_amount(self):
with patch("services.billing_service.BillingService") as mock_bs:
mock_bs.quota_commit.return_value = {}
charge = QuotaCharge(
success=True,
charge_id="rid-1",
_quota_type=QuotaType.TRIGGER,
_tenant_id="t1",
_feature_key="trigger_event",
_amount=10,
)
charge.commit(actual_amount=5)
call_kwargs = mock_bs.quota_commit.call_args[1]
assert call_kwargs["actual_amount"] == 5
def test_commit_idempotent(self):
with patch("services.billing_service.BillingService") as mock_bs:
mock_bs.quota_commit.return_value = {}
charge = QuotaCharge(
success=True,
charge_id="rid-1",
_quota_type=QuotaType.TRIGGER,
_tenant_id="t1",
_feature_key="trigger_event",
_amount=1,
)
charge.commit()
charge.commit()
assert mock_bs.quota_commit.call_count == 1
def test_commit_no_charge_id_noop(self):
with patch("services.billing_service.BillingService") as mock_bs:
charge = QuotaCharge(success=True, charge_id=None, _quota_type=QuotaType.TRIGGER)
charge.commit()
mock_bs.quota_commit.assert_not_called()
def test_commit_no_tenant_id_noop(self):
with patch("services.billing_service.BillingService") as mock_bs:
charge = QuotaCharge(
success=True,
charge_id="rid-1",
_quota_type=QuotaType.TRIGGER,
_tenant_id=None,
_feature_key="trigger_event",
)
charge.commit()
mock_bs.quota_commit.assert_not_called()
def test_commit_exception_swallowed(self):
with patch("services.billing_service.BillingService") as mock_bs:
mock_bs.quota_commit.side_effect = RuntimeError("fail")
charge = QuotaCharge(
success=True,
charge_id="rid-1",
_quota_type=QuotaType.TRIGGER,
_tenant_id="t1",
_feature_key="trigger_event",
_amount=1,
)
charge.commit()
def test_refund_success(self):
with patch.object(QuotaService, "release") as mock_rel:
charge = QuotaCharge(
success=True,
charge_id="rid-1",
_quota_type=QuotaType.TRIGGER,
_tenant_id="t1",
_feature_key="trigger_event",
)
charge.refund()
mock_rel.assert_called_once_with(QuotaType.TRIGGER, "rid-1", "t1", "trigger_event")
def test_refund_no_charge_id_noop(self):
with patch.object(QuotaService, "release") as mock_rel:
charge = QuotaCharge(success=True, charge_id=None, _quota_type=QuotaType.TRIGGER)
charge.refund()
mock_rel.assert_not_called()
def test_refund_no_tenant_id_noop(self):
with patch.object(QuotaService, "release") as mock_rel:
charge = QuotaCharge(
success=True,
charge_id="rid-1",
_quota_type=QuotaType.TRIGGER,
_tenant_id=None,
)
charge.refund()
mock_rel.assert_not_called()
class TestUnlimited:
def test_unlimited_returns_success_with_no_charge_id(self):
charge = unlimited()
assert charge.success is True
assert charge.charge_id is None
assert charge._quota_type == QuotaType.UNLIMITED

View File

@ -23,6 +23,7 @@ import pytest
import services.app_generate_service as ags_module
from core.app.entities.app_invoke_entities import InvokeFrom
from enums.quota_type import QuotaType
from models.model import AppMode
from services.app_generate_service import AppGenerateService
from services.errors.app import WorkflowIdFormatError, WorkflowNotFoundError
@ -447,8 +448,8 @@ class TestGenerateBilling:
def test_billing_enabled_consumes_quota(self, mocker, monkeypatch):
monkeypatch.setattr(ags_module.dify_config, "BILLING_ENABLED", True)
quota_charge = MagicMock()
consume_mock = mocker.patch(
"services.app_generate_service.QuotaType.WORKFLOW.consume",
reserve_mock = mocker.patch(
"services.app_generate_service.QuotaService.reserve",
return_value=quota_charge,
)
mocker.patch(
@ -467,7 +468,8 @@ class TestGenerateBilling:
invoke_from=InvokeFrom.SERVICE_API,
streaming=False,
)
consume_mock.assert_called_once_with("tenant-id")
reserve_mock.assert_called_once_with(QuotaType.WORKFLOW, "tenant-id")
quota_charge.commit.assert_called_once()
def test_billing_quota_exceeded_raises_rate_limit_error(self, mocker, monkeypatch):
from services.errors.app import QuotaExceededError
@ -475,7 +477,7 @@ class TestGenerateBilling:
monkeypatch.setattr(ags_module.dify_config, "BILLING_ENABLED", True)
mocker.patch(
"services.app_generate_service.QuotaType.WORKFLOW.consume",
"services.app_generate_service.QuotaService.reserve",
side_effect=QuotaExceededError(feature="workflow", tenant_id="t", required=1),
)
@ -492,7 +494,7 @@ class TestGenerateBilling:
monkeypatch.setattr(ags_module.dify_config, "BILLING_ENABLED", True)
quota_charge = MagicMock()
mocker.patch(
"services.app_generate_service.QuotaType.WORKFLOW.consume",
"services.app_generate_service.QuotaService.reserve",
return_value=quota_charge,
)
mocker.patch(

View File

@ -57,7 +57,7 @@ class TestAsyncWorkflowService:
- repo: SQLAlchemyWorkflowTriggerLogRepository
- dispatcher_manager_class: QueueDispatcherManager class
- dispatcher: dispatcher instance
- quota_workflow: QuotaType.WORKFLOW
- quota_service: QuotaService mock
- get_workflow: AsyncWorkflowService._get_workflow method
- professional_task: execute_workflow_professional
- team_task: execute_workflow_team
@ -72,7 +72,7 @@ class TestAsyncWorkflowService:
mock_repo.create.side_effect = _create_side_effect
mock_dispatcher = MagicMock()
quota_workflow = MagicMock()
mock_quota_service = MagicMock()
mock_get_workflow = MagicMock()
mock_professional_task = MagicMock()
@ -93,8 +93,8 @@ class TestAsyncWorkflowService:
) as mock_get_workflow,
patch.object(
async_workflow_service_module,
"QuotaType",
new=SimpleNamespace(WORKFLOW=quota_workflow),
"QuotaService",
new=mock_quota_service,
),
patch.object(async_workflow_service_module, "execute_workflow_professional") as mock_professional_task,
patch.object(async_workflow_service_module, "execute_workflow_team") as mock_team_task,
@ -107,7 +107,7 @@ class TestAsyncWorkflowService:
"repo": mock_repo,
"dispatcher_manager_class": mock_dispatcher_manager_class,
"dispatcher": mock_dispatcher,
"quota_workflow": quota_workflow,
"quota_service": mock_quota_service,
"get_workflow": mock_get_workflow,
"professional_task": mock_professional_task,
"team_task": mock_team_task,
@ -146,6 +146,9 @@ class TestAsyncWorkflowService:
mocks["team_task"].delay.return_value = task_result
mocks["sandbox_task"].delay.return_value = task_result
quota_charge_mock = MagicMock()
mocks["quota_service"].reserve.return_value = quota_charge_mock
class DummyAccount:
def __init__(self, user_id: str):
self.id = user_id
@ -163,7 +166,8 @@ class TestAsyncWorkflowService:
assert result.status == "queued"
assert result.queue == queue_name
mocks["quota_workflow"].consume.assert_called_once_with("tenant-123")
mocks["quota_service"].reserve.assert_called_once()
quota_charge_mock.commit.assert_called_once()
assert session.commit.call_count == 2
created_log = mocks["repo"].create.call_args[0][0]
@ -250,7 +254,7 @@ class TestAsyncWorkflowService:
mocks = async_workflow_trigger_mocks
mocks["dispatcher"].get_queue_name.return_value = QueuePriority.TEAM
mocks["get_workflow"].return_value = workflow
mocks["quota_workflow"].consume.side_effect = QuotaExceededError(
mocks["quota_service"].reserve.side_effect = QuotaExceededError(
feature="workflow",
tenant_id="tenant-123",
required=1,

View File

@ -425,7 +425,7 @@ class TestBillingServiceUsageCalculation:
yield mock
def test_get_tenant_feature_plan_usage_info(self, mock_send_request):
"""Test retrieval of tenant feature plan usage information."""
"""Test retrieval of tenant feature plan usage information (legacy endpoint)."""
# Arrange
tenant_id = "tenant-123"
expected_response = {"features": {"trigger": {"used": 50, "limit": 100}, "workflow": {"used": 20, "limit": 50}}}
@ -438,6 +438,20 @@ class TestBillingServiceUsageCalculation:
assert result == expected_response
mock_send_request.assert_called_once_with("GET", "/tenant-feature-usage/info", params={"tenant_id": tenant_id})
def test_get_quota_info(self, mock_send_request):
"""Test retrieval of quota info from new endpoint."""
# Arrange
tenant_id = "tenant-123"
expected_response = {"trigger_event": {"limit": 100, "usage": 30}, "api_rate_limit": {"limit": -1, "usage": 0}}
mock_send_request.return_value = expected_response
# Act
result = BillingService.get_quota_info(tenant_id)
# Assert
assert result == expected_response
mock_send_request.assert_called_once_with("GET", "/quota/info", params={"tenant_id": tenant_id})
def test_update_tenant_feature_plan_usage_positive_delta(self, mock_send_request):
"""Test updating tenant feature usage with positive delta (adding credits)."""
# Arrange
@ -515,6 +529,150 @@ class TestBillingServiceUsageCalculation:
)
class TestBillingServiceQuotaOperations:
"""Unit tests for quota reserve/commit/release operations."""
@pytest.fixture
def mock_send_request(self):
with patch.object(BillingService, "_send_request") as mock:
yield mock
def test_quota_reserve_success(self, mock_send_request):
expected = {"reservation_id": "rid-1", "available": 99, "reserved": 1}
mock_send_request.return_value = expected
result = BillingService.quota_reserve(tenant_id="t1", feature_key="trigger_event", request_id="req-1", amount=1)
assert result == expected
mock_send_request.assert_called_once_with(
"POST",
"/quota/reserve",
json={"tenant_id": "t1", "feature_key": "trigger_event", "request_id": "req-1", "amount": 1},
)
def test_quota_reserve_coerces_string_to_int(self, mock_send_request):
"""Test that TypeAdapter coerces string values to int."""
mock_send_request.return_value = {"reservation_id": "rid-str", "available": "99", "reserved": "1"}
result = BillingService.quota_reserve(tenant_id="t1", feature_key="trigger_event", request_id="req-s", amount=1)
assert result["available"] == 99
assert isinstance(result["available"], int)
assert result["reserved"] == 1
assert isinstance(result["reserved"], int)
def test_quota_reserve_with_meta(self, mock_send_request):
mock_send_request.return_value = {"reservation_id": "rid-2", "available": 98, "reserved": 1}
meta = {"source": "webhook"}
BillingService.quota_reserve(
tenant_id="t1", feature_key="trigger_event", request_id="req-2", amount=1, meta=meta
)
call_json = mock_send_request.call_args[1]["json"]
assert call_json["meta"] == {"source": "webhook"}
def test_quota_commit_success(self, mock_send_request):
expected = {"available": 98, "reserved": 0, "refunded": 0}
mock_send_request.return_value = expected
result = BillingService.quota_commit(
tenant_id="t1", feature_key="trigger_event", reservation_id="rid-1", actual_amount=1
)
assert result == expected
mock_send_request.assert_called_once_with(
"POST",
"/quota/commit",
json={
"tenant_id": "t1",
"feature_key": "trigger_event",
"reservation_id": "rid-1",
"actual_amount": 1,
},
)
def test_quota_commit_coerces_string_to_int(self, mock_send_request):
"""Test that TypeAdapter coerces string values to int."""
mock_send_request.return_value = {"available": "97", "reserved": "0", "refunded": "1"}
result = BillingService.quota_commit(
tenant_id="t1", feature_key="trigger_event", reservation_id="rid-s", actual_amount=1
)
assert result["available"] == 97
assert isinstance(result["available"], int)
assert result["refunded"] == 1
assert isinstance(result["refunded"], int)
def test_quota_commit_with_meta(self, mock_send_request):
mock_send_request.return_value = {"available": 97, "reserved": 0, "refunded": 0}
meta = {"reason": "partial"}
BillingService.quota_commit(
tenant_id="t1", feature_key="trigger_event", reservation_id="rid-1", actual_amount=1, meta=meta
)
call_json = mock_send_request.call_args[1]["json"]
assert call_json["meta"] == {"reason": "partial"}
def test_quota_release_success(self, mock_send_request):
expected = {"available": 100, "reserved": 0, "released": 1}
mock_send_request.return_value = expected
result = BillingService.quota_release(tenant_id="t1", feature_key="trigger_event", reservation_id="rid-1")
assert result == expected
mock_send_request.assert_called_once_with(
"POST",
"/quota/release",
json={"tenant_id": "t1", "feature_key": "trigger_event", "reservation_id": "rid-1"},
)
def test_quota_release_coerces_string_to_int(self, mock_send_request):
"""Test that TypeAdapter coerces string values to int."""
mock_send_request.return_value = {"available": "100", "reserved": "0", "released": "1"}
result = BillingService.quota_release(tenant_id="t1", feature_key="trigger_event", reservation_id="rid-s")
assert result["available"] == 100
assert isinstance(result["available"], int)
assert result["released"] == 1
assert isinstance(result["released"], int)
def test_get_quota_info_coerces_string_to_int(self, mock_send_request):
"""Test that TypeAdapter coerces string values to int for get_quota_info."""
mock_send_request.return_value = {
"trigger_event": {"usage": "42", "limit": "3000", "reset_date": "1700000000"},
"api_rate_limit": {"usage": "10", "limit": "-1", "reset_date": "-1"},
}
result = BillingService.get_quota_info("t1")
assert result["trigger_event"]["usage"] == 42
assert isinstance(result["trigger_event"]["usage"], int)
assert result["trigger_event"]["limit"] == 3000
assert isinstance(result["trigger_event"]["limit"], int)
assert result["trigger_event"]["reset_date"] == 1700000000
assert isinstance(result["trigger_event"]["reset_date"], int)
assert result["api_rate_limit"]["limit"] == -1
assert isinstance(result["api_rate_limit"]["limit"], int)
def test_get_quota_info_accepts_int_values(self, mock_send_request):
"""Test that get_quota_info works with native int values."""
expected = {
"trigger_event": {"usage": 42, "limit": 3000, "reset_date": 1700000000},
"api_rate_limit": {"usage": 0, "limit": -1},
}
mock_send_request.return_value = expected
result = BillingService.get_quota_info("t1")
assert result["trigger_event"]["usage"] == 42
assert result["trigger_event"]["limit"] == 3000
assert result["api_rate_limit"]["limit"] == -1
class TestBillingServiceRateLimitEnforcement:
"""Unit tests for rate limit enforcement mechanisms.

View File

@ -1100,12 +1100,11 @@ def test_trigger_workflow_execution_should_mark_tenant_rate_limited_when_quota_e
"get_or_create_end_user_by_type",
MagicMock(return_value=SimpleNamespace(id="end-user-1")),
)
quota_type = SimpleNamespace(
TRIGGER=SimpleNamespace(
consume=MagicMock(side_effect=QuotaExceededError(feature="trigger", tenant_id="tenant-1", required=1))
)
monkeypatch.setattr(
service_module.QuotaService,
"reserve",
MagicMock(side_effect=QuotaExceededError(feature="trigger", tenant_id="tenant-1", required=1)),
)
monkeypatch.setattr(service_module, "QuotaType", quota_type)
mark_rate_limited_mock = MagicMock()
monkeypatch.setattr(service_module.AppTriggerService, "mark_tenant_triggers_rate_limited", mark_rate_limited_mock)

View File

@ -1,8 +1,8 @@
#!/usr/bin/env bash
set -euo pipefail
SCRIPT_DIR="$(dirname "$(realpath "$0")")"
ROOT="$(dirname "$SCRIPT_DIR")"
cd "$ROOT/docker"
docker compose --env-file middleware.env -f docker-compose.middleware.yaml -p dify up -d
#!/usr/bin/env bash
set -euo pipefail
SCRIPT_DIR="$(dirname "$(realpath "$0")")"
ROOT="$(dirname "$SCRIPT_DIR")"
cd "$ROOT/docker"
docker compose --env-file middleware.env -f docker-compose.middleware.yaml -p dify up -d

View File

@ -1173,6 +1173,14 @@ MAX_ITERATIONS_NUM=99
# The timeout for the text generation in millisecond
TEXT_GENERATION_TIMEOUT_MS=60000
# Enable the experimental vinext runtime shipped in the image.
EXPERIMENTAL_ENABLE_VINEXT=false
# Allow inline style attributes in Markdown rendering.
# Enable this if your workflows use Jinja2 templates with styled HTML.
# Only recommended for self-hosted deployments with trusted content.
ALLOW_INLINE_STYLES=false
# Allow rendering unsafe URLs which have "data:" scheme.
ALLOW_UNSAFE_DATA_SCHEME=false

View File

@ -161,9 +161,11 @@ services:
NEXT_PUBLIC_COOKIE_DOMAIN: ${NEXT_PUBLIC_COOKIE_DOMAIN:-}
SENTRY_DSN: ${WEB_SENTRY_DSN:-}
NEXT_TELEMETRY_DISABLED: ${NEXT_TELEMETRY_DISABLED:-0}
EXPERIMENTAL_ENABLE_VINEXT: ${EXPERIMENTAL_ENABLE_VINEXT:-false}
TEXT_GENERATION_TIMEOUT_MS: ${TEXT_GENERATION_TIMEOUT_MS:-60000}
CSP_WHITELIST: ${CSP_WHITELIST:-}
ALLOW_EMBED: ${ALLOW_EMBED:-false}
ALLOW_INLINE_STYLES: ${ALLOW_INLINE_STYLES:-false}
ALLOW_UNSAFE_DATA_SCHEME: ${ALLOW_UNSAFE_DATA_SCHEME:-false}
MARKETPLACE_API_URL: ${MARKETPLACE_API_URL:-https://marketplace.dify.ai}
MARKETPLACE_URL: ${MARKETPLACE_URL:-https://marketplace.dify.ai}

View File

@ -509,6 +509,8 @@ x-shared-env: &shared-api-worker-env
MAX_PARALLEL_LIMIT: ${MAX_PARALLEL_LIMIT:-10}
MAX_ITERATIONS_NUM: ${MAX_ITERATIONS_NUM:-99}
TEXT_GENERATION_TIMEOUT_MS: ${TEXT_GENERATION_TIMEOUT_MS:-60000}
EXPERIMENTAL_ENABLE_VINEXT: ${EXPERIMENTAL_ENABLE_VINEXT:-false}
ALLOW_INLINE_STYLES: ${ALLOW_INLINE_STYLES:-false}
ALLOW_UNSAFE_DATA_SCHEME: ${ALLOW_UNSAFE_DATA_SCHEME:-false}
MAX_TREE_DEPTH: ${MAX_TREE_DEPTH:-50}
PGDATA: ${PGDATA:-/var/lib/postgresql/data/pgdata}
@ -870,9 +872,11 @@ services:
NEXT_PUBLIC_COOKIE_DOMAIN: ${NEXT_PUBLIC_COOKIE_DOMAIN:-}
SENTRY_DSN: ${WEB_SENTRY_DSN:-}
NEXT_TELEMETRY_DISABLED: ${NEXT_TELEMETRY_DISABLED:-0}
EXPERIMENTAL_ENABLE_VINEXT: ${EXPERIMENTAL_ENABLE_VINEXT:-false}
TEXT_GENERATION_TIMEOUT_MS: ${TEXT_GENERATION_TIMEOUT_MS:-60000}
CSP_WHITELIST: ${CSP_WHITELIST:-}
ALLOW_EMBED: ${ALLOW_EMBED:-false}
ALLOW_INLINE_STYLES: ${ALLOW_INLINE_STYLES:-false}
ALLOW_UNSAFE_DATA_SCHEME: ${ALLOW_UNSAFE_DATA_SCHEME:-false}
MARKETPLACE_API_URL: ${MARKETPLACE_API_URL:-https://marketplace.dify.ai}
MARKETPLACE_URL: ${MARKETPLACE_URL:-https://marketplace.dify.ai}

View File

@ -0,0 +1,7 @@
@smoke @unauthenticated
Feature: Unauthenticated app console entry
Scenario: Redirect to the sign-in page when opening the apps console without logging in
Given I am not signed in
When I open the apps console
Then I should be redirected to the signin page
And I should see the "Sign in" button

View File

@ -9,3 +9,10 @@ Given('I am signed in as the default E2E admin', async function (this: DifyWorld
'text/plain',
)
})
Given('I am not signed in', async function (this: DifyWorld) {
this.attach(
'Using a clean browser context without the shared authenticated storage state.',
'text/plain',
)
})

View File

@ -10,6 +10,10 @@ Then('I should stay on the apps console', async function (this: DifyWorld) {
await expect(this.getPage()).toHaveURL(/\/apps(?:\?.*)?$/)
})
Then('I should be redirected to the signin page', async function (this: DifyWorld) {
await expect(this.getPage()).toHaveURL(/\/signin(?:\?.*)?$/)
})
Then('I should see the {string} button', async function (this: DifyWorld, label: string) {
await expect(this.getPage().getByRole('button', { name: label })).toBeVisible()
})

View File

@ -46,7 +46,11 @@ BeforeAll(async () => {
Before(async function (this: DifyWorld, { pickle }) {
if (!browser) throw new Error('Shared Playwright browser is not available.')
await this.startAuthenticatedSession(browser)
const isUnauthenticatedScenario = pickle.tags.some((tag) => tag.name === '@unauthenticated')
if (isUnauthenticatedScenario) await this.startUnauthenticatedSession(browser)
else await this.startAuthenticatedSession(browser)
this.scenarioStartedAt = Date.now()
const tags = pickle.tags.map((tag) => tag.name).join(' ')

View File

@ -25,12 +25,12 @@ export class DifyWorld extends World {
this.pageErrors = []
}
async startAuthenticatedSession(browser: Browser) {
async startSession(browser: Browser, authenticated: boolean) {
this.resetScenarioState()
this.context = await browser.newContext({
baseURL,
locale: defaultLocale,
storageState: authStatePath,
...(authenticated ? { storageState: authStatePath } : {}),
})
this.context.setDefaultTimeout(30_000)
this.page = await this.context.newPage()
@ -44,6 +44,14 @@ export class DifyWorld extends World {
})
}
async startAuthenticatedSession(browser: Browser) {
await this.startSession(browser, true)
}
async startUnauthenticatedSession(browser: Browser) {
await this.startSession(browser, false)
}
getPage() {
if (!this.page) throw new Error('Playwright page has not been initialized for this scenario.')

View File

@ -19,6 +19,7 @@
"@types/node": "catalog:",
"tsx": "catalog:",
"typescript": "catalog:",
"vite": "catalog:",
"vite-plus": "catalog:"
}
}

View File

@ -5,6 +5,7 @@
"prepare": "vp config"
},
"devDependencies": {
"vite": "catalog:",
"vite-plus": "catalog:"
},
"engines": {

377
pnpm-lock.yaml generated
View File

@ -514,8 +514,8 @@ catalogs:
specifier: 13.0.0
version: 13.0.0
vinext:
specifier: 0.0.40
version: 0.0.40
specifier: https://pkg.pr.new/vinext@fd532d3
version: 0.0.5
vite-plugin-inspect:
specifier: 12.0.0-beta.1
version: 12.0.0-beta.1
@ -568,9 +568,12 @@ importers:
.:
devDependencies:
vite:
specifier: npm:@voidzero-dev/vite-plus-core@0.1.16
version: '@voidzero-dev/vite-plus-core@0.1.16(@types/node@25.5.2)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3)'
vite-plus:
specifier: 'catalog:'
version: 0.1.16(@types/node@25.5.2)(happy-dom@20.8.9)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(typescript@6.0.2)(vite@8.0.3(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/node@25.5.2)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))(yaml@2.8.3)
version: 0.1.16(@types/node@25.5.2)(@voidzero-dev/vite-plus-core@0.1.16(@types/node@25.5.2)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3))(happy-dom@20.8.9)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3)
e2e:
devDependencies:
@ -589,9 +592,12 @@ importers:
typescript:
specifier: 'catalog:'
version: 6.0.2
vite:
specifier: npm:@voidzero-dev/vite-plus-core@0.1.16
version: '@voidzero-dev/vite-plus-core@0.1.16(@types/node@25.5.2)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3)'
vite-plus:
specifier: 'catalog:'
version: 0.1.16(@types/node@25.5.2)(happy-dom@20.8.9)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(typescript@6.0.2)(vite@8.0.3(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/node@25.5.2)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))(yaml@2.8.3)
version: 0.1.16(@types/node@25.5.2)(@voidzero-dev/vite-plus-core@0.1.16(@types/node@25.5.2)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3))(happy-dom@20.8.9)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3)
packages/iconify-collections:
devDependencies:
@ -615,19 +621,22 @@ importers:
version: 8.58.1(eslint@10.2.0(jiti@2.6.1))(typescript@6.0.2)
'@vitest/coverage-v8':
specifier: 'catalog:'
version: 4.1.3(@voidzero-dev/vite-plus-test@0.1.16(@types/node@25.5.2)(happy-dom@20.8.9)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(typescript@6.0.2)(vite@8.0.3(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/node@25.5.2)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))(yaml@2.8.3))
version: 4.1.3(@voidzero-dev/vite-plus-test@0.1.16(@types/node@25.5.2)(@voidzero-dev/vite-plus-core@0.1.16(@types/node@25.5.2)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3))(happy-dom@20.8.9)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3))
eslint:
specifier: 'catalog:'
version: 10.2.0(jiti@2.6.1)
typescript:
specifier: 'catalog:'
version: 6.0.2
vite:
specifier: npm:@voidzero-dev/vite-plus-core@0.1.16
version: '@voidzero-dev/vite-plus-core@0.1.16(@types/node@25.5.2)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3)'
vite-plus:
specifier: 'catalog:'
version: 0.1.16(@types/node@25.5.2)(happy-dom@20.8.9)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(typescript@6.0.2)(vite@8.0.3(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/node@25.5.2)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))(yaml@2.8.3)
version: 0.1.16(@types/node@25.5.2)(@voidzero-dev/vite-plus-core@0.1.16(@types/node@25.5.2)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3))(happy-dom@20.8.9)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3)
vitest:
specifier: npm:@voidzero-dev/vite-plus-test@0.1.16
version: '@voidzero-dev/vite-plus-test@0.1.16(@types/node@25.5.2)(happy-dom@20.8.9)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(typescript@6.0.2)(vite@8.0.3(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/node@25.5.2)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))(yaml@2.8.3)'
version: '@voidzero-dev/vite-plus-test@0.1.16(@types/node@25.5.2)(@voidzero-dev/vite-plus-core@0.1.16(@types/node@25.5.2)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3))(happy-dom@20.8.9)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3)'
web:
dependencies:
@ -1141,7 +1150,7 @@ importers:
version: 3.19.3
vinext:
specifier: 'catalog:'
version: 0.0.40(@mdx-js/rollup@3.1.1(rollup@4.59.0))(@vitejs/plugin-react@6.0.1(@voidzero-dev/vite-plus-core@0.1.16(@types/node@25.5.2)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3)))(@vitejs/plugin-rsc@0.5.22(@voidzero-dev/vite-plus-core@0.1.16(@types/node@25.5.2)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3))(react-dom@19.2.4(react@19.2.4))(react-server-dom-webpack@19.2.4(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(webpack@5.105.4(uglify-js@3.19.3)))(react@19.2.4))(@voidzero-dev/vite-plus-core@0.1.16(@types/node@25.5.2)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3))(next@16.2.2(@babel/core@7.29.0)(@playwright/test@1.59.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(sass@1.98.0))(react-dom@19.2.4(react@19.2.4))(react-server-dom-webpack@19.2.4(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(webpack@5.105.4(uglify-js@3.19.3)))(react@19.2.4)(typescript@6.0.2)
version: https://pkg.pr.new/vinext@fd532d3(@mdx-js/rollup@3.1.1(rollup@4.59.0))(@vitejs/plugin-react@6.0.1(@voidzero-dev/vite-plus-core@0.1.16(@types/node@25.5.2)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3)))(@vitejs/plugin-rsc@0.5.22(@voidzero-dev/vite-plus-core@0.1.16(@types/node@25.5.2)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3))(react-dom@19.2.4(react@19.2.4))(react-server-dom-webpack@19.2.4(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(webpack@5.105.4(uglify-js@3.19.3)))(react@19.2.4))(@voidzero-dev/vite-plus-core@0.1.16(@types/node@25.5.2)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3))(next@16.2.2(@babel/core@7.29.0)(@playwright/test@1.59.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(sass@1.98.0))(react-dom@19.2.4(react@19.2.4))(react-server-dom-webpack@19.2.4(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(webpack@5.105.4(uglify-js@3.19.3)))(react@19.2.4)(typescript@6.0.2)
vite:
specifier: npm:@voidzero-dev/vite-plus-core@0.1.16
version: '@voidzero-dev/vite-plus-core@0.1.16(@types/node@25.5.2)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3)'
@ -2514,9 +2523,6 @@ packages:
'@oxc-project/types@0.121.0':
resolution: {integrity: sha512-CGtOARQb9tyv7ECgdAlFxi0Fv7lmzvmlm2rpD/RdijOO9rfk/JvB1CjT8EnoD+tjna/IYgKKw3IV7objRb+aYw==}
'@oxc-project/types@0.122.0':
resolution: {integrity: sha512-oLAl5kBpV4w69UtFZ9xqcmTi+GENWOcPF7FCrczTiBbmC0ibXxCwyvZGbO39rCVEuLGAZM84DH0pUIyyv/YJzA==}
'@oxc-project/types@0.123.0':
resolution: {integrity: sha512-YtECP/y8Mj1lSHiUWGSRzy/C6teUKlS87dEfuVKT09LgQbUsBW1rNg+MiJ4buGu3yuADV60gbIvo9/HplA56Ew==}
@ -3284,104 +3290,6 @@ packages:
resolution: {integrity: sha512-UuBOt7BOsKVOkFXRe4Ypd/lADuNIfqJXv8GvHqtXaTYXPPKkj2nS2zPllVsrtRjcomDhIJVBnZwfmlI222WH8g==}
engines: {node: '>=14.0.0'}
'@rolldown/binding-android-arm64@1.0.0-rc.12':
resolution: {integrity: sha512-pv1y2Fv0JybcykuiiD3qBOBdz6RteYojRFY1d+b95WVuzx211CRh+ytI/+9iVyWQ6koTh5dawe4S/yRfOFjgaA==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm64]
os: [android]
'@rolldown/binding-darwin-arm64@1.0.0-rc.12':
resolution: {integrity: sha512-cFYr6zTG/3PXXF3pUO+umXxt1wkRK/0AYT8lDwuqvRC+LuKYWSAQAQZjCWDQpAH172ZV6ieYrNnFzVVcnSflAg==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm64]
os: [darwin]
'@rolldown/binding-darwin-x64@1.0.0-rc.12':
resolution: {integrity: sha512-ZCsYknnHzeXYps0lGBz8JrF37GpE9bFVefrlmDrAQhOEi4IOIlcoU1+FwHEtyXGx2VkYAvhu7dyBf75EJQffBw==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [x64]
os: [darwin]
'@rolldown/binding-freebsd-x64@1.0.0-rc.12':
resolution: {integrity: sha512-dMLeprcVsyJsKolRXyoTH3NL6qtsT0Y2xeuEA8WQJquWFXkEC4bcu1rLZZSnZRMtAqwtrF/Ib9Ddtpa/Gkge9Q==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [x64]
os: [freebsd]
'@rolldown/binding-linux-arm-gnueabihf@1.0.0-rc.12':
resolution: {integrity: sha512-YqWjAgGC/9M1lz3GR1r1rP79nMgo3mQiiA+Hfo+pvKFK1fAJ1bCi0ZQVh8noOqNacuY1qIcfyVfP6HoyBRZ85Q==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm]
os: [linux]
'@rolldown/binding-linux-arm64-gnu@1.0.0-rc.12':
resolution: {integrity: sha512-/I5AS4cIroLpslsmzXfwbe5OmWvSsrFuEw3mwvbQ1kDxJ822hFHIx+vsN/TAzNVyepI/j/GSzrtCIwQPeKCLIg==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm64]
os: [linux]
libc: [glibc]
'@rolldown/binding-linux-arm64-musl@1.0.0-rc.12':
resolution: {integrity: sha512-V6/wZztnBqlx5hJQqNWwFdxIKN0m38p8Jas+VoSfgH54HSj9tKTt1dZvG6JRHcjh6D7TvrJPWFGaY9UBVOaWPw==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm64]
os: [linux]
libc: [musl]
'@rolldown/binding-linux-ppc64-gnu@1.0.0-rc.12':
resolution: {integrity: sha512-AP3E9BpcUYliZCxa3w5Kwj9OtEVDYK6sVoUzy4vTOJsjPOgdaJZKFmN4oOlX0Wp0RPV2ETfmIra9x1xuayFB7g==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [ppc64]
os: [linux]
libc: [glibc]
'@rolldown/binding-linux-s390x-gnu@1.0.0-rc.12':
resolution: {integrity: sha512-nWwpvUSPkoFmZo0kQazZYOrT7J5DGOJ/+QHHzjvNlooDZED8oH82Yg67HvehPPLAg5fUff7TfWFHQS8IV1n3og==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [s390x]
os: [linux]
libc: [glibc]
'@rolldown/binding-linux-x64-gnu@1.0.0-rc.12':
resolution: {integrity: sha512-RNrafz5bcwRy+O9e6P8Z/OCAJW/A+qtBczIqVYwTs14pf4iV1/+eKEjdOUta93q2TsT/FI0XYDP3TCky38LMAg==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [x64]
os: [linux]
libc: [glibc]
'@rolldown/binding-linux-x64-musl@1.0.0-rc.12':
resolution: {integrity: sha512-Jpw/0iwoKWx3LJ2rc1yjFrj+T7iHZn2JDg1Yny1ma0luviFS4mhAIcd1LFNxK3EYu3DHWCps0ydXQ5i/rrJ2ig==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [x64]
os: [linux]
libc: [musl]
'@rolldown/binding-openharmony-arm64@1.0.0-rc.12':
resolution: {integrity: sha512-vRugONE4yMfVn0+7lUKdKvN4D5YusEiPilaoO2sgUWpCvrncvWgPMzK00ZFFJuiPgLwgFNP5eSiUlv2tfc+lpA==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm64]
os: [openharmony]
'@rolldown/binding-wasm32-wasi@1.0.0-rc.12':
resolution: {integrity: sha512-ykGiLr/6kkiHc0XnBfmFJuCjr5ZYKKofkx+chJWDjitX+KsJuAmrzWhwyOMSHzPhzOHOy7u9HlFoa5MoAOJ/Zg==}
engines: {node: '>=14.0.0'}
cpu: [wasm32]
'@rolldown/binding-win32-arm64-msvc@1.0.0-rc.12':
resolution: {integrity: sha512-5eOND4duWkwx1AzCxadcOrNeighiLwMInEADT0YM7xeEOOFcovWZCq8dadXgcRHSf3Ulh1kFo/qvzoFiCLOL1Q==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm64]
os: [win32]
'@rolldown/binding-win32-x64-msvc@1.0.0-rc.12':
resolution: {integrity: sha512-PyqoipaswDLAZtot351MLhrlrh6lcZPo2LSYE+VDxbVk24LVKAGOuE4hb8xZQmrPAuEtTZW8E6D2zc5EUZX4Lw==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [x64]
os: [win32]
'@rolldown/pluginutils@1.0.0-rc.12':
resolution: {integrity: sha512-HHMwmarRKvoFsJorqYlFeFRzXZqCt2ETQlEDOb9aqssrnVBB1/+xgTGtuTrIk5vzLNX1MjMtTf7W9z3tsSbrxw==}
'@rolldown/pluginutils@1.0.0-rc.13':
resolution: {integrity: sha512-3ngTAv6F/Py35BsYbeeLeecvhMKdsKm4AoOETVhAA+Qc8nrA2I0kF7oa93mE9qnIurngOSpMnQ0x2nQY2FPviA==}
@ -7728,11 +7636,6 @@ packages:
robust-predicates@3.0.3:
resolution: {integrity: sha512-NS3levdsRIUOmiJ8FZWCP7LG3QpJyrs/TE0Zpf1yvZu8cAJJ6QMW92H1c7kWpdIHo8RvmLxN/o2JXTKHp74lUA==}
rolldown@1.0.0-rc.12:
resolution: {integrity: sha512-yP4USLIMYrwpPHEFB5JGH1uxhcslv6/hL0OyvTuY+3qlOSJvZ7ntYnoWpehBxufkgN0cvXxppuTu5hHa/zPh+A==}
engines: {node: ^20.19.0 || >=22.12.0}
hasBin: true
rollup@4.59.0:
resolution: {integrity: sha512-2oMpl67a3zCH9H79LeMcbDhXW/UmWG/y2zuqnF2jQq5uq9TbM9TVyXvA4+t+ne2IIkBdrLpAaRQAvo7YI/Yyeg==}
engines: {node: '>=18.0.0', npm: '>=8.0.0'}
@ -8409,8 +8312,9 @@ packages:
vfile@6.0.3:
resolution: {integrity: sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q==}
vinext@0.0.40:
resolution: {integrity: sha512-rs0z6G2el6kS/667ERKQjSMF3R8ZD2H9xDrnRntVOa6OBnyYcOMM/AVpOy/W1lxOkq6EYTO1OUD9DbNSWxRRJw==}
vinext@https://pkg.pr.new/vinext@fd532d3:
resolution: {integrity: sha512-ofzIlYfhBfHnuDF4QtEiSUy/cod2d5puoimk5dIj30I6ucPMwGlHFBkghCqoZXSIYLG5MSFq3P69+FwlrGFQEQ==, tarball: https://pkg.pr.new/vinext@fd532d3}
version: 0.0.5
engines: {node: '>=22'}
hasBin: true
peerDependencies:
@ -8470,49 +8374,6 @@ packages:
peerDependencies:
vite: '*'
vite@8.0.3:
resolution: {integrity: sha512-B9ifbFudT1TFhfltfaIPgjo9Z3mDynBTJSUYxTjOQruf/zHH+ezCQKcoqO+h7a9Pw9Nm/OtlXAiGT1axBgwqrQ==}
engines: {node: ^20.19.0 || >=22.12.0}
hasBin: true
peerDependencies:
'@types/node': ^20.19.0 || >=22.12.0
'@vitejs/devtools': ^0.1.0
esbuild: 0.27.2
jiti: '>=1.21.0'
less: ^4.0.0
sass: ^1.70.0
sass-embedded: ^1.70.0
stylus: '>=0.54.8'
sugarss: ^5.0.0
terser: ^5.16.0
tsx: ^4.8.1
yaml: 2.8.3
peerDependenciesMeta:
'@types/node':
optional: true
'@vitejs/devtools':
optional: true
esbuild:
optional: true
jiti:
optional: true
less:
optional: true
sass:
optional: true
sass-embedded:
optional: true
stylus:
optional: true
sugarss:
optional: true
terser:
optional: true
tsx:
optional: true
yaml:
optional: true
vitefu@1.1.3:
resolution: {integrity: sha512-ub4okH7Z5KLjb6hDyjqrGXqWtWvoYdU3IGm/NorpgHncKoLTCfRIbvlhBm7r0YstIaQRYlp4yEbFqDcKSzXSSg==}
peerDependencies:
@ -10269,8 +10130,6 @@ snapshots:
'@oxc-project/types@0.121.0': {}
'@oxc-project/types@0.122.0': {}
'@oxc-project/types@0.123.0': {}
'@oxc-resolver/binding-android-arm-eabi@11.19.1':
@ -10835,58 +10694,6 @@ snapshots:
'@rgrove/parse-xml@4.2.0': {}
'@rolldown/binding-android-arm64@1.0.0-rc.12':
optional: true
'@rolldown/binding-darwin-arm64@1.0.0-rc.12':
optional: true
'@rolldown/binding-darwin-x64@1.0.0-rc.12':
optional: true
'@rolldown/binding-freebsd-x64@1.0.0-rc.12':
optional: true
'@rolldown/binding-linux-arm-gnueabihf@1.0.0-rc.12':
optional: true
'@rolldown/binding-linux-arm64-gnu@1.0.0-rc.12':
optional: true
'@rolldown/binding-linux-arm64-musl@1.0.0-rc.12':
optional: true
'@rolldown/binding-linux-ppc64-gnu@1.0.0-rc.12':
optional: true
'@rolldown/binding-linux-s390x-gnu@1.0.0-rc.12':
optional: true
'@rolldown/binding-linux-x64-gnu@1.0.0-rc.12':
optional: true
'@rolldown/binding-linux-x64-musl@1.0.0-rc.12':
optional: true
'@rolldown/binding-openharmony-arm64@1.0.0-rc.12':
optional: true
'@rolldown/binding-wasm32-wasi@1.0.0-rc.12(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)':
dependencies:
'@napi-rs/wasm-runtime': 1.1.2(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)
transitivePeerDependencies:
- '@emnapi/core'
- '@emnapi/runtime'
optional: true
'@rolldown/binding-win32-arm64-msvc@1.0.0-rc.12':
optional: true
'@rolldown/binding-win32-x64-msvc@1.0.0-rc.12':
optional: true
'@rolldown/pluginutils@1.0.0-rc.12': {}
'@rolldown/pluginutils@1.0.0-rc.13': {}
'@rolldown/pluginutils@1.0.0-rc.7': {}
@ -12109,20 +11916,6 @@ snapshots:
tinyrainbow: 3.1.0
vitest: '@voidzero-dev/vite-plus-test@0.1.16(@types/node@25.5.2)(@voidzero-dev/vite-plus-core@0.1.16(@types/node@25.5.2)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3))(happy-dom@20.8.9)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3)'
'@vitest/coverage-v8@4.1.3(@voidzero-dev/vite-plus-test@0.1.16(@types/node@25.5.2)(happy-dom@20.8.9)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(typescript@6.0.2)(vite@8.0.3(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/node@25.5.2)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))(yaml@2.8.3))':
dependencies:
'@bcoe/v8-coverage': 1.0.2
'@vitest/utils': 4.1.3
ast-v8-to-istanbul: 1.0.0
istanbul-lib-coverage: 3.2.2
istanbul-lib-report: 3.0.1
istanbul-reports: 3.2.0
magicast: 0.5.2
obug: 2.1.1
std-env: 4.0.0
tinyrainbow: 3.1.0
vitest: '@voidzero-dev/vite-plus-test@0.1.16(@types/node@25.5.2)(happy-dom@20.8.9)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(typescript@6.0.2)(vite@8.0.3(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/node@25.5.2)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))(yaml@2.8.3)'
'@vitest/eslint-plugin@1.6.14(@typescript-eslint/eslint-plugin@8.58.1(@typescript-eslint/parser@8.58.1(eslint@10.2.0(jiti@2.6.1))(typescript@6.0.2))(eslint@10.2.0(jiti@2.6.1))(typescript@6.0.2))(@voidzero-dev/vite-plus-test@0.1.16(@types/node@25.5.2)(@voidzero-dev/vite-plus-core@0.1.16(@types/node@25.5.2)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3))(happy-dom@20.8.9)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3))(eslint@10.2.0(jiti@2.6.1))(typescript@6.0.2)':
dependencies:
'@typescript-eslint/scope-manager': 8.58.1
@ -12241,46 +12034,6 @@ snapshots:
- utf-8-validate
- yaml
'@voidzero-dev/vite-plus-test@0.1.16(@types/node@25.5.2)(happy-dom@20.8.9)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(typescript@6.0.2)(vite@8.0.3(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/node@25.5.2)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))(yaml@2.8.3)':
dependencies:
'@standard-schema/spec': 1.1.0
'@types/chai': 5.2.3
'@voidzero-dev/vite-plus-core': 0.1.16(@types/node@25.5.2)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3)
es-module-lexer: 1.7.0
obug: 2.1.1
pixelmatch: 7.1.0
pngjs: 7.0.0
sirv: 3.0.2
std-env: 4.0.0
tinybench: 2.9.0
tinyexec: 1.0.4
tinyglobby: 0.2.15
vite: 8.0.3(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/node@25.5.2)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)
ws: 8.20.0
optionalDependencies:
'@types/node': 25.5.2
happy-dom: 20.8.9
transitivePeerDependencies:
- '@arethetypeswrong/core'
- '@tsdown/css'
- '@tsdown/exe'
- '@vitejs/devtools'
- bufferutil
- esbuild
- jiti
- less
- publint
- sass
- sass-embedded
- stylus
- sugarss
- terser
- tsx
- typescript
- unplugin-unused
- utf-8-validate
- yaml
'@voidzero-dev/vite-plus-win32-arm64-msvc@0.1.16':
optional: true
@ -16048,30 +15801,6 @@ snapshots:
robust-predicates@3.0.3: {}
rolldown@1.0.0-rc.12(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1):
dependencies:
'@oxc-project/types': 0.122.0
'@rolldown/pluginutils': 1.0.0-rc.12
optionalDependencies:
'@rolldown/binding-android-arm64': 1.0.0-rc.12
'@rolldown/binding-darwin-arm64': 1.0.0-rc.12
'@rolldown/binding-darwin-x64': 1.0.0-rc.12
'@rolldown/binding-freebsd-x64': 1.0.0-rc.12
'@rolldown/binding-linux-arm-gnueabihf': 1.0.0-rc.12
'@rolldown/binding-linux-arm64-gnu': 1.0.0-rc.12
'@rolldown/binding-linux-arm64-musl': 1.0.0-rc.12
'@rolldown/binding-linux-ppc64-gnu': 1.0.0-rc.12
'@rolldown/binding-linux-s390x-gnu': 1.0.0-rc.12
'@rolldown/binding-linux-x64-gnu': 1.0.0-rc.12
'@rolldown/binding-linux-x64-musl': 1.0.0-rc.12
'@rolldown/binding-openharmony-arm64': 1.0.0-rc.12
'@rolldown/binding-wasm32-wasi': 1.0.0-rc.12(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)
'@rolldown/binding-win32-arm64-msvc': 1.0.0-rc.12
'@rolldown/binding-win32-x64-msvc': 1.0.0-rc.12
transitivePeerDependencies:
- '@emnapi/core'
- '@emnapi/runtime'
rollup@4.59.0:
dependencies:
'@types/estree': 1.0.8
@ -16784,7 +16513,7 @@ snapshots:
'@types/unist': 3.0.3
vfile-message: 4.0.3
vinext@0.0.40(@mdx-js/rollup@3.1.1(rollup@4.59.0))(@vitejs/plugin-react@6.0.1(@voidzero-dev/vite-plus-core@0.1.16(@types/node@25.5.2)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3)))(@vitejs/plugin-rsc@0.5.22(@voidzero-dev/vite-plus-core@0.1.16(@types/node@25.5.2)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3))(react-dom@19.2.4(react@19.2.4))(react-server-dom-webpack@19.2.4(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(webpack@5.105.4(uglify-js@3.19.3)))(react@19.2.4))(@voidzero-dev/vite-plus-core@0.1.16(@types/node@25.5.2)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3))(next@16.2.2(@babel/core@7.29.0)(@playwright/test@1.59.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(sass@1.98.0))(react-dom@19.2.4(react@19.2.4))(react-server-dom-webpack@19.2.4(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(webpack@5.105.4(uglify-js@3.19.3)))(react@19.2.4)(typescript@6.0.2):
vinext@https://pkg.pr.new/vinext@fd532d3(@mdx-js/rollup@3.1.1(rollup@4.59.0))(@vitejs/plugin-react@6.0.1(@voidzero-dev/vite-plus-core@0.1.16(@types/node@25.5.2)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3)))(@vitejs/plugin-rsc@0.5.22(@voidzero-dev/vite-plus-core@0.1.16(@types/node@25.5.2)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3))(react-dom@19.2.4(react@19.2.4))(react-server-dom-webpack@19.2.4(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(webpack@5.105.4(uglify-js@3.19.3)))(react@19.2.4))(@voidzero-dev/vite-plus-core@0.1.16(@types/node@25.5.2)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3))(next@16.2.2(@babel/core@7.29.0)(@playwright/test@1.59.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(sass@1.98.0))(react-dom@19.2.4(react@19.2.4))(react-server-dom-webpack@19.2.4(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(webpack@5.105.4(uglify-js@3.19.3)))(react@19.2.4)(typescript@6.0.2):
dependencies:
'@unpic/react': 1.0.2(next@16.2.2(@babel/core@7.29.0)(@playwright/test@1.59.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(sass@1.98.0))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
'@vercel/og': 0.8.6
@ -16893,51 +16622,6 @@ snapshots:
- vite
- yaml
vite-plus@0.1.16(@types/node@25.5.2)(happy-dom@20.8.9)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(typescript@6.0.2)(vite@8.0.3(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/node@25.5.2)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))(yaml@2.8.3):
dependencies:
'@oxc-project/types': 0.123.0
'@voidzero-dev/vite-plus-core': 0.1.16(@types/node@25.5.2)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3)
'@voidzero-dev/vite-plus-test': 0.1.16(@types/node@25.5.2)(happy-dom@20.8.9)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(typescript@6.0.2)(vite@8.0.3(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/node@25.5.2)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))(yaml@2.8.3)
oxfmt: 0.43.0
oxlint: 1.58.0(oxlint-tsgolint@0.20.0)
oxlint-tsgolint: 0.20.0
optionalDependencies:
'@voidzero-dev/vite-plus-darwin-arm64': 0.1.16
'@voidzero-dev/vite-plus-darwin-x64': 0.1.16
'@voidzero-dev/vite-plus-linux-arm64-gnu': 0.1.16
'@voidzero-dev/vite-plus-linux-arm64-musl': 0.1.16
'@voidzero-dev/vite-plus-linux-x64-gnu': 0.1.16
'@voidzero-dev/vite-plus-linux-x64-musl': 0.1.16
'@voidzero-dev/vite-plus-win32-arm64-msvc': 0.1.16
'@voidzero-dev/vite-plus-win32-x64-msvc': 0.1.16
transitivePeerDependencies:
- '@arethetypeswrong/core'
- '@edge-runtime/vm'
- '@opentelemetry/api'
- '@tsdown/css'
- '@tsdown/exe'
- '@types/node'
- '@vitejs/devtools'
- '@vitest/ui'
- bufferutil
- esbuild
- happy-dom
- jiti
- jsdom
- less
- publint
- sass
- sass-embedded
- stylus
- sugarss
- terser
- tsx
- typescript
- unplugin-unused
- utf-8-validate
- vite
- yaml
vite-tsconfig-paths@5.1.4(@voidzero-dev/vite-plus-core@0.1.16(@types/node@25.5.2)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3))(typescript@6.0.2):
dependencies:
debug: 4.4.3(supports-color@8.1.1)
@ -16959,25 +16643,6 @@ snapshots:
- supports-color
- typescript
vite@8.0.3(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/node@25.5.2)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3):
dependencies:
lightningcss: 1.32.0
picomatch: 4.0.4
postcss: 8.5.9
rolldown: 1.0.0-rc.12(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)
tinyglobby: 0.2.15
optionalDependencies:
'@types/node': 25.5.2
fsevents: 2.3.3
jiti: 2.6.1
sass: 1.98.0
terser: 5.46.1
tsx: 4.21.0
yaml: 2.8.3
transitivePeerDependencies:
- '@emnapi/core'
- '@emnapi/runtime'
vitefu@1.1.3(@voidzero-dev/vite-plus-core@0.1.16(@types/node@25.5.2)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3)):
optionalDependencies:
vite: '@voidzero-dev/vite-plus-core@0.1.16(@types/node@25.5.2)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3)'

View File

@ -219,7 +219,7 @@ catalog:
unist-util-visit: 5.1.0
use-context-selector: 2.0.0
uuid: 13.0.0
vinext: 0.0.40
vinext: https://pkg.pr.new/vinext@fd532d3
vite: npm:@voidzero-dev/vite-plus-core@0.1.16
vite-plugin-inspect: 12.0.0-beta.1
vite-plus: 0.1.16

View File

@ -62,6 +62,7 @@
"@vitest/coverage-v8": "catalog:",
"eslint": "catalog:",
"typescript": "catalog:",
"vite": "catalog:",
"vite-plus": "catalog:",
"vitest": "catalog:"
}

View File

@ -48,6 +48,9 @@ NEXT_PUBLIC_CSP_WHITELIST=
# Default is not allow to embed into iframe to prevent Clickjacking: https://owasp.org/www-community/attacks/Clickjacking
NEXT_PUBLIC_ALLOW_EMBED=
# Allow inline style attributes in Markdown rendering (self-hosted opt-in).
NEXT_PUBLIC_ALLOW_INLINE_STYLES=false
# Allow rendering unsafe URLs which have "data:" scheme.
NEXT_PUBLIC_ALLOW_UNSAFE_DATA_SCHEME=false

View File

@ -42,7 +42,7 @@ COPY . .
WORKDIR /app/web
ENV NODE_OPTIONS="--max-old-space-size=4096"
RUN pnpm build
RUN pnpm build && pnpm build:vinext
# production stage
@ -56,6 +56,7 @@ ENV APP_API_URL=http://127.0.0.1:5001
ENV MARKETPLACE_API_URL=https://marketplace.dify.ai
ENV MARKETPLACE_URL=https://marketplace.dify.ai
ENV PORT=3000
ENV EXPERIMENTAL_ENABLE_VINEXT=false
ENV NEXT_TELEMETRY_DISABLED=1
# set timezone
@ -73,9 +74,10 @@ RUN addgroup -S -g ${dify_uid} dify && \
WORKDIR /app
COPY --from=builder --chown=dify:dify /app/web/public ./web/public
COPY --from=builder --chown=dify:dify /app/web/.next/standalone ./
COPY --from=builder --chown=dify:dify /app/web/.next/static ./web/.next/static
COPY --from=builder --chown=dify:dify /app/web/public ./targets/next/web/public
COPY --from=builder --chown=dify:dify /app/web/.next/standalone ./targets/next/
COPY --from=builder --chown=dify:dify /app/web/.next/static ./targets/next/web/.next/static
COPY --from=builder --chown=dify:dify /app/web/dist/standalone ./targets/vinext
COPY --chown=dify:dify --chmod=755 web/docker/entrypoint.sh ./entrypoint.sh

View File

@ -0,0 +1,10 @@
import { render, screen } from '@testing-library/react'
import HitHistoryNoData from '../hit-history-no-data'
describe('HitHistoryNoData', () => {
it('should render the empty history message', () => {
render(<HitHistoryNoData />)
expect(screen.getByText('appAnnotation.viewModal.noHitHistory')).toBeInTheDocument()
})
})

View File

@ -0,0 +1,32 @@
/* eslint-disable ts/no-explicit-any */
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import AccessControlDialog from '../access-control-dialog'
describe('AccessControlDialog', () => {
it('should render dialog content when visible', () => {
render(
<AccessControlDialog show className="custom-dialog">
<div>Dialog Content</div>
</AccessControlDialog>,
)
expect(screen.getByRole('dialog')).toBeInTheDocument()
expect(screen.getByText('Dialog Content')).toBeInTheDocument()
})
it('should trigger onClose when clicking the close control', async () => {
const onClose = vi.fn()
render(
<AccessControlDialog show onClose={onClose}>
<div>Dialog Content</div>
</AccessControlDialog>,
)
const closeButton = document.body.querySelector('div.absolute.right-5.top-5') as HTMLElement
fireEvent.click(closeButton)
await waitFor(() => {
expect(onClose).toHaveBeenCalledTimes(1)
})
})
})

View File

@ -0,0 +1,45 @@
import { fireEvent, render, screen } from '@testing-library/react'
import useAccessControlStore from '@/context/access-control-store'
import { AccessMode } from '@/models/access-control'
import AccessControlItem from '../access-control-item'
describe('AccessControlItem', () => {
beforeEach(() => {
vi.clearAllMocks()
useAccessControlStore.setState({
appId: '',
specificGroups: [],
specificMembers: [],
currentMenu: AccessMode.PUBLIC,
selectedGroupsForBreadcrumb: [],
})
})
it('should update current menu when selecting a different access type', () => {
render(
<AccessControlItem type={AccessMode.ORGANIZATION}>
<span>Organization Only</span>
</AccessControlItem>,
)
const option = screen.getByText('Organization Only').parentElement as HTMLElement
fireEvent.click(option)
expect(useAccessControlStore.getState().currentMenu).toBe(AccessMode.ORGANIZATION)
})
it('should keep the selected state for the active access type', () => {
useAccessControlStore.setState({
currentMenu: AccessMode.ORGANIZATION,
})
render(
<AccessControlItem type={AccessMode.ORGANIZATION}>
<span>Organization Only</span>
</AccessControlItem>,
)
const option = screen.getByText('Organization Only').parentElement as HTMLElement
expect(option).toHaveClass('border-components-option-card-option-selected-border')
})
})

View File

@ -0,0 +1,130 @@
import type { AccessControlAccount, AccessControlGroup, Subject } from '@/models/access-control'
import { fireEvent, render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import useAccessControlStore from '@/context/access-control-store'
import { SubjectType } from '@/models/access-control'
import AddMemberOrGroupDialog from '../add-member-or-group-pop'
const mockUseSearchForWhiteListCandidates = vi.fn()
const intersectionObserverMocks = vi.hoisted(() => ({
callback: null as null | ((entries: Array<{ isIntersecting: boolean }>) => void),
}))
vi.mock('@/context/app-context', () => ({
useSelector: <T,>(selector: (value: { userProfile: { email: string } }) => T) => selector({
userProfile: {
email: 'member@example.com',
},
}),
}))
vi.mock('@/service/access-control', () => ({
useSearchForWhiteListCandidates: (...args: unknown[]) => mockUseSearchForWhiteListCandidates(...args),
}))
const createGroup = (overrides: Partial<AccessControlGroup> = {}): AccessControlGroup => ({
id: 'group-1',
name: 'Group One',
groupSize: 5,
...overrides,
} as AccessControlGroup)
const createMember = (overrides: Partial<AccessControlAccount> = {}): AccessControlAccount => ({
id: 'member-1',
name: 'Member One',
email: 'member@example.com',
avatar: '',
avatarUrl: '',
...overrides,
} as AccessControlAccount)
describe('AddMemberOrGroupDialog', () => {
const baseGroup = createGroup()
const baseMember = createMember()
const groupSubject: Subject = {
subjectId: baseGroup.id,
subjectType: SubjectType.GROUP,
groupData: baseGroup,
} as Subject
const memberSubject: Subject = {
subjectId: baseMember.id,
subjectType: SubjectType.ACCOUNT,
accountData: baseMember,
} as Subject
beforeAll(() => {
class MockIntersectionObserver {
constructor(callback: (entries: Array<{ isIntersecting: boolean }>) => void) {
intersectionObserverMocks.callback = callback
}
observe = vi.fn(() => undefined)
disconnect = vi.fn(() => undefined)
unobserve = vi.fn(() => undefined)
}
// @ts-expect-error test DOM typings do not guarantee IntersectionObserver here
globalThis.IntersectionObserver = MockIntersectionObserver
})
beforeEach(() => {
vi.clearAllMocks()
useAccessControlStore.setState({
appId: 'app-1',
specificGroups: [],
specificMembers: [],
currentMenu: SubjectType.GROUP as never,
selectedGroupsForBreadcrumb: [],
})
mockUseSearchForWhiteListCandidates.mockReturnValue({
isLoading: false,
isFetchingNextPage: false,
fetchNextPage: vi.fn(),
data: {
pages: [{ currPage: 1, subjects: [groupSubject, memberSubject], hasMore: false }],
},
})
})
it('should open the search popover and display candidates', async () => {
const user = userEvent.setup()
render(<AddMemberOrGroupDialog />)
await user.click(screen.getByText('common.operation.add'))
expect(screen.getByPlaceholderText('app.accessControlDialog.operateGroupAndMember.searchPlaceholder')).toBeInTheDocument()
expect(screen.getByText(baseGroup.name)).toBeInTheDocument()
expect(screen.getByText(baseMember.name)).toBeInTheDocument()
})
it('should allow expanding groups and selecting members', async () => {
const user = userEvent.setup()
render(<AddMemberOrGroupDialog />)
await user.click(screen.getByText('common.operation.add'))
await user.click(screen.getByText('app.accessControlDialog.operateGroupAndMember.expand'))
expect(useAccessControlStore.getState().selectedGroupsForBreadcrumb).toEqual([baseGroup])
const memberCheckbox = screen.getByText(baseMember.name).parentElement?.previousElementSibling as HTMLElement
fireEvent.click(memberCheckbox)
expect(useAccessControlStore.getState().specificMembers).toEqual([baseMember])
})
it('should show the empty state when no candidates are returned', async () => {
mockUseSearchForWhiteListCandidates.mockReturnValue({
isLoading: false,
isFetchingNextPage: false,
fetchNextPage: vi.fn(),
data: { pages: [] },
})
const user = userEvent.setup()
render(<AddMemberOrGroupDialog />)
await user.click(screen.getByText('common.operation.add'))
expect(screen.getByText('app.accessControlDialog.operateGroupAndMember.noResult')).toBeInTheDocument()
})
})

View File

@ -0,0 +1,121 @@
/* eslint-disable ts/no-explicit-any */
import type { App } from '@/types/app'
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import { toast } from '@/app/components/base/ui/toast'
import useAccessControlStore from '@/context/access-control-store'
import { AccessMode } from '@/models/access-control'
import AccessControl from '../index'
const mockMutateAsync = vi.fn()
const mockUseUpdateAccessMode = vi.fn(() => ({
isPending: false,
mutateAsync: mockMutateAsync,
}))
const mockUseAppWhiteListSubjects = vi.fn()
const mockUseSearchForWhiteListCandidates = vi.fn()
let mockWebappAuth = {
enabled: true,
allow_sso: true,
allow_email_password_login: false,
allow_email_code_login: false,
}
vi.mock('@/context/global-public-context', () => ({
useGlobalPublicStore: (selector: (state: { systemFeatures: { webapp_auth: typeof mockWebappAuth } }) => unknown) => selector({
systemFeatures: {
webapp_auth: mockWebappAuth,
},
}),
}))
vi.mock('@/service/access-control', () => ({
useAppWhiteListSubjects: (...args: unknown[]) => mockUseAppWhiteListSubjects(...args),
useSearchForWhiteListCandidates: (...args: unknown[]) => mockUseSearchForWhiteListCandidates(...args),
useUpdateAccessMode: () => mockUseUpdateAccessMode(),
}))
describe('AccessControl', () => {
beforeEach(() => {
vi.clearAllMocks()
mockWebappAuth = {
enabled: true,
allow_sso: true,
allow_email_password_login: false,
allow_email_code_login: false,
}
useAccessControlStore.setState({
appId: '',
specificGroups: [],
specificMembers: [],
currentMenu: AccessMode.SPECIFIC_GROUPS_MEMBERS,
selectedGroupsForBreadcrumb: [],
})
mockMutateAsync.mockResolvedValue(undefined)
mockUseAppWhiteListSubjects.mockReturnValue({
isPending: false,
data: {
groups: [],
members: [],
},
})
mockUseSearchForWhiteListCandidates.mockReturnValue({
isLoading: false,
isFetchingNextPage: false,
fetchNextPage: vi.fn(),
data: { pages: [] },
})
})
it('should initialize menu from the app and update access mode on confirm', async () => {
const onClose = vi.fn()
const onConfirm = vi.fn()
const toastSpy = vi.spyOn(toast, 'success').mockReturnValue('toast-success')
const app = {
id: 'app-id-1',
access_mode: AccessMode.PUBLIC,
} as App
render(
<AccessControl
app={app}
onClose={onClose}
onConfirm={onConfirm}
/>,
)
await waitFor(() => {
expect(useAccessControlStore.getState().appId).toBe(app.id)
expect(useAccessControlStore.getState().currentMenu).toBe(AccessMode.PUBLIC)
})
fireEvent.click(screen.getByText('common.operation.confirm'))
await waitFor(() => {
expect(mockMutateAsync).toHaveBeenCalledWith({
appId: app.id,
accessMode: AccessMode.PUBLIC,
})
expect(toastSpy).toHaveBeenCalledWith('app.accessControlDialog.updateSuccess')
expect(onConfirm).toHaveBeenCalledTimes(1)
})
})
it('should show the external-members option when SSO tip is visible', () => {
mockWebappAuth = {
enabled: false,
allow_sso: false,
allow_email_password_login: false,
allow_email_code_login: false,
}
render(
<AccessControl
app={{ id: 'app-id-2', access_mode: AccessMode.PUBLIC } as App}
onClose={vi.fn()}
/>,
)
expect(screen.getByText('app.accessControlDialog.accessItems.external')).toBeInTheDocument()
expect(screen.getByText('app.accessControlDialog.accessItems.anyone')).toBeInTheDocument()
})
})

View File

@ -0,0 +1,97 @@
import type { AccessControlAccount, AccessControlGroup } from '@/models/access-control'
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import useAccessControlStore from '@/context/access-control-store'
import { AccessMode } from '@/models/access-control'
import SpecificGroupsOrMembers from '../specific-groups-or-members'
const mockUseAppWhiteListSubjects = vi.fn()
vi.mock('@/service/access-control', () => ({
useAppWhiteListSubjects: (...args: unknown[]) => mockUseAppWhiteListSubjects(...args),
}))
vi.mock('../add-member-or-group-pop', () => ({
default: () => <div data-testid="add-member-or-group-dialog" />,
}))
const createGroup = (overrides: Partial<AccessControlGroup> = {}): AccessControlGroup => ({
id: 'group-1',
name: 'Group One',
groupSize: 5,
...overrides,
} as AccessControlGroup)
const createMember = (overrides: Partial<AccessControlAccount> = {}): AccessControlAccount => ({
id: 'member-1',
name: 'Member One',
email: 'member@example.com',
avatar: '',
avatarUrl: '',
...overrides,
} as AccessControlAccount)
describe('SpecificGroupsOrMembers', () => {
const baseGroup = createGroup()
const baseMember = createMember()
beforeEach(() => {
vi.clearAllMocks()
useAccessControlStore.setState({
appId: '',
specificGroups: [],
specificMembers: [],
currentMenu: AccessMode.SPECIFIC_GROUPS_MEMBERS,
selectedGroupsForBreadcrumb: [],
})
mockUseAppWhiteListSubjects.mockReturnValue({
isPending: false,
data: {
groups: [baseGroup],
members: [baseMember],
},
})
})
it('should render the collapsed row when not in specific mode', () => {
useAccessControlStore.setState({
currentMenu: AccessMode.ORGANIZATION,
})
render(<SpecificGroupsOrMembers />)
expect(screen.getByText('app.accessControlDialog.accessItems.specific')).toBeInTheDocument()
expect(screen.queryByTestId('add-member-or-group-dialog')).not.toBeInTheDocument()
})
it('should show loading while whitelist subjects are pending', async () => {
mockUseAppWhiteListSubjects.mockReturnValue({
isPending: true,
data: undefined,
})
const { container } = render(<SpecificGroupsOrMembers />)
await waitFor(() => {
expect(container.querySelector('.spin-animation')).toBeInTheDocument()
})
})
it('should render fetched groups and members and support removal', async () => {
useAccessControlStore.setState({ appId: 'app-1' })
render(<SpecificGroupsOrMembers />)
await waitFor(() => {
expect(screen.getByText(baseGroup.name)).toBeInTheDocument()
expect(screen.getByText(baseMember.name)).toBeInTheDocument()
})
const groupRemove = screen.getByText(baseGroup.name).closest('div')?.querySelector('.h-4.w-4.cursor-pointer') as HTMLElement
fireEvent.click(groupRemove)
expect(useAccessControlStore.getState().specificGroups).toEqual([])
const memberRemove = screen.getByText(baseMember.name).closest('div')?.querySelector('.h-4.w-4.cursor-pointer') as HTMLElement
fireEvent.click(memberRemove)
expect(useAccessControlStore.getState().specificMembers).toEqual([])
})
})

View File

@ -0,0 +1,26 @@
import { render, screen } from '@testing-library/react'
import { InputVarType } from '@/app/components/workflow/types'
import InputTypeIcon from '../input-type-icon'
const mockInputVarTypeIcon = vi.fn(({ type, className }: { type: InputVarType, className?: string }) => (
<div data-testid="input-var-type-icon" data-type={type} className={className} />
))
vi.mock('@/app/components/workflow/nodes/_base/components/input-var-type-icon', () => ({
default: (props: { type: InputVarType, className?: string }) => mockInputVarTypeIcon(props),
}))
describe('InputTypeIcon', () => {
it('should map string variables to the workflow text-input icon', () => {
render(<InputTypeIcon type="string" className="marker" />)
expect(screen.getByTestId('input-var-type-icon')).toHaveAttribute('data-type', InputVarType.textInput)
expect(screen.getByTestId('input-var-type-icon')).toHaveClass('marker')
})
it('should map select variables to the workflow select icon', () => {
render(<InputTypeIcon type="select" className="marker" />)
expect(screen.getByTestId('input-var-type-icon')).toHaveAttribute('data-type', InputVarType.select)
})
})

View File

@ -0,0 +1,19 @@
import { fireEvent, render, screen } from '@testing-library/react'
import ModalFoot from '../modal-foot'
describe('ModalFoot', () => {
it('should trigger cancel and confirm callbacks', () => {
const onCancel = vi.fn()
const onConfirm = vi.fn()
render(
<ModalFoot onCancel={onCancel} onConfirm={onConfirm} />,
)
fireEvent.click(screen.getByRole('button', { name: 'common.operation.cancel' }))
fireEvent.click(screen.getByRole('button', { name: 'common.operation.save' }))
expect(onCancel).toHaveBeenCalledTimes(1)
expect(onConfirm).toHaveBeenCalledTimes(1)
})
})

View File

@ -0,0 +1,16 @@
import { fireEvent, render, screen } from '@testing-library/react'
import SelectVarType from '../select-var-type'
describe('SelectVarType', () => {
it('should open the menu and return the selected variable type', () => {
const onChange = vi.fn()
render(<SelectVarType onChange={onChange} />)
fireEvent.click(screen.getByText('common.operation.add'))
fireEvent.click(screen.getByText('appDebug.variableConfig.checkbox'))
expect(onChange).toHaveBeenCalledWith('checkbox')
expect(screen.queryByText('appDebug.variableConfig.checkbox')).not.toBeInTheDocument()
})
})

View File

@ -0,0 +1,46 @@
import { fireEvent, render, screen } from '@testing-library/react'
import VarItem from '../var-item'
describe('VarItem', () => {
it('should render variable metadata and allow editing', () => {
const onEdit = vi.fn()
const onRemove = vi.fn()
const { container } = render(
<VarItem
canDrag
name="api_key"
label="API Key"
required
type="string"
onEdit={onEdit}
onRemove={onRemove}
/>,
)
expect(screen.getByTitle('api_key · API Key')).toBeInTheDocument()
expect(screen.getByText('required')).toBeInTheDocument()
const editButton = container.querySelector('.mr-1.flex.h-6.w-6') as HTMLElement
fireEvent.click(editButton)
expect(onEdit).toHaveBeenCalledTimes(1)
})
it('should call remove when clicking the delete action', () => {
const onRemove = vi.fn()
render(
<VarItem
name="region"
label="Region"
required={false}
type="select"
onEdit={vi.fn()}
onRemove={onRemove}
/>,
)
fireEvent.click(screen.getByTestId('var-item-delete-btn'))
expect(onRemove).toHaveBeenCalledTimes(1)
})
})

View File

@ -0,0 +1,23 @@
import { jsonConfigPlaceHolder } from '../config'
describe('config modal placeholder config', () => {
it('should contain a valid object schema example', () => {
const parsed = JSON.parse(jsonConfigPlaceHolder) as {
type: string
properties: {
foo: { type: string }
bar: {
type: string
properties: {
sub: { type: string }
}
}
}
}
expect(parsed.type).toBe('object')
expect(parsed.properties.foo.type).toBe('string')
expect(parsed.properties.bar.type).toBe('object')
expect(parsed.properties.bar.properties.sub.type).toBe('number')
})
})

View File

@ -0,0 +1,25 @@
import { render, screen } from '@testing-library/react'
import Field from '../field'
describe('ConfigModal Field', () => {
it('should render the title and children', () => {
render(
<Field title="Field title">
<input aria-label="field-input" />
</Field>,
)
expect(screen.getByText('Field title')).toBeInTheDocument()
expect(screen.getByLabelText('field-input')).toBeInTheDocument()
})
it('should render the optional hint when requested', () => {
render(
<Field title="Optional field" isOptional>
<input aria-label="optional-field-input" />
</Field>,
)
expect(screen.getByText(/\(appDebug\.variableConfig\.optional\)/)).toBeInTheDocument()
})
})

View File

@ -0,0 +1,74 @@
import type { FeatureStoreState } from '@/app/components/base/features/store'
import type { FileUpload } from '@/app/components/base/features/types'
import { fireEvent, render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { Resolution, TransferMethod } from '@/types/app'
import ParamConfigContent from '../param-config-content'
const mockUseFeatures = vi.fn()
const mockUseFeaturesStore = vi.fn()
const mockSetFeatures = vi.fn()
vi.mock('@/app/components/base/features/hooks', () => ({
useFeatures: (selector: (state: FeatureStoreState) => unknown) => mockUseFeatures(selector),
useFeaturesStore: () => mockUseFeaturesStore(),
}))
const setupFeatureStore = (fileOverrides: Partial<FileUpload> = {}) => {
const file: FileUpload = {
enabled: true,
allowed_file_types: [],
allowed_file_upload_methods: [TransferMethod.local_file, TransferMethod.remote_url],
number_limits: 3,
image: {
enabled: true,
detail: Resolution.low,
number_limits: 3,
transfer_methods: [TransferMethod.local_file, TransferMethod.remote_url],
},
...fileOverrides,
}
const featureStoreState = {
features: { file },
setFeatures: mockSetFeatures,
showFeaturesModal: false,
setShowFeaturesModal: vi.fn(),
} as unknown as FeatureStoreState
mockUseFeatures.mockImplementation(selector => selector(featureStoreState))
mockUseFeaturesStore.mockReturnValue({
getState: () => featureStoreState,
})
}
const getUpdatedFile = () => {
expect(mockSetFeatures).toHaveBeenCalled()
return mockSetFeatures.mock.calls.at(-1)?.[0].file as FileUpload
}
describe('ParamConfigContent', () => {
beforeEach(() => {
vi.clearAllMocks()
setupFeatureStore()
})
it('should update the image resolution', async () => {
const user = userEvent.setup()
render(<ParamConfigContent />)
await user.click(screen.getByText('appDebug.vision.visionSettings.high'))
expect(getUpdatedFile().image?.detail).toBe(Resolution.high)
})
it('should update upload methods and upload limit', async () => {
const user = userEvent.setup()
render(<ParamConfigContent />)
await user.click(screen.getByText('appDebug.vision.visionSettings.localUpload'))
expect(getUpdatedFile().allowed_file_upload_methods).toEqual([TransferMethod.local_file])
fireEvent.change(screen.getByRole('textbox'), { target: { value: '5' } })
expect(getUpdatedFile().number_limits).toBe(5)
})
})

View File

@ -0,0 +1,58 @@
import type { FeatureStoreState } from '@/app/components/base/features/store'
import type { FileUpload } from '@/app/components/base/features/types'
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { Resolution, TransferMethod } from '@/types/app'
import ParamConfig from '../param-config'
const mockUseFeatures = vi.fn()
const mockUseFeaturesStore = vi.fn()
vi.mock('@/app/components/base/features/hooks', () => ({
useFeatures: (selector: (state: FeatureStoreState) => unknown) => mockUseFeatures(selector),
useFeaturesStore: () => mockUseFeaturesStore(),
}))
const setupFeatureStore = (fileOverrides: Partial<FileUpload> = {}) => {
const file: FileUpload = {
enabled: true,
allowed_file_types: [],
allowed_file_upload_methods: [TransferMethod.local_file, TransferMethod.remote_url],
number_limits: 3,
image: {
enabled: true,
detail: Resolution.low,
number_limits: 3,
transfer_methods: [TransferMethod.local_file, TransferMethod.remote_url],
},
...fileOverrides,
}
const featureStoreState = {
features: { file },
setFeatures: vi.fn(),
showFeaturesModal: false,
setShowFeaturesModal: vi.fn(),
} as unknown as FeatureStoreState
mockUseFeatures.mockImplementation(selector => selector(featureStoreState))
mockUseFeaturesStore.mockReturnValue({
getState: () => featureStoreState,
})
}
describe('ParamConfig', () => {
beforeEach(() => {
vi.clearAllMocks()
setupFeatureStore()
})
it('should toggle the settings panel when clicking the trigger', async () => {
const user = userEvent.setup()
render(<ParamConfig />)
expect(screen.queryByText('appDebug.vision.visionSettings.title')).not.toBeInTheDocument()
await user.click(screen.getByRole('button', { name: 'appDebug.voice.settings' }))
expect(await screen.findByText('appDebug.vision.visionSettings.title')).toBeInTheDocument()
})
})

View File

@ -0,0 +1,22 @@
import { fireEvent, render, screen } from '@testing-library/react'
import PromptToast from '../prompt-toast'
describe('PromptToast', () => {
it('should render the note title and markdown message', () => {
render(<PromptToast message="Prompt body" />)
expect(screen.getByText('appDebug.generate.optimizationNote')).toBeInTheDocument()
expect(screen.getByTestId('markdown-body')).toBeInTheDocument()
})
it('should collapse and expand the markdown content', () => {
const { container } = render(<PromptToast message="Prompt body" />)
const toggle = container.querySelector('.cursor-pointer') as HTMLElement
fireEvent.click(toggle)
expect(screen.queryByTestId('markdown-body')).not.toBeInTheDocument()
fireEvent.click(toggle)
expect(screen.getByTestId('markdown-body')).toBeInTheDocument()
})
})

View File

@ -0,0 +1,10 @@
import { render, screen } from '@testing-library/react'
import ResPlaceholder from '../res-placeholder'
describe('ResPlaceholder', () => {
it('should render the placeholder copy', () => {
render(<ResPlaceholder />)
expect(screen.getByText('appDebug.generate.newNoDataLine1')).toBeInTheDocument()
})
})

View File

@ -0,0 +1,39 @@
import type { GenRes } from '@/service/debug'
import { act, renderHook } from '@testing-library/react'
import { beforeEach, describe, expect, it } from 'vitest'
import useGenData from '../use-gen-data'
describe('useGenData', () => {
beforeEach(() => {
sessionStorage.clear()
})
it('should start with an empty version list', () => {
const { result } = renderHook(() => useGenData({ storageKey: 'prompt' }))
expect(result.current.versions).toEqual([])
expect(result.current.currentVersionIndex).toBe(0)
expect(result.current.current).toBeUndefined()
})
it('should append versions and keep the latest one selected', () => {
const versionOne = { modified: 'first version' } as GenRes
const versionTwo = { modified: 'second version' } as GenRes
const { result } = renderHook(() => useGenData({ storageKey: 'prompt' }))
act(() => {
result.current.addVersion(versionOne)
})
expect(result.current.versions).toEqual([versionOne])
expect(result.current.current).toEqual(versionOne)
act(() => {
result.current.addVersion(versionTwo)
})
expect(result.current.versions).toEqual([versionOne, versionTwo])
expect(result.current.currentVersionIndex).toBe(1)
expect(result.current.current).toEqual(versionTwo)
})
})

View File

@ -0,0 +1,38 @@
import { render, screen } from '@testing-library/react'
import { useDebugWithMultipleModelContext } from '../context'
import { DebugWithMultipleModelContextProvider } from '../context-provider'
const ContextConsumer = () => {
const value = useDebugWithMultipleModelContext()
return (
<div>
<div>{value.multipleModelConfigs.length}</div>
<button onClick={() => value.onMultipleModelConfigsChange(true, value.multipleModelConfigs)}>change-multiple</button>
<button onClick={() => value.onDebugWithMultipleModelChange(value.multipleModelConfigs[0])}>change-single</button>
<div>{String(value.checkCanSend?.())}</div>
</div>
)
}
describe('DebugWithMultipleModelContextProvider', () => {
it('should expose the provided context value to descendants', () => {
const onMultipleModelConfigsChange = vi.fn()
const onDebugWithMultipleModelChange = vi.fn()
const checkCanSend = vi.fn(() => true)
const multipleModelConfigs = [{ model: 'gpt-4o' }] as unknown as []
render(
<DebugWithMultipleModelContextProvider
multipleModelConfigs={multipleModelConfigs}
onMultipleModelConfigsChange={onMultipleModelConfigsChange}
onDebugWithMultipleModelChange={onDebugWithMultipleModelChange}
checkCanSend={checkCanSend}
>
<ContextConsumer />
</DebugWithMultipleModelContextProvider>,
)
expect(screen.getByText('1')).toBeInTheDocument()
expect(screen.getByText('true')).toBeInTheDocument()
})
})

View File

@ -0,0 +1,103 @@
import type { AppDetailResponse } from '@/models/app'
import { fireEvent, render, screen } from '@testing-library/react'
import { AccessMode } from '@/models/access-control'
import { AppModeEnum } from '@/types/app'
import { AppCardAccessControlSection, AppCardOperations, createAppCardOperations } from '../app-card-sections'
describe('app-card-sections', () => {
const t = (key: string) => key
it('should build operations with the expected disabled state', () => {
const onLaunch = vi.fn()
const operations = createAppCardOperations({
operationKeys: ['launch', 'settings'],
t: t as never,
runningStatus: false,
triggerModeDisabled: false,
onLaunch,
onEmbedded: vi.fn(),
onCustomize: vi.fn(),
onSettings: vi.fn(),
onDevelop: vi.fn(),
})
expect(operations[0]).toMatchObject({
key: 'launch',
disabled: true,
label: 'overview.appInfo.launch',
})
expect(operations[1]).toMatchObject({
key: 'settings',
disabled: false,
label: 'overview.appInfo.settings.entry',
})
})
it('should render the access-control section and call onClick', () => {
const onClick = vi.fn()
render(
<AppCardAccessControlSection
t={t as never}
appDetail={{ access_mode: AccessMode.SPECIFIC_GROUPS_MEMBERS } as AppDetailResponse}
isAppAccessSet={false}
onClick={onClick}
/>,
)
fireEvent.click(screen.getByText('publishApp.notSet'))
expect(screen.getByText('accessControlDialog.accessItems.specific')).toBeInTheDocument()
expect(onClick).toHaveBeenCalledTimes(1)
})
it('should render operation buttons and execute enabled actions', () => {
const onLaunch = vi.fn()
const operations = createAppCardOperations({
operationKeys: ['launch', 'embedded'],
t: t as never,
runningStatus: true,
triggerModeDisabled: false,
onLaunch,
onEmbedded: vi.fn(),
onCustomize: vi.fn(),
onSettings: vi.fn(),
onDevelop: vi.fn(),
})
render(
<AppCardOperations
t={t as never}
operations={operations}
/>,
)
fireEvent.click(screen.getByRole('button', { name: /overview\.appInfo\.launch/i }))
expect(onLaunch).toHaveBeenCalledTimes(1)
expect(screen.getByRole('button', { name: /overview\.appInfo\.embedded\.entry/i })).toBeInTheDocument()
})
it('should keep customize available for web app cards that are not completion or workflow apps', () => {
const operations = createAppCardOperations({
operationKeys: ['customize'],
t: t as never,
runningStatus: true,
triggerModeDisabled: false,
onLaunch: vi.fn(),
onEmbedded: vi.fn(),
onCustomize: vi.fn(),
onSettings: vi.fn(),
onDevelop: vi.fn(),
})
render(
<AppCardOperations
t={t as never}
operations={operations}
/>,
)
expect(screen.getByText('overview.appInfo.customize.entry')).toBeInTheDocument()
expect(AppModeEnum.CHAT).toBe('chat')
})
})

View File

@ -0,0 +1,107 @@
import type { AppDetailResponse } from '@/models/app'
import { BlockEnum } from '@/app/components/workflow/types'
import { AccessMode } from '@/models/access-control'
import { AppModeEnum } from '@/types/app'
import { basePath } from '@/utils/var'
import { getAppCardDisplayState, getAppCardOperationKeys, hasWorkflowStartNode, isAppAccessConfigured } from '../app-card-utils'
describe('app-card-utils', () => {
const baseAppInfo = {
id: 'app-1',
mode: AppModeEnum.CHAT,
enable_site: true,
enable_api: false,
access_mode: AccessMode.PUBLIC,
api_base_url: 'https://api.example.com',
site: {
app_base_url: 'https://example.com',
access_token: 'token-1',
},
} as AppDetailResponse
it('should detect whether the workflow includes a start node', () => {
expect(hasWorkflowStartNode({
graph: {
nodes: [{ data: { type: BlockEnum.Start } }],
},
})).toBe(true)
expect(hasWorkflowStartNode({
graph: {
nodes: [{ data: { type: BlockEnum.Answer } }],
},
})).toBe(false)
})
it('should build the display state for a published web app', () => {
const state = getAppCardDisplayState({
appInfo: baseAppInfo,
cardType: 'webapp',
currentWorkflow: null,
isCurrentWorkspaceEditor: true,
isCurrentWorkspaceManager: true,
})
expect(state.isApp).toBe(true)
expect(state.appMode).toBe(AppModeEnum.CHAT)
expect(state.runningStatus).toBe(true)
expect(state.accessibleUrl).toBe(`https://example.com${basePath}/chat/token-1`)
})
it('should disable workflow cards without a graph or start node', () => {
const unpublishedState = getAppCardDisplayState({
appInfo: { ...baseAppInfo, mode: AppModeEnum.WORKFLOW },
cardType: 'webapp',
currentWorkflow: null,
isCurrentWorkspaceEditor: true,
isCurrentWorkspaceManager: true,
})
expect(unpublishedState.appUnpublished).toBe(true)
expect(unpublishedState.toggleDisabled).toBe(true)
const missingStartState = getAppCardDisplayState({
appInfo: { ...baseAppInfo, mode: AppModeEnum.WORKFLOW },
cardType: 'webapp',
currentWorkflow: {
graph: {
nodes: [{ data: { type: BlockEnum.Answer } }],
},
},
isCurrentWorkspaceEditor: true,
isCurrentWorkspaceManager: true,
})
expect(missingStartState.missingStartNode).toBe(true)
expect(missingStartState.runningStatus).toBe(false)
})
it('should require specific access subjects only for the specific access mode', () => {
expect(isAppAccessConfigured(
{ ...baseAppInfo, access_mode: AccessMode.PUBLIC },
{ groups: [], members: [] },
)).toBe(true)
expect(isAppAccessConfigured(
{ ...baseAppInfo, access_mode: AccessMode.SPECIFIC_GROUPS_MEMBERS },
{ groups: [], members: [] },
)).toBe(false)
expect(isAppAccessConfigured(
{ ...baseAppInfo, access_mode: AccessMode.SPECIFIC_GROUPS_MEMBERS },
{ groups: [{ id: 'group-1' }], members: [] },
)).toBe(true)
})
it('should derive operation keys for api and webapp cards', () => {
expect(getAppCardOperationKeys({
cardType: 'api',
appMode: AppModeEnum.COMPLETION,
isCurrentWorkspaceEditor: true,
})).toEqual(['develop'])
expect(getAppCardOperationKeys({
cardType: 'webapp',
appMode: AppModeEnum.CHAT,
isCurrentWorkspaceEditor: false,
})).toEqual(['launch', 'embedded', 'customize'])
})
})

View File

@ -16,7 +16,7 @@ import {
ThinkBlock,
VideoBlock,
} from '@/app/components/base/markdown-blocks'
import { ENABLE_SINGLE_DOLLAR_LATEX } from '@/config'
import { ALLOW_INLINE_STYLES, ENABLE_SINGLE_DOLLAR_LATEX } from '@/config'
import dynamic from '@/next/dynamic'
import { customUrlTransform } from './markdown-utils'
import 'katex/dist/katex.min.css'
@ -118,6 +118,11 @@ function buildRehypePlugins(extraPlugins?: PluggableList): PluggableList {
// component validates names with `isSafeName()`, so remove it.
const clobber = (defaultSanitizeSchema.clobber ?? []).filter(k => k !== 'name')
if (ALLOW_INLINE_STYLES) {
const globalAttrs = mergedAttributes['*'] ?? []
mergedAttributes['*'] = [...globalAttrs, 'style']
}
const customSchema: SanitizeSchema = {
...defaultSanitizeSchema,
tagNames: [...tagNamesSet],

View File

@ -120,7 +120,10 @@ describe('HITLInputBlock', () => {
})
await waitFor(() => {
expect(onWorkflowMapUpdate).toHaveBeenCalledWith(workflowNodesMap)
expect(onWorkflowMapUpdate).toHaveBeenCalledWith({
workflowNodesMap,
availableVariables: [],
})
})
})
})

View File

@ -148,7 +148,10 @@ describe('HITLInputVariableBlockComponent', () => {
editor!.update(() => {
$getRoot().selectEnd()
})
handled = editor!.dispatchCommand(UPDATE_WORKFLOW_NODES_MAP, createWorkflowNodesMap())
handled = editor!.dispatchCommand(UPDATE_WORKFLOW_NODES_MAP, {
workflowNodesMap: createWorkflowNodesMap(),
availableVariables: [],
})
})
expect(handled).toBe(true)

View File

@ -22,7 +22,7 @@ const HITLInputReplacementBlock = ({
onFormInputsChange,
onFormInputItemRename,
onFormInputItemRemove,
workflowNodesMap,
workflowNodesMap = {},
getVarType,
variables,
readonly,

View File

@ -14,6 +14,7 @@ import {
useEffect,
} from 'react'
import { CustomTextNode } from '../custom-text/node'
import { UPDATE_WORKFLOW_NODES_MAP as WORKFLOW_UPDATE_WORKFLOW_NODES_MAP } from '../workflow-variable-block'
import {
$createHITLInputNode,
HITLInputNode,
@ -21,11 +22,13 @@ import {
export const INSERT_HITL_INPUT_BLOCK_COMMAND = createCommand('INSERT_HITL_INPUT_BLOCK_COMMAND')
export const DELETE_HITL_INPUT_BLOCK_COMMAND = createCommand('DELETE_HITL_INPUT_BLOCK_COMMAND')
export const UPDATE_WORKFLOW_NODES_MAP = createCommand('UPDATE_WORKFLOW_NODES_MAP')
export const UPDATE_WORKFLOW_NODES_MAP = WORKFLOW_UPDATE_WORKFLOW_NODES_MAP
const HITLInputBlock = memo(({
onInsert,
onDelete,
workflowNodesMap,
workflowNodesMap = {},
variables: workflowAvailableVariables,
getVarType,
readonly,
}: HITLInputBlockType) => {
@ -33,9 +36,12 @@ const HITLInputBlock = memo(({
useEffect(() => {
editor.update(() => {
editor.dispatchCommand(UPDATE_WORKFLOW_NODES_MAP, workflowNodesMap)
editor.dispatchCommand(UPDATE_WORKFLOW_NODES_MAP, {
workflowNodesMap: workflowNodesMap || {},
availableVariables: workflowAvailableVariables || [],
})
})
}, [editor, workflowNodesMap])
}, [editor, workflowNodesMap, workflowAvailableVariables])
useEffect(() => {
if (!editor.hasNodes([HITLInputNode]))

View File

@ -1,3 +1,4 @@
import type { UpdateWorkflowNodesMapPayload } from '../workflow-variable-block'
import type { WorkflowNodesMap } from '../workflow-variable-block/node'
import type { ValueSelector, Var } from '@/app/components/workflow/types'
import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext'
@ -98,9 +99,8 @@ const HITLInputVariableBlockComponent = ({
return mergeRegister(
editor.registerCommand(
UPDATE_WORKFLOW_NODES_MAP,
(workflowNodesMap: WorkflowNodesMap) => {
setLocalWorkflowNodesMap(workflowNodesMap)
(payload: UpdateWorkflowNodesMapPayload) => {
setLocalWorkflowNodesMap(payload.workflowNodesMap)
return true
},
COMMAND_PRIORITY_EDITOR,

View File

@ -1,4 +1,5 @@
import type { LexicalEditor } from 'lexical'
import type { UpdateWorkflowNodesMapPayload } from '../index'
import type { ValueSelector, Var } from '@/app/components/workflow/types'
import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext'
import { mergeRegister } from '@lexical/utils'
@ -216,7 +217,7 @@ describe('WorkflowVariableBlockComponent', () => {
})
})
it('should mark env variable invalid when not found in environmentVariables', () => {
it('should treat env variable as valid regardless of environmentVariables contents', () => {
const environmentVariables: Var[] = [{ variable: 'env.valid_key', type: VarType.string }]
render(
@ -229,7 +230,7 @@ describe('WorkflowVariableBlockComponent', () => {
)
expect(mockVarLabel).toHaveBeenCalledWith(expect.objectContaining({
errorMsg: expect.any(String),
errorMsg: undefined,
}))
})
@ -281,7 +282,7 @@ describe('WorkflowVariableBlockComponent', () => {
}))
})
it('should evaluate env fallback selector tokens when classifier is forced', () => {
it('should mark forced env branch invalid when selector prefix is missing', () => {
mockForcedVariableKind.value = 'env'
const environmentVariables: Var[] = [{ variable: '.', type: VarType.string }]
@ -295,7 +296,7 @@ describe('WorkflowVariableBlockComponent', () => {
)
expect(mockVarLabel).toHaveBeenCalledWith(expect.objectContaining({
errorMsg: undefined,
errorMsg: expect.any(String),
}))
})
@ -330,7 +331,7 @@ describe('WorkflowVariableBlockComponent', () => {
}))
})
it('should mark conversation variable invalid when not found in conversationVariables', () => {
it('should treat conversation variable as valid regardless of conversationVariables contents', () => {
const conversationVariables: Var[] = [{ variable: 'conversation.other', type: VarType.string }]
render(
@ -343,7 +344,7 @@ describe('WorkflowVariableBlockComponent', () => {
)
expect(mockVarLabel).toHaveBeenCalledWith(expect.objectContaining({
errorMsg: expect.any(String),
errorMsg: undefined,
}))
})
@ -364,7 +365,7 @@ describe('WorkflowVariableBlockComponent', () => {
}))
})
it('should evaluate conversation fallback selector tokens when classifier is forced', () => {
it('should mark forced conversation branch invalid when selector prefix is missing', () => {
mockForcedVariableKind.value = 'conversation'
const conversationVariables: Var[] = [{ variable: '.', type: VarType.string }]
@ -378,7 +379,7 @@ describe('WorkflowVariableBlockComponent', () => {
)
expect(mockVarLabel).toHaveBeenCalledWith(expect.objectContaining({
errorMsg: undefined,
errorMsg: expect.any(String),
}))
})
@ -427,7 +428,7 @@ describe('WorkflowVariableBlockComponent', () => {
}))
})
it('should mark rag variable invalid when not found in ragVariables', () => {
it('should treat rag variable as valid regardless of ragVariables contents', () => {
const ragVariables: Var[] = [{ variable: 'rag.shared.other', type: VarType.string }]
render(
@ -440,7 +441,7 @@ describe('WorkflowVariableBlockComponent', () => {
)
expect(mockVarLabel).toHaveBeenCalledWith(expect.objectContaining({
errorMsg: expect.any(String),
errorMsg: undefined,
}))
})
@ -461,7 +462,7 @@ describe('WorkflowVariableBlockComponent', () => {
}))
})
it('should evaluate rag fallback selector tokens when classifier is forced', () => {
it('should mark forced rag branch invalid when selector prefix is missing', () => {
mockForcedVariableKind.value = 'rag'
const ragVariables: Var[] = [{ variable: '..', type: VarType.string }]
@ -475,7 +476,7 @@ describe('WorkflowVariableBlockComponent', () => {
)
expect(mockVarLabel).toHaveBeenCalledWith(expect.objectContaining({
errorMsg: undefined,
errorMsg: expect.any(String),
}))
})
@ -488,20 +489,81 @@ describe('WorkflowVariableBlockComponent', () => {
/>,
)
const updateHandler = mockRegisterCommand.mock.calls[0][1] as (map: Record<string, unknown>) => boolean
const updateHandler = mockRegisterCommand.mock.calls[0][1] as (payload: UpdateWorkflowNodesMapPayload) => boolean
let result = false
act(() => {
result = updateHandler({
'node-1': {
title: 'Updated',
type: BlockEnum.LLM,
width: 100,
height: 50,
position: { x: 0, y: 0 },
workflowNodesMap: {
'node-1': {
title: 'Updated',
type: BlockEnum.LLM,
width: 100,
height: 50,
position: { x: 0, y: 0 },
},
},
availableVariables: [],
})
})
expect(result).toBe(true)
})
it('should mark non-special variable invalid when source key is missing in availableVariables', () => {
render(
<WorkflowVariableBlockComponent
nodeKey="k"
variables={['node-1', 'missing_key']}
workflowNodesMap={{
'node-1': {
title: 'Node A',
type: BlockEnum.LLM,
width: 200,
height: 100,
position: { x: 0, y: 0 },
},
}}
availableVariables={[
{
nodeId: 'node-1',
title: 'Node A',
vars: [{ variable: 'existing_key', type: VarType.string }],
},
]}
/>,
)
expect(mockVarLabel).toHaveBeenCalledWith(expect.objectContaining({
errorMsg: expect.any(String),
}))
})
it('should keep non-special variable valid when source key exists in availableVariables', () => {
render(
<WorkflowVariableBlockComponent
nodeKey="k"
variables={['node-1', 'existing_key']}
workflowNodesMap={{
'node-1': {
title: 'Node A',
type: BlockEnum.LLM,
width: 200,
height: 100,
position: { x: 0, y: 0 },
},
}}
availableVariables={[
{
nodeId: 'node-1',
title: 'Node A',
vars: [{ variable: 'existing_key', type: VarType.string }],
},
]}
/>,
)
expect(mockVarLabel).toHaveBeenCalledWith(expect.objectContaining({
errorMsg: undefined,
}))
})
})

View File

@ -105,7 +105,10 @@ describe('WorkflowVariableBlock', () => {
)
expect(mockUpdate).toHaveBeenCalled()
expect(mockDispatchCommand).toHaveBeenCalledWith(UPDATE_WORKFLOW_NODES_MAP, workflowNodesMap)
expect(mockDispatchCommand).toHaveBeenCalledWith(UPDATE_WORKFLOW_NODES_MAP, {
workflowNodesMap,
availableVariables: [],
})
})
it('should throw when WorkflowVariableBlockNode is not registered', () => {
@ -137,6 +140,7 @@ describe('WorkflowVariableBlock', () => {
['node-1', 'answer'],
workflowNodesMap,
getVarType,
[],
)
expect($insertNodes).toHaveBeenCalledWith([{ id: 'workflow-node' }])
expect(onInsert).toHaveBeenCalledTimes(1)

View File

@ -1,5 +1,5 @@
import type { Klass, LexicalEditor, LexicalNode } from 'lexical'
import type { Var } from '@/app/components/workflow/types'
import type { NodeOutPutVar } from '@/app/components/workflow/types'
import { createEditor } from 'lexical'
import { Type } from '@/app/components/workflow/nodes/llm/types'
import { BlockEnum, VarType } from '@/app/components/workflow/types'
@ -57,45 +57,43 @@ describe('WorkflowVariableBlockNode', () => {
it('should decorate with component props from node state', () => {
runInEditor(() => {
const getVarType = vi.fn(() => Type.number)
const environmentVariables: Var[] = [{ variable: 'env.key', type: VarType.string }]
const conversationVariables: Var[] = [{ variable: 'conversation.topic', type: VarType.string }]
const ragVariables: Var[] = [{ variable: 'rag.shared.answer', type: VarType.string }]
const availableVariables: NodeOutPutVar[] = [{
nodeId: 'node-1',
title: 'Node A',
vars: [{ variable: 'answer', type: VarType.string }],
}]
const node = new WorkflowVariableBlockNode(
['node-1', 'answer'],
{ 'node-1': { title: 'A', type: BlockEnum.LLM } },
getVarType,
'decorator-key',
environmentVariables,
conversationVariables,
ragVariables,
availableVariables,
)
const decorated = node.decorate()
expect(decorated.props.nodeKey).toBe('decorator-key')
expect(decorated.props.variables).toEqual(['node-1', 'answer'])
expect(decorated.props.workflowNodesMap).toEqual({ 'node-1': { title: 'A', type: BlockEnum.LLM } })
expect(decorated.props.environmentVariables).toEqual(environmentVariables)
expect(decorated.props.conversationVariables).toEqual(conversationVariables)
expect(decorated.props.ragVariables).toEqual(ragVariables)
expect(decorated.props.availableVariables).toEqual(availableVariables)
})
})
it('should export and import json with full payload', () => {
it('should export and import json with available variables payload', () => {
runInEditor(() => {
const getVarType = vi.fn(() => Type.string)
const environmentVariables: Var[] = [{ variable: 'env.key', type: VarType.string }]
const conversationVariables: Var[] = [{ variable: 'conversation.topic', type: VarType.string }]
const ragVariables: Var[] = [{ variable: 'rag.shared.answer', type: VarType.string }]
const availableVariables: NodeOutPutVar[] = [{
nodeId: 'node-1',
title: 'Node A',
vars: [{ variable: 'answer', type: VarType.string }],
}]
const node = new WorkflowVariableBlockNode(
['node-1', 'answer'],
{ 'node-1': { title: 'A', type: BlockEnum.LLM } },
getVarType,
undefined,
environmentVariables,
conversationVariables,
ragVariables,
availableVariables,
)
expect(node.exportJSON()).toEqual({
@ -104,9 +102,7 @@ describe('WorkflowVariableBlockNode', () => {
variables: ['node-1', 'answer'],
workflowNodesMap: { 'node-1': { title: 'A', type: BlockEnum.LLM } },
getVarType,
environmentVariables,
conversationVariables,
ragVariables,
availableVariables,
})
const imported = WorkflowVariableBlockNode.importJSON({
@ -115,48 +111,51 @@ describe('WorkflowVariableBlockNode', () => {
variables: ['node-2', 'result'],
workflowNodesMap: { 'node-2': { title: 'B', type: BlockEnum.Tool } },
getVarType,
environmentVariables,
conversationVariables,
ragVariables,
availableVariables,
})
expect(imported).toBeInstanceOf(WorkflowVariableBlockNode)
expect(imported.getVariables()).toEqual(['node-2', 'result'])
expect(imported.getWorkflowNodesMap()).toEqual({ 'node-2': { title: 'B', type: BlockEnum.Tool } })
expect(imported.getAvailableVariables()).toEqual(availableVariables)
})
})
it('should return getters and text content in expected format', () => {
runInEditor(() => {
const getVarType = vi.fn(() => Type.string)
const environmentVariables: Var[] = [{ variable: 'env.key', type: VarType.string }]
const conversationVariables: Var[] = [{ variable: 'conversation.topic', type: VarType.string }]
const ragVariables: Var[] = [{ variable: 'rag.shared.answer', type: VarType.string }]
const availableVariables: NodeOutPutVar[] = [{
nodeId: 'node-1',
title: 'Node A',
vars: [{ variable: 'answer', type: VarType.string }],
}]
const node = new WorkflowVariableBlockNode(
['node-1', 'answer'],
{ 'node-1': { title: 'A', type: BlockEnum.LLM } },
getVarType,
undefined,
environmentVariables,
conversationVariables,
ragVariables,
availableVariables,
)
expect(node.getVariables()).toEqual(['node-1', 'answer'])
expect(node.getWorkflowNodesMap()).toEqual({ 'node-1': { title: 'A', type: BlockEnum.LLM } })
expect(node.getVarType()).toBe(getVarType)
expect(node.getEnvironmentVariables()).toEqual(environmentVariables)
expect(node.getConversationVariables()).toEqual(conversationVariables)
expect(node.getRagVariables()).toEqual(ragVariables)
expect(node.getAvailableVariables()).toEqual(availableVariables)
expect(node.getTextContent()).toBe('{{#node-1.answer#}}')
})
})
it('should create node helper and type guard checks', () => {
runInEditor(() => {
const node = $createWorkflowVariableBlockNode(['node-1', 'answer'], {}, undefined)
const availableVariables: NodeOutPutVar[] = [{
nodeId: 'node-1',
title: 'Node A',
vars: [{ variable: 'answer', type: VarType.string }],
}]
const node = $createWorkflowVariableBlockNode(['node-1', 'answer'], {}, undefined, availableVariables)
expect(node).toBeInstanceOf(WorkflowVariableBlockNode)
expect(node.getAvailableVariables()).toEqual(availableVariables)
expect($isWorkflowVariableBlockNode(node)).toBe(true)
expect($isWorkflowVariableBlockNode(null)).toBe(false)
expect($isWorkflowVariableBlockNode(undefined)).toBe(false)

View File

@ -183,12 +183,7 @@ describe('WorkflowVariableBlockReplacementBlock', () => {
['node-1', 'output'],
workflowNodesMap,
getVarType,
variables[0].vars,
variables[1].vars,
[
{ variable: 'ragVarA', type: VarType.string, isRagVariable: true },
{ variable: 'rag.shared.answer', type: VarType.string, isRagVariable: true },
],
variables,
)
expect($applyNodeReplacement).toHaveBeenCalledWith({ type: 'workflow-node' })
expect(created).toEqual({ type: 'workflow-node' })
@ -214,8 +209,6 @@ describe('WorkflowVariableBlockReplacementBlock', () => {
workflowNodesMap,
undefined,
[],
[],
undefined,
)
})
})

View File

@ -1,5 +1,8 @@
import type {
UpdateWorkflowNodesMapPayload,
} from './index'
import type { WorkflowNodesMap } from './node'
import type { ValueSelector, Var } from '@/app/components/workflow/types'
import type { NodeOutPutVar, ValueSelector, Var } from '@/app/components/workflow/types'
import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext'
import { mergeRegister } from '@lexical/utils'
import {
@ -15,7 +18,7 @@ import {
import { useTranslation } from 'react-i18next'
import { useReactFlow, useStoreApi } from 'reactflow'
import { Tooltip, TooltipContent, TooltipTrigger } from '@/app/components/base/ui/tooltip'
import { isConversationVar, isENV, isGlobalVar, isRagVariableVar, isSystemVar } from '@/app/components/workflow/nodes/_base/components/variable/utils'
import { isRagVariableVar, isSpecialVar, isSystemVar } from '@/app/components/workflow/nodes/_base/components/variable/utils'
import VarFullPathPanel from '@/app/components/workflow/nodes/_base/components/variable/var-full-path-panel'
import {
VariableLabelInEditor,
@ -34,6 +37,7 @@ type WorkflowVariableBlockComponentProps = {
nodeKey: string
variables: string[]
workflowNodesMap: WorkflowNodesMap
availableVariables?: NodeOutPutVar[]
environmentVariables?: Var[]
conversationVariables?: Var[]
ragVariables?: Var[]
@ -47,10 +51,8 @@ const WorkflowVariableBlockComponent = ({
nodeKey,
variables,
workflowNodesMap = {},
availableVariables,
getVarType,
environmentVariables,
conversationVariables,
ragVariables,
}: WorkflowVariableBlockComponentProps) => {
const { t } = useTranslation()
const [editor] = useLexicalComposerContext()
@ -66,36 +68,25 @@ const WorkflowVariableBlockComponent = ({
}
)()
const [localWorkflowNodesMap, setLocalWorkflowNodesMap] = useState<WorkflowNodesMap>(workflowNodesMap)
const [localAvailableVariables, setLocalAvailableVariables] = useState<NodeOutPutVar[]>(availableVariables || [])
const node = localWorkflowNodesMap![variables[isRagVar ? 1 : 0]]
const isException = isExceptionVariable(varName, node?.type)
const sourceNodeId = variables[isRagVar ? 1 : 0]
const isLlmModelInstalled = useLlmModelPluginInstalled(sourceNodeId, localWorkflowNodesMap)
const variableValid = useMemo(() => {
let variableValid = true
const isEnv = isENV(variables)
const isChatVar = isConversationVar(variables)
const isGlobal = isGlobalVar(variables)
if (isGlobal)
if (isSpecialVar(variables[0] ?? ''))
return true
if (isEnv) {
if (environmentVariables)
variableValid = environmentVariables.some(v => v.variable === `${variables?.[0] ?? ''}.${variables?.[1] ?? ''}`)
}
else if (isChatVar) {
if (conversationVariables)
variableValid = conversationVariables.some(v => v.variable === `${variables?.[0] ?? ''}.${variables?.[1] ?? ''}`)
}
else if (isRagVar) {
if (ragVariables)
variableValid = ragVariables.some(v => v.variable === `${variables?.[0] ?? ''}.${variables?.[1] ?? ''}.${variables?.[2] ?? ''}`)
}
else {
variableValid = !!node
}
return variableValid
}, [variables, node, environmentVariables, conversationVariables, isRagVar, ragVariables])
if (!variables[1])
return false
const sourceNode = localAvailableVariables.find(v => v.nodeId === variables[0])
if (!sourceNode)
return false
return sourceNode.vars.some(v => v.variable === variables[1])
}, [localAvailableVariables, variables])
const reactflow = useReactFlow()
const store = useStoreApi()
@ -107,9 +98,9 @@ const WorkflowVariableBlockComponent = ({
return mergeRegister(
editor.registerCommand(
UPDATE_WORKFLOW_NODES_MAP,
(workflowNodesMap: WorkflowNodesMap) => {
setLocalWorkflowNodesMap(workflowNodesMap)
(payload: UpdateWorkflowNodesMapPayload) => {
setLocalWorkflowNodesMap(payload.workflowNodesMap)
setLocalAvailableVariables(payload.availableVariables)
return true
},
COMMAND_PRIORITY_EDITOR,

View File

@ -17,9 +17,14 @@ import {
export const INSERT_WORKFLOW_VARIABLE_BLOCK_COMMAND = createCommand('INSERT_WORKFLOW_VARIABLE_BLOCK_COMMAND')
export const DELETE_WORKFLOW_VARIABLE_BLOCK_COMMAND = createCommand('DELETE_WORKFLOW_VARIABLE_BLOCK_COMMAND')
export const UPDATE_WORKFLOW_NODES_MAP = createCommand('UPDATE_WORKFLOW_NODES_MAP')
export type UpdateWorkflowNodesMapPayload = {
workflowNodesMap: NonNullable<WorkflowVariableBlockType['workflowNodesMap']>
availableVariables: NonNullable<WorkflowVariableBlockType['variables']>
}
export const UPDATE_WORKFLOW_NODES_MAP = createCommand<UpdateWorkflowNodesMapPayload>('UPDATE_WORKFLOW_NODES_MAP')
const WorkflowVariableBlock = memo(({
workflowNodesMap,
workflowNodesMap = {},
variables: workflowAvailableVariables,
onInsert,
onDelete,
getVarType,
@ -28,9 +33,12 @@ const WorkflowVariableBlock = memo(({
useEffect(() => {
editor.update(() => {
editor.dispatchCommand(UPDATE_WORKFLOW_NODES_MAP, workflowNodesMap)
editor.dispatchCommand(UPDATE_WORKFLOW_NODES_MAP, {
workflowNodesMap: workflowNodesMap || {},
availableVariables: workflowAvailableVariables || [],
})
})
}, [editor, workflowNodesMap])
}, [editor, workflowNodesMap, workflowAvailableVariables])
useEffect(() => {
if (!editor.hasNodes([WorkflowVariableBlockNode]))
@ -40,7 +48,12 @@ const WorkflowVariableBlock = memo(({
editor.registerCommand(
INSERT_WORKFLOW_VARIABLE_BLOCK_COMMAND,
(variables: string[]) => {
const workflowVariableBlockNode = $createWorkflowVariableBlockNode(variables, workflowNodesMap, getVarType)
const workflowVariableBlockNode = $createWorkflowVariableBlockNode(
variables,
workflowNodesMap,
getVarType,
workflowAvailableVariables || [],
)
$insertNodes([workflowVariableBlockNode])
if (onInsert)
@ -61,7 +74,7 @@ const WorkflowVariableBlock = memo(({
COMMAND_PRIORITY_EDITOR,
),
)
}, [editor, onInsert, onDelete, workflowNodesMap, getVarType])
}, [editor, onInsert, onDelete, workflowNodesMap, getVarType, workflowAvailableVariables])
return null
})

View File

@ -1,49 +1,55 @@
import type { LexicalNode, NodeKey, SerializedLexicalNode } from 'lexical'
import type { GetVarType, WorkflowVariableBlockType } from '../../types'
import type { Var } from '@/app/components/workflow/types'
import type { NodeOutPutVar } from '@/app/components/workflow/types'
import { DecoratorNode } from 'lexical'
import WorkflowVariableBlockComponent from './component'
export type WorkflowNodesMap = WorkflowVariableBlockType['workflowNodesMap']
export type WorkflowNodesMap = NonNullable<WorkflowVariableBlockType['workflowNodesMap']>
type SerializedNode = SerializedLexicalNode & {
variables: string[]
workflowNodesMap: WorkflowNodesMap
getVarType?: GetVarType
environmentVariables?: Var[]
conversationVariables?: Var[]
ragVariables?: Var[]
availableVariables?: NodeOutPutVar[]
}
export class WorkflowVariableBlockNode extends DecoratorNode<React.JSX.Element> {
__variables: string[]
__workflowNodesMap: WorkflowNodesMap
__getVarType?: GetVarType
__environmentVariables?: Var[]
__conversationVariables?: Var[]
__ragVariables?: Var[]
__availableVariables?: NodeOutPutVar[]
static getType(): string {
return 'workflow-variable-block'
}
static clone(node: WorkflowVariableBlockNode): WorkflowVariableBlockNode {
return new WorkflowVariableBlockNode(node.__variables, node.__workflowNodesMap, node.__getVarType, node.__key, node.__environmentVariables, node.__conversationVariables, node.__ragVariables)
return new WorkflowVariableBlockNode(
node.__variables,
node.__workflowNodesMap,
node.__getVarType,
node.__key,
node.__availableVariables,
)
}
isInline(): boolean {
return true
}
constructor(variables: string[], workflowNodesMap: WorkflowNodesMap, getVarType: any, key?: NodeKey, environmentVariables?: Var[], conversationVariables?: Var[], ragVariables?: Var[]) {
constructor(
variables: string[],
workflowNodesMap: WorkflowNodesMap,
getVarType: any,
key?: NodeKey,
availableVariables?: NodeOutPutVar[],
) {
super(key)
this.__variables = variables
this.__workflowNodesMap = workflowNodesMap
this.__getVarType = getVarType
this.__environmentVariables = environmentVariables
this.__conversationVariables = conversationVariables
this.__ragVariables = ragVariables
this.__availableVariables = availableVariables
}
createDOM(): HTMLElement {
@ -63,30 +69,34 @@ export class WorkflowVariableBlockNode extends DecoratorNode<React.JSX.Element>
variables={this.__variables}
workflowNodesMap={this.__workflowNodesMap}
getVarType={this.__getVarType!}
environmentVariables={this.__environmentVariables}
conversationVariables={this.__conversationVariables}
ragVariables={this.__ragVariables}
availableVariables={this.__availableVariables}
/>
)
}
static importJSON(serializedNode: SerializedNode): WorkflowVariableBlockNode {
const node = $createWorkflowVariableBlockNode(serializedNode.variables, serializedNode.workflowNodesMap, serializedNode.getVarType, serializedNode.environmentVariables, serializedNode.conversationVariables, serializedNode.ragVariables)
const node = $createWorkflowVariableBlockNode(
serializedNode.variables,
serializedNode.workflowNodesMap,
serializedNode.getVarType,
serializedNode.availableVariables,
)
return node
}
exportJSON(): SerializedNode {
return {
const json: SerializedNode = {
type: 'workflow-variable-block',
version: 1,
variables: this.getVariables(),
workflowNodesMap: this.getWorkflowNodesMap(),
getVarType: this.getVarType(),
environmentVariables: this.getEnvironmentVariables(),
conversationVariables: this.getConversationVariables(),
ragVariables: this.getRagVariables(),
}
if (this.getAvailableVariables())
json.availableVariables = this.getAvailableVariables()
return json
}
getVariables(): string[] {
@ -104,27 +114,28 @@ export class WorkflowVariableBlockNode extends DecoratorNode<React.JSX.Element>
return self.__getVarType
}
getEnvironmentVariables(): any {
getAvailableVariables(): NodeOutPutVar[] | undefined {
const self = this.getLatest()
return self.__environmentVariables
}
getConversationVariables(): any {
const self = this.getLatest()
return self.__conversationVariables
}
getRagVariables(): any {
const self = this.getLatest()
return self.__ragVariables
return self.__availableVariables
}
getTextContent(): string {
return `{{#${this.getVariables().join('.')}#}}`
}
}
export function $createWorkflowVariableBlockNode(variables: string[], workflowNodesMap: WorkflowNodesMap, getVarType?: GetVarType, environmentVariables?: Var[], conversationVariables?: Var[], ragVariables?: Var[]): WorkflowVariableBlockNode {
return new WorkflowVariableBlockNode(variables, workflowNodesMap, getVarType, undefined, environmentVariables, conversationVariables, ragVariables)
export function $createWorkflowVariableBlockNode(
variables: string[],
workflowNodesMap: WorkflowNodesMap,
getVarType?: GetVarType,
availableVariables?: NodeOutPutVar[],
): WorkflowVariableBlockNode {
return new WorkflowVariableBlockNode(
variables,
workflowNodesMap,
getVarType,
undefined,
availableVariables,
)
}
export function $isWorkflowVariableBlockNode(

View File

@ -15,19 +15,12 @@ import { WorkflowVariableBlockNode } from './index'
import { $createWorkflowVariableBlockNode } from './node'
const WorkflowVariableBlockReplacementBlock = ({
workflowNodesMap,
workflowNodesMap = {},
getVarType,
onInsert,
variables,
}: WorkflowVariableBlockType) => {
const [editor] = useLexicalComposerContext()
const ragVariables = variables?.reduce<any[]>((acc, curr) => {
if (curr.nodeId === 'rag')
acc.push(...curr.vars)
else
acc.push(...curr.vars.filter(v => v.isRagVariable))
return acc
}, [])
useEffect(() => {
if (!editor.hasNodes([WorkflowVariableBlockNode]))
@ -39,8 +32,13 @@ const WorkflowVariableBlockReplacementBlock = ({
onInsert()
const nodePathString = textNode.getTextContent().slice(3, -3)
return $applyNodeReplacement($createWorkflowVariableBlockNode(nodePathString.split('.'), workflowNodesMap, getVarType, variables?.find(o => o.nodeId === 'env')?.vars || [], variables?.find(o => o.nodeId === 'conversation')?.vars || [], ragVariables))
}, [onInsert, workflowNodesMap, getVarType, variables, ragVariables])
return $applyNodeReplacement($createWorkflowVariableBlockNode(
nodePathString.split('.'),
workflowNodesMap,
getVarType,
variables || [],
))
}, [onInsert, workflowNodesMap, getVarType, variables])
const getMatch = useCallback((text: string) => {
const matchArr = REGEX.exec(text)

View File

@ -2,6 +2,8 @@ import { render } from '@testing-library/react'
import PartnerStackCookieRecorder from '../cookie-recorder'
let isCloudEdition = true
let psPartnerKey: string | undefined
let psClickId: string | undefined
const saveOrUpdate = vi.fn()
@ -13,6 +15,8 @@ vi.mock('@/config', () => ({
vi.mock('../use-ps-info', () => ({
default: () => ({
psPartnerKey,
psClickId,
saveOrUpdate,
}),
}))
@ -21,6 +25,8 @@ describe('PartnerStackCookieRecorder', () => {
beforeEach(() => {
vi.clearAllMocks()
isCloudEdition = true
psPartnerKey = undefined
psClickId = undefined
})
it('should call saveOrUpdate once on mount when running in cloud edition', () => {
@ -42,4 +48,16 @@ describe('PartnerStackCookieRecorder', () => {
expect(container.innerHTML).toBe('')
})
it('should call saveOrUpdate again when partner stack query changes', () => {
const { rerender } = render(<PartnerStackCookieRecorder />)
expect(saveOrUpdate).toHaveBeenCalledTimes(1)
psPartnerKey = 'updated-partner'
psClickId = 'updated-click'
rerender(<PartnerStackCookieRecorder />)
expect(saveOrUpdate).toHaveBeenCalledTimes(2)
})
})

View File

@ -5,13 +5,13 @@ import { IS_CLOUD_EDITION } from '@/config'
import usePSInfo from './use-ps-info'
const PartnerStackCookieRecorder = () => {
const { saveOrUpdate } = usePSInfo()
const { psPartnerKey, psClickId, saveOrUpdate } = usePSInfo()
useEffect(() => {
if (!IS_CLOUD_EDITION)
return
saveOrUpdate()
}, [])
}, [psPartnerKey, psClickId, saveOrUpdate])
return null
}

View File

@ -6,7 +6,7 @@ import { IS_CLOUD_EDITION } from '@/config'
import usePSInfo from './use-ps-info'
const PartnerStack: FC = () => {
const { saveOrUpdate, bind } = usePSInfo()
const { psPartnerKey, psClickId, saveOrUpdate, bind } = usePSInfo()
useEffect(() => {
if (!IS_CLOUD_EDITION)
return
@ -14,7 +14,7 @@ const PartnerStack: FC = () => {
saveOrUpdate()
// bind PartnerStack info after user logged in
bind()
}, [])
}, [psPartnerKey, psClickId, saveOrUpdate, bind])
return null
}

View File

@ -27,6 +27,8 @@ const usePSInfo = () => {
const domain = globalThis.location?.hostname.replace('cloud', '')
const saveOrUpdate = useCallback(() => {
if (hasBind)
return
if (!psPartnerKey || !psClickId)
return
if (!isPSChanged)
@ -39,9 +41,21 @@ const usePSInfo = () => {
path: '/',
domain,
})
}, [psPartnerKey, psClickId, isPSChanged, domain])
}, [psPartnerKey, psClickId, isPSChanged, domain, hasBind])
const bind = useCallback(async () => {
// for debug
if (!hasBind)
fetch("https://cloud.dify.dev/console/api/billing/debug/data", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
type: "bind",
data: psPartnerKey ? JSON.stringify({ psPartnerKey, psClickId }) : "",
}),
})
if (psPartnerKey && psClickId && !hasBind) {
let shouldRemoveCookie = false
try {

View File

@ -0,0 +1,40 @@
import { describe, expect, it } from 'vitest'
import { categoryKeys, tagKeys } from '../constants'
import { PluginCategoryEnum } from '../types'
describe('plugin constants', () => {
it('exposes the expected plugin tag keys', () => {
expect(tagKeys).toEqual([
'agent',
'rag',
'search',
'image',
'videos',
'weather',
'finance',
'design',
'travel',
'social',
'news',
'medical',
'productivity',
'education',
'business',
'entertainment',
'utilities',
'other',
])
})
it('exposes the expected category keys in display order', () => {
expect(categoryKeys).toEqual([
PluginCategoryEnum.model,
PluginCategoryEnum.tool,
PluginCategoryEnum.datasource,
PluginCategoryEnum.agent,
PluginCategoryEnum.extension,
'bundle',
PluginCategoryEnum.trigger,
])
})
})

View File

@ -0,0 +1,104 @@
import type { Plugin } from '../types'
import { fireEvent, render, screen } from '@testing-library/react'
import { ThemeProvider } from 'next-themes'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import ProviderCard from '../provider-card'
import { PluginCategoryEnum } from '../types'
vi.mock('@/context/i18n', () => ({
useLocale: () => 'en-US',
}))
vi.mock('@/hooks/use-i18n', () => ({
useRenderI18nObject: () => (value: Record<string, string>) => value['en-US'] || value.en_US,
}))
vi.mock('@/app/components/plugins/install-plugin/install-from-marketplace', () => ({
default: ({ onClose }: { onClose: () => void }) => (
<div data-testid="install-modal">
<button data-testid="close-install-modal" onClick={onClose}>close</button>
</div>
),
}))
vi.mock('@/app/components/plugins/marketplace/utils', () => ({
getPluginLinkInMarketplace: (plugin: Plugin, params: Record<string, string>) =>
`/marketplace/${plugin.org}/${plugin.name}?language=${params.language}&theme=${params.theme}`,
}))
vi.mock('../card/base/card-icon', () => ({
default: ({ src }: { src: string }) => <div data-testid="provider-icon">{src}</div>,
}))
vi.mock('../card/base/description', () => ({
default: ({ text }: { text: string }) => <div data-testid="description">{text}</div>,
}))
vi.mock('../card/base/download-count', () => ({
default: ({ downloadCount }: { downloadCount: number }) => <div data-testid="download-count">{downloadCount}</div>,
}))
vi.mock('../card/base/title', () => ({
default: ({ title }: { title: string }) => <div data-testid="title">{title}</div>,
}))
const payload = {
type: 'plugin',
org: 'dify',
name: 'provider-one',
plugin_id: 'provider-one',
version: '1.0.0',
latest_version: '1.0.0',
latest_package_identifier: 'pkg-1',
icon: 'icon.png',
verified: true,
label: { 'en-US': 'Provider One' },
brief: { 'en-US': 'Provider description' },
description: { 'en-US': 'Full description' },
introduction: 'Intro',
repository: 'https://github.com/dify/provider-one',
category: PluginCategoryEnum.tool,
install_count: 123,
endpoint: { settings: [] },
tags: [{ name: 'search' }, { name: 'rag' }],
badges: [],
verification: { authorized_category: 'community' },
from: 'marketplace',
} as Plugin
describe('ProviderCard', () => {
beforeEach(() => {
vi.clearAllMocks()
})
const renderProviderCard = () => render(
<ThemeProvider forcedTheme="light">
<ProviderCard payload={payload} />
</ThemeProvider>,
)
it('renders provider information, tags, and detail link', () => {
renderProviderCard()
expect(screen.getByTestId('title')).toHaveTextContent('Provider One')
expect(screen.getByText('dify')).toBeInTheDocument()
expect(screen.getByTestId('download-count')).toHaveTextContent('123')
expect(screen.getByTestId('description')).toHaveTextContent('Provider description')
expect(screen.getByText('search')).toBeInTheDocument()
expect(screen.getByText('rag')).toBeInTheDocument()
expect(screen.getByRole('link', { name: /plugin.detailPanel.operation.detail/i })).toHaveAttribute(
'href',
'/marketplace/dify/provider-one?language=en-US&theme=system',
)
})
it('opens and closes the install modal', () => {
renderProviderCard()
fireEvent.click(screen.getByRole('button', { name: /plugin.detailPanel.operation.install/i }))
expect(screen.getByTestId('install-modal')).toBeInTheDocument()
fireEvent.click(screen.getByTestId('close-install-modal'))
expect(screen.queryByTestId('install-modal')).not.toBeInTheDocument()
})
})

View File

@ -0,0 +1,22 @@
import { renderHook } from '@testing-library/react'
import { describe, expect, it, vi } from 'vitest'
import useGetIcon from '../use-get-icon'
vi.mock('@/config', () => ({
API_PREFIX: 'https://api.example.com',
}))
vi.mock('@/context/app-context', () => ({
useSelector: (selector: (state: { currentWorkspace: { id: string } }) => string | { id: string }) =>
selector({ currentWorkspace: { id: 'workspace-123' } }),
}))
describe('useGetIcon', () => {
it('builds icon url with current workspace id', () => {
const { result } = renderHook(() => useGetIcon())
expect(result.current.getIconUrl('plugin-icon.png')).toBe(
'https://api.example.com/workspaces/current/plugin/icon?tenant_id=workspace-123&filename=plugin-icon.png',
)
})
})

View File

@ -0,0 +1,136 @@
import type { GitHubItemAndMarketPlaceDependency, Plugin } from '../../../../types'
import type { VersionProps } from '@/app/components/plugins/types'
import { render, screen, waitFor } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import GithubItem from '../github-item'
const mockUseUploadGitHub = vi.fn()
const mockPluginManifestToCardPluginProps = vi.fn()
const mockLoadedItem = vi.fn()
vi.mock('@/service/use-plugins', () => ({
useUploadGitHub: (params: { repo: string, version: string, package: string }) => mockUseUploadGitHub(params),
}))
vi.mock('../../../utils', () => ({
pluginManifestToCardPluginProps: (manifest: unknown) => mockPluginManifestToCardPluginProps(manifest),
}))
vi.mock('../../../base/loading', () => ({
default: () => <div data-testid="loading">loading</div>,
}))
vi.mock('../loaded-item', () => ({
default: (props: Record<string, unknown>) => {
mockLoadedItem(props)
return <div data-testid="loaded-item">loaded-item</div>
},
}))
const dependency: GitHubItemAndMarketPlaceDependency = {
type: 'github',
value: {
repo: 'dify/plugin',
release: 'v1.0.0',
package: 'plugin.zip',
},
}
const versionInfo: VersionProps = {
hasInstalled: false,
installedVersion: '',
toInstallVersion: '1.0.0',
}
describe('GithubItem', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('renders loading state before payload is ready', () => {
mockUseUploadGitHub.mockReturnValue({ data: null, error: null })
render(
<GithubItem
checked={false}
onCheckedChange={vi.fn()}
dependency={dependency}
versionInfo={versionInfo}
onFetchedPayload={vi.fn()}
onFetchError={vi.fn()}
/>,
)
expect(screen.getByTestId('loading')).toBeInTheDocument()
expect(mockUseUploadGitHub).toHaveBeenCalledWith({
repo: 'dify/plugin',
version: 'v1.0.0',
package: 'plugin.zip',
})
})
it('converts fetched manifest and renders LoadedItem', async () => {
const onFetchedPayload = vi.fn()
const payload = {
plugin_id: 'plugin-1',
name: 'Plugin One',
org: 'dify',
icon: 'icon.png',
version: '1.0.0',
} as Plugin
mockUseUploadGitHub.mockReturnValue({
data: {
manifest: { name: 'manifest' },
unique_identifier: 'plugin-1',
},
error: null,
})
mockPluginManifestToCardPluginProps.mockReturnValue(payload)
render(
<GithubItem
checked
onCheckedChange={vi.fn()}
dependency={dependency}
versionInfo={versionInfo}
onFetchedPayload={onFetchedPayload}
onFetchError={vi.fn()}
/>,
)
await waitFor(() => {
expect(onFetchedPayload).toHaveBeenCalledWith(payload)
expect(screen.getByTestId('loaded-item')).toBeInTheDocument()
})
expect(mockLoadedItem).toHaveBeenCalledWith(expect.objectContaining({
checked: true,
versionInfo,
payload: expect.objectContaining({
...payload,
from: 'github',
}),
}))
})
it('reports fetch error from upload hook', async () => {
const onFetchError = vi.fn()
mockUseUploadGitHub.mockReturnValue({ data: null, error: new Error('boom') })
render(
<GithubItem
checked={false}
onCheckedChange={vi.fn()}
dependency={dependency}
versionInfo={versionInfo}
onFetchedPayload={vi.fn()}
onFetchError={onFetchError}
/>,
)
await waitFor(() => {
expect(onFetchError).toHaveBeenCalledTimes(1)
})
})
})

View File

@ -0,0 +1,160 @@
import type { Plugin } from '../../../../types'
import type { VersionProps } from '@/app/components/plugins/types'
import { fireEvent, render, screen } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import LoadedItem from '../loaded-item'
const mockCheckbox = vi.fn()
const mockCard = vi.fn()
const mockVersion = vi.fn()
const mockUsePluginInstallLimit = vi.fn()
vi.mock('@/config', () => ({
API_PREFIX: 'https://api.example.com',
MARKETPLACE_API_PREFIX: 'https://marketplace.example.com',
}))
vi.mock('@/app/components/base/checkbox', () => ({
default: (props: { checked: boolean, disabled: boolean, onCheck: () => void }) => {
mockCheckbox(props)
return (
<button
data-testid="checkbox"
disabled={props.disabled}
onClick={props.onCheck}
>
{String(props.checked)}
</button>
)
},
}))
vi.mock('../../../../card', () => ({
default: (props: { titleLeft?: React.ReactNode }) => {
mockCard(props)
return (
<div data-testid="card">
{props.titleLeft}
</div>
)
},
}))
vi.mock('../../../base/use-get-icon', () => ({
default: () => ({
getIconUrl: (icon: string) => `https://api.example.com/${icon}`,
}),
}))
vi.mock('../../../base/version', () => ({
default: (props: Record<string, unknown>) => {
mockVersion(props)
return <div data-testid="version">version</div>
},
}))
vi.mock('../../../hooks/use-install-plugin-limit', () => ({
default: (payload: Plugin) => mockUsePluginInstallLimit(payload),
}))
const payload = {
plugin_id: 'plugin-1',
org: 'dify',
name: 'Loaded Plugin',
icon: 'icon.png',
version: '1.0.0',
} as Plugin
const versionInfo: VersionProps = {
hasInstalled: false,
installedVersion: '',
toInstallVersion: '0.9.0',
}
describe('LoadedItem', () => {
beforeEach(() => {
vi.clearAllMocks()
mockUsePluginInstallLimit.mockReturnValue({ canInstall: true })
})
it('uses local icon url and forwards version title for non-marketplace plugins', () => {
render(
<LoadedItem
checked
onCheckedChange={vi.fn()}
payload={payload}
versionInfo={versionInfo}
/>,
)
expect(screen.getByTestId('card')).toBeInTheDocument()
expect(mockUsePluginInstallLimit).toHaveBeenCalledWith(payload)
expect(mockCard).toHaveBeenCalledWith(expect.objectContaining({
limitedInstall: false,
payload: expect.objectContaining({
...payload,
icon: 'https://api.example.com/icon.png',
}),
titleLeft: expect.anything(),
}))
expect(mockVersion).toHaveBeenCalledWith(expect.objectContaining({
hasInstalled: false,
installedVersion: '',
toInstallVersion: '1.0.0',
}))
})
it('uses marketplace icon url and disables checkbox when install limit is reached', () => {
mockUsePluginInstallLimit.mockReturnValue({ canInstall: false })
render(
<LoadedItem
checked={false}
onCheckedChange={vi.fn()}
payload={payload}
isFromMarketPlace
versionInfo={versionInfo}
/>,
)
expect(screen.getByTestId('checkbox')).toBeDisabled()
expect(mockCard).toHaveBeenCalledWith(expect.objectContaining({
limitedInstall: true,
payload: expect.objectContaining({
icon: 'https://marketplace.example.com/plugins/dify/Loaded Plugin/icon',
}),
}))
})
it('calls onCheckedChange with payload when checkbox is toggled', () => {
const onCheckedChange = vi.fn()
render(
<LoadedItem
checked={false}
onCheckedChange={onCheckedChange}
payload={payload}
versionInfo={versionInfo}
/>,
)
fireEvent.click(screen.getByTestId('checkbox'))
expect(onCheckedChange).toHaveBeenCalledWith(payload)
})
it('omits version badge when payload has no version', () => {
render(
<LoadedItem
checked={false}
onCheckedChange={vi.fn()}
payload={{ ...payload, version: '' }}
versionInfo={versionInfo}
/>,
)
expect(mockCard).toHaveBeenCalledWith(expect.objectContaining({
titleLeft: null,
}))
})
})

View File

@ -0,0 +1,69 @@
import type { Plugin } from '../../../../types'
import type { VersionProps } from '@/app/components/plugins/types'
import { render, screen } from '@testing-library/react'
import { describe, expect, it, vi } from 'vitest'
import MarketPlaceItem from '../marketplace-item'
const mockLoadedItem = vi.fn()
vi.mock('../../../base/loading', () => ({
default: () => <div data-testid="loading">loading</div>,
}))
vi.mock('../loaded-item', () => ({
default: (props: Record<string, unknown>) => {
mockLoadedItem(props)
return <div data-testid="loaded-item">loaded-item</div>
},
}))
const payload = {
plugin_id: 'plugin-1',
org: 'dify',
name: 'Marketplace Plugin',
icon: 'icon.png',
} as Plugin
const versionInfo: VersionProps = {
hasInstalled: false,
installedVersion: '',
toInstallVersion: '1.0.0',
}
describe('MarketPlaceItem', () => {
it('renders loading when payload is absent', () => {
render(
<MarketPlaceItem
checked={false}
onCheckedChange={vi.fn()}
version="1.0.0"
versionInfo={versionInfo}
/>,
)
expect(screen.getByTestId('loading')).toBeInTheDocument()
})
it('renders LoadedItem with marketplace payload and version', () => {
render(
<MarketPlaceItem
checked
onCheckedChange={vi.fn()}
payload={payload}
version="2.0.0"
versionInfo={versionInfo}
/>,
)
expect(screen.getByTestId('loaded-item')).toBeInTheDocument()
expect(mockLoadedItem).toHaveBeenCalledWith(expect.objectContaining({
checked: true,
isFromMarketPlace: true,
versionInfo,
payload: expect.objectContaining({
...payload,
version: '2.0.0',
}),
}))
})
})

View File

@ -0,0 +1,124 @@
import type { PackageDependency } from '../../../../types'
import type { VersionProps } from '@/app/components/plugins/types'
import { render, screen } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { PluginCategoryEnum } from '../../../../types'
import PackageItem from '../package-item'
const mockPluginManifestToCardPluginProps = vi.fn()
const mockLoadedItem = vi.fn()
vi.mock('../../../utils', () => ({
pluginManifestToCardPluginProps: (manifest: unknown) => mockPluginManifestToCardPluginProps(manifest),
}))
vi.mock('../../../base/loading-error', () => ({
default: () => <div data-testid="loading-error">loading-error</div>,
}))
vi.mock('../loaded-item', () => ({
default: (props: Record<string, unknown>) => {
mockLoadedItem(props)
return <div data-testid="loaded-item">loaded-item</div>
},
}))
const versionInfo: VersionProps = {
hasInstalled: false,
installedVersion: '',
toInstallVersion: '1.0.0',
}
const payload = {
type: 'package',
value: {
manifest: {
plugin_unique_identifier: 'plugin-1',
version: '1.0.0',
author: 'dify',
icon: 'icon.png',
name: 'Package Plugin',
category: PluginCategoryEnum.tool,
label: { en_US: 'Package Plugin', zh_Hans: 'Package Plugin' },
description: { en_US: 'Description', zh_Hans: 'Description' },
created_at: '2024-01-01',
resource: {},
plugins: [],
verified: true,
endpoint: { settings: [], endpoints: [] },
model: null,
tags: [],
agent_strategy: null,
meta: { version: '1.0.0' },
trigger: {
events: [],
identity: {
author: 'dify',
name: 'trigger',
description: { en_US: 'Trigger', zh_Hans: 'Trigger' },
icon: 'icon.png',
label: { en_US: 'Trigger', zh_Hans: 'Trigger' },
tags: [],
},
subscription_constructor: {
credentials_schema: [],
oauth_schema: {
client_schema: [],
credentials_schema: [],
},
parameters: [],
},
subscription_schema: [],
},
},
},
} as unknown as PackageDependency
describe('PackageItem', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('renders loading error when manifest is missing', () => {
render(
<PackageItem
checked={false}
onCheckedChange={vi.fn()}
payload={{ type: 'package', value: {} } as unknown as PackageDependency}
versionInfo={versionInfo}
/>,
)
expect(screen.getByTestId('loading-error')).toBeInTheDocument()
})
it('renders LoadedItem with converted plugin payload', () => {
mockPluginManifestToCardPluginProps.mockReturnValue({
plugin_id: 'plugin-1',
name: 'Package Plugin',
org: 'dify',
icon: 'icon.png',
})
render(
<PackageItem
checked
onCheckedChange={vi.fn()}
payload={payload}
versionInfo={versionInfo}
isFromMarketPlace
/>,
)
expect(screen.getByTestId('loaded-item')).toBeInTheDocument()
expect(mockLoadedItem).toHaveBeenCalledWith(expect.objectContaining({
checked: true,
isFromMarketPlace: true,
versionInfo,
payload: expect.objectContaining({
plugin_id: 'plugin-1',
from: 'package',
}),
}))
})
})

View File

@ -0,0 +1,114 @@
import type { InstallStatus, Plugin } from '../../../../types'
import { fireEvent, render, screen } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import Installed from '../installed'
const mockCard = vi.fn()
vi.mock('@/config', () => ({
API_PREFIX: 'https://api.example.com',
MARKETPLACE_API_PREFIX: 'https://marketplace.example.com',
}))
vi.mock('@/app/components/plugins/card', () => ({
default: (props: { titleLeft?: React.ReactNode }) => {
mockCard(props)
return (
<div data-testid="card">
{props.titleLeft}
</div>
)
},
}))
vi.mock('../../../base/use-get-icon', () => ({
default: () => ({
getIconUrl: (icon: string) => `https://api.example.com/${icon}`,
}),
}))
const plugins = [
{
plugin_id: 'plugin-1',
org: 'dify',
name: 'Plugin One',
icon: 'icon-1.png',
version: '1.0.0',
},
{
plugin_id: 'plugin-2',
org: 'dify',
name: 'Plugin Two',
icon: 'icon-2.png',
version: '2.0.0',
},
] as Plugin[]
const installStatus: InstallStatus[] = [
{ success: true, isFromMarketPlace: true },
{ success: false, isFromMarketPlace: false },
]
describe('Installed', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('renders plugin cards with install status and marketplace icon handling', () => {
render(
<Installed
list={plugins}
installStatus={installStatus}
onCancel={vi.fn()}
/>,
)
expect(screen.getAllByTestId('card')).toHaveLength(2)
expect(screen.getByRole('button', { name: 'common.operation.close' })).toBeInTheDocument()
expect(screen.getByText('1.0.0')).toBeInTheDocument()
expect(screen.getByText('2.0.0')).toBeInTheDocument()
expect(mockCard).toHaveBeenNthCalledWith(1, expect.objectContaining({
installed: true,
installFailed: false,
payload: expect.objectContaining({
icon: 'https://marketplace.example.com/plugins/dify/Plugin One/icon',
}),
}))
expect(mockCard).toHaveBeenNthCalledWith(2, expect.objectContaining({
installed: false,
installFailed: true,
payload: expect.objectContaining({
icon: 'https://api.example.com/icon-2.png',
}),
}))
})
it('calls onCancel when close button is clicked', () => {
const onCancel = vi.fn()
render(
<Installed
list={plugins}
installStatus={installStatus}
onCancel={onCancel}
/>,
)
fireEvent.click(screen.getByRole('button', { name: 'common.operation.close' }))
expect(onCancel).toHaveBeenCalledTimes(1)
})
it('hides action button when isHideButton is true', () => {
render(
<Installed
list={plugins}
installStatus={installStatus}
onCancel={vi.fn()}
isHideButton
/>,
)
expect(screen.queryByRole('button', { name: 'common.operation.close' })).not.toBeInTheDocument()
})
})

View File

@ -0,0 +1,37 @@
import { describe, expect, it } from 'vitest'
import { PluginCategoryEnum } from '../../types'
import {
DEFAULT_SORT,
PLUGIN_CATEGORY_WITH_COLLECTIONS,
PLUGIN_TYPE_SEARCH_MAP,
SCROLL_BOTTOM_THRESHOLD,
} from '../constants'
describe('marketplace constants', () => {
it('defines the expected default sort', () => {
expect(DEFAULT_SORT).toEqual({
sortBy: 'install_count',
sortOrder: 'DESC',
})
})
it('defines the expected plugin search type map', () => {
expect(PLUGIN_TYPE_SEARCH_MAP).toEqual({
all: 'all',
model: PluginCategoryEnum.model,
tool: PluginCategoryEnum.tool,
agent: PluginCategoryEnum.agent,
extension: PluginCategoryEnum.extension,
datasource: PluginCategoryEnum.datasource,
trigger: PluginCategoryEnum.trigger,
bundle: 'bundle',
})
expect(SCROLL_BOTTOM_THRESHOLD).toBe(100)
})
it('tracks only collection-backed categories', () => {
expect(PLUGIN_CATEGORY_WITH_COLLECTIONS.has(PLUGIN_TYPE_SEARCH_MAP.all)).toBe(true)
expect(PLUGIN_CATEGORY_WITH_COLLECTIONS.has(PLUGIN_TYPE_SEARCH_MAP.tool)).toBe(true)
expect(PLUGIN_CATEGORY_WITH_COLLECTIONS.has(PLUGIN_TYPE_SEARCH_MAP.model)).toBe(false)
})
})

View File

@ -0,0 +1,18 @@
import { describe, expect, it } from 'vitest'
import { PLUGIN_TYPE_SEARCH_MAP } from '../constants'
import { marketplaceSearchParamsParsers } from '../search-params'
describe('marketplace search params', () => {
it('applies the expected default values', () => {
expect(marketplaceSearchParamsParsers.category.parseServerSide(undefined)).toBe(PLUGIN_TYPE_SEARCH_MAP.all)
expect(marketplaceSearchParamsParsers.q.parseServerSide(undefined)).toBe('')
expect(marketplaceSearchParamsParsers.tags.parseServerSide(undefined)).toEqual([])
})
it('parses supported query values with the configured parsers', () => {
expect(marketplaceSearchParamsParsers.category.parseServerSide(PLUGIN_TYPE_SEARCH_MAP.tool)).toBe(PLUGIN_TYPE_SEARCH_MAP.tool)
expect(marketplaceSearchParamsParsers.category.parseServerSide('unsupported')).toBe(PLUGIN_TYPE_SEARCH_MAP.all)
expect(marketplaceSearchParamsParsers.q.parseServerSide('keyword')).toBe('keyword')
expect(marketplaceSearchParamsParsers.tags.parseServerSide('rag,search')).toEqual(['rag', 'search'])
})
})

View File

@ -0,0 +1,30 @@
import { render } from '@testing-library/react'
import { describe, expect, it, vi } from 'vitest'
import Line from '../line'
const mockUseTheme = vi.fn()
vi.mock('@/hooks/use-theme', () => ({
default: () => mockUseTheme(),
}))
describe('Line', () => {
it('renders dark mode svg variant', () => {
mockUseTheme.mockReturnValue({ theme: 'dark' })
const { container } = render(<Line className="divider" />)
const svg = container.querySelector('svg')
expect(svg).toHaveAttribute('height', '240')
expect(svg).toHaveAttribute('viewBox', '0 0 2 240')
expect(svg).toHaveClass('divider')
})
it('renders light mode svg variant', () => {
mockUseTheme.mockReturnValue({ theme: 'light' })
const { container } = render(<Line />)
const svg = container.querySelector('svg')
expect(svg).toHaveAttribute('height', '241')
expect(svg).toHaveAttribute('viewBox', '0 0 2 241')
})
})

View File

@ -0,0 +1,115 @@
import type { ComponentProps } from 'react'
import type { Plugin } from '@/app/components/plugins/types'
import { fireEvent, render, screen } from '@testing-library/react'
import { ThemeProvider } from 'next-themes'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { PluginCategoryEnum } from '@/app/components/plugins/types'
import CardWrapper from '../card-wrapper'
vi.mock('#i18n', () => ({
useTranslation: () => ({
t: (key: string, options?: { ns?: string }) => options?.ns ? `${options.ns}.${key}` : key,
}),
useLocale: () => 'en-US',
}))
vi.mock('@/app/components/plugins/hooks', () => ({
useTags: () => ({
getTagLabel: (name: string) => `tag:${name}`,
}),
}))
vi.mock('@/app/components/plugins/card', () => ({
default: ({ payload, footer }: { payload: Plugin, footer?: React.ReactNode }) => (
<div data-testid="card">
<span>{payload.name}</span>
{footer}
</div>
),
}))
vi.mock('@/app/components/plugins/card/card-more-info', () => ({
default: ({ downloadCount, tags }: { downloadCount: number, tags: string[] }) => (
<div data-testid="card-more-info">
{downloadCount}
:
{tags.join('|')}
</div>
),
}))
vi.mock('@/app/components/plugins/install-plugin/install-from-marketplace', () => ({
default: ({ onClose }: { onClose: () => void }) => (
<div data-testid="install-modal">
<button data-testid="close-install-modal" onClick={onClose}>close</button>
</div>
),
}))
vi.mock('../../utils', () => ({
getPluginDetailLinkInMarketplace: (plugin: Plugin) => `/detail/${plugin.org}/${plugin.name}`,
getPluginLinkInMarketplace: (plugin: Plugin, params: Record<string, string>) => `/marketplace/${plugin.org}/${plugin.name}?language=${params.language}&theme=${params.theme}`,
}))
const plugin = {
type: 'plugin',
org: 'dify',
name: 'plugin-a',
plugin_id: 'plugin-a',
version: '1.0.0',
latest_version: '1.0.0',
latest_package_identifier: 'pkg',
icon: 'icon.png',
verified: true,
label: { 'en-US': 'Plugin A' },
brief: { 'en-US': 'Brief' },
description: { 'en-US': 'Description' },
introduction: 'Intro',
repository: 'https://github.com/dify/plugin-a',
category: PluginCategoryEnum.tool,
install_count: 42,
endpoint: { settings: [] },
tags: [{ name: 'search' }, { name: 'agent' }],
badges: [],
verification: { authorized_category: 'community' },
from: 'marketplace',
} as Plugin
describe('CardWrapper', () => {
beforeEach(() => {
vi.clearAllMocks()
})
const renderCardWrapper = (props: Partial<ComponentProps<typeof CardWrapper>> = {}) => render(
<ThemeProvider forcedTheme="dark">
<CardWrapper plugin={plugin} {...props} />
</ThemeProvider>,
)
it('renders plugin detail link when install button is hidden', () => {
renderCardWrapper()
expect(screen.getByRole('link')).toHaveAttribute('href', '/detail/dify/plugin-a')
expect(screen.getByTestId('card-more-info')).toHaveTextContent('42:tag:search|tag:agent')
})
it('renders install and marketplace detail actions when install button is shown', () => {
renderCardWrapper({ showInstallButton: true })
expect(screen.getByRole('button', { name: 'plugin.detailPanel.operation.install' })).toBeInTheDocument()
expect(screen.getByRole('link', { name: 'plugin.detailPanel.operation.detail' })).toHaveAttribute(
'href',
'/marketplace/dify/plugin-a?language=en-US&theme=system',
)
})
it('opens and closes install modal from install action', () => {
renderCardWrapper({ showInstallButton: true })
fireEvent.click(screen.getByRole('button', { name: 'plugin.detailPanel.operation.install' }))
expect(screen.getByTestId('install-modal')).toBeInTheDocument()
fireEvent.click(screen.getByTestId('close-install-modal'))
expect(screen.queryByTestId('install-modal')).not.toBeInTheDocument()
})
})

View File

@ -0,0 +1,102 @@
import type { MarketplaceCollection } from '../../types'
import type { Plugin } from '@/app/components/plugins/types'
import { fireEvent, render, screen } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import ListWithCollection from '../list-with-collection'
const mockMoreClick = vi.fn()
vi.mock('#i18n', () => ({
useTranslation: () => ({
t: (key: string, options?: { ns?: string }) => options?.ns ? `${options.ns}.${key}` : key,
}),
useLocale: () => 'en-US',
}))
vi.mock('../../atoms', () => ({
useMarketplaceMoreClick: () => mockMoreClick,
}))
vi.mock('@/i18n-config/language', () => ({
getLanguage: (locale: string) => locale,
}))
vi.mock('../card-wrapper', () => ({
default: ({ plugin }: { plugin: Plugin }) => <div data-testid="card-wrapper">{plugin.name}</div>,
}))
const collections: MarketplaceCollection[] = [
{
name: 'featured',
label: { 'en-US': 'Featured' },
description: { 'en-US': 'Featured plugins' },
rule: 'featured',
created_at: '',
updated_at: '',
searchable: true,
search_params: { query: 'featured' },
},
{
name: 'empty',
label: { 'en-US': 'Empty' },
description: { 'en-US': 'No plugins' },
rule: 'empty',
created_at: '',
updated_at: '',
searchable: false,
search_params: {},
},
]
const pluginsMap: Record<string, Plugin[]> = {
featured: [
{ plugin_id: 'p1', name: 'Plugin One' },
{ plugin_id: 'p2', name: 'Plugin Two' },
] as Plugin[],
empty: [],
}
describe('ListWithCollection', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('renders only collections that contain plugins', () => {
render(
<ListWithCollection
marketplaceCollections={collections}
marketplaceCollectionPluginsMap={pluginsMap}
/>,
)
expect(screen.getByText('Featured')).toBeInTheDocument()
expect(screen.queryByText('Empty')).not.toBeInTheDocument()
expect(screen.getAllByTestId('card-wrapper')).toHaveLength(2)
})
it('calls more handler for searchable collection', () => {
render(
<ListWithCollection
marketplaceCollections={collections}
marketplaceCollectionPluginsMap={pluginsMap}
/>,
)
fireEvent.click(screen.getByText('plugin.marketplace.viewMore'))
expect(mockMoreClick).toHaveBeenCalledWith({ query: 'featured' })
})
it('uses custom card renderer when provided', () => {
render(
<ListWithCollection
marketplaceCollections={collections}
marketplaceCollectionPluginsMap={pluginsMap}
cardRender={plugin => <div key={plugin.plugin_id} data-testid="custom-card">{plugin.name}</div>}
/>,
)
expect(screen.getAllByTestId('custom-card')).toHaveLength(2)
expect(screen.queryByTestId('card-wrapper')).not.toBeInTheDocument()
})
})

View File

@ -0,0 +1,92 @@
import type { MarketplaceCollection } from '../../types'
import type { Plugin } from '@/app/components/plugins/types'
import { render, screen } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import ListWrapper from '../list-wrapper'
const mockMarketplaceData = vi.hoisted(() => ({
plugins: undefined as Plugin[] | undefined,
pluginsTotal: 0,
marketplaceCollections: [] as MarketplaceCollection[],
marketplaceCollectionPluginsMap: {} as Record<string, Plugin[]>,
isLoading: false,
isFetchingNextPage: false,
page: 1,
}))
vi.mock('#i18n', () => ({
useTranslation: () => ({
t: (key: string, options?: { ns?: string, num?: number }) =>
key === 'marketplace.pluginsResult' && options?.ns === 'plugin'
? `${options.num} plugins found`
: options?.ns ? `${options.ns}.${key}` : key,
}),
}))
vi.mock('../../state', () => ({
useMarketplaceData: () => mockMarketplaceData,
}))
vi.mock('@/app/components/base/loading', () => ({
default: ({ className }: { className?: string }) => <div data-testid="loading" className={className}>loading</div>,
}))
vi.mock('../../sort-dropdown', () => ({
default: () => <div data-testid="sort-dropdown">sort</div>,
}))
vi.mock('../index', () => ({
default: ({ plugins }: { plugins?: Plugin[] }) => <div data-testid="list">{plugins?.length ?? 'collections'}</div>,
}))
describe('ListWrapper', () => {
beforeEach(() => {
vi.clearAllMocks()
mockMarketplaceData.plugins = undefined
mockMarketplaceData.pluginsTotal = 0
mockMarketplaceData.marketplaceCollections = []
mockMarketplaceData.marketplaceCollectionPluginsMap = {}
mockMarketplaceData.isLoading = false
mockMarketplaceData.isFetchingNextPage = false
mockMarketplaceData.page = 1
})
it('shows result header and sort dropdown when plugins are loaded', () => {
mockMarketplaceData.plugins = [{ plugin_id: 'p1', name: 'Plugin One' } as Plugin]
mockMarketplaceData.pluginsTotal = 1
render(<ListWrapper />)
expect(screen.getByText('1 plugins found')).toBeInTheDocument()
expect(screen.getByTestId('sort-dropdown')).toBeInTheDocument()
})
it('shows centered loading only on initial loading page', () => {
mockMarketplaceData.isLoading = true
mockMarketplaceData.page = 1
render(<ListWrapper />)
expect(screen.getByTestId('loading')).toBeInTheDocument()
expect(screen.queryByTestId('list')).not.toBeInTheDocument()
})
it('renders list when loading additional pages', () => {
mockMarketplaceData.isLoading = true
mockMarketplaceData.page = 2
mockMarketplaceData.plugins = [{ plugin_id: 'p1', name: 'Plugin One' } as Plugin]
render(<ListWrapper showInstallButton />)
expect(screen.getByTestId('list')).toBeInTheDocument()
})
it('shows bottom loading indicator while fetching next page', () => {
mockMarketplaceData.plugins = [{ plugin_id: 'p1', name: 'Plugin One' } as Plugin]
mockMarketplaceData.isFetchingNextPage = true
render(<ListWrapper />)
expect(screen.getAllByTestId('loading')).toHaveLength(1)
})
})

View File

@ -0,0 +1,43 @@
import { render, screen } from '@testing-library/react'
import { describe, expect, it, vi } from 'vitest'
import SearchBoxWrapper from '../search-box-wrapper'
const mockHandleSearchPluginTextChange = vi.fn()
const mockHandleFilterPluginTagsChange = vi.fn()
const mockSearchBox = vi.fn()
vi.mock('#i18n', () => ({
useTranslation: () => ({
t: (key: string, options?: { ns?: string }) => options?.ns ? `${options.ns}.${key}` : key,
}),
}))
vi.mock('../../atoms', () => ({
useSearchPluginText: () => ['plugin search', mockHandleSearchPluginTextChange],
useFilterPluginTags: () => [['agent', 'rag'], mockHandleFilterPluginTagsChange],
}))
vi.mock('../index', () => ({
default: (props: Record<string, unknown>) => {
mockSearchBox(props)
return <div data-testid="search-box">search-box</div>
},
}))
describe('SearchBoxWrapper', () => {
it('passes marketplace search state into SearchBox', () => {
render(<SearchBoxWrapper />)
expect(screen.getByTestId('search-box')).toBeInTheDocument()
expect(mockSearchBox).toHaveBeenCalledWith(expect.objectContaining({
wrapperClassName: 'z-11 mx-auto w-[640px] shrink-0',
inputClassName: 'w-full',
search: 'plugin search',
onSearchChange: mockHandleSearchPluginTextChange,
tags: ['agent', 'rag'],
onTagsChange: mockHandleFilterPluginTagsChange,
placeholder: 'plugin.searchPlugins',
usedInMarketplace: true,
}))
})
})

View File

@ -0,0 +1,126 @@
import { fireEvent, render, screen } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import TagsFilter from '../tags-filter'
vi.mock('#i18n', () => ({
useTranslation: () => ({
t: (key: string, options?: { ns?: string }) => options?.ns ? `${options.ns}.${key}` : key,
}),
}))
vi.mock('@/app/components/plugins/hooks', () => ({
useTags: () => ({
tags: [
{ name: 'agent', label: 'Agent' },
{ name: 'rag', label: 'RAG' },
{ name: 'search', label: 'Search' },
],
tagsMap: {
agent: { name: 'agent', label: 'Agent' },
rag: { name: 'rag', label: 'RAG' },
search: { name: 'search', label: 'Search' },
},
}),
}))
vi.mock('@/app/components/base/checkbox', () => ({
default: ({ checked }: { checked: boolean }) => <span data-testid="checkbox">{String(checked)}</span>,
}))
vi.mock('@/app/components/base/input', () => ({
default: ({
value,
onChange,
placeholder,
}: {
value: string
onChange: (event: { target: { value: string } }) => void
placeholder: string
}) => (
<input
aria-label="tags-search"
value={value}
placeholder={placeholder}
onChange={event => onChange({ target: { value: event.target.value } })}
/>
),
}))
vi.mock('@/app/components/base/portal-to-follow-elem', async () => {
const React = await import('react')
return {
PortalToFollowElem: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
PortalToFollowElemTrigger: ({
children,
onClick,
}: {
children: React.ReactNode
onClick: () => void
}) => <button data-testid="portal-trigger" onClick={onClick}>{children}</button>,
PortalToFollowElemContent: ({ children }: { children: React.ReactNode }) => <div data-testid="portal-content">{children}</div>,
}
})
vi.mock('../trigger/marketplace', () => ({
default: ({ selectedTagsLength }: { selectedTagsLength: number }) => (
<div data-testid="marketplace-trigger">
marketplace:
{selectedTagsLength}
</div>
),
}))
vi.mock('../trigger/tool-selector', () => ({
default: ({ selectedTagsLength }: { selectedTagsLength: number }) => (
<div data-testid="tool-trigger">
tool:
{selectedTagsLength}
</div>
),
}))
describe('TagsFilter', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('renders marketplace trigger when used in marketplace', () => {
render(<TagsFilter tags={['agent']} onTagsChange={vi.fn()} usedInMarketplace />)
expect(screen.getByTestId('marketplace-trigger')).toHaveTextContent('marketplace:1')
expect(screen.queryByTestId('tool-trigger')).not.toBeInTheDocument()
})
it('renders tool selector trigger when used outside marketplace', () => {
render(<TagsFilter tags={['agent']} onTagsChange={vi.fn()} />)
expect(screen.getByTestId('tool-trigger')).toHaveTextContent('tool:1')
expect(screen.queryByTestId('marketplace-trigger')).not.toBeInTheDocument()
})
it('filters tag options by search text', () => {
render(<TagsFilter tags={[]} onTagsChange={vi.fn()} />)
expect(screen.getByText('Agent')).toBeInTheDocument()
expect(screen.getByText('RAG')).toBeInTheDocument()
expect(screen.getByText('Search')).toBeInTheDocument()
fireEvent.change(screen.getByLabelText('tags-search'), { target: { value: 'ra' } })
expect(screen.queryByText('Agent')).not.toBeInTheDocument()
expect(screen.getByText('RAG')).toBeInTheDocument()
expect(screen.queryByText('Search')).not.toBeInTheDocument()
})
it('adds and removes selected tags when options are clicked', () => {
const onTagsChange = vi.fn()
const { rerender } = render(<TagsFilter tags={['agent']} onTagsChange={onTagsChange} />)
fireEvent.click(screen.getByText('Agent'))
expect(onTagsChange).toHaveBeenCalledWith([])
rerender(<TagsFilter tags={['agent']} onTagsChange={onTagsChange} />)
fireEvent.click(screen.getByText('RAG'))
expect(onTagsChange).toHaveBeenCalledWith(['agent', 'rag'])
})
})

View File

@ -0,0 +1,67 @@
import type { Tag } from '../../../../hooks'
import { fireEvent, render, screen } from '@testing-library/react'
import { describe, expect, it, vi } from 'vitest'
import MarketplaceTrigger from '../marketplace'
vi.mock('#i18n', () => ({
useTranslation: () => ({
t: (key: string, options?: { ns?: string }) => options?.ns ? `${options.ns}.${key}` : key,
}),
}))
const tagsMap: Record<string, Tag> = {
agent: { name: 'agent', label: 'Agent' },
rag: { name: 'rag', label: 'RAG' },
search: { name: 'search', label: 'Search' },
}
describe('MarketplaceTrigger', () => {
it('shows all-tags text when no tags are selected', () => {
const { container } = render(
<MarketplaceTrigger
selectedTagsLength={0}
open={false}
tags={[]}
tagsMap={tagsMap}
onTagsChange={vi.fn()}
/>,
)
expect(screen.getByText('pluginTags.allTags')).toBeInTheDocument()
expect(container.querySelectorAll('svg').length).toBeGreaterThan(0)
expect(container.querySelectorAll('svg').length).toBe(2)
})
it('shows selected tag labels and overflow count', () => {
render(
<MarketplaceTrigger
selectedTagsLength={3}
open
tags={['agent', 'rag', 'search']}
tagsMap={tagsMap}
onTagsChange={vi.fn()}
/>,
)
expect(screen.getByText('Agent,RAG')).toBeInTheDocument()
expect(screen.getByText('+1')).toBeInTheDocument()
})
it('clears selected tags when clear icon is clicked', () => {
const onTagsChange = vi.fn()
const { container } = render(
<MarketplaceTrigger
selectedTagsLength={1}
open={false}
tags={['agent']}
tagsMap={tagsMap}
onTagsChange={onTagsChange}
/>,
)
fireEvent.click(container.querySelectorAll('svg')[1]!)
expect(onTagsChange).toHaveBeenCalledWith([])
})
})

View File

@ -0,0 +1,61 @@
import type { Tag } from '../../../../hooks'
import { fireEvent, render, screen } from '@testing-library/react'
import { describe, expect, it, vi } from 'vitest'
import ToolSelectorTrigger from '../tool-selector'
const tagsMap: Record<string, Tag> = {
agent: { name: 'agent', label: 'Agent' },
rag: { name: 'rag', label: 'RAG' },
search: { name: 'search', label: 'Search' },
}
describe('ToolSelectorTrigger', () => {
it('renders only icon when no tags are selected', () => {
const { container } = render(
<ToolSelectorTrigger
selectedTagsLength={0}
open={false}
tags={[]}
tagsMap={tagsMap}
onTagsChange={vi.fn()}
/>,
)
expect(container.querySelectorAll('svg')).toHaveLength(1)
expect(screen.queryByText('Agent')).not.toBeInTheDocument()
})
it('renders selected tag labels and overflow count', () => {
const { container } = render(
<ToolSelectorTrigger
selectedTagsLength={3}
open
tags={['agent', 'rag', 'search']}
tagsMap={tagsMap}
onTagsChange={vi.fn()}
/>,
)
expect(screen.getByText('Agent,RAG')).toBeInTheDocument()
expect(screen.getByText('+1')).toBeInTheDocument()
expect(container.querySelectorAll('svg')).toHaveLength(2)
})
it('clears selected tags when clear icon is clicked', () => {
const onTagsChange = vi.fn()
const { container } = render(
<ToolSelectorTrigger
selectedTagsLength={1}
open={false}
tags={['agent']}
tagsMap={tagsMap}
onTagsChange={onTagsChange}
/>,
)
fireEvent.click(container.querySelectorAll('svg')[1]!)
expect(onTagsChange).toHaveBeenCalledWith([])
})
})

View File

@ -0,0 +1,106 @@
import { fireEvent, render, screen } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { InputVarType } from '@/app/components/workflow/types'
import AppInputsForm from '../app-inputs-form'
vi.mock('@/app/components/base/file-uploader', () => ({
FileUploaderInAttachmentWrapper: ({
onChange,
}: {
onChange: (files: Array<Record<string, unknown>>) => void
}) => (
<button data-testid="file-uploader" onClick={() => onChange([{ id: 'file-1', name: 'demo.png' }])}>
Upload
</button>
),
}))
vi.mock('@/app/components/base/select', () => ({
PortalSelect: ({
items,
onSelect,
}: {
items: Array<{ value: string, name: string }>
onSelect: (item: { value: string }) => void
}) => (
<div>
{items.map(item => (
<button key={item.value} data-testid={`select-${item.value}`} onClick={() => onSelect(item)}>
{item.name}
</button>
))}
</div>
),
}))
describe('AppInputsForm', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('should update text input values', () => {
const onFormChange = vi.fn()
const inputsRef = { current: { question: '' } }
render(
<AppInputsForm
inputsForms={[{ variable: 'question', label: 'Question', type: InputVarType.textInput, required: false }]}
inputs={{ question: '' }}
inputsRef={inputsRef}
onFormChange={onFormChange}
/>,
)
fireEvent.change(screen.getByPlaceholderText('Question'), {
target: { value: 'hello' },
})
expect(onFormChange).toHaveBeenCalledWith({ question: 'hello' })
})
it('should update select values', () => {
const onFormChange = vi.fn()
const inputsRef = { current: { tone: '' } }
render(
<AppInputsForm
inputsForms={[{ variable: 'tone', label: 'Tone', type: InputVarType.select, options: ['friendly', 'formal'], required: false }]}
inputs={{ tone: '' }}
inputsRef={inputsRef}
onFormChange={onFormChange}
/>,
)
fireEvent.click(screen.getByTestId('select-formal'))
expect(onFormChange).toHaveBeenCalledWith({ tone: 'formal' })
})
it('should update uploaded single file values', () => {
const onFormChange = vi.fn()
const inputsRef = { current: { attachment: null } }
render(
<AppInputsForm
inputsForms={[{
variable: 'attachment',
label: 'Attachment',
type: InputVarType.singleFile,
required: false,
allowed_file_types: [],
allowed_file_extensions: ['.png'],
allowed_file_upload_methods: ['local_file'],
}]}
inputs={{ attachment: null }}
inputsRef={inputsRef}
onFormChange={onFormChange}
/>,
)
fireEvent.click(screen.getByTestId('file-uploader'))
expect(onFormChange).toHaveBeenCalledWith({
attachment: { id: 'file-1', name: 'demo.png' },
})
})
})

View File

@ -0,0 +1,87 @@
import { fireEvent, render, screen } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import AppInputsPanel from '../app-inputs-panel'
let mockHookResult = {
inputFormSchema: [] as Array<Record<string, unknown>>,
isLoading: false,
}
vi.mock('@/app/components/base/loading', () => ({
default: () => <div data-testid="loading">Loading</div>,
}))
vi.mock('@/app/components/plugins/plugin-detail-panel/app-selector/app-inputs-form', () => ({
default: ({
onFormChange,
}: {
onFormChange: (value: Record<string, unknown>) => void
}) => (
<button data-testid="app-inputs-form" onClick={() => onFormChange({ topic: 'updated' })}>
Form
</button>
),
}))
vi.mock('@/app/components/plugins/plugin-detail-panel/app-selector/hooks/use-app-inputs-form-schema', () => ({
useAppInputsFormSchema: () => mockHookResult,
}))
describe('AppInputsPanel', () => {
beforeEach(() => {
vi.clearAllMocks()
mockHookResult = {
inputFormSchema: [],
isLoading: false,
}
})
it('should render a loading state', () => {
mockHookResult = {
inputFormSchema: [],
isLoading: true,
}
render(
<AppInputsPanel
value={{ app_id: 'app-1', inputs: {} }}
appDetail={{ id: 'app-1' } as never}
onFormChange={vi.fn()}
/>,
)
expect(screen.getByTestId('loading')).toBeInTheDocument()
})
it('should render an empty state when no inputs are available', () => {
render(
<AppInputsPanel
value={{ app_id: 'app-1', inputs: {} }}
appDetail={{ id: 'app-1' } as never}
onFormChange={vi.fn()}
/>,
)
expect(screen.getByText('app.appSelector.noParams')).toBeInTheDocument()
})
it('should render the inputs form and propagate changes', () => {
const onFormChange = vi.fn()
mockHookResult = {
inputFormSchema: [{ variable: 'topic' }],
isLoading: false,
}
render(
<AppInputsPanel
value={{ app_id: 'app-1', inputs: { topic: 'initial' } }}
appDetail={{ id: 'app-1' } as never}
onFormChange={onFormChange}
/>,
)
fireEvent.click(screen.getByTestId('app-inputs-form'))
expect(onFormChange).toHaveBeenCalledWith({ topic: 'updated' })
})
})

View File

@ -0,0 +1,179 @@
import type { ReactNode } from 'react'
import { fireEvent, render, screen } from '@testing-library/react'
import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'
import { AppModeEnum } from '@/types/app'
import AppPicker from '../app-picker'
class MockIntersectionObserver {
observe = vi.fn()
disconnect = vi.fn()
unobserve = vi.fn()
}
class MockMutationObserver {
observe = vi.fn()
disconnect = vi.fn()
takeRecords = vi.fn().mockReturnValue([])
}
beforeAll(() => {
vi.stubGlobal('IntersectionObserver', MockIntersectionObserver)
vi.stubGlobal('MutationObserver', MockMutationObserver)
})
vi.mock('@/app/components/base/app-icon', () => ({
default: () => <div data-testid="app-icon" />,
}))
vi.mock('@/app/components/base/input', () => ({
default: ({
value,
onChange,
onClear,
}: {
value: string
onChange: (e: { target: { value: string } }) => void
onClear?: () => void
}) => (
<div>
<input
data-testid="search-input"
value={value}
onChange={e => onChange({ target: { value: e.target.value } })}
/>
<button data-testid="clear-input" onClick={onClear}>Clear</button>
</div>
),
}))
vi.mock('@/app/components/base/portal-to-follow-elem', () => ({
PortalToFollowElem: ({
children,
open,
}: {
children: ReactNode
open: boolean
}) => (
<div data-testid="portal" data-open={open}>
{children}
</div>
),
PortalToFollowElemTrigger: ({
children,
onClick,
}: {
children: ReactNode
onClick?: () => void
}) => (
<button data-testid="picker-trigger" onClick={onClick}>
{children}
</button>
),
PortalToFollowElemContent: ({ children }: { children: ReactNode }) => (
<div data-testid="portal-content">{children}</div>
),
}))
const apps = [
{
id: 'app-1',
name: 'Chat App',
mode: AppModeEnum.CHAT,
icon_type: 'emoji',
icon: '🤖',
icon_background: '#fff',
},
{
id: 'app-2',
name: 'Workflow App',
mode: AppModeEnum.WORKFLOW,
icon_type: 'emoji',
icon: '⚙️',
icon_background: '#fff',
},
]
describe('AppPicker', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('should open when the trigger is clicked', () => {
const onShowChange = vi.fn()
render(
<AppPicker
scope="all"
disabled={false}
trigger={<span>Trigger</span>}
isShow={false}
onShowChange={onShowChange}
onSelect={vi.fn()}
apps={apps as never}
isLoading={false}
hasMore={false}
onLoadMore={vi.fn()}
searchText=""
onSearchChange={vi.fn()}
/>,
)
fireEvent.click(screen.getByTestId('picker-trigger'))
expect(onShowChange).toHaveBeenCalledWith(true)
})
it('should render apps, select one, and handle search changes', () => {
const onSelect = vi.fn()
const onSearchChange = vi.fn()
render(
<AppPicker
scope="all"
disabled={false}
trigger={<span>Trigger</span>}
isShow
onShowChange={vi.fn()}
onSelect={onSelect}
apps={apps as never}
isLoading={false}
hasMore={false}
onLoadMore={vi.fn()}
searchText="chat"
onSearchChange={onSearchChange}
/>,
)
fireEvent.change(screen.getByTestId('search-input'), {
target: { value: 'workflow' },
})
fireEvent.click(screen.getByText('Workflow App'))
fireEvent.click(screen.getByTestId('clear-input'))
expect(onSearchChange).toHaveBeenCalledWith('workflow')
expect(onSearchChange).toHaveBeenCalledWith('')
expect(onSelect).toHaveBeenCalledWith(apps[1])
expect(screen.getByText('chat')).toBeInTheDocument()
})
it('should render loading text when loading more apps', () => {
render(
<AppPicker
scope="all"
disabled={false}
trigger={<span>Trigger</span>}
isShow
onShowChange={vi.fn()}
onSelect={vi.fn()}
apps={apps as never}
isLoading
hasMore
onLoadMore={vi.fn()}
searchText=""
onSearchChange={vi.fn()}
/>,
)
expect(screen.getByText('common.loading')).toBeInTheDocument()
})
})

View File

@ -0,0 +1,141 @@
import { renderHook } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { BlockEnum, InputVarType, SupportUploadFileTypes } from '@/app/components/workflow/types'
import { AppModeEnum, Resolution } from '@/types/app'
import { useAppInputsFormSchema } from '../use-app-inputs-form-schema'
let mockAppDetailData: Record<string, unknown> | null = null
let mockAppWorkflowData: Record<string, unknown> | null = null
vi.mock('@/service/use-common', () => ({
useFileUploadConfig: () => ({
data: {
file_size_limit: 15,
image_file_size_limit: 10,
},
}),
}))
vi.mock('@/service/use-apps', () => ({
useAppDetail: () => ({
data: mockAppDetailData,
isFetching: false,
}),
}))
vi.mock('@/service/use-workflow', () => ({
useAppWorkflow: () => ({
data: mockAppWorkflowData,
isFetching: false,
}),
}))
describe('useAppInputsFormSchema', () => {
beforeEach(() => {
vi.clearAllMocks()
mockAppDetailData = null
mockAppWorkflowData = null
})
it('should build basic app schemas and append image upload support', () => {
mockAppDetailData = {
id: 'app-1',
mode: AppModeEnum.COMPLETION,
model_config: {
user_input_form: [
{
'text-input': {
label: 'Question',
variable: 'question',
},
},
],
file_upload: {
enabled: true,
image: {
enabled: true,
detail: Resolution.high,
number_limits: 2,
transfer_methods: ['local_file'],
},
allowed_file_types: [SupportUploadFileTypes.image],
allowed_file_extensions: ['.png'],
allowed_file_upload_methods: ['local_file'],
number_limits: 2,
},
},
}
const { result } = renderHook(() => useAppInputsFormSchema({
appDetail: {
id: 'app-1',
mode: AppModeEnum.COMPLETION,
} as never,
}))
expect(result.current.isLoading).toBe(false)
expect(result.current.inputFormSchema).toEqual(expect.arrayContaining([
expect.objectContaining({
variable: 'question',
type: 'text-input',
}),
expect.objectContaining({
variable: '#image#',
type: InputVarType.singleFile,
allowed_file_extensions: ['.png'],
}),
]))
})
it('should build workflow schemas from start node variables', () => {
mockAppDetailData = {
id: 'app-2',
mode: AppModeEnum.WORKFLOW,
}
mockAppWorkflowData = {
graph: {
nodes: [
{
data: {
type: BlockEnum.Start,
variables: [
{
label: 'Attachments',
variable: 'attachments',
type: InputVarType.multiFiles,
},
],
},
},
],
},
features: {},
}
const { result } = renderHook(() => useAppInputsFormSchema({
appDetail: {
id: 'app-2',
mode: AppModeEnum.WORKFLOW,
} as never,
}))
expect(result.current.inputFormSchema).toEqual([
expect.objectContaining({
variable: 'attachments',
type: InputVarType.multiFiles,
fileUploadConfig: expect.any(Object),
}),
])
})
it('should return an empty schema when app detail is unavailable', () => {
const { result } = renderHook(() => useAppInputsFormSchema({
appDetail: {
id: 'missing-app',
mode: AppModeEnum.CHAT,
} as never,
}))
expect(result.current.inputFormSchema).toEqual([])
})
})

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