Compare commits
10
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b7cb4927f5 | ||
|
|
7d33794853 | ||
|
|
996148697e | ||
|
|
af852a8176 | ||
|
|
7ae49bacba | ||
|
|
e81a5ba3a8 | ||
|
|
f0df9ee016 | ||
|
|
0531ffaeba | ||
|
|
230faff872 | ||
|
|
b078c75ccf |
@@ -28,6 +28,7 @@ Follow these steps when using this skill:
|
||||
3. Compose the final output strictly follow the **Required Output Format**.
|
||||
|
||||
Notes when using this skill:
|
||||
|
||||
- Always include actionable fixes or suggestions (including possible code snippets).
|
||||
- Use best-effort `File:Line` references when a file path and line numbers are available; otherwise, use the most specific identifier you can.
|
||||
|
||||
@@ -43,6 +44,7 @@ Notes when using this skill:
|
||||
### 1. Security Review
|
||||
|
||||
Check for:
|
||||
|
||||
- SQL injection vulnerabilities
|
||||
- Server-Side Request Forgery (SSRF)
|
||||
- Command injection
|
||||
@@ -54,6 +56,7 @@ Check for:
|
||||
### 2. Performance Review
|
||||
|
||||
Check for:
|
||||
|
||||
- N+1 queries
|
||||
- Missing database indexes
|
||||
- Memory leaks
|
||||
@@ -63,6 +66,7 @@ Check for:
|
||||
### 3. Code Quality Review
|
||||
|
||||
Check for:
|
||||
|
||||
- Code forward compatibility
|
||||
- Code duplication (DRY violations)
|
||||
- Functions doing too much (SRP violations)
|
||||
@@ -75,6 +79,7 @@ Check for:
|
||||
### 4. Testing Review
|
||||
|
||||
Check for:
|
||||
|
||||
- Missing test coverage for new code
|
||||
- Tests that don't test behavior
|
||||
- Flaky test patterns
|
||||
@@ -108,6 +113,7 @@ FilePath: <path> line <line>
|
||||
2. <code example> (optional, omit if not applicable)
|
||||
|
||||
---
|
||||
|
||||
... (repeat for each critical issue) ...
|
||||
|
||||
Found <Y> suggestions for improvement:
|
||||
@@ -129,11 +135,13 @@ FilePath: <path> line <line>
|
||||
2. <code example> (optional, omit if not applicable)
|
||||
|
||||
---
|
||||
|
||||
... (repeat for each suggestion) ...
|
||||
|
||||
Found <Z> optional nits:
|
||||
|
||||
## 🟢 Nits (Optional)
|
||||
|
||||
### 1. <brief description of the nit>
|
||||
|
||||
FilePath: <path> line <line>
|
||||
@@ -148,6 +156,7 @@ FilePath: <path> line <line>
|
||||
- <minor suggestions>
|
||||
|
||||
---
|
||||
|
||||
... (repeat for each nits) ...
|
||||
|
||||
## ✅ What's Good
|
||||
@@ -164,5 +173,6 @@ FilePath: <path> line <line>
|
||||
|
||||
```markdown
|
||||
## Code Review Summary
|
||||
|
||||
✅ No issues found.
|
||||
```
|
||||
```
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
# Rule Catalog — Architecture
|
||||
|
||||
## Scope
|
||||
|
||||
- Covers: controller/service/core-domain/libs/model layering, dependency direction, responsibility placement, observability-friendly flow.
|
||||
|
||||
## Rules
|
||||
|
||||
### Keep business logic out of controllers
|
||||
|
||||
- Category: maintainability
|
||||
- Severity: critical
|
||||
- Description: Controllers should parse input, call services, and return serialized responses. Business decisions inside controllers make behavior hard to reuse and test.
|
||||
@@ -33,12 +35,14 @@
|
||||
```
|
||||
|
||||
### Preserve layer dependency direction
|
||||
|
||||
- Category: best practices
|
||||
- Severity: critical
|
||||
- Description: Controllers may depend on services, and services may depend on core/domain abstractions. Reversing this direction (for example, core importing controller/web modules) creates cycles and leaks transport concerns into domain code.
|
||||
- Suggested fix: Extract shared contracts into core/domain or service-level modules and make upper layers depend on lower, not the reverse.
|
||||
- Example:
|
||||
- Bad:
|
||||
|
||||
```python
|
||||
# core/policy/publish_policy.py
|
||||
from controllers.console.app import request_context
|
||||
@@ -46,7 +50,9 @@
|
||||
def can_publish() -> bool:
|
||||
return request_context.current_user.is_admin
|
||||
```
|
||||
|
||||
- Good:
|
||||
|
||||
```python
|
||||
# core/policy/publish_policy.py
|
||||
def can_publish(role: str) -> bool:
|
||||
@@ -57,6 +63,7 @@
|
||||
```
|
||||
|
||||
### Keep libs business-agnostic
|
||||
|
||||
- Category: maintainability
|
||||
- Severity: critical
|
||||
- Description: Modules under `api/libs/` should remain reusable, business-agnostic building blocks. They must not encode product/domain-specific rules, workflow orchestration, or business decisions.
|
||||
@@ -65,6 +72,7 @@
|
||||
- Keep `libs` dependencies clean: avoid importing service/controller/domain-specific modules into `api/libs/`.
|
||||
- Example:
|
||||
- Bad:
|
||||
|
||||
```python
|
||||
# api/libs/conversation_filter.py
|
||||
from services.conversation_service import ConversationService
|
||||
@@ -76,7 +84,9 @@
|
||||
return conversation.idle_days > 90
|
||||
return conversation.idle_days > 30
|
||||
```
|
||||
|
||||
- Good:
|
||||
|
||||
```python
|
||||
# api/libs/datetime_utils.py (business-agnostic helper)
|
||||
def older_than_days(idle_days: int, threshold_days: int) -> bool:
|
||||
@@ -88,4 +98,4 @@
|
||||
def should_archive_conversation(conversation, tenant_id: str) -> bool:
|
||||
threshold_days = 90 if has_paid_plan(tenant_id) else 30
|
||||
return older_than_days(conversation.idle_days, threshold_days)
|
||||
```
|
||||
```
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
# Rule Catalog — DB Schema Design
|
||||
|
||||
## Scope
|
||||
|
||||
- Covers: model/base inheritance, schema boundaries in model properties, tenant-aware schema design, index redundancy checks, dialect portability in models, and cross-database compatibility in migrations.
|
||||
- Does NOT cover: session lifecycle, transaction boundaries, and query execution patterns (handled by `sqlalchemy-rule.md`).
|
||||
|
||||
## Rules
|
||||
|
||||
### Do not query other tables inside `@property`
|
||||
|
||||
- Category: [maintainability, performance]
|
||||
- Severity: critical
|
||||
- Description: A model `@property` must not open sessions or query other tables. This hides dependencies across models, tightly couples schema objects to data access, and can cause N+1 query explosions when iterating collections.
|
||||
@@ -16,6 +18,7 @@
|
||||
- For list/batch reads, fetch required related data explicitly (join/preload/bulk query) before rendering derived values.
|
||||
- Example:
|
||||
- Bad:
|
||||
|
||||
```python
|
||||
class Conversation(TypeBase):
|
||||
__tablename__ = "conversations"
|
||||
@@ -26,7 +29,9 @@
|
||||
app = session.execute(select(App).where(App.id == self.app_id)).scalar_one()
|
||||
return app.name
|
||||
```
|
||||
|
||||
- Good:
|
||||
|
||||
```python
|
||||
class Conversation(TypeBase):
|
||||
__tablename__ = "conversations"
|
||||
@@ -40,6 +45,7 @@
|
||||
```
|
||||
|
||||
### Prefer including `tenant_id` in model definitions
|
||||
|
||||
- Category: maintainability
|
||||
- Severity: suggestion
|
||||
- Description: In multi-tenant domains, include `tenant_id` in schema definitions whenever the entity belongs to tenant-owned data. This improves data isolation safety and keeps future partitioning/sharding strategies practical as data volume grows.
|
||||
@@ -49,6 +55,7 @@
|
||||
- Exception: if a table is explicitly designed as non-tenant-scoped global metadata, document that design decision clearly.
|
||||
- Example:
|
||||
- Bad:
|
||||
|
||||
```python
|
||||
from sqlalchemy.orm import Mapped
|
||||
|
||||
@@ -57,7 +64,9 @@
|
||||
id: Mapped[str] = mapped_column(StringUUID, primary_key=True)
|
||||
name: Mapped[str] = mapped_column(sa.String(255), nullable=False)
|
||||
```
|
||||
|
||||
- Good:
|
||||
|
||||
```python
|
||||
from sqlalchemy.orm import Mapped
|
||||
|
||||
@@ -69,6 +78,7 @@
|
||||
```
|
||||
|
||||
### Detect and avoid duplicate/redundant indexes
|
||||
|
||||
- Category: performance
|
||||
- Severity: suggestion
|
||||
- Description: Review index definitions for leftmost-prefix redundancy. For example, index `(a, b, c)` can safely cover most lookups for `(a, b)`. Keeping both may increase write overhead and can mislead the optimizer into suboptimal execution plans.
|
||||
@@ -93,6 +103,7 @@
|
||||
```
|
||||
|
||||
### Avoid PostgreSQL-only dialect usage in models; wrap in `models.types`
|
||||
|
||||
- Category: maintainability
|
||||
- Severity: critical
|
||||
- Description: Model/schema definitions should avoid PostgreSQL-only constructs directly in business models. When database-specific behavior is required, encapsulate it in `api/models/types.py` using both PostgreSQL and MySQL dialect implementations, then consume that abstraction from model code.
|
||||
@@ -101,6 +112,7 @@
|
||||
- Add or extend wrappers in `models.types` (for example, `AdjustedJSON`, `LongText`, `BinaryData`) to normalize behavior across PostgreSQL and MySQL.
|
||||
- Example:
|
||||
- Bad:
|
||||
|
||||
```python
|
||||
from sqlalchemy.dialects.postgresql import JSONB
|
||||
from sqlalchemy.orm import Mapped
|
||||
@@ -109,7 +121,9 @@
|
||||
__tablename__ = "tool_configs"
|
||||
config: Mapped[dict] = mapped_column(JSONB, nullable=False)
|
||||
```
|
||||
|
||||
- Good:
|
||||
|
||||
```python
|
||||
from sqlalchemy.orm import Mapped
|
||||
|
||||
@@ -121,6 +135,7 @@
|
||||
```
|
||||
|
||||
### Guard migration incompatibilities with dialect checks and shared types
|
||||
|
||||
- Category: maintainability
|
||||
- Severity: critical
|
||||
- Description: Migration scripts under `api/migrations/versions/` must account for PostgreSQL/MySQL incompatibilities explicitly. For dialect-sensitive DDL or defaults, branch on the active dialect (for example, `conn.dialect.name == "postgresql"`), and prefer reusable compatibility abstractions from `models.types` where applicable.
|
||||
@@ -142,6 +157,7 @@
|
||||
)
|
||||
```
|
||||
- Good:
|
||||
|
||||
```python
|
||||
def _is_pg(conn) -> bool:
|
||||
return conn.dialect.name == "postgresql"
|
||||
|
||||
@@ -1,17 +1,19 @@
|
||||
# Rule Catalog - Repositories Abstraction
|
||||
|
||||
## Scope
|
||||
|
||||
- Covers: when to reuse existing repository abstractions, when to introduce new repositories, and how to preserve dependency direction between service/core and infrastructure implementations.
|
||||
- Does NOT cover: SQLAlchemy session lifecycle and query-shape specifics (handled by `sqlalchemy-rule.md`), and table schema/migration design (handled by `db-schema-rule.md`).
|
||||
|
||||
## Rules
|
||||
|
||||
### Introduce repositories abstraction
|
||||
|
||||
- Category: maintainability
|
||||
- Severity: suggestion
|
||||
- Description: If a table/model already has a repository abstraction, all reads/writes/queries for that table should use the existing repository. If no repository exists, introduce one only when complexity justifies it, such as large/high-volume tables, repeated complex query logic, or likely storage-strategy variation.
|
||||
- Suggested fix:
|
||||
- First check `api/repositories`, `api/core/repositories`, and `api/extensions/*/repositories/` to verify whether the table/model already has a repository abstraction. If it exists, route all operations through it and add missing repository methods instead of bypassing it with ad-hoc SQLAlchemy access.
|
||||
- First check `api/repositories`, `api/core/repositories`, and `api/extensions/*/repositories/` to verify whether the table/model already has a repository abstraction. If it exists, route all operations through it and add missing repository methods instead of bypassing it with ad-hoc SQLAlchemy access.
|
||||
- If no repository exists, add one only when complexity warrants it (for example, repeated complex queries, large data domains, or multiple storage strategies), while preserving dependency direction (service/core depends on abstraction; infra provides implementation).
|
||||
- Example:
|
||||
- Bad:
|
||||
@@ -26,6 +28,7 @@
|
||||
self.session.commit()
|
||||
```
|
||||
- Good:
|
||||
|
||||
```python
|
||||
# Case A: Existing repository must be reused for all table operations.
|
||||
class AppService:
|
||||
@@ -37,6 +40,7 @@
|
||||
# If the query is missing, extend the existing abstraction.
|
||||
active_apps = self.app_repo.list_active_for_tenant(tenant_id=tenant_id)
|
||||
```
|
||||
|
||||
- Bad:
|
||||
```python
|
||||
# No repository exists, but large-domain query logic is scattered in service code.
|
||||
@@ -46,6 +50,7 @@
|
||||
# many filters/joins/pagination variants duplicated across services
|
||||
```
|
||||
- Good:
|
||||
|
||||
```python
|
||||
# Case B: Introduce repository for large/complex domains or storage variation.
|
||||
class ConversationRepository(Protocol):
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
# Rule Catalog — SQLAlchemy Patterns
|
||||
|
||||
## Scope
|
||||
|
||||
- Covers: SQLAlchemy session and transaction lifecycle, query construction, tenant scoping, raw SQL boundaries, and write-path concurrency safeguards.
|
||||
- Does NOT cover: table/model schema and migration design details (handled by `db-schema-rule.md`).
|
||||
|
||||
## Rules
|
||||
|
||||
### Use Session context manager with explicit transaction control behavior
|
||||
|
||||
- Category: best practices
|
||||
- Severity: critical
|
||||
- Description: Session and transaction lifecycle must be explicit and bounded on write paths. Missing commits can silently drop intended updates, while ad-hoc or long-lived transactions increase contention, lock duration, and deadlock risk.
|
||||
@@ -16,6 +18,7 @@
|
||||
- Keep transaction windows short: avoid network I/O, heavy computation, or unrelated work inside the transaction.
|
||||
- Example:
|
||||
- Bad:
|
||||
|
||||
```python
|
||||
# Missing commit: write may never be persisted.
|
||||
with Session(db.engine, expire_on_commit=False) as session:
|
||||
@@ -28,7 +31,9 @@
|
||||
run.status = "cancelled"
|
||||
call_external_api()
|
||||
```
|
||||
|
||||
- Good:
|
||||
|
||||
```python
|
||||
# Option 1: explicit commit.
|
||||
with Session(db.engine, expire_on_commit=False) as session:
|
||||
@@ -46,6 +51,7 @@
|
||||
```
|
||||
|
||||
### Enforce tenant_id scoping on shared-resource queries
|
||||
|
||||
- Category: security
|
||||
- Severity: critical
|
||||
- Description: Reads and writes against shared tables must be scoped by `tenant_id` to prevent cross-tenant data leakage or corruption.
|
||||
@@ -66,6 +72,7 @@
|
||||
```
|
||||
|
||||
### Prefer SQLAlchemy expressions over raw SQL by default
|
||||
|
||||
- Category: maintainability
|
||||
- Severity: suggestion
|
||||
- Description: Raw SQL should be exceptional. ORM/Core expressions are easier to evolve, safer to compose, and more consistent with the codebase.
|
||||
@@ -88,6 +95,7 @@
|
||||
```
|
||||
|
||||
### Protect write paths with concurrency safeguards
|
||||
|
||||
- Category: quality
|
||||
- Severity: critical
|
||||
- Description: Multi-writer paths without explicit concurrency control can silently overwrite data. Choose the safeguard based on contention level, lock scope, and throughput cost instead of defaulting to one strategy.
|
||||
@@ -104,6 +112,7 @@
|
||||
session.commit() # silently overwrites concurrent updates
|
||||
```
|
||||
- Good:
|
||||
|
||||
```python
|
||||
# 1) Optimistic lock (low contention, retry on conflict)
|
||||
result = session.execute(
|
||||
@@ -136,4 +145,4 @@
|
||||
).scalar_one()
|
||||
run.status = "cancelled"
|
||||
session.commit()
|
||||
```
|
||||
```
|
||||
|
||||
@@ -46,12 +46,12 @@ pnpm analyze-component <path> --json
|
||||
|
||||
### Complexity Score Interpretation
|
||||
|
||||
| Score | Level | Action |
|
||||
|-------|-------|--------|
|
||||
| 0-25 | 🟢 Simple | Ready for testing |
|
||||
| 26-50 | 🟡 Medium | Consider minor refactoring |
|
||||
| 51-75 | 🟠 Complex | **Refactor before testing** |
|
||||
| 76-100 | 🔴 Very Complex | **Must refactor** |
|
||||
| Score | Level | Action |
|
||||
| ------ | --------------- | --------------------------- |
|
||||
| 0-25 | 🟢 Simple | Ready for testing |
|
||||
| 26-50 | 🟡 Medium | Consider minor refactoring |
|
||||
| 51-75 | 🟠 Complex | **Refactor before testing** |
|
||||
| 76-100 | 🔴 Very Complex | **Must refactor** |
|
||||
|
||||
## Core Refactoring Patterns
|
||||
|
||||
@@ -67,9 +67,9 @@ function Configuration() {
|
||||
const [modelConfig, setModelConfig] = useState<ModelConfig>(...)
|
||||
const [datasetConfigs, setDatasetConfigs] = useState<DatasetConfigs>(...)
|
||||
const [completionParams, setCompletionParams] = useState<FormValue>({})
|
||||
|
||||
|
||||
// 50+ lines of state management logic...
|
||||
|
||||
|
||||
return <div>...</div>
|
||||
}
|
||||
|
||||
@@ -78,9 +78,9 @@ function Configuration() {
|
||||
export const useModelConfig = (appId: string) => {
|
||||
const [modelConfig, setModelConfig] = useState<ModelConfig>(...)
|
||||
const [completionParams, setCompletionParams] = useState<FormValue>({})
|
||||
|
||||
|
||||
// Related state management logic here
|
||||
|
||||
|
||||
return { modelConfig, setModelConfig, completionParams, setCompletionParams }
|
||||
}
|
||||
|
||||
@@ -92,6 +92,7 @@ function Configuration() {
|
||||
```
|
||||
|
||||
**Dify Examples**:
|
||||
|
||||
- `web/app/components/app/configuration/hooks/use-advanced-prompt-config.ts`
|
||||
- `web/app/components/app/configuration/debug/hooks.tsx`
|
||||
- `web/app/components/workflow/hooks/use-workflow.ts`
|
||||
@@ -123,7 +124,7 @@ const AppInfo = () => {
|
||||
|
||||
const AppInfo = () => {
|
||||
const { showModal, setShowModal } = useAppInfoModals()
|
||||
|
||||
|
||||
return (
|
||||
<div>
|
||||
<AppHeader appDetail={appDetail} />
|
||||
@@ -135,6 +136,7 @@ const AppInfo = () => {
|
||||
```
|
||||
|
||||
**Dify Examples**:
|
||||
|
||||
- `web/app/components/app/configuration/` directory structure
|
||||
- `web/app/components/workflow/nodes/` per-node organization
|
||||
|
||||
@@ -177,7 +179,7 @@ const TEMPLATE_MAP = {
|
||||
const Template = useMemo(() => {
|
||||
const modeTemplates = TEMPLATE_MAP[appDetail?.mode]
|
||||
if (!modeTemplates) return null
|
||||
|
||||
|
||||
const TemplateComponent = modeTemplates[locale] || modeTemplates.default
|
||||
return <TemplateComponent appDetail={appDetail} />
|
||||
}, [appDetail, locale])
|
||||
@@ -188,11 +190,13 @@ const Template = useMemo(() => {
|
||||
**When**: Component directly handles API calls, data transformation, or complex async operations.
|
||||
|
||||
**Dify Convention**:
|
||||
|
||||
- This skill is for component decomposition, not query/mutation design.
|
||||
- Do not introduce deprecated `useInvalid` / `useReset`.
|
||||
- Do not add thin passthrough `useQuery` wrappers during refactoring; only extract a custom hook when it truly orchestrates multiple queries/mutations or shared derived state.
|
||||
|
||||
**Dify Examples**:
|
||||
|
||||
- `web/service/use-workflow.ts`
|
||||
- `web/service/use-common.ts`
|
||||
- `web/service/knowledge/use-dataset.ts`
|
||||
@@ -220,10 +224,10 @@ type ModalType = 'edit' | 'duplicate' | 'delete' | 'switch' | 'import' | null
|
||||
|
||||
const useAppInfoModals = () => {
|
||||
const [activeModal, setActiveModal] = useState<ModalType>(null)
|
||||
|
||||
|
||||
const openModal = useCallback((type: ModalType) => setActiveModal(type), [])
|
||||
const closeModal = useCallback(() => setActiveModal(null), [])
|
||||
|
||||
|
||||
return {
|
||||
activeModal,
|
||||
openModal,
|
||||
@@ -248,7 +252,7 @@ const ConfigForm = () => {
|
||||
defaultValues: { name: '', description: '' },
|
||||
onSubmit: handleSubmit,
|
||||
})
|
||||
|
||||
|
||||
return <form.Provider>...</form.Provider>
|
||||
}
|
||||
```
|
||||
@@ -285,6 +289,7 @@ return <ConfigContext.Provider value={value}>...</ConfigContext.Provider>
|
||||
**When**: Refactoring workflow node components (`web/app/components/workflow/nodes/`).
|
||||
|
||||
**Conventions**:
|
||||
|
||||
- Keep node logic in `use-interactions.ts`
|
||||
- Extract panel UI to separate files
|
||||
- Use `_base` components for common patterns
|
||||
@@ -303,6 +308,7 @@ nodes/<node-type>/
|
||||
**When**: Refactoring app configuration components.
|
||||
|
||||
**Conventions**:
|
||||
|
||||
- Separate config sections into subdirectories
|
||||
- Use existing patterns from `web/app/components/app/configuration/`
|
||||
- Keep feature toggles in dedicated components
|
||||
@@ -312,6 +318,7 @@ nodes/<node-type>/
|
||||
**When**: Refactoring tool-related components (`web/app/components/tools/`).
|
||||
|
||||
**Conventions**:
|
||||
|
||||
- Follow existing modal patterns
|
||||
- Use service hooks from `web/service/use-tools.ts`
|
||||
- Keep provider-specific logic isolated
|
||||
@@ -325,6 +332,7 @@ pnpm refactor-component <path>
|
||||
```
|
||||
|
||||
This command will:
|
||||
|
||||
- Analyze component complexity and features
|
||||
- Identify specific refactoring actions needed
|
||||
- Generate a prompt for AI assistant (auto-copied to clipboard on macOS)
|
||||
@@ -337,6 +345,7 @@ pnpm analyze-component <path> --json
|
||||
```
|
||||
|
||||
Identify:
|
||||
|
||||
- Total complexity score
|
||||
- Max function complexity
|
||||
- Line count
|
||||
@@ -346,13 +355,13 @@ Identify:
|
||||
|
||||
Create a refactoring plan based on detected features:
|
||||
|
||||
| Detected Feature | Refactoring Action |
|
||||
|------------------|-------------------|
|
||||
| `hasState: true` + `hasEffects: true` | Extract custom hook |
|
||||
| `hasAPI: true` | Extract data/service hook |
|
||||
| `hasEvents: true` (many) | Extract event handlers |
|
||||
| `lineCount > 300` | Split into sub-components |
|
||||
| `maxComplexity > 50` | Simplify conditional logic |
|
||||
| Detected Feature | Refactoring Action |
|
||||
| ------------------------------------- | -------------------------- |
|
||||
| `hasState: true` + `hasEffects: true` | Extract custom hook |
|
||||
| `hasAPI: true` | Extract data/service hook |
|
||||
| `hasEvents: true` (many) | Extract event handlers |
|
||||
| `lineCount > 300` | Split into sub-components |
|
||||
| `maxComplexity > 50` | Simplify conditional logic |
|
||||
|
||||
### Step 4: Execute Incrementally
|
||||
|
||||
|
||||
@@ -13,16 +13,16 @@ The `pnpm analyze-component` tool uses SonarJS cognitive complexity metrics:
|
||||
|
||||
### What Increases Complexity
|
||||
|
||||
| Pattern | Complexity Impact |
|
||||
|---------|-------------------|
|
||||
| `if/else` | +1 per branch |
|
||||
| Nested conditions | +1 per nesting level |
|
||||
| `switch/case` | +1 per case |
|
||||
| `for/while/do` | +1 per loop |
|
||||
| `&&`/`||` chains | +1 per operator |
|
||||
| Nested callbacks | +1 per nesting level |
|
||||
| `try/catch` | +1 per catch |
|
||||
| Ternary expressions | +1 per nesting |
|
||||
| Pattern | Complexity Impact |
|
||||
| ------------------- | -------------------- | -------- | --------------- |
|
||||
| `if/else` | +1 per branch |
|
||||
| Nested conditions | +1 per nesting level |
|
||||
| `switch/case` | +1 per case |
|
||||
| `for/while/do` | +1 per loop |
|
||||
| `&&`/` | | ` chains | +1 per operator |
|
||||
| Nested callbacks | +1 per nesting level |
|
||||
| `try/catch` | +1 per catch |
|
||||
| Ternary expressions | +1 per nesting |
|
||||
|
||||
## Pattern 1: Replace Conditionals with Lookup Tables
|
||||
|
||||
@@ -85,10 +85,10 @@ const TEMPLATE_MAP: Record<AppModeEnum, Record<string, ComponentType<TemplatePro
|
||||
// Clean component logic
|
||||
const Template = useMemo(() => {
|
||||
if (!appDetail?.mode) return null
|
||||
|
||||
|
||||
const templates = TEMPLATE_MAP[appDetail.mode]
|
||||
if (!templates) return null
|
||||
|
||||
|
||||
const TemplateComponent = templates[locale] ?? templates.default
|
||||
return <TemplateComponent appDetail={appDetail} />
|
||||
}, [appDetail, locale])
|
||||
@@ -124,17 +124,17 @@ const handleSubmit = () => {
|
||||
showValidationError()
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
if (!hasChanges) {
|
||||
showNoChangesMessage()
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
if (!isConnected) {
|
||||
showConnectionError()
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
submitData()
|
||||
}
|
||||
```
|
||||
@@ -146,12 +146,10 @@ const handleSubmit = () => {
|
||||
```typescript
|
||||
const canPublish = (() => {
|
||||
if (mode !== AppModeEnum.COMPLETION) {
|
||||
if (!isAdvancedMode)
|
||||
return true
|
||||
if (!isAdvancedMode) return true
|
||||
|
||||
if (modelModeType === ModelModeType.completion) {
|
||||
if (!hasSetBlockStatus.history || !hasSetBlockStatus.query)
|
||||
return false
|
||||
if (!hasSetBlockStatus.history || !hasSetBlockStatus.query) return false
|
||||
return true
|
||||
}
|
||||
return true
|
||||
@@ -173,9 +171,8 @@ const canPublishInChatMode = () => {
|
||||
}
|
||||
|
||||
// Clean main logic
|
||||
const canPublish = mode === AppModeEnum.COMPLETION
|
||||
? canPublishInCompletionMode()
|
||||
: canPublishInChatMode()
|
||||
const canPublish =
|
||||
mode === AppModeEnum.COMPLETION ? canPublishInCompletionMode() : canPublishInChatMode()
|
||||
```
|
||||
|
||||
## Pattern 4: Replace Chained Ternaries
|
||||
@@ -232,7 +229,7 @@ const statusText = t(STATUS_TEXT_MAP[getStatusKey()])
|
||||
```typescript
|
||||
const processData = (items: Item[]) => {
|
||||
const results: ProcessedItem[] = []
|
||||
|
||||
|
||||
for (const item of items) {
|
||||
if (item.isValid) {
|
||||
for (const child of item.children) {
|
||||
@@ -250,7 +247,7 @@ const processData = (items: Item[]) => {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
return results
|
||||
}
|
||||
```
|
||||
@@ -261,19 +258,19 @@ const processData = (items: Item[]) => {
|
||||
// Use functional approach
|
||||
const processData = (items: Item[]) => {
|
||||
return items
|
||||
.filter(item => item.isValid)
|
||||
.flatMap(item =>
|
||||
.filter((item) => item.isValid)
|
||||
.flatMap((item) =>
|
||||
item.children
|
||||
.filter(child => child.isActive)
|
||||
.flatMap(child =>
|
||||
.filter((child) => child.isActive)
|
||||
.flatMap((child) =>
|
||||
child.properties
|
||||
.filter(prop => prop.value !== null)
|
||||
.map(prop => ({
|
||||
.filter((prop) => prop.value !== null)
|
||||
.map((prop) => ({
|
||||
itemId: item.id,
|
||||
childId: child.id,
|
||||
propValue: prop.value,
|
||||
}))
|
||||
)
|
||||
})),
|
||||
),
|
||||
)
|
||||
}
|
||||
```
|
||||
@@ -309,10 +306,10 @@ const Component = () => {
|
||||
setDataSets(data)
|
||||
}
|
||||
hideSelectDataSet()
|
||||
|
||||
|
||||
// 40 more lines of logic...
|
||||
}
|
||||
|
||||
|
||||
return <div>...</div>
|
||||
}
|
||||
```
|
||||
@@ -325,7 +322,7 @@ const useDatasetSelection = (dataSets: DataSet[], setDataSets: SetState<DataSet[
|
||||
const normalizeSelection = (data: DataSet[]) => {
|
||||
const hasUnloadedItem = data.some(item => !item.name)
|
||||
if (!hasUnloadedItem) return data
|
||||
|
||||
|
||||
return produce(data, (draft) => {
|
||||
data.forEach((item, index) => {
|
||||
if (!item.name) {
|
||||
@@ -335,33 +332,33 @@ const useDatasetSelection = (dataSets: DataSet[], setDataSets: SetState<DataSet[
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
const hasSelectionChanged = (newData: DataSet[]) => {
|
||||
return !isEqual(
|
||||
newData.map(item => item.id),
|
||||
dataSets.map(item => item.id)
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
return { normalizeSelection, hasSelectionChanged }
|
||||
}
|
||||
|
||||
// Component becomes cleaner
|
||||
const Component = () => {
|
||||
const { normalizeSelection, hasSelectionChanged } = useDatasetSelection(dataSets, setDataSets)
|
||||
|
||||
|
||||
const handleSelect = (data: DataSet[]) => {
|
||||
if (!hasSelectionChanged(data)) {
|
||||
hideSelectDataSet()
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
formattingChangedDispatcher()
|
||||
const normalized = normalizeSelection(data)
|
||||
setDataSets(normalized)
|
||||
hideSelectDataSet()
|
||||
}
|
||||
|
||||
|
||||
return <div>...</div>
|
||||
}
|
||||
```
|
||||
@@ -371,12 +368,13 @@ const Component = () => {
|
||||
**Before** (complexity: ~8):
|
||||
|
||||
```typescript
|
||||
const toggleDisabled = hasInsufficientPermissions
|
||||
|| appUnpublished
|
||||
|| missingStartNode
|
||||
|| triggerModeDisabled
|
||||
|| (isAdvancedApp && !currentWorkflow?.graph)
|
||||
|| (isBasicApp && !basicAppConfig.updated_at)
|
||||
const toggleDisabled =
|
||||
hasInsufficientPermissions ||
|
||||
appUnpublished ||
|
||||
missingStartNode ||
|
||||
triggerModeDisabled ||
|
||||
(isAdvancedApp && !currentWorkflow?.graph) ||
|
||||
(isBasicApp && !basicAppConfig.updated_at)
|
||||
```
|
||||
|
||||
**After** (complexity: ~3):
|
||||
@@ -425,8 +423,7 @@ const payload = useMemo(() => {
|
||||
description: '',
|
||||
type: item.value_type,
|
||||
}))
|
||||
}
|
||||
else if (detail && detail.tool) {
|
||||
} else if (detail && detail.tool) {
|
||||
parameters = (inputs || []).map((item) => ({
|
||||
// Complex transformation...
|
||||
}))
|
||||
@@ -434,7 +431,7 @@ const payload = useMemo(() => {
|
||||
// Complex transformation...
|
||||
}))
|
||||
}
|
||||
|
||||
|
||||
return {
|
||||
icon: detail?.icon || icon,
|
||||
label: detail?.label || name,
|
||||
@@ -450,7 +447,7 @@ const payload = useMemo(() => {
|
||||
const useParameterTransform = (inputs: InputVar[], detail?: ToolDetail, published?: boolean) => {
|
||||
return useMemo(() => {
|
||||
if (!published) {
|
||||
return inputs.map(item => ({
|
||||
return inputs.map((item) => ({
|
||||
name: item.variable,
|
||||
description: '',
|
||||
form: 'llm',
|
||||
@@ -458,15 +455,16 @@ const useParameterTransform = (inputs: InputVar[], detail?: ToolDetail, publishe
|
||||
type: item.type,
|
||||
}))
|
||||
}
|
||||
|
||||
|
||||
if (!detail?.tool) return []
|
||||
|
||||
return inputs.map(item => ({
|
||||
|
||||
return inputs.map((item) => ({
|
||||
name: item.variable,
|
||||
required: item.required,
|
||||
type: item.type === 'paragraph' ? 'string' : item.type,
|
||||
description: detail.tool.parameters.find(p => p.name === item.variable)?.llm_description || '',
|
||||
form: detail.tool.parameters.find(p => p.name === item.variable)?.form || 'llm',
|
||||
description:
|
||||
detail.tool.parameters.find((p) => p.name === item.variable)?.llm_description || '',
|
||||
form: detail.tool.parameters.find((p) => p.name === item.variable)?.form || 'llm',
|
||||
}))
|
||||
}, [inputs, detail, published])
|
||||
}
|
||||
@@ -475,21 +473,24 @@ const useParameterTransform = (inputs: InputVar[], detail?: ToolDetail, publishe
|
||||
const parameters = useParameterTransform(inputs, detail, published)
|
||||
const outputParameters = useOutputTransform(outputs, detail, published)
|
||||
|
||||
const payload = useMemo(() => ({
|
||||
icon: detail?.icon || icon,
|
||||
label: detail?.label || name,
|
||||
parameters,
|
||||
outputParameters,
|
||||
// ...
|
||||
}), [detail, icon, name, parameters, outputParameters])
|
||||
const payload = useMemo(
|
||||
() => ({
|
||||
icon: detail?.icon || icon,
|
||||
label: detail?.label || name,
|
||||
parameters,
|
||||
outputParameters,
|
||||
// ...
|
||||
}),
|
||||
[detail, icon, name, parameters, outputParameters],
|
||||
)
|
||||
```
|
||||
|
||||
## Target Metrics After Refactoring
|
||||
|
||||
| Metric | Target |
|
||||
|--------|--------|
|
||||
| Total Complexity | < 50 |
|
||||
| Max Function Complexity | < 30 |
|
||||
| Function Length | < 30 lines |
|
||||
| Nesting Depth | ≤ 3 levels |
|
||||
| Conditional Chains | ≤ 3 conditions |
|
||||
| Metric | Target |
|
||||
| ----------------------- | -------------- |
|
||||
| Total Complexity | < 50 |
|
||||
| Max Function Complexity | < 30 |
|
||||
| Function Length | < 30 lines |
|
||||
| Nesting Depth | ≤ 3 levels |
|
||||
| Conditional Chains | ≤ 3 conditions |
|
||||
|
||||
@@ -32,17 +32,17 @@ const ConfigurationPage = () => {
|
||||
<AppPublisher ... />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
{/* Config Section - 200 lines */}
|
||||
<div className="config">
|
||||
<Config />
|
||||
</div>
|
||||
|
||||
|
||||
{/* Debug Section - 150 lines */}
|
||||
<div className="debug">
|
||||
<Debug ... />
|
||||
</div>
|
||||
|
||||
|
||||
{/* Modals Section - 100 lines */}
|
||||
{showSelectDataSet && <SelectDataSet ... />}
|
||||
{showHistoryModal && <EditHistoryModal ... />}
|
||||
@@ -70,7 +70,7 @@ function ConfigurationHeader({
|
||||
onPublish,
|
||||
}: ConfigurationHeaderProps) {
|
||||
const { t } = useTranslation()
|
||||
|
||||
|
||||
return (
|
||||
<div className="header">
|
||||
<h1>{t('configuration.title')}</h1>
|
||||
@@ -87,7 +87,7 @@ function ConfigurationHeader({
|
||||
const ConfigurationPage = () => {
|
||||
const { modelConfig, setModelConfig } = useModelConfig()
|
||||
const { activeModal, openModal, closeModal } = useModalState()
|
||||
|
||||
|
||||
return (
|
||||
<div>
|
||||
<ConfigurationHeader
|
||||
@@ -175,15 +175,15 @@ const AppInfo = () => {
|
||||
const [showDuplicate, setShowDuplicate] = useState(false)
|
||||
const [showDelete, setShowDelete] = useState(false)
|
||||
const [showSwitch, setShowSwitch] = useState(false)
|
||||
|
||||
|
||||
const onEdit = async (data) => { /* 20 lines */ }
|
||||
const onDuplicate = async (data) => { /* 20 lines */ }
|
||||
const onDelete = async () => { /* 15 lines */ }
|
||||
|
||||
|
||||
return (
|
||||
<div>
|
||||
{/* Main content */}
|
||||
|
||||
|
||||
{showEdit && <EditModal onConfirm={onEdit} onClose={() => setShowEdit(false)} />}
|
||||
{showDuplicate && <DuplicateModal onConfirm={onDuplicate} onClose={() => setShowDuplicate(false)} />}
|
||||
{showDelete && <DeleteConfirm onConfirm={onDelete} onClose={() => setShowDelete(false)} />}
|
||||
@@ -248,12 +248,12 @@ function AppInfoModals({
|
||||
// Parent component
|
||||
const AppInfo = () => {
|
||||
const { activeModal, openModal, closeModal } = useModalState()
|
||||
|
||||
|
||||
return (
|
||||
<div>
|
||||
{/* Main content with openModal triggers */}
|
||||
<Button onClick={() => openModal('edit')}>Edit</Button>
|
||||
|
||||
|
||||
<AppInfoModals
|
||||
appDetail={appDetail}
|
||||
activeModal={activeModal}
|
||||
@@ -418,7 +418,7 @@ Use callbacks for child-to-parent communication:
|
||||
// Parent
|
||||
const Parent = () => {
|
||||
const [value, setValue] = useState('')
|
||||
|
||||
|
||||
return (
|
||||
<Child
|
||||
value={value}
|
||||
@@ -460,7 +460,7 @@ function List<T>({ items, renderItem, renderEmpty }: ListProps<T>) {
|
||||
if (items.length === 0 && renderEmpty) {
|
||||
return <>{renderEmpty()}</>
|
||||
}
|
||||
|
||||
|
||||
return (
|
||||
<div>
|
||||
{items.map((item, index) => renderItem(item, index))}
|
||||
|
||||
@@ -81,9 +81,9 @@ export const useModelConfig = ({
|
||||
// ... default values
|
||||
...initialConfig,
|
||||
})
|
||||
|
||||
|
||||
const [completionParams, setCompletionParams] = useState<FormValue>({})
|
||||
|
||||
|
||||
const modelModeType = modelConfig.mode
|
||||
|
||||
// Fill old app data missing model mode
|
||||
@@ -91,9 +91,11 @@ export const useModelConfig = ({
|
||||
if (hasFetchedDetail && !modelModeType) {
|
||||
const mode = currModel?.model_properties?.mode
|
||||
if (mode) {
|
||||
setModelConfig(produce(modelConfig, (draft) => {
|
||||
draft.mode = mode
|
||||
}))
|
||||
setModelConfig(
|
||||
produce(modelConfig, (draft) => {
|
||||
draft.mode = mode
|
||||
}),
|
||||
)
|
||||
}
|
||||
}
|
||||
}, [hasFetchedDetail, modelModeType, currModel])
|
||||
@@ -129,7 +131,7 @@ function Configuration() {
|
||||
currModel,
|
||||
hasFetchedDetail,
|
||||
})
|
||||
|
||||
|
||||
// Component now focuses on UI
|
||||
}
|
||||
```
|
||||
@@ -179,18 +181,21 @@ export const useConfigForm = (initialValues: ConfigFormValues) => {
|
||||
}, [values])
|
||||
|
||||
const handleChange = useCallback((field: string, value: any) => {
|
||||
setValues(prev => ({ ...prev, [field]: value }))
|
||||
setValues((prev) => ({ ...prev, [field]: value }))
|
||||
}, [])
|
||||
|
||||
const handleSubmit = useCallback(async (onSubmit: (values: ConfigFormValues) => Promise<void>) => {
|
||||
if (!validate()) return
|
||||
setIsSubmitting(true)
|
||||
try {
|
||||
await onSubmit(values)
|
||||
} finally {
|
||||
setIsSubmitting(false)
|
||||
}
|
||||
}, [values, validate])
|
||||
const handleSubmit = useCallback(
|
||||
async (onSubmit: (values: ConfigFormValues) => Promise<void>) => {
|
||||
if (!validate()) return
|
||||
setIsSubmitting(true)
|
||||
try {
|
||||
await onSubmit(values)
|
||||
} finally {
|
||||
setIsSubmitting(false)
|
||||
}
|
||||
},
|
||||
[values, validate],
|
||||
)
|
||||
|
||||
return { values, errors, isSubmitting, handleChange, handleSubmit }
|
||||
}
|
||||
@@ -233,7 +238,7 @@ export const useModalState = () => {
|
||||
export const useToggle = (initialValue = false) => {
|
||||
const [value, setValue] = useState(initialValue)
|
||||
|
||||
const toggle = useCallback(() => setValue(v => !v), [])
|
||||
const toggle = useCallback(() => setValue((v) => !v), [])
|
||||
const setTrue = useCallback(() => setValue(true), [])
|
||||
const setFalse = useCallback(() => setValue(false), [])
|
||||
|
||||
@@ -255,18 +260,22 @@ import { useModelConfig } from './use-model-config'
|
||||
|
||||
describe('useModelConfig', () => {
|
||||
it('should initialize with default values', () => {
|
||||
const { result } = renderHook(() => useModelConfig({
|
||||
hasFetchedDetail: false,
|
||||
}))
|
||||
const { result } = renderHook(() =>
|
||||
useModelConfig({
|
||||
hasFetchedDetail: false,
|
||||
}),
|
||||
)
|
||||
|
||||
expect(result.current.modelConfig.provider).toBe('langgenius/openai/openai')
|
||||
expect(result.current.modelModeType).toBe(ModelModeType.unset)
|
||||
})
|
||||
|
||||
it('should update model config', () => {
|
||||
const { result } = renderHook(() => useModelConfig({
|
||||
hasFetchedDetail: true,
|
||||
}))
|
||||
const { result } = renderHook(() =>
|
||||
useModelConfig({
|
||||
hasFetchedDetail: true,
|
||||
}),
|
||||
)
|
||||
|
||||
act(() => {
|
||||
result.current.setModelConfig({
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
interface:
|
||||
display_name: "E2E Cucumber + Playwright"
|
||||
short_description: "Write and review Dify E2E scenarios."
|
||||
default_prompt: "Use $e2e-cucumber-playwright to write or review a Dify E2E scenario under e2e/."
|
||||
display_name: 'E2E Cucumber + Playwright'
|
||||
short_description: 'Write and review Dify E2E scenarios.'
|
||||
default_prompt: 'Use $e2e-cucumber-playwright to write or review a Dify E2E scenario under e2e/.'
|
||||
|
||||
@@ -93,10 +93,10 @@ describe('ComponentName', () => {
|
||||
it('should render without crashing', () => {
|
||||
// Arrange
|
||||
const props = { title: 'Test' }
|
||||
|
||||
|
||||
// Act
|
||||
render(<Component {...props} />)
|
||||
|
||||
|
||||
// Assert
|
||||
expect(screen.getByText('Test')).toBeInTheDocument()
|
||||
})
|
||||
@@ -115,9 +115,9 @@ describe('ComponentName', () => {
|
||||
it('should handle click events', () => {
|
||||
const handleClick = vi.fn()
|
||||
render(<Component onClick={handleClick} />)
|
||||
|
||||
|
||||
fireEvent.click(screen.getByRole('button'))
|
||||
|
||||
|
||||
expect(handleClick).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
@@ -278,16 +278,16 @@ it('should disable input when isReadOnly is true')
|
||||
|
||||
### Conditional (When Present)
|
||||
|
||||
| Feature | Test Focus |
|
||||
|---------|-----------|
|
||||
| `useState` | Initial state, transitions, cleanup |
|
||||
| `useEffect` | Execution, dependencies, cleanup |
|
||||
| Event handlers | All onClick, onChange, onSubmit, keyboard |
|
||||
| API calls | Loading, success, error states |
|
||||
| Routing | Navigation, params, query strings |
|
||||
| `useCallback`/`useMemo` | Referential equality |
|
||||
| Context | Provider values, consumer behavior |
|
||||
| Forms | Validation, submission, error display |
|
||||
| Feature | Test Focus |
|
||||
| ----------------------- | ----------------------------------------- |
|
||||
| `useState` | Initial state, transitions, cleanup |
|
||||
| `useEffect` | Execution, dependencies, cleanup |
|
||||
| Event handlers | All onClick, onChange, onSubmit, keyboard |
|
||||
| API calls | Loading, success, error states |
|
||||
| Routing | Navigation, params, query strings |
|
||||
| `useCallback`/`useMemo` | Referential equality |
|
||||
| Context | Provider values, consumer behavior |
|
||||
| Forms | Validation, submission, error display |
|
||||
|
||||
## Coverage Goals (Per File)
|
||||
|
||||
|
||||
@@ -108,10 +108,8 @@ describe('ComponentName', () => {
|
||||
it('should render without crashing', () => {
|
||||
// Arrange - Setup data and mocks
|
||||
// const props = createMockProps()
|
||||
|
||||
// Act - Render the component
|
||||
// render(<ComponentName {...props} />)
|
||||
|
||||
// Assert - Verify expected output
|
||||
// Prefer getByRole for accessibility; it's what users "see"
|
||||
// expect(screen.getByRole('...')).toBeInTheDocument()
|
||||
|
||||
@@ -9,7 +9,7 @@ import { render, screen, waitFor } from '@testing-library/react'
|
||||
|
||||
it('should load and display data', async () => {
|
||||
render(<DataComponent />)
|
||||
|
||||
|
||||
// Wait for element to appear
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Loaded Data')).toBeInTheDocument()
|
||||
@@ -18,7 +18,7 @@ it('should load and display data', async () => {
|
||||
|
||||
it('should hide loading spinner after load', async () => {
|
||||
render(<DataComponent />)
|
||||
|
||||
|
||||
// Wait for element to disappear
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByText('Loading...')).not.toBeInTheDocument()
|
||||
@@ -31,11 +31,11 @@ it('should hide loading spinner after load', async () => {
|
||||
```typescript
|
||||
it('should show user name after fetch', async () => {
|
||||
render(<UserProfile />)
|
||||
|
||||
|
||||
// findBy returns a promise, auto-waits up to 1000ms
|
||||
const userName = await screen.findByText('John Doe')
|
||||
expect(userName).toBeInTheDocument()
|
||||
|
||||
|
||||
// findByRole with options
|
||||
const button = await screen.findByRole('button', { name: /submit/i })
|
||||
expect(button).toBeEnabled()
|
||||
@@ -50,13 +50,13 @@ import userEvent from '@testing-library/user-event'
|
||||
it('should submit form', async () => {
|
||||
const user = userEvent.setup()
|
||||
const onSubmit = vi.fn()
|
||||
|
||||
|
||||
render(<Form onSubmit={onSubmit} />)
|
||||
|
||||
|
||||
// userEvent methods are async
|
||||
await user.type(screen.getByLabelText('Email'), 'test@example.com')
|
||||
await user.click(screen.getByRole('button', { name: /submit/i }))
|
||||
|
||||
|
||||
await waitFor(() => {
|
||||
expect(onSubmit).toHaveBeenCalledWith({ email: 'test@example.com' })
|
||||
})
|
||||
@@ -87,16 +87,16 @@ describe('Debounced Search', () => {
|
||||
it('should debounce search input', async () => {
|
||||
const onSearch = vi.fn()
|
||||
render(<SearchInput onSearch={onSearch} debounceMs={300} />)
|
||||
|
||||
|
||||
// Type in the input
|
||||
fireEvent.change(screen.getByRole('textbox'), { target: { value: 'query' } })
|
||||
|
||||
|
||||
// Search not called immediately
|
||||
expect(onSearch).not.toHaveBeenCalled()
|
||||
|
||||
|
||||
// Advance timers
|
||||
vi.advanceTimersByTime(300)
|
||||
|
||||
|
||||
// Now search is called
|
||||
expect(onSearch).toHaveBeenCalledWith('query')
|
||||
})
|
||||
@@ -111,23 +111,23 @@ it('should retry on failure', async () => {
|
||||
const fetchData = vi.fn()
|
||||
.mockRejectedValueOnce(new Error('Network error'))
|
||||
.mockResolvedValueOnce({ data: 'success' })
|
||||
|
||||
|
||||
render(<RetryComponent fetchData={fetchData} retryDelayMs={1000} />)
|
||||
|
||||
|
||||
// First call fails
|
||||
await waitFor(() => {
|
||||
expect(fetchData).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
|
||||
// Advance timer for retry
|
||||
vi.advanceTimersByTime(1000)
|
||||
|
||||
|
||||
// Second call succeeds
|
||||
await waitFor(() => {
|
||||
expect(fetchData).toHaveBeenCalledTimes(2)
|
||||
expect(screen.getByText('success')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
|
||||
vi.useRealTimers()
|
||||
})
|
||||
```
|
||||
@@ -163,31 +163,31 @@ describe('DataFetcher', () => {
|
||||
|
||||
it('should show loading state', () => {
|
||||
mockedApi.fetchData.mockImplementation(() => new Promise(() => {})) // Never resolves
|
||||
|
||||
|
||||
render(<DataFetcher />)
|
||||
|
||||
|
||||
expect(screen.getByTestId('loading-spinner')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should show data on success', async () => {
|
||||
mockedApi.fetchData.mockResolvedValue({ items: ['Item 1', 'Item 2'] })
|
||||
|
||||
|
||||
render(<DataFetcher />)
|
||||
|
||||
|
||||
// Use findBy* for multiple async elements (better error messages than waitFor with multiple assertions)
|
||||
const item1 = await screen.findByText('Item 1')
|
||||
const item2 = await screen.findByText('Item 2')
|
||||
expect(item1).toBeInTheDocument()
|
||||
expect(item2).toBeInTheDocument()
|
||||
|
||||
|
||||
expect(screen.queryByTestId('loading-spinner')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should show error on failure', async () => {
|
||||
mockedApi.fetchData.mockRejectedValue(new Error('Failed to fetch'))
|
||||
|
||||
|
||||
render(<DataFetcher />)
|
||||
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/failed to fetch/i)).toBeInTheDocument()
|
||||
})
|
||||
@@ -195,16 +195,16 @@ describe('DataFetcher', () => {
|
||||
|
||||
it('should retry on error', async () => {
|
||||
mockedApi.fetchData.mockRejectedValue(new Error('Network error'))
|
||||
|
||||
|
||||
render(<DataFetcher />)
|
||||
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('button', { name: /retry/i })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
|
||||
mockedApi.fetchData.mockResolvedValue({ items: ['Item 1'] })
|
||||
fireEvent.click(screen.getByRole('button', { name: /retry/i }))
|
||||
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Item 1')).toBeInTheDocument()
|
||||
})
|
||||
@@ -218,19 +218,19 @@ describe('DataFetcher', () => {
|
||||
it('should submit form and show success', async () => {
|
||||
const user = userEvent.setup()
|
||||
mockedApi.createItem.mockResolvedValue({ id: '1', name: 'New Item' })
|
||||
|
||||
|
||||
render(<CreateItemForm />)
|
||||
|
||||
|
||||
await user.type(screen.getByLabelText('Name'), 'New Item')
|
||||
await user.click(screen.getByRole('button', { name: /create/i }))
|
||||
|
||||
|
||||
// Button should be disabled during submission
|
||||
expect(screen.getByRole('button', { name: /creating/i })).toBeDisabled()
|
||||
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/created successfully/i)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
|
||||
expect(mockedApi.createItem).toHaveBeenCalledWith({ name: 'New Item' })
|
||||
})
|
||||
```
|
||||
@@ -242,9 +242,9 @@ it('should submit form and show success', async () => {
|
||||
```typescript
|
||||
it('should fetch data on mount', async () => {
|
||||
const fetchData = vi.fn().mockResolvedValue({ data: 'test' })
|
||||
|
||||
|
||||
render(<ComponentWithEffect fetchData={fetchData} />)
|
||||
|
||||
|
||||
await waitFor(() => {
|
||||
expect(fetchData).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
@@ -256,15 +256,15 @@ it('should fetch data on mount', async () => {
|
||||
```typescript
|
||||
it('should refetch when id changes', async () => {
|
||||
const fetchData = vi.fn().mockResolvedValue({ data: 'test' })
|
||||
|
||||
|
||||
const { rerender } = render(<ComponentWithEffect id="1" fetchData={fetchData} />)
|
||||
|
||||
|
||||
await waitFor(() => {
|
||||
expect(fetchData).toHaveBeenCalledWith('1')
|
||||
})
|
||||
|
||||
|
||||
rerender(<ComponentWithEffect id="2" fetchData={fetchData} />)
|
||||
|
||||
|
||||
await waitFor(() => {
|
||||
expect(fetchData).toHaveBeenCalledWith('2')
|
||||
expect(fetchData).toHaveBeenCalledTimes(2)
|
||||
@@ -279,13 +279,13 @@ it('should cleanup subscription on unmount', () => {
|
||||
const subscribe = vi.fn()
|
||||
const unsubscribe = vi.fn()
|
||||
subscribe.mockReturnValue(unsubscribe)
|
||||
|
||||
|
||||
const { unmount } = render(<SubscriptionComponent subscribe={subscribe} />)
|
||||
|
||||
|
||||
expect(subscribe).toHaveBeenCalledTimes(1)
|
||||
|
||||
|
||||
unmount()
|
||||
|
||||
|
||||
expect(unsubscribe).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
```
|
||||
|
||||
@@ -51,16 +51,16 @@ Use this checklist when generating or reviewing tests for Dify frontend componen
|
||||
|
||||
### Conditional Sections (Add When Feature Present)
|
||||
|
||||
| Feature | Add Tests For |
|
||||
|---------|---------------|
|
||||
| `useState` | Initial state, transitions, cleanup |
|
||||
| `useEffect` | Execution, dependencies, cleanup |
|
||||
| Event handlers | onClick, onChange, onSubmit, keyboard |
|
||||
| API calls | Loading, success, error states |
|
||||
| Routing | Navigation, params, query strings |
|
||||
| `useCallback`/`useMemo` | Referential equality |
|
||||
| Context | Provider values, consumer behavior |
|
||||
| Forms | Validation, submission, error display |
|
||||
| Feature | Add Tests For |
|
||||
| ----------------------- | ------------------------------------- |
|
||||
| `useState` | Initial state, transitions, cleanup |
|
||||
| `useEffect` | Execution, dependencies, cleanup |
|
||||
| Event handlers | onClick, onChange, onSubmit, keyboard |
|
||||
| API calls | Loading, success, error states |
|
||||
| Routing | Navigation, params, query strings |
|
||||
| `useCallback`/`useMemo` | Referential equality |
|
||||
| Context | Provider values, consumer behavior |
|
||||
| Forms | Validation, submission, error display |
|
||||
|
||||
## Code Quality Checklist
|
||||
|
||||
@@ -110,8 +110,8 @@ For the current file being tested:
|
||||
|
||||
- [ ] 100% function coverage
|
||||
- [ ] 100% statement coverage
|
||||
- [ ] >95% branch coverage
|
||||
- [ ] >95% line coverage
|
||||
- [ ] > 95% branch coverage
|
||||
- [ ] > 95% line coverage
|
||||
|
||||
## Post-Generation (Per File)
|
||||
|
||||
|
||||
@@ -107,13 +107,13 @@ describe('Counter', () => {
|
||||
it('should increment count', async () => {
|
||||
const user = userEvent.setup()
|
||||
render(<Counter initialCount={0} />)
|
||||
|
||||
|
||||
// Initial state
|
||||
expect(screen.getByText('Count: 0')).toBeInTheDocument()
|
||||
|
||||
|
||||
// Trigger transition
|
||||
await user.click(screen.getByRole('button', { name: /increment/i }))
|
||||
|
||||
|
||||
// New state
|
||||
expect(screen.getByText('Count: 1')).toBeInTheDocument()
|
||||
})
|
||||
@@ -127,17 +127,17 @@ describe('ControlledInput', () => {
|
||||
it('should call onChange with new value', async () => {
|
||||
const user = userEvent.setup()
|
||||
const handleChange = vi.fn()
|
||||
|
||||
|
||||
render(<ControlledInput value="" onChange={handleChange} />)
|
||||
|
||||
|
||||
await user.type(screen.getByRole('textbox'), 'a')
|
||||
|
||||
|
||||
expect(handleChange).toHaveBeenCalledWith('a')
|
||||
})
|
||||
|
||||
it('should display controlled value', () => {
|
||||
render(<ControlledInput value="controlled" onChange={vi.fn()} />)
|
||||
|
||||
|
||||
expect(screen.getByRole('textbox')).toHaveValue('controlled')
|
||||
})
|
||||
})
|
||||
@@ -149,26 +149,26 @@ describe('ControlledInput', () => {
|
||||
describe('ConditionalComponent', () => {
|
||||
it('should show loading state', () => {
|
||||
render(<DataDisplay isLoading={true} data={null} />)
|
||||
|
||||
|
||||
expect(screen.getByText(/loading/i)).toBeInTheDocument()
|
||||
expect(screen.queryByTestId('data-content')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should show error state', () => {
|
||||
render(<DataDisplay isLoading={false} data={null} error="Failed to load" />)
|
||||
|
||||
|
||||
expect(screen.getByText(/failed to load/i)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should show data when loaded', () => {
|
||||
render(<DataDisplay isLoading={false} data={{ name: 'Test' }} />)
|
||||
|
||||
|
||||
expect(screen.getByText('Test')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should show empty state when no data', () => {
|
||||
render(<DataDisplay isLoading={false} data={[]} />)
|
||||
|
||||
|
||||
expect(screen.getByText(/no data/i)).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
@@ -186,7 +186,7 @@ describe('ItemList', () => {
|
||||
|
||||
it('should render all items', () => {
|
||||
render(<ItemList items={items} />)
|
||||
|
||||
|
||||
expect(screen.getAllByRole('listitem')).toHaveLength(3)
|
||||
items.forEach(item => {
|
||||
expect(screen.getByText(item.name)).toBeInTheDocument()
|
||||
@@ -196,17 +196,17 @@ describe('ItemList', () => {
|
||||
it('should handle item selection', async () => {
|
||||
const user = userEvent.setup()
|
||||
const onSelect = vi.fn()
|
||||
|
||||
|
||||
render(<ItemList items={items} onSelect={onSelect} />)
|
||||
|
||||
|
||||
await user.click(screen.getByText('Item 2'))
|
||||
|
||||
|
||||
expect(onSelect).toHaveBeenCalledWith(items[1])
|
||||
})
|
||||
|
||||
it('should handle empty list', () => {
|
||||
render(<ItemList items={[]} />)
|
||||
|
||||
|
||||
expect(screen.getByText(/no items/i)).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
@@ -218,55 +218,55 @@ describe('ItemList', () => {
|
||||
describe('Modal', () => {
|
||||
it('should not render when closed', () => {
|
||||
render(<Modal isOpen={false} onClose={vi.fn()} />)
|
||||
|
||||
|
||||
expect(screen.queryByRole('dialog')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render when open', () => {
|
||||
render(<Modal isOpen={true} onClose={vi.fn()} />)
|
||||
|
||||
|
||||
expect(screen.getByRole('dialog')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should call onClose when clicking overlay', async () => {
|
||||
const user = userEvent.setup()
|
||||
const handleClose = vi.fn()
|
||||
|
||||
|
||||
render(<Modal isOpen={true} onClose={handleClose} />)
|
||||
|
||||
|
||||
await user.click(screen.getByTestId('modal-overlay'))
|
||||
|
||||
|
||||
expect(handleClose).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should call onClose when pressing Escape', async () => {
|
||||
const user = userEvent.setup()
|
||||
const handleClose = vi.fn()
|
||||
|
||||
|
||||
render(<Modal isOpen={true} onClose={handleClose} />)
|
||||
|
||||
|
||||
await user.keyboard('{Escape}')
|
||||
|
||||
|
||||
expect(handleClose).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should trap focus inside modal', async () => {
|
||||
const user = userEvent.setup()
|
||||
|
||||
|
||||
render(
|
||||
<Modal isOpen={true} onClose={vi.fn()}>
|
||||
<button>First</button>
|
||||
<button>Second</button>
|
||||
</Modal>
|
||||
)
|
||||
|
||||
|
||||
// Focus should cycle within modal
|
||||
await user.tab()
|
||||
expect(screen.getByText('First')).toHaveFocus()
|
||||
|
||||
|
||||
await user.tab()
|
||||
expect(screen.getByText('Second')).toHaveFocus()
|
||||
|
||||
|
||||
await user.tab()
|
||||
expect(screen.getByText('First')).toHaveFocus() // Cycles back
|
||||
})
|
||||
@@ -280,13 +280,13 @@ describe('LoginForm', () => {
|
||||
it('should submit valid form', async () => {
|
||||
const user = userEvent.setup()
|
||||
const onSubmit = vi.fn()
|
||||
|
||||
|
||||
render(<LoginForm onSubmit={onSubmit} />)
|
||||
|
||||
|
||||
await user.type(screen.getByLabelText(/email/i), 'test@example.com')
|
||||
await user.type(screen.getByLabelText(/password/i), 'password123')
|
||||
await user.click(screen.getByRole('button', { name: /sign in/i }))
|
||||
|
||||
|
||||
expect(onSubmit).toHaveBeenCalledWith({
|
||||
email: 'test@example.com',
|
||||
password: 'password123',
|
||||
@@ -295,39 +295,39 @@ describe('LoginForm', () => {
|
||||
|
||||
it('should show validation errors', async () => {
|
||||
const user = userEvent.setup()
|
||||
|
||||
|
||||
render(<LoginForm onSubmit={vi.fn()} />)
|
||||
|
||||
|
||||
// Submit empty form
|
||||
await user.click(screen.getByRole('button', { name: /sign in/i }))
|
||||
|
||||
|
||||
expect(screen.getByText(/email is required/i)).toBeInTheDocument()
|
||||
expect(screen.getByText(/password is required/i)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should validate email format', async () => {
|
||||
const user = userEvent.setup()
|
||||
|
||||
|
||||
render(<LoginForm onSubmit={vi.fn()} />)
|
||||
|
||||
|
||||
await user.type(screen.getByLabelText(/email/i), 'invalid-email')
|
||||
await user.click(screen.getByRole('button', { name: /sign in/i }))
|
||||
|
||||
|
||||
expect(screen.getByText(/invalid email/i)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should disable submit button while submitting', async () => {
|
||||
const user = userEvent.setup()
|
||||
const onSubmit = vi.fn(() => new Promise(resolve => setTimeout(resolve, 100)))
|
||||
|
||||
|
||||
render(<LoginForm onSubmit={onSubmit} />)
|
||||
|
||||
|
||||
await user.type(screen.getByLabelText(/email/i), 'test@example.com')
|
||||
await user.type(screen.getByLabelText(/password/i), 'password123')
|
||||
await user.click(screen.getByRole('button', { name: /sign in/i }))
|
||||
|
||||
|
||||
expect(screen.getByRole('button', { name: /signing in/i })).toBeDisabled()
|
||||
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('button', { name: /sign in/i })).toBeEnabled()
|
||||
})
|
||||
@@ -346,7 +346,7 @@ describe('StatusBadge', () => {
|
||||
['info', 'bg-blue-500'],
|
||||
])('should apply correct class for %s status', (status, expectedClass) => {
|
||||
render(<StatusBadge status={status} />)
|
||||
|
||||
|
||||
expect(screen.getByTestId('status-badge')).toHaveClass(expectedClass)
|
||||
})
|
||||
|
||||
@@ -357,7 +357,7 @@ describe('StatusBadge', () => {
|
||||
{ input: 'invalid', expected: 'Unknown' },
|
||||
])('should show "Unknown" for invalid input: $input', ({ input, expected }) => {
|
||||
render(<StatusBadge status={input} />)
|
||||
|
||||
|
||||
expect(screen.getByText(expected)).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -53,18 +53,18 @@ describe('NodeConfigPanel', () => {
|
||||
it('should render node type selector', () => {
|
||||
const node = createMockNode({ type: 'llm' })
|
||||
render(<NodeConfigPanel node={node} />)
|
||||
|
||||
|
||||
expect(screen.getByLabelText(/model/i)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should update node config on change', async () => {
|
||||
const user = userEvent.setup()
|
||||
const node = createMockNode({ type: 'llm' })
|
||||
|
||||
|
||||
render(<NodeConfigPanel node={node} />)
|
||||
|
||||
|
||||
await user.selectOptions(screen.getByLabelText(/model/i), 'gpt-4')
|
||||
|
||||
|
||||
expect(mockWorkflowStore.updateNode).toHaveBeenCalledWith(
|
||||
node.id,
|
||||
expect.objectContaining({ model: 'gpt-4' })
|
||||
@@ -76,14 +76,14 @@ describe('NodeConfigPanel', () => {
|
||||
it('should show error for invalid input', async () => {
|
||||
const user = userEvent.setup()
|
||||
const node = createMockNode({ type: 'code' })
|
||||
|
||||
|
||||
render(<NodeConfigPanel node={node} />)
|
||||
|
||||
|
||||
// Enter invalid code
|
||||
const codeInput = screen.getByLabelText(/code/i)
|
||||
await user.clear(codeInput)
|
||||
await user.type(codeInput, 'invalid syntax {{{')
|
||||
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/syntax error/i)).toBeInTheDocument()
|
||||
})
|
||||
@@ -91,11 +91,11 @@ describe('NodeConfigPanel', () => {
|
||||
|
||||
it('should validate required fields', async () => {
|
||||
const node = createMockNode({ type: 'http', data: { url: '' } })
|
||||
|
||||
|
||||
render(<NodeConfigPanel node={node} />)
|
||||
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /save/i }))
|
||||
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/url is required/i)).toBeInTheDocument()
|
||||
})
|
||||
@@ -113,28 +113,28 @@ describe('NodeConfigPanel', () => {
|
||||
id: 'node-2',
|
||||
type: 'llm',
|
||||
})
|
||||
|
||||
|
||||
mockWorkflowStore.nodes = [upstreamNode, currentNode]
|
||||
mockWorkflowStore.edges = [{ source: 'node-1', target: 'node-2' }]
|
||||
|
||||
|
||||
render(<NodeConfigPanel node={currentNode} />)
|
||||
|
||||
|
||||
// Variable selector should show upstream variables
|
||||
fireEvent.click(screen.getByRole('button', { name: /add variable/i }))
|
||||
|
||||
|
||||
expect(screen.getByText('user_input')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should insert variable into prompt template', async () => {
|
||||
const user = userEvent.setup()
|
||||
const node = createMockNode({ type: 'llm' })
|
||||
|
||||
|
||||
render(<NodeConfigPanel node={node} />)
|
||||
|
||||
|
||||
// Click variable button
|
||||
await user.click(screen.getByRole('button', { name: /insert variable/i }))
|
||||
await user.click(screen.getByText('user_input'))
|
||||
|
||||
|
||||
const promptInput = screen.getByLabelText(/prompt/i)
|
||||
expect(promptInput).toHaveValue(expect.stringContaining('{{user_input}}'))
|
||||
})
|
||||
@@ -179,14 +179,14 @@ describe('DocumentUploader', () => {
|
||||
const user = userEvent.setup()
|
||||
const onUpload = vi.fn()
|
||||
mockedService.uploadDocument.mockResolvedValue({ id: 'doc-1' })
|
||||
|
||||
|
||||
render(<DocumentUploader onUpload={onUpload} />)
|
||||
|
||||
|
||||
const file = new File(['content'], 'test.pdf', { type: 'application/pdf' })
|
||||
const input = screen.getByLabelText(/upload/i)
|
||||
|
||||
|
||||
await user.upload(input, file)
|
||||
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockedService.uploadDocument).toHaveBeenCalledWith(
|
||||
expect.any(FormData)
|
||||
@@ -196,35 +196,35 @@ describe('DocumentUploader', () => {
|
||||
|
||||
it('should reject invalid file types', async () => {
|
||||
const user = userEvent.setup()
|
||||
|
||||
|
||||
render(<DocumentUploader />)
|
||||
|
||||
|
||||
const file = new File(['content'], 'test.exe', { type: 'application/x-msdownload' })
|
||||
const input = screen.getByLabelText(/upload/i)
|
||||
|
||||
|
||||
await user.upload(input, file)
|
||||
|
||||
|
||||
expect(screen.getByText(/unsupported file type/i)).toBeInTheDocument()
|
||||
expect(mockedService.uploadDocument).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should show upload progress', async () => {
|
||||
const user = userEvent.setup()
|
||||
|
||||
|
||||
// Mock upload with progress
|
||||
mockedService.uploadDocument.mockImplementation(() => {
|
||||
return new Promise((resolve) => {
|
||||
setTimeout(() => resolve({ id: 'doc-1' }), 100)
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
render(<DocumentUploader />)
|
||||
|
||||
|
||||
const file = new File(['content'], 'test.pdf', { type: 'application/pdf' })
|
||||
await user.upload(screen.getByLabelText(/upload/i), file)
|
||||
|
||||
|
||||
expect(screen.getByRole('progressbar')).toBeInTheDocument()
|
||||
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByRole('progressbar')).not.toBeInTheDocument()
|
||||
})
|
||||
@@ -235,12 +235,12 @@ describe('DocumentUploader', () => {
|
||||
it('should handle upload failure', async () => {
|
||||
const user = userEvent.setup()
|
||||
mockedService.uploadDocument.mockRejectedValue(new Error('Upload failed'))
|
||||
|
||||
|
||||
render(<DocumentUploader />)
|
||||
|
||||
|
||||
const file = new File(['content'], 'test.pdf', { type: 'application/pdf' })
|
||||
await user.upload(screen.getByLabelText(/upload/i), file)
|
||||
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/upload failed/i)).toBeInTheDocument()
|
||||
})
|
||||
@@ -251,18 +251,18 @@ describe('DocumentUploader', () => {
|
||||
mockedService.uploadDocument
|
||||
.mockRejectedValueOnce(new Error('Network error'))
|
||||
.mockResolvedValueOnce({ id: 'doc-1' })
|
||||
|
||||
|
||||
render(<DocumentUploader />)
|
||||
|
||||
|
||||
const file = new File(['content'], 'test.pdf', { type: 'application/pdf' })
|
||||
await user.upload(screen.getByLabelText(/upload/i), file)
|
||||
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('button', { name: /retry/i })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
|
||||
await user.click(screen.getByRole('button', { name: /retry/i }))
|
||||
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/uploaded successfully/i)).toBeInTheDocument()
|
||||
})
|
||||
@@ -283,13 +283,13 @@ describe('DocumentList', () => {
|
||||
page: 1,
|
||||
pageSize: 10,
|
||||
})
|
||||
|
||||
|
||||
render(<DocumentList datasetId="ds-1" />)
|
||||
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Doc 1')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
|
||||
expect(mockedService.getDocuments).toHaveBeenCalledWith('ds-1', { page: 1 })
|
||||
})
|
||||
|
||||
@@ -301,22 +301,22 @@ describe('DocumentList', () => {
|
||||
page: 1,
|
||||
pageSize: 10,
|
||||
})
|
||||
|
||||
|
||||
render(<DocumentList datasetId="ds-1" />)
|
||||
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Doc 1')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
|
||||
mockedService.getDocuments.mockResolvedValue({
|
||||
data: [{ id: '11', name: 'Doc 11' }],
|
||||
total: 50,
|
||||
page: 2,
|
||||
pageSize: 10,
|
||||
})
|
||||
|
||||
|
||||
await user.click(screen.getByRole('button', { name: /next/i }))
|
||||
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Doc 11')).toBeInTheDocument()
|
||||
})
|
||||
@@ -327,21 +327,21 @@ describe('DocumentList', () => {
|
||||
it('should filter by search query', async () => {
|
||||
const user = userEvent.setup()
|
||||
vi.useFakeTimers()
|
||||
|
||||
|
||||
render(<DocumentList datasetId="ds-1" />)
|
||||
|
||||
|
||||
await user.type(screen.getByPlaceholderText(/search/i), 'test query')
|
||||
|
||||
|
||||
// Debounce
|
||||
vi.advanceTimersByTime(300)
|
||||
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockedService.getDocuments).toHaveBeenCalledWith(
|
||||
'ds-1',
|
||||
expect.objectContaining({ search: 'test query' })
|
||||
)
|
||||
})
|
||||
|
||||
|
||||
vi.useRealTimers()
|
||||
})
|
||||
})
|
||||
@@ -391,50 +391,50 @@ describe('AppConfigForm', () => {
|
||||
describe('Form Validation', () => {
|
||||
it('should require app name', async () => {
|
||||
const user = userEvent.setup()
|
||||
|
||||
|
||||
render(<AppConfigForm appId="app-1" />)
|
||||
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByLabelText(/name/i)).toHaveValue('My App')
|
||||
})
|
||||
|
||||
|
||||
// Clear name field
|
||||
await user.clear(screen.getByLabelText(/name/i))
|
||||
await user.click(screen.getByRole('button', { name: /save/i }))
|
||||
|
||||
|
||||
expect(screen.getByText(/name is required/i)).toBeInTheDocument()
|
||||
expect(mockedService.updateAppConfig).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should validate name length', async () => {
|
||||
const user = userEvent.setup()
|
||||
|
||||
|
||||
render(<AppConfigForm appId="app-1" />)
|
||||
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByLabelText(/name/i)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
|
||||
// Enter very long name
|
||||
await user.clear(screen.getByLabelText(/name/i))
|
||||
await user.type(screen.getByLabelText(/name/i), 'a'.repeat(101))
|
||||
|
||||
|
||||
expect(screen.getByText(/name must be less than 100 characters/i)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should allow empty optional fields', async () => {
|
||||
const user = userEvent.setup()
|
||||
mockedService.updateAppConfig.mockResolvedValue({ success: true })
|
||||
|
||||
|
||||
render(<AppConfigForm appId="app-1" />)
|
||||
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByLabelText(/name/i)).toHaveValue('My App')
|
||||
})
|
||||
|
||||
|
||||
// Leave description empty (optional)
|
||||
await user.click(screen.getByRole('button', { name: /save/i }))
|
||||
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockedService.updateAppConfig).toHaveBeenCalled()
|
||||
})
|
||||
@@ -445,58 +445,58 @@ describe('AppConfigForm', () => {
|
||||
it('should save configuration', async () => {
|
||||
const user = userEvent.setup()
|
||||
mockedService.updateAppConfig.mockResolvedValue({ success: true })
|
||||
|
||||
|
||||
render(<AppConfigForm appId="app-1" />)
|
||||
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByLabelText(/name/i)).toHaveValue('My App')
|
||||
})
|
||||
|
||||
|
||||
await user.clear(screen.getByLabelText(/name/i))
|
||||
await user.type(screen.getByLabelText(/name/i), 'Updated App')
|
||||
await user.click(screen.getByRole('button', { name: /save/i }))
|
||||
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockedService.updateAppConfig).toHaveBeenCalledWith(
|
||||
'app-1',
|
||||
expect.objectContaining({ name: 'Updated App' })
|
||||
)
|
||||
})
|
||||
|
||||
|
||||
expect(screen.getByText(/saved successfully/i)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should reset to default values', async () => {
|
||||
const user = userEvent.setup()
|
||||
|
||||
|
||||
render(<AppConfigForm appId="app-1" />)
|
||||
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByLabelText(/name/i)).toHaveValue('My App')
|
||||
})
|
||||
|
||||
|
||||
// Make changes
|
||||
await user.clear(screen.getByLabelText(/name/i))
|
||||
await user.type(screen.getByLabelText(/name/i), 'Changed Name')
|
||||
|
||||
|
||||
// Reset
|
||||
await user.click(screen.getByRole('button', { name: /reset/i }))
|
||||
|
||||
|
||||
expect(screen.getByLabelText(/name/i)).toHaveValue('My App')
|
||||
})
|
||||
|
||||
it('should show unsaved changes warning', async () => {
|
||||
const user = userEvent.setup()
|
||||
|
||||
|
||||
render(<AppConfigForm appId="app-1" />)
|
||||
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByLabelText(/name/i)).toHaveValue('My App')
|
||||
})
|
||||
|
||||
|
||||
// Make changes
|
||||
await user.type(screen.getByLabelText(/name/i), ' Updated')
|
||||
|
||||
|
||||
expect(screen.getByText(/unsaved changes/i)).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
@@ -505,15 +505,15 @@ describe('AppConfigForm', () => {
|
||||
it('should show error on save failure', async () => {
|
||||
const user = userEvent.setup()
|
||||
mockedService.updateAppConfig.mockRejectedValue(new Error('Server error'))
|
||||
|
||||
|
||||
render(<AppConfigForm appId="app-1" />)
|
||||
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByLabelText(/name/i)).toHaveValue('My App')
|
||||
})
|
||||
|
||||
|
||||
await user.click(screen.getByRole('button', { name: /save/i }))
|
||||
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/failed to save/i)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
@@ -54,12 +54,12 @@ See [Zustand Store Testing](#zustand-store-testing) section for full details.
|
||||
|
||||
## Mock Placement
|
||||
|
||||
| Location | Purpose |
|
||||
|----------|---------|
|
||||
| `web/vitest.setup.ts` | Global mocks shared by all tests (`react-i18next`, `zustand`, clipboard, FloatingPortal, Monaco, localStorage`) |
|
||||
| `web/__mocks__/zustand.ts` | Zustand mock implementation (auto-resets stores after each test) |
|
||||
| `web/__mocks__/` | Reusable mock factories shared across multiple test files |
|
||||
| Test file | Test-specific mocks, inline with `vi.mock()` |
|
||||
| Location | Purpose |
|
||||
| -------------------------- | --------------------------------------------------------------------------------------------------------------- |
|
||||
| `web/vitest.setup.ts` | Global mocks shared by all tests (`react-i18next`, `zustand`, clipboard, FloatingPortal, Monaco, localStorage`) |
|
||||
| `web/__mocks__/zustand.ts` | Zustand mock implementation (auto-resets stores after each test) |
|
||||
| `web/__mocks__/` | Reusable mock factories shared across multiple test files |
|
||||
| Test file | Test-specific mocks, inline with `vi.mock()` |
|
||||
|
||||
Modules are not mocked automatically. Use `vi.mock` in test files, or add global mocks in `web/vitest.setup.ts`.
|
||||
|
||||
@@ -85,10 +85,12 @@ The global mock provides:
|
||||
```typescript
|
||||
import { createReactI18nextMock } from '@/test/i18n-mock'
|
||||
|
||||
vi.mock('react-i18next', () => createReactI18nextMock({
|
||||
'my.custom.key': 'Custom translation',
|
||||
'button.save': 'Save',
|
||||
}))
|
||||
vi.mock('react-i18next', () =>
|
||||
createReactI18nextMock({
|
||||
'my.custom.key': 'Custom translation',
|
||||
'button.save': 'Save',
|
||||
}),
|
||||
)
|
||||
```
|
||||
|
||||
**Avoid**: Manually defining `useTranslation` mocks that just return the key - the global mock already does this.
|
||||
@@ -189,16 +191,16 @@ const mockedApi = vi.mocked(api)
|
||||
describe('Component', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
|
||||
|
||||
// Setup default mock implementation
|
||||
mockedApi.fetchData.mockResolvedValue({ data: [] })
|
||||
})
|
||||
|
||||
it('should show data on success', async () => {
|
||||
mockedApi.fetchData.mockResolvedValue({ data: [{ id: 1 }] })
|
||||
|
||||
|
||||
render(<Component />)
|
||||
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('1')).toBeInTheDocument()
|
||||
})
|
||||
@@ -206,9 +208,9 @@ describe('Component', () => {
|
||||
|
||||
it('should show error on failure', async () => {
|
||||
mockedApi.fetchData.mockRejectedValue(new Error('Network error'))
|
||||
|
||||
|
||||
render(<Component />)
|
||||
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/error/i)).toBeInTheDocument()
|
||||
})
|
||||
@@ -231,9 +233,9 @@ describe('GithubComponent', () => {
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
}),
|
||||
)
|
||||
|
||||
|
||||
render(<GithubComponent />)
|
||||
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('dify')).toBeInTheDocument()
|
||||
})
|
||||
@@ -246,9 +248,9 @@ describe('GithubComponent', () => {
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
}),
|
||||
)
|
||||
|
||||
|
||||
render(<GithubComponent />)
|
||||
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/error/i)).toBeInTheDocument()
|
||||
})
|
||||
@@ -267,25 +269,25 @@ import { createMockProviderContextValue, createMockPlan } from '@/__mocks__/prov
|
||||
describe('Component with Context', () => {
|
||||
it('should render for free plan', () => {
|
||||
const mockContext = createMockPlan('sandbox')
|
||||
|
||||
|
||||
render(
|
||||
<ProviderContext.Provider value={mockContext}>
|
||||
<Component />
|
||||
</ProviderContext.Provider>
|
||||
)
|
||||
|
||||
|
||||
expect(screen.getByText('Upgrade')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render for pro plan', () => {
|
||||
const mockContext = createMockPlan('professional')
|
||||
|
||||
|
||||
render(
|
||||
<ProviderContext.Provider value={mockContext}>
|
||||
<Component />
|
||||
</ProviderContext.Provider>
|
||||
)
|
||||
|
||||
|
||||
expect(screen.queryByText('Upgrade')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
@@ -411,7 +413,7 @@ Manual mocking conflicts with the global Zustand mock and loses store functional
|
||||
```typescript
|
||||
// ❌ WRONG: Don't mock the store module
|
||||
vi.mock('@/app/components/app/store', () => ({
|
||||
useStore: (selector) => mockSelector(selector), // Missing getState, setState!
|
||||
useStore: (selector) => mockSelector(selector), // Missing getState, setState!
|
||||
}))
|
||||
|
||||
// ❌ WRONG: This conflicts with global zustand mock
|
||||
@@ -439,14 +441,11 @@ const mockStore = {
|
||||
}
|
||||
|
||||
vi.mock('@/app/components/app/store', () => ({
|
||||
useStore: Object.assign(
|
||||
(selector: (state: typeof mockStore) => unknown) => selector(mockStore),
|
||||
{
|
||||
getState: () => mockStore,
|
||||
setState: vi.fn(),
|
||||
subscribe: vi.fn(),
|
||||
},
|
||||
),
|
||||
useStore: Object.assign((selector: (state: typeof mockStore) => unknown) => selector(mockStore), {
|
||||
getState: () => mockStore,
|
||||
setState: vi.fn(),
|
||||
subscribe: vi.fn(),
|
||||
}),
|
||||
}))
|
||||
```
|
||||
|
||||
@@ -528,7 +527,7 @@ it('should display project owner', () => {
|
||||
const project = createMockProject({
|
||||
owner: createMockUser({ name: 'John Doe' }),
|
||||
})
|
||||
|
||||
|
||||
render(<ProjectCard project={project} />)
|
||||
expect(screen.getByText('John Doe')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
@@ -6,10 +6,10 @@ This guide defines the workflow for generating tests, especially for complex com
|
||||
|
||||
This guide addresses **multi-file workflow** (how to process multiple test files). For coverage requirements within a single test file, see `web/docs/test.md` § Coverage Goals.
|
||||
|
||||
| Scope | Rule |
|
||||
|-------|------|
|
||||
| **Single file** | Complete coverage in one generation (100% function, >95% branch) |
|
||||
| **Multi-file directory** | Process one file at a time, verify each before proceeding |
|
||||
| Scope | Rule |
|
||||
| ------------------------ | ---------------------------------------------------------------- |
|
||||
| **Single file** | Complete coverage in one generation (100% function, >95% branch) |
|
||||
| **Multi-file directory** | Process one file at a time, verify each before proceeding |
|
||||
|
||||
## ⚠️ Critical Rule: Incremental Approach for Multi-File Testing
|
||||
|
||||
@@ -17,14 +17,14 @@ When testing a **directory with multiple files**, **NEVER generate all test file
|
||||
|
||||
### Why Incremental?
|
||||
|
||||
| Batch Approach (❌) | Incremental Approach (✅) |
|
||||
|---------------------|---------------------------|
|
||||
| Generate 5+ tests at once | Generate 1 test at a time |
|
||||
| Run tests only at the end | Run test immediately after each file |
|
||||
| Multiple failures compound | Single point of failure, easy to debug |
|
||||
| Hard to identify root cause | Clear cause-effect relationship |
|
||||
| Mock issues affect many files | Mock issues caught early |
|
||||
| Messy git history | Clean, atomic commits possible |
|
||||
| Batch Approach (❌) | Incremental Approach (✅) |
|
||||
| ----------------------------- | -------------------------------------- |
|
||||
| Generate 5+ tests at once | Generate 1 test at a time |
|
||||
| Run tests only at the end | Run test immediately after each file |
|
||||
| Multiple failures compound | Single point of failure, easy to debug |
|
||||
| Hard to identify root cause | Clear cause-effect relationship |
|
||||
| Mock issues affect many files | Mock issues caught early |
|
||||
| Messy git history | Clean, atomic commits possible |
|
||||
|
||||
## Single File Workflow
|
||||
|
||||
@@ -190,7 +190,7 @@ Update status as you complete each:
|
||||
```
|
||||
# BAD: Writing all files then testing
|
||||
Write component-a.spec.tsx
|
||||
Write component-b.spec.tsx
|
||||
Write component-b.spec.tsx
|
||||
Write component-c.spec.tsx
|
||||
Write component-d.spec.tsx
|
||||
Run pnpm test ← Multiple failures, hard to debug
|
||||
|
||||
@@ -1,49 +1,43 @@
|
||||
// For format details, see https://aka.ms/devcontainer.json. For config options, see the
|
||||
// README at: https://github.com/devcontainers/templates/tree/main/src/anaconda
|
||||
{
|
||||
"name": "Python 3.12",
|
||||
"build": {
|
||||
"context": "..",
|
||||
"dockerfile": "Dockerfile"
|
||||
},
|
||||
"mounts": [
|
||||
"source=dify-dev-tmp,target=/tmp,type=volume"
|
||||
],
|
||||
"features": {
|
||||
"ghcr.io/devcontainers/features/node:1": {
|
||||
"nodeGypDependencies": true,
|
||||
"version": "lts"
|
||||
},
|
||||
"ghcr.io/devcontainers-extra/features/npm-package:1": {
|
||||
"package": "typescript",
|
||||
"version": "latest"
|
||||
},
|
||||
"ghcr.io/devcontainers/features/docker-in-docker:2": {
|
||||
"moby": true,
|
||||
"azureDnsAutoDetection": true,
|
||||
"installDockerBuildx": true,
|
||||
"version": "latest",
|
||||
"dockerDashComposeVersion": "v2"
|
||||
}
|
||||
},
|
||||
"customizations": {
|
||||
"vscode": {
|
||||
"extensions": [
|
||||
"ms-python.pylint",
|
||||
"GitHub.copilot",
|
||||
"ms-python.python"
|
||||
]
|
||||
}
|
||||
},
|
||||
"postStartCommand": "./.devcontainer/post_start_command.sh",
|
||||
"postCreateCommand": "./.devcontainer/post_create_command.sh"
|
||||
// Features to add to the dev container. More info: https://containers.dev/features.
|
||||
// "features": {},
|
||||
// Use 'forwardPorts' to make a list of ports inside the container available locally.
|
||||
// "forwardPorts": [],
|
||||
// Use 'postCreateCommand' to run commands after the container is created.
|
||||
// "postCreateCommand": "python --version",
|
||||
// Configure tool-specific properties.
|
||||
// "customizations": {},
|
||||
// Uncomment to connect as root instead. More info: https://aka.ms/dev-containers-non-root.
|
||||
}
|
||||
"name": "Python 3.12",
|
||||
"build": {
|
||||
"context": "..",
|
||||
"dockerfile": "Dockerfile"
|
||||
},
|
||||
"mounts": ["source=dify-dev-tmp,target=/tmp,type=volume"],
|
||||
"features": {
|
||||
"ghcr.io/devcontainers/features/node:1": {
|
||||
"nodeGypDependencies": true,
|
||||
"version": "lts"
|
||||
},
|
||||
"ghcr.io/devcontainers-extra/features/npm-package:1": {
|
||||
"package": "typescript",
|
||||
"version": "latest"
|
||||
},
|
||||
"ghcr.io/devcontainers/features/docker-in-docker:2": {
|
||||
"moby": true,
|
||||
"azureDnsAutoDetection": true,
|
||||
"installDockerBuildx": true,
|
||||
"version": "latest",
|
||||
"dockerDashComposeVersion": "v2"
|
||||
}
|
||||
},
|
||||
"customizations": {
|
||||
"vscode": {
|
||||
"extensions": ["ms-python.pylint", "GitHub.copilot", "ms-python.python"]
|
||||
}
|
||||
},
|
||||
"postStartCommand": "./.devcontainer/post_start_command.sh",
|
||||
"postCreateCommand": "./.devcontainer/post_create_command.sh"
|
||||
// Features to add to the dev container. More info: https://containers.dev/features.
|
||||
// "features": {},
|
||||
// Use 'forwardPorts' to make a list of ports inside the container available locally.
|
||||
// "forwardPorts": [],
|
||||
// Use 'postCreateCommand' to run commands after the container is created.
|
||||
// "postCreateCommand": "python --version",
|
||||
// Configure tool-specific properties.
|
||||
// "customizations": {},
|
||||
// Uncomment to connect as root instead. More info: https://aka.ms/dev-containers-non-root.
|
||||
}
|
||||
|
||||
@@ -1,17 +1,17 @@
|
||||
title: "General Discussion"
|
||||
title: 'General Discussion'
|
||||
body:
|
||||
- type: checkboxes
|
||||
attributes:
|
||||
label: Self Checks
|
||||
description: "To make sure we get to you in time, please check the following :)"
|
||||
description: 'To make sure we get to you in time, please check the following :)'
|
||||
options:
|
||||
- label: I have searched for existing issues [search for existing issues](https://github.com/langgenius/dify/issues), including closed ones.
|
||||
required: true
|
||||
- label: I confirm that I am using English to submit this report (我已阅读并同意 [Language Policy](https://github.com/langgenius/dify/issues/1542)).
|
||||
required: true
|
||||
- label: "[FOR CHINESE USERS] 请务必使用英文提交 Issue,否则会被关闭。谢谢!:)"
|
||||
- label: '[FOR CHINESE USERS] 请务必使用英文提交 Issue,否则会被关闭。谢谢!:)'
|
||||
required: true
|
||||
- label: "Please do not modify this template :) and fill in all the required fields."
|
||||
- label: 'Please do not modify this template :) and fill in all the required fields.'
|
||||
required: true
|
||||
- type: textarea
|
||||
attributes:
|
||||
|
||||
@@ -1,17 +1,17 @@
|
||||
title: "Help"
|
||||
title: 'Help'
|
||||
body:
|
||||
- type: checkboxes
|
||||
attributes:
|
||||
label: Self Checks
|
||||
description: "To make sure we get to you in time, please check the following :)"
|
||||
description: 'To make sure we get to you in time, please check the following :)'
|
||||
options:
|
||||
- label: I have searched for existing issues [search for existing issues](https://github.com/langgenius/dify/issues), including closed ones.
|
||||
required: true
|
||||
- label: I confirm that I am using English to submit this report (我已阅读并同意 [Language Policy](https://github.com/langgenius/dify/issues/1542)).
|
||||
required: true
|
||||
- label: "[FOR CHINESE USERS] 请务必使用英文提交 Issue,否则会被关闭。谢谢!:)"
|
||||
- label: '[FOR CHINESE USERS] 请务必使用英文提交 Issue,否则会被关闭。谢谢!:)'
|
||||
required: true
|
||||
- label: "Please do not modify this template :) and fill in all the required fields."
|
||||
- label: 'Please do not modify this template :) and fill in all the required fields.'
|
||||
required: true
|
||||
- type: textarea
|
||||
attributes:
|
||||
|
||||
@@ -3,15 +3,15 @@ body:
|
||||
- type: checkboxes
|
||||
attributes:
|
||||
label: Self Checks
|
||||
description: "To make sure we get to you in time, please check the following :)"
|
||||
description: 'To make sure we get to you in time, please check the following :)'
|
||||
options:
|
||||
- label: I have searched for existing issues [search for existing issues](https://github.com/langgenius/dify/issues), including closed ones.
|
||||
required: true
|
||||
- label: I confirm that I am using English to submit this report (我已阅读并同意 [Language Policy](https://github.com/langgenius/dify/issues/1542)).
|
||||
required: true
|
||||
- label: "[FOR CHINESE USERS] 请务必使用英文提交 Issue,否则会被关闭。谢谢!:)"
|
||||
- label: '[FOR CHINESE USERS] 请务必使用英文提交 Issue,否则会被关闭。谢谢!:)'
|
||||
required: true
|
||||
- label: "Please do not modify this template :) and fill in all the required fields."
|
||||
- label: 'Please do not modify this template :) and fill in all the required fields.'
|
||||
required: true
|
||||
- type: textarea
|
||||
attributes:
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
name: "🕷️ Bug report"
|
||||
name: '🕷️ Bug report'
|
||||
description: Report errors or unexpected behavior
|
||||
labels:
|
||||
- bug
|
||||
@@ -6,7 +6,7 @@ body:
|
||||
- type: checkboxes
|
||||
attributes:
|
||||
label: Self Checks
|
||||
description: "To make sure we get to you in time, please check the following :)"
|
||||
description: 'To make sure we get to you in time, please check the following :)'
|
||||
options:
|
||||
- label: I have read the [Contributing Guide](https://github.com/langgenius/dify/blob/main/CONTRIBUTING.md) and [Language Policy](https://github.com/langgenius/dify/issues/1542).
|
||||
required: true
|
||||
@@ -18,7 +18,7 @@ body:
|
||||
required: true
|
||||
- label: 【中文用户 & Non English User】请使用英语提交,否则会被关闭 :)
|
||||
required: true
|
||||
- label: "Please do not modify this template :) and fill in all the required fields."
|
||||
- label: 'Please do not modify this template :) and fill in all the required fields.'
|
||||
required: true
|
||||
|
||||
- type: input
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
blank_issues_enabled: false
|
||||
contact_links:
|
||||
- name: "\U0001F510 Security Vulnerabilities"
|
||||
url: "https://github.com/langgenius/dify/security/advisories/new"
|
||||
url: 'https://github.com/langgenius/dify/security/advisories/new'
|
||||
about: Report security vulnerabilities through GitHub Security Advisories to ensure responsible disclosure. 💡 Please do not report security vulnerabilities in public issues.
|
||||
- name: "\U0001F4A1 Model Providers & Plugins"
|
||||
url: "https://github.com/langgenius/dify-official-plugins/issues/new/choose"
|
||||
url: 'https://github.com/langgenius/dify-official-plugins/issues/new/choose'
|
||||
about: Report issues with official plugins or model providers, you will need to provide the plugin version and other relevant details.
|
||||
- name: "\U0001F4AC Documentation Issues"
|
||||
url: "https://github.com/langgenius/dify-docs/issues/new"
|
||||
url: 'https://github.com/langgenius/dify-docs/issues/new'
|
||||
about: Report issues with the documentation, such as typos, outdated information, or missing content. Please provide the specific section and details of the issue.
|
||||
- name: "\U0001F4E7 Discussions"
|
||||
url: https://github.com/langgenius/dify/discussions/categories/general
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
name: "⭐ Feature or enhancement request"
|
||||
name: '⭐ Feature or enhancement request'
|
||||
description: Propose something new.
|
||||
labels:
|
||||
- enhancement
|
||||
@@ -6,7 +6,7 @@ body:
|
||||
- type: checkboxes
|
||||
attributes:
|
||||
label: Self Checks
|
||||
description: "To make sure we get to you in time, please check the following :)"
|
||||
description: 'To make sure we get to you in time, please check the following :)'
|
||||
options:
|
||||
- label: I have read the [Contributing Guide](https://github.com/langgenius/dify/blob/main/CONTRIBUTING.md) and [Language Policy](https://github.com/langgenius/dify/issues/1542).
|
||||
required: true
|
||||
@@ -14,7 +14,7 @@ body:
|
||||
required: true
|
||||
- label: I confirm that I am using English to submit this report, otherwise it will be closed.
|
||||
required: true
|
||||
- label: "Please do not modify this template :) and fill in all the required fields."
|
||||
- label: 'Please do not modify this template :) and fill in all the required fields.'
|
||||
required: true
|
||||
- type: textarea
|
||||
attributes:
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
name: "✨ Refactor or Chore"
|
||||
name: '✨ Refactor or Chore'
|
||||
description: Refactor existing code or perform maintenance chores to improve readability and reliability.
|
||||
title: "[Refactor/Chore] "
|
||||
title: '[Refactor/Chore] '
|
||||
body:
|
||||
- type: checkboxes
|
||||
attributes:
|
||||
label: Self Checks
|
||||
description: "To make sure we get to you in time, please check the following :)"
|
||||
description: 'To make sure we get to you in time, please check the following :)'
|
||||
options:
|
||||
- label: I have read the [Contributing Guide](https://github.com/langgenius/dify/blob/main/CONTRIBUTING.md) and [Language Policy](https://github.com/langgenius/dify/issues/1542).
|
||||
required: true
|
||||
@@ -17,26 +17,26 @@ body:
|
||||
required: true
|
||||
- label: 【中文用户 & Non English User】请使用英语提交,否则会被关闭 :)
|
||||
required: true
|
||||
- label: "Please do not modify this template :) and fill in all the required fields."
|
||||
- label: 'Please do not modify this template :) and fill in all the required fields.'
|
||||
required: true
|
||||
- type: textarea
|
||||
id: description
|
||||
attributes:
|
||||
label: Description
|
||||
placeholder: "Describe the refactor or chore you are proposing."
|
||||
placeholder: 'Describe the refactor or chore you are proposing.'
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: motivation
|
||||
attributes:
|
||||
label: Motivation
|
||||
placeholder: "Explain why this refactor or chore is necessary."
|
||||
placeholder: 'Explain why this refactor or chore is necessary.'
|
||||
validations:
|
||||
required: false
|
||||
- type: textarea
|
||||
id: additional-context
|
||||
attributes:
|
||||
label: Additional Context
|
||||
placeholder: "Add any other context or screenshots about the request here."
|
||||
placeholder: 'Add any other context or screenshots about the request here.'
|
||||
validations:
|
||||
required: false
|
||||
|
||||
+164
-164
@@ -1,223 +1,223 @@
|
||||
version: 2
|
||||
|
||||
updates:
|
||||
- package-ecosystem: "uv"
|
||||
directory: "/api"
|
||||
- package-ecosystem: 'uv'
|
||||
directory: '/api'
|
||||
open-pull-requests-limit: 10
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
interval: 'weekly'
|
||||
groups:
|
||||
flask:
|
||||
patterns:
|
||||
- "flask"
|
||||
- "flask-*"
|
||||
- "werkzeug"
|
||||
- "gunicorn"
|
||||
- 'flask'
|
||||
- 'flask-*'
|
||||
- 'werkzeug'
|
||||
- 'gunicorn'
|
||||
google:
|
||||
patterns:
|
||||
- "google-*"
|
||||
- "googleapis-*"
|
||||
- 'google-*'
|
||||
- 'googleapis-*'
|
||||
opentelemetry:
|
||||
patterns:
|
||||
- "opentelemetry-*"
|
||||
- 'opentelemetry-*'
|
||||
pydantic:
|
||||
patterns:
|
||||
- "pydantic"
|
||||
- "pydantic-*"
|
||||
- 'pydantic'
|
||||
- 'pydantic-*'
|
||||
llm:
|
||||
patterns:
|
||||
- "langfuse"
|
||||
- "langsmith"
|
||||
- "litellm"
|
||||
- "mlflow*"
|
||||
- "opik"
|
||||
- "weave*"
|
||||
- "arize*"
|
||||
- "tiktoken"
|
||||
- "transformers"
|
||||
- 'langfuse'
|
||||
- 'langsmith'
|
||||
- 'litellm'
|
||||
- 'mlflow*'
|
||||
- 'opik'
|
||||
- 'weave*'
|
||||
- 'arize*'
|
||||
- 'tiktoken'
|
||||
- 'transformers'
|
||||
database:
|
||||
patterns:
|
||||
- "sqlalchemy"
|
||||
- "psycopg2*"
|
||||
- "psycogreen"
|
||||
- "redis*"
|
||||
- "alembic*"
|
||||
- 'sqlalchemy'
|
||||
- 'psycopg2*'
|
||||
- 'psycogreen'
|
||||
- 'redis*'
|
||||
- 'alembic*'
|
||||
storage:
|
||||
patterns:
|
||||
- "boto3*"
|
||||
- "botocore*"
|
||||
- "azure-*"
|
||||
- "bce-*"
|
||||
- "cos-python-*"
|
||||
- "esdk-obs-*"
|
||||
- "google-cloud-storage"
|
||||
- "opendal"
|
||||
- "oss2"
|
||||
- "supabase*"
|
||||
- "tos*"
|
||||
- 'boto3*'
|
||||
- 'botocore*'
|
||||
- 'azure-*'
|
||||
- 'bce-*'
|
||||
- 'cos-python-*'
|
||||
- 'esdk-obs-*'
|
||||
- 'google-cloud-storage'
|
||||
- 'opendal'
|
||||
- 'oss2'
|
||||
- 'supabase*'
|
||||
- 'tos*'
|
||||
vdb:
|
||||
patterns:
|
||||
- "alibabacloud*"
|
||||
- "chromadb"
|
||||
- "clickhouse-*"
|
||||
- "clickzetta-*"
|
||||
- "couchbase"
|
||||
- "elasticsearch"
|
||||
- "opensearch-py"
|
||||
- "oracledb"
|
||||
- "pgvect*"
|
||||
- "pymilvus"
|
||||
- "pymochow"
|
||||
- "pyobvector"
|
||||
- "qdrant-client"
|
||||
- "intersystems-*"
|
||||
- "tablestore"
|
||||
- "tcvectordb"
|
||||
- "tidb-vector"
|
||||
- "upstash-*"
|
||||
- "volcengine-*"
|
||||
- "weaviate-*"
|
||||
- "xinference-*"
|
||||
- "mo-vector"
|
||||
- "mysql-connector-*"
|
||||
- 'alibabacloud*'
|
||||
- 'chromadb'
|
||||
- 'clickhouse-*'
|
||||
- 'clickzetta-*'
|
||||
- 'couchbase'
|
||||
- 'elasticsearch'
|
||||
- 'opensearch-py'
|
||||
- 'oracledb'
|
||||
- 'pgvect*'
|
||||
- 'pymilvus'
|
||||
- 'pymochow'
|
||||
- 'pyobvector'
|
||||
- 'qdrant-client'
|
||||
- 'intersystems-*'
|
||||
- 'tablestore'
|
||||
- 'tcvectordb'
|
||||
- 'tidb-vector'
|
||||
- 'upstash-*'
|
||||
- 'volcengine-*'
|
||||
- 'weaviate-*'
|
||||
- 'xinference-*'
|
||||
- 'mo-vector'
|
||||
- 'mysql-connector-*'
|
||||
dev:
|
||||
patterns:
|
||||
- "coverage"
|
||||
- "dotenv-linter"
|
||||
- "faker"
|
||||
- "lxml-stubs"
|
||||
- "basedpyright"
|
||||
- "ruff"
|
||||
- "pytest*"
|
||||
- "types-*"
|
||||
- "boto3-stubs"
|
||||
- "hypothesis"
|
||||
- "pandas-stubs"
|
||||
- "scipy-stubs"
|
||||
- "import-linter"
|
||||
- "celery-types"
|
||||
- "mypy*"
|
||||
- "pyrefly"
|
||||
- 'coverage'
|
||||
- 'dotenv-linter'
|
||||
- 'faker'
|
||||
- 'lxml-stubs'
|
||||
- 'basedpyright'
|
||||
- 'ruff'
|
||||
- 'pytest*'
|
||||
- 'types-*'
|
||||
- 'boto3-stubs'
|
||||
- 'hypothesis'
|
||||
- 'pandas-stubs'
|
||||
- 'scipy-stubs'
|
||||
- 'import-linter'
|
||||
- 'celery-types'
|
||||
- 'mypy*'
|
||||
- 'pyrefly'
|
||||
python-packages:
|
||||
patterns:
|
||||
- "*"
|
||||
- package-ecosystem: "github-actions"
|
||||
directory: "/"
|
||||
- '*'
|
||||
- package-ecosystem: 'github-actions'
|
||||
directory: '/'
|
||||
open-pull-requests-limit: 5
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
interval: 'weekly'
|
||||
groups:
|
||||
github-actions-dependencies:
|
||||
patterns:
|
||||
- "*"
|
||||
- package-ecosystem: "uv"
|
||||
directory: "/api"
|
||||
target-branch: "lts/1.13.x"
|
||||
- '*'
|
||||
- package-ecosystem: 'uv'
|
||||
directory: '/api'
|
||||
target-branch: 'lts/1.13.x'
|
||||
open-pull-requests-limit: 10
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
interval: 'weekly'
|
||||
groups:
|
||||
flask:
|
||||
patterns:
|
||||
- "flask"
|
||||
- "flask-*"
|
||||
- "werkzeug"
|
||||
- "gunicorn"
|
||||
- 'flask'
|
||||
- 'flask-*'
|
||||
- 'werkzeug'
|
||||
- 'gunicorn'
|
||||
google:
|
||||
patterns:
|
||||
- "google-*"
|
||||
- "googleapis-*"
|
||||
- 'google-*'
|
||||
- 'googleapis-*'
|
||||
opentelemetry:
|
||||
patterns:
|
||||
- "opentelemetry-*"
|
||||
- 'opentelemetry-*'
|
||||
pydantic:
|
||||
patterns:
|
||||
- "pydantic"
|
||||
- "pydantic-*"
|
||||
- 'pydantic'
|
||||
- 'pydantic-*'
|
||||
llm:
|
||||
patterns:
|
||||
- "langfuse"
|
||||
- "langsmith"
|
||||
- "litellm"
|
||||
- "mlflow*"
|
||||
- "opik"
|
||||
- "weave*"
|
||||
- "arize*"
|
||||
- "tiktoken"
|
||||
- "transformers"
|
||||
- 'langfuse'
|
||||
- 'langsmith'
|
||||
- 'litellm'
|
||||
- 'mlflow*'
|
||||
- 'opik'
|
||||
- 'weave*'
|
||||
- 'arize*'
|
||||
- 'tiktoken'
|
||||
- 'transformers'
|
||||
database:
|
||||
patterns:
|
||||
- "sqlalchemy"
|
||||
- "psycopg2*"
|
||||
- "psycogreen"
|
||||
- "redis*"
|
||||
- "alembic*"
|
||||
- 'sqlalchemy'
|
||||
- 'psycopg2*'
|
||||
- 'psycogreen'
|
||||
- 'redis*'
|
||||
- 'alembic*'
|
||||
storage:
|
||||
patterns:
|
||||
- "boto3*"
|
||||
- "botocore*"
|
||||
- "azure-*"
|
||||
- "bce-*"
|
||||
- "cos-python-*"
|
||||
- "esdk-obs-*"
|
||||
- "google-cloud-storage"
|
||||
- "opendal"
|
||||
- "oss2"
|
||||
- "supabase*"
|
||||
- "tos*"
|
||||
- 'boto3*'
|
||||
- 'botocore*'
|
||||
- 'azure-*'
|
||||
- 'bce-*'
|
||||
- 'cos-python-*'
|
||||
- 'esdk-obs-*'
|
||||
- 'google-cloud-storage'
|
||||
- 'opendal'
|
||||
- 'oss2'
|
||||
- 'supabase*'
|
||||
- 'tos*'
|
||||
vdb:
|
||||
patterns:
|
||||
- "alibabacloud*"
|
||||
- "chromadb"
|
||||
- "clickhouse-*"
|
||||
- "clickzetta-*"
|
||||
- "couchbase"
|
||||
- "elasticsearch"
|
||||
- "opensearch-py"
|
||||
- "oracledb"
|
||||
- "pgvect*"
|
||||
- "pymilvus"
|
||||
- "pymochow"
|
||||
- "pyobvector"
|
||||
- "qdrant-client"
|
||||
- "intersystems-*"
|
||||
- "tablestore"
|
||||
- "tcvectordb"
|
||||
- "tidb-vector"
|
||||
- "upstash-*"
|
||||
- "volcengine-*"
|
||||
- "weaviate-*"
|
||||
- "xinference-*"
|
||||
- "mo-vector"
|
||||
- "mysql-connector-*"
|
||||
- 'alibabacloud*'
|
||||
- 'chromadb'
|
||||
- 'clickhouse-*'
|
||||
- 'clickzetta-*'
|
||||
- 'couchbase'
|
||||
- 'elasticsearch'
|
||||
- 'opensearch-py'
|
||||
- 'oracledb'
|
||||
- 'pgvect*'
|
||||
- 'pymilvus'
|
||||
- 'pymochow'
|
||||
- 'pyobvector'
|
||||
- 'qdrant-client'
|
||||
- 'intersystems-*'
|
||||
- 'tablestore'
|
||||
- 'tcvectordb'
|
||||
- 'tidb-vector'
|
||||
- 'upstash-*'
|
||||
- 'volcengine-*'
|
||||
- 'weaviate-*'
|
||||
- 'xinference-*'
|
||||
- 'mo-vector'
|
||||
- 'mysql-connector-*'
|
||||
dev:
|
||||
patterns:
|
||||
- "coverage"
|
||||
- "dotenv-linter"
|
||||
- "faker"
|
||||
- "lxml-stubs"
|
||||
- "basedpyright"
|
||||
- "ruff"
|
||||
- "pytest*"
|
||||
- "types-*"
|
||||
- "boto3-stubs"
|
||||
- "hypothesis"
|
||||
- "pandas-stubs"
|
||||
- "scipy-stubs"
|
||||
- "import-linter"
|
||||
- "celery-types"
|
||||
- "mypy*"
|
||||
- "pyrefly"
|
||||
- 'coverage'
|
||||
- 'dotenv-linter'
|
||||
- 'faker'
|
||||
- 'lxml-stubs'
|
||||
- 'basedpyright'
|
||||
- 'ruff'
|
||||
- 'pytest*'
|
||||
- 'types-*'
|
||||
- 'boto3-stubs'
|
||||
- 'hypothesis'
|
||||
- 'pandas-stubs'
|
||||
- 'scipy-stubs'
|
||||
- 'import-linter'
|
||||
- 'celery-types'
|
||||
- 'mypy*'
|
||||
- 'pyrefly'
|
||||
python-packages:
|
||||
patterns:
|
||||
- "*"
|
||||
- package-ecosystem: "github-actions"
|
||||
directory: "/"
|
||||
target-branch: "lts/1.13.x"
|
||||
- '*'
|
||||
- package-ecosystem: 'github-actions'
|
||||
directory: '/'
|
||||
target-branch: 'lts/1.13.x'
|
||||
open-pull-requests-limit: 5
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
interval: 'weekly'
|
||||
groups:
|
||||
github-actions-dependencies:
|
||||
patterns:
|
||||
- "*"
|
||||
- '*'
|
||||
|
||||
@@ -1 +1 @@
|
||||
failure-threshold: "error"
|
||||
failure-threshold: 'error'
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
---
|
||||
|
||||
extends: default
|
||||
|
||||
rules:
|
||||
|
||||
@@ -1,22 +1,22 @@
|
||||
{
|
||||
"Verbose": false,
|
||||
"Debug": false,
|
||||
"IgnoreDefaults": false,
|
||||
"SpacesAfterTabs": false,
|
||||
"NoColor": false,
|
||||
"Exclude": [
|
||||
"^web/public/vs/",
|
||||
"^web/public/pdf.worker.min.mjs$",
|
||||
"web/app/components/base/icons/src/vender/"
|
||||
],
|
||||
"AllowedContentTypes": [],
|
||||
"PassedFiles": [],
|
||||
"Disable": {
|
||||
"EndOfLine": false,
|
||||
"Indentation": false,
|
||||
"IndentSize": true,
|
||||
"InsertFinalNewline": false,
|
||||
"TrimTrailingWhitespace": false,
|
||||
"MaxLineLength": false
|
||||
}
|
||||
"Verbose": false,
|
||||
"Debug": false,
|
||||
"IgnoreDefaults": false,
|
||||
"SpacesAfterTabs": false,
|
||||
"NoColor": false,
|
||||
"Exclude": [
|
||||
"^web/public/vs/",
|
||||
"^web/public/pdf.worker.min.mjs$",
|
||||
"web/app/components/base/icons/src/vender/"
|
||||
],
|
||||
"AllowedContentTypes": [],
|
||||
"PassedFiles": [],
|
||||
"Disable": {
|
||||
"EndOfLine": false,
|
||||
"Indentation": false,
|
||||
"IndentSize": true,
|
||||
"InsertFinalNewline": false,
|
||||
"TrimTrailingWhitespace": false,
|
||||
"MaxLineLength": false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,8 +12,8 @@
|
||||
## Screenshots
|
||||
|
||||
| Before | After |
|
||||
|--------|-------|
|
||||
| ... | ... |
|
||||
| ------ | ----- |
|
||||
| ... | ... |
|
||||
|
||||
## Checklist
|
||||
|
||||
|
||||
@@ -8,31 +8,31 @@ 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 englishPath = (fileStem) => path.join(repoRoot, 'web', 'i18n', 'en-US', `${fileStem}.json`)
|
||||
|
||||
const readCurrentJson = (fileStem) => {
|
||||
const filePath = englishPath(fileStem)
|
||||
if (!fs.existsSync(filePath))
|
||||
return null
|
||||
if (!fs.existsSync(filePath)) return null
|
||||
|
||||
return JSON.parse(fs.readFileSync(filePath, 'utf8'))
|
||||
}
|
||||
|
||||
const readBaseJson = (fileStem) => {
|
||||
if (!baseSha)
|
||||
return null
|
||||
if (!baseSha) return null
|
||||
|
||||
try {
|
||||
const relativePath = `web/i18n/en-US/${fileStem}.json`
|
||||
const content = execFileSync('git', ['show', `${baseSha}:${relativePath}`], { encoding: 'utf8' })
|
||||
const content = execFileSync('git', ['show', `${baseSha}:${relativePath}`], {
|
||||
encoding: 'utf8',
|
||||
})
|
||||
return JSON.parse(content)
|
||||
}
|
||||
catch {
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
const compareJson = (beforeValue, afterValue) => JSON.stringify(beforeValue) === JSON.stringify(afterValue)
|
||||
const compareJson = (beforeValue, afterValue) =>
|
||||
JSON.stringify(beforeValue) === JSON.stringify(afterValue)
|
||||
|
||||
const changes = {}
|
||||
|
||||
@@ -59,8 +59,7 @@ for (const fileStem of files) {
|
||||
}
|
||||
|
||||
for (const key of Object.keys(beforeJson)) {
|
||||
if (!(key in afterJson))
|
||||
deleted.push(key)
|
||||
if (!(key in afterJson)) deleted.push(key)
|
||||
}
|
||||
|
||||
changes[fileStem] = {
|
||||
@@ -78,5 +77,5 @@ fs.writeFileSync(
|
||||
headSha,
|
||||
files,
|
||||
changes,
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -25,7 +25,7 @@ jobs:
|
||||
strategy:
|
||||
matrix:
|
||||
python-version:
|
||||
- "3.12"
|
||||
- '3.12'
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
@@ -87,7 +87,7 @@ jobs:
|
||||
strategy:
|
||||
matrix:
|
||||
python-version:
|
||||
- "3.12"
|
||||
- '3.12'
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
@@ -151,7 +151,7 @@ jobs:
|
||||
uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0
|
||||
with:
|
||||
enable-cache: true
|
||||
python-version: "3.12"
|
||||
python-version: '3.12'
|
||||
cache-dependency-glob: api/uv.lock
|
||||
|
||||
- name: Install dependencies
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
name: autofix.ci
|
||||
on:
|
||||
pull_request:
|
||||
branches: ["main"]
|
||||
branches: ['main']
|
||||
merge_group:
|
||||
branches: ["main"]
|
||||
branches: ['main']
|
||||
types: [checks_requested]
|
||||
push:
|
||||
branches: ["main"]
|
||||
branches: ['main']
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
@@ -44,6 +44,12 @@ jobs:
|
||||
pnpm-lock.yaml
|
||||
pnpm-workspace.yaml
|
||||
.nvmrc
|
||||
vite.config.ts
|
||||
eslint.config.mjs
|
||||
web/eslint.config.mjs
|
||||
.vscode/**
|
||||
.github/workflows/autofix.yml
|
||||
.github/workflows/style.yml
|
||||
- name: Check api inputs
|
||||
if: github.event_name != 'merge_group'
|
||||
id: api-changes
|
||||
@@ -63,7 +69,7 @@ jobs:
|
||||
- if: github.event_name != 'merge_group'
|
||||
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
|
||||
with:
|
||||
python-version: "3.11"
|
||||
python-version: '3.11'
|
||||
|
||||
- if: github.event_name != 'merge_group'
|
||||
uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0
|
||||
@@ -146,6 +152,12 @@ jobs:
|
||||
if: github.event_name != 'merge_group' && steps.api-changes.outputs.any_changed == 'true'
|
||||
run: pnpm --dir packages/contracts gen-api-contract-from-openapi
|
||||
|
||||
- name: Format web files
|
||||
if: github.event_name != 'merge_group' && steps.web-changes.outputs.any_changed == 'true'
|
||||
env:
|
||||
CHANGED_FILES: ${{ steps.web-changes.outputs.all_changed_files }}
|
||||
run: vp fmt --no-error-on-unmatched-pattern $CHANGED_FILES
|
||||
|
||||
- name: ESLint autofix
|
||||
if: github.event_name != 'merge_group' && steps.web-changes.outputs.any_changed == 'true'
|
||||
run: |
|
||||
|
||||
@@ -3,14 +3,14 @@ name: Build and Push API & Web
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- "main"
|
||||
- "deploy/**"
|
||||
- "build/**"
|
||||
- "release/e-*"
|
||||
- "hotfix/**"
|
||||
- "feat/hitl-backend"
|
||||
- 'main'
|
||||
- 'deploy/**'
|
||||
- 'build/**'
|
||||
- 'release/e-*'
|
||||
- 'hotfix/**'
|
||||
- 'feat/hitl-backend'
|
||||
tags:
|
||||
- "*"
|
||||
- '*'
|
||||
|
||||
concurrency:
|
||||
group: build-push-${{ github.head_ref || github.run_id }}
|
||||
@@ -32,32 +32,32 @@ jobs:
|
||||
strategy:
|
||||
matrix:
|
||||
include:
|
||||
- service_name: "build-api-amd64"
|
||||
image_name_env: "DIFY_API_IMAGE_NAME"
|
||||
artifact_context: "api"
|
||||
build_context: "{{defaultContext}}"
|
||||
file: "api/Dockerfile"
|
||||
- service_name: 'build-api-amd64'
|
||||
image_name_env: 'DIFY_API_IMAGE_NAME'
|
||||
artifact_context: 'api'
|
||||
build_context: '{{defaultContext}}'
|
||||
file: 'api/Dockerfile'
|
||||
platform: linux/amd64
|
||||
runs_on: depot-ubuntu-24.04-4
|
||||
- service_name: "build-api-arm64"
|
||||
image_name_env: "DIFY_API_IMAGE_NAME"
|
||||
artifact_context: "api"
|
||||
build_context: "{{defaultContext}}"
|
||||
file: "api/Dockerfile"
|
||||
- service_name: 'build-api-arm64'
|
||||
image_name_env: 'DIFY_API_IMAGE_NAME'
|
||||
artifact_context: 'api'
|
||||
build_context: '{{defaultContext}}'
|
||||
file: 'api/Dockerfile'
|
||||
platform: linux/arm64
|
||||
runs_on: depot-ubuntu-24.04-4
|
||||
- service_name: "build-web-amd64"
|
||||
image_name_env: "DIFY_WEB_IMAGE_NAME"
|
||||
artifact_context: "web"
|
||||
build_context: "{{defaultContext}}"
|
||||
file: "web/Dockerfile"
|
||||
- service_name: 'build-web-amd64'
|
||||
image_name_env: 'DIFY_WEB_IMAGE_NAME'
|
||||
artifact_context: 'web'
|
||||
build_context: '{{defaultContext}}'
|
||||
file: 'web/Dockerfile'
|
||||
platform: linux/amd64
|
||||
runs_on: depot-ubuntu-24.04-4
|
||||
- service_name: "build-web-arm64"
|
||||
image_name_env: "DIFY_WEB_IMAGE_NAME"
|
||||
artifact_context: "web"
|
||||
build_context: "{{defaultContext}}"
|
||||
file: "web/Dockerfile"
|
||||
- service_name: 'build-web-arm64'
|
||||
image_name_env: 'DIFY_WEB_IMAGE_NAME'
|
||||
artifact_context: 'web'
|
||||
build_context: '{{defaultContext}}'
|
||||
file: 'web/Dockerfile'
|
||||
platform: linux/arm64
|
||||
runs_on: depot-ubuntu-24.04-4
|
||||
|
||||
@@ -116,12 +116,12 @@ jobs:
|
||||
strategy:
|
||||
matrix:
|
||||
include:
|
||||
- service_name: "validate-api-amd64"
|
||||
build_context: "{{defaultContext}}"
|
||||
file: "api/Dockerfile"
|
||||
- service_name: "validate-web-amd64"
|
||||
build_context: "{{defaultContext}}"
|
||||
file: "web/Dockerfile"
|
||||
- service_name: 'validate-api-amd64'
|
||||
build_context: '{{defaultContext}}'
|
||||
file: 'api/Dockerfile'
|
||||
- service_name: 'validate-web-amd64'
|
||||
build_context: '{{defaultContext}}'
|
||||
file: 'web/Dockerfile'
|
||||
steps:
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0
|
||||
@@ -141,12 +141,12 @@ jobs:
|
||||
strategy:
|
||||
matrix:
|
||||
include:
|
||||
- service_name: "merge-api-images"
|
||||
image_name_env: "DIFY_API_IMAGE_NAME"
|
||||
context: "api"
|
||||
- service_name: "merge-web-images"
|
||||
image_name_env: "DIFY_WEB_IMAGE_NAME"
|
||||
context: "web"
|
||||
- service_name: 'merge-api-images'
|
||||
image_name_env: 'DIFY_API_IMAGE_NAME'
|
||||
context: 'api'
|
||||
- service_name: 'merge-web-images'
|
||||
image_name_env: 'DIFY_WEB_IMAGE_NAME'
|
||||
context: 'web'
|
||||
steps:
|
||||
- name: Download digests
|
||||
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||
|
||||
+108
-109
@@ -4,19 +4,19 @@ on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
cli_ref:
|
||||
description: "Git ref (default: current branch)"
|
||||
description: 'Git ref (default: current branch)'
|
||||
type: string
|
||||
required: false
|
||||
|
||||
edition:
|
||||
description: "Dify edition"
|
||||
description: 'Dify edition'
|
||||
type: choice
|
||||
required: false
|
||||
default: ee
|
||||
options: [ee, ce]
|
||||
|
||||
test_scope:
|
||||
description: "smoke = [P0] only / full = all cases"
|
||||
description: 'smoke = [P0] only / full = all cases'
|
||||
type: choice
|
||||
required: false
|
||||
default: full
|
||||
@@ -24,23 +24,23 @@ on:
|
||||
|
||||
# ── Suite on/off ────────────────────────────────────────────────────────
|
||||
suite_framework_output_error:
|
||||
description: "framework + output + error-handling suites"
|
||||
description: 'framework + output + error-handling suites'
|
||||
type: boolean
|
||||
default: true
|
||||
suite_discovery:
|
||||
description: "discovery suite (get app / describe app)"
|
||||
description: 'discovery suite (get app / describe app)'
|
||||
type: boolean
|
||||
default: true
|
||||
suite_run:
|
||||
description: "run suite (basic / streaming / conversation / file / hitl)"
|
||||
description: 'run suite (basic / streaming / conversation / file / hitl)'
|
||||
type: boolean
|
||||
default: true
|
||||
suite_auth:
|
||||
description: "auth suite (login / status / whoami / use / devices / logout)"
|
||||
description: 'auth suite (login / status / whoami / use / devices / logout)'
|
||||
type: boolean
|
||||
default: true
|
||||
suite_agent:
|
||||
description: "agent suite"
|
||||
description: 'agent suite'
|
||||
type: boolean
|
||||
default: true
|
||||
|
||||
@@ -51,32 +51,31 @@ permissions:
|
||||
# Each job reads DIFY_E2E_TOKEN + app IDs from the provision job outputs,
|
||||
# so global-setup skips minting and finds existing apps in < 10 s.
|
||||
env:
|
||||
DIFY_E2E_NO_KEYRING: "1" # Linux CI has no keychain; skip probe
|
||||
VITEST_RETRY: "2" # Retry flaky staging responses
|
||||
DIFY_E2E_NO_KEYRING: '1' # Linux CI has no keychain; skip probe
|
||||
VITEST_RETRY: '2' # Retry flaky staging responses
|
||||
|
||||
jobs:
|
||||
|
||||
# ════════════════════════════════════════════════════════════════════════════
|
||||
# 0. PROVISION — mint token + import DSL fixtures (runs once, outputs IDs)
|
||||
# ════════════════════════════════════════════════════════════════════════════
|
||||
# ════════════════════════════════════════════════════════════════════════════
|
||||
# 0. PROVISION — mint token + import DSL fixtures (runs once, outputs IDs)
|
||||
# ════════════════════════════════════════════════════════════════════════════
|
||||
provision:
|
||||
name: "Provision: mint token + DSL apps"
|
||||
name: 'Provision: mint token + DSL apps'
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
outputs:
|
||||
token: ${{ steps.out.outputs.DIFY_E2E_TOKEN }}
|
||||
workspace_id: ${{ steps.out.outputs.DIFY_E2E_WORKSPACE_ID }}
|
||||
workspace_name: ${{ steps.out.outputs.DIFY_E2E_WORKSPACE_NAME }}
|
||||
ws2_id: ${{ steps.out.outputs.DIFY_E2E_WS2_ID }}
|
||||
chat_app_id: ${{ steps.out.outputs.DIFY_E2E_CHAT_APP_ID }}
|
||||
workflow_app_id: ${{ steps.out.outputs.DIFY_E2E_WORKFLOW_APP_ID }}
|
||||
file_app_id: ${{ steps.out.outputs.DIFY_E2E_FILE_APP_ID }}
|
||||
file_chat_app_id: ${{ steps.out.outputs.DIFY_E2E_FILE_CHAT_APP_ID }}
|
||||
hitl_app_id: ${{ steps.out.outputs.DIFY_E2E_HITL_APP_ID }}
|
||||
hitl_external_app_id: ${{ steps.out.outputs.DIFY_E2E_HITL_EXTERNAL_APP_ID }}
|
||||
hitl_single_action_app_id: ${{ steps.out.outputs.DIFY_E2E_HITL_SINGLE_ACTION_APP_ID }}
|
||||
hitl_multi_node_app_id: ${{ steps.out.outputs.DIFY_E2E_HITL_MULTI_NODE_APP_ID }}
|
||||
ws2_app_id: ${{ steps.out.outputs.DIFY_E2E_WS2_APP_ID }}
|
||||
token: ${{ steps.out.outputs.DIFY_E2E_TOKEN }}
|
||||
workspace_id: ${{ steps.out.outputs.DIFY_E2E_WORKSPACE_ID }}
|
||||
workspace_name: ${{ steps.out.outputs.DIFY_E2E_WORKSPACE_NAME }}
|
||||
ws2_id: ${{ steps.out.outputs.DIFY_E2E_WS2_ID }}
|
||||
chat_app_id: ${{ steps.out.outputs.DIFY_E2E_CHAT_APP_ID }}
|
||||
workflow_app_id: ${{ steps.out.outputs.DIFY_E2E_WORKFLOW_APP_ID }}
|
||||
file_app_id: ${{ steps.out.outputs.DIFY_E2E_FILE_APP_ID }}
|
||||
file_chat_app_id: ${{ steps.out.outputs.DIFY_E2E_FILE_CHAT_APP_ID }}
|
||||
hitl_app_id: ${{ steps.out.outputs.DIFY_E2E_HITL_APP_ID }}
|
||||
hitl_external_app_id: ${{ steps.out.outputs.DIFY_E2E_HITL_EXTERNAL_APP_ID }}
|
||||
hitl_single_action_app_id: ${{ steps.out.outputs.DIFY_E2E_HITL_SINGLE_ACTION_APP_ID }}
|
||||
hitl_multi_node_app_id: ${{ steps.out.outputs.DIFY_E2E_HITL_MULTI_NODE_APP_ID }}
|
||||
ws2_app_id: ${{ steps.out.outputs.DIFY_E2E_WS2_APP_ID }}
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v4
|
||||
@@ -101,18 +100,18 @@ jobs:
|
||||
id: out
|
||||
working-directory: cli
|
||||
env:
|
||||
DIFY_E2E_HOST: ${{ secrets.DIFY_E2E_HOST }}
|
||||
DIFY_E2E_EMAIL: ${{ secrets.DIFY_E2E_EMAIL }}
|
||||
DIFY_E2E_HOST: ${{ secrets.DIFY_E2E_HOST }}
|
||||
DIFY_E2E_EMAIL: ${{ secrets.DIFY_E2E_EMAIL }}
|
||||
DIFY_E2E_PASSWORD: ${{ secrets.DIFY_E2E_PASSWORD }}
|
||||
DIFY_E2E_TOKEN: ${{ secrets.DIFY_E2E_TOKEN }}
|
||||
DIFY_E2E_EDITION: ${{ inputs.edition || 'ee' }}
|
||||
DIFY_E2E_TOKEN: ${{ secrets.DIFY_E2E_TOKEN }}
|
||||
DIFY_E2E_EDITION: ${{ inputs.edition || 'ee' }}
|
||||
run: bun scripts/e2e-provision.ts
|
||||
|
||||
# ════════════════════════════════════════════════════════════════════════════
|
||||
# 1-B. framework + output + error-handling (parallel with run/discovery)
|
||||
# ════════════════════════════════════════════════════════════════════════════
|
||||
# ════════════════════════════════════════════════════════════════════════════
|
||||
# 1-B. framework + output + error-handling (parallel with run/discovery)
|
||||
# ════════════════════════════════════════════════════════════════════════════
|
||||
suite-framework-output-error:
|
||||
name: "Suite: framework + output + error-handling"
|
||||
name: 'Suite: framework + output + error-handling'
|
||||
if: ${{ inputs.suite_framework_output_error != 'false' }}
|
||||
needs: provision
|
||||
runs-on: ubuntu-latest
|
||||
@@ -138,16 +137,16 @@ jobs:
|
||||
|
||||
- name: Run framework + output + error-handling
|
||||
env:
|
||||
DIFY_E2E_HOST: ${{ secrets.DIFY_E2E_HOST }}
|
||||
DIFY_E2E_EMAIL: ${{ secrets.DIFY_E2E_EMAIL }}
|
||||
DIFY_E2E_PASSWORD: ${{ secrets.DIFY_E2E_PASSWORD }}
|
||||
DIFY_E2E_EDITION: ${{ inputs.edition || 'ee' }}
|
||||
DIFY_E2E_TOKEN: ${{ needs.provision.outputs.token }}
|
||||
DIFY_E2E_WORKSPACE_ID: ${{ needs.provision.outputs.workspace_id }}
|
||||
DIFY_E2E_HOST: ${{ secrets.DIFY_E2E_HOST }}
|
||||
DIFY_E2E_EMAIL: ${{ secrets.DIFY_E2E_EMAIL }}
|
||||
DIFY_E2E_PASSWORD: ${{ secrets.DIFY_E2E_PASSWORD }}
|
||||
DIFY_E2E_EDITION: ${{ inputs.edition || 'ee' }}
|
||||
DIFY_E2E_TOKEN: ${{ needs.provision.outputs.token }}
|
||||
DIFY_E2E_WORKSPACE_ID: ${{ needs.provision.outputs.workspace_id }}
|
||||
DIFY_E2E_WORKSPACE_NAME: ${{ needs.provision.outputs.workspace_name }}
|
||||
DIFY_E2E_CHAT_APP_ID: ${{ needs.provision.outputs.chat_app_id }}
|
||||
DIFY_E2E_CHAT_APP_ID: ${{ needs.provision.outputs.chat_app_id }}
|
||||
DIFY_E2E_WORKFLOW_APP_ID: ${{ needs.provision.outputs.workflow_app_id }}
|
||||
DIFY_E2E_INCLUDE: "test/e2e/suites/framework/**/*.e2e.ts,test/e2e/suites/output/**/*.e2e.ts,test/e2e/suites/error-handling/**/*.e2e.ts"
|
||||
DIFY_E2E_INCLUDE: 'test/e2e/suites/framework/**/*.e2e.ts,test/e2e/suites/output/**/*.e2e.ts,test/e2e/suites/error-handling/**/*.e2e.ts'
|
||||
run: |
|
||||
if [ "${{ inputs.test_scope }}" = "smoke" ]; then
|
||||
pnpm test:e2e -- -t "\[P0\]"
|
||||
@@ -155,11 +154,11 @@ jobs:
|
||||
pnpm test:e2e
|
||||
fi
|
||||
|
||||
# ════════════════════════════════════════════════════════════════════════════
|
||||
# 1-C. Discovery (parallel)
|
||||
# ════════════════════════════════════════════════════════════════════════════
|
||||
# ════════════════════════════════════════════════════════════════════════════
|
||||
# 1-C. Discovery (parallel)
|
||||
# ════════════════════════════════════════════════════════════════════════════
|
||||
suite-discovery:
|
||||
name: "Suite: discovery"
|
||||
name: 'Suite: discovery'
|
||||
if: ${{ inputs.suite_discovery != 'false' }}
|
||||
needs: provision
|
||||
runs-on: ubuntu-latest
|
||||
@@ -185,17 +184,17 @@ jobs:
|
||||
|
||||
- name: Run discovery suite
|
||||
env:
|
||||
DIFY_E2E_HOST: ${{ secrets.DIFY_E2E_HOST }}
|
||||
DIFY_E2E_EMAIL: ${{ secrets.DIFY_E2E_EMAIL }}
|
||||
DIFY_E2E_PASSWORD: ${{ secrets.DIFY_E2E_PASSWORD }}
|
||||
DIFY_E2E_EDITION: ${{ inputs.edition || 'ee' }}
|
||||
DIFY_E2E_TOKEN: ${{ needs.provision.outputs.token }}
|
||||
DIFY_E2E_WORKSPACE_ID: ${{ needs.provision.outputs.workspace_id }}
|
||||
DIFY_E2E_HOST: ${{ secrets.DIFY_E2E_HOST }}
|
||||
DIFY_E2E_EMAIL: ${{ secrets.DIFY_E2E_EMAIL }}
|
||||
DIFY_E2E_PASSWORD: ${{ secrets.DIFY_E2E_PASSWORD }}
|
||||
DIFY_E2E_EDITION: ${{ inputs.edition || 'ee' }}
|
||||
DIFY_E2E_TOKEN: ${{ needs.provision.outputs.token }}
|
||||
DIFY_E2E_WORKSPACE_ID: ${{ needs.provision.outputs.workspace_id }}
|
||||
DIFY_E2E_WORKSPACE_NAME: ${{ needs.provision.outputs.workspace_name }}
|
||||
DIFY_E2E_WS2_ID: ${{ needs.provision.outputs.ws2_id }}
|
||||
DIFY_E2E_CHAT_APP_ID: ${{ needs.provision.outputs.chat_app_id }}
|
||||
DIFY_E2E_WS2_ID: ${{ needs.provision.outputs.ws2_id }}
|
||||
DIFY_E2E_CHAT_APP_ID: ${{ needs.provision.outputs.chat_app_id }}
|
||||
DIFY_E2E_WORKFLOW_APP_ID: ${{ needs.provision.outputs.workflow_app_id }}
|
||||
DIFY_E2E_INCLUDE: "test/e2e/suites/discovery/**/*.e2e.ts"
|
||||
DIFY_E2E_INCLUDE: 'test/e2e/suites/discovery/**/*.e2e.ts'
|
||||
run: |
|
||||
if [ "${{ inputs.test_scope }}" = "smoke" ]; then
|
||||
pnpm test:e2e -- -t "\[P0\]"
|
||||
@@ -203,11 +202,11 @@ jobs:
|
||||
pnpm test:e2e
|
||||
fi
|
||||
|
||||
# ════════════════════════════════════════════════════════════════════════════
|
||||
# 1-D. Run suite — 5 files in matrix (parallel)
|
||||
# ════════════════════════════════════════════════════════════════════════════
|
||||
# ════════════════════════════════════════════════════════════════════════════
|
||||
# 1-D. Run suite — 5 files in matrix (parallel)
|
||||
# ════════════════════════════════════════════════════════════════════════════
|
||||
suite-run:
|
||||
name: "Suite: run / ${{ matrix.name }}"
|
||||
name: 'Suite: run / ${{ matrix.name }}'
|
||||
if: ${{ inputs.suite_run != 'false' }}
|
||||
needs: provision
|
||||
runs-on: ubuntu-latest
|
||||
@@ -246,25 +245,25 @@ jobs:
|
||||
- run: pnpm install --frozen-lockfile
|
||||
- run: pnpm tree:gen
|
||||
|
||||
- name: "Run run/${{ matrix.name }}"
|
||||
- name: 'Run run/${{ matrix.name }}'
|
||||
env:
|
||||
DIFY_E2E_HOST: ${{ secrets.DIFY_E2E_HOST }}
|
||||
DIFY_E2E_EMAIL: ${{ secrets.DIFY_E2E_EMAIL }}
|
||||
DIFY_E2E_PASSWORD: ${{ secrets.DIFY_E2E_PASSWORD }}
|
||||
DIFY_E2E_EDITION: ${{ inputs.edition || 'ee' }}
|
||||
DIFY_E2E_SSO_TOKEN: ${{ secrets.DIFY_E2E_SSO_TOKEN }}
|
||||
DIFY_E2E_TOKEN: ${{ needs.provision.outputs.token }}
|
||||
DIFY_E2E_WORKSPACE_ID: ${{ needs.provision.outputs.workspace_id }}
|
||||
DIFY_E2E_WORKSPACE_NAME: ${{ needs.provision.outputs.workspace_name }}
|
||||
DIFY_E2E_CHAT_APP_ID: ${{ needs.provision.outputs.chat_app_id }}
|
||||
DIFY_E2E_WORKFLOW_APP_ID: ${{ needs.provision.outputs.workflow_app_id }}
|
||||
DIFY_E2E_FILE_APP_ID: ${{ needs.provision.outputs.file_app_id }}
|
||||
DIFY_E2E_FILE_CHAT_APP_ID: ${{ needs.provision.outputs.file_chat_app_id }}
|
||||
DIFY_E2E_HITL_APP_ID: ${{ needs.provision.outputs.hitl_app_id }}
|
||||
DIFY_E2E_HITL_EXTERNAL_APP_ID: ${{ needs.provision.outputs.hitl_external_app_id }}
|
||||
DIFY_E2E_HOST: ${{ secrets.DIFY_E2E_HOST }}
|
||||
DIFY_E2E_EMAIL: ${{ secrets.DIFY_E2E_EMAIL }}
|
||||
DIFY_E2E_PASSWORD: ${{ secrets.DIFY_E2E_PASSWORD }}
|
||||
DIFY_E2E_EDITION: ${{ inputs.edition || 'ee' }}
|
||||
DIFY_E2E_SSO_TOKEN: ${{ secrets.DIFY_E2E_SSO_TOKEN }}
|
||||
DIFY_E2E_TOKEN: ${{ needs.provision.outputs.token }}
|
||||
DIFY_E2E_WORKSPACE_ID: ${{ needs.provision.outputs.workspace_id }}
|
||||
DIFY_E2E_WORKSPACE_NAME: ${{ needs.provision.outputs.workspace_name }}
|
||||
DIFY_E2E_CHAT_APP_ID: ${{ needs.provision.outputs.chat_app_id }}
|
||||
DIFY_E2E_WORKFLOW_APP_ID: ${{ needs.provision.outputs.workflow_app_id }}
|
||||
DIFY_E2E_FILE_APP_ID: ${{ needs.provision.outputs.file_app_id }}
|
||||
DIFY_E2E_FILE_CHAT_APP_ID: ${{ needs.provision.outputs.file_chat_app_id }}
|
||||
DIFY_E2E_HITL_APP_ID: ${{ needs.provision.outputs.hitl_app_id }}
|
||||
DIFY_E2E_HITL_EXTERNAL_APP_ID: ${{ needs.provision.outputs.hitl_external_app_id }}
|
||||
DIFY_E2E_HITL_SINGLE_ACTION_APP_ID: ${{ needs.provision.outputs.hitl_single_action_app_id }}
|
||||
DIFY_E2E_HITL_MULTI_NODE_APP_ID: ${{ needs.provision.outputs.hitl_multi_node_app_id }}
|
||||
DIFY_E2E_INCLUDE: "test/e2e/suites/run/${{ matrix.file }}"
|
||||
DIFY_E2E_HITL_MULTI_NODE_APP_ID: ${{ needs.provision.outputs.hitl_multi_node_app_id }}
|
||||
DIFY_E2E_INCLUDE: 'test/e2e/suites/run/${{ matrix.file }}'
|
||||
run: |
|
||||
if [ "${{ inputs.test_scope }}" = "smoke" ]; then
|
||||
pnpm test:e2e -- -t "\[P0\]"
|
||||
@@ -280,11 +279,11 @@ jobs:
|
||||
path: cli/test-results/
|
||||
retention-days: 3
|
||||
|
||||
# ════════════════════════════════════════════════════════════════════════════
|
||||
# 1-E. auth/login + status + whoami (parallel, read-only, safe)
|
||||
# ════════════════════════════════════════════════════════════════════════════
|
||||
# ════════════════════════════════════════════════════════════════════════════
|
||||
# 1-E. auth/login + status + whoami (parallel, read-only, safe)
|
||||
# ════════════════════════════════════════════════════════════════════════════
|
||||
suite-auth-safe:
|
||||
name: "Suite: auth (login / status / whoami)"
|
||||
name: 'Suite: auth (login / status / whoami)'
|
||||
if: ${{ inputs.suite_auth != 'false' }}
|
||||
needs: provision
|
||||
runs-on: ubuntu-latest
|
||||
@@ -310,15 +309,15 @@ jobs:
|
||||
|
||||
- name: Run auth/login + status + whoami
|
||||
env:
|
||||
DIFY_E2E_HOST: ${{ secrets.DIFY_E2E_HOST }}
|
||||
DIFY_E2E_EMAIL: ${{ secrets.DIFY_E2E_EMAIL }}
|
||||
DIFY_E2E_PASSWORD: ${{ secrets.DIFY_E2E_PASSWORD }}
|
||||
DIFY_E2E_EDITION: ${{ inputs.edition || 'ee' }}
|
||||
DIFY_E2E_TOKEN: ${{ needs.provision.outputs.token }}
|
||||
DIFY_E2E_WORKSPACE_ID: ${{ needs.provision.outputs.workspace_id }}
|
||||
DIFY_E2E_HOST: ${{ secrets.DIFY_E2E_HOST }}
|
||||
DIFY_E2E_EMAIL: ${{ secrets.DIFY_E2E_EMAIL }}
|
||||
DIFY_E2E_PASSWORD: ${{ secrets.DIFY_E2E_PASSWORD }}
|
||||
DIFY_E2E_EDITION: ${{ inputs.edition || 'ee' }}
|
||||
DIFY_E2E_TOKEN: ${{ needs.provision.outputs.token }}
|
||||
DIFY_E2E_WORKSPACE_ID: ${{ needs.provision.outputs.workspace_id }}
|
||||
DIFY_E2E_WORKSPACE_NAME: ${{ needs.provision.outputs.workspace_name }}
|
||||
DIFY_E2E_WS2_ID: ${{ needs.provision.outputs.ws2_id }}
|
||||
DIFY_E2E_INCLUDE: "test/e2e/suites/auth/login.e2e.ts,test/e2e/suites/auth/status.e2e.ts,test/e2e/suites/auth/whoami.e2e.ts"
|
||||
DIFY_E2E_WS2_ID: ${{ needs.provision.outputs.ws2_id }}
|
||||
DIFY_E2E_INCLUDE: 'test/e2e/suites/auth/login.e2e.ts,test/e2e/suites/auth/status.e2e.ts,test/e2e/suites/auth/whoami.e2e.ts'
|
||||
run: |
|
||||
if [ "${{ inputs.test_scope }}" = "smoke" ]; then
|
||||
pnpm test:e2e -- -t "\[P0\]"
|
||||
@@ -326,13 +325,13 @@ jobs:
|
||||
pnpm test:e2e
|
||||
fi
|
||||
|
||||
# ════════════════════════════════════════════════════════════════════════════
|
||||
# 2. DESTRUCTIVE — auth/use + devices + logout + agent (serial, runs LAST)
|
||||
# Must wait for ALL parallel suites to finish to avoid token revocation
|
||||
# invalidating other in-flight requests.
|
||||
# ════════════════════════════════════════════════════════════════════════════
|
||||
# ════════════════════════════════════════════════════════════════════════════
|
||||
# 2. DESTRUCTIVE — auth/use + devices + logout + agent (serial, runs LAST)
|
||||
# Must wait for ALL parallel suites to finish to avoid token revocation
|
||||
# invalidating other in-flight requests.
|
||||
# ════════════════════════════════════════════════════════════════════════════
|
||||
suite-last:
|
||||
name: "Suite: auth-use + devices + logout + agent (last, serial)"
|
||||
name: 'Suite: auth-use + devices + logout + agent (last, serial)'
|
||||
# Runs when auth is selected; also runs after all parallel jobs finish
|
||||
if: ${{ inputs.suite_auth != 'false' || inputs.suite_agent != 'false' }}
|
||||
needs:
|
||||
@@ -366,20 +365,20 @@ jobs:
|
||||
|
||||
- name: Run use / devices / logout / agent (serial)
|
||||
env:
|
||||
DIFY_E2E_HOST: ${{ secrets.DIFY_E2E_HOST }}
|
||||
DIFY_E2E_EMAIL: ${{ secrets.DIFY_E2E_EMAIL }}
|
||||
DIFY_E2E_PASSWORD: ${{ secrets.DIFY_E2E_PASSWORD }}
|
||||
DIFY_E2E_EDITION: ${{ inputs.edition || 'ee' }}
|
||||
DIFY_E2E_TOKEN: ${{ needs.provision.outputs.token }}
|
||||
DIFY_E2E_WORKSPACE_ID: ${{ needs.provision.outputs.workspace_id }}
|
||||
DIFY_E2E_WORKSPACE_NAME: ${{ needs.provision.outputs.workspace_name }}
|
||||
DIFY_E2E_WS2_ID: ${{ needs.provision.outputs.ws2_id }}
|
||||
DIFY_E2E_CHAT_APP_ID: ${{ needs.provision.outputs.chat_app_id }}
|
||||
DIFY_E2E_WORKFLOW_APP_ID: ${{ needs.provision.outputs.workflow_app_id }}
|
||||
DIFY_E2E_HITL_APP_ID: ${{ needs.provision.outputs.hitl_app_id }}
|
||||
DIFY_E2E_HITL_EXTERNAL_APP_ID: ${{ needs.provision.outputs.hitl_external_app_id }}
|
||||
DIFY_E2E_HOST: ${{ secrets.DIFY_E2E_HOST }}
|
||||
DIFY_E2E_EMAIL: ${{ secrets.DIFY_E2E_EMAIL }}
|
||||
DIFY_E2E_PASSWORD: ${{ secrets.DIFY_E2E_PASSWORD }}
|
||||
DIFY_E2E_EDITION: ${{ inputs.edition || 'ee' }}
|
||||
DIFY_E2E_TOKEN: ${{ needs.provision.outputs.token }}
|
||||
DIFY_E2E_WORKSPACE_ID: ${{ needs.provision.outputs.workspace_id }}
|
||||
DIFY_E2E_WORKSPACE_NAME: ${{ needs.provision.outputs.workspace_name }}
|
||||
DIFY_E2E_WS2_ID: ${{ needs.provision.outputs.ws2_id }}
|
||||
DIFY_E2E_CHAT_APP_ID: ${{ needs.provision.outputs.chat_app_id }}
|
||||
DIFY_E2E_WORKFLOW_APP_ID: ${{ needs.provision.outputs.workflow_app_id }}
|
||||
DIFY_E2E_HITL_APP_ID: ${{ needs.provision.outputs.hitl_app_id }}
|
||||
DIFY_E2E_HITL_EXTERNAL_APP_ID: ${{ needs.provision.outputs.hitl_external_app_id }}
|
||||
DIFY_E2E_HITL_SINGLE_ACTION_APP_ID: ${{ needs.provision.outputs.hitl_single_action_app_id }}
|
||||
DIFY_E2E_HITL_MULTI_NODE_APP_ID: ${{ needs.provision.outputs.hitl_multi_node_app_id }}
|
||||
DIFY_E2E_HITL_MULTI_NODE_APP_ID: ${{ needs.provision.outputs.hitl_multi_node_app_id }}
|
||||
run: |
|
||||
# Collect files in safe order: use → devices → logout (revokes last) → agent
|
||||
FILES=()
|
||||
|
||||
@@ -4,11 +4,11 @@ on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
dify_version:
|
||||
description: "Dify image tag to test against (e.g. 1.7.0)"
|
||||
description: 'Dify image tag to test against (e.g. 1.7.0)'
|
||||
type: string
|
||||
required: true
|
||||
cli_ref:
|
||||
description: "Git ref to build the cli from (default: current branch)"
|
||||
description: 'Git ref to build the cli from (default: current branch)'
|
||||
type: string
|
||||
required: false
|
||||
|
||||
|
||||
@@ -22,7 +22,7 @@ jobs:
|
||||
uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0
|
||||
with:
|
||||
enable-cache: true
|
||||
python-version: "3.12"
|
||||
python-version: '3.12'
|
||||
cache-dependency-glob: api/uv.lock
|
||||
|
||||
- name: Install dependencies
|
||||
@@ -72,7 +72,7 @@ jobs:
|
||||
uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0
|
||||
with:
|
||||
enable-cache: true
|
||||
python-version: "3.12"
|
||||
python-version: '3.12'
|
||||
cache-dependency-glob: api/uv.lock
|
||||
|
||||
- name: Install dependencies
|
||||
|
||||
@@ -2,9 +2,9 @@ name: Deploy Dev
|
||||
|
||||
on:
|
||||
workflow_run:
|
||||
workflows: ["Build and Push API & Web"]
|
||||
workflows: ['Build and Push API & Web']
|
||||
branches:
|
||||
- "deploy/dev"
|
||||
- 'deploy/dev'
|
||||
types:
|
||||
- completed
|
||||
|
||||
|
||||
@@ -5,9 +5,9 @@ permissions:
|
||||
|
||||
on:
|
||||
workflow_run:
|
||||
workflows: ["Build and Push API & Web"]
|
||||
workflows: ['Build and Push API & Web']
|
||||
branches:
|
||||
- "deploy/enterprise"
|
||||
- 'deploy/enterprise'
|
||||
types:
|
||||
- completed
|
||||
|
||||
|
||||
@@ -2,9 +2,9 @@ name: Deploy HITL
|
||||
|
||||
on:
|
||||
workflow_run:
|
||||
workflows: ["Build and Push API & Web"]
|
||||
workflows: ['Build and Push API & Web']
|
||||
branches:
|
||||
- "build/feat/hitl"
|
||||
- 'build/feat/hitl'
|
||||
types:
|
||||
- completed
|
||||
|
||||
|
||||
@@ -5,9 +5,9 @@ permissions:
|
||||
|
||||
on:
|
||||
workflow_run:
|
||||
workflows: ["Build and Push API & Web"]
|
||||
workflows: ['Build and Push API & Web']
|
||||
branches:
|
||||
- "deploy/saas"
|
||||
- 'deploy/saas'
|
||||
types:
|
||||
- completed
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@ name: Build docker image
|
||||
on:
|
||||
pull_request:
|
||||
branches:
|
||||
- "main"
|
||||
- 'main'
|
||||
paths:
|
||||
- api/Dockerfile
|
||||
- api/Dockerfile.dockerignore
|
||||
@@ -28,26 +28,26 @@ jobs:
|
||||
strategy:
|
||||
matrix:
|
||||
include:
|
||||
- service_name: "api-amd64"
|
||||
- service_name: 'api-amd64'
|
||||
platform: linux/amd64
|
||||
runs_on: depot-ubuntu-24.04-4
|
||||
context: "{{defaultContext}}"
|
||||
file: "api/Dockerfile"
|
||||
- service_name: "api-arm64"
|
||||
context: '{{defaultContext}}'
|
||||
file: 'api/Dockerfile'
|
||||
- service_name: 'api-arm64'
|
||||
platform: linux/arm64
|
||||
runs_on: depot-ubuntu-24.04-4
|
||||
context: "{{defaultContext}}"
|
||||
file: "api/Dockerfile"
|
||||
- service_name: "web-amd64"
|
||||
context: '{{defaultContext}}'
|
||||
file: 'api/Dockerfile'
|
||||
- service_name: 'web-amd64'
|
||||
platform: linux/amd64
|
||||
runs_on: depot-ubuntu-24.04-4
|
||||
context: "{{defaultContext}}"
|
||||
file: "web/Dockerfile"
|
||||
- service_name: "web-arm64"
|
||||
context: '{{defaultContext}}'
|
||||
file: 'web/Dockerfile'
|
||||
- service_name: 'web-arm64'
|
||||
platform: linux/arm64
|
||||
runs_on: depot-ubuntu-24.04-4
|
||||
context: "{{defaultContext}}"
|
||||
file: "web/Dockerfile"
|
||||
context: '{{defaultContext}}'
|
||||
file: 'web/Dockerfile'
|
||||
steps:
|
||||
- name: Set up Depot CLI
|
||||
uses: depot/setup-action@15c09a5f77a0840ad4bce955686522a257853461 # v1.7.1
|
||||
@@ -69,12 +69,12 @@ jobs:
|
||||
strategy:
|
||||
matrix:
|
||||
include:
|
||||
- service_name: "api-amd64"
|
||||
context: "{{defaultContext}}"
|
||||
file: "api/Dockerfile"
|
||||
- service_name: "web-amd64"
|
||||
context: "{{defaultContext}}"
|
||||
file: "web/Dockerfile"
|
||||
- service_name: 'api-amd64'
|
||||
context: '{{defaultContext}}'
|
||||
file: 'api/Dockerfile'
|
||||
- service_name: 'web-amd64'
|
||||
context: '{{defaultContext}}'
|
||||
file: 'web/Dockerfile'
|
||||
steps:
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
name: "Pull Request Labeler"
|
||||
name: 'Pull Request Labeler'
|
||||
on:
|
||||
pull_request_target:
|
||||
|
||||
|
||||
@@ -2,12 +2,12 @@ name: Main CI Pipeline
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
branches: ["main"]
|
||||
branches: ['main']
|
||||
merge_group:
|
||||
branches: ["main"]
|
||||
branches: ['main']
|
||||
types: [checks_requested]
|
||||
push:
|
||||
branches: ["main"]
|
||||
branches: ['main']
|
||||
|
||||
permissions:
|
||||
actions: write
|
||||
|
||||
@@ -8,7 +8,7 @@ on:
|
||||
- reopened
|
||||
- synchronize
|
||||
merge_group:
|
||||
branches: ["main"]
|
||||
branches: ['main']
|
||||
types: [checks_requested]
|
||||
|
||||
jobs:
|
||||
|
||||
@@ -11,7 +11,6 @@ on:
|
||||
|
||||
jobs:
|
||||
stale:
|
||||
|
||||
runs-on: depot-ubuntu-24.04
|
||||
permissions:
|
||||
issues: write
|
||||
@@ -23,8 +22,8 @@ jobs:
|
||||
days-before-issue-stale: 15
|
||||
days-before-issue-close: 3
|
||||
repo-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
stale-issue-message: "Closed due to inactivity. If you have any questions, you can reopen it."
|
||||
stale-pr-message: "Closed due to inactivity. If you have any questions, you can reopen it."
|
||||
stale-issue-message: 'Closed due to inactivity. If you have any questions, you can reopen it.'
|
||||
stale-pr-message: 'Closed due to inactivity. If you have any questions, you can reopen it.'
|
||||
stale-issue-label: 'no-issue-activity'
|
||||
stale-pr-label: 'no-pr-activity'
|
||||
any-of-labels: '🌚 invalid,🙋♂️ question,wont-fix,no-issue-activity,no-pr-activity,💪 enhancement,🤔 cant-reproduce,🙏 help wanted'
|
||||
|
||||
@@ -36,7 +36,7 @@ jobs:
|
||||
uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0
|
||||
with:
|
||||
enable-cache: false
|
||||
python-version: "3.12"
|
||||
python-version: '3.12'
|
||||
cache-dependency-glob: api/uv.lock
|
||||
|
||||
- name: Install dependencies
|
||||
@@ -132,7 +132,10 @@ jobs:
|
||||
pnpm-lock.yaml
|
||||
pnpm-workspace.yaml
|
||||
.nvmrc
|
||||
vite.config.ts
|
||||
eslint.config.mjs
|
||||
.vscode/**
|
||||
.github/workflows/autofix.yml
|
||||
.github/workflows/style.yml
|
||||
.github/actions/setup-web/**
|
||||
|
||||
@@ -140,6 +143,12 @@ jobs:
|
||||
if: steps.changed-files.outputs.any_changed == 'true'
|
||||
uses: ./.github/actions/setup-web
|
||||
|
||||
- name: Format check
|
||||
if: steps.changed-files.outputs.any_changed == 'true'
|
||||
env:
|
||||
CHANGED_FILES: ${{ steps.changed-files.outputs.all_changed_files }}
|
||||
run: vp fmt --check --no-error-on-unmatched-pattern $CHANGED_FILES
|
||||
|
||||
- name: Restore ESLint cache
|
||||
if: steps.changed-files.outputs.any_changed == 'true'
|
||||
id: eslint-cache-restore
|
||||
|
||||
@@ -20,7 +20,7 @@ jobs:
|
||||
strategy:
|
||||
matrix:
|
||||
python-version:
|
||||
- "3.12"
|
||||
- '3.12'
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
@@ -48,16 +48,16 @@ jobs:
|
||||
- name: Install dependencies
|
||||
run: uv sync --project api --dev
|
||||
|
||||
# - name: Set up Vector Store (TiDB)
|
||||
# uses: hoverkraft-tech/compose-action@v2.0.2
|
||||
# with:
|
||||
# compose-file: docker/tidb/docker-compose.yaml
|
||||
# services: |
|
||||
# tidb
|
||||
# tiflash
|
||||
# - name: Set up Vector Store (TiDB)
|
||||
# uses: hoverkraft-tech/compose-action@v2.0.2
|
||||
# with:
|
||||
# compose-file: docker/tidb/docker-compose.yaml
|
||||
# services: |
|
||||
# tidb
|
||||
# tiflash
|
||||
|
||||
# - name: Check VDB Ready (TiDB)
|
||||
# run: uv run --project api python api/providers/vdb/tidb-vector/tests/integration_tests/check_tiflash_ready.py
|
||||
# - name: Check VDB Ready (TiDB)
|
||||
# run: uv run --project api python api/providers/vdb/tidb-vector/tests/integration_tests/check_tiflash_ready.py
|
||||
|
||||
- name: Test Vector Stores
|
||||
run: |
|
||||
|
||||
@@ -17,7 +17,7 @@ jobs:
|
||||
strategy:
|
||||
matrix:
|
||||
python-version:
|
||||
- "3.12"
|
||||
- '3.12'
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
@@ -45,16 +45,16 @@ jobs:
|
||||
- name: Install dependencies
|
||||
run: uv sync --project api --dev
|
||||
|
||||
# - name: Set up Vector Store (TiDB)
|
||||
# uses: hoverkraft-tech/compose-action@v2.0.2
|
||||
# with:
|
||||
# compose-file: docker/tidb/docker-compose.yaml
|
||||
# services: |
|
||||
# tidb
|
||||
# tiflash
|
||||
# - name: Set up Vector Store (TiDB)
|
||||
# uses: hoverkraft-tech/compose-action@v2.0.2
|
||||
# with:
|
||||
# compose-file: docker/tidb/docker-compose.yaml
|
||||
# services: |
|
||||
# tidb
|
||||
# tiflash
|
||||
|
||||
# - name: Check VDB Ready (TiDB)
|
||||
# run: uv run --project api python api/providers/vdb/tidb-vector/tests/integration_tests/check_tiflash_ready.py
|
||||
# - name: Check VDB Ready (TiDB)
|
||||
# run: uv run --project api python api/providers/vdb/tidb-vector/tests/integration_tests/check_tiflash_ready.py
|
||||
|
||||
- name: Test Vector Stores
|
||||
run: |
|
||||
|
||||
@@ -31,7 +31,7 @@ jobs:
|
||||
uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0
|
||||
with:
|
||||
enable-cache: true
|
||||
python-version: "3.12"
|
||||
python-version: '3.12'
|
||||
cache-dependency-glob: api/uv.lock
|
||||
|
||||
- name: Install API dependencies
|
||||
@@ -47,7 +47,7 @@ jobs:
|
||||
E2E_ADMIN_EMAIL: e2e-admin@example.com
|
||||
E2E_ADMIN_NAME: E2E Admin
|
||||
E2E_ADMIN_PASSWORD: E2eAdmin12345
|
||||
E2E_FORCE_WEB_BUILD: "1"
|
||||
E2E_FORCE_WEB_BUILD: '1'
|
||||
E2E_INIT_PASSWORD: E2eInit12345
|
||||
run: vp run e2e:full
|
||||
|
||||
|
||||
Vendored
+9
-23
@@ -1,31 +1,17 @@
|
||||
{
|
||||
"cucumber.features": [
|
||||
"e2e/features/**/*.feature",
|
||||
],
|
||||
"cucumber.glue": [
|
||||
"e2e/features/**/*.ts",
|
||||
],
|
||||
"cucumber.features": ["e2e/features/**/*.feature"],
|
||||
"cucumber.glue": ["e2e/features/**/*.ts"],
|
||||
|
||||
"tailwindCSS.experimental.configFile": "web/app/styles/globals.css",
|
||||
|
||||
// Auto fix
|
||||
"editor.codeActionsOnSave": {
|
||||
"source.fixAll.eslint": "explicit",
|
||||
},
|
||||
// Format
|
||||
"editor.formatOnSave": true,
|
||||
"oxc.fmt.configPath": "./vite.config.ts",
|
||||
|
||||
// Silent the stylistic rules in your IDE, but still auto fix them
|
||||
"eslint.rules.customizations": [
|
||||
{ "rule": "style/*", "severity": "off", "fixable": true },
|
||||
{ "rule": "format/*", "severity": "off", "fixable": true },
|
||||
{ "rule": "*-indent", "severity": "off", "fixable": true },
|
||||
{ "rule": "*-spacing", "severity": "off", "fixable": true },
|
||||
{ "rule": "*-spaces", "severity": "off", "fixable": true },
|
||||
{ "rule": "*-order", "severity": "off", "fixable": true },
|
||||
{ "rule": "*-dangle", "severity": "off", "fixable": true },
|
||||
{ "rule": "*-newline", "severity": "off", "fixable": true },
|
||||
{ "rule": "*quotes", "severity": "off", "fixable": true },
|
||||
{ "rule": "*semi", "severity": "off", "fixable": true }
|
||||
],
|
||||
// Lint fix
|
||||
"editor.codeActionsOnSave": {
|
||||
"source.fixAll.eslint": "explicit"
|
||||
},
|
||||
|
||||
// Enable eslint for all supported languages
|
||||
"eslint.validate": [
|
||||
|
||||
@@ -31,7 +31,7 @@ The codebase is split into:
|
||||
## Language Style
|
||||
|
||||
- **Python**: Keep type hints on functions and attributes, and implement relevant special methods (e.g., `__repr__`, `__str__`). Prefer `TypedDict` over `dict` or `Mapping` for type safety and better code documentation.
|
||||
- **TypeScript**: Use the strict config, rely on ESLint (`pnpm lint:fix` preferred) plus `pnpm type-check`, and avoid `any` types.
|
||||
- **TypeScript**: Use the strict config, rely on `vp fmt` for formatting, ESLint for lint-only fixes, plus `pnpm type-check`, and avoid `any` types.
|
||||
|
||||
## General Practices
|
||||
|
||||
|
||||
+10
-10
@@ -34,11 +34,11 @@ Don't forget to link an existing issue or open a new issue in the PR's descripti
|
||||
|
||||
How we prioritize:
|
||||
|
||||
| Issue Type | Priority |
|
||||
| ------------------------------------------------------------ | --------------- |
|
||||
| Bugs in core functions (cloud service, cannot login, applications not working, security loopholes) | Critical |
|
||||
| Non-critical bugs, performance boosts | Medium Priority |
|
||||
| Minor fixes (typos, confusing but working UI) | Low Priority |
|
||||
| Issue Type | Priority |
|
||||
| -------------------------------------------------------------------------------------------------- | --------------- |
|
||||
| Bugs in core functions (cloud service, cannot login, applications not working, security loopholes) | Critical |
|
||||
| Non-critical bugs, performance boosts | Medium Priority |
|
||||
| Minor fixes (typos, confusing but working UI) | Low Priority |
|
||||
|
||||
### Feature requests
|
||||
|
||||
@@ -52,12 +52,12 @@ How we prioritize:
|
||||
|
||||
How we prioritize:
|
||||
|
||||
| Feature Type | Priority |
|
||||
| ------------------------------------------------------------ | --------------- |
|
||||
| High-Priority Features as being labeled by a team member | High Priority |
|
||||
| Feature Type | Priority |
|
||||
| --------------------------------------------------------------------------------------------------------------------------------- | --------------- |
|
||||
| High-Priority Features as being labeled by a team member | High Priority |
|
||||
| Popular feature requests from our [community feedback board](https://github.com/langgenius/dify/discussions/categories/feedbacks) | Medium Priority |
|
||||
| Non-core features and minor enhancements | Low Priority |
|
||||
| Valuable but not immediate | Future-Feature |
|
||||
| Non-core features and minor enhancements | Low Priority |
|
||||
| Valuable but not immediate | Future-Feature |
|
||||
|
||||
## Submitting your PR
|
||||
|
||||
|
||||
+28
-12
@@ -80,7 +80,14 @@ export default class MyCommand extends DifyCommand {
|
||||
// Authed: authedCtx() sets outputFormat + builds context
|
||||
const ctx = await this.authedCtx({ format: flags.output })
|
||||
|
||||
process.stdout.write(await runMyThing({ /* args */ }, { bundle: ctx.bundle, http: ctx.http, io: ctx.io }))
|
||||
process.stdout.write(
|
||||
await runMyThing(
|
||||
{
|
||||
/* args */
|
||||
},
|
||||
{ bundle: ctx.bundle, http: ctx.http, io: ctx.io },
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
```
|
||||
@@ -102,7 +109,7 @@ import { ErrorCode } from '../../errors/codes.js'
|
||||
throw new BaseError({
|
||||
code: ErrorCode.UsageMissingArg,
|
||||
message: 'workspace id required',
|
||||
hint: 'pass --workspace or run \'difyctl use workspace <id>\'',
|
||||
hint: "pass --workspace or run 'difyctl use workspace <id>'",
|
||||
})
|
||||
```
|
||||
|
||||
@@ -150,10 +157,7 @@ export type IOStreams = {
|
||||
`runWithSpinner` wraps async call with animated spinner on stderr. Auto-disables for structured output — no manual `enabled:` flag needed.
|
||||
|
||||
```typescript
|
||||
const result = await runWithSpinner(
|
||||
{ io, label: 'Fetching apps' },
|
||||
() => client.list(params),
|
||||
)
|
||||
const result = await runWithSpinner({ io, label: 'Fetching apps' }, () => client.list(params))
|
||||
```
|
||||
|
||||
`STRUCTURED_FORMATS = new Set(['json', 'yaml', 'name'])` drives disable check. New structured format = add to this set only — no other callsites change.
|
||||
@@ -174,9 +178,15 @@ Output rendering separated from data fetching via protocol objects.
|
||||
```typescript
|
||||
// handlers.ts — implement the protocol on the data object
|
||||
export class MyListOutput implements TablePrintable {
|
||||
tableColumns() { return COLUMNS }
|
||||
tableRows() { return this.rows.map(r => r.tableRow()) }
|
||||
json() { return { items: this.rows.map(r => r.json()) } }
|
||||
tableColumns() {
|
||||
return COLUMNS
|
||||
}
|
||||
tableRows() {
|
||||
return this.rows.map((r) => r.tableRow())
|
||||
}
|
||||
json() {
|
||||
return { items: this.rows.map((r) => r.json()) }
|
||||
}
|
||||
}
|
||||
|
||||
// index.ts — wrap and return
|
||||
@@ -215,10 +225,16 @@ One file per resource under `src/api/`. Each exports class wrapping `KyInstance`
|
||||
```typescript
|
||||
export class AppsClient {
|
||||
private readonly http: KyInstance
|
||||
constructor(http: KyInstance) { this.http = http }
|
||||
constructor(http: KyInstance) {
|
||||
this.http = http
|
||||
}
|
||||
|
||||
async list(params: ListParams): Promise<ListResponse> { /* ... */ throw new Error('elided') }
|
||||
async describe(id: string, workspaceId: string, fields: string[]): Promise<DescribeResponse> { /* ... */ throw new Error('elided') }
|
||||
async list(params: ListParams): Promise<ListResponse> {
|
||||
/* ... */ throw new Error('elided')
|
||||
}
|
||||
async describe(id: string, workspaceId: string, fields: string[]): Promise<DescribeResponse> {
|
||||
/* ... */ throw new Error('elided')
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
|
||||
+29
-24
@@ -6,13 +6,8 @@ import markdownPreferences from 'eslint-plugin-markdown-preferences'
|
||||
|
||||
export default antfu(
|
||||
{
|
||||
ignores: original => [
|
||||
'context/**',
|
||||
'docs/**',
|
||||
'dist/**',
|
||||
'coverage/**',
|
||||
...original,
|
||||
],
|
||||
stylistic: false,
|
||||
ignores: (original) => ['context/**', 'docs/**', 'dist/**', 'coverage/**', ...original],
|
||||
typescript: {
|
||||
overrides: {
|
||||
'ts/consistent-type-definitions': ['error', 'type'],
|
||||
@@ -26,11 +21,6 @@ export default antfu(
|
||||
'test/prefer-lowercase-title': 'off',
|
||||
},
|
||||
},
|
||||
stylistic: {
|
||||
overrides: {
|
||||
'antfu/top-level-function': 'off',
|
||||
},
|
||||
},
|
||||
e18e: false,
|
||||
},
|
||||
markdownPreferences.configs.standard,
|
||||
@@ -45,10 +35,7 @@ export default antfu(
|
||||
minLinks: 1,
|
||||
},
|
||||
],
|
||||
'markdown-preferences/ordered-list-marker-sequence': [
|
||||
'error',
|
||||
{ increment: 'never' },
|
||||
],
|
||||
'markdown-preferences/ordered-list-marker-sequence': ['error', { increment: 'never' }],
|
||||
'markdown-preferences/definitions-last': 'error',
|
||||
'markdown-preferences/sort-definitions': 'error',
|
||||
},
|
||||
@@ -56,6 +43,18 @@ export default antfu(
|
||||
{
|
||||
rules: {
|
||||
'node/prefer-global/process': 'off',
|
||||
'unicorn/number-literal-case': 'off',
|
||||
'unused-imports/no-unused-vars': [
|
||||
'error',
|
||||
{
|
||||
args: 'after-used',
|
||||
argsIgnorePattern: '^_',
|
||||
caughtErrors: 'none',
|
||||
ignoreRestSiblings: true,
|
||||
vars: 'all',
|
||||
varsIgnorePattern: '^_',
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -67,14 +66,20 @@ export default antfu(
|
||||
{
|
||||
files: ['src/**/*.ts'],
|
||||
rules: {
|
||||
'no-restricted-imports': ['error', {
|
||||
patterns: [
|
||||
{
|
||||
group: ['../**', './*/**', '..'],
|
||||
message: 'Use the @/ (or @test/) alias for parent-directory or nested relative imports; keep ./ only for same-folder siblings.',
|
||||
},
|
||||
],
|
||||
}],
|
||||
'no-restricted-imports': [
|
||||
'error',
|
||||
{
|
||||
patterns: [
|
||||
{
|
||||
group: ['../**', './*/**', '..'],
|
||||
message:
|
||||
'Use the @/ (or @test/) alias for parent-directory or nested relative imports; keep ./ only for same-folder siblings.',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
)
|
||||
.remove('antfu/sort/package-json')
|
||||
.remove('antfu/sort/tsconfig-json')
|
||||
|
||||
+48
-28
@@ -1,41 +1,19 @@
|
||||
{
|
||||
"name": "@langgenius/difyctl",
|
||||
"type": "module",
|
||||
"version": "0.1.0-rc.1",
|
||||
"description": "Dify command-line interface",
|
||||
"difyctl": {
|
||||
"channel": "rc",
|
||||
"compat": {
|
||||
"minDify": "1.14.0",
|
||||
"maxDify": "1.15.0"
|
||||
},
|
||||
"release": {
|
||||
"tagPrefix": "difyctl-v",
|
||||
"binName": "difyctl",
|
||||
"checksumsSuffix": "-checksums.txt",
|
||||
"targets": [
|
||||
{ "id": "linux-x64", "bunTarget": "bun-linux-x64", "exe": false },
|
||||
{ "id": "linux-arm64", "bunTarget": "bun-linux-arm64", "exe": false },
|
||||
{ "id": "darwin-x64", "bunTarget": "bun-darwin-x64", "exe": false },
|
||||
{ "id": "darwin-arm64", "bunTarget": "bun-darwin-arm64", "exe": false },
|
||||
{ "id": "windows-x64", "bunTarget": "bun-windows-x64", "exe": true }
|
||||
]
|
||||
}
|
||||
},
|
||||
"license": "Apache-2.0",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./dist/index.d.ts",
|
||||
"import": "./dist/index.js"
|
||||
}
|
||||
},
|
||||
"files": [
|
||||
"README.md",
|
||||
"bin",
|
||||
"dist"
|
||||
],
|
||||
"engines": {
|
||||
"node": "^22.22.1"
|
||||
"type": "module",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./dist/index.d.ts",
|
||||
"import": "./dist/index.js"
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
"build": "vp pack",
|
||||
@@ -89,5 +67,47 @@
|
||||
"vite": "catalog:",
|
||||
"vite-plus": "catalog:",
|
||||
"vitest": "catalog:"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^22.22.1"
|
||||
},
|
||||
"difyctl": {
|
||||
"channel": "rc",
|
||||
"compat": {
|
||||
"minDify": "1.14.0",
|
||||
"maxDify": "1.15.0"
|
||||
},
|
||||
"release": {
|
||||
"tagPrefix": "difyctl-v",
|
||||
"binName": "difyctl",
|
||||
"checksumsSuffix": "-checksums.txt",
|
||||
"targets": [
|
||||
{
|
||||
"id": "linux-x64",
|
||||
"bunTarget": "bun-linux-x64",
|
||||
"exe": false
|
||||
},
|
||||
{
|
||||
"id": "linux-arm64",
|
||||
"bunTarget": "bun-linux-arm64",
|
||||
"exe": false
|
||||
},
|
||||
{
|
||||
"id": "darwin-x64",
|
||||
"bunTarget": "bun-darwin-x64",
|
||||
"exe": false
|
||||
},
|
||||
{
|
||||
"id": "darwin-arm64",
|
||||
"bunTarget": "bun-darwin-arm64",
|
||||
"exe": false
|
||||
},
|
||||
{
|
||||
"id": "windows-x64",
|
||||
"bunTarget": "bun-windows-x64",
|
||||
"exe": true
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -36,7 +36,7 @@ import { fileURLToPath } from 'node:url'
|
||||
const host = process.env.DIFY_E2E_HOST ?? ''
|
||||
const email = process.env.DIFY_E2E_EMAIL ?? ''
|
||||
const password = process.env.DIFY_E2E_PASSWORD ?? ''
|
||||
const edition = ((process.env.DIFY_E2E_EDITION ?? 'ee').toLowerCase()) as 'ee' | 'ce'
|
||||
const edition = (process.env.DIFY_E2E_EDITION ?? 'ee').toLowerCase() as 'ee' | 'ce'
|
||||
const preToken = process.env.DIFY_E2E_TOKEN ?? ''
|
||||
|
||||
if (!host || !email || !password) {
|
||||
@@ -49,10 +49,10 @@ const base = host.replace(/\/$/, '')
|
||||
// ── Helpers ───────────────────────────────────────────────────────────────────
|
||||
|
||||
function sleep(ms: number) {
|
||||
return new Promise(r => setTimeout(r, ms))
|
||||
return new Promise((r) => setTimeout(r, ms))
|
||||
}
|
||||
|
||||
async function consoleLogin(): Promise<{ cookieString: string, csrfToken: string }> {
|
||||
async function consoleLogin(): Promise<{ cookieString: string; csrfToken: string }> {
|
||||
const passwordB64 = Buffer.from(password, 'utf8').toString('base64')
|
||||
const res = await fetch(`${base}/console/api/login`, {
|
||||
method: 'POST',
|
||||
@@ -60,14 +60,15 @@ async function consoleLogin(): Promise<{ cookieString: string, csrfToken: string
|
||||
body: JSON.stringify({ email, password: passwordB64, remember_me: false }),
|
||||
signal: AbortSignal.timeout(15_000),
|
||||
})
|
||||
if (!res.ok)
|
||||
throw new Error(`console/api/login failed: HTTP ${res.status}`)
|
||||
if (!res.ok) throw new Error(`console/api/login failed: HTTP ${res.status}`)
|
||||
|
||||
const setCookies = res.headers.getSetCookie?.() ?? []
|
||||
const cookieString = setCookies.map(c => c.split(';')[0]).join('; ')
|
||||
const cookieString = setCookies.map((c) => c.split(';')[0]).join('; ')
|
||||
// cookie names may have __Host- prefix on HTTPS deployments
|
||||
const csrfPair = setCookies.map(c => c.split(';')[0]).find(p => p.includes('csrf_token='))
|
||||
const csrfToken = csrfPair ? csrfPair.slice(csrfPair.indexOf('csrf_token=') + 'csrf_token='.length) : ''
|
||||
const csrfPair = setCookies.map((c) => c.split(';')[0]).find((p) => p.includes('csrf_token='))
|
||||
const csrfToken = csrfPair
|
||||
? csrfPair.slice(csrfPair.indexOf('csrf_token=') + 'csrf_token='.length)
|
||||
: ''
|
||||
return { cookieString, csrfToken }
|
||||
}
|
||||
|
||||
@@ -78,8 +79,9 @@ async function validateToken(token: string): Promise<boolean> {
|
||||
signal: AbortSignal.timeout(10_000),
|
||||
})
|
||||
return res.ok
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
catch { return false }
|
||||
}
|
||||
|
||||
async function mintToken(cookieStr: string, csrf: string, label: string): Promise<string> {
|
||||
@@ -90,28 +92,27 @@ async function mintToken(cookieStr: string, csrf: string, label: string): Promis
|
||||
body: JSON.stringify({ client_id: 'difyctl', device_label: label }),
|
||||
signal: AbortSignal.timeout(15_000),
|
||||
})
|
||||
if (!codeRes.ok)
|
||||
throw new Error(`device/code failed: HTTP ${codeRes.status}`)
|
||||
const { device_code, user_code } = await codeRes.json() as { device_code: string, user_code: string }
|
||||
if (!codeRes.ok) throw new Error(`device/code failed: HTTP ${codeRes.status}`)
|
||||
const { device_code, user_code } = (await codeRes.json()) as {
|
||||
device_code: string
|
||||
user_code: string
|
||||
}
|
||||
|
||||
// Step 2: approve (with retry)
|
||||
let approveRes: Response | undefined
|
||||
for (let i = 1; i <= 5; i++) {
|
||||
approveRes = await fetch(`${base}/openapi/v1/oauth/device/approve`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', 'Cookie': cookieStr, 'X-CSRFToken': csrf },
|
||||
headers: { 'Content-Type': 'application/json', Cookie: cookieStr, 'X-CSRFToken': csrf },
|
||||
body: JSON.stringify({ user_code }),
|
||||
signal: AbortSignal.timeout(10_000),
|
||||
})
|
||||
if (approveRes.ok)
|
||||
break
|
||||
if (approveRes.status !== 429 && approveRes.status < 500)
|
||||
break
|
||||
if (approveRes.ok) break
|
||||
if (approveRes.status !== 429 && approveRes.status < 500) break
|
||||
console.warn(`[provision] device/approve HTTP ${approveRes.status}; retry ${i}/5 in ${i * 2}s`)
|
||||
await sleep(i * 2_000)
|
||||
}
|
||||
if (!approveRes?.ok)
|
||||
throw new Error(`device/approve failed: HTTP ${approveRes?.status}`)
|
||||
if (!approveRes?.ok) throw new Error(`device/approve failed: HTTP ${approveRes?.status}`)
|
||||
|
||||
// Step 3: exchange token
|
||||
const tokenRes = await fetch(`${base}/openapi/v1/oauth/device/token`, {
|
||||
@@ -120,39 +121,42 @@ async function mintToken(cookieStr: string, csrf: string, label: string): Promis
|
||||
body: JSON.stringify({ device_code, client_id: 'difyctl' }),
|
||||
signal: AbortSignal.timeout(10_000),
|
||||
})
|
||||
if (!tokenRes.ok)
|
||||
throw new Error(`device/token failed: HTTP ${tokenRes.status}`)
|
||||
const body = await tokenRes.json() as { token?: string }
|
||||
if (!body.token)
|
||||
throw new Error(`device/token missing token: ${JSON.stringify(body)}`)
|
||||
if (!tokenRes.ok) throw new Error(`device/token failed: HTTP ${tokenRes.status}`)
|
||||
const body = (await tokenRes.json()) as { token?: string }
|
||||
if (!body.token) throw new Error(`device/token missing token: ${JSON.stringify(body)}`)
|
||||
return body.token
|
||||
}
|
||||
|
||||
async function discoverWorkspaces(cookieStr: string, csrf: string) {
|
||||
const res = await fetch(`${base}/console/api/workspaces`, {
|
||||
headers: { 'Cookie': cookieStr, 'X-CSRF-Token': csrf },
|
||||
headers: { Cookie: cookieStr, 'X-CSRF-Token': csrf },
|
||||
signal: AbortSignal.timeout(10_000),
|
||||
})
|
||||
if (!res.ok)
|
||||
throw new Error(`list workspaces failed: HTTP ${res.status}`)
|
||||
const data = await res.json() as { workspaces?: Array<{ id: string, name: string }> }
|
||||
if (!res.ok) throw new Error(`list workspaces failed: HTTP ${res.status}`)
|
||||
const data = (await res.json()) as { workspaces?: Array<{ id: string; name: string }> }
|
||||
const all = data.workspaces ?? []
|
||||
|
||||
if (edition === 'ee') {
|
||||
const ws0 = all.find(w => w.name === 'auto_test0')
|
||||
const ws1 = all.find(w => w.name === 'auto_test1')
|
||||
if (!ws0)
|
||||
throw new Error('[provision] EE: workspace "auto_test0" not found')
|
||||
const ws0 = all.find((w) => w.name === 'auto_test0')
|
||||
const ws1 = all.find((w) => w.name === 'auto_test1')
|
||||
if (!ws0) throw new Error('[provision] EE: workspace "auto_test0" not found')
|
||||
console.warn(`[provision] EE primary: ${ws0.name} (${ws0.id})`)
|
||||
console.warn(`[provision] EE secondary: ${ws1?.name ?? 'reuses primary'} (${ws1?.id ?? ws0.id})`)
|
||||
console.warn(
|
||||
`[provision] EE secondary: ${ws1?.name ?? 'reuses primary'} (${ws1?.id ?? ws0.id})`,
|
||||
)
|
||||
return { primaryWsId: ws0.id, primaryWsName: ws0.name, secondaryWsId: ws1?.id ?? ws0.id }
|
||||
}
|
||||
|
||||
const auto = all.filter(w => w.name.toLowerCase().includes('auto')).sort((a, b) => a.name.localeCompare(b.name))
|
||||
const auto = all
|
||||
.filter((w) => w.name.toLowerCase().includes('auto'))
|
||||
.sort((a, b) => a.name.localeCompare(b.name))
|
||||
const primary = auto[0] ?? all[0]
|
||||
if (!primary)
|
||||
throw new Error('[provision] No workspaces found')
|
||||
return { primaryWsId: primary.id, primaryWsName: primary.name, secondaryWsId: auto[1]?.id ?? primary.id }
|
||||
if (!primary) throw new Error('[provision] No workspaces found')
|
||||
return {
|
||||
primaryWsId: primary.id,
|
||||
primaryWsName: primary.name,
|
||||
secondaryWsId: auto[1]?.id ?? primary.id,
|
||||
}
|
||||
}
|
||||
|
||||
async function provisionApps(
|
||||
@@ -162,7 +166,7 @@ async function provisionApps(
|
||||
secondaryWsId: string,
|
||||
): Promise<Record<string, string>> {
|
||||
const mkH = (extra: Record<string, string> = {}) => ({
|
||||
'Cookie': cookieStr,
|
||||
Cookie: cookieStr,
|
||||
'X-CSRF-Token': csrf,
|
||||
...extra,
|
||||
})
|
||||
@@ -202,9 +206,10 @@ async function provisionApps(
|
||||
}
|
||||
|
||||
const dsl = await readFile(join(fixturesDir, dslFile), 'utf8')
|
||||
const appName = (dsl.match(/^[ \t]+name:[ \t]*(\S[^\n]*)$/m) ?? [])[1]
|
||||
?.trim()
|
||||
.replace(/^['"]|['"]$/g, '') ?? dslFile
|
||||
const appName =
|
||||
(dsl.match(/^[ \t]+name:[ \t]*(\S[^\n]*)$/m) ?? [])[1]
|
||||
?.trim()
|
||||
.replace(/^['"]|['"]$/g, '') ?? dslFile
|
||||
const appMode = (dsl.match(/^[ \t]+mode:[ \t]*(\S+)/m) ?? [])[1] ?? ''
|
||||
|
||||
// Find existing or import
|
||||
@@ -212,34 +217,34 @@ async function provisionApps(
|
||||
`${base}/console/api/apps?name=${encodeURIComponent(appName)}&limit=50&page=1`,
|
||||
{ headers: mkH(), signal: AbortSignal.timeout(10_000) },
|
||||
)
|
||||
const searchData = await searchRes.json() as { data?: Array<{ id: string, name: string }> }
|
||||
let appId = searchData.data?.find(a => a.name === appName)?.id
|
||||
const searchData = (await searchRes.json()) as { data?: Array<{ id: string; name: string }> }
|
||||
let appId = searchData.data?.find((a) => a.name === appName)?.id
|
||||
|
||||
if (appId) {
|
||||
console.warn(`[provision] ${dslFile}: exists id=${appId}`)
|
||||
}
|
||||
else {
|
||||
} else {
|
||||
const importRes = await fetch(`${base}/console/api/apps/imports`, {
|
||||
method: 'POST',
|
||||
headers: { ...mkH(), 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ mode: 'yaml-content', yaml_content: dsl }),
|
||||
signal: AbortSignal.timeout(30_000),
|
||||
})
|
||||
const importData = await importRes.json() as { app_id?: string, import_id?: string }
|
||||
const importData = (await importRes.json()) as { app_id?: string; import_id?: string }
|
||||
if (importRes.status === 202 && importData.import_id) {
|
||||
const confirmRes = await fetch(`${base}/console/api/apps/imports/${importData.import_id}/confirm`, {
|
||||
method: 'POST',
|
||||
headers: mkH(),
|
||||
signal: AbortSignal.timeout(15_000),
|
||||
})
|
||||
const confirmData = await confirmRes.json() as { app_id?: string }
|
||||
const confirmRes = await fetch(
|
||||
`${base}/console/api/apps/imports/${importData.import_id}/confirm`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: mkH(),
|
||||
signal: AbortSignal.timeout(15_000),
|
||||
},
|
||||
)
|
||||
const confirmData = (await confirmRes.json()) as { app_id?: string }
|
||||
appId = confirmData.app_id
|
||||
}
|
||||
else {
|
||||
} else {
|
||||
appId = importData.app_id
|
||||
}
|
||||
if (!appId)
|
||||
throw new Error(`import failed: ${JSON.stringify(importData)}`)
|
||||
if (!appId) throw new Error(`import failed: ${JSON.stringify(importData)}`)
|
||||
console.warn(`[provision] ${dslFile}: imported id=${appId}`)
|
||||
}
|
||||
|
||||
@@ -270,8 +275,7 @@ async function provisionApps(
|
||||
}
|
||||
|
||||
results[envVar] = appId
|
||||
}
|
||||
catch (err) {
|
||||
} catch (err) {
|
||||
console.warn(`[provision] ${dslFile} skipped: ${err}`)
|
||||
}
|
||||
}
|
||||
@@ -281,7 +285,9 @@ async function provisionApps(
|
||||
|
||||
async function writeOutputs(outputs: Record<string, string>) {
|
||||
const ghOutput = process.env.GITHUB_OUTPUT
|
||||
const lines = `${Object.entries(outputs).map(([k, v]) => `${k}=${v}`).join('\n')}\n`
|
||||
const lines = `${Object.entries(outputs)
|
||||
.map(([k, v]) => `${k}=${v}`)
|
||||
.join('\n')}\n`
|
||||
|
||||
// Always write local JSON for debugging
|
||||
const { writeFile } = await import('node:fs/promises')
|
||||
@@ -310,18 +316,19 @@ async function main() {
|
||||
|
||||
// 2. Token
|
||||
let primaryToken = preToken
|
||||
if (primaryToken && await validateToken(primaryToken)) {
|
||||
if (primaryToken && (await validateToken(primaryToken))) {
|
||||
console.warn(`[provision] Using pre-set token: ${primaryToken.slice(0, 20)}…`)
|
||||
}
|
||||
else {
|
||||
if (primaryToken)
|
||||
console.warn('[provision] Pre-set token invalid, minting fresh…')
|
||||
} else {
|
||||
if (primaryToken) console.warn('[provision] Pre-set token invalid, minting fresh…')
|
||||
primaryToken = await mintToken(cookieString, csrfToken, 'e2e-provision')
|
||||
console.warn(`[provision] Minted token: ${primaryToken.slice(0, 20)}…`)
|
||||
}
|
||||
|
||||
// 3. Discover workspaces
|
||||
const { primaryWsId, primaryWsName, secondaryWsId } = await discoverWorkspaces(cookieString, csrfToken)
|
||||
const { primaryWsId, primaryWsName, secondaryWsId } = await discoverWorkspaces(
|
||||
cookieString,
|
||||
csrfToken,
|
||||
)
|
||||
|
||||
// 4. Provision apps
|
||||
const appIds = await provisionApps(cookieString, csrfToken, primaryWsId, secondaryWsId)
|
||||
@@ -333,10 +340,16 @@ async function main() {
|
||||
// their describe calls rejected with "workspace_id does not match app's workspace".
|
||||
await fetch(`${base}/console/api/workspaces/switch`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', 'Cookie': cookieString, 'X-CSRF-Token': csrfToken },
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Cookie: cookieString,
|
||||
'X-CSRF-Token': csrfToken,
|
||||
},
|
||||
body: JSON.stringify({ tenant_id: primaryWsId }),
|
||||
signal: AbortSignal.timeout(10_000),
|
||||
}).catch((err: unknown) => console.warn(`[provision] switch-back to primary failed (non-fatal): ${err}`))
|
||||
}).catch((err: unknown) =>
|
||||
console.warn(`[provision] switch-back to primary failed (non-fatal): ${err}`),
|
||||
)
|
||||
console.warn(`[provision] Session workspace reset to primary: ${primaryWsId}`)
|
||||
|
||||
// 5. Write outputs
|
||||
|
||||
@@ -14,18 +14,22 @@ import {
|
||||
|
||||
describe('pathToTokens', () => {
|
||||
it('extracts tokens for nested command', () => {
|
||||
expect(pathToTokens('src/commands/auth/devices/list/index.ts', 'src/commands'))
|
||||
.toEqual(['auth', 'devices', 'list'])
|
||||
expect(pathToTokens('src/commands/auth/devices/list/index.ts', 'src/commands')).toEqual([
|
||||
'auth',
|
||||
'devices',
|
||||
'list',
|
||||
])
|
||||
})
|
||||
|
||||
it('extracts tokens for top-level command', () => {
|
||||
expect(pathToTokens('src/commands/version/index.ts', 'src/commands'))
|
||||
.toEqual(['version'])
|
||||
expect(pathToTokens('src/commands/version/index.ts', 'src/commands')).toEqual(['version'])
|
||||
})
|
||||
|
||||
it('normalizes backslashes (windows-style paths)', () => {
|
||||
expect(pathToTokens('src\\commands\\auth\\login\\index.ts', 'src/commands'))
|
||||
.toEqual(['auth', 'login'])
|
||||
expect(pathToTokens('src\\commands\\auth\\login\\index.ts', 'src/commands')).toEqual([
|
||||
'auth',
|
||||
'login',
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -49,22 +53,35 @@ describe('tokensToIdentifier', () => {
|
||||
describe('buildTree', () => {
|
||||
it('assembles a nested tree from entries', () => {
|
||||
const entries = [
|
||||
{ tokens: ['auth', 'login'], identifier: 'AuthLogin', importPath: '@/commands/auth/login/index' },
|
||||
{ tokens: ['auth', 'devices', 'list'], identifier: 'AuthDevicesList', importPath: '@/commands/auth/devices/list/index' },
|
||||
{
|
||||
tokens: ['auth', 'login'],
|
||||
identifier: 'AuthLogin',
|
||||
importPath: '@/commands/auth/login/index',
|
||||
},
|
||||
{
|
||||
tokens: ['auth', 'devices', 'list'],
|
||||
identifier: 'AuthDevicesList',
|
||||
importPath: '@/commands/auth/devices/list/index',
|
||||
},
|
||||
{ tokens: ['version'], identifier: 'Version', importPath: '@/commands/version/index' },
|
||||
]
|
||||
const tree = buildTree(entries)
|
||||
expect(tree.subcommands.get('auth')?.command).toBeUndefined()
|
||||
expect(tree.subcommands.get('auth')?.subcommands.get('login')?.command).toBe('AuthLogin')
|
||||
expect(tree.subcommands.get('auth')?.subcommands.get('devices')?.subcommands.get('list')?.command)
|
||||
.toBe('AuthDevicesList')
|
||||
expect(
|
||||
tree.subcommands.get('auth')?.subcommands.get('devices')?.subcommands.get('list')?.command,
|
||||
).toBe('AuthDevicesList')
|
||||
expect(tree.subcommands.get('version')?.command).toBe('Version')
|
||||
})
|
||||
|
||||
it('supports a parent command with its own children', () => {
|
||||
const entries = [
|
||||
{ tokens: ['run', 'app'], identifier: 'RunApp', importPath: '@/commands/run/app/index' },
|
||||
{ tokens: ['run', 'app', 'resume'], identifier: 'RunAppResume', importPath: '@/commands/run/app/resume/index' },
|
||||
{
|
||||
tokens: ['run', 'app', 'resume'],
|
||||
identifier: 'RunAppResume',
|
||||
importPath: '@/commands/run/app/resume/index',
|
||||
},
|
||||
]
|
||||
const tree = buildTree(entries)
|
||||
const runApp = tree.subcommands.get('run')?.subcommands.get('app')
|
||||
@@ -76,9 +93,17 @@ describe('buildTree', () => {
|
||||
describe('formatModule', () => {
|
||||
it('produces a deterministic ESM file with imports + tree literal', () => {
|
||||
const entries: CommandEntry[] = [
|
||||
{ tokens: ['auth', 'login'], identifier: 'AuthLogin', importPath: '@/commands/auth/login/index' },
|
||||
{
|
||||
tokens: ['auth', 'login'],
|
||||
identifier: 'AuthLogin',
|
||||
importPath: '@/commands/auth/login/index',
|
||||
},
|
||||
{ tokens: ['version'], identifier: 'Version', importPath: '@/commands/version/index' },
|
||||
{ tokens: ['auth', 'devices', 'list'], identifier: 'AuthDevicesList', importPath: '@/commands/auth/devices/list/index' },
|
||||
{
|
||||
tokens: ['auth', 'devices', 'list'],
|
||||
identifier: 'AuthDevicesList',
|
||||
importPath: '@/commands/auth/devices/list/index',
|
||||
},
|
||||
]
|
||||
const tree = buildTree(entries)
|
||||
const out = formatModule(entries, tree)
|
||||
@@ -111,7 +136,11 @@ export const commandTree: CommandTree = {
|
||||
it('emits parent-with-own-command shape', () => {
|
||||
const entries: CommandEntry[] = [
|
||||
{ tokens: ['run', 'app'], identifier: 'RunApp', importPath: '@/commands/run/app/index' },
|
||||
{ tokens: ['run', 'app', 'resume'], identifier: 'RunAppResume', importPath: '@/commands/run/app/resume/index' },
|
||||
{
|
||||
tokens: ['run', 'app', 'resume'],
|
||||
identifier: 'RunAppResume',
|
||||
importPath: '@/commands/run/app/resume/index',
|
||||
},
|
||||
]
|
||||
const tree = buildTree(entries)
|
||||
const out = formatModule(entries, tree)
|
||||
@@ -130,7 +159,11 @@ export const commandTree: CommandTree = {
|
||||
it('imports sorted alphabetically by import path', () => {
|
||||
const entries: CommandEntry[] = [
|
||||
{ tokens: ['version'], identifier: 'Version', importPath: '@/commands/version/index' },
|
||||
{ tokens: ['auth', 'login'], identifier: 'AuthLogin', importPath: '@/commands/auth/login/index' },
|
||||
{
|
||||
tokens: ['auth', 'login'],
|
||||
identifier: 'AuthLogin',
|
||||
importPath: '@/commands/auth/login/index',
|
||||
},
|
||||
]
|
||||
const out = formatModule(entries, buildTree(entries))
|
||||
const authIdx = out.indexOf('AuthLogin')
|
||||
@@ -145,7 +178,10 @@ function makeFixture(): string {
|
||||
mkdirSync(join(commands, 'auth', 'login'), { recursive: true })
|
||||
writeFileSync(join(commands, 'auth', 'login', 'index.ts'), 'export default class Login {}\n')
|
||||
mkdirSync(join(commands, 'auth', 'devices', 'list'), { recursive: true })
|
||||
writeFileSync(join(commands, 'auth', 'devices', 'list', 'index.ts'), 'export default class DevicesList {}\n')
|
||||
writeFileSync(
|
||||
join(commands, 'auth', 'devices', 'list', 'index.ts'),
|
||||
'export default class DevicesList {}\n',
|
||||
)
|
||||
mkdirSync(join(commands, '_shared'), { recursive: true })
|
||||
writeFileSync(join(commands, '_shared', 'index.ts'), 'export default class Shared {}\n')
|
||||
mkdirSync(join(commands, 'version'), { recursive: true })
|
||||
@@ -157,12 +193,12 @@ describe('discoverCommands', () => {
|
||||
it('returns sorted entries, skipping _-prefixed segments', async () => {
|
||||
const root = makeFixture()
|
||||
const entries = await discoverCommands(join(root, 'src', 'commands'))
|
||||
expect(entries.map(e => e.tokens.join('/'))).toEqual([
|
||||
expect(entries.map((e) => e.tokens.join('/'))).toEqual([
|
||||
'auth/devices/list',
|
||||
'auth/login',
|
||||
'version',
|
||||
])
|
||||
expect(entries.find(e => e.tokens[0] === '_shared')).toBeUndefined()
|
||||
expect(entries.find((e) => e.tokens[0] === '_shared')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('errors on a loose .ts file under commands/', async () => {
|
||||
@@ -194,8 +230,7 @@ describe('generate', () => {
|
||||
const commandsDir = join(root, 'src', 'commands')
|
||||
await generate({ commandsDir, mode: 'write' })
|
||||
const result = await generate({ commandsDir, mode: 'check' })
|
||||
if (result.mode !== 'check')
|
||||
throw new Error('expected check mode')
|
||||
if (result.mode !== 'check') throw new Error('expected check mode')
|
||||
expect(result.ok).toBe(true)
|
||||
})
|
||||
|
||||
@@ -205,10 +240,8 @@ describe('generate', () => {
|
||||
await generate({ commandsDir, mode: 'write' })
|
||||
writeFileSync(join(commandsDir, 'tree.generated.ts'), '// stale\n')
|
||||
const result = await generate({ commandsDir, mode: 'check' })
|
||||
if (result.mode !== 'check')
|
||||
throw new Error('expected check mode')
|
||||
if (result.mode !== 'check') throw new Error('expected check mode')
|
||||
expect(result.ok).toBe(false)
|
||||
if (!result.ok)
|
||||
expect(result.diff).toBeDefined()
|
||||
if (!result.ok) expect(result.diff).toBeDefined()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -57,26 +57,22 @@ export type TreeNode = {
|
||||
export function pathToTokens(filePath: string, commandsRoot: string): string[] {
|
||||
const normalized = filePath.replace(/\\/g, '/')
|
||||
const root = commandsRoot.replace(/\\/g, '/').replace(/\/$/, '')
|
||||
const trimmed = normalized.startsWith(`${root}/`)
|
||||
? normalized.slice(root.length + 1)
|
||||
: normalized
|
||||
const trimmed = normalized.startsWith(`${root}/`) ? normalized.slice(root.length + 1) : normalized
|
||||
const withoutIndex = trimmed.replace(/\/index\.ts$/, '')
|
||||
return withoutIndex.split('/').filter(s => s.length > 0)
|
||||
return withoutIndex.split('/').filter((s) => s.length > 0)
|
||||
}
|
||||
|
||||
function capitalize(part: string): string {
|
||||
if (part.length === 0)
|
||||
return ''
|
||||
if (part.length === 0) return ''
|
||||
return part[0]!.toUpperCase() + part.slice(1)
|
||||
}
|
||||
|
||||
export function tokensToIdentifier(tokens: readonly string[]): string {
|
||||
const id = tokens
|
||||
.flatMap(t => t.split(/[-_]/))
|
||||
.flatMap((t) => t.split(/[-_]/))
|
||||
.map(capitalize)
|
||||
.join('')
|
||||
if (RESERVED_JS_KEYWORDS.has(id.toLowerCase()))
|
||||
return `_${id}`
|
||||
if (RESERVED_JS_KEYWORDS.has(id.toLowerCase())) return `_${id}`
|
||||
return id
|
||||
}
|
||||
|
||||
@@ -103,18 +99,15 @@ const HEADER = `// @generated by scripts/generate-command-tree.ts — DO NOT EDI
|
||||
`
|
||||
|
||||
function compareStrings(a: string, b: string): number {
|
||||
if (a < b)
|
||||
return -1
|
||||
if (a > b)
|
||||
return 1
|
||||
if (a < b) return -1
|
||||
if (a > b) return 1
|
||||
return 0
|
||||
}
|
||||
|
||||
function emitImports(entries: readonly CommandEntry[]): string {
|
||||
const sorted = [...entries].sort((a, b) => compareStrings(a.importPath, b.importPath))
|
||||
const lines = [`import type { CommandTree } from '@/framework/registry'`]
|
||||
for (const e of sorted)
|
||||
lines.push(`import ${e.identifier} from '${e.importPath}'`)
|
||||
for (const e of sorted) lines.push(`import ${e.identifier} from '${e.importPath}'`)
|
||||
return lines.join('\n')
|
||||
}
|
||||
|
||||
@@ -123,13 +116,11 @@ function emitNode(node: TreeNode, indent: string): string {
|
||||
const keys = [...node.subcommands.keys()].sort()
|
||||
const parts: string[] = []
|
||||
|
||||
if (node.command !== undefined)
|
||||
parts.push(`${inner}command: ${node.command},`)
|
||||
if (node.command !== undefined) parts.push(`${inner}command: ${node.command},`)
|
||||
|
||||
if (keys.length === 0) {
|
||||
parts.push(`${inner}subcommands: {},`)
|
||||
}
|
||||
else {
|
||||
} else {
|
||||
parts.push(`${inner}subcommands: {`)
|
||||
for (const key of keys) {
|
||||
const child = node.subcommands.get(key)!
|
||||
@@ -143,14 +134,9 @@ function emitNode(node: TreeNode, indent: string): string {
|
||||
|
||||
function emitEntry(key: string, node: TreeNode, indent: string): string {
|
||||
const isLeaf = node.subcommands.size === 0 && node.command !== undefined
|
||||
if (isLeaf)
|
||||
return `${indent}${key}: { command: ${node.command}, subcommands: {} },`
|
||||
if (isLeaf) return `${indent}${key}: { command: ${node.command}, subcommands: {} },`
|
||||
|
||||
return [
|
||||
`${indent}${key}: {`,
|
||||
emitNode(node, indent),
|
||||
`${indent}},`,
|
||||
].join('\n')
|
||||
return [`${indent}${key}: {`, emitNode(node, indent), `${indent}},`].join('\n')
|
||||
}
|
||||
|
||||
export function formatModule(entries: readonly CommandEntry[], tree: TreeNode): string {
|
||||
@@ -170,10 +156,8 @@ async function walk(dir: string): Promise<string[]> {
|
||||
const entries = await readdir(dir, { withFileTypes: true })
|
||||
for (const e of entries) {
|
||||
const full = join(dir, e.name)
|
||||
if (e.isDirectory())
|
||||
out.push(...await walk(full))
|
||||
else if (e.isFile())
|
||||
out.push(full)
|
||||
if (e.isDirectory()) out.push(...(await walk(full)))
|
||||
else if (e.isFile()) out.push(full)
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -184,21 +168,20 @@ function toPosix(p: string): string {
|
||||
|
||||
export async function discoverCommands(commandsDir: string): Promise<CommandEntry[]> {
|
||||
const all = await walk(commandsDir)
|
||||
const tsFiles = all.filter(f => f.endsWith('.ts') && !f.endsWith('.test.ts') && !f.endsWith('.d.ts'))
|
||||
const tsFiles = all.filter(
|
||||
(f) => f.endsWith('.ts') && !f.endsWith('.test.ts') && !f.endsWith('.d.ts'),
|
||||
)
|
||||
|
||||
const loose: string[] = []
|
||||
for (const abs of tsFiles) {
|
||||
const rel = toPosix(relative(commandsDir, abs))
|
||||
if (isExcludedCommandPath(rel))
|
||||
continue
|
||||
if (rel === 'tree.ts' || rel === 'tree.generated.ts')
|
||||
continue
|
||||
if (isExcludedCommandPath(rel)) continue
|
||||
if (rel === 'tree.ts' || rel === 'tree.generated.ts') continue
|
||||
// Only flag files directly under commands/ (no path separator — no parent folder)
|
||||
if (!rel.includes('/'))
|
||||
loose.push(rel)
|
||||
if (!rel.includes('/')) loose.push(rel)
|
||||
}
|
||||
if (loose.length > 0) {
|
||||
const list = loose.map(p => ` - src/commands/${p}`).join('\n')
|
||||
const list = loose.map((p) => ` - src/commands/${p}`).join('\n')
|
||||
throw new Error(
|
||||
`commands must live under their own folder (see CLAUDE memory: feedback_cli_command_structure). Found:\n${list}`,
|
||||
)
|
||||
@@ -207,15 +190,11 @@ export async function discoverCommands(commandsDir: string): Promise<CommandEntr
|
||||
const entries: CommandEntry[] = []
|
||||
for (const abs of tsFiles) {
|
||||
const rel = toPosix(relative(commandsDir, abs))
|
||||
if (isExcludedCommandPath(rel))
|
||||
continue
|
||||
if (!rel.endsWith('/index.ts'))
|
||||
continue
|
||||
if (isExcludedCommandPath(rel)) continue
|
||||
if (!rel.endsWith('/index.ts')) continue
|
||||
const tokens = pathToTokens(rel, '')
|
||||
if (tokens.length === 0)
|
||||
continue
|
||||
if (tokens[0]!.startsWith('-'))
|
||||
throw new Error(`command token cannot start with '-': ${rel}`)
|
||||
if (tokens.length === 0) continue
|
||||
if (tokens[0]!.startsWith('-')) throw new Error(`command token cannot start with '-': ${rel}`)
|
||||
entries.push({
|
||||
tokens,
|
||||
identifier: tokensToIdentifier(tokens),
|
||||
@@ -225,8 +204,7 @@ export async function discoverCommands(commandsDir: string): Promise<CommandEntr
|
||||
|
||||
entries.sort((a, b) => compareStrings(a.importPath, b.importPath))
|
||||
|
||||
if (entries.length === 0)
|
||||
throw new Error(`no commands found under ${commandsDir}`)
|
||||
if (entries.length === 0) throw new Error(`no commands found under ${commandsDir}`)
|
||||
|
||||
assertUniqueIdentifiers(entries)
|
||||
return entries
|
||||
@@ -247,10 +225,10 @@ export type GenerateOptions = {
|
||||
readonly mode: 'write' | 'check'
|
||||
}
|
||||
|
||||
export type GenerateResult
|
||||
= | { mode: 'write', wrote: boolean, path: string }
|
||||
| { mode: 'check', ok: true, path: string }
|
||||
| { mode: 'check', ok: false, path: string, diff: string }
|
||||
export type GenerateResult =
|
||||
| { mode: 'write'; wrote: boolean; path: string }
|
||||
| { mode: 'check'; ok: true; path: string }
|
||||
| { mode: 'check'; ok: false; path: string; diff: string }
|
||||
|
||||
export async function generate(opts: GenerateOptions): Promise<GenerateResult> {
|
||||
const entries = await discoverCommands(opts.commandsDir)
|
||||
@@ -262,12 +240,10 @@ export async function generate(opts: GenerateOptions): Promise<GenerateResult> {
|
||||
let onDisk = ''
|
||||
try {
|
||||
onDisk = await readFile(target, 'utf8')
|
||||
}
|
||||
catch {
|
||||
} catch {
|
||||
onDisk = ''
|
||||
}
|
||||
if (onDisk === content)
|
||||
return { mode: 'check', ok: true, path: target }
|
||||
if (onDisk === content) return { mode: 'check', ok: true, path: target }
|
||||
return { mode: 'check', ok: false, path: target, diff: shortDiff(onDisk, content) }
|
||||
}
|
||||
|
||||
@@ -284,10 +260,8 @@ function shortDiff(a: string, b: string): string {
|
||||
const max = Math.max(aLines.length, bLines.length)
|
||||
for (let i = 0; i < max; i++) {
|
||||
if (aLines[i] !== bLines[i]) {
|
||||
if (aLines[i] !== undefined)
|
||||
lines.push(`- ${aLines[i]}`)
|
||||
if (bLines[i] !== undefined)
|
||||
lines.push(`+ ${bLines[i]}`)
|
||||
if (aLines[i] !== undefined) lines.push(`- ${aLines[i]}`)
|
||||
if (bLines[i] !== undefined) lines.push(`+ ${bLines[i]}`)
|
||||
}
|
||||
}
|
||||
return lines.slice(0, 40).join('\n')
|
||||
@@ -307,12 +281,13 @@ async function main(): Promise<void> {
|
||||
process.stderr.write(`tree:check ok\n`)
|
||||
return
|
||||
}
|
||||
process.stderr.write(`tree:check FAILED — tree.generated.ts is stale.\nDiff (first 40 lines):\n${result.diff}\n\nRun \`pnpm tree:gen\` and commit.\n`)
|
||||
process.stderr.write(
|
||||
`tree:check FAILED — tree.generated.ts is stale.\nDiff (first 40 lines):\n${result.diff}\n\nRun \`pnpm tree:gen\` and commit.\n`,
|
||||
)
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
const invokedDirectly = process.argv[1] !== undefined
|
||||
&& fileURLToPath(import.meta.url) === process.argv[1]
|
||||
const invokedDirectly =
|
||||
process.argv[1] !== undefined && fileURLToPath(import.meta.url) === process.argv[1]
|
||||
|
||||
if (invokedDirectly)
|
||||
await main()
|
||||
if (invokedDirectly) await main()
|
||||
|
||||
@@ -41,18 +41,35 @@ const FETCH_STUB = [
|
||||
].join('\n')
|
||||
/* eslint-enable no-template-curly-in-string */
|
||||
|
||||
function runLib(program: string, env: Record<string, string> = {}): { code: number, stdout: string, stderr: string } {
|
||||
function runLib(
|
||||
program: string,
|
||||
env: Record<string, string> = {},
|
||||
): { code: number; stdout: string; stderr: string } {
|
||||
const full = `. "${SCRIPT}"\n${FETCH_STUB}\n${program}`
|
||||
const r = spawnSync('sh', ['-c', full], {
|
||||
encoding: 'utf8',
|
||||
env: { ...process.env, DIFYCTL_INSTALL_LIB: '1', DIFY_VERSION: '', DIFYCTL_VERSION: '', ...env },
|
||||
env: {
|
||||
...process.env,
|
||||
DIFYCTL_INSTALL_LIB: '1',
|
||||
DIFY_VERSION: '',
|
||||
DIFYCTL_VERSION: '',
|
||||
...env,
|
||||
},
|
||||
})
|
||||
return { code: r.status ?? 1, stdout: (r.stdout ?? '').trim(), stderr: r.stderr ?? '' }
|
||||
}
|
||||
|
||||
const REL_1142 = JSON.stringify({ tag_name: '1.14.2', assets: [{ name: 'difyctl-v0.2.0-linux-x64' }, { name: 'difyctl-v0.2.0-checksums.txt' }] })
|
||||
const REL_1150 = JSON.stringify({ tag_name: '1.15.0', assets: [{ name: 'difyctl-v0.3.0-linux-x64' }] })
|
||||
const LIST_NEWEST_FIRST = JSON.stringify({ releases: [{ tag_name: '1.15.0' }, { tag_name: '1.14.2' }] })
|
||||
const REL_1142 = JSON.stringify({
|
||||
tag_name: '1.14.2',
|
||||
assets: [{ name: 'difyctl-v0.2.0-linux-x64' }, { name: 'difyctl-v0.2.0-checksums.txt' }],
|
||||
})
|
||||
const REL_1150 = JSON.stringify({
|
||||
tag_name: '1.15.0',
|
||||
assets: [{ name: 'difyctl-v0.3.0-linux-x64' }],
|
||||
})
|
||||
const LIST_NEWEST_FIRST = JSON.stringify({
|
||||
releases: [{ tag_name: '1.15.0' }, { tag_name: '1.14.2' }],
|
||||
})
|
||||
|
||||
const RELEASE = JSON.stringify({
|
||||
tag_name: '1.14.2',
|
||||
@@ -117,7 +134,10 @@ describe('install-cli asset_version', () => {
|
||||
|
||||
describe('install-cli resolve_release', () => {
|
||||
it('DIFY_VERSION pins the release directly', () => {
|
||||
const r = runLib('resolve_release linux-x64; printf "%s" "$DIFY_TAG"', { DIFY_VERSION: '1.14.2', TAG_1_14_2: REL_1142 })
|
||||
const r = runLib('resolve_release linux-x64; printf "%s" "$DIFY_TAG"', {
|
||||
DIFY_VERSION: '1.14.2',
|
||||
TAG_1_14_2: REL_1142,
|
||||
})
|
||||
expect(r.code).toBe(0)
|
||||
expect(r.stdout).toBe('1.14.2')
|
||||
})
|
||||
@@ -129,7 +149,9 @@ describe('install-cli resolve_release', () => {
|
||||
})
|
||||
|
||||
it('blank resolves to the latest stable release', () => {
|
||||
const r = runLib('resolve_release linux-x64; printf "%s" "$DIFY_TAG"', { LATEST_JSON: REL_1150 })
|
||||
const r = runLib('resolve_release linux-x64; printf "%s" "$DIFY_TAG"', {
|
||||
LATEST_JSON: REL_1150,
|
||||
})
|
||||
expect(r.code).toBe(0)
|
||||
expect(r.stdout).toBe('1.15.0')
|
||||
})
|
||||
|
||||
@@ -5,9 +5,13 @@ import { describe, expect, it } from 'vitest'
|
||||
const SCRIPT = fileURLToPath(new URL('./install.ps1', import.meta.url))
|
||||
|
||||
function hasPwsh(): boolean {
|
||||
const r = spawnSync('pwsh', ['-NoProfile', '-NonInteractive', '-Command', '$PSVersionTable.PSVersion.Major'], {
|
||||
encoding: 'utf8',
|
||||
})
|
||||
const r = spawnSync(
|
||||
'pwsh',
|
||||
['-NoProfile', '-NonInteractive', '-Command', '$PSVersionTable.PSVersion.Major'],
|
||||
{
|
||||
encoding: 'utf8',
|
||||
},
|
||||
)
|
||||
return r.status === 0
|
||||
}
|
||||
|
||||
@@ -16,26 +20,26 @@ const PWSH = hasPwsh()
|
||||
const STUB = [
|
||||
'function Invoke-RestMethod {',
|
||||
' param([string]$Uri, $Headers)',
|
||||
' if ($Uri -like \'*/releases/latest\') {',
|
||||
' if (-not $env:HX_LATEST) { throw \'mock 404\' }',
|
||||
" if ($Uri -like '*/releases/latest') {",
|
||||
" if (-not $env:HX_LATEST) { throw 'mock 404' }",
|
||||
' return ($env:HX_LATEST | ConvertFrom-Json)',
|
||||
' }',
|
||||
' elseif ($Uri -like \'*/releases?per_page=100\') {',
|
||||
' if (-not $env:HX_LIST) { throw \'mock 404\' }',
|
||||
" elseif ($Uri -like '*/releases?per_page=100') {",
|
||||
" if (-not $env:HX_LIST) { throw 'mock 404' }",
|
||||
' return ($env:HX_LIST | ConvertFrom-Json)',
|
||||
' }',
|
||||
' elseif ($Uri -like \'*/releases/tags/*\') {',
|
||||
' $t = $Uri -replace \'.*/releases/tags/\', \'\'',
|
||||
' $k = \'HX_TAG_\' + ($t -replace \'[.\\-]\', \'_\')',
|
||||
" elseif ($Uri -like '*/releases/tags/*') {",
|
||||
" $t = $Uri -replace '.*/releases/tags/', ''",
|
||||
" $k = 'HX_TAG_' + ($t -replace '[.\\-]', '_')",
|
||||
' $v = [Environment]::GetEnvironmentVariable($k)',
|
||||
' if (-not $v) { throw \'mock 404\' }',
|
||||
" if (-not $v) { throw 'mock 404' }",
|
||||
' return ($v | ConvertFrom-Json)',
|
||||
' }',
|
||||
' throw "unexpected uri $Uri"',
|
||||
'}',
|
||||
].join('\n')
|
||||
|
||||
type Run = { code: number, stdout: string, stderr: string }
|
||||
type Run = { code: number; stdout: string; stderr: string }
|
||||
|
||||
function runPwsh(body: string, env: Record<string, string> = {}): Run {
|
||||
const script = `$ErrorActionPreference='Stop'\n${STUB}\n. '${SCRIPT}'\n${body}`
|
||||
@@ -54,8 +58,14 @@ function runPwsh(body: string, env: Record<string, string> = {}): Run {
|
||||
return { code: r.status ?? 1, stdout: (r.stdout ?? '').trim(), stderr: r.stderr ?? '' }
|
||||
}
|
||||
|
||||
const REL_1142 = JSON.stringify({ tag_name: '1.14.2', assets: [{ name: 'difyctl-v0.2.0-windows-x64.exe' }] })
|
||||
const REL_1150 = JSON.stringify({ tag_name: '1.15.0', assets: [{ name: 'difyctl-v0.3.0-windows-x64.exe' }] })
|
||||
const REL_1142 = JSON.stringify({
|
||||
tag_name: '1.14.2',
|
||||
assets: [{ name: 'difyctl-v0.2.0-windows-x64.exe' }],
|
||||
})
|
||||
const REL_1150 = JSON.stringify({
|
||||
tag_name: '1.15.0',
|
||||
assets: [{ name: 'difyctl-v0.3.0-windows-x64.exe' }],
|
||||
})
|
||||
const LIST_NEWEST_FIRST = JSON.stringify([
|
||||
{ tag_name: '1.15.0', assets: [{ name: 'difyctl-v0.3.0-windows-x64.exe' }] },
|
||||
{ tag_name: '1.14.2', assets: [{ name: 'difyctl-v0.2.0-windows-x64.exe' }] },
|
||||
@@ -63,25 +73,31 @@ const LIST_NEWEST_FIRST = JSON.stringify([
|
||||
|
||||
describe.skipIf(!PWSH)('install.ps1 Get-AssetSemver', () => {
|
||||
it('extracts the version from a windows .exe asset name', () => {
|
||||
const r = runPwsh('(Get-AssetSemver \'difyctl-v0.2.0-windows-x64.exe\').Version')
|
||||
const r = runPwsh("(Get-AssetSemver 'difyctl-v0.2.0-windows-x64.exe').Version")
|
||||
expect(r.code).toBe(0)
|
||||
expect(r.stdout).toBe('0.2.0')
|
||||
})
|
||||
|
||||
it('extracts a prerelease version and its rc number', () => {
|
||||
const r = runPwsh('$a = Get-AssetSemver \'difyctl-v0.1.0-rc.1-windows-x64.exe\'; "$($a.Version) $($a.Rc)"')
|
||||
const r = runPwsh(
|
||||
'$a = Get-AssetSemver \'difyctl-v0.1.0-rc.1-windows-x64.exe\'; "$($a.Version) $($a.Rc)"',
|
||||
)
|
||||
expect(r.code).toBe(0)
|
||||
expect(r.stdout).toBe('0.1.0-rc.1 1')
|
||||
})
|
||||
|
||||
it('rejects a non-windows asset (returns null)', () => {
|
||||
const r = runPwsh('if ($null -eq (Get-AssetSemver \'difyctl-v0.2.0-linux-x64\')) { \'NULL\' } else { \'OBJ\' }')
|
||||
const r = runPwsh(
|
||||
"if ($null -eq (Get-AssetSemver 'difyctl-v0.2.0-linux-x64')) { 'NULL' } else { 'OBJ' }",
|
||||
)
|
||||
expect(r.code).toBe(0)
|
||||
expect(r.stdout).toBe('NULL')
|
||||
})
|
||||
|
||||
it('rejects a malformed core version (returns null)', () => {
|
||||
const r = runPwsh('if ($null -eq (Get-AssetSemver \'difyctl-vx.y.z-windows-x64.exe\')) { \'NULL\' } else { \'OBJ\' }')
|
||||
const r = runPwsh(
|
||||
"if ($null -eq (Get-AssetSemver 'difyctl-vx.y.z-windows-x64.exe')) { 'NULL' } else { 'OBJ' }",
|
||||
)
|
||||
expect(r.code).toBe(0)
|
||||
expect(r.stdout).toBe('NULL')
|
||||
})
|
||||
@@ -89,33 +105,39 @@ describe.skipIf(!PWSH)('install.ps1 Get-AssetSemver', () => {
|
||||
|
||||
describe.skipIf(!PWSH)('install.ps1 Select-Asset', () => {
|
||||
it('picks the highest semver among several windows builds', () => {
|
||||
const rel = JSON.stringify({ assets: [
|
||||
{ name: 'difyctl-v0.2.0-windows-x64.exe' },
|
||||
{ name: 'difyctl-v0.10.0-windows-x64.exe' },
|
||||
{ name: 'difyctl-v0.9.0-windows-x64.exe' },
|
||||
] })
|
||||
const rel = JSON.stringify({
|
||||
assets: [
|
||||
{ name: 'difyctl-v0.2.0-windows-x64.exe' },
|
||||
{ name: 'difyctl-v0.10.0-windows-x64.exe' },
|
||||
{ name: 'difyctl-v0.9.0-windows-x64.exe' },
|
||||
],
|
||||
})
|
||||
const r = runPwsh(`(Select-Asset ('${rel}' | ConvertFrom-Json)).Version`)
|
||||
expect(r.code).toBe(0)
|
||||
expect(r.stdout).toBe('0.10.0')
|
||||
})
|
||||
|
||||
it('prefers the stable release over an rc of the same core', () => {
|
||||
const rel = JSON.stringify({ assets: [
|
||||
{ name: 'difyctl-v0.2.0-rc.1-windows-x64.exe' },
|
||||
{ name: 'difyctl-v0.2.0-windows-x64.exe' },
|
||||
] })
|
||||
const rel = JSON.stringify({
|
||||
assets: [
|
||||
{ name: 'difyctl-v0.2.0-rc.1-windows-x64.exe' },
|
||||
{ name: 'difyctl-v0.2.0-windows-x64.exe' },
|
||||
],
|
||||
})
|
||||
const r = runPwsh(`(Select-Asset ('${rel}' | ConvertFrom-Json)).Version`)
|
||||
expect(r.code).toBe(0)
|
||||
expect(r.stdout).toBe('0.2.0')
|
||||
})
|
||||
|
||||
it('ignores checksums and non-windows assets', () => {
|
||||
const rel = JSON.stringify({ assets: [
|
||||
{ name: 'difyctl-v0.2.0-linux-x64' },
|
||||
{ name: 'difyctl-v0.2.0-checksums.txt' },
|
||||
{ name: 'difyctl-v0.2.0-windows-x64.exe' },
|
||||
{ name: 'some-other-asset.zip' },
|
||||
] })
|
||||
const rel = JSON.stringify({
|
||||
assets: [
|
||||
{ name: 'difyctl-v0.2.0-linux-x64' },
|
||||
{ name: 'difyctl-v0.2.0-checksums.txt' },
|
||||
{ name: 'difyctl-v0.2.0-windows-x64.exe' },
|
||||
{ name: 'some-other-asset.zip' },
|
||||
],
|
||||
})
|
||||
const r = runPwsh(`(Select-Asset ('${rel}' | ConvertFrom-Json)).Name`)
|
||||
expect(r.code).toBe(0)
|
||||
expect(r.stdout).toBe('difyctl-v0.2.0-windows-x64.exe')
|
||||
@@ -123,7 +145,9 @@ describe.skipIf(!PWSH)('install.ps1 Select-Asset', () => {
|
||||
|
||||
it('yields null when no windows asset is present', () => {
|
||||
const rel = JSON.stringify({ assets: [{ name: 'difyctl-v0.2.0-linux-x64' }] })
|
||||
const r = runPwsh(`if ($null -eq (Select-Asset ('${rel}' | ConvertFrom-Json))) { 'NULL' } else { 'OBJ' }`)
|
||||
const r = runPwsh(
|
||||
`if ($null -eq (Select-Asset ('${rel}' | ConvertFrom-Json))) { 'NULL' } else { 'OBJ' }`,
|
||||
)
|
||||
expect(r.code).toBe(0)
|
||||
expect(r.stdout).toBe('NULL')
|
||||
})
|
||||
@@ -131,7 +155,10 @@ describe.skipIf(!PWSH)('install.ps1 Select-Asset', () => {
|
||||
|
||||
describe.skipIf(!PWSH)('install.ps1 Resolve-Release', () => {
|
||||
it('DIFY_VERSION pins the release directly', () => {
|
||||
const r = runPwsh('(Resolve-Release).tag_name', { DIFY_VERSION: '1.14.2', HX_TAG_1_14_2: REL_1142 })
|
||||
const r = runPwsh('(Resolve-Release).tag_name', {
|
||||
DIFY_VERSION: '1.14.2',
|
||||
HX_TAG_1_14_2: REL_1142,
|
||||
})
|
||||
expect(r.code).toBe(0)
|
||||
expect(r.stdout).toBe('1.14.2')
|
||||
})
|
||||
@@ -155,13 +182,19 @@ describe.skipIf(!PWSH)('install.ps1 Resolve-Release', () => {
|
||||
})
|
||||
|
||||
it('DIFYCTL_VERSION resolves to the release hosting that build', () => {
|
||||
const r = runPwsh('(Resolve-Release).tag_name', { DIFYCTL_VERSION: '0.2.0', HX_LIST: LIST_NEWEST_FIRST })
|
||||
const r = runPwsh('(Resolve-Release).tag_name', {
|
||||
DIFYCTL_VERSION: '0.2.0',
|
||||
HX_LIST: LIST_NEWEST_FIRST,
|
||||
})
|
||||
expect(r.code).toBe(0)
|
||||
expect(r.stdout).toBe('1.14.2')
|
||||
})
|
||||
|
||||
it('DIFYCTL_VERSION not hosted anywhere throws', () => {
|
||||
const r = runPwsh('(Resolve-Release).tag_name', { DIFYCTL_VERSION: '9.9.9', HX_LIST: LIST_NEWEST_FIRST })
|
||||
const r = runPwsh('(Resolve-Release).tag_name', {
|
||||
DIFYCTL_VERSION: '9.9.9',
|
||||
HX_LIST: LIST_NEWEST_FIRST,
|
||||
})
|
||||
expect(r.code).not.toBe(0)
|
||||
expect(r.stderr).toContain('difyctl 9.9.9 not found on any Dify release')
|
||||
})
|
||||
@@ -169,13 +202,16 @@ describe.skipIf(!PWSH)('install.ps1 Resolve-Release', () => {
|
||||
|
||||
describe.skipIf(!PWSH)('install.ps1 Find-ReleaseForDifyctl', () => {
|
||||
it('returns the newest release whose assets host the wanted build', () => {
|
||||
const r = runPwsh('(Find-ReleaseForDifyctl \'0.2.0\').tag_name', { HX_LIST: LIST_NEWEST_FIRST })
|
||||
const r = runPwsh("(Find-ReleaseForDifyctl '0.2.0').tag_name", { HX_LIST: LIST_NEWEST_FIRST })
|
||||
expect(r.code).toBe(0)
|
||||
expect(r.stdout).toBe('1.14.2')
|
||||
})
|
||||
|
||||
it('returns nothing when no release hosts the wanted build', () => {
|
||||
const r = runPwsh('$x = Find-ReleaseForDifyctl \'9.9.9\'; if ($null -eq $x) { \'NULL\' } else { $x.tag_name }', { HX_LIST: LIST_NEWEST_FIRST })
|
||||
const r = runPwsh(
|
||||
"$x = Find-ReleaseForDifyctl '9.9.9'; if ($null -eq $x) { 'NULL' } else { $x.tag_name }",
|
||||
{ HX_LIST: LIST_NEWEST_FIRST },
|
||||
)
|
||||
expect(r.code).toBe(0)
|
||||
expect(r.stdout).toBe('NULL')
|
||||
})
|
||||
|
||||
@@ -27,8 +27,7 @@ const GIT_PROBE_OPTS: ExecSyncOptions = {
|
||||
export const defaultGitProbe: GitProbe = (cmd) => {
|
||||
try {
|
||||
return execSync(cmd, GIT_PROBE_OPTS).toString().trim() || null
|
||||
}
|
||||
catch {
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
@@ -36,7 +35,7 @@ export const defaultGitProbe: GitProbe = (cmd) => {
|
||||
type PackageManifest = {
|
||||
difyctl?: {
|
||||
channel?: string
|
||||
compat?: { minDify?: string, maxDify?: string }
|
||||
compat?: { minDify?: string; maxDify?: string }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -48,8 +47,7 @@ const defaultPackageReader: PackageReader = () => {
|
||||
try {
|
||||
const pkgPath = resolve(dirname(fileURLToPath(import.meta.url)), '..', '..', 'package.json')
|
||||
return JSON.parse(readFileSync(pkgPath, 'utf8')) as PackageManifest
|
||||
}
|
||||
catch {
|
||||
} catch {
|
||||
return {}
|
||||
}
|
||||
}
|
||||
@@ -69,20 +67,12 @@ export function resolveBuildInfo(opts: ResolveOptions = {}): BuildInfo {
|
||||
|
||||
const channel = env.DIFYCTL_CHANNEL ?? pkg.difyctl?.channel ?? 'dev'
|
||||
if (!(BUILD_CHANNELS as readonly string[]).includes(channel)) {
|
||||
throw new Error(
|
||||
`invalid DIFYCTL_CHANNEL: ${channel} (expected ${BUILD_CHANNELS.join(' | ')})`,
|
||||
)
|
||||
throw new Error(`invalid DIFYCTL_CHANNEL: ${channel} (expected ${BUILD_CHANNELS.join(' | ')})`)
|
||||
}
|
||||
|
||||
const version
|
||||
= env.DIFYCTL_VERSION
|
||||
?? git('git describe --tags --dirty --always')
|
||||
?? '0.0.0-dev'
|
||||
const version = env.DIFYCTL_VERSION ?? git('git describe --tags --dirty --always') ?? '0.0.0-dev'
|
||||
|
||||
const commit
|
||||
= env.DIFYCTL_COMMIT
|
||||
?? git('git rev-parse HEAD')
|
||||
?? 'none'
|
||||
const commit = env.DIFYCTL_COMMIT ?? git('git rev-parse HEAD') ?? 'none'
|
||||
|
||||
const buildDate = env.DIFYCTL_BUILD_DATE ?? now().toISOString()
|
||||
const minDify = env.DIFYCTL_MIN_DIFY ?? pkg.difyctl?.compat?.minDify ?? '0.0.0'
|
||||
|
||||
@@ -2,8 +2,8 @@ import { resolveBuildInfo } from './lib/resolve-buildinfo.js'
|
||||
|
||||
const info = resolveBuildInfo()
|
||||
process.stdout.write(
|
||||
`version: ${info.version}\n`
|
||||
+ `commit: ${info.commit}\n`
|
||||
+ `built: ${info.buildDate}\n`
|
||||
+ `channel: ${info.channel}\n`,
|
||||
`version: ${info.version}\n` +
|
||||
`commit: ${info.commit}\n` +
|
||||
`built: ${info.buildDate}\n` +
|
||||
`channel: ${info.channel}\n`,
|
||||
)
|
||||
|
||||
@@ -32,8 +32,8 @@ const CHANNELS = [
|
||||
{ name: 'rc', prerelease: true, versionForm: /^\d+\.\d+\.\d+-rc\.\d+$/ },
|
||||
]
|
||||
|
||||
const channelByName = name => CHANNELS.find(c => c.name === name)
|
||||
const channelNames = () => CHANNELS.map(c => c.name).join(', ')
|
||||
const channelByName = (name) => CHANNELS.find((c) => c.name === name)
|
||||
const channelNames = () => CHANNELS.map((c) => c.name).join(', ')
|
||||
|
||||
function parsePrecedence(v) {
|
||||
const s = String(v).replace(/^v/, '').replace(/\+.*$/, '')
|
||||
@@ -48,21 +48,16 @@ function comparePre(a, b) {
|
||||
const bparts = b.split('.')
|
||||
const len = Math.max(aparts.length, bparts.length)
|
||||
for (let i = 0; i < len; i++) {
|
||||
if (aparts[i] === undefined)
|
||||
return -1
|
||||
if (bparts[i] === undefined)
|
||||
return 1
|
||||
if (aparts[i] === undefined) return -1
|
||||
if (bparts[i] === undefined) return 1
|
||||
const an = /^\d+$/.test(aparts[i])
|
||||
const bn = /^\d+$/.test(bparts[i])
|
||||
if (an && bn) {
|
||||
const d = Number(aparts[i]) - Number(bparts[i])
|
||||
if (d !== 0)
|
||||
return d < 0 ? -1 : 1
|
||||
}
|
||||
else if (an !== bn) {
|
||||
if (d !== 0) return d < 0 ? -1 : 1
|
||||
} else if (an !== bn) {
|
||||
return an ? -1 : 1
|
||||
}
|
||||
else if (aparts[i] !== bparts[i]) {
|
||||
} else if (aparts[i] !== bparts[i]) {
|
||||
return aparts[i] < bparts[i] ? -1 : 1
|
||||
}
|
||||
}
|
||||
@@ -75,15 +70,11 @@ function comparePrecedence(a, b) {
|
||||
for (let i = 0; i < SEMVER_CORE_LEN; i++) {
|
||||
const x = A.nums[i] ?? 0
|
||||
const y = B.nums[i] ?? 0
|
||||
if (x !== y)
|
||||
return x < y ? -1 : 1
|
||||
if (x !== y) return x < y ? -1 : 1
|
||||
}
|
||||
if (A.pre === B.pre)
|
||||
return 0
|
||||
if (A.pre === '')
|
||||
return 1
|
||||
if (B.pre === '')
|
||||
return -1
|
||||
if (A.pre === B.pre) return 0
|
||||
if (A.pre === '') return 1
|
||||
if (B.pre === '') return -1
|
||||
return comparePre(A.pre, B.pre)
|
||||
}
|
||||
|
||||
@@ -95,8 +86,7 @@ function die(msg) {
|
||||
function loadPkg() {
|
||||
const pkgUrl = new URL('../package.json', import.meta.url)
|
||||
const pkg = JSON.parse(readFileSync(pkgUrl, 'utf8'))
|
||||
if (!pkg.difyctl?.release)
|
||||
die('cli/package.json missing difyctl.release')
|
||||
if (!pkg.difyctl?.release) die('cli/package.json missing difyctl.release')
|
||||
return {
|
||||
version: pkg.version,
|
||||
channel: pkg.difyctl.channel,
|
||||
@@ -119,32 +109,29 @@ function githubEnv() {
|
||||
tagPrefix: release.tagPrefix,
|
||||
difyctlTag: `${release.tagPrefix}${version}`,
|
||||
}
|
||||
return Object.entries(fields).map(([k, v]) => `${k}=${v}`).join('\n')
|
||||
return Object.entries(fields)
|
||||
.map(([k, v]) => `${k}=${v}`)
|
||||
.join('\n')
|
||||
}
|
||||
|
||||
function requireVersion(version) {
|
||||
if (!version)
|
||||
die('version argument is required')
|
||||
if (!version) die('version argument is required')
|
||||
return version
|
||||
}
|
||||
|
||||
function assetName(release, version, id) {
|
||||
const target = release.targets.find(t => t.id === id)
|
||||
if (!target)
|
||||
die(`unknown target id: ${id}`)
|
||||
const target = release.targets.find((t) => t.id === id)
|
||||
if (!target) die(`unknown target id: ${id}`)
|
||||
const suffix = target.exe ? '.exe' : ''
|
||||
return `${release.tagPrefix}${version}-${id}${suffix}`
|
||||
}
|
||||
|
||||
function validateRelease(release) {
|
||||
const problems = []
|
||||
const str = v => typeof v === 'string' && v.length > 0
|
||||
if (!str(release.tagPrefix))
|
||||
problems.push('tagPrefix must be a non-empty string')
|
||||
if (!str(release.binName))
|
||||
problems.push('binName must be a non-empty string')
|
||||
if (!str(release.checksumsSuffix))
|
||||
problems.push('checksumsSuffix must be a non-empty string')
|
||||
const str = (v) => typeof v === 'string' && v.length > 0
|
||||
if (!str(release.tagPrefix)) problems.push('tagPrefix must be a non-empty string')
|
||||
if (!str(release.binName)) problems.push('binName must be a non-empty string')
|
||||
if (!str(release.checksumsSuffix)) problems.push('checksumsSuffix must be a non-empty string')
|
||||
if (!Array.isArray(release.targets) || release.targets.length === 0) {
|
||||
problems.push('targets must be a non-empty array')
|
||||
return problems
|
||||
@@ -152,15 +139,12 @@ function validateRelease(release) {
|
||||
const seen = new Set()
|
||||
for (const t of release.targets) {
|
||||
const label = t?.id ?? JSON.stringify(t)
|
||||
if (!str(t?.id))
|
||||
problems.push(`target ${label}: id must be a non-empty string`)
|
||||
else if (seen.has(t.id))
|
||||
problems.push(`duplicate target id: ${t.id}`)
|
||||
if (!str(t?.id)) problems.push(`target ${label}: id must be a non-empty string`)
|
||||
else if (seen.has(t.id)) problems.push(`duplicate target id: ${t.id}`)
|
||||
else seen.add(t.id)
|
||||
if (!str(t?.bunTarget) || !BUN_TARGET_RE.test(t.bunTarget))
|
||||
problems.push(`target ${label}: bunTarget must match ${BUN_TARGET_RE}`)
|
||||
if (typeof t?.exe !== 'boolean')
|
||||
problems.push(`target ${label}: exe must be a boolean`)
|
||||
if (typeof t?.exe !== 'boolean') problems.push(`target ${label}: exe must be a boolean`)
|
||||
else if (str(t?.bunTarget) && t.exe !== t.bunTarget.startsWith('bun-windows-'))
|
||||
problems.push(`target ${label}: exe must be true iff bunTarget is bun-windows-*`)
|
||||
}
|
||||
@@ -176,9 +160,13 @@ function validateVersionChannel(version, channel) {
|
||||
return ['package.json version must be a non-empty string']
|
||||
const ch = channelByName(channel)
|
||||
if (!ch)
|
||||
return [`difyctl.channel ${JSON.stringify(channel)} is not a known channel (expected one of: ${channelNames()})`]
|
||||
return [
|
||||
`difyctl.channel ${JSON.stringify(channel)} is not a known channel (expected one of: ${channelNames()})`,
|
||||
]
|
||||
if (!ch.versionForm.test(version))
|
||||
problems.push(`version "${version}" does not match the ${channel} channel form ${ch.versionForm}; an installer could not resolve it`)
|
||||
problems.push(
|
||||
`version "${version}" does not match the ${channel} channel form ${ch.versionForm}; an installer could not resolve it`,
|
||||
)
|
||||
return problems
|
||||
}
|
||||
|
||||
@@ -188,7 +176,11 @@ function main(argv) {
|
||||
case 'tag':
|
||||
return `${loadPkg().release.tagPrefix}${requireVersion(rest[0])}`
|
||||
case 'asset':
|
||||
return assetName(loadPkg().release, requireVersion(rest[0]), rest[1] ?? die('target id is required'))
|
||||
return assetName(
|
||||
loadPkg().release,
|
||||
requireVersion(rest[0]),
|
||||
rest[1] ?? die('target id is required'),
|
||||
)
|
||||
case 'checksums': {
|
||||
const { release } = loadPkg()
|
||||
return `${release.tagPrefix}${requireVersion(rest[0])}${release.checksumsSuffix}`
|
||||
@@ -196,9 +188,11 @@ function main(argv) {
|
||||
case 'tag-prefix':
|
||||
return loadPkg().release.tagPrefix
|
||||
case 'targets':
|
||||
return loadPkg().release.targets.map(t => `${t.bunTarget}\t${t.id}\t${t.exe ? 1 : 0}`).join('\n')
|
||||
return loadPkg()
|
||||
.release.targets.map((t) => `${t.bunTarget}\t${t.id}\t${t.exe ? 1 : 0}`)
|
||||
.join('\n')
|
||||
case 'channels':
|
||||
return CHANNELS.map(c => c.name).join('\n')
|
||||
return CHANNELS.map((c) => c.name).join('\n')
|
||||
case 'github-env':
|
||||
return githubEnv()
|
||||
case 'compat-check': {
|
||||
@@ -206,14 +200,18 @@ function main(argv) {
|
||||
const difyVersion = requireVersion(rest[0])
|
||||
if (!compat.minDify || !compat.maxDify)
|
||||
die('cli/package.json missing difyctl.compat.minDify/maxDify')
|
||||
if (comparePrecedence(difyVersion, compat.minDify) < 0 || comparePrecedence(difyVersion, compat.maxDify) > 0)
|
||||
die(`Dify ${difyVersion} is outside difyctl compatibility window ${compat.minDify}..${compat.maxDify}; bump difyctl.compat in cli/package.json`)
|
||||
if (
|
||||
comparePrecedence(difyVersion, compat.minDify) < 0 ||
|
||||
comparePrecedence(difyVersion, compat.maxDify) > 0
|
||||
)
|
||||
die(
|
||||
`Dify ${difyVersion} is outside difyctl compatibility window ${compat.minDify}..${compat.maxDify}; bump difyctl.compat in cli/package.json`,
|
||||
)
|
||||
return `compatible: Dify ${difyVersion} within ${compat.minDify}..${compat.maxDify}`
|
||||
}
|
||||
case 'prerelease': {
|
||||
const ch = channelByName(rest[0] ?? die('channel argument is required'))
|
||||
if (!ch)
|
||||
die(`unknown channel: ${rest[0]} (expected one of: ${channelNames()})`)
|
||||
if (!ch) die(`unknown channel: ${rest[0]} (expected one of: ${channelNames()})`)
|
||||
return String(ch.prerelease)
|
||||
}
|
||||
case 'validate': {
|
||||
|
||||
@@ -4,13 +4,12 @@ import { describe, expect, it } from 'vitest'
|
||||
|
||||
const SCRIPT = fileURLToPath(new URL('./release-naming.mjs', import.meta.url))
|
||||
|
||||
function run(args: string[]): { code: number, stdout: string, stderr: string } {
|
||||
function run(args: string[]): { code: number; stdout: string; stderr: string } {
|
||||
try {
|
||||
const stdout = execFileSync('node', [SCRIPT, ...args], { encoding: 'utf8' })
|
||||
return { code: 0, stdout, stderr: '' }
|
||||
}
|
||||
catch (e) {
|
||||
const err = e as { status?: number, stdout?: string, stderr?: string }
|
||||
} catch (e) {
|
||||
const err = e as { status?: number; stdout?: string; stderr?: string }
|
||||
return { code: err.status ?? 1, stdout: err.stdout ?? '', stderr: err.stderr ?? '' }
|
||||
}
|
||||
}
|
||||
|
||||
+26
-13
@@ -1,7 +1,7 @@
|
||||
#!/usr/bin/env -S bun
|
||||
import { execSync } from 'node:child_process'
|
||||
|
||||
type Check = { name: string, run: () => void }
|
||||
type Check = { name: string; run: () => void }
|
||||
|
||||
const baseUrlIdx = process.argv.indexOf('--base-url')
|
||||
const baseUrl = baseUrlIdx > -1 ? process.argv[baseUrlIdx + 1] : 'http://localhost:5001'
|
||||
@@ -17,16 +17,30 @@ function cli(args: string): string {
|
||||
}
|
||||
|
||||
const checks: Check[] = [
|
||||
{ name: 'config show', run: () => { cli('config show') } },
|
||||
{ name: 'get workspace', run: () => {
|
||||
if (!cli('get workspace').includes('id'))
|
||||
throw new Error('no workspace listed')
|
||||
} },
|
||||
{ name: 'get apps', run: () => { cli('get apps') } },
|
||||
{ name: 'difyctl version prints compat', run: () => {
|
||||
if (!cli('version').includes('compat:'))
|
||||
throw new Error('no compat line')
|
||||
} },
|
||||
{
|
||||
name: 'config show',
|
||||
run: () => {
|
||||
cli('config show')
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'get workspace',
|
||||
run: () => {
|
||||
if (!cli('get workspace').includes('id')) throw new Error('no workspace listed')
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'get apps',
|
||||
run: () => {
|
||||
cli('get apps')
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'difyctl version prints compat',
|
||||
run: () => {
|
||||
if (!cli('version').includes('compat:')) throw new Error('no compat line')
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
let failed = 0
|
||||
@@ -34,8 +48,7 @@ for (const c of checks) {
|
||||
try {
|
||||
c.run()
|
||||
console.log(`[x] ${c.name}`)
|
||||
}
|
||||
catch (err) {
|
||||
} catch (err) {
|
||||
failed++
|
||||
console.log(`[ ] ${c.name} — ${(err as Error).message}`)
|
||||
}
|
||||
|
||||
@@ -19,7 +19,7 @@ describe('AccountSessionsClient.list', () => {
|
||||
})
|
||||
|
||||
it('GETs account/sessions with no query when paging is unset', async () => {
|
||||
stub = await startStubServer(cap => jsonResponder(200, LIST_BODY, cap))
|
||||
stub = await startStubServer((cap) => jsonResponder(200, LIST_BODY, cap))
|
||||
|
||||
await makeClient(stub.url).list()
|
||||
|
||||
@@ -29,7 +29,7 @@ describe('AccountSessionsClient.list', () => {
|
||||
})
|
||||
|
||||
it('forwards page/limit when supplied', async () => {
|
||||
stub = await startStubServer(cap => jsonResponder(200, LIST_BODY, cap))
|
||||
stub = await startStubServer((cap) => jsonResponder(200, LIST_BODY, cap))
|
||||
|
||||
await makeClient(stub.url).list({ page: 2, limit: 25 })
|
||||
|
||||
@@ -50,7 +50,7 @@ describe('AccountSessionsClient.revoke', () => {
|
||||
// The server replies 200 + {status:"revoked"}; revoke() returns void but the
|
||||
// typed client still parses the body — this guards against a regression where
|
||||
// a non-empty 200 body trips JSON handling.
|
||||
stub = await startStubServer(cap => jsonResponder(200, { status: 'revoked' }, cap))
|
||||
stub = await startStubServer((cap) => jsonResponder(200, { status: 'revoked' }, cap))
|
||||
|
||||
await expect(makeClient(stub.url).revoke('sess-1')).resolves.toBeUndefined()
|
||||
expect(stub.captured.method).toBe('DELETE')
|
||||
@@ -58,7 +58,7 @@ describe('AccountSessionsClient.revoke', () => {
|
||||
})
|
||||
|
||||
it('URL-encodes the session id', async () => {
|
||||
stub = await startStubServer(cap => jsonResponder(200, { status: 'revoked' }, cap))
|
||||
stub = await startStubServer((cap) => jsonResponder(200, { status: 'revoked' }, cap))
|
||||
|
||||
await makeClient(stub.url).revoke('sess/1 2')
|
||||
|
||||
@@ -66,16 +66,17 @@ describe('AccountSessionsClient.revoke', () => {
|
||||
})
|
||||
|
||||
it('propagates 404 as a classified BaseError', async () => {
|
||||
stub = await startStubServer(cap =>
|
||||
jsonResponder(404, { error: { code: 'not_found', message: 'session not found' } }, cap))
|
||||
stub = await startStubServer((cap) =>
|
||||
jsonResponder(404, { error: { code: 'not_found', message: 'session not found' } }, cap),
|
||||
)
|
||||
|
||||
await expect(makeClient(stub.url).revoke('missing')).rejects.toSatisfy(
|
||||
err => isHttpClientError(err) && err.httpStatus === 404,
|
||||
(err) => isHttpClientError(err) && err.httpStatus === 404,
|
||||
)
|
||||
})
|
||||
|
||||
it('revokeSelf DELETEs the self subresource', async () => {
|
||||
stub = await startStubServer(cap => jsonResponder(200, { status: 'revoked' }, cap))
|
||||
stub = await startStubServer((cap) => jsonResponder(200, { status: 'revoked' }, cap))
|
||||
|
||||
await expect(makeClient(stub.url).revokeSelf()).resolves.toBeUndefined()
|
||||
expect(stub.captured.method).toBe('DELETE')
|
||||
|
||||
@@ -10,7 +10,7 @@ export class AccountSessionsClient {
|
||||
this.orpc = createOpenApiClient(http)
|
||||
}
|
||||
|
||||
async list(q?: { page?: number, limit?: number }): Promise<SessionListResponse> {
|
||||
async list(q?: { page?: number; limit?: number }): Promise<SessionListResponse> {
|
||||
return this.orpc.account.sessions.get({ query: { page: q?.page, limit: q?.limit } })
|
||||
}
|
||||
|
||||
|
||||
@@ -17,11 +17,16 @@ describe('AccountClient.get', () => {
|
||||
})
|
||||
|
||||
it('GETs account, sends the bearer, and returns the parsed payload', async () => {
|
||||
stub = await startStubServer(cap =>
|
||||
jsonResponder(200, {
|
||||
subject_type: 'account',
|
||||
account: { id: 'acct-1', email: 'a@e.com', name: 'A' },
|
||||
}, cap))
|
||||
stub = await startStubServer((cap) =>
|
||||
jsonResponder(
|
||||
200,
|
||||
{
|
||||
subject_type: 'account',
|
||||
account: { id: 'acct-1', email: 'a@e.com', name: 'A' },
|
||||
},
|
||||
cap,
|
||||
),
|
||||
)
|
||||
|
||||
const res = await makeClient(stub.url).get()
|
||||
|
||||
@@ -32,10 +37,10 @@ describe('AccountClient.get', () => {
|
||||
})
|
||||
|
||||
it('maps 401 to a classified BaseError', async () => {
|
||||
stub = await startStubServer(cap => jsonResponder(401, { error: 'expired' }, cap))
|
||||
stub = await startStubServer((cap) => jsonResponder(401, { error: 'expired' }, cap))
|
||||
|
||||
await expect(makeClient(stub.url).get()).rejects.toSatisfy(
|
||||
err => isHttpClientError(err) && err.httpStatus === 401,
|
||||
(err) => isHttpClientError(err) && err.httpStatus === 401,
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
+21
-11
@@ -21,7 +21,7 @@ describe('AppDslClient.exportDsl', () => {
|
||||
})
|
||||
|
||||
it('returns the data string from the response', async () => {
|
||||
stub = await startStubServer(cap => jsonResponder(200, { data: DSL_YAML }, cap))
|
||||
stub = await startStubServer((cap) => jsonResponder(200, { data: DSL_YAML }, cap))
|
||||
|
||||
const yaml = await makeClient(stub.url).exportDsl('app-1')
|
||||
|
||||
@@ -31,16 +31,18 @@ describe('AppDslClient.exportDsl', () => {
|
||||
})
|
||||
|
||||
it('throws when response has no data field', async () => {
|
||||
stub = await startStubServer(cap => jsonResponder(200, { wrong: 1 }, cap))
|
||||
stub = await startStubServer((cap) => jsonResponder(200, { wrong: 1 }, cap))
|
||||
|
||||
await expect(makeClient(stub.url).exportDsl('app-1')).rejects.toThrow('export response missing data field')
|
||||
await expect(makeClient(stub.url).exportDsl('app-1')).rejects.toThrow(
|
||||
'export response missing data field',
|
||||
)
|
||||
})
|
||||
|
||||
it('propagates 404 as a classified HttpClientError', async () => {
|
||||
stub = await startStubServer(cap => jsonResponder(404, { error: 'not_found' }, cap))
|
||||
stub = await startStubServer((cap) => jsonResponder(404, { error: 'not_found' }, cap))
|
||||
|
||||
await expect(makeClient(stub.url).exportDsl('missing')).rejects.toSatisfy(
|
||||
err => isHttpClientError(err) && err.httpStatus === 404,
|
||||
(err) => isHttpClientError(err) && err.httpStatus === 404,
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -53,7 +55,7 @@ describe('AppDslClient.importApp', () => {
|
||||
})
|
||||
|
||||
it('POST to /workspaces/:id/apps/imports with body and returns Import', async () => {
|
||||
stub = await startStubServer(cap => jsonResponder(200, COMPLETED_IMPORT, cap))
|
||||
stub = await startStubServer((cap) => jsonResponder(200, COMPLETED_IMPORT, cap))
|
||||
|
||||
const result = await makeClient(stub.url).importApp('ws-1', {
|
||||
mode: 'yaml-content',
|
||||
@@ -67,10 +69,18 @@ describe('AppDslClient.importApp', () => {
|
||||
})
|
||||
|
||||
it('returns pending import on 202', async () => {
|
||||
const pending = { id: 'imp-1', status: 'pending', current_dsl_version: '0.1.4', imported_dsl_version: '0.0.9' }
|
||||
stub = await startStubServer(cap => jsonResponder(202, pending, cap))
|
||||
const pending = {
|
||||
id: 'imp-1',
|
||||
status: 'pending',
|
||||
current_dsl_version: '0.1.4',
|
||||
imported_dsl_version: '0.0.9',
|
||||
}
|
||||
stub = await startStubServer((cap) => jsonResponder(202, pending, cap))
|
||||
|
||||
const result = await makeClient(stub.url).importApp('ws-1', { mode: 'yaml-content', yaml_content: DSL_YAML })
|
||||
const result = await makeClient(stub.url).importApp('ws-1', {
|
||||
mode: 'yaml-content',
|
||||
yaml_content: DSL_YAML,
|
||||
})
|
||||
|
||||
expect(result.status).toBe('pending')
|
||||
expect(result.id).toBe('imp-1')
|
||||
@@ -85,7 +95,7 @@ describe('AppDslClient.confirmImport', () => {
|
||||
})
|
||||
|
||||
it('POST to confirm URL and returns completed Import', async () => {
|
||||
stub = await startStubServer(cap => jsonResponder(200, COMPLETED_IMPORT, cap))
|
||||
stub = await startStubServer((cap) => jsonResponder(200, COMPLETED_IMPORT, cap))
|
||||
|
||||
const result = await makeClient(stub.url).confirmImport('ws-1', 'imp-1')
|
||||
|
||||
@@ -103,7 +113,7 @@ describe('AppDslClient.checkDependencies', () => {
|
||||
})
|
||||
|
||||
it('returns empty leaked_dependencies on healthy app', async () => {
|
||||
stub = await startStubServer(cap => jsonResponder(200, { leaked_dependencies: [] }, cap))
|
||||
stub = await startStubServer((cap) => jsonResponder(200, { leaked_dependencies: [] }, cap))
|
||||
|
||||
const result = await makeClient(stub.url).checkDependencies('app-1')
|
||||
|
||||
|
||||
@@ -35,19 +35,19 @@ export class AppDslClient {
|
||||
async exportDsl(appId: string, query?: ExportQuery): Promise<string> {
|
||||
const resp = await this.orpc.apps.byAppId.export.get({
|
||||
params: { app_id: appId },
|
||||
query: query !== undefined
|
||||
? {
|
||||
include_secret: query.includeSecret,
|
||||
workflow_id: query.workflowId,
|
||||
}
|
||||
: undefined,
|
||||
query:
|
||||
query !== undefined
|
||||
? {
|
||||
include_secret: query.includeSecret,
|
||||
workflow_id: query.workflowId,
|
||||
}
|
||||
: undefined,
|
||||
})
|
||||
// The response schema is an open object {"data": "<yaml string>"}; the
|
||||
// contract generator marks it as loose because the backend annotation
|
||||
// does not narrow the shape. Extract `data` directly.
|
||||
const data = (resp as Record<string, unknown>).data
|
||||
if (typeof data !== 'string')
|
||||
throw new Error('export response missing data field')
|
||||
if (typeof data !== 'string') throw new Error('export response missing data field')
|
||||
return data
|
||||
}
|
||||
|
||||
|
||||
@@ -23,10 +23,8 @@ describe('AppMetaClient', () => {
|
||||
process.env[ENV_CACHE_DIR] = dir
|
||||
})
|
||||
afterEach(async () => {
|
||||
if (prevCacheDir === undefined)
|
||||
delete process.env[ENV_CACHE_DIR]
|
||||
else
|
||||
process.env[ENV_CACHE_DIR] = prevCacheDir
|
||||
if (prevCacheDir === undefined) delete process.env[ENV_CACHE_DIR]
|
||||
else process.env[ENV_CACHE_DIR] = prevCacheDir
|
||||
await mock.stop()
|
||||
await rm(dir, { recursive: true, force: true })
|
||||
})
|
||||
@@ -61,15 +59,29 @@ describe('AppMetaClient', () => {
|
||||
})
|
||||
|
||||
it('expired cache entry refetches', async () => {
|
||||
const cache = await loadAppInfoCache({ store: getCache(CACHE_APP_INFO), ttlMs: 100, now: () => new Date('2026-05-09T00:00:00Z') })
|
||||
const cache = await loadAppInfoCache({
|
||||
store: getCache(CACHE_APP_INFO),
|
||||
ttlMs: 100,
|
||||
now: () => new Date('2026-05-09T00:00:00Z'),
|
||||
})
|
||||
const apps = new AppsClient(testHttpClient(mock.url, 'dfoa_test'))
|
||||
const spy = vi.spyOn(apps, 'describe')
|
||||
const client = new AppMetaClient({ apps, host: mock.url, cache, now: () => new Date('2026-05-09T00:00:00Z') })
|
||||
const client = new AppMetaClient({
|
||||
apps,
|
||||
host: mock.url,
|
||||
cache,
|
||||
now: () => new Date('2026-05-09T00:00:00Z'),
|
||||
})
|
||||
|
||||
await client.get('app-1', [FieldInfo])
|
||||
expect(spy).toHaveBeenCalledTimes(1)
|
||||
|
||||
const client2 = new AppMetaClient({ apps, host: mock.url, cache, now: () => new Date('2026-05-09T00:00:01Z') })
|
||||
const client2 = new AppMetaClient({
|
||||
apps,
|
||||
host: mock.url,
|
||||
cache,
|
||||
now: () => new Date('2026-05-09T00:00:01Z'),
|
||||
})
|
||||
await client2.get('app-1', [FieldInfo])
|
||||
expect(spy).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
+11
-8
@@ -25,21 +25,24 @@ export class AppMetaClient {
|
||||
|
||||
async get(appId: string, fields: readonly AppMetaFieldKey[] = []): Promise<AppMeta> {
|
||||
const cached = this.cache?.get(this.host, appId)
|
||||
if (cached !== undefined && this.cache?.isFresh(cached, this.now()) === true && covers(cached.meta, fields))
|
||||
if (
|
||||
cached !== undefined &&
|
||||
this.cache?.isFresh(cached, this.now()) === true &&
|
||||
covers(cached.meta, fields)
|
||||
)
|
||||
return cached.meta
|
||||
|
||||
const resp = await this.apps.describe(appId, fields.length === 0 ? undefined : fields)
|
||||
const fresh = fromDescribe(resp, fields)
|
||||
const merged = cached !== undefined && this.cache?.isFresh(cached, this.now()) === true
|
||||
? mergeMeta(cached.meta, fresh)
|
||||
: fresh
|
||||
if (this.cache !== undefined)
|
||||
await this.cache.set(this.host, appId, merged)
|
||||
const merged =
|
||||
cached !== undefined && this.cache?.isFresh(cached, this.now()) === true
|
||||
? mergeMeta(cached.meta, fresh)
|
||||
: fresh
|
||||
if (this.cache !== undefined) await this.cache.set(this.host, appId, merged)
|
||||
return merged
|
||||
}
|
||||
|
||||
async invalidate(appId: string): Promise<void> {
|
||||
if (this.cache !== undefined)
|
||||
await this.cache.delete(this.host, appId)
|
||||
if (this.cache !== undefined) await this.cache.delete(this.host, appId)
|
||||
}
|
||||
}
|
||||
|
||||
+17
-10
@@ -75,21 +75,24 @@ describe('AppRunClient.runStream', () => {
|
||||
it('throws typed BaseError on non-2xx open', async () => {
|
||||
mock.setScenario('server-5xx')
|
||||
const c = new AppRunClient(testHttpClient(mock.url, { bearer: 'dfoa_test', retryAttempts: 0 }))
|
||||
await expect(
|
||||
c.runStream('app-1', buildRunBody({ message: 'hi' })),
|
||||
).rejects.toMatchObject({ code: 'server_5xx' })
|
||||
await expect(c.runStream('app-1', buildRunBody({ message: 'hi' }))).rejects.toMatchObject({
|
||||
code: 'server_5xx',
|
||||
})
|
||||
})
|
||||
|
||||
it('aborts when signal fires', async () => {
|
||||
expect.assertions(1)
|
||||
const c = new AppRunClient(testHttpClient(mock.url, 'dfoa_test'))
|
||||
const ctrl = new AbortController()
|
||||
const iter = await c.runStream('app-1', buildRunBody({ message: 'hi' }), { signal: ctrl.signal })
|
||||
const iter = await c.runStream('app-1', buildRunBody({ message: 'hi' }), {
|
||||
signal: ctrl.signal,
|
||||
})
|
||||
ctrl.abort()
|
||||
try {
|
||||
for await (const _ of iter) { /* drain */ }
|
||||
}
|
||||
catch (e) {
|
||||
for await (const _ of iter) {
|
||||
/* drain */
|
||||
}
|
||||
} catch (e) {
|
||||
expect((e as Error).name).toBe('AbortError')
|
||||
}
|
||||
})
|
||||
@@ -98,9 +101,13 @@ describe('AppRunClient.runStream', () => {
|
||||
const c = new AppRunClient(testHttpClient(mock.url, 'dfoa_test'))
|
||||
const iter = await c.runStream('app-2', buildRunBody({ inputs: { x: '1' } }))
|
||||
const names: string[] = []
|
||||
for await (const ev of iter)
|
||||
names.push(ev.name)
|
||||
expect(names).toEqual(['workflow_started', 'node_started', 'node_finished', 'workflow_finished'])
|
||||
for await (const ev of iter) names.push(ev.name)
|
||||
expect(names).toEqual([
|
||||
'workflow_started',
|
||||
'node_started',
|
||||
'node_finished',
|
||||
'workflow_finished',
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
+5
-10
@@ -18,16 +18,13 @@ export function buildRunBody(args: RunBodyArgs): Record<string, unknown> {
|
||||
const body: Record<string, unknown> = {
|
||||
inputs: args.inputs ?? {},
|
||||
}
|
||||
if (args.message !== undefined && args.message !== '')
|
||||
body.query = args.message
|
||||
if (args.message !== undefined && args.message !== '') body.query = args.message
|
||||
if (args.conversationId !== undefined && args.conversationId !== '')
|
||||
body.conversation_id = args.conversationId
|
||||
if (args.workspaceId !== undefined && args.workspaceId !== '')
|
||||
body.workspace_id = args.workspaceId
|
||||
if (args.workflowId !== undefined && args.workflowId !== '')
|
||||
body.workflow_id = args.workflowId
|
||||
if (args.files !== undefined && args.files.length > 0)
|
||||
body.files = args.files
|
||||
if (args.workflowId !== undefined && args.workflowId !== '') body.workflow_id = args.workflowId
|
||||
if (args.files !== undefined && args.files.length > 0) body.files = args.files
|
||||
return body
|
||||
}
|
||||
|
||||
@@ -62,8 +59,7 @@ export class AppRunClient {
|
||||
throwOnError: true,
|
||||
retryOnRateLimit: opts.retryOnRateLimit,
|
||||
})
|
||||
if (res.body === null)
|
||||
throw new Error('streaming response body missing')
|
||||
if (res.body === null) throw new Error('streaming response body missing')
|
||||
return normalizeDifyStream(parseSSE(res.body, opts.signal))
|
||||
}
|
||||
|
||||
@@ -100,8 +96,7 @@ export class AppRunClient {
|
||||
signal: opts.signal,
|
||||
throwOnError: true,
|
||||
})
|
||||
if (res.body === null)
|
||||
throw new Error('reconnect stream body missing')
|
||||
if (res.body === null) throw new Error('reconnect stream body missing')
|
||||
return normalizeDifyStream(parseSSE(res.body, opts.signal))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,7 +6,9 @@ import { isHttpClientError } from '@/errors/base'
|
||||
import { AppsClient } from './apps.js'
|
||||
|
||||
const LIST_BODY = { page: 1, limit: 20, total: 0, has_more: false, data: [] }
|
||||
const DESCRIBE_BODY = { info: { id: 'app-1', name: 'Demo', mode: 'chat', service_api_enabled: true } }
|
||||
const DESCRIBE_BODY = {
|
||||
info: { id: 'app-1', name: 'Demo', mode: 'chat', service_api_enabled: true },
|
||||
}
|
||||
|
||||
function makeClient(host: string): AppsClient {
|
||||
return new AppsClient(testHttpClient(host, 'dfoa_test'))
|
||||
@@ -24,7 +26,7 @@ describe('AppsClient.list', () => {
|
||||
})
|
||||
|
||||
it('defaults page=1 & limit=20 and always sends workspace_id', async () => {
|
||||
stub = await startStubServer(cap => jsonResponder(200, LIST_BODY, cap))
|
||||
stub = await startStubServer((cap) => jsonResponder(200, LIST_BODY, cap))
|
||||
|
||||
await makeClient(stub.url).list({ workspaceId: 'ws-1' })
|
||||
|
||||
@@ -40,7 +42,7 @@ describe('AppsClient.list', () => {
|
||||
})
|
||||
|
||||
it('forwards explicit pagination and filters', async () => {
|
||||
stub = await startStubServer(cap => jsonResponder(200, LIST_BODY, cap))
|
||||
stub = await startStubServer((cap) => jsonResponder(200, LIST_BODY, cap))
|
||||
|
||||
await makeClient(stub.url).list({
|
||||
workspaceId: 'ws-1',
|
||||
@@ -60,7 +62,7 @@ describe('AppsClient.list', () => {
|
||||
})
|
||||
|
||||
it('treats empty-string filters as absent (not blank query params)', async () => {
|
||||
stub = await startStubServer(cap => jsonResponder(200, LIST_BODY, cap))
|
||||
stub = await startStubServer((cap) => jsonResponder(200, LIST_BODY, cap))
|
||||
|
||||
await makeClient(stub.url).list({ workspaceId: 'ws-1', mode: '', name: '', tag: '' })
|
||||
|
||||
@@ -71,10 +73,10 @@ describe('AppsClient.list', () => {
|
||||
})
|
||||
|
||||
it('propagates server 403 as a classified BaseError', async () => {
|
||||
stub = await startStubServer(cap => jsonResponder(403, { error: 'forbidden' }, cap))
|
||||
stub = await startStubServer((cap) => jsonResponder(403, { error: 'forbidden' }, cap))
|
||||
|
||||
await expect(makeClient(stub.url).list({ workspaceId: 'ws-1' })).rejects.toSatisfy(
|
||||
err => isHttpClientError(err) && err.httpStatus === 403,
|
||||
(err) => isHttpClientError(err) && err.httpStatus === 403,
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -87,7 +89,7 @@ describe('AppsClient.describe', () => {
|
||||
})
|
||||
|
||||
it('hits /apps/<id>/describe, omits workspace_id and fields when not given', async () => {
|
||||
stub = await startStubServer(cap => jsonResponder(200, DESCRIBE_BODY, cap))
|
||||
stub = await startStubServer((cap) => jsonResponder(200, DESCRIBE_BODY, cap))
|
||||
|
||||
const res = await makeClient(stub.url).describe('app-1')
|
||||
|
||||
@@ -99,7 +101,7 @@ describe('AppsClient.describe', () => {
|
||||
})
|
||||
|
||||
it('joins fields with commas', async () => {
|
||||
stub = await startStubServer(cap => jsonResponder(200, DESCRIBE_BODY, cap))
|
||||
stub = await startStubServer((cap) => jsonResponder(200, DESCRIBE_BODY, cap))
|
||||
|
||||
await makeClient(stub.url).describe('app-1', ['parameters', 'input_schema'])
|
||||
|
||||
@@ -107,7 +109,7 @@ describe('AppsClient.describe', () => {
|
||||
})
|
||||
|
||||
it('URL-encodes the app id', async () => {
|
||||
stub = await startStubServer(cap => jsonResponder(200, DESCRIBE_BODY, cap))
|
||||
stub = await startStubServer((cap) => jsonResponder(200, DESCRIBE_BODY, cap))
|
||||
|
||||
await makeClient(stub.url).describe('app/with space')
|
||||
|
||||
|
||||
+5
-1
@@ -1,4 +1,8 @@
|
||||
import type { AppDescribeResponse, AppListResponse, AppMode } from '@dify/contracts/api/openapi/types.gen'
|
||||
import type {
|
||||
AppDescribeResponse,
|
||||
AppListResponse,
|
||||
AppMode,
|
||||
} from '@dify/contracts/api/openapi/types.gen'
|
||||
import type { OpenApiClient } from '@/http/orpc'
|
||||
import type { HttpClient } from '@/http/types'
|
||||
import { createOpenApiClient } from '@/http/orpc'
|
||||
|
||||
@@ -15,24 +15,33 @@ type StubServer = {
|
||||
stop: () => Promise<void>
|
||||
}
|
||||
|
||||
function startStub(handler: (req: http.IncomingMessage, res: http.ServerResponse) => void): Promise<StubServer> {
|
||||
function startStub(
|
||||
handler: (req: http.IncomingMessage, res: http.ServerResponse) => void,
|
||||
): Promise<StubServer> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const server = http.createServer(handler)
|
||||
server.listen(0, '127.0.0.1', () => {
|
||||
const addr = server.address() as AddressInfo
|
||||
resolve({
|
||||
url: `http://127.0.0.1:${addr.port}`,
|
||||
stop: () => new Promise<void>((res, rej) => server.close(err => err ? rej(err) : res())),
|
||||
stop: () =>
|
||||
new Promise<void>((res, rej) => server.close((err) => (err ? rej(err) : res()))),
|
||||
})
|
||||
})
|
||||
server.on('error', reject)
|
||||
})
|
||||
}
|
||||
|
||||
function jsonStub(status: number, body: unknown): (req: http.IncomingMessage, res: http.ServerResponse) => void {
|
||||
function jsonStub(
|
||||
status: number,
|
||||
body: unknown,
|
||||
): (req: http.IncomingMessage, res: http.ServerResponse) => void {
|
||||
return (_req, res) => {
|
||||
const payload = JSON.stringify(body)
|
||||
res.writeHead(status, { 'content-type': 'application/json', 'content-length': Buffer.byteLength(payload) })
|
||||
res.writeHead(status, {
|
||||
'content-type': 'application/json',
|
||||
'content-length': Buffer.byteLength(payload),
|
||||
})
|
||||
res.end(payload)
|
||||
}
|
||||
}
|
||||
@@ -74,15 +83,12 @@ describe('DeviceFlowApi.requestCode', () => {
|
||||
let caught: unknown
|
||||
try {
|
||||
await api.requestCode({ device_label: 'l' })
|
||||
}
|
||||
catch (e) {
|
||||
} catch (e) {
|
||||
caught = e
|
||||
}
|
||||
expect(isBaseError(caught)).toBe(true)
|
||||
if (isBaseError(caught))
|
||||
expect(caught.code).toBe(ErrorCode.UnsupportedEndpoint)
|
||||
}
|
||||
finally {
|
||||
if (isBaseError(caught)) expect(caught.code).toBe(ErrorCode.UnsupportedEndpoint)
|
||||
} finally {
|
||||
await stub?.stop()
|
||||
}
|
||||
})
|
||||
@@ -108,8 +114,7 @@ describe('DeviceFlowApi.pollOnce', () => {
|
||||
const api = makeApi(mock)
|
||||
const r = await api.pollOnce({ device_code: 'devcode-1' })
|
||||
expect(r.status).toBe('approved')
|
||||
if (r.status === 'approved')
|
||||
expect(r.success.token).toBe('dfoa_test')
|
||||
if (r.status === 'approved') expect(r.success.token).toBe('dfoa_test')
|
||||
})
|
||||
|
||||
it('maps authorization_pending to pending', async () => {
|
||||
@@ -119,8 +124,7 @@ describe('DeviceFlowApi.pollOnce', () => {
|
||||
const api = new DeviceFlowApi(testHttpClient(stub.url))
|
||||
const r = await api.pollOnce({ device_code: 'dc' })
|
||||
expect(r.status).toBe('pending')
|
||||
}
|
||||
finally {
|
||||
} finally {
|
||||
await stub?.stop()
|
||||
}
|
||||
})
|
||||
@@ -152,8 +156,7 @@ describe('DeviceFlowApi.pollOnce', () => {
|
||||
stub = await startStub(jsonStub(404, {}))
|
||||
const api = new DeviceFlowApi(testHttpClient(stub.url))
|
||||
await expect(api.pollOnce({ device_code: 'dc' })).rejects.toThrow(/device flow/i)
|
||||
}
|
||||
finally {
|
||||
} finally {
|
||||
await stub?.stop()
|
||||
}
|
||||
})
|
||||
@@ -171,8 +174,7 @@ describe('DeviceFlowApi.pollOnce', () => {
|
||||
stub = await startStub(jsonStub(200, {}))
|
||||
const api = new DeviceFlowApi(testHttpClient(stub.url))
|
||||
await expect(api.pollOnce({ device_code: 'dc' })).rejects.toThrow(/no OAuth envelope|token/i)
|
||||
}
|
||||
finally {
|
||||
} finally {
|
||||
await stub?.stop()
|
||||
}
|
||||
})
|
||||
@@ -183,8 +185,7 @@ describe('DeviceFlowApi.pollOnce', () => {
|
||||
stub = await startStub(jsonStub(400, { error: 'something_else' }))
|
||||
const api = new DeviceFlowApi(testHttpClient(stub.url))
|
||||
await expect(api.pollOnce({ device_code: 'dc' })).rejects.toThrow(/unknown poll error/)
|
||||
}
|
||||
finally {
|
||||
} finally {
|
||||
await stub?.stop()
|
||||
}
|
||||
})
|
||||
|
||||
@@ -36,7 +36,7 @@ describe('FileUploadClient.upload', () => {
|
||||
it('POSTs multipart/form-data (boundary intact, no JSON content-type) and returns the parsed file', async () => {
|
||||
const filePath = join(dir, 'hello.png')
|
||||
await writeFile(filePath, 'hello')
|
||||
stub = await startStubServer(cap => jsonResponder(200, UPLOADED, cap))
|
||||
stub = await startStubServer((cap) => jsonResponder(200, UPLOADED, cap))
|
||||
|
||||
const result = await makeClient(stub.url).upload('app-1', filePath)
|
||||
|
||||
@@ -57,7 +57,7 @@ describe('FileUploadClient.upload', () => {
|
||||
it('encodes the app id in the path', async () => {
|
||||
const filePath = join(dir, 'a.txt')
|
||||
await writeFile(filePath, 'x')
|
||||
stub = await startStubServer(cap => jsonResponder(200, UPLOADED, cap))
|
||||
stub = await startStubServer((cap) => jsonResponder(200, UPLOADED, cap))
|
||||
|
||||
await makeClient(stub.url).upload('app/with space', filePath)
|
||||
|
||||
@@ -67,10 +67,10 @@ describe('FileUploadClient.upload', () => {
|
||||
it('propagates a server 413 as a classified BaseError', async () => {
|
||||
const filePath = join(dir, 'big.bin')
|
||||
await writeFile(filePath, 'data')
|
||||
stub = await startStubServer(cap => jsonResponder(413, { error: 'file too large' }, cap))
|
||||
stub = await startStubServer((cap) => jsonResponder(413, { error: 'file too large' }, cap))
|
||||
|
||||
await expect(makeClient(stub.url).upload('app-1', filePath)).rejects.toSatisfy(
|
||||
err => isHttpClientError(err) && err.httpStatus === 413,
|
||||
(err) => isHttpClientError(err) && err.httpStatus === 413,
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -64,9 +64,9 @@ export class FileUploadClient {
|
||||
const form = new FormData()
|
||||
form.append('file', blob, filename)
|
||||
|
||||
return this.http.post<UploadedFile>(
|
||||
`apps/${encodeURIComponent(appId)}/files/upload`,
|
||||
{ body: form, timeoutMs: 60_000 },
|
||||
)
|
||||
return this.http.post<UploadedFile>(`apps/${encodeURIComponent(appId)}/files/upload`, {
|
||||
body: form,
|
||||
timeoutMs: 60_000,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
+30
-27
@@ -18,7 +18,7 @@ describe('MembersClient.list', () => {
|
||||
})
|
||||
|
||||
it('GETs /workspaces/<id>/members and returns parsed envelope', async () => {
|
||||
stub = await startStubServer(cap =>
|
||||
stub = await startStubServer((cap) =>
|
||||
jsonResponder(
|
||||
200,
|
||||
{
|
||||
@@ -26,12 +26,11 @@ describe('MembersClient.list', () => {
|
||||
limit: 20,
|
||||
total: 1,
|
||||
has_more: false,
|
||||
data: [
|
||||
{ id: 'm-1', name: 'Mia', email: 'mia@e.com', role: 'admin', status: 'active' },
|
||||
],
|
||||
data: [{ id: 'm-1', name: 'Mia', email: 'mia@e.com', role: 'admin', status: 'active' }],
|
||||
},
|
||||
cap,
|
||||
))
|
||||
),
|
||||
)
|
||||
|
||||
const result = await makeClient(stub.url).list('ws-1')
|
||||
|
||||
@@ -41,8 +40,9 @@ describe('MembersClient.list', () => {
|
||||
})
|
||||
|
||||
it('URL-encodes workspace id', async () => {
|
||||
stub = await startStubServer(cap =>
|
||||
jsonResponder(200, { page: 1, limit: 20, total: 0, has_more: false, data: [] }, cap))
|
||||
stub = await startStubServer((cap) =>
|
||||
jsonResponder(200, { page: 1, limit: 20, total: 0, has_more: false, data: [] }, cap),
|
||||
)
|
||||
|
||||
await makeClient(stub.url).list('ws with space')
|
||||
|
||||
@@ -50,8 +50,9 @@ describe('MembersClient.list', () => {
|
||||
})
|
||||
|
||||
it('forwards page/limit as query params', async () => {
|
||||
stub = await startStubServer(cap =>
|
||||
jsonResponder(200, { page: 2, limit: 50, total: 0, has_more: false, data: [] }, cap))
|
||||
stub = await startStubServer((cap) =>
|
||||
jsonResponder(200, { page: 2, limit: 50, total: 0, has_more: false, data: [] }, cap),
|
||||
)
|
||||
|
||||
await makeClient(stub.url).list('ws-1', { page: 2, limit: 50 })
|
||||
|
||||
@@ -59,18 +60,18 @@ describe('MembersClient.list', () => {
|
||||
})
|
||||
|
||||
it('propagates server 403 as classified BaseError', async () => {
|
||||
stub = await startStubServer(cap => jsonResponder(403, { error: 'forbidden' }, cap))
|
||||
stub = await startStubServer((cap) => jsonResponder(403, { error: 'forbidden' }, cap))
|
||||
|
||||
await expect(makeClient(stub.url).list('ws-1')).rejects.toSatisfy(
|
||||
err => isHttpClientError(err) && err.httpStatus === 403,
|
||||
(err) => isHttpClientError(err) && err.httpStatus === 403,
|
||||
)
|
||||
})
|
||||
|
||||
it('propagates 404 as classified BaseError', async () => {
|
||||
stub = await startStubServer(cap => jsonResponder(404, { error: 'not found' }, cap))
|
||||
stub = await startStubServer((cap) => jsonResponder(404, { error: 'not found' }, cap))
|
||||
|
||||
await expect(makeClient(stub.url).list('ws-missing')).rejects.toSatisfy(
|
||||
err => isHttpClientError(err) && err.httpStatus === 404,
|
||||
(err) => isHttpClientError(err) && err.httpStatus === 404,
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -83,7 +84,7 @@ describe('MembersClient.invite', () => {
|
||||
})
|
||||
|
||||
it('POSTs JSON body and returns parsed invite response', async () => {
|
||||
stub = await startStubServer(cap =>
|
||||
stub = await startStubServer((cap) =>
|
||||
jsonResponder(
|
||||
201,
|
||||
{
|
||||
@@ -95,7 +96,8 @@ describe('MembersClient.invite', () => {
|
||||
tenant_id: 'ws-1',
|
||||
},
|
||||
cap,
|
||||
))
|
||||
),
|
||||
)
|
||||
|
||||
const result = await makeClient(stub.url).invite('ws-1', {
|
||||
email: 'new@e.com',
|
||||
@@ -113,11 +115,11 @@ describe('MembersClient.invite', () => {
|
||||
})
|
||||
|
||||
it('propagates 400 (already in tenant) as classified BaseError', async () => {
|
||||
stub = await startStubServer(cap => jsonResponder(400, { error: 'already in tenant' }, cap))
|
||||
stub = await startStubServer((cap) => jsonResponder(400, { error: 'already in tenant' }, cap))
|
||||
|
||||
await expect(
|
||||
makeClient(stub.url).invite('ws-1', { email: 'u@e.com', role: 'normal' }),
|
||||
).rejects.toSatisfy(err => isHttpClientError(err) && err.httpStatus === 400)
|
||||
).rejects.toSatisfy((err) => isHttpClientError(err) && err.httpStatus === 400)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -129,7 +131,7 @@ describe('MembersClient.remove', () => {
|
||||
})
|
||||
|
||||
it('DELETEs member by id and returns success', async () => {
|
||||
stub = await startStubServer(cap => jsonResponder(200, { result: 'success' }, cap))
|
||||
stub = await startStubServer((cap) => jsonResponder(200, { result: 'success' }, cap))
|
||||
|
||||
const result = await makeClient(stub.url).remove('ws-1', 'm-1')
|
||||
|
||||
@@ -139,10 +141,10 @@ describe('MembersClient.remove', () => {
|
||||
})
|
||||
|
||||
it('propagates 400 (cannot operate self / cannot remove owner)', async () => {
|
||||
stub = await startStubServer(cap => jsonResponder(400, { error: 'cannot operate self' }, cap))
|
||||
stub = await startStubServer((cap) => jsonResponder(400, { error: 'cannot operate self' }, cap))
|
||||
|
||||
await expect(makeClient(stub.url).remove('ws-1', 'm-1')).rejects.toSatisfy(
|
||||
err => isHttpClientError(err) && err.httpStatus === 400,
|
||||
(err) => isHttpClientError(err) && err.httpStatus === 400,
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -155,7 +157,7 @@ describe('MembersClient.updateRole', () => {
|
||||
})
|
||||
|
||||
it('PUTs role payload to /role subresource', async () => {
|
||||
stub = await startStubServer(cap => jsonResponder(200, { result: 'success' }, cap))
|
||||
stub = await startStubServer((cap) => jsonResponder(200, { result: 'success' }, cap))
|
||||
|
||||
const result = await makeClient(stub.url).updateRole('ws-1', 'm-1', { role: 'admin' })
|
||||
|
||||
@@ -166,11 +168,11 @@ describe('MembersClient.updateRole', () => {
|
||||
})
|
||||
|
||||
it('propagates 400 (admin cannot demote owner)', async () => {
|
||||
stub = await startStubServer(cap => jsonResponder(400, { error: 'no permission' }, cap))
|
||||
stub = await startStubServer((cap) => jsonResponder(400, { error: 'no permission' }, cap))
|
||||
|
||||
await expect(
|
||||
makeClient(stub.url).updateRole('ws-1', 'm-1', { role: 'admin' }),
|
||||
).rejects.toSatisfy(err => isHttpClientError(err) && err.httpStatus === 400)
|
||||
).rejects.toSatisfy((err) => isHttpClientError(err) && err.httpStatus === 400)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -182,7 +184,7 @@ describe('WorkspacesClient.switch (integration with stub)', () => {
|
||||
})
|
||||
|
||||
it('POSTs /workspaces/<id>/switch and returns workspace detail', async () => {
|
||||
stub = await startStubServer(cap =>
|
||||
stub = await startStubServer((cap) =>
|
||||
jsonResponder(
|
||||
200,
|
||||
{
|
||||
@@ -194,7 +196,8 @@ describe('WorkspacesClient.switch (integration with stub)', () => {
|
||||
created_at: '2026-05-18T00:00:00Z',
|
||||
},
|
||||
cap,
|
||||
))
|
||||
),
|
||||
)
|
||||
|
||||
const client = new WorkspacesClient(testHttpClient(stub.url, 'dfoa_test'))
|
||||
const result = await client.switch('ws-1')
|
||||
@@ -205,11 +208,11 @@ describe('WorkspacesClient.switch (integration with stub)', () => {
|
||||
})
|
||||
|
||||
it('propagates 404 (non-member)', async () => {
|
||||
stub = await startStubServer(cap => jsonResponder(404, { error: 'not found' }, cap))
|
||||
stub = await startStubServer((cap) => jsonResponder(404, { error: 'not found' }, cap))
|
||||
|
||||
const client = new WorkspacesClient(testHttpClient(stub.url, 'dfoa_test'))
|
||||
await expect(client.switch('ws-x')).rejects.toSatisfy(
|
||||
err => isHttpClientError(err) && err.httpStatus === 404,
|
||||
(err) => isHttpClientError(err) && err.httpStatus === 404,
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -22,7 +22,10 @@ export class MembersClient {
|
||||
this.orpc = createOpenApiClient(http)
|
||||
}
|
||||
|
||||
async list(workspaceId: string, q?: { page?: number, limit?: number }): Promise<MemberListResponse> {
|
||||
async list(
|
||||
workspaceId: string,
|
||||
q?: { page?: number; limit?: number },
|
||||
): Promise<MemberListResponse> {
|
||||
return this.orpc.workspaces.byWorkspaceId.members.get({
|
||||
params: { workspace_id: workspaceId },
|
||||
query: { page: q?.page, limit: q?.limit },
|
||||
|
||||
+13
-17
@@ -46,13 +46,13 @@ export type PollSuccess = {
|
||||
token_id?: string
|
||||
}
|
||||
|
||||
export type PollResult
|
||||
= | { status: 'pending' }
|
||||
| { status: 'slow_down' }
|
||||
| { status: 'expired' }
|
||||
| { status: 'denied' }
|
||||
| { status: 'retry_5xx' }
|
||||
| { status: 'approved', success: PollSuccess }
|
||||
export type PollResult =
|
||||
| { status: 'pending' }
|
||||
| { status: 'slow_down' }
|
||||
| { status: 'expired' }
|
||||
| { status: 'denied' }
|
||||
| { status: 'retry_5xx' }
|
||||
| { status: 'approved'; success: PollSuccess }
|
||||
|
||||
const POLL_ERROR_TO_STATUS: Record<string, PollResult['status']> = {
|
||||
authorization_pending: 'pending',
|
||||
@@ -77,8 +77,7 @@ export class DeviceFlowApi {
|
||||
}
|
||||
const body = { client_id: req.client_id ?? DEFAULT_CLIENT_ID, device_label: req.device_label }
|
||||
const res = await this.http.fetch('oauth/device/code', { method: 'POST', json: body })
|
||||
if (res.status === 404)
|
||||
throw versionSkew()
|
||||
if (res.status === 404) throw versionSkew()
|
||||
if (!res.ok) {
|
||||
throw new HttpClientError({
|
||||
code: ErrorCode.Server4xxOther,
|
||||
@@ -86,7 +85,7 @@ export class DeviceFlowApi {
|
||||
httpStatus: res.status,
|
||||
})
|
||||
}
|
||||
return await res.json() as CodeResponse
|
||||
return (await res.json()) as CodeResponse
|
||||
}
|
||||
|
||||
async pollOnce(req: PollRequest): Promise<PollResult> {
|
||||
@@ -98,16 +97,13 @@ export class DeviceFlowApi {
|
||||
}
|
||||
const body = { client_id: req.client_id ?? DEFAULT_CLIENT_ID, device_code: req.device_code }
|
||||
const res = await this.http.fetch('oauth/device/token', { method: 'POST', json: body })
|
||||
if (res.status === 404)
|
||||
throw versionSkew()
|
||||
if (res.status >= 500)
|
||||
return { status: 'retry_5xx' }
|
||||
if (res.status === 404) throw versionSkew()
|
||||
if (res.status >= 500) return { status: 'retry_5xx' }
|
||||
let payload: { error?: string } & Partial<PollSuccess> = {}
|
||||
try {
|
||||
const text = await res.text()
|
||||
payload = text === '' ? {} : JSON.parse(text) as typeof payload
|
||||
}
|
||||
catch (err) {
|
||||
payload = text === '' ? {} : (JSON.parse(text) as typeof payload)
|
||||
} catch (err) {
|
||||
throw new BaseError({
|
||||
code: ErrorCode.Unknown,
|
||||
message: `decode poll response: ${(err as Error).message}`,
|
||||
|
||||
@@ -22,12 +22,17 @@ describe('WorkspacesClient.list', () => {
|
||||
})
|
||||
|
||||
it('GETs /workspaces and returns the parsed list', async () => {
|
||||
stub = await startStubServer(cap =>
|
||||
jsonResponder(200, {
|
||||
workspaces: [
|
||||
{ id: 'ws-1', name: 'Default', role: 'owner', status: 'normal', current: true },
|
||||
],
|
||||
}, cap))
|
||||
stub = await startStubServer((cap) =>
|
||||
jsonResponder(
|
||||
200,
|
||||
{
|
||||
workspaces: [
|
||||
{ id: 'ws-1', name: 'Default', role: 'owner', status: 'normal', current: true },
|
||||
],
|
||||
},
|
||||
cap,
|
||||
),
|
||||
)
|
||||
|
||||
const res = await makeClient(stub.url).list()
|
||||
|
||||
@@ -37,10 +42,10 @@ describe('WorkspacesClient.list', () => {
|
||||
})
|
||||
|
||||
it('maps 401 to a classified BaseError', async () => {
|
||||
stub = await startStubServer(cap => jsonResponder(401, { error: 'expired' }, cap))
|
||||
stub = await startStubServer((cap) => jsonResponder(401, { error: 'expired' }, cap))
|
||||
|
||||
await expect(makeClient(stub.url).list()).rejects.toSatisfy(
|
||||
err => isHttpClientError(err) && err.httpStatus === 401,
|
||||
(err) => isHttpClientError(err) && err.httpStatus === 401,
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
import type { WorkspaceDetailResponse, WorkspaceListResponse } from '@dify/contracts/api/openapi/types.gen'
|
||||
import type {
|
||||
WorkspaceDetailResponse,
|
||||
WorkspaceListResponse,
|
||||
} from '@dify/contracts/api/openapi/types.gen'
|
||||
import type { OpenApiClient } from '@/http/orpc'
|
||||
import type { HttpClient } from '@/http/types'
|
||||
import { createOpenApiClient } from '@/http/orpc'
|
||||
|
||||
@@ -71,13 +71,15 @@ describe('notLoggedInError', () => {
|
||||
expect(notLoggedInError().toString()).toMatch(/auth login/)
|
||||
})
|
||||
it('accepts a custom hint', () => {
|
||||
expect(notLoggedInError('run \'difyctl use host\'').toString()).toMatch(/use host/)
|
||||
expect(notLoggedInError("run 'difyctl use host'").toString()).toMatch(/use host/)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Registry (pure)', () => {
|
||||
const baseReg = (): Registry => Registry.empty('file')
|
||||
const ctx = (email: string): AccountContext => ({ account: { id: `id-${email}`, email, name: email } })
|
||||
const ctx = (email: string): AccountContext => ({
|
||||
account: { id: `id-${email}`, email, name: email },
|
||||
})
|
||||
|
||||
it('upsert creates host + account; remove drops them', () => {
|
||||
const reg = baseReg()
|
||||
|
||||
+28
-32
@@ -60,7 +60,7 @@ export type ActiveContext = {
|
||||
readonly scheme?: string
|
||||
}
|
||||
|
||||
export function notLoggedInError(hint = 'run \'difyctl auth login\''): BaseError {
|
||||
export function notLoggedInError(hint = "run 'difyctl auth login'"): BaseError {
|
||||
return new BaseError({ code: ErrorCode.NotLoggedIn, message: 'not logged in', hint })
|
||||
}
|
||||
|
||||
@@ -73,8 +73,7 @@ export class Registry {
|
||||
|
||||
static async load(): Promise<Registry> {
|
||||
const raw = await getHostStore().getTyped<Record<string, unknown>>()
|
||||
if (raw === null)
|
||||
return Registry.empty()
|
||||
if (raw === null) return Registry.empty()
|
||||
return new Registry(RegistrySchema.parse(raw))
|
||||
}
|
||||
|
||||
@@ -86,31 +85,34 @@ export class Registry {
|
||||
return new Registry(data)
|
||||
}
|
||||
|
||||
get hosts(): RegistryData['hosts'] { return this.data.hosts }
|
||||
get current_host(): string | undefined { return this.data.current_host }
|
||||
get token_storage(): StorageMode { return this.data.token_storage }
|
||||
set token_storage(mode: StorageMode) { this.data.token_storage = mode }
|
||||
get hosts(): RegistryData['hosts'] {
|
||||
return this.data.hosts
|
||||
}
|
||||
get current_host(): string | undefined {
|
||||
return this.data.current_host
|
||||
}
|
||||
get token_storage(): StorageMode {
|
||||
return this.data.token_storage
|
||||
}
|
||||
set token_storage(mode: StorageMode) {
|
||||
this.data.token_storage = mode
|
||||
}
|
||||
|
||||
resolveActive(): ActiveContext | undefined {
|
||||
const host = this.data.current_host
|
||||
if (host === undefined || host === '')
|
||||
return undefined
|
||||
if (host === undefined || host === '') return undefined
|
||||
const entry = this.data.hosts[host]
|
||||
if (entry === undefined)
|
||||
return undefined
|
||||
if (entry === undefined) return undefined
|
||||
const email = entry?.current_account
|
||||
if (!email)
|
||||
return undefined
|
||||
if (!email) return undefined
|
||||
const ctx = entry.accounts[email]
|
||||
if (ctx === undefined)
|
||||
return undefined
|
||||
if (ctx === undefined) return undefined
|
||||
return { host, email, ctx, scheme: entry.scheme }
|
||||
}
|
||||
|
||||
requireActive(hint?: string): ActiveContext {
|
||||
const active = this.resolveActive()
|
||||
if (active === undefined)
|
||||
throw notLoggedInError(hint)
|
||||
if (active === undefined) throw notLoggedInError(hint)
|
||||
return active
|
||||
}
|
||||
|
||||
@@ -122,18 +124,14 @@ export class Registry {
|
||||
|
||||
remove(host: string, email: string): void {
|
||||
const entry = this.data.hosts[host]
|
||||
if (entry === undefined)
|
||||
return
|
||||
if (entry === undefined) return
|
||||
const wasActive = entry.current_account === email
|
||||
delete entry.accounts[email]
|
||||
if (wasActive)
|
||||
entry.current_account = undefined
|
||||
if (wasActive) entry.current_account = undefined
|
||||
if (Object.keys(entry.accounts).length === 0) {
|
||||
delete this.data.hosts[host]
|
||||
if (this.data.current_host === host)
|
||||
this.data.current_host = undefined
|
||||
}
|
||||
else if (wasActive && this.data.current_host === host) {
|
||||
if (this.data.current_host === host) this.data.current_host = undefined
|
||||
} else if (wasActive && this.data.current_host === host) {
|
||||
this.data.current_host = undefined
|
||||
}
|
||||
}
|
||||
@@ -144,17 +142,14 @@ export class Registry {
|
||||
|
||||
setAccount(email: string): void {
|
||||
const host = this.data.current_host
|
||||
if (host === undefined)
|
||||
return
|
||||
if (host === undefined) return
|
||||
const entry = this.data.hosts[host]
|
||||
if (entry !== undefined)
|
||||
entry.current_account = email
|
||||
if (entry !== undefined) entry.current_account = email
|
||||
}
|
||||
|
||||
setScheme(host: string, scheme: string): void {
|
||||
const entry = this.data.hosts[host]
|
||||
if (entry !== undefined)
|
||||
entry.scheme = scheme
|
||||
if (entry !== undefined) entry.scheme = scheme
|
||||
}
|
||||
|
||||
activate(host: string, email: string, ctx: AccountContext): void {
|
||||
@@ -168,8 +163,9 @@ export class Registry {
|
||||
async forget(active: ActiveContext, store: TokenStore): Promise<void> {
|
||||
try {
|
||||
await store.remove(active.host, active.email)
|
||||
} catch {
|
||||
/* best-effort */
|
||||
}
|
||||
catch { /* best-effort */ }
|
||||
this.remove(active.host, active.email)
|
||||
await this.save()
|
||||
}
|
||||
|
||||
Vendored
+3
-6
@@ -42,10 +42,8 @@ describe('app-info disk cache', () => {
|
||||
process.env[ENV_CACHE_DIR] = dir
|
||||
})
|
||||
afterEach(async () => {
|
||||
if (prevCacheDir === undefined)
|
||||
delete process.env[ENV_CACHE_DIR]
|
||||
else
|
||||
process.env[ENV_CACHE_DIR] = prevCacheDir
|
||||
if (prevCacheDir === undefined) delete process.env[ENV_CACHE_DIR]
|
||||
else process.env[ENV_CACHE_DIR] = prevCacheDir
|
||||
await rm(dir, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
@@ -93,8 +91,7 @@ describe('app-info disk cache', () => {
|
||||
await c.set('h', 'app-1', metaInfoOnly())
|
||||
const { stat } = await import('node:fs/promises')
|
||||
const s = await stat(appInfoPath(dir))
|
||||
if (platform() !== 'win32')
|
||||
expect(s.mode & 0o777).toBe(0o600)
|
||||
if (platform() !== 'win32') expect(s.mode & 0o777).toBe(0o600)
|
||||
})
|
||||
|
||||
it('missing cache file is not an error', async () => {
|
||||
|
||||
Vendored
+10
-8
@@ -46,7 +46,10 @@ export async function loadAppInfoCache(opts: AppInfoCacheOptions = {}): Promise<
|
||||
return {
|
||||
get: (host, appId) => state.entries.get(key(host, appId)),
|
||||
set: async (host, appId, meta) => {
|
||||
const record: AppMetaCacheRecord = { meta, fetchedAt: (opts.now ?? (() => new Date()))().toISOString() }
|
||||
const record: AppMetaCacheRecord = {
|
||||
meta,
|
||||
fetchedAt: (opts.now ?? (() => new Date()))().toISOString(),
|
||||
}
|
||||
state.entries.set(key(host, appId), record)
|
||||
await writeEntries(store, state.entries)
|
||||
},
|
||||
@@ -70,12 +73,10 @@ async function readEntries(store: Store): Promise<Map<string, AppMetaCacheRecord
|
||||
let raw: Record<string, DiskEntry>
|
||||
try {
|
||||
raw = await store.get(ENTRIES_KEY)
|
||||
}
|
||||
catch {
|
||||
} catch {
|
||||
return out
|
||||
}
|
||||
for (const [k, e] of Object.entries(raw))
|
||||
out.set(k, deserialize(e))
|
||||
for (const [k, e] of Object.entries(raw)) out.set(k, deserialize(e))
|
||||
return out
|
||||
}
|
||||
|
||||
@@ -93,10 +94,11 @@ function deserialize(e: DiskEntry): AppMetaCacheRecord {
|
||||
}
|
||||
|
||||
function filterFields(input: unknown): AppMetaFieldKey[] {
|
||||
if (!Array.isArray(input))
|
||||
return []
|
||||
if (!Array.isArray(input)) return []
|
||||
const valid = new Set<AppMetaFieldKey>([FieldInfo, FieldParameters, FieldInputSchema])
|
||||
return input.filter((s): s is AppMetaFieldKey => typeof s === 'string' && valid.has(s as AppMetaFieldKey))
|
||||
return input.filter(
|
||||
(s): s is AppMetaFieldKey => typeof s === 'string' && valid.has(s as AppMetaFieldKey),
|
||||
)
|
||||
}
|
||||
|
||||
function serialize(record: AppMetaCacheRecord): DiskEntry {
|
||||
|
||||
Vendored
+2
-4
@@ -22,10 +22,8 @@ describe('NudgeStore', () => {
|
||||
process.env[ENV_CACHE_DIR] = dir
|
||||
})
|
||||
afterEach(async () => {
|
||||
if (prevCacheDir === undefined)
|
||||
delete process.env[ENV_CACHE_DIR]
|
||||
else
|
||||
process.env[ENV_CACHE_DIR] = prevCacheDir
|
||||
if (prevCacheDir === undefined) delete process.env[ENV_CACHE_DIR]
|
||||
else process.env[ENV_CACHE_DIR] = prevCacheDir
|
||||
await rm(dir, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
|
||||
Vendored
+4
-8
@@ -28,8 +28,7 @@ export async function loadNudgeStore(opts: NudgeStoreOptions = {}): Promise<Nudg
|
||||
return {
|
||||
canWarn: (host, now) => {
|
||||
const last = memory.get(host)
|
||||
if (last === undefined)
|
||||
return true
|
||||
if (last === undefined) return true
|
||||
const elapsed = Math.max(0, (now ?? clock()).getTime() - last)
|
||||
return elapsed >= intervalMs
|
||||
},
|
||||
@@ -51,21 +50,18 @@ async function readWarned(store: Store): Promise<Map<string, number>> {
|
||||
let raw: Record<string, string>
|
||||
try {
|
||||
raw = await store.get(WARNED_KEY)
|
||||
}
|
||||
catch {
|
||||
} catch {
|
||||
return out
|
||||
}
|
||||
for (const [host, iso] of Object.entries(raw)) {
|
||||
const t = Date.parse(iso)
|
||||
if (!Number.isNaN(t))
|
||||
out.set(host, t)
|
||||
if (!Number.isNaN(t)) out.set(host, t)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
async function writeWarned(store: Store, state: Map<string, number>): Promise<void> {
|
||||
const warned: Record<string, string> = {}
|
||||
for (const [host, t] of state)
|
||||
warned[host] = new Date(t).toISOString()
|
||||
for (const [host, t] of state) warned[host] = new Date(t).toISOString()
|
||||
await store.set(WARNED_KEY, warned)
|
||||
}
|
||||
|
||||
@@ -41,13 +41,11 @@ export async function buildAuthedContext(
|
||||
const io = realStreams(opts.format ?? '')
|
||||
const reg = await Registry.load()
|
||||
const active = reg.resolveActive()
|
||||
if (active === undefined)
|
||||
fail(cmd, opts, io)
|
||||
if (active === undefined) fail(cmd, opts, io)
|
||||
|
||||
const store = getTokenStore(reg.token_storage)
|
||||
const bearer = await store.read(active.host, active.email)
|
||||
if (bearer === '')
|
||||
fail(cmd, opts, io)
|
||||
if (bearer === '') fail(cmd, opts, io)
|
||||
|
||||
const host = hostWithScheme(active.host, active.scheme)
|
||||
const retryAttempts = resolveRetryAttempts({ flag: opts.retryFlag, env: getEnv })
|
||||
@@ -62,7 +60,9 @@ export async function buildAuthedContext(
|
||||
|
||||
function fail(cmd: Pick<Command, 'error'>, opts: AuthedContextOptions, io: IOStreams): never {
|
||||
const err = notLoggedInError()
|
||||
cmd.error(formatErrorForCli(err, { format: opts.format, isErrTTY: io.isErrTTY }), { exit: err.exit() })
|
||||
cmd.error(formatErrorForCli(err, { format: opts.format, isErrTTY: io.isErrTTY }), {
|
||||
exit: err.exit(),
|
||||
})
|
||||
}
|
||||
|
||||
// Best-effort nudge: never throws, never blocks. Lives here so every authed
|
||||
@@ -76,17 +76,20 @@ async function runCompatNudge(opts: {
|
||||
await maybeNudgeCompat(opts.host, {
|
||||
store,
|
||||
probe: async (host) => {
|
||||
const http = createHttpClient({ baseURL: openAPIBase(host), timeoutMs: META_PROBE_TIMEOUT_MS, retryAttempts: 0 })
|
||||
const http = createHttpClient({
|
||||
baseURL: openAPIBase(host),
|
||||
timeoutMs: META_PROBE_TIMEOUT_MS,
|
||||
retryAttempts: 0,
|
||||
})
|
||||
return new MetaClient(http).serverVersion()
|
||||
},
|
||||
emit: line => opts.io.err.write(line),
|
||||
emit: (line) => opts.io.err.write(line),
|
||||
isTty: opts.io.isOutTTY,
|
||||
format: opts.io.outputFormat,
|
||||
clientVersion: versionInfo.version,
|
||||
color: opts.io.isErrTTY,
|
||||
})
|
||||
}
|
||||
catch {
|
||||
} catch {
|
||||
// already swallowed inside maybeNudgeCompat; this is belt-and-braces
|
||||
}
|
||||
}
|
||||
|
||||
@@ -22,8 +22,7 @@ describe('resolveRetryAttempts', () => {
|
||||
let caught: unknown
|
||||
try {
|
||||
resolveRetryAttempts({ flag: undefined, env: () => 'foo' })
|
||||
}
|
||||
catch (e) {
|
||||
} catch (e) {
|
||||
caught = e
|
||||
}
|
||||
expect((caught as { code: string }).code).toBe('usage_invalid_flag')
|
||||
@@ -34,8 +33,7 @@ describe('resolveRetryAttempts', () => {
|
||||
let caught: unknown
|
||||
try {
|
||||
resolveRetryAttempts({ flag: undefined, env: () => '-1' })
|
||||
}
|
||||
catch (e) {
|
||||
} catch (e) {
|
||||
caught = e
|
||||
}
|
||||
expect((caught as { code: string }).code).toBe('usage_invalid_flag')
|
||||
|
||||
@@ -5,7 +5,8 @@ import { Flags } from '@/framework/flags'
|
||||
export const HTTP_RETRY_DEFAULT = 3
|
||||
|
||||
export const httpRetryFlag = Flags.integer({
|
||||
description: 'HTTP retry attempts for GET/PUT/DELETE on transient errors. 0 disables. Overrides DIFYCTL_HTTP_RETRY.',
|
||||
description:
|
||||
'HTTP retry attempts for GET/PUT/DELETE on transient errors. 0 disables. Overrides DIFYCTL_HTTP_RETRY.',
|
||||
helpGroup: 'GLOBAL',
|
||||
})
|
||||
|
||||
@@ -15,15 +16,15 @@ export type ResolveRetryAttemptsOpts = {
|
||||
}
|
||||
|
||||
export function resolveRetryAttempts(opts: ResolveRetryAttemptsOpts): number {
|
||||
if (opts.flag !== undefined)
|
||||
return opts.flag
|
||||
if (opts.flag !== undefined) return opts.flag
|
||||
const raw = opts.env('DIFYCTL_HTTP_RETRY')
|
||||
if (raw === undefined || raw === '')
|
||||
return HTTP_RETRY_DEFAULT
|
||||
if (raw === undefined || raw === '') return HTTP_RETRY_DEFAULT
|
||||
if (!/^-?\d+$/.test(raw))
|
||||
throw newError(ErrorCode.UsageInvalidFlag, `DIFYCTL_HTTP_RETRY: ${JSON.stringify(raw)} is not a non-negative integer`)
|
||||
throw newError(
|
||||
ErrorCode.UsageInvalidFlag,
|
||||
`DIFYCTL_HTTP_RETRY: ${JSON.stringify(raw)} is not a non-negative integer`,
|
||||
)
|
||||
const n = Number(raw)
|
||||
if (n < 0)
|
||||
throw newError(ErrorCode.UsageInvalidFlag, `DIFYCTL_HTTP_RETRY: ${n} is negative`)
|
||||
if (n < 0) throw newError(ErrorCode.UsageInvalidFlag, `DIFYCTL_HTTP_RETRY: ${n} is negative`)
|
||||
return n
|
||||
}
|
||||
|
||||
@@ -11,7 +11,11 @@ import { Registry } from '@/auth/hosts'
|
||||
import { bufferStreams } from '@/sys/io/streams'
|
||||
import { listAllSessions, runDevicesList, runDevicesRevoke } from './devices.js'
|
||||
|
||||
function buildRegistry(host: string, email: string, tokenId: string): { reg: Registry, active: ActiveContext } {
|
||||
function buildRegistry(
|
||||
host: string,
|
||||
email: string,
|
||||
tokenId: string,
|
||||
): { reg: Registry; active: ActiveContext } {
|
||||
const reg = Registry.empty('file')
|
||||
reg.upsert(host, email, {
|
||||
account: { id: 'acct-1', email, name: 'Test Tester' },
|
||||
@@ -42,7 +46,7 @@ describe('runDevicesList', () => {
|
||||
expect(out).toContain('difyctl on laptop')
|
||||
expect(out).toContain('difyctl on desktop')
|
||||
const lines = out.trim().split('\n')
|
||||
const laptopLine = lines.find(l => l.includes('difyctl on laptop'))!
|
||||
const laptopLine = lines.find((l) => l.includes('difyctl on laptop'))!
|
||||
expect(laptopLine).toMatch(/\*\s*$/)
|
||||
})
|
||||
|
||||
@@ -75,7 +79,15 @@ describe('runDevicesRevoke', () => {
|
||||
await reg.save()
|
||||
const http = testHttpClient(mock.url, 'dfoa_test')
|
||||
|
||||
await runDevicesRevoke({ io, reg, active, store, http, target: 'difyctl on desktop', all: false })
|
||||
await runDevicesRevoke({
|
||||
io,
|
||||
reg,
|
||||
active,
|
||||
store,
|
||||
http,
|
||||
target: 'difyctl on desktop',
|
||||
all: false,
|
||||
})
|
||||
expect(io.outBuf()).toContain('Revoked 1 session(s)')
|
||||
expect(store.entries.size).toBe(1)
|
||||
})
|
||||
@@ -106,9 +118,9 @@ describe('runDevicesRevoke', () => {
|
||||
const { reg, active } = buildRegistry(mock.url, 'tester@dify.ai', 'tok-1')
|
||||
const http = testHttpClient(mock.url, 'dfoa_test')
|
||||
|
||||
await expect(runDevicesRevoke({ io, reg, active, store, http, target: 'difyctl', all: false }))
|
||||
.rejects
|
||||
.toThrow(/matches multiple/)
|
||||
await expect(
|
||||
runDevicesRevoke({ io, reg, active, store, http, target: 'difyctl', all: false }),
|
||||
).rejects.toThrow(/matches multiple/)
|
||||
})
|
||||
|
||||
it('no match throws', async () => {
|
||||
@@ -117,9 +129,9 @@ describe('runDevicesRevoke', () => {
|
||||
const { reg, active } = buildRegistry(mock.url, 'tester@dify.ai', 'tok-1')
|
||||
const http = testHttpClient(mock.url, 'dfoa_test')
|
||||
|
||||
await expect(runDevicesRevoke({ io, reg, active, store, http, target: 'nonexistent', all: false }))
|
||||
.rejects
|
||||
.toThrow(/no session matches/)
|
||||
await expect(
|
||||
runDevicesRevoke({ io, reg, active, store, http, target: 'nonexistent', all: false }),
|
||||
).rejects.toThrow(/no session matches/)
|
||||
})
|
||||
|
||||
it('--all: revokes everything except current', async () => {
|
||||
@@ -151,9 +163,9 @@ describe('runDevicesRevoke', () => {
|
||||
const store = new MemStore()
|
||||
const { reg, active } = buildRegistry(mock.url, 'tester@dify.ai', 'tok-1')
|
||||
const http = testHttpClient(mock.url, 'dfoa_test')
|
||||
await expect(runDevicesRevoke({ io, reg, active, store, http, all: false }))
|
||||
.rejects
|
||||
.toThrow(/specify a device label/)
|
||||
await expect(runDevicesRevoke({ io, reg, active, store, http, all: false })).rejects.toThrow(
|
||||
/specify a device label/,
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -168,12 +180,14 @@ describe('listAllSessions', () => {
|
||||
expires_at: null,
|
||||
})
|
||||
|
||||
function stubClient(pages: readonly SessionListResponse[]): { client: AccountSessionsClient, list: ReturnType<typeof vi.fn> } {
|
||||
const list = vi.fn(async (q?: { page?: number, limit?: number }) => {
|
||||
function stubClient(pages: readonly SessionListResponse[]): {
|
||||
client: AccountSessionsClient
|
||||
list: ReturnType<typeof vi.fn>
|
||||
} {
|
||||
const list = vi.fn(async (q?: { page?: number; limit?: number }) => {
|
||||
const page = q?.page ?? 1
|
||||
const env = pages[page - 1]
|
||||
if (env === undefined)
|
||||
throw new Error(`stub: no page ${page}`)
|
||||
if (env === undefined) throw new Error(`stub: no page ${page}`)
|
||||
return env
|
||||
})
|
||||
return { client: { list } as unknown as AccountSessionsClient, list }
|
||||
@@ -181,8 +195,20 @@ describe('listAllSessions', () => {
|
||||
|
||||
it('exhausts pages until has_more=false', async () => {
|
||||
const { client, list } = stubClient([
|
||||
{ page: 1, limit: 200, total: 250, has_more: true, data: Array.from({ length: 200 }, (_, i) => row(`s-${i}`)) },
|
||||
{ page: 2, limit: 200, total: 250, has_more: false, data: Array.from({ length: 50 }, (_, i) => row(`s-${200 + i}`)) },
|
||||
{
|
||||
page: 1,
|
||||
limit: 200,
|
||||
total: 250,
|
||||
has_more: true,
|
||||
data: Array.from({ length: 200 }, (_, i) => row(`s-${i}`)),
|
||||
},
|
||||
{
|
||||
page: 2,
|
||||
limit: 200,
|
||||
total: 250,
|
||||
has_more: false,
|
||||
data: Array.from({ length: 50 }, (_, i) => row(`s-${200 + i}`)),
|
||||
},
|
||||
])
|
||||
const all = await listAllSessions(client)
|
||||
expect(all.length).toBe(250)
|
||||
|
||||
@@ -25,9 +25,8 @@ export async function runDevicesList(opts: DevicesListOptions): Promise<void> {
|
||||
const env = opts.envLookup ?? ((k: string) => process.env[k])
|
||||
const limit = resolveLimit(opts.limitRaw, env)
|
||||
const page = opts.page === undefined || opts.page <= 0 ? 1 : opts.page
|
||||
const envelope = await runWithSpinner(
|
||||
{ io: opts.io, label: 'Fetching devices' },
|
||||
() => sessions.list({ page, limit }),
|
||||
const envelope = await runWithSpinner({ io: opts.io, label: 'Fetching devices' }, () =>
|
||||
sessions.list({ page, limit }),
|
||||
)
|
||||
|
||||
if (opts.json === true) {
|
||||
@@ -39,11 +38,9 @@ export async function runDevicesList(opts: DevicesListOptions): Promise<void> {
|
||||
}
|
||||
|
||||
function resolveLimit(raw: string | undefined, env: (k: string) => string | undefined): number {
|
||||
if (raw !== undefined && raw !== '')
|
||||
return parseLimit(raw, '--limit')
|
||||
if (raw !== undefined && raw !== '') return parseLimit(raw, '--limit')
|
||||
const envValue = env('DIFY_LIMIT')
|
||||
if (envValue !== undefined && envValue !== '')
|
||||
return parseLimit(envValue, 'DIFY_LIMIT')
|
||||
if (envValue !== undefined && envValue !== '') return parseLimit(envValue, 'DIFY_LIMIT')
|
||||
return LIMIT_DEFAULT
|
||||
}
|
||||
|
||||
@@ -52,7 +49,9 @@ function resolveLimit(raw: string | undefined, env: (k: string) => string | unde
|
||||
* session sitting on page 2+ is still findable / revocable. Uses the max
|
||||
* page size (LIMIT_MAX) to minimize round-trips.
|
||||
*/
|
||||
export async function listAllSessions(client: AccountSessionsClient): Promise<readonly SessionRow[]> {
|
||||
export async function listAllSessions(
|
||||
client: AccountSessionsClient,
|
||||
): Promise<readonly SessionRow[]> {
|
||||
const out: SessionRow[] = []
|
||||
let page = 1
|
||||
// Hard guard against a misbehaving server that lies about has_more.
|
||||
@@ -60,8 +59,7 @@ export async function listAllSessions(client: AccountSessionsClient): Promise<re
|
||||
while (page <= MAX_PAGES) {
|
||||
const env = await client.list({ page, limit: LIMIT_MAX })
|
||||
out.push(...env.data)
|
||||
if (!env.has_more)
|
||||
return out
|
||||
if (!env.has_more) return out
|
||||
page++
|
||||
}
|
||||
return out
|
||||
@@ -84,7 +82,7 @@ export async function runDevicesRevoke(opts: DevicesRevokeOptions): Promise<void
|
||||
throw new BaseError({
|
||||
code: ErrorCode.UsageMissingArg,
|
||||
message: 'specify a device label / id, or pass --all',
|
||||
hint: 'see \'difyctl auth devices list\'',
|
||||
hint: "see 'difyctl auth devices list'",
|
||||
})
|
||||
}
|
||||
|
||||
@@ -96,11 +94,9 @@ export async function runDevicesRevoke(opts: DevicesRevokeOptions): Promise<void
|
||||
return
|
||||
}
|
||||
|
||||
for (const id of ids)
|
||||
await sessions.revoke(id)
|
||||
for (const id of ids) await sessions.revoke(id)
|
||||
|
||||
if (selfHit)
|
||||
await opts.reg.forget(opts.active, opts.store)
|
||||
if (selfHit) await opts.reg.forget(opts.active, opts.store)
|
||||
|
||||
opts.io.out.write(`${cs.successIcon()} Revoked ${ids.length} session(s)\n`)
|
||||
}
|
||||
@@ -110,30 +106,29 @@ export type PickResult = {
|
||||
selfHit: boolean
|
||||
}
|
||||
|
||||
export function pickTargets(rows: readonly SessionRow[], opts: { target?: string, all: boolean }, currentId: string): PickResult {
|
||||
export function pickTargets(
|
||||
rows: readonly SessionRow[],
|
||||
opts: { target?: string; all: boolean },
|
||||
currentId: string,
|
||||
): PickResult {
|
||||
if (opts.all) {
|
||||
const ids = rows.filter(r => r.id !== currentId).map(r => r.id)
|
||||
const ids = rows.filter((r) => r.id !== currentId).map((r) => r.id)
|
||||
return { ids, selfHit: false }
|
||||
}
|
||||
const target = opts.target ?? ''
|
||||
const byLabel = rows.filter(r => r.device_label === target)
|
||||
if (byLabel.length > 1)
|
||||
throw ambiguous(target, byLabel)
|
||||
const byLabel = rows.filter((r) => r.device_label === target)
|
||||
if (byLabel.length > 1) throw ambiguous(target, byLabel)
|
||||
const onlyLabel = byLabel[0]
|
||||
if (onlyLabel !== undefined)
|
||||
return { ids: [onlyLabel.id], selfHit: onlyLabel.id === currentId }
|
||||
if (onlyLabel !== undefined) return { ids: [onlyLabel.id], selfHit: onlyLabel.id === currentId }
|
||||
|
||||
const byId = rows.find(r => r.id === target)
|
||||
if (byId !== undefined)
|
||||
return { ids: [byId.id], selfHit: byId.id === currentId }
|
||||
const byId = rows.find((r) => r.id === target)
|
||||
if (byId !== undefined) return { ids: [byId.id], selfHit: byId.id === currentId }
|
||||
|
||||
const needle = target.toLowerCase()
|
||||
const bySub = rows.filter(r => r.device_label.toLowerCase().includes(needle))
|
||||
if (bySub.length > 1)
|
||||
throw ambiguous(target, bySub)
|
||||
const bySub = rows.filter((r) => r.device_label.toLowerCase().includes(needle))
|
||||
if (bySub.length > 1) throw ambiguous(target, bySub)
|
||||
const onlySub = bySub[0]
|
||||
if (onlySub !== undefined)
|
||||
return { ids: [onlySub.id], selfHit: onlySub.id === currentId }
|
||||
if (onlySub !== undefined) return { ids: [onlySub.id], selfHit: onlySub.id === currentId }
|
||||
|
||||
throw new BaseError({
|
||||
code: ErrorCode.UsageMissingArg,
|
||||
@@ -142,7 +137,7 @@ export function pickTargets(rows: readonly SessionRow[], opts: { target?: string
|
||||
}
|
||||
|
||||
function ambiguous(target: string, rows: readonly SessionRow[]): BaseError {
|
||||
const labels = rows.map(r => `${r.device_label} (${r.id})`).join(', ')
|
||||
const labels = rows.map((r) => `${r.device_label} (${r.id})`).join(', ')
|
||||
return new BaseError({
|
||||
code: ErrorCode.UsageInvalidFlag,
|
||||
message: `"${target}" matches multiple sessions: ${labels}; pass an exact id to disambiguate`,
|
||||
@@ -151,14 +146,19 @@ function ambiguous(target: string, rows: readonly SessionRow[]): BaseError {
|
||||
|
||||
function renderTable(rows: readonly SessionRow[], currentId: string): string {
|
||||
const header = ['DEVICE', 'CREATED', 'LAST USED', 'CURRENT']
|
||||
const body = rows.map(r => [
|
||||
const body = rows.map((r) => [
|
||||
r.device_label !== '' ? r.device_label : r.id,
|
||||
r.created_at ?? '',
|
||||
r.last_used_at ?? '',
|
||||
r.id === currentId ? '*' : '',
|
||||
])
|
||||
const widths = header.map((h, i) => Math.max(h.length, ...body.map(row => (row[i] ?? '').length)))
|
||||
const widths = header.map((h, i) =>
|
||||
Math.max(h.length, ...body.map((row) => (row[i] ?? '').length)),
|
||||
)
|
||||
const fmt = (cells: readonly string[]): string =>
|
||||
cells.map((c, i) => c.padEnd(widths[i] ?? 0)).join(' ').trimEnd()
|
||||
cells
|
||||
.map((c, i) => c.padEnd(widths[i] ?? 0))
|
||||
.join(' ')
|
||||
.trimEnd()
|
||||
return body.length === 0 ? `${fmt(header)}\n` : `${[fmt(header), ...body.map(fmt)].join('\n')}\n`
|
||||
}
|
||||
|
||||
@@ -14,9 +14,9 @@ export default class DevicesList extends DifyCommand {
|
||||
|
||||
static override flags = {
|
||||
'http-retry': httpRetryFlag,
|
||||
'json': Flags.boolean({ description: 'emit JSON', default: false }),
|
||||
'page': Flags.integer({ description: 'page number', default: 1 }),
|
||||
'limit': Flags.string({ description: 'page size [1..200]' }),
|
||||
json: Flags.boolean({ description: 'emit JSON', default: false }),
|
||||
page: Flags.integer({ description: 'page number', default: 1 }),
|
||||
limit: Flags.string({ description: 'page size [1..200]' }),
|
||||
}
|
||||
|
||||
async run(argv: string[]): Promise<void> {
|
||||
|
||||
@@ -19,9 +19,12 @@ export default class DevicesRevoke extends DifyCommand {
|
||||
}
|
||||
|
||||
static override flags = {
|
||||
'all': Flags.boolean({ description: 'revoke every session except the current one', default: false }),
|
||||
all: Flags.boolean({
|
||||
description: 'revoke every session except the current one',
|
||||
default: false,
|
||||
}),
|
||||
'http-retry': httpRetryFlag,
|
||||
'yes': Flags.boolean({ description: 'skip confirmation prompt', default: false }),
|
||||
yes: Flags.boolean({ description: 'skip confirmation prompt', default: false }),
|
||||
}
|
||||
|
||||
async run(argv: string[]): Promise<void> {
|
||||
|
||||
@@ -52,14 +52,14 @@ export class ContextListOutput {
|
||||
}
|
||||
|
||||
tableRows(): readonly (readonly TableCell[])[] {
|
||||
return this.rows.map(r => r.tableRow())
|
||||
return this.rows.map((r) => r.tableRow())
|
||||
}
|
||||
|
||||
name(): string {
|
||||
return this.rows.map(r => r.name()).join('\n')
|
||||
return this.rows.map((r) => r.name()).join('\n')
|
||||
}
|
||||
|
||||
json() {
|
||||
return { contexts: this.rows.map(r => r.json()) }
|
||||
return { contexts: this.rows.map((r) => r.json()) }
|
||||
}
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user