mirror of
https://github.com/langgenius/dify.git
synced 2026-04-09 07:07:29 +08:00
Compare commits
57 Commits
codex/fix-
...
deploy/dev
| Author | SHA1 | Date | |
|---|---|---|---|
| 93f9004898 | |||
| 56fd708cf6 | |||
| 6234776ae3 | |||
| 731adab593 | |||
| ccfc8c6f15 | |||
| 4fb3fab82d | |||
| 3cea0dfb07 | |||
| 0d6db3a3f3 | |||
| d06ce2ef78 | |||
| 3d5a81bd30 | |||
| abcf4a5730 | |||
| 5b3616aa33 | |||
| 208604a3a8 | |||
| 19ab594c72 | |||
| b64e930771 | |||
| 63bfba0bdb | |||
| 9948a51b14 | |||
| 0e0bb3582f | |||
| 40bca2ad9c | |||
| 546062d2cd | |||
| aad0b3c157 | |||
| ef7dc9eabb | |||
| ae01a5d137 | |||
| ad6670ebcc | |||
| 8ca0917044 | |||
| b2861e019b | |||
| cad9936c0a | |||
| 8c0b596ced | |||
| 65e434cf06 | |||
| 12a0f85b72 | |||
| 1fdb653875 | |||
| 4ba8c71962 | |||
| 1f1c74099f | |||
| 359007848d | |||
| 43fedac47b | |||
| 20ddc9c48a | |||
| a91c1a2af0 | |||
| b3870524d4 | |||
| 919c080452 | |||
| 4653ed7ead | |||
| c543188434 | |||
| f319a9e42f | |||
| 58241a89a5 | |||
| 422bf3506e | |||
| 6e745f9e9b | |||
| 4e50d55339 | |||
| b95cdabe26 | |||
| daa47c25bb | |||
| f1bcd6d715 | |||
| 8643ff43f5 | |||
| c5f30a47f0 | |||
| 37d438fa19 | |||
| 9503803997 | |||
| d6476f5434 | |||
| 80b4633e8f | |||
| 3888969af3 | |||
| 658ac15589 |
82
.github/scripts/generate-i18n-changes.mjs
vendored
Normal file
82
.github/scripts/generate-i18n-changes.mjs
vendored
Normal 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,
|
||||
})
|
||||
)
|
||||
101
.github/workflows/translate-i18n-claude.yml
vendored
101
.github/workflows/translate-i18n-claude.yml
vendored
@ -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',
|
||||
'',
|
||||
|
||||
83
.github/workflows/trigger-i18n-sync.yml
vendored
83
.github/workflows/trigger-i18n-sync.yml
vendored
@ -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"
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
@ -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"}
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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
|
||||
)
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -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"]
|
||||
|
||||
233
api/services/quota_service.py
Normal file
233
api/services/quota_service.py
Normal 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
|
||||
@ -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)
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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)
|
||||
|
||||
0
api/tests/unit_tests/enums/__init__.py
Normal file
0
api/tests/unit_tests/enums/__init__.py
Normal file
349
api/tests/unit_tests/enums/test_quota_type.py
Normal file
349
api/tests/unit_tests/enums/test_quota_type.py
Normal 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
|
||||
@ -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(
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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.
|
||||
|
||||
|
||||
@ -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)
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -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}
|
||||
|
||||
7
e2e/features/smoke/unauthenticated-entry.feature
Normal file
7
e2e/features/smoke/unauthenticated-entry.feature
Normal 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
|
||||
@ -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',
|
||||
)
|
||||
})
|
||||
|
||||
@ -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()
|
||||
})
|
||||
|
||||
@ -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(' ')
|
||||
|
||||
@ -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.')
|
||||
|
||||
|
||||
@ -19,6 +19,7 @@
|
||||
"@types/node": "catalog:",
|
||||
"tsx": "catalog:",
|
||||
"typescript": "catalog:",
|
||||
"vite": "catalog:",
|
||||
"vite-plus": "catalog:"
|
||||
}
|
||||
}
|
||||
|
||||
@ -5,6 +5,7 @@
|
||||
"prepare": "vp config"
|
||||
},
|
||||
"devDependencies": {
|
||||
"vite": "catalog:",
|
||||
"vite-plus": "catalog:"
|
||||
},
|
||||
"engines": {
|
||||
|
||||
377
pnpm-lock.yaml
generated
377
pnpm-lock.yaml
generated
@ -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)'
|
||||
|
||||
@ -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
|
||||
|
||||
@ -62,6 +62,7 @@
|
||||
"@vitest/coverage-v8": "catalog:",
|
||||
"eslint": "catalog:",
|
||||
"typescript": "catalog:",
|
||||
"vite": "catalog:",
|
||||
"vite-plus": "catalog:",
|
||||
"vitest": "catalog:"
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
@ -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()
|
||||
})
|
||||
})
|
||||
@ -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)
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -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')
|
||||
})
|
||||
})
|
||||
@ -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()
|
||||
})
|
||||
})
|
||||
@ -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()
|
||||
})
|
||||
})
|
||||
@ -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([])
|
||||
})
|
||||
})
|
||||
@ -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)
|
||||
})
|
||||
})
|
||||
@ -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)
|
||||
})
|
||||
})
|
||||
@ -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()
|
||||
})
|
||||
})
|
||||
@ -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)
|
||||
})
|
||||
})
|
||||
@ -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')
|
||||
})
|
||||
})
|
||||
@ -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()
|
||||
})
|
||||
})
|
||||
@ -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)
|
||||
})
|
||||
})
|
||||
@ -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()
|
||||
})
|
||||
})
|
||||
@ -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()
|
||||
})
|
||||
})
|
||||
@ -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()
|
||||
})
|
||||
})
|
||||
@ -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)
|
||||
})
|
||||
})
|
||||
@ -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()
|
||||
})
|
||||
})
|
||||
@ -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')
|
||||
})
|
||||
})
|
||||
107
web/app/components/app/overview/__tests__/app-card-utils.spec.ts
Normal file
107
web/app/components/app/overview/__tests__/app-card-utils.spec.ts
Normal 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'])
|
||||
})
|
||||
})
|
||||
@ -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],
|
||||
|
||||
@ -120,7 +120,10 @@ describe('HITLInputBlock', () => {
|
||||
})
|
||||
|
||||
await waitFor(() => {
|
||||
expect(onWorkflowMapUpdate).toHaveBeenCalledWith(workflowNodesMap)
|
||||
expect(onWorkflowMapUpdate).toHaveBeenCalledWith({
|
||||
workflowNodesMap,
|
||||
availableVariables: [],
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -22,7 +22,7 @@ const HITLInputReplacementBlock = ({
|
||||
onFormInputsChange,
|
||||
onFormInputItemRename,
|
||||
onFormInputItemRemove,
|
||||
workflowNodesMap,
|
||||
workflowNodesMap = {},
|
||||
getVarType,
|
||||
variables,
|
||||
readonly,
|
||||
|
||||
@ -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]))
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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,
|
||||
}))
|
||||
})
|
||||
})
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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,
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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
|
||||
})
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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 {
|
||||
|
||||
40
web/app/components/plugins/__tests__/constants.spec.ts
Normal file
40
web/app/components/plugins/__tests__/constants.spec.ts
Normal 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,
|
||||
])
|
||||
})
|
||||
})
|
||||
104
web/app/components/plugins/__tests__/provider-card.spec.tsx
Normal file
104
web/app/components/plugins/__tests__/provider-card.spec.tsx
Normal 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()
|
||||
})
|
||||
})
|
||||
@ -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',
|
||||
)
|
||||
})
|
||||
})
|
||||
@ -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)
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -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,
|
||||
}))
|
||||
})
|
||||
})
|
||||
@ -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',
|
||||
}),
|
||||
}))
|
||||
})
|
||||
})
|
||||
@ -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',
|
||||
}),
|
||||
}))
|
||||
})
|
||||
})
|
||||
@ -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()
|
||||
})
|
||||
})
|
||||
@ -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)
|
||||
})
|
||||
})
|
||||
@ -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'])
|
||||
})
|
||||
})
|
||||
@ -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')
|
||||
})
|
||||
})
|
||||
@ -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()
|
||||
})
|
||||
})
|
||||
@ -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()
|
||||
})
|
||||
})
|
||||
@ -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)
|
||||
})
|
||||
})
|
||||
@ -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,
|
||||
}))
|
||||
})
|
||||
})
|
||||
@ -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'])
|
||||
})
|
||||
})
|
||||
@ -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([])
|
||||
})
|
||||
})
|
||||
@ -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([])
|
||||
})
|
||||
})
|
||||
@ -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' },
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -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' })
|
||||
})
|
||||
})
|
||||
@ -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()
|
||||
})
|
||||
})
|
||||
@ -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
Reference in New Issue
Block a user