mirror of
https://github.com/hanzoai/flow.git
synced 2026-10-17 01:29:18 +00:00
merge: upstream/main (sync, brand preserved)
# Conflicts: # .github/workflows/docs-update-openapi.yml # AGENTS.md # AGENTS.md~upstream_main # CLAUDE.md # CLAUDE.md~upstream_main # docker_example/docker-compose.yml # src/backend/base/flow/agentic/flows/TranslationFlow.json # src/backend/base/flow/agentic/flows/langflow_assistant.py # src/backend/base/flow/agentic/helpers/code_security.py # src/backend/base/flow/agentic/helpers/input_sanitization.py # src/backend/base/flow/agentic/helpers/streaming_retry.py # src/backend/base/flow/alembic/versions/8255e9fc18d9_add_name_to_deployment_provider_account.py # src/backend/base/flow/alembic/versions/a1b2c3d4e5f6_convert_provider_key_and_deployment_.py # src/backend/base/flow/alembic/versions/c0d2ce43b315_add_flow_version_deployment_attachment_.py # src/backend/base/flow/alembic/versions/d306e5c17c41_add_api_key_hash_column_to_apikey_table.py # src/backend/base/flow/alembic/versions/ef4b036b585d_add_session_metadata_column_to_message_.py # src/backend/base/flow/api/utils/flow_utils.py # src/backend/base/flow/api/utils/zip_utils.py # src/backend/base/flow/api/v1/flow_events.py # src/backend/base/flow/api/v1/flows_helpers.py # src/backend/base/flow/api/v1/mappers/__init__.py # src/backend/base/flow/api/v1/mappers/deployments/RULES.md # src/backend/base/flow/api/v1/mappers/deployments/__init__.py # src/backend/base/flow/api/v1/mappers/deployments/base.py # src/backend/base/flow/api/v1/mappers/deployments/contracts.py # src/backend/base/flow/api/v1/mappers/deployments/helpers.py # src/backend/base/flow/api/v1/mappers/deployments/registry.py # src/backend/base/flow/api/v1/mappers/deployments/sync.py # src/backend/base/flow/api/v1/mappers/deployments/watsonx_orchestrate/__init__.py # src/backend/base/flow/api/v1/mappers/deployments/watsonx_orchestrate/mapper.py # src/backend/base/flow/api/v1/mappers/deployments/watsonx_orchestrate/payloads.py # src/backend/base/flow/api/v1/projects_files.py # src/backend/base/flow/api/v1/projects_mcp_helpers.py # src/backend/base/flow/services/cache/disk.py # src/backend/base/flow/services/database/models/deployment/exceptions.py # src/backend/base/flow/services/database/models/deployment/guards.py # src/backend/base/flow/services/database/models/deployment/orm_guards.py # src/backend/base/flow/services/database/models/deployment_provider_account/schemas.py # src/backend/base/flow/services/database/models/deployment_provider_account/utils.py # src/backend/base/flow/services/tracing/span_sorting.py # src/backend/base/flow/tests/api/v1/test_deployment_guard_delete_endpoints.py # src/backend/base/flow/tests/services/database/models/deployment/test_deployment_guard_triggers.py # src/backend/base/flow/tests/services/database/models/deployment/test_exceptions.py # src/backend/base/flow/tests/services/database/models/deployment/test_guards.py # src/backend/base/flow/tests/services/database/models/deployment_provider_account/test_utils.py # src/backend/base/langflow/api/v1/__init__.py # src/backend/tests/unit/agentic/flows/test_langflow_assistant.py # src/frontend/Dockerfile # src/lfx/src/lfx/base/models/unified_models.py
This commit is contained in:
@@ -0,0 +1,225 @@
|
||||
---
|
||||
name: backend-code-review
|
||||
description: Review backend code for quality, security, maintainability, and best practices based on established checklist rules. Use when the user requests a review, analysis, or improvement of backend files (e.g., `.py`) under the `src/backend/` directory. Do NOT use for frontend files (e.g., `.tsx`, `.ts`, `.js`). Supports pending-change review, code snippets review, and file-focused review.
|
||||
---
|
||||
|
||||
# Backend Code Review
|
||||
|
||||
## When to use this skill
|
||||
|
||||
Use this skill whenever the user asks to **review, analyze, or improve** backend code (e.g., `.py`) under the `src/backend/` directory. Supports the following review modes:
|
||||
|
||||
- **Pending-change review**: when the user asks to review current changes (inspect staged/working-tree files slated for commit to get the changes).
|
||||
- **Code snippets review**: when the user pastes code snippets (e.g., a function/class/module excerpt) into the chat and asks for a review.
|
||||
- **File-focused review**: when the user points to specific files and asks for a review of those files (one file or a small, explicit set of files, e.g., `src/backend/base/langflow/api/v1/flows.py`).
|
||||
|
||||
Do NOT use this skill when:
|
||||
|
||||
- The request is about frontend code or UI (e.g., `.tsx`, `.ts`, `.js`, `src/frontend/`).
|
||||
- The user is not asking for a review/analysis/improvement of backend code.
|
||||
- The scope is not under `src/backend/` (unless the user explicitly asks to review backend-related changes outside `src/backend/`).
|
||||
|
||||
## How to use this skill
|
||||
|
||||
Follow these steps when using this skill:
|
||||
|
||||
1. **Identify the review mode** (pending-change vs snippet vs file-focused) based on the user's input. Keep the scope tight: review only what the user provided or explicitly referenced.
|
||||
2. Follow the rules defined in **Checklist** to perform the review. If no Checklist rule matches, apply **General Review Rules** as a fallback to perform the best-effort review.
|
||||
3. Compose the final output strictly following 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.
|
||||
|
||||
## Checklist
|
||||
|
||||
- db schema design: if the review scope includes code/files under `src/backend/base/langflow/services/database/models/` or Alembic migrations under `src/backend/base/langflow/alembic/versions/`, follow [references/db-schema-rule.md](references/db-schema-rule.md) to perform the review
|
||||
- architecture: if the review scope involves route/service/model layering, dependency direction, or moving responsibilities across modules, follow [references/architecture-rule.md](references/architecture-rule.md) to perform the review
|
||||
- service abstraction: if the review scope contains table/model operations (e.g., `select(...)`, `session.execute(...)`, joins, CRUD) and is not already inside a service under `src/backend/base/langflow/services/`, follow [references/repositories-rule.md](references/repositories-rule.md) to perform the review
|
||||
- sqlalchemy patterns: if the review scope involves SQLAlchemy/SQLModel session/query usage, db transaction/crud usage, `session_scope()` usage, or raw SQL usage, follow [references/sqlalchemy-rule.md](references/sqlalchemy-rule.md) to perform the review
|
||||
|
||||
## General Review Rules
|
||||
|
||||
### 1. Security Review
|
||||
|
||||
Check for:
|
||||
- SQL injection vulnerabilities (especially raw `text()` queries with string interpolation). Consequence: attacker can read/modify/delete any data in the database.
|
||||
- Server-Side Request Forgery (SSRF) in component HTTP calls. Consequence: attacker uses the server to scan internal networks or access cloud metadata endpoints.
|
||||
- Command injection (especially in subprocess or shell-executing components). Consequence: attacker gains shell access to the server.
|
||||
- Insecure deserialization (pickle, yaml.load without SafeLoader). Consequence: arbitrary code execution on the server.
|
||||
- Hardcoded secrets/credentials. Consequence: secrets leak via git history and are impossible to fully revoke.
|
||||
- Improper authentication/authorization (missing `CurrentActiveUser` dependency). Consequence: unauthenticated users can access protected endpoints.
|
||||
- Insecure direct object references (missing `user_id` scoping on queries). Consequence: user A can read/modify user B's flows, variables, API keys.
|
||||
- Path traversal in file storage operations. Consequence: attacker reads arbitrary server files (e.g., `/etc/passwd`, `.env`).
|
||||
|
||||
### 2. Performance Review
|
||||
|
||||
Check for:
|
||||
- N+1 queries (especially in loops calling `session.execute()`). Consequence: 100 flows = 101 DB queries instead of 2; page load goes from 50ms to 5s.
|
||||
- Missing database indexes on frequently queried columns. Consequence: full table scans on large datasets; queries degrade from O(log n) to O(n).
|
||||
- Memory leaks (unbounded caches, retained references in long-lived services). Consequence: server OOM after hours of operation; pods restart in production.
|
||||
- Blocking operations in async code (`time.sleep()`, synchronous I/O, CPU-bound work without `run_in_executor`). Consequence: entire event loop stalls; all concurrent requests hang until the blocking call completes.
|
||||
- Missing caching opportunities for expensive computations. Consequence: repeated computation of the same result on every request.
|
||||
- Large result sets loaded entirely into memory without pagination. Consequence: memory spike + slow response when user has 10K+ flows.
|
||||
|
||||
### 3. Code Quality Review
|
||||
|
||||
Check for:
|
||||
- Code forward compatibility with Python 3.10-3.13
|
||||
- Code duplication (DRY violations — extract when the *exact same business rule* is duplicated in 3+ places)
|
||||
- Functions doing too much (SRP violations — if you need "and" to describe it, split it)
|
||||
- Deep nesting / complex conditionals (prefer early returns and guard clauses)
|
||||
- Magic numbers/strings (extract to named constants or enums)
|
||||
- Poor naming: unclear abbreviations, misleading names, generic names (`data`, `result`, `obj`, `temp`). Functions should use verbs (`get`, `create`, `validate`). Booleans should use prefixes (`is_`, `has_`, `can_`, `should_`).
|
||||
- Missing error handling (bare `except`, swallowed exceptions, silent failures)
|
||||
- Incomplete type coverage (use strong typing, avoid `Any` where a concrete type is known)
|
||||
- Use Python 3.10+ union syntax (`X | Y` not `Union[X, Y]`, `X | None` not `Optional[X]`)
|
||||
- Use `TYPE_CHECKING` guard for imports only needed for type annotations (prevents circular imports)
|
||||
- Use `Annotated[Type, Depends(...)]` with project aliases (`CurrentActiveUser`, `DbSession`, `DbSessionReadOnly`) for FastAPI DI
|
||||
- Google-style docstrings (enforced by Ruff): `Args:`, `Returns:`, `Raises:` sections for public functions
|
||||
- Violations of SOLID principles
|
||||
- YAGNI violations (code that anticipates future needs without a present requirement)
|
||||
- Line length exceeding 120 characters (project Ruff config)
|
||||
- Comments that explain WHAT instead of WHY (comments should only explain reasoning, not restate code)
|
||||
- Commented-out code (use version control instead)
|
||||
- Boolean parameters that switch function behavior (split into two named functions instead)
|
||||
- Mutable shared state where immutable alternatives exist (prefer returning new objects over mutation)
|
||||
|
||||
### 4. File Structure Review
|
||||
|
||||
Check for:
|
||||
- Production files exceeding ~500 lines of code (excluding imports, types, and docstrings). Files above 600 lines are a red flag and should be split by responsibility. Why: Files above 500 lines have statistically higher defect rates and take longer to review. They signal multiple responsibilities (SRP violation). In Langflow, services like `DatabaseService` that grow beyond this limit should have their CRUD operations extracted to dedicated modules.
|
||||
- Test files exceeding ~1000 lines. Split by logical grouping if exceeded.
|
||||
- No more than 5 functions with different responsibilities in a single file (per AGENTS-example.md).
|
||||
- Each file has a single reason to exist and a single reason to change (SRP).
|
||||
- No generic file names: `utils.py`, `helpers.py`, `misc.py`, `common.py` as standalone files. Why: A file named `utils.py` becomes a dumping ground for unrelated functions. Within months it has 50+ functions covering formatting, validation, parsing, and HTTP calls — violating SRP. Each function group should be in a file named after its responsibility (`formatting.py`, `validation.py`).
|
||||
|
||||
### 5. Testing Review
|
||||
|
||||
Check for:
|
||||
- Missing test coverage for new code paths
|
||||
- Tests that don't test behavior (testing implementation details)
|
||||
- Flaky test patterns (time-dependent, order-dependent, external-service-dependent)
|
||||
- Proper use of `pytest.mark.asyncio` for async tests
|
||||
- Excessive mocking (prefer real integrations per project conventions)
|
||||
- Coverage target: 80% (minimum acceptable: 75%)
|
||||
- Test anti-patterns: The Liar (passes but doesn't verify claimed behavior), The Mirror (asserts exactly what code does), The Giant (50+ lines setup), The Mockery (tests only mock setup), The Inspector (coupled to implementation), The Chain Gang (depends on execution order), The Flaky (inconsistent results)
|
||||
|
||||
**Happy path tests are the foundation but are NOT enough.** Tests MUST also challenge the code to find real defects:
|
||||
|
||||
- **Unexpected inputs**: `None`, `""`, `[]`, `{}`, `0`, `-1`, `UUID("00000000-0000-0000-0000-000000000000")`
|
||||
- **Boundary values**: max length strings, exactly at the limit, one past the limit, zero items, max items
|
||||
- **Malformed data**: missing required fields, extra unexpected fields, wrong types, invalid formats
|
||||
- **Error states**: what happens when the database is down? When an external API returns 500? When the user doesn't exist?
|
||||
- **What should NOT happen**: verify that user A CANNOT access user B's flows. Verify that a deleted flow returns 404. Verify that invalid `endpoint_name` is rejected with 422.
|
||||
- **Error messages and types**: not just that it fails, but that it fails with the RIGHT exception and the RIGHT message
|
||||
- **Concurrency**: what happens when two requests try to update the same flow simultaneously?
|
||||
|
||||
**Write tests based on REQUIREMENTS/SPEC, not on what the source code currently does.** This is how you catch bugs where the code diverges from expected behavior.
|
||||
|
||||
**When a test fails:** first ask if the CODE is wrong, not the test. Do NOT silently change a failing assertion to match the current code without understanding WHY.
|
||||
|
||||
### 6. Observability Review
|
||||
|
||||
Check for:
|
||||
- Use the async logger from `lfx.log.logger` with `a`-prefixed methods (`adebug`, `ainfo`, `awarning`, `aerror`, `aexception`). Never use `print()` or stdlib `logging`.
|
||||
- Log at key decision points and boundaries, not inside tight loops
|
||||
- Include: operation name, relevant IDs, outcome (success/failure), duration if relevant
|
||||
- Correct log levels: ERROR (broken, needs attention), WARN (degraded but recoverable), INFO (significant events), DEBUG (diagnostic, off in prod)
|
||||
- **ZERO PII TOLERANCE**: Never log email addresses, user names, phone numbers, tokens, passwords. Only approved identifiers: `user_id`, `flow_id`, `session_id`
|
||||
- No `print()` statements — these go to production logs
|
||||
- Use `{e!s}` for string representation of exceptions in log messages
|
||||
|
||||
### 7. Pre-Commit Verification
|
||||
|
||||
For pending-change reviews, verify the author has run:
|
||||
- `make format_backend` (Ruff formatter) — inconsistent formatting creates noisy diffs that hide real changes in code review. Format first, review second.
|
||||
- `make lint` (MyPy type checking) — type errors caught at lint time are 10x cheaper to fix than runtime crashes in production. Langflow services use duck typing via `Service` base class; MyPy catches mismatches early.
|
||||
- `make unit_tests` (pytest) — a failing test means the change breaks existing behavior. Never merge with failing tests; investigate whether the code or the test is wrong.
|
||||
|
||||
## Required Output Format
|
||||
|
||||
When this skill is invoked, the response must exactly follow one of the two templates:
|
||||
|
||||
### Template A (any findings)
|
||||
|
||||
```markdown
|
||||
# Code Review Summary
|
||||
|
||||
Found <X> critical issues need to be fixed:
|
||||
|
||||
## 🔴 Critical (Must Fix)
|
||||
|
||||
### 1. <brief description of the issue>
|
||||
|
||||
FilePath: <path> line <line>
|
||||
<relevant code snippet or pointer>
|
||||
|
||||
#### Explanation
|
||||
|
||||
<detailed explanation and references of the issue>
|
||||
|
||||
#### Suggested Fix
|
||||
|
||||
1. <brief description of suggested fix>
|
||||
2. <code example> (optional, omit if not applicable)
|
||||
|
||||
---
|
||||
... (repeat for each critical issue) ...
|
||||
|
||||
Found <Y> suggestions for improvement:
|
||||
|
||||
## 🟡 Suggestions (Should Consider)
|
||||
|
||||
### 1. <brief description of the suggestion>
|
||||
|
||||
FilePath: <path> line <line>
|
||||
<relevant code snippet or pointer>
|
||||
|
||||
#### Explanation
|
||||
|
||||
<detailed explanation and references of the suggestion>
|
||||
|
||||
#### Suggested Fix
|
||||
|
||||
1. <brief description of suggested fix>
|
||||
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>
|
||||
<relevant code snippet or pointer>
|
||||
|
||||
#### Explanation
|
||||
|
||||
<explanation and references of the optional nit>
|
||||
|
||||
#### Suggested Fix
|
||||
|
||||
- <minor suggestions>
|
||||
|
||||
---
|
||||
... (repeat for each nits) ...
|
||||
|
||||
## ✅ What's Good
|
||||
|
||||
- <Positive feedback on good patterns>
|
||||
```
|
||||
|
||||
- If there are no critical issues or suggestions or optional nits or good points, just omit that section.
|
||||
- If the issue number is more than 10, summarize as "Found 10+ critical issues/suggestions/optional nits" and only output the first 10 items.
|
||||
- Don't compress the blank lines between sections; keep them as-is for readability.
|
||||
- If there is any issue that requires code changes, append a brief follow-up question to ask whether the user wants to apply the fix(es) after the structured output. For example: "Would you like me to use the Suggested fix(es) to address these issues?"
|
||||
|
||||
### Template B (no issues)
|
||||
|
||||
```markdown
|
||||
## Code Review Summary
|
||||
✅ No issues found.
|
||||
```
|
||||
@@ -0,0 +1,243 @@
|
||||
# Rule Catalog — Architecture
|
||||
|
||||
## Scope
|
||||
- Covers: route handler/service/model layering, dependency direction, responsibility placement, helper module purity, and observability-friendly flow.
|
||||
- Key directories:
|
||||
- Routes: `src/backend/base/langflow/api/v1/`, `src/backend/base/langflow/api/v2/`
|
||||
- Services: `src/backend/base/langflow/services/`
|
||||
- Models: `src/backend/base/langflow/services/database/models/`
|
||||
- Helpers: `src/backend/base/langflow/helpers/`
|
||||
- Components: `src/backend/base/langflow/components/`
|
||||
|
||||
## Rules
|
||||
|
||||
### Keep business logic out of route handlers
|
||||
- Category: maintainability
|
||||
- Severity: critical
|
||||
- Description: Route handlers (FastAPI endpoint functions) should parse input, delegate to a service, and return a serialized response. Business decisions inside route handlers make behavior hard to reuse, test, and maintain. Langflow uses FastAPI with `Depends()` for dependency injection and async handlers throughout.
|
||||
- Suggested fix: Move domain/business logic into the appropriate service under `src/backend/base/langflow/services/`. Keep route handlers thin and orchestration-focused.
|
||||
- Example:
|
||||
- Bad:
|
||||
```python
|
||||
@router.post("/flows/{flow_id}/publish")
|
||||
async def publish_flow(
|
||||
flow_id: UUID,
|
||||
session: AsyncSession = Depends(injectable_session_scope),
|
||||
current_user: User = Depends(get_current_active_user),
|
||||
):
|
||||
stmt = select(Flow).where(Flow.id == flow_id, Flow.user_id == current_user.id)
|
||||
flow = (await session.execute(stmt)).scalar_one_or_none()
|
||||
if not flow:
|
||||
raise HTTPException(status_code=404, detail="Flow not found")
|
||||
if flow.access_type == AccessTypeEnum.PUBLIC:
|
||||
raise HTTPException(status_code=400, detail="Already published")
|
||||
flow.access_type = AccessTypeEnum.PUBLIC
|
||||
flow.updated_at = datetime.now(timezone.utc)
|
||||
session.add(flow)
|
||||
await session.commit()
|
||||
await session.refresh(flow)
|
||||
return FlowRead.model_validate(flow, from_attributes=True)
|
||||
```
|
||||
- Good:
|
||||
```python
|
||||
@router.post("/flows/{flow_id}/publish")
|
||||
async def publish_flow(
|
||||
flow_id: UUID,
|
||||
session: AsyncSession = Depends(injectable_session_scope),
|
||||
current_user: User = Depends(get_current_active_user),
|
||||
):
|
||||
flow = await flow_service.publish_flow(
|
||||
flow_id=flow_id, user_id=current_user.id, session=session
|
||||
)
|
||||
return FlowRead.model_validate(flow, from_attributes=True)
|
||||
```
|
||||
|
||||
### Preserve layer dependency direction
|
||||
- Category: best practices
|
||||
- Severity: critical
|
||||
- Description: Routes may depend on services, and services may depend on models and domain abstractions. Reversing this direction (for example, a model or service importing from `langflow.api`) creates cycles and leaks transport concerns into domain code. The dependency flow must be: Routes -> Services -> Models (never reverse).
|
||||
- Suggested fix: Extract shared contracts into service-level or model-level modules and make upper layers depend on lower, not the reverse.
|
||||
- Example:
|
||||
- Bad:
|
||||
```python
|
||||
# src/backend/base/langflow/services/database/models/flow/model.py
|
||||
from langflow.api.v1.schemas import FlowListCreate # Model importing from API layer
|
||||
|
||||
class Flow(FlowBase, table=True):
|
||||
def to_api_response(self) -> FlowListCreate:
|
||||
return FlowListCreate(...)
|
||||
```
|
||||
- Good:
|
||||
```python
|
||||
# src/backend/base/langflow/services/database/models/flow/model.py
|
||||
class Flow(FlowBase, table=True):
|
||||
pass # No API-layer imports
|
||||
|
||||
# src/backend/base/langflow/api/v1/flows.py (route layer handles serialization)
|
||||
flow = await get_flow(flow_id, session)
|
||||
return FlowRead.model_validate(flow, from_attributes=True)
|
||||
```
|
||||
|
||||
### Keep helpers business-agnostic
|
||||
- Category: maintainability
|
||||
- Severity: critical
|
||||
- Description: Modules under `src/backend/base/langflow/helpers/` should remain reusable, business-agnostic building blocks. They must not encode product/domain-specific rules, workflow orchestration, or business decisions. Helpers may contain thin wrappers for user lookups or data transformation but must not implement business policy.
|
||||
- Suggested fix:
|
||||
- If business logic appears in `src/backend/base/langflow/helpers/`, extract it into the appropriate service under `src/backend/base/langflow/services/` and keep helpers focused on generic, cross-cutting utilities.
|
||||
- Keep helper dependencies clean: avoid importing service or route modules into helpers.
|
||||
- Example:
|
||||
- Bad:
|
||||
```python
|
||||
# src/backend/base/langflow/helpers/flow.py
|
||||
from langflow.services.variable.service import DatabaseVariableService
|
||||
|
||||
def should_archive_flow(flow: Flow, user_id: UUID) -> bool:
|
||||
# Domain policy and service dependency are leaking into helpers.
|
||||
service = DatabaseVariableService(get_settings_service())
|
||||
if service.has_premium_plan(user_id):
|
||||
return flow.idle_days > 90
|
||||
return flow.idle_days > 30
|
||||
```
|
||||
- Good:
|
||||
```python
|
||||
# src/backend/base/langflow/helpers/flow.py (business-agnostic helper)
|
||||
def is_older_than_days(updated_at: datetime, threshold_days: int) -> bool:
|
||||
delta = datetime.now(timezone.utc) - updated_at
|
||||
return delta.days > threshold_days
|
||||
|
||||
# src/backend/base/langflow/services/flow_service.py (business logic stays in service)
|
||||
from langflow.helpers.flow import is_older_than_days
|
||||
|
||||
async def should_archive_flow(flow: Flow, user_id: UUID) -> bool:
|
||||
threshold_days = 90 if await has_premium_plan(user_id) else 30
|
||||
return is_older_than_days(flow.updated_at, threshold_days)
|
||||
```
|
||||
|
||||
### Domain logic must not import from FastAPI/HTTP layers
|
||||
- Category: best practices
|
||||
- Severity: critical
|
||||
- Description: Service classes and model definitions must never import FastAPI-specific constructs such as `Request`, `Response`, `HTTPException`, `APIRouter`, or `Depends`. This keeps the domain layer transport-agnostic and testable without spinning up an HTTP server. Langflow services inherit from `langflow.services.base.Service` and receive dependencies through their factory's `create()` method or via constructor injection, not through FastAPI's `Depends()`.
|
||||
- Suggested fix: Raise domain-specific exceptions in services (e.g., `FlowNotFoundError`, `PermissionDeniedError`) and let the route handler translate them into HTTP responses.
|
||||
- Example:
|
||||
- Bad:
|
||||
```python
|
||||
# src/backend/base/langflow/services/variable/service.py
|
||||
from fastapi import HTTPException
|
||||
|
||||
class DatabaseVariableService(VariableService, Service):
|
||||
async def get_variable(self, variable_id: UUID, user_id: UUID, session: AsyncSession):
|
||||
variable = await session.get(Variable, variable_id)
|
||||
if not variable or variable.user_id != user_id:
|
||||
raise HTTPException(status_code=404, detail="Variable not found")
|
||||
return variable
|
||||
```
|
||||
- Good:
|
||||
```python
|
||||
# src/backend/base/langflow/services/variable/service.py
|
||||
class VariableNotFoundError(Exception):
|
||||
pass
|
||||
|
||||
class DatabaseVariableService(VariableService, Service):
|
||||
async def get_variable(self, variable_id: UUID, user_id: UUID, session: AsyncSession):
|
||||
variable = await session.get(Variable, variable_id)
|
||||
if not variable or variable.user_id != user_id:
|
||||
raise VariableNotFoundError(f"Variable {variable_id} not found for user {user_id}")
|
||||
return variable
|
||||
|
||||
# src/backend/base/langflow/api/v1/variables.py (route translates to HTTP)
|
||||
@router.get("/variables/{variable_id}")
|
||||
async def get_variable(variable_id: UUID, ...):
|
||||
try:
|
||||
return await variable_service.get_variable(variable_id, current_user.id, session)
|
||||
except VariableNotFoundError:
|
||||
raise HTTPException(status_code=404, detail="Variable not found")
|
||||
```
|
||||
|
||||
### Components are leaf nodes in the dependency graph
|
||||
- Category: best practices
|
||||
- Severity: suggestion
|
||||
- Description: Components under `src/backend/base/langflow/components/` represent flow nodes and are instantiated dynamically by the graph engine. They should depend on services and models but no other component should import from them. Component class names are stable identifiers used to match components in saved flows; renaming a component class is a breaking change.
|
||||
- Suggested fix: If shared logic exists between components, extract it into a helper or a base class under `src/backend/base/langflow/custom/` rather than creating cross-component imports.
|
||||
|
||||
### Use Google-style docstrings
|
||||
- Category: best practices
|
||||
- Severity: suggestion
|
||||
- Description: The project enforces Google-style docstrings via Ruff (`pydocstyle.convention = "google"`). Public functions and classes should have docstrings with `Args:`, `Returns:`, and `Raises:` sections. Route handlers need at minimum a one-line summary. Private helpers (`_func`) can use simpler docstrings.
|
||||
- Example:
|
||||
- Bad:
|
||||
```python
|
||||
def timestamp_to_str(timestamp):
|
||||
# converts timestamp to string
|
||||
...
|
||||
```
|
||||
- Good:
|
||||
```python
|
||||
def timestamp_to_str(timestamp: datetime | str) -> str:
|
||||
"""Convert timestamp to standardized string format.
|
||||
|
||||
Args:
|
||||
timestamp: Input timestamp as datetime object or string.
|
||||
|
||||
Returns:
|
||||
Formatted timestamp string in 'YYYY-MM-DD HH:MM:SS UTC' format.
|
||||
|
||||
Raises:
|
||||
ValueError: If string timestamp is in invalid format.
|
||||
"""
|
||||
...
|
||||
```
|
||||
|
||||
### Use the async logger from `lfx.log.logger`
|
||||
- Category: best practices
|
||||
- Severity: critical
|
||||
- Description: Langflow uses an async-aware logger from `lfx.log.logger`. In async code, always use the `a`-prefixed methods (`adebug`, `ainfo`, `awarning`, `aerror`, `aexception`) to avoid blocking the event loop. Never use `print()` or stdlib `logging` directly. Use `aexception` for errors (auto-includes traceback). Use `{e!s}` for string representation of exceptions.
|
||||
- Example:
|
||||
- Bad:
|
||||
```python
|
||||
import logging
|
||||
logger = logging.getLogger(__name__)
|
||||
logger.info(f"Processing flow {flow_id}")
|
||||
print(f"Error: {e}")
|
||||
```
|
||||
- Good:
|
||||
```python
|
||||
from lfx.log.logger import logger
|
||||
|
||||
await logger.ainfo(f"Processing flow {flow_id}")
|
||||
await logger.aexception(f"Error processing flow {flow_id}: {e!s}")
|
||||
await logger.adebug("Skipping environment variable storage.")
|
||||
await logger.awarning(f"Session rolled back during {var_name} query.")
|
||||
```
|
||||
|
||||
### Use Python 3.10+ type syntax and FastAPI DI aliases
|
||||
- Category: best practices
|
||||
- Severity: suggestion
|
||||
- Description: Use modern Python 3.10+ union syntax (`X | Y` instead of `Union[X, Y]`, `X | None` instead of `Optional[X]`). Use `TYPE_CHECKING` guard for imports only needed for type annotations (prevents circular imports). Use `Annotated[Type, Depends(...)]` for FastAPI dependency injection with project type aliases like `CurrentActiveUser`, `DbSession`, `DbSessionReadOnly`.
|
||||
- Example:
|
||||
- Bad:
|
||||
```python
|
||||
from typing import Optional, Union
|
||||
from fastapi import Depends
|
||||
|
||||
async def get_flow(
|
||||
flow_id: UUID,
|
||||
session: AsyncSession = Depends(injectable_session_scope),
|
||||
user: User = Depends(get_current_active_user),
|
||||
) -> Optional[Flow]:
|
||||
...
|
||||
```
|
||||
- Good:
|
||||
```python
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from langflow.services.database.models.user.model import User
|
||||
|
||||
async def get_flow(
|
||||
*,
|
||||
flow_id: UUID,
|
||||
session: DbSession, # Annotated[AsyncSession, Depends(injectable_session_scope)]
|
||||
current_user: CurrentActiveUser, # Annotated[User, Depends(get_current_active_user)]
|
||||
) -> Flow | None:
|
||||
...
|
||||
```
|
||||
@@ -0,0 +1,261 @@
|
||||
# Rule Catalog — DB Schema Design
|
||||
|
||||
## Scope
|
||||
- Covers: SQLModel model definitions, schema boundaries in model properties, user-scoped schema design, index redundancy checks, dialect portability in models (SQLite + PostgreSQL), and cross-database compatibility in Alembic migrations.
|
||||
- Key directories:
|
||||
- Models: `src/backend/base/langflow/services/database/models/`
|
||||
- Migrations: `src/backend/base/langflow/alembic/versions/`
|
||||
- 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. Langflow uses SQLModel which combines SQLAlchemy table models with Pydantic validation; properties should only derive values from already-loaded fields.
|
||||
- Suggested fix:
|
||||
- Keep model properties pure and local to already-loaded fields.
|
||||
- Move cross-table data fetching to service methods or CRUD functions under the model's module (e.g., `models/flow/utils.py`).
|
||||
- For list/batch reads, fetch required related data explicitly (join/preload/bulk query) before rendering derived values.
|
||||
- Example:
|
||||
- Bad:
|
||||
```python
|
||||
class Flow(FlowBase, table=True):
|
||||
__tablename__ = "flow"
|
||||
|
||||
@property
|
||||
def folder_name(self) -> str:
|
||||
from lfx.services.deps import session_scope
|
||||
import asyncio
|
||||
loop = asyncio.get_event_loop()
|
||||
async def _get():
|
||||
async with session_scope() as session:
|
||||
folder = (await session.execute(
|
||||
select(Folder).where(Folder.id == self.folder_id)
|
||||
)).scalar_one()
|
||||
return folder.name
|
||||
return loop.run_until_complete(_get())
|
||||
```
|
||||
- Good:
|
||||
```python
|
||||
class Flow(FlowBase, table=True):
|
||||
__tablename__ = "flow"
|
||||
|
||||
@property
|
||||
def display_name(self) -> str:
|
||||
return self.name or "Untitled Flow"
|
||||
|
||||
# Service or CRUD layer performs explicit fetch for related Folder rows.
|
||||
async def get_flow_with_folder(flow_id: UUID, session: AsyncSession) -> tuple[Flow, Folder]:
|
||||
stmt = select(Flow, Folder).join(Folder, Flow.folder_id == Folder.id).where(Flow.id == flow_id)
|
||||
result = (await session.execute(stmt)).one_or_none()
|
||||
return result
|
||||
```
|
||||
|
||||
### Use `user_id` for user-scoped data
|
||||
- Category: maintainability
|
||||
- Severity: suggestion
|
||||
- Description: Langflow scopes user-owned data by `user_id` (not `tenant_id`). When an entity belongs to a specific user, include `user_id` in the model definition. This improves data isolation safety and keeps future multi-user or partitioning strategies practical. The `user_id` column should reference `user.id` via a foreign key where appropriate.
|
||||
- Suggested fix:
|
||||
- Add a `user_id` column and ensure related unique/index constraints include the user dimension when applicable.
|
||||
- Propagate `user_id` through service interfaces to keep access paths user-scoped.
|
||||
- Exception: if a table is explicitly designed as globally shared metadata (e.g., system settings), document that design decision clearly.
|
||||
- Example:
|
||||
- Bad:
|
||||
```python
|
||||
class CustomComponent(SQLModel, table=True):
|
||||
__tablename__ = "custom_component"
|
||||
id: UUID = Field(default_factory=uuid4, primary_key=True)
|
||||
name: str = Field(index=True)
|
||||
code: str = Field(sa_column=Column(Text))
|
||||
# Missing user_id: any user can see/modify any custom component
|
||||
```
|
||||
- Good:
|
||||
```python
|
||||
class CustomComponent(SQLModel, table=True):
|
||||
__tablename__ = "custom_component"
|
||||
id: UUID = Field(default_factory=uuid4, primary_key=True)
|
||||
user_id: UUID = Field(foreign_key="user.id", index=True)
|
||||
name: str = Field(index=True)
|
||||
code: str = Field(sa_column=Column(Text))
|
||||
```
|
||||
|
||||
### 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. This applies to both SQLModel `Field(index=True)` declarations and explicit `__table_args__` index definitions.
|
||||
- Suggested fix:
|
||||
- Before adding an index, compare against existing composite indexes by leftmost-prefix rules.
|
||||
- Drop or avoid creating redundant prefixes unless there is a proven query-pattern need.
|
||||
- Apply the same review standard in both model `__table_args__` and Alembic migration index DDL.
|
||||
- Example:
|
||||
- Bad:
|
||||
```python
|
||||
class Message(SQLModel, table=True):
|
||||
__tablename__ = "message"
|
||||
__table_args__ = (
|
||||
sa.Index("idx_msg_user_flow", "user_id", "flow_id"),
|
||||
sa.Index("idx_msg_user_flow_created", "user_id", "flow_id", "created_at"),
|
||||
)
|
||||
```
|
||||
- Good:
|
||||
```python
|
||||
class Message(SQLModel, table=True):
|
||||
__tablename__ = "message"
|
||||
__table_args__ = (
|
||||
# Keep the wider index unless profiling proves a dedicated short index is needed.
|
||||
sa.Index("idx_msg_user_flow_created", "user_id", "flow_id", "created_at"),
|
||||
)
|
||||
```
|
||||
|
||||
### Avoid dialect-specific constructs directly in models; use portable types
|
||||
- Category: maintainability
|
||||
- Severity: critical
|
||||
- Description: Langflow supports both SQLite (development) and PostgreSQL 15+ (production). Model/schema definitions should avoid PostgreSQL-only or SQLite-only constructs directly in business models. When database-specific behavior is required, encapsulate it behind a portable abstraction. SQLModel's `Field()` and `Column()` already provide most portability, but direct use of dialect-specific types (e.g., `postgresql.JSONB`, `postgresql.ARRAY`) breaks SQLite compatibility.
|
||||
- Suggested fix:
|
||||
- Use SQLModel's `JSON` column type (which maps to the appropriate dialect type) instead of `postgresql.JSONB`.
|
||||
- Use `sa.Text` or `sa.String` instead of dialect-specific text types.
|
||||
- If a dialect-specific feature is truly needed, add a conditional wrapper and document the rationale.
|
||||
- Example:
|
||||
- Bad:
|
||||
```python
|
||||
from sqlalchemy.dialects.postgresql import JSONB
|
||||
|
||||
class ToolConfig(SQLModel, table=True):
|
||||
__tablename__ = "tool_config"
|
||||
id: UUID = Field(default_factory=uuid4, primary_key=True)
|
||||
config: dict = Field(sa_column=Column(JSONB, nullable=False)) # Breaks on SQLite
|
||||
```
|
||||
- Good:
|
||||
```python
|
||||
from sqlmodel import JSON
|
||||
|
||||
class ToolConfig(SQLModel, table=True):
|
||||
__tablename__ = "tool_config"
|
||||
id: UUID = Field(default_factory=uuid4, primary_key=True)
|
||||
config: dict | None = Field(default=None, sa_column=Column(JSON)) # Works on both dialects
|
||||
```
|
||||
|
||||
### Guard migration incompatibilities with dialect checks
|
||||
- Category: maintainability
|
||||
- Severity: critical
|
||||
- Description: Alembic migration scripts under `src/backend/base/langflow/alembic/versions/` must account for SQLite/PostgreSQL incompatibilities explicitly. SQLite has limited ALTER TABLE support (no DROP COLUMN before 3.35, no ADD CONSTRAINT). For dialect-sensitive DDL or defaults, branch on the active dialect or use `op.batch_alter_table()` for SQLite compatibility.
|
||||
- Suggested fix:
|
||||
- In migration upgrades/downgrades, bind connection and branch by dialect for incompatible SQL fragments.
|
||||
- Use `op.batch_alter_table()` for column modifications on SQLite.
|
||||
- Avoid one-dialect-only migration logic unless there is a documented, deliberate compatibility exception.
|
||||
- Example:
|
||||
- Bad:
|
||||
```python
|
||||
def upgrade():
|
||||
op.add_column("flow", sa.Column(
|
||||
"access_type",
|
||||
sa.String(50),
|
||||
server_default=sa.text("'PRIVATE'::character varying"), # PostgreSQL-only cast
|
||||
nullable=False,
|
||||
))
|
||||
```
|
||||
- Good:
|
||||
```python
|
||||
def upgrade():
|
||||
conn = op.get_bind()
|
||||
if conn.dialect.name == "postgresql":
|
||||
default_expr = sa.text("'PRIVATE'::character varying")
|
||||
else:
|
||||
default_expr = sa.text("'PRIVATE'")
|
||||
|
||||
with op.batch_alter_table("flow") as batch_op:
|
||||
batch_op.add_column(sa.Column(
|
||||
"access_type",
|
||||
sa.String(50),
|
||||
server_default=default_expr,
|
||||
nullable=False,
|
||||
))
|
||||
```
|
||||
|
||||
### SQLModel-specific patterns
|
||||
- Category: best practices
|
||||
- Severity: suggestion
|
||||
- Description: Langflow uses SQLModel which merges SQLAlchemy ORM and Pydantic validation into a single class hierarchy. Follow these conventions for model definitions:
|
||||
- Use `SQLModel` as the base class with `table=True` for database-backed models.
|
||||
- Use `SQLModel` without `table=True` for schema/validation-only models (e.g., `FlowCreate`, `FlowRead`, `FlowUpdate`).
|
||||
- Use `Field()` for column definitions; use `sa_column=Column(...)` only when `Field()` alone cannot express the constraint.
|
||||
- Use `Relationship()` for ORM relationships; put related model type hints behind `TYPE_CHECKING` to avoid circular imports.
|
||||
- Suggested fix: Follow the existing patterns in `src/backend/base/langflow/services/database/models/flow/model.py` for reference on how to define base models, table models, and CRUD schema models.
|
||||
- Example:
|
||||
- Bad:
|
||||
```python
|
||||
# Mixing table model and API schema in one class
|
||||
class Flow(SQLModel, table=True):
|
||||
id: UUID = Field(default_factory=uuid4, primary_key=True)
|
||||
name: str
|
||||
data: dict | None = None
|
||||
# API-only fields mixed in
|
||||
component_count: int = 0 # Not a DB column, computed at API time
|
||||
```
|
||||
- Good:
|
||||
```python
|
||||
# Table model: only DB columns
|
||||
class Flow(FlowBase, table=True):
|
||||
id: UUID = Field(default_factory=uuid4, primary_key=True)
|
||||
user_id: UUID = Field(foreign_key="user.id", index=True)
|
||||
|
||||
# Schema model: includes computed fields for API responses
|
||||
class FlowRead(FlowBase):
|
||||
id: UUID
|
||||
user_id: UUID
|
||||
component_count: int = 0
|
||||
```
|
||||
|
||||
### Follow the Base → Table → Create → Read → Update schema pattern
|
||||
- Category: best practices
|
||||
- Severity: suggestion
|
||||
- Description: Langflow uses a layered SQLModel schema pattern. `{Entity}Base(SQLModel)` defines shared fields, validators (`@field_validator`), and serializers (`@field_serializer`). `{Entity}(Base, table=True)` adds primary key and relationships. Separate `Create`, `Read`, and `Update` schemas control API boundaries. Use `model_dump(exclude_unset=True, exclude_none=True)` for partial updates. Use `FlowRead.model_validate(db_flow, from_attributes=True)` to convert ORM→schema while session is active.
|
||||
- Suggested fix:
|
||||
- Define Base → Table → Create → Read → Update for every entity.
|
||||
- Put `@field_validator` and `@field_serializer` on the Base so they apply everywhere.
|
||||
- Never return the Table model directly from a route — always convert to the Read schema.
|
||||
- Example:
|
||||
- Bad:
|
||||
```python
|
||||
# Returning the table model directly (leaks internal fields, relationships)
|
||||
@router.get("/{flow_id}")
|
||||
async def get_flow(flow_id: UUID, session: DbSession) -> Flow:
|
||||
return (await session.execute(select(Flow).where(Flow.id == flow_id))).scalar_one()
|
||||
```
|
||||
- Good:
|
||||
```python
|
||||
class FlowBase(SQLModel):
|
||||
name: str = Field(index=True)
|
||||
description: str | None = Field(default=None)
|
||||
|
||||
@field_validator("endpoint_name")
|
||||
@classmethod
|
||||
def validate_endpoint_name(cls, v):
|
||||
if v is not None and not re.match(r"^[a-zA-Z0-9_-]+$", v):
|
||||
raise HTTPException(status_code=422, detail="Invalid endpoint name")
|
||||
return v
|
||||
|
||||
@field_serializer("updated_at")
|
||||
def serialize_datetime(self, value):
|
||||
if isinstance(value, datetime):
|
||||
return value.replace(microsecond=0).isoformat()
|
||||
return value
|
||||
|
||||
class Flow(FlowBase, table=True):
|
||||
id: UUID = Field(default_factory=uuid4, primary_key=True)
|
||||
user_id: UUID = Field(foreign_key="user.id")
|
||||
|
||||
class FlowCreate(FlowBase):
|
||||
user_id: UUID | None = None
|
||||
folder_id: UUID | None = None
|
||||
|
||||
class FlowRead(FlowBase):
|
||||
id: UUID
|
||||
user_id: UUID | None = Field()
|
||||
|
||||
@router.get("/{flow_id}", response_model=FlowRead)
|
||||
async def get_flow(*, flow_id: UUID, session: DbSession, current_user: CurrentActiveUser) -> FlowRead:
|
||||
flow = (await session.execute(select(Flow).where(Flow.id == flow_id, Flow.user_id == current_user.id))).scalar_one()
|
||||
return FlowRead.model_validate(flow, from_attributes=True)
|
||||
```
|
||||
@@ -0,0 +1,217 @@
|
||||
# Rule Catalog — Service Abstraction
|
||||
|
||||
## Scope
|
||||
- Covers: when to reuse existing service abstractions, when to introduce new services, how to preserve dependency direction between route handlers and service/infrastructure implementations, and the ServiceFactory pattern.
|
||||
- Key directories:
|
||||
- Services: `src/backend/base/langflow/services/` (each service has `service.py` and `factory.py`)
|
||||
- Model CRUD functions: `src/backend/base/langflow/services/database/models/*/crud.py`
|
||||
- Service base class: `langflow.services.base.Service`
|
||||
- Service factory base: `langflow.services.factory.ServiceFactory`
|
||||
- Service registry: `langflow.services.manager.ServiceManager`
|
||||
- Service types: `langflow.services.schema.ServiceType`
|
||||
- Dependency helpers: `langflow.services.deps` (provides `get_service()`, `get_xxx_service()` functions)
|
||||
- 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
|
||||
|
||||
### Use existing services for DB operations; do not bypass with ad-hoc queries
|
||||
- Category: maintainability
|
||||
- Severity: suggestion
|
||||
- Description: Langflow uses a service layer pattern rather than a repository pattern. Each service under `src/backend/base/langflow/services/` encapsulates business logic and data access for its domain. If a service already handles operations for a given model/table, all reads/writes/queries for that table should go through the existing service (or its associated CRUD module). Additionally, many models have CRUD utility functions in `src/backend/base/langflow/services/database/models/<model>/crud.py` that should be reused rather than duplicated.
|
||||
- Suggested fix:
|
||||
- First check `src/backend/base/langflow/services/` and `src/backend/base/langflow/services/database/models/<model>/crud.py` to verify whether the table/model already has a service or CRUD abstraction. If it exists, route all operations through it and add missing methods instead of bypassing it with ad-hoc SQLAlchemy queries.
|
||||
- If no service or CRUD module exists, add methods to the most closely related existing service or CRUD module rather than scattering inline queries across route handlers.
|
||||
- Example:
|
||||
- Bad:
|
||||
```python
|
||||
# Route handler bypasses the existing variable service with inline queries
|
||||
@router.get("/variables")
|
||||
async def list_variables(
|
||||
session: AsyncSession = Depends(injectable_session_scope_readonly),
|
||||
current_user: User = Depends(get_current_active_user),
|
||||
):
|
||||
stmt = select(Variable).where(Variable.user_id == current_user.id)
|
||||
variables = (await session.execute(stmt)).scalars().all()
|
||||
return [VariableRead.model_validate(v, from_attributes=True) for v in variables]
|
||||
```
|
||||
- Good:
|
||||
```python
|
||||
# Route handler delegates to the variable service
|
||||
@router.get("/variables")
|
||||
async def list_variables(
|
||||
session: AsyncSession = Depends(injectable_session_scope_readonly),
|
||||
current_user: User = Depends(get_current_active_user),
|
||||
):
|
||||
variable_service = get_variable_service()
|
||||
variables = await variable_service.list_variables(user_id=current_user.id, session=session)
|
||||
return [VariableRead.model_validate(v, from_attributes=True) for v in variables]
|
||||
```
|
||||
|
||||
### Follow the ServiceFactory pattern for new services
|
||||
- Category: best practices
|
||||
- Severity: critical
|
||||
- Description: Langflow manages services through `ServiceManager` which uses `ServiceFactory` instances to create and configure services. Each service consists of:
|
||||
1. A base class or protocol (optional, for abstraction) inheriting from `langflow.services.base.Service`
|
||||
2. A concrete implementation in `service.py`
|
||||
3. A factory class in `factory.py` inheriting from `langflow.services.factory.ServiceFactory`
|
||||
4. A `ServiceType` enum entry in `langflow.services.schema`
|
||||
5. A `get_xxx_service()` function in `langflow.services.deps`
|
||||
|
||||
New services must follow this pattern to integrate with the dependency injection system. The factory's `create()` method receives other services as arguments (resolved by name from the ServiceManager).
|
||||
- Suggested fix: When introducing a new service, create all required files following the existing pattern. Register the factory in `ServiceManager.get_factories()` and add a convenience getter in `langflow.services.deps`.
|
||||
- Example:
|
||||
- Bad:
|
||||
```python
|
||||
# Ad-hoc singleton without factory integration
|
||||
class NotificationService:
|
||||
_instance = None
|
||||
|
||||
@classmethod
|
||||
def get_instance(cls):
|
||||
if cls._instance is None:
|
||||
cls._instance = cls()
|
||||
return cls._instance
|
||||
|
||||
def notify(self, user_id: UUID, message: str):
|
||||
...
|
||||
```
|
||||
- Good:
|
||||
```python
|
||||
# src/backend/base/langflow/services/notification/service.py
|
||||
from langflow.services.base import Service
|
||||
|
||||
class NotificationService(Service):
|
||||
name = "notification_service"
|
||||
|
||||
def __init__(self, settings_service: SettingsService):
|
||||
self.settings_service = settings_service
|
||||
|
||||
async def notify(self, user_id: UUID, message: str, session: AsyncSession) -> None:
|
||||
...
|
||||
|
||||
# src/backend/base/langflow/services/notification/factory.py
|
||||
from langflow.services.factory import ServiceFactory
|
||||
from langflow.services.notification.service import NotificationService
|
||||
|
||||
class NotificationServiceFactory(ServiceFactory):
|
||||
def __init__(self) -> None:
|
||||
super().__init__(NotificationService)
|
||||
|
||||
def create(self, settings_service: SettingsService):
|
||||
return NotificationService(settings_service)
|
||||
|
||||
# Register in ServiceManager.get_factories() and add getter in deps.py
|
||||
```
|
||||
|
||||
### When to introduce a new service vs. extend an existing one
|
||||
- Category: best practices
|
||||
- Severity: suggestion
|
||||
- Description: Not every new feature needs a new service. Introduce a new service only when:
|
||||
1. The domain is distinct enough that mixing it into an existing service would violate single responsibility.
|
||||
2. The service manages its own lifecycle (e.g., connections, background tasks, caches).
|
||||
3. Multiple other services or route modules need to depend on this functionality.
|
||||
4. The data access patterns are sufficiently different from existing services.
|
||||
|
||||
Otherwise, extend an existing service by adding methods to its `service.py` and corresponding CRUD functions.
|
||||
- Suggested fix:
|
||||
- For small, related features: add methods to the closest existing service.
|
||||
- For distinct domains with their own lifecycle: create a new service following the ServiceFactory pattern.
|
||||
- When in doubt, start by extending an existing service; extract into a new service later when complexity warrants it (YAGNI principle).
|
||||
- Example:
|
||||
- Bad:
|
||||
```python
|
||||
# Unnecessary new service for a simple feature that belongs in an existing service
|
||||
# src/backend/base/langflow/services/flow_stats/service.py
|
||||
class FlowStatsService(Service):
|
||||
name = "flow_stats_service"
|
||||
|
||||
async def get_flow_component_count(self, flow_id: UUID, session: AsyncSession) -> int:
|
||||
flow = (await session.execute(select(Flow).where(Flow.id == flow_id))).scalar_one()
|
||||
return len(flow.data.get("nodes", []))
|
||||
```
|
||||
- Good:
|
||||
```python
|
||||
# Add to existing flow-related code or a utility function
|
||||
# src/backend/base/langflow/services/database/models/flow/utils.py
|
||||
def count_components(flow_data: dict | None) -> int:
|
||||
if not flow_data:
|
||||
return 0
|
||||
return len(flow_data.get("nodes", []))
|
||||
```
|
||||
|
||||
### Access services through `get_service()` or dependency getters, not direct instantiation
|
||||
- Category: maintainability
|
||||
- Severity: critical
|
||||
- Description: Services are singletons managed by `ServiceManager`. They must be accessed through `get_service(ServiceType.XXX)` or the convenience functions in `langflow.services.deps` (e.g., `get_settings_service()`, `get_variable_service()`). Direct instantiation bypasses the factory pattern, ignores lifecycle management, and can create multiple conflicting instances.
|
||||
- Suggested fix: Always use the provided dependency functions. In route handlers, use FastAPI's `Depends()` with the appropriate getter. In service-to-service calls, use `get_service()` or accept the dependency through the factory's `create()` method.
|
||||
- Example:
|
||||
- Bad:
|
||||
```python
|
||||
# Direct instantiation bypasses the service manager
|
||||
from langflow.services.variable.service import DatabaseVariableService
|
||||
|
||||
async def some_function():
|
||||
settings = get_settings_service()
|
||||
variable_service = DatabaseVariableService(settings) # Wrong: creates a new instance
|
||||
await variable_service.get_variable(...)
|
||||
```
|
||||
- Good:
|
||||
```python
|
||||
# Use the dependency getter
|
||||
from langflow.services.deps import get_variable_service
|
||||
|
||||
async def some_function():
|
||||
variable_service = get_variable_service()
|
||||
await variable_service.get_variable(...)
|
||||
|
||||
# In route handlers, use Depends()
|
||||
@router.get("/variables/{variable_id}")
|
||||
async def get_variable(
|
||||
variable_id: UUID,
|
||||
session: AsyncSession = Depends(injectable_session_scope_readonly),
|
||||
current_user: User = Depends(get_current_active_user),
|
||||
):
|
||||
variable_service = get_variable_service()
|
||||
return await variable_service.get_variable(variable_id, current_user.id, session)
|
||||
```
|
||||
|
||||
### CRUD modules complement services, not replace them
|
||||
- Category: best practices
|
||||
- Severity: suggestion
|
||||
- Description: Many Langflow models have a `crud.py` file alongside their model definition (e.g., `models/message/crud.py`, `models/user/crud.py`). These CRUD modules contain reusable async functions for common database operations (get, list, create, update, delete). They are lower-level building blocks that services call internally. Route handlers should prefer calling service methods rather than CRUD functions directly, unless the operation is trivially simple and the model has no associated service.
|
||||
- Suggested fix:
|
||||
- Services should call CRUD functions internally to avoid duplicating query logic.
|
||||
- Route handlers should call services, not CRUD functions, to maintain the layering.
|
||||
- New CRUD functions should accept an `AsyncSession` parameter and stay stateless (pure data access, no business logic).
|
||||
- Example:
|
||||
- Bad:
|
||||
```python
|
||||
# Route handler calling CRUD directly, bypassing any business logic layer
|
||||
from langflow.services.database.models.message.crud import get_messages_by_flow_id
|
||||
|
||||
@router.get("/flows/{flow_id}/messages")
|
||||
async def list_messages(
|
||||
flow_id: UUID,
|
||||
session: AsyncSession = Depends(injectable_session_scope_readonly),
|
||||
current_user: User = Depends(get_current_active_user),
|
||||
):
|
||||
# No authorization check, no business logic, direct CRUD call
|
||||
return await get_messages_by_flow_id(flow_id, session)
|
||||
```
|
||||
- Good:
|
||||
```python
|
||||
# Service wraps CRUD with authorization and business logic
|
||||
class ChatService(Service):
|
||||
async def get_messages(self, flow_id: UUID, user_id: UUID, session: AsyncSession):
|
||||
# Verify user owns the flow
|
||||
flow = await get_flow_by_id_and_user(flow_id, user_id, session)
|
||||
if not flow:
|
||||
raise FlowNotFoundError(f"Flow {flow_id} not found")
|
||||
return await get_messages_by_flow_id(flow_id, session)
|
||||
|
||||
# Route handler delegates to service
|
||||
@router.get("/flows/{flow_id}/messages")
|
||||
async def list_messages(flow_id: UUID, ...):
|
||||
chat_service = get_chat_service()
|
||||
return await chat_service.get_messages(flow_id, current_user.id, session)
|
||||
```
|
||||
@@ -0,0 +1,222 @@
|
||||
# Rule Catalog — SQLAlchemy Patterns
|
||||
|
||||
## Scope
|
||||
- Covers: SQLAlchemy/SQLModel async session and transaction lifecycle, query construction, user scoping, raw SQL boundaries, write-path concurrency safeguards, and async-specific pitfalls.
|
||||
- Key modules:
|
||||
- Session management (canonical source): `lfx.services.deps.session_scope`, `lfx.services.deps.session_scope_readonly`
|
||||
- Langflow wrappers (delegates to lfx): `langflow.services.deps.session_scope`, `langflow.services.deps.session_scope_readonly`
|
||||
- Injectable dependencies: `lfx.services.deps.injectable_session_scope`, `lfx.services.deps.injectable_session_scope_readonly`
|
||||
- Database service: `langflow.services.database.service.DatabaseService`
|
||||
- Model CRUD functions: `src/backend/base/langflow/services/database/models/*/crud.py`
|
||||
- Import preference: Use `from langflow.services.deps import session_scope` in Langflow code for consistency. The Langflow wrappers are thin `@asynccontextmanager` functions that delegate to `lfx.services.deps`. Only import directly from `lfx` when working inside the `lfx` package itself.
|
||||
- Does NOT cover: table/model schema and migration design details (handled by `db-schema-rule.md`).
|
||||
|
||||
## Rules
|
||||
|
||||
### Use `session_scope()` context manager with explicit transaction awareness
|
||||
- Category: best practices
|
||||
- Severity: critical
|
||||
- Description: Langflow uses async sessions exclusively. The `session_scope()` context manager provides auto-commit on successful exit and auto-rollback on exception. For read-only paths, use `session_scope_readonly()` which skips commit overhead. In route handlers, use the injectable variants via `Depends(injectable_session_scope)`. Missing commits can silently drop intended updates, while ad-hoc or long-lived transactions increase contention and deadlock risk.
|
||||
- Suggested fix:
|
||||
- For write operations in services/CRUD: use `async with session_scope() as session:` which auto-commits on success.
|
||||
- For read-only operations: use `async with session_scope_readonly() as session:` to avoid unnecessary commit calls.
|
||||
- In route handlers: use `session: AsyncSession = Depends(injectable_session_scope)` for writes or `Depends(injectable_session_scope_readonly)` for reads.
|
||||
- Keep transaction windows short: avoid network I/O, heavy computation, or unrelated work inside the session scope.
|
||||
- Example:
|
||||
- Bad:
|
||||
```python
|
||||
# Creating a raw session without the context manager
|
||||
from sqlmodel.ext.asyncio.session import AsyncSession
|
||||
from langflow.services.deps import get_service
|
||||
|
||||
db_service = get_service(ServiceType.DATABASE_SERVICE)
|
||||
session = AsyncSession(db_service.engine)
|
||||
flow = (await session.execute(select(Flow).where(Flow.id == flow_id))).scalar_one()
|
||||
flow.name = "Updated"
|
||||
# Missing commit, session never closed properly
|
||||
```
|
||||
- Good:
|
||||
```python
|
||||
from langflow.services.deps import session_scope
|
||||
|
||||
async with session_scope() as session:
|
||||
flow = (await session.execute(select(Flow).where(Flow.id == flow_id))).scalar_one()
|
||||
flow.name = "Updated"
|
||||
# session_scope auto-commits on successful exit
|
||||
|
||||
# For read-only operations:
|
||||
from langflow.services.deps import session_scope_readonly
|
||||
|
||||
async with session_scope_readonly() as session:
|
||||
flows = (await session.execute(select(Flow).where(Flow.user_id == user_id))).scalars().all()
|
||||
```
|
||||
|
||||
### Enforce `user_id` scoping on user-owned queries
|
||||
- Category: security
|
||||
- Severity: critical
|
||||
- Description: Reads and writes against user-owned tables must be scoped by `user_id` to prevent cross-user data leakage or corruption. Langflow uses `user_id` (not `tenant_id`) for user-scoped data isolation. Every query on user-owned entities (flows, variables, folders, messages, API keys) must include a `user_id` filter.
|
||||
- Suggested fix: Add `user_id` predicate to all user-owned entity queries and propagate user context through service interfaces. The `current_user.id` is available from the `get_current_active_user` dependency in route handlers.
|
||||
- Example:
|
||||
- Bad:
|
||||
```python
|
||||
# Missing user_id scope: any authenticated user can read any flow
|
||||
stmt = select(Flow).where(Flow.id == flow_id)
|
||||
flow = (await session.execute(stmt)).scalar_one_or_none()
|
||||
```
|
||||
- Good:
|
||||
```python
|
||||
stmt = select(Flow).where(
|
||||
Flow.id == flow_id,
|
||||
Flow.user_id == current_user.id,
|
||||
)
|
||||
flow = (await session.execute(stmt)).scalar_one_or_none()
|
||||
```
|
||||
|
||||
### Prefer SQLAlchemy/SQLModel expressions over raw SQL
|
||||
- Category: maintainability
|
||||
- Severity: suggestion
|
||||
- Description: Raw SQL via `text()` should be exceptional. ORM/Core expressions are easier to evolve, safer to compose, dialect-portable (SQLite + PostgreSQL), and more consistent with the codebase. Langflow uses `sqlmodel.select()` for queries which provides both type safety and dialect portability.
|
||||
- Suggested fix: Rewrite straightforward raw SQL into SQLModel `select/update/delete` expressions; keep raw SQL only when required by clear technical constraints (e.g., database-specific administrative queries).
|
||||
- Example:
|
||||
- Bad:
|
||||
```python
|
||||
from sqlmodel import text
|
||||
|
||||
result = await session.execute(
|
||||
text("SELECT * FROM flow WHERE id = :id AND user_id = :user_id"),
|
||||
{"id": str(flow_id), "user_id": str(user_id)},
|
||||
)
|
||||
row = result.first()
|
||||
```
|
||||
- Good:
|
||||
```python
|
||||
from sqlmodel import select
|
||||
|
||||
stmt = select(Flow).where(
|
||||
Flow.id == flow_id,
|
||||
Flow.user_id == user_id,
|
||||
)
|
||||
flow = (await session.execute(stmt)).scalar_one_or_none()
|
||||
```
|
||||
|
||||
### Do not run blocking operations inside async session scopes
|
||||
- Category: performance
|
||||
- Severity: critical
|
||||
- Description: Langflow runs on an async event loop (uvicorn + FastAPI). Blocking calls inside a session scope (synchronous I/O, `time.sleep()`, CPU-bound computation, synchronous HTTP requests) block the entire event loop and starve other coroutines. This also extends transaction duration unnecessarily, increasing lock contention.
|
||||
- Suggested fix:
|
||||
- Move blocking operations outside the session scope.
|
||||
- Use `asyncio.to_thread()` or `anyio.to_thread.run_sync()` for CPU-bound or synchronous I/O work.
|
||||
- Perform external API calls before or after the database transaction, not inside it.
|
||||
- Example:
|
||||
- Bad:
|
||||
```python
|
||||
async with session_scope() as session:
|
||||
flow = (await session.execute(select(Flow).where(Flow.id == flow_id))).scalar_one()
|
||||
# Blocking HTTP call inside transaction scope
|
||||
import requests
|
||||
response = requests.post("https://external-api.com/validate", json=flow.data)
|
||||
flow.validated = response.ok
|
||||
```
|
||||
- Good:
|
||||
```python
|
||||
async with session_scope_readonly() as session:
|
||||
flow = (await session.execute(select(Flow).where(Flow.id == flow_id))).scalar_one()
|
||||
flow_data = flow.data
|
||||
|
||||
# External call outside transaction scope
|
||||
import httpx
|
||||
async with httpx.AsyncClient() as client:
|
||||
response = await client.post("https://external-api.com/validate", json=flow_data)
|
||||
|
||||
async with session_scope() as session:
|
||||
flow = (await session.execute(select(Flow).where(Flow.id == flow_id))).scalar_one()
|
||||
flow.validated = response.is_success
|
||||
```
|
||||
|
||||
### 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. Langflow's async architecture means multiple coroutines may attempt concurrent writes.
|
||||
- Suggested fix:
|
||||
- **Optimistic locking**: Use when contention is usually low and retries are acceptable. Add a version or `updated_at` guard in `WHERE` and treat `rowcount == 0` as a conflict.
|
||||
- **SELECT ... FOR UPDATE**: Use when contention is high on the same rows and strict in-transaction serialization is required. Keep transactions short to reduce lock wait/deadlock risk.
|
||||
- In all cases, scope by `user_id` and verify affected row counts for conditional writes.
|
||||
- Example:
|
||||
- Bad:
|
||||
```python
|
||||
# No user scope, no conflict detection on a contested write path
|
||||
async with session_scope() as session:
|
||||
await session.execute(
|
||||
update(Flow).where(Flow.id == flow_id).values(name="Updated")
|
||||
)
|
||||
```
|
||||
- Good:
|
||||
```python
|
||||
# Optimistic lock (low contention, retry on conflict)
|
||||
from sqlmodel import update
|
||||
|
||||
async with session_scope() as session:
|
||||
result = await session.execute(
|
||||
update(Flow)
|
||||
.where(
|
||||
Flow.id == flow_id,
|
||||
Flow.user_id == user_id,
|
||||
Flow.updated_at == expected_updated_at,
|
||||
)
|
||||
.values(name="Updated", updated_at=datetime.now(timezone.utc))
|
||||
)
|
||||
if result.rowcount == 0:
|
||||
raise FlowStateConflictError("Flow was modified concurrently, retry")
|
||||
|
||||
# Pessimistic lock with SELECT ... FOR UPDATE (high contention)
|
||||
async with session_scope() as session:
|
||||
flow = (await session.execute(
|
||||
select(Flow)
|
||||
.where(Flow.id == flow_id, Flow.user_id == user_id)
|
||||
.with_for_update()
|
||||
)).scalar_one()
|
||||
flow.name = "Updated"
|
||||
flow.updated_at = datetime.now(timezone.utc)
|
||||
```
|
||||
|
||||
### Keep transactions short; no I/O inside transactions
|
||||
- Category: performance
|
||||
- Severity: suggestion
|
||||
- Description: Long transactions hold database locks and increase contention, especially on SQLite where write locking is database-wide. Structure code so that data is fetched, external operations are performed, and then the write transaction is opened as late as possible and closed as early as possible.
|
||||
- Suggested fix:
|
||||
- Read data in one session scope, perform computation or external calls, then write results in a separate session scope.
|
||||
- Avoid nesting `session_scope()` calls (the context manager is not reentrant).
|
||||
- Batch related writes into a single session scope rather than opening multiple sequential scopes for related changes.
|
||||
- Example:
|
||||
- Bad:
|
||||
```python
|
||||
async with session_scope() as session:
|
||||
flows = (await session.execute(select(Flow).where(Flow.user_id == user_id))).scalars().all()
|
||||
for flow in flows:
|
||||
# Expensive computation inside transaction
|
||||
analysis = await analyze_flow_complexity(flow.data)
|
||||
flow.complexity_score = analysis.score
|
||||
flow.updated_at = datetime.now(timezone.utc)
|
||||
```
|
||||
- Good:
|
||||
```python
|
||||
# Read phase
|
||||
async with session_scope_readonly() as session:
|
||||
flows = (await session.execute(select(Flow).where(Flow.user_id == user_id))).scalars().all()
|
||||
flow_data = [(f.id, f.data) for f in flows]
|
||||
|
||||
# Compute phase (outside transaction)
|
||||
updates = {}
|
||||
for flow_id, data in flow_data:
|
||||
analysis = await analyze_flow_complexity(data)
|
||||
updates[flow_id] = analysis.score
|
||||
|
||||
# Write phase (short transaction)
|
||||
async with session_scope() as session:
|
||||
for flow_id, score in updates.items():
|
||||
await session.execute(
|
||||
update(Flow)
|
||||
.where(Flow.id == flow_id, Flow.user_id == user_id)
|
||||
.values(complexity_score=score, updated_at=datetime.now(timezone.utc))
|
||||
)
|
||||
```
|
||||
@@ -0,0 +1,435 @@
|
||||
---
|
||||
name: component-refactoring
|
||||
description: Refactor high-complexity React components in Langflow frontend. Use when manual complexity assessment shows complexity > 50 or lineCount > 300, when the user asks for code splitting, hook extraction, or complexity reduction; avoid for simple/well-structured components, third-party wrappers, or when the user explicitly wants testing without refactoring.
|
||||
---
|
||||
|
||||
# Langflow Component Refactoring Skill
|
||||
|
||||
Refactor high-complexity React components in the Langflow frontend codebase with the patterns and workflow below.
|
||||
|
||||
> **Complexity Threshold**: Components with complexity > 50 (measured manually by counting conditionals, nesting levels, and lines) should be refactored before testing.
|
||||
|
||||
## Quick Reference
|
||||
|
||||
### Commands (run from `src/frontend/`)
|
||||
|
||||
```bash
|
||||
cd src/frontend
|
||||
|
||||
# Lint with Biome
|
||||
npm run lint
|
||||
|
||||
# Type checking
|
||||
npm run type-check
|
||||
|
||||
# Run tests
|
||||
npm test
|
||||
```
|
||||
|
||||
### Manual Complexity Assessment
|
||||
|
||||
Since Langflow does not have automated complexity analysis tools, assess components manually:
|
||||
|
||||
1. **Count conditionals**: Each `if/else`, `switch/case`, ternary, `&&`/`||` chain adds +1.
|
||||
2. **Count nesting levels**: Each level of nesting within conditionals or loops adds +1.
|
||||
3. **Count total lines**: Target < 300 lines per component file.
|
||||
4. **Count state hooks**: More than 5 `useState` calls suggests hook extraction.
|
||||
5. **Count effects**: More than 3 `useEffect` calls suggests effect consolidation.
|
||||
|
||||
### 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** |
|
||||
|
||||
## Core Refactoring Patterns
|
||||
|
||||
### Pattern 1: Extract Custom Hooks
|
||||
|
||||
**When**: Component has complex state management, multiple `useState`/`useEffect`, or business logic mixed with UI.
|
||||
|
||||
**Langflow Convention**: Place hooks in a `hooks/` subdirectory or alongside the component as `use-<feature>.ts`. Langflow uses kebab-case filenames with `use-` prefix.
|
||||
|
||||
```typescript
|
||||
// Before: Complex state logic in component
|
||||
const FlowPage: FC = () => {
|
||||
const [nodes, setNodes] = useState<Node[]>([])
|
||||
const [edges, setEdges] = useState<Edge[]>([])
|
||||
const [buildStatus, setBuildStatus] = useState<BuildStatus>(BuildStatus.IDLE)
|
||||
|
||||
// 50+ lines of state management logic...
|
||||
|
||||
return <div>...</div>
|
||||
}
|
||||
|
||||
// After: Extract to custom hook
|
||||
// hooks/use-flow-state.ts
|
||||
export const useFlowState = (flowId: string) => {
|
||||
const [nodes, setNodes] = useState<Node[]>([])
|
||||
const [edges, setEdges] = useState<Edge[]>([])
|
||||
const [buildStatus, setBuildStatus] = useState<BuildStatus>(BuildStatus.IDLE)
|
||||
|
||||
// Related state management logic here
|
||||
|
||||
return { nodes, setNodes, edges, setEdges, buildStatus, setBuildStatus }
|
||||
}
|
||||
|
||||
// Component becomes cleaner
|
||||
const FlowPage: FC = () => {
|
||||
const { nodes, setNodes, edges, setEdges } = useFlowState(flowId)
|
||||
return <div>...</div>
|
||||
}
|
||||
```
|
||||
|
||||
**Langflow Examples**:
|
||||
- `src/frontend/src/hooks/use-add-component.ts`
|
||||
- `src/frontend/src/hooks/use-unsaved-changes.ts`
|
||||
- `src/frontend/src/hooks/use-refresh-model-inputs.ts`
|
||||
|
||||
### Pattern 2: Extract Sub-Components
|
||||
|
||||
**When**: Single component has multiple UI sections, conditional rendering blocks, or repeated patterns.
|
||||
|
||||
**Langflow Convention**: Place sub-components in subdirectories or as separate files in the same directory. UI primitives go in `components/ui/`, domain components in `components/core/`, reusable components in `components/common/`.
|
||||
|
||||
```typescript
|
||||
// Before: Monolithic JSX with multiple sections
|
||||
const GenericNode = () => {
|
||||
return (
|
||||
<div>
|
||||
{/* 100 lines of header UI */}
|
||||
{/* 100 lines of parameter fields */}
|
||||
{/* 100 lines of output handles */}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// After: Split into focused components
|
||||
// CustomNodes/GenericNode/
|
||||
// generic-node.tsx (orchestration only — kebab-case, descriptive name)
|
||||
// components/
|
||||
// node-header.tsx
|
||||
// node-parameters.tsx
|
||||
// node-outputs.tsx
|
||||
|
||||
const GenericNode = () => {
|
||||
return (
|
||||
<div>
|
||||
<NodeHeader nodeData={data} />
|
||||
<NodeParameters fields={fields} />
|
||||
<NodeOutputs outputs={outputs} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
**Langflow Examples**:
|
||||
- `src/frontend/src/CustomNodes/GenericNode/components/`
|
||||
- `src/frontend/src/components/core/`
|
||||
- `src/frontend/src/components/ui/`
|
||||
|
||||
### Pattern 3: Simplify Conditional Logic
|
||||
|
||||
**When**: Deep nesting (> 3 levels), complex ternaries, or multiple `if/else` chains.
|
||||
|
||||
```typescript
|
||||
// Before: Deeply nested conditionals
|
||||
const getFieldComponent = (field: InputFieldType) => {
|
||||
if (field.type === "str") {
|
||||
if (field.multiline) {
|
||||
return <TextAreaComponent />
|
||||
} else if (field.password) {
|
||||
return <PasswordInput />
|
||||
} else if (field.options?.length) {
|
||||
return <Dropdown options={field.options} />
|
||||
} else {
|
||||
return <InputComponent />
|
||||
}
|
||||
} else if (field.type === "int") {
|
||||
return <IntComponent />
|
||||
} else if (field.type === "float") {
|
||||
return <FloatComponent />
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
// After: Use lookup tables + early returns
|
||||
const FIELD_COMPONENT_MAP: Record<string, FC<FieldProps>> = {
|
||||
int: IntComponent,
|
||||
float: FloatComponent,
|
||||
bool: ToggleComponent,
|
||||
code: CodeAreaComponent,
|
||||
}
|
||||
|
||||
const STR_VARIANT_MAP: Record<string, FC<FieldProps>> = {
|
||||
multiline: TextAreaComponent,
|
||||
password: PasswordInput,
|
||||
options: Dropdown,
|
||||
}
|
||||
|
||||
const getFieldComponent = (field: InputFieldType) => {
|
||||
if (field.type !== "str") {
|
||||
const Component = FIELD_COMPONENT_MAP[field.type]
|
||||
return Component ? <Component {...field} /> : null
|
||||
}
|
||||
|
||||
const variant = field.multiline ? "multiline"
|
||||
: field.password ? "password"
|
||||
: field.options?.length ? "options"
|
||||
: "default"
|
||||
|
||||
const Component = STR_VARIANT_MAP[variant] ?? InputComponent
|
||||
return <Component {...field} />
|
||||
}
|
||||
```
|
||||
|
||||
### Pattern 4: Extract API/Data Logic
|
||||
|
||||
**When**: Component directly handles API calls, data transformation, or complex async operations.
|
||||
|
||||
**Langflow Convention**:
|
||||
- This skill is for component decomposition, not query/mutation design.
|
||||
- When refactoring data fetching, use `frontend-query-mutation` for query patterns, `UseRequestProcessor`, cache invalidation, and mutation error handling.
|
||||
- Do not create thin passthrough `useQuery` wrappers during refactoring; only extract a custom hook when it truly orchestrates multiple queries/mutations or shared derived state.
|
||||
- API hooks live in `controllers/API/queries/{domain}/`.
|
||||
|
||||
**Langflow Examples**:
|
||||
- `src/frontend/src/controllers/API/queries/flows/use-post-add-flow.ts`
|
||||
- `src/frontend/src/controllers/API/queries/variables/use-get-global-variables.ts`
|
||||
- `src/frontend/src/controllers/API/queries/folders/use-get-folders.ts`
|
||||
|
||||
### Pattern 5: Extract Modal/Dialog Management
|
||||
|
||||
**When**: Component manages multiple modals with complex open/close states.
|
||||
|
||||
**Langflow Convention**: Modals should be extracted with their state management.
|
||||
|
||||
```typescript
|
||||
// Before: Multiple modal states in component
|
||||
const FlowToolbar = () => {
|
||||
const [showExportModal, setShowExportModal] = useState(false)
|
||||
const [showShareModal, setShowShareModal] = useState(false)
|
||||
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false)
|
||||
const [showApiModal, setShowApiModal] = useState(false)
|
||||
// 5+ more modal states...
|
||||
}
|
||||
|
||||
// After: Extract to modal management hook
|
||||
type ModalType = "export" | "share" | "delete" | "api" | null
|
||||
|
||||
const useFlowToolbarModals = () => {
|
||||
const [activeModal, setActiveModal] = useState<ModalType>(null)
|
||||
|
||||
const openModal = useCallback((type: ModalType) => setActiveModal(type), [])
|
||||
const closeModal = useCallback(() => setActiveModal(null), [])
|
||||
|
||||
return {
|
||||
activeModal,
|
||||
openModal,
|
||||
closeModal,
|
||||
isOpen: (type: ModalType) => activeModal === type,
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Pattern 6: Extract Form Logic
|
||||
|
||||
**When**: Complex form validation, submission handling, or field transformation.
|
||||
|
||||
**Langflow Convention**: Extract form state and validation into hooks.
|
||||
|
||||
```typescript
|
||||
// Extract form validation and submission
|
||||
const useFlowSettingsForm = (initialValues: FlowSettings) => {
|
||||
const [values, setValues] = useState(initialValues)
|
||||
const [errors, setErrors] = useState<Record<string, string>>({})
|
||||
|
||||
const validate = useCallback(() => {
|
||||
const newErrors: Record<string, string> = {}
|
||||
if (!values.name?.trim()) newErrors.name = "Name is required"
|
||||
if (values.endpoint_name && !/^[a-z0-9-]+$/.test(values.endpoint_name)) {
|
||||
newErrors.endpoint_name = "Must be lowercase alphanumeric with hyphens"
|
||||
}
|
||||
setErrors(newErrors)
|
||||
return Object.keys(newErrors).length === 0
|
||||
}, [values])
|
||||
|
||||
const handleChange = useCallback((field: string, value: any) => {
|
||||
setValues((prev) => ({ ...prev, [field]: value }))
|
||||
}, [])
|
||||
|
||||
return { values, errors, validate, handleChange }
|
||||
}
|
||||
```
|
||||
|
||||
## Langflow-Specific Refactoring Guidelines
|
||||
|
||||
### 1. Zustand Store Selectors
|
||||
|
||||
**When**: Component reads many values from a Zustand store, causing unnecessary re-renders.
|
||||
|
||||
```typescript
|
||||
// Before: Selecting too many values
|
||||
const Component = () => {
|
||||
const flowStore = useFlowStore()
|
||||
// Component re-renders on ANY store change
|
||||
}
|
||||
|
||||
// After: Use individual selectors
|
||||
const Component = () => {
|
||||
const nodes = useFlowStore((state) => state.nodes)
|
||||
const edges = useFlowStore((state) => state.edges)
|
||||
// Component only re-renders when nodes or edges change
|
||||
}
|
||||
```
|
||||
|
||||
**Langflow Reference**: All stores in `src/frontend/src/stores/` follow this selector pattern.
|
||||
|
||||
### 2. Custom Node Components
|
||||
|
||||
**When**: Refactoring flow node components (`CustomNodes/GenericNode/`).
|
||||
|
||||
**Conventions**:
|
||||
- Keep node logic in custom hooks
|
||||
- Extract parameter rendering to separate components
|
||||
- Use the existing `components/` subdirectory for sub-components
|
||||
|
||||
```
|
||||
CustomNodes/GenericNode/
|
||||
generic-node.tsx # Node registration and main render (kebab-case, NOT index.tsx)
|
||||
components/
|
||||
handle-render.tsx # Handle rendering
|
||||
node-description.tsx # Node description display
|
||||
node-input-field.tsx # Input field rendering
|
||||
node-name.tsx # Node name display
|
||||
node-output-field.tsx # Output field rendering
|
||||
node-status.tsx # Build status display
|
||||
```
|
||||
|
||||
### 3. Flow Canvas Components
|
||||
|
||||
**When**: Refactoring components related to the flow editor canvas.
|
||||
|
||||
**Conventions**:
|
||||
- `@xyflow/react` v12 is the canvas library
|
||||
- Keep canvas event handlers separate from UI rendering
|
||||
- Use `useFlowStore` for flow state management
|
||||
- Use `useFlowsManagerStore` for multi-flow management
|
||||
|
||||
### 4. API Query Hook Components
|
||||
|
||||
**When**: Refactoring components that consume API data.
|
||||
|
||||
**Conventions**:
|
||||
- Use existing query hooks from `controllers/API/queries/`
|
||||
- Access `UseRequestProcessor` for new queries/mutations
|
||||
- Use query key arrays like `["useGetGlobalVariables"]` for cache management
|
||||
- Cache invalidation belongs in mutation `onSettled` callbacks
|
||||
|
||||
## Refactoring Workflow
|
||||
|
||||
### Step 1: Assess Complexity
|
||||
|
||||
Manually count:
|
||||
- Total conditionals (if/else, switch, ternary, &&/||)
|
||||
- Maximum nesting depth
|
||||
- Total lines of code
|
||||
- Number of useState/useEffect hooks
|
||||
- Number of distinct UI sections
|
||||
|
||||
### Step 2: Plan
|
||||
|
||||
Create a refactoring plan based on detected features:
|
||||
|
||||
| Detected Feature | Refactoring Action |
|
||||
|------------------|-------------------|
|
||||
| 5+ `useState` hooks with related state | Extract custom hook |
|
||||
| API calls in component body | Extract to query hook |
|
||||
| 3+ event handlers with logic | Extract event handlers to hook |
|
||||
| 300+ lines | Split into sub-components |
|
||||
| Deep conditional nesting (>3) | Simplify conditional logic |
|
||||
| Multiple modal states | Extract modal management |
|
||||
|
||||
### Step 3: Execute Incrementally
|
||||
|
||||
1. **Extract one piece at a time**
|
||||
2. **Run lint, type-check, and tests after each extraction**
|
||||
3. **Verify functionality before next step**
|
||||
|
||||
```
|
||||
For each extraction:
|
||||
1. Extract code
|
||||
2. Run: npm run lint
|
||||
3. Run: npm run type-check
|
||||
4. Run: npm test
|
||||
5. Test functionality manually
|
||||
6. PASS? -> Next extraction
|
||||
FAIL? -> Fix before continuing
|
||||
```
|
||||
|
||||
### Step 4: Verify
|
||||
|
||||
After refactoring, re-assess complexity manually:
|
||||
|
||||
- Target complexity < 50
|
||||
- Target line count < 300
|
||||
- Target max nesting depth <= 3
|
||||
- Target max function length < 30 lines
|
||||
|
||||
## Common Mistakes to Avoid
|
||||
|
||||
### Over-Engineering
|
||||
|
||||
```typescript
|
||||
// Too many tiny hooks
|
||||
const useButtonText = () => useState("Click")
|
||||
const useButtonDisabled = () => useState(false)
|
||||
const useButtonLoading = () => useState(false)
|
||||
|
||||
// Cohesive hook with related state
|
||||
const useButtonState = () => {
|
||||
const [text, setText] = useState("Click")
|
||||
const [disabled, setDisabled] = useState(false)
|
||||
const [loading, setLoading] = useState(false)
|
||||
return { text, setText, disabled, setDisabled, loading, setLoading }
|
||||
}
|
||||
```
|
||||
|
||||
### Breaking Existing Patterns
|
||||
|
||||
- Follow existing directory structures in `components/ui/`, `components/core/`, `components/common/`
|
||||
- Maintain naming conventions (kebab-case files, PascalCase components)
|
||||
- Preserve export patterns for compatibility
|
||||
- Keep Zustand store selector patterns consistent
|
||||
|
||||
### Premature Abstraction
|
||||
|
||||
- Only extract when there is clear complexity benefit
|
||||
- Do not create abstractions for single-use code
|
||||
- Keep refactored code in the same domain area
|
||||
|
||||
### Bypassing UseRequestProcessor
|
||||
|
||||
- Do not call `useQuery` or `useMutation` directly for API calls
|
||||
- Always use the `UseRequestProcessor` pattern for consistency with retry and invalidation logic
|
||||
- See `frontend-query-mutation` skill for API hook patterns
|
||||
|
||||
## References
|
||||
|
||||
### Langflow Codebase Examples
|
||||
|
||||
- **Hook extraction**: `src/frontend/src/hooks/`
|
||||
- **Component splitting**: `src/frontend/src/CustomNodes/GenericNode/components/`
|
||||
- **UI components**: `src/frontend/src/components/ui/`
|
||||
- **Core components**: `src/frontend/src/components/core/`
|
||||
- **Common components**: `src/frontend/src/components/common/`
|
||||
- **API query hooks**: `src/frontend/src/controllers/API/queries/`
|
||||
- **Zustand stores**: `src/frontend/src/stores/`
|
||||
|
||||
### Related Skills
|
||||
|
||||
- `frontend-query-mutation` - For API query and mutation patterns
|
||||
- `frontend-testing` - For testing refactored components
|
||||
@@ -0,0 +1,519 @@
|
||||
# Complexity Reduction Patterns
|
||||
|
||||
This document provides patterns for reducing cognitive complexity in Langflow React components.
|
||||
|
||||
## Understanding Complexity
|
||||
|
||||
### SonarJS Cognitive Complexity
|
||||
|
||||
Langflow does not have automated complexity analysis tools. Assess complexity manually using SonarJS cognitive complexity rules:
|
||||
|
||||
- **Total Complexity**: Sum of all functions' complexity in the file
|
||||
- **Max Complexity**: Highest single function complexity
|
||||
|
||||
### 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 1: Replace Conditionals with Lookup Tables
|
||||
|
||||
**Before** (complexity: ~15):
|
||||
|
||||
```typescript
|
||||
const getParameterComponent = (field: InputFieldType) => {
|
||||
if (field.type === "str") {
|
||||
if (field.multiline) {
|
||||
return <TextAreaComponent value={field.value} />
|
||||
} else if (field.password) {
|
||||
return <PasswordInput value={field.value} />
|
||||
} else if (field.options?.length) {
|
||||
return <Dropdown options={field.options} value={field.value} />
|
||||
} else {
|
||||
return <InputComponent value={field.value} />
|
||||
}
|
||||
}
|
||||
if (field.type === "int") {
|
||||
return <IntComponent value={field.value} />
|
||||
}
|
||||
if (field.type === "float") {
|
||||
return <FloatComponent value={field.value} />
|
||||
}
|
||||
if (field.type === "bool") {
|
||||
return <ToggleComponent value={field.value} />
|
||||
}
|
||||
if (field.type === "code") {
|
||||
return <CodeAreaComponent value={field.value} />
|
||||
}
|
||||
return null
|
||||
}
|
||||
```
|
||||
|
||||
**After** (complexity: ~3):
|
||||
|
||||
```typescript
|
||||
// Define lookup table outside component
|
||||
const FIELD_TYPE_MAP: Record<string, FC<FieldProps>> = {
|
||||
int: IntComponent,
|
||||
float: FloatComponent,
|
||||
bool: ToggleComponent,
|
||||
code: CodeAreaComponent,
|
||||
dict: DictComponent,
|
||||
file: FileComponent,
|
||||
}
|
||||
|
||||
const getStrVariant = (field: InputFieldType): FC<FieldProps> => {
|
||||
if (field.multiline) return TextAreaComponent
|
||||
if (field.password) return PasswordInput
|
||||
if (field.options?.length) return Dropdown
|
||||
return InputComponent
|
||||
}
|
||||
|
||||
// Clean component logic
|
||||
const getParameterComponent = (field: InputFieldType) => {
|
||||
const Component = field.type === "str"
|
||||
? getStrVariant(field)
|
||||
: FIELD_TYPE_MAP[field.type]
|
||||
|
||||
if (!Component) return null
|
||||
return <Component value={field.value} />
|
||||
}
|
||||
```
|
||||
|
||||
## Pattern 2: Use Early Returns
|
||||
|
||||
**Before** (complexity: ~10):
|
||||
|
||||
```typescript
|
||||
const handleNodeBuild = () => {
|
||||
if (isAuthenticated) {
|
||||
if (hasValidFlow) {
|
||||
if (!isBuilding) {
|
||||
if (allInputsConnected) {
|
||||
startBuild()
|
||||
} else {
|
||||
showMissingInputsError()
|
||||
}
|
||||
} else {
|
||||
showBuildInProgressWarning()
|
||||
}
|
||||
} else {
|
||||
showInvalidFlowError()
|
||||
}
|
||||
} else {
|
||||
showAuthError()
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**After** (complexity: ~4):
|
||||
|
||||
```typescript
|
||||
const handleNodeBuild = () => {
|
||||
if (!isAuthenticated) {
|
||||
showAuthError()
|
||||
return
|
||||
}
|
||||
|
||||
if (!hasValidFlow) {
|
||||
showInvalidFlowError()
|
||||
return
|
||||
}
|
||||
|
||||
if (isBuilding) {
|
||||
showBuildInProgressWarning()
|
||||
return
|
||||
}
|
||||
|
||||
if (!allInputsConnected) {
|
||||
showMissingInputsError()
|
||||
return
|
||||
}
|
||||
|
||||
startBuild()
|
||||
}
|
||||
```
|
||||
|
||||
## Pattern 3: Extract Complex Conditions
|
||||
|
||||
**Before** (complexity: high):
|
||||
|
||||
```typescript
|
||||
const canRunFlow = (() => {
|
||||
if (flow.is_component) {
|
||||
return false
|
||||
}
|
||||
if (!nodes.length) {
|
||||
return false
|
||||
}
|
||||
if (isBuilding) {
|
||||
return false
|
||||
}
|
||||
if (nodes.some((n) => n.data?.node?.error)) {
|
||||
return false
|
||||
}
|
||||
if (
|
||||
edges.some(
|
||||
(e) =>
|
||||
!e.sourceHandle ||
|
||||
!e.targetHandle ||
|
||||
(e.data?.isInvalid && e.data.isInvalid === true),
|
||||
)
|
||||
) {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
})()
|
||||
```
|
||||
|
||||
**After** (complexity: lower):
|
||||
|
||||
```typescript
|
||||
// Extract to named functions
|
||||
const hasValidNodes = (nodes: Node[]) => {
|
||||
return nodes.length > 0 && !nodes.some((n) => n.data?.node?.error)
|
||||
}
|
||||
|
||||
const hasValidEdges = (edges: Edge[]) => {
|
||||
return !edges.some(
|
||||
(e) => !e.sourceHandle || !e.targetHandle || e.data?.isInvalid,
|
||||
)
|
||||
}
|
||||
|
||||
// Clean main logic
|
||||
const canRunFlow =
|
||||
!flow.is_component &&
|
||||
!isBuilding &&
|
||||
hasValidNodes(nodes) &&
|
||||
hasValidEdges(edges)
|
||||
```
|
||||
|
||||
## Pattern 4: Replace Chained Ternaries
|
||||
|
||||
**Before** (complexity: ~5):
|
||||
|
||||
```typescript
|
||||
const buildStatusIcon = buildStatus === BuildStatus.BUILT
|
||||
? <CheckCircle className="text-green-500" />
|
||||
: buildStatus === BuildStatus.BUILDING
|
||||
? <Loader className="animate-spin text-blue-500" />
|
||||
: buildStatus === BuildStatus.ERROR
|
||||
? <XCircle className="text-red-500" />
|
||||
: <Circle className="text-gray-400" />
|
||||
```
|
||||
|
||||
**After** (complexity: ~2):
|
||||
|
||||
```typescript
|
||||
const BUILD_STATUS_ICONS: Record<BuildStatus, ReactNode> = {
|
||||
[BuildStatus.BUILT]: <CheckCircle className="text-green-500" />,
|
||||
[BuildStatus.BUILDING]: <Loader className="animate-spin text-blue-500" />,
|
||||
[BuildStatus.ERROR]: <XCircle className="text-red-500" />,
|
||||
[BuildStatus.IDLE]: <Circle className="text-gray-400" />,
|
||||
}
|
||||
|
||||
const buildStatusIcon = BUILD_STATUS_ICONS[buildStatus] ?? BUILD_STATUS_ICONS[BuildStatus.IDLE]
|
||||
```
|
||||
|
||||
## Pattern 5: Flatten Nested Loops
|
||||
|
||||
**Before** (complexity: high):
|
||||
|
||||
```typescript
|
||||
const getConnectedNodes = (flow: FlowType) => {
|
||||
const results: ConnectedNode[] = []
|
||||
|
||||
for (const node of flow.data.nodes) {
|
||||
if (node.data?.node?.template) {
|
||||
for (const [fieldName, field] of Object.entries(node.data.node.template)) {
|
||||
if (field.type === "str" && field.load_from_db) {
|
||||
for (const variable of globalVariables) {
|
||||
if (variable.name === field.value) {
|
||||
results.push({
|
||||
nodeId: node.id,
|
||||
fieldName,
|
||||
variableName: variable.name,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return results
|
||||
}
|
||||
```
|
||||
|
||||
**After** (complexity: lower):
|
||||
|
||||
```typescript
|
||||
// Use functional approach
|
||||
const getConnectedNodes = (flow: FlowType) => {
|
||||
return flow.data.nodes
|
||||
.filter((node) => node.data?.node?.template)
|
||||
.flatMap((node) =>
|
||||
Object.entries(node.data.node.template)
|
||||
.filter(([, field]) => field.type === "str" && field.load_from_db)
|
||||
.flatMap(([fieldName, field]) =>
|
||||
globalVariables
|
||||
.filter((variable) => variable.name === field.value)
|
||||
.map((variable) => ({
|
||||
nodeId: node.id,
|
||||
fieldName,
|
||||
variableName: variable.name,
|
||||
})),
|
||||
),
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
## Pattern 6: Extract Event Handler Logic
|
||||
|
||||
**Before** (complexity: high in component):
|
||||
|
||||
```typescript
|
||||
const FlowCanvas = () => {
|
||||
const handleNodeDrop = useCallback(
|
||||
(event: DragEvent) => {
|
||||
event.preventDefault()
|
||||
const nodeData = JSON.parse(event.dataTransfer.getData("application/json"))
|
||||
|
||||
if (!nodeData || !nodeData.type) return
|
||||
|
||||
const position = screenToFlowPosition({
|
||||
x: event.clientX,
|
||||
y: event.clientY,
|
||||
})
|
||||
|
||||
// Validate the node type exists
|
||||
const nodeType = types[nodeData.type]
|
||||
if (!nodeType) {
|
||||
setErrorData({ title: "Invalid node type" })
|
||||
return
|
||||
}
|
||||
|
||||
// Check for duplicates if component
|
||||
if (nodeData.node?.is_component) {
|
||||
const existingComponent = nodes.find(
|
||||
(n) => n.data.node?.display_name === nodeData.node.display_name,
|
||||
)
|
||||
if (existingComponent) {
|
||||
// 20 more lines of duplicate handling...
|
||||
}
|
||||
}
|
||||
|
||||
// Create the new node
|
||||
const newNode = buildNodeFromData(nodeData, position)
|
||||
setNodes((prev) => [...prev, newNode])
|
||||
|
||||
// 20 more lines of post-drop logic...
|
||||
},
|
||||
[nodes, types, screenToFlowPosition],
|
||||
)
|
||||
|
||||
return <div>...</div>
|
||||
}
|
||||
```
|
||||
|
||||
**After** (complexity: lower):
|
||||
|
||||
```typescript
|
||||
// Extract to hook
|
||||
const useNodeDrop = (nodes: Node[], types: Record<string, any>) => {
|
||||
const validateNodeData = useCallback(
|
||||
(nodeData: any): boolean => {
|
||||
if (!nodeData?.type) return false
|
||||
if (!types[nodeData.type]) return false
|
||||
return true
|
||||
},
|
||||
[types],
|
||||
)
|
||||
|
||||
const checkDuplicateComponent = useCallback(
|
||||
(nodeData: any): Node | undefined => {
|
||||
if (!nodeData.node?.is_component) return undefined
|
||||
return nodes.find(
|
||||
(n) => n.data.node?.display_name === nodeData.node.display_name,
|
||||
)
|
||||
},
|
||||
[nodes],
|
||||
)
|
||||
|
||||
return { validateNodeData, checkDuplicateComponent }
|
||||
}
|
||||
|
||||
// Component becomes cleaner
|
||||
const FlowCanvas = () => {
|
||||
const { validateNodeData, checkDuplicateComponent } = useNodeDrop(nodes, types)
|
||||
|
||||
const handleNodeDrop = useCallback(
|
||||
(event: DragEvent) => {
|
||||
event.preventDefault()
|
||||
const nodeData = JSON.parse(event.dataTransfer.getData("application/json"))
|
||||
|
||||
if (!validateNodeData(nodeData)) {
|
||||
setErrorData({ title: "Invalid node type" })
|
||||
return
|
||||
}
|
||||
|
||||
const duplicate = checkDuplicateComponent(nodeData)
|
||||
if (duplicate) {
|
||||
handleDuplicate(duplicate, nodeData)
|
||||
return
|
||||
}
|
||||
|
||||
const position = screenToFlowPosition({
|
||||
x: event.clientX,
|
||||
y: event.clientY,
|
||||
})
|
||||
const newNode = buildNodeFromData(nodeData, position)
|
||||
setNodes((prev) => [...prev, newNode])
|
||||
},
|
||||
[validateNodeData, checkDuplicateComponent, screenToFlowPosition],
|
||||
)
|
||||
|
||||
return <div>...</div>
|
||||
}
|
||||
```
|
||||
|
||||
## Pattern 7: Reduce Boolean Logic Complexity
|
||||
|
||||
**Before** (complexity: ~8):
|
||||
|
||||
```typescript
|
||||
const isNodeDisabled = !isAuthenticated
|
||||
|| flow.is_component
|
||||
|| isBuilding
|
||||
|| node.data?.node?.frozen
|
||||
|| (node.data?.node?.error && node.data.node.error !== "")
|
||||
|| (!hasConnectedInputs && node.type !== "genericNode")
|
||||
|| (isLocked && !isSuperUser)
|
||||
```
|
||||
|
||||
**After** (complexity: ~3):
|
||||
|
||||
```typescript
|
||||
// Extract meaningful boolean functions
|
||||
const hasNodeError = (node: Node) => {
|
||||
return !!node.data?.node?.error && node.data.node.error !== ""
|
||||
}
|
||||
|
||||
const isNodeAccessible = (node: Node, isLocked: boolean, isSuperUser: boolean) => {
|
||||
if (isLocked && !isSuperUser) return false
|
||||
if (node.data?.node?.frozen) return false
|
||||
return true
|
||||
}
|
||||
|
||||
const canInteractWithNode = (node: Node) => {
|
||||
if (!isAuthenticated) return false
|
||||
if (flow.is_component) return false
|
||||
if (isBuilding) return false
|
||||
if (hasNodeError(node)) return false
|
||||
if (!isNodeAccessible(node, isLocked, isSuperUser)) return false
|
||||
if (!hasConnectedInputs && node.type !== "genericNode") return false
|
||||
return true
|
||||
}
|
||||
|
||||
const isNodeDisabled = !canInteractWithNode(node)
|
||||
```
|
||||
|
||||
## Pattern 8: Simplify useMemo/useCallback Dependencies
|
||||
|
||||
**Before** (complexity: multiple recalculations):
|
||||
|
||||
```typescript
|
||||
const processedNodes = useMemo(() => {
|
||||
let result = nodes
|
||||
|
||||
if (searchTerm) {
|
||||
result = result.filter(
|
||||
(n) =>
|
||||
n.data?.node?.display_name?.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
n.data?.type?.toLowerCase().includes(searchTerm.toLowerCase()),
|
||||
)
|
||||
}
|
||||
|
||||
if (filterByType) {
|
||||
result = result.filter((n) => n.data?.type === filterByType)
|
||||
}
|
||||
|
||||
if (sortOrder === "name") {
|
||||
result = [...result].sort((a, b) =>
|
||||
(a.data?.node?.display_name ?? "").localeCompare(b.data?.node?.display_name ?? ""),
|
||||
)
|
||||
} else if (sortOrder === "type") {
|
||||
result = [...result].sort((a, b) =>
|
||||
(a.data?.type ?? "").localeCompare(b.data?.type ?? ""),
|
||||
)
|
||||
}
|
||||
|
||||
return result
|
||||
}, [nodes, searchTerm, filterByType, sortOrder])
|
||||
```
|
||||
|
||||
**After** (complexity: separated concerns):
|
||||
|
||||
```typescript
|
||||
// Separate filter and sort utilities
|
||||
const filterNodes = (nodes: Node[], searchTerm: string, filterByType?: string) => {
|
||||
let result = nodes
|
||||
|
||||
if (searchTerm) {
|
||||
const term = searchTerm.toLowerCase()
|
||||
result = result.filter(
|
||||
(n) =>
|
||||
n.data?.node?.display_name?.toLowerCase().includes(term) ||
|
||||
n.data?.type?.toLowerCase().includes(term),
|
||||
)
|
||||
}
|
||||
|
||||
if (filterByType) {
|
||||
result = result.filter((n) => n.data?.type === filterByType)
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
const SORT_COMPARATORS: Record<string, (a: Node, b: Node) => number> = {
|
||||
name: (a, b) =>
|
||||
(a.data?.node?.display_name ?? "").localeCompare(b.data?.node?.display_name ?? ""),
|
||||
type: (a, b) =>
|
||||
(a.data?.type ?? "").localeCompare(b.data?.type ?? ""),
|
||||
}
|
||||
|
||||
const sortNodes = (nodes: Node[], sortOrder: string) => {
|
||||
const comparator = SORT_COMPARATORS[sortOrder]
|
||||
if (!comparator) return nodes
|
||||
return [...nodes].sort(comparator)
|
||||
}
|
||||
|
||||
// Clean component usage
|
||||
const filteredNodes = useMemo(
|
||||
() => filterNodes(nodes, searchTerm, filterByType),
|
||||
[nodes, searchTerm, filterByType],
|
||||
)
|
||||
|
||||
const processedNodes = useMemo(
|
||||
() => sortNodes(filteredNodes, sortOrder),
|
||||
[filteredNodes, sortOrder],
|
||||
)
|
||||
```
|
||||
|
||||
## 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 |
|
||||
@@ -0,0 +1,635 @@
|
||||
# Component Splitting Patterns
|
||||
|
||||
This document provides detailed guidance on splitting large components into smaller, focused components in Langflow.
|
||||
|
||||
## When to Split Components
|
||||
|
||||
Split a component when you identify:
|
||||
|
||||
1. **Multiple UI sections** - Distinct visual areas with minimal coupling that can be composed independently
|
||||
1. **Conditional rendering blocks** - Large `{condition && <JSX />}` blocks
|
||||
1. **Repeated patterns** - Similar UI structures used multiple times
|
||||
1. **300+ lines** - Component exceeds manageable size
|
||||
1. **Modal clusters** - Multiple modals rendered in one component
|
||||
|
||||
## Splitting Strategies
|
||||
|
||||
### Strategy 1: Section-Based Splitting
|
||||
|
||||
Identify visual sections and extract each as a component.
|
||||
|
||||
```typescript
|
||||
// Before: Monolithic component (500+ lines)
|
||||
const FlowPage = () => {
|
||||
return (
|
||||
<div className="flex h-full w-full">
|
||||
{/* Sidebar Section - 100 lines */}
|
||||
<div className="w-64 border-r">
|
||||
<input placeholder="Search components..." />
|
||||
{categories.map((cat) => (
|
||||
<div key={cat.name}>
|
||||
<h3>{cat.name}</h3>
|
||||
{cat.components.map((comp) => (
|
||||
<div key={comp.name} draggable>
|
||||
{comp.display_name}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Canvas Section - 200 lines */}
|
||||
<div className="flex-1">
|
||||
<ReactFlow nodes={nodes} edges={edges} onConnect={onConnect}>
|
||||
{/* toolbar, minimap, controls */}
|
||||
</ReactFlow>
|
||||
</div>
|
||||
|
||||
{/* Inspect Panel Section - 150 lines */}
|
||||
{showInspectPanel && (
|
||||
<div className="w-80 border-l">
|
||||
{selectedNode && <NodeInspector node={selectedNode} />}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Modals Section - 50 lines */}
|
||||
{showExportModal && <ExportModal />}
|
||||
{showShareModal && <ShareModal />}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// After: Split into focused components (kebab-case, descriptive names — NEVER index.tsx)
|
||||
// pages/FlowPage/
|
||||
// flow-page.tsx (orchestration)
|
||||
// components/
|
||||
// flow-sidebar.tsx
|
||||
// flow-canvas.tsx
|
||||
// flow-inspect-panel.tsx
|
||||
// flow-modals.tsx
|
||||
|
||||
// FlowSidebar.tsx
|
||||
interface FlowSidebarProps {
|
||||
categories: Category[]
|
||||
searchTerm: string
|
||||
onSearchChange: (term: string) => void
|
||||
}
|
||||
|
||||
const FlowSidebar: FC<FlowSidebarProps> = ({
|
||||
categories,
|
||||
searchTerm,
|
||||
onSearchChange,
|
||||
}) => {
|
||||
return (
|
||||
<div className="w-64 border-r">
|
||||
<input
|
||||
placeholder="Search components..."
|
||||
value={searchTerm}
|
||||
onChange={(e) => onSearchChange(e.target.value)}
|
||||
/>
|
||||
{categories.map((cat) => (
|
||||
<SidebarCategory key={cat.name} category={cat} />
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// flow-page.tsx (orchestration only — kebab-case, NOT index.tsx)
|
||||
const FlowPage = () => {
|
||||
const { nodes, edges, onConnect } = useFlowState()
|
||||
const { activeModal, openModal, closeModal } = useModalState()
|
||||
const [searchTerm, setSearchTerm] = useState("")
|
||||
|
||||
return (
|
||||
<div className="flex h-full w-full">
|
||||
<FlowSidebar
|
||||
categories={filteredCategories}
|
||||
searchTerm={searchTerm}
|
||||
onSearchChange={setSearchTerm}
|
||||
/>
|
||||
<FlowCanvas
|
||||
nodes={nodes}
|
||||
edges={edges}
|
||||
onConnect={onConnect}
|
||||
/>
|
||||
{showInspectPanel && (
|
||||
<FlowInspectPanel selectedNode={selectedNode} />
|
||||
)}
|
||||
<FlowModals
|
||||
activeModal={activeModal}
|
||||
onClose={closeModal}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
### Strategy 2: Conditional Block Extraction
|
||||
|
||||
Extract large conditional rendering blocks.
|
||||
|
||||
```typescript
|
||||
// Before: Large conditional blocks
|
||||
const NodeField = ({ field }: { field: InputFieldType }) => {
|
||||
return (
|
||||
<div>
|
||||
{field.show ? (
|
||||
<div className="field-visible">
|
||||
{field.load_from_db ? (
|
||||
<div className="global-variable-badge">
|
||||
<Badge>{field.value}</Badge>
|
||||
<Button onClick={() => clearGlobalVariable(field.name)}>
|
||||
Clear
|
||||
</Button>
|
||||
</div>
|
||||
) : field.type === "str" && field.multiline ? (
|
||||
<TextAreaComponent
|
||||
value={field.value}
|
||||
onChange={(val) => handleChange(field.name, val)}
|
||||
/>
|
||||
) : field.type === "str" ? (
|
||||
<InputComponent
|
||||
value={field.value}
|
||||
onChange={(val) => handleChange(field.name, val)}
|
||||
password={field.password}
|
||||
/>
|
||||
) : field.type === "code" ? (
|
||||
<CodeAreaComponent
|
||||
value={field.value}
|
||||
onChange={(val) => handleChange(field.name, val)}
|
||||
/>
|
||||
) : (
|
||||
<GenericInput field={field} onChange={handleChange} />
|
||||
)}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// After: Separate rendering components
|
||||
const GlobalVariableBadge: FC<{ field: InputFieldType; onClear: () => void }> = ({
|
||||
field,
|
||||
onClear,
|
||||
}) => (
|
||||
<div className="global-variable-badge">
|
||||
<Badge>{field.value}</Badge>
|
||||
<Button onClick={onClear}>Clear</Button>
|
||||
</div>
|
||||
)
|
||||
|
||||
const FieldInput: FC<{ field: InputFieldType; onChange: FieldChangeHandler }> = ({
|
||||
field,
|
||||
onChange,
|
||||
}) => {
|
||||
if (field.load_from_db) {
|
||||
return <GlobalVariableBadge field={field} onClear={() => onChange(field.name, "")} />
|
||||
}
|
||||
|
||||
const Component = getFieldComponent(field)
|
||||
return <Component value={field.value} onChange={(val) => onChange(field.name, val)} />
|
||||
}
|
||||
|
||||
const NodeField = ({ field }: { field: InputFieldType }) => {
|
||||
if (!field.show) return null
|
||||
|
||||
return (
|
||||
<div className="field-visible">
|
||||
<FieldInput field={field} onChange={handleChange} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
### Strategy 3: Modal Extraction
|
||||
|
||||
Extract modals with their trigger logic.
|
||||
|
||||
```typescript
|
||||
// Before: Multiple modals in one component
|
||||
const FlowToolbar = () => {
|
||||
const [showExport, setShowExport] = useState(false)
|
||||
const [showShare, setShowShare] = useState(false)
|
||||
const [showDelete, setShowDelete] = useState(false)
|
||||
const [showApi, setShowApi] = useState(false)
|
||||
|
||||
const onExport = async (format: string) => { /* 20 lines */ }
|
||||
const onShare = async (data: ShareData) => { /* 20 lines */ }
|
||||
const onDelete = async () => { /* 15 lines */ }
|
||||
|
||||
return (
|
||||
<div>
|
||||
{/* Main toolbar content */}
|
||||
|
||||
{showExport && <ExportModal onConfirm={onExport} onClose={() => setShowExport(false)} />}
|
||||
{showShare && <ShareModal onConfirm={onShare} onClose={() => setShowShare(false)} />}
|
||||
{showDelete && <DeleteConfirm onConfirm={onDelete} onClose={() => setShowDelete(false)} />}
|
||||
{showApi && <ApiModal flowId={flowId} onClose={() => setShowApi(false)} />}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// After: Modal manager component
|
||||
// flow-toolbar-modals.tsx
|
||||
type ToolbarModalType = "export" | "share" | "delete" | "api" | null
|
||||
|
||||
interface FlowToolbarModalsProps {
|
||||
flowId: string
|
||||
activeModal: ToolbarModalType
|
||||
onClose: () => void
|
||||
onSuccess: () => void
|
||||
}
|
||||
|
||||
const FlowToolbarModals: FC<FlowToolbarModalsProps> = ({
|
||||
flowId,
|
||||
activeModal,
|
||||
onClose,
|
||||
onSuccess,
|
||||
}) => {
|
||||
const handleExport = async (format: string) => {
|
||||
// export logic
|
||||
onSuccess()
|
||||
}
|
||||
|
||||
const handleShare = async (data: ShareData) => {
|
||||
// share logic
|
||||
onSuccess()
|
||||
}
|
||||
|
||||
const handleDelete = async () => {
|
||||
// delete logic
|
||||
onSuccess()
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{activeModal === "export" && (
|
||||
<ExportModal onConfirm={handleExport} onClose={onClose} />
|
||||
)}
|
||||
{activeModal === "share" && (
|
||||
<ShareModal onConfirm={handleShare} onClose={onClose} />
|
||||
)}
|
||||
{activeModal === "delete" && (
|
||||
<DeleteConfirm onConfirm={handleDelete} onClose={onClose} />
|
||||
)}
|
||||
{activeModal === "api" && (
|
||||
<ApiModal flowId={flowId} onClose={onClose} />
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
// Parent component
|
||||
const FlowToolbar = () => {
|
||||
const { activeModal, openModal, closeModal } = useModalState()
|
||||
|
||||
return (
|
||||
<div>
|
||||
{/* Main toolbar with openModal triggers */}
|
||||
<Button onClick={() => openModal("export")}>Export</Button>
|
||||
<Button onClick={() => openModal("share")}>Share</Button>
|
||||
|
||||
<FlowToolbarModals
|
||||
flowId={flowId}
|
||||
activeModal={activeModal}
|
||||
onClose={closeModal}
|
||||
onSuccess={handleSuccess}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
### Strategy 4: List Item Extraction
|
||||
|
||||
Extract repeated item rendering.
|
||||
|
||||
```typescript
|
||||
// Before: Inline item rendering
|
||||
const ComponentList = () => {
|
||||
return (
|
||||
<div>
|
||||
{components.map((comp) => (
|
||||
<div key={comp.name} className="component-item">
|
||||
<div className="flex items-center gap-2">
|
||||
{comp.icon && <img src={comp.icon} className="h-5 w-5" />}
|
||||
<span className="font-medium">{comp.display_name}</span>
|
||||
{comp.beta && <Badge variant="secondary">Beta</Badge>}
|
||||
{comp.legacy && <Badge variant="destructive">Legacy</Badge>}
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground">{comp.description}</p>
|
||||
<div className="flex gap-1">
|
||||
{comp.output_types?.map((type) => (
|
||||
<Badge key={type} variant="outline">{type}</Badge>
|
||||
))}
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => handleAddToCanvas(comp)}
|
||||
>
|
||||
Add
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// After: Extracted item component
|
||||
interface ComponentItemProps {
|
||||
component: APIClassType
|
||||
onAdd: (component: APIClassType) => void
|
||||
}
|
||||
|
||||
const ComponentItem: FC<ComponentItemProps> = ({ component, onAdd }) => {
|
||||
return (
|
||||
<div className="component-item">
|
||||
<div className="flex items-center gap-2">
|
||||
{component.icon && <img src={component.icon} className="h-5 w-5" />}
|
||||
<span className="font-medium">{component.display_name}</span>
|
||||
{component.beta && <Badge variant="secondary">Beta</Badge>}
|
||||
{component.legacy && <Badge variant="destructive">Legacy</Badge>}
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground">{component.description}</p>
|
||||
<div className="flex gap-1">
|
||||
{component.output_types?.map((type) => (
|
||||
<Badge key={type} variant="outline">{type}</Badge>
|
||||
))}
|
||||
</div>
|
||||
<Button variant="ghost" size="sm" onClick={() => onAdd(component)}>
|
||||
Add
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const ComponentList = () => {
|
||||
return (
|
||||
<div>
|
||||
{components.map((comp) => (
|
||||
<ComponentItem
|
||||
key={comp.display_name}
|
||||
component={comp}
|
||||
onAdd={handleAddToCanvas}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
## Directory Structure Patterns
|
||||
|
||||
### Pattern A: Flat Structure (Simple Components)
|
||||
|
||||
For components with 2-3 sub-components:
|
||||
|
||||
```
|
||||
my-component/
|
||||
my-component.tsx # Main component (kebab-case, descriptive — NOT index.tsx)
|
||||
sub-component-a.tsx
|
||||
sub-component-b.tsx
|
||||
my-component-types.ts # Shared types
|
||||
```
|
||||
|
||||
### Pattern B: Nested Structure (Complex Components)
|
||||
|
||||
For components with many sub-components:
|
||||
|
||||
```
|
||||
my-component/
|
||||
my-component.tsx # Main orchestration (NOT index.tsx)
|
||||
my-component-types.ts # Shared types
|
||||
hooks/
|
||||
use-feature-a.ts
|
||||
use-feature-b.ts
|
||||
components/
|
||||
header-section.tsx
|
||||
content-section.tsx
|
||||
modals-section.tsx
|
||||
helpers/
|
||||
format-data.ts
|
||||
```
|
||||
|
||||
> **IMPORTANT**: Never use `index.tsx` for new files. This is a legacy pattern. Use kebab-case file names that describe the component's purpose.
|
||||
|
||||
### Pattern C: Page Structure
|
||||
|
||||
Pages follow a standard directory layout with sub-pages, components, hooks, and helpers:
|
||||
|
||||
```
|
||||
pages/SettingsPage/
|
||||
settings-page.tsx # Main page component
|
||||
pages/ # Sub-pages
|
||||
ApiKeysPage/
|
||||
api-keys-page.tsx
|
||||
components/
|
||||
api-key-header.tsx
|
||||
helpers/
|
||||
column-defs.ts
|
||||
get-modal-props.tsx
|
||||
GlobalVariablesPage/
|
||||
global-variables-page.tsx
|
||||
components/ # Shared page components
|
||||
hooks/ # Page-level hooks
|
||||
utils/ # Page-level utilities
|
||||
```
|
||||
|
||||
### Pattern D: UI Components (shadcn — kebab-case files)
|
||||
|
||||
```
|
||||
components/ui/
|
||||
button.tsx
|
||||
input.tsx
|
||||
badge.tsx
|
||||
dialog.tsx
|
||||
popover.tsx
|
||||
select.tsx
|
||||
textarea.tsx
|
||||
tooltip.tsx
|
||||
dropdown-menu.tsx
|
||||
```
|
||||
|
||||
### Pattern E: Legacy Codebase (existing — do NOT follow for new code)
|
||||
|
||||
The existing codebase has `index.tsx` and mixed naming. When refactoring, migrate toward the new kebab-case standard:
|
||||
|
||||
```
|
||||
// Legacy (existing — DO NOT create new files this way)
|
||||
components/core/appHeaderComponent/index.tsx
|
||||
components/common/loadingComponent/index.tsx
|
||||
CustomNodes/GenericNode/index.tsx
|
||||
|
||||
// New standard (use this for all new code and refactors)
|
||||
components/core/app-header/app-header.tsx
|
||||
components/common/loading-indicator/loading-indicator.tsx
|
||||
CustomNodes/GenericNode/generic-node.tsx
|
||||
NodeName/
|
||||
NodeOutputField/
|
||||
NodeStatus/
|
||||
```
|
||||
|
||||
## Props Design
|
||||
|
||||
### Minimal Props Principle
|
||||
|
||||
Pass only what is needed:
|
||||
|
||||
```typescript
|
||||
// Bad: Passing entire objects when only some fields needed
|
||||
<NodeHeader nodeData={nodeData} flowData={flowData} />
|
||||
|
||||
// Good: Destructure to minimum required
|
||||
<NodeHeader
|
||||
displayName={nodeData.node?.display_name ?? ""}
|
||||
nodeType={nodeData.type}
|
||||
isFrozen={nodeData.node?.frozen ?? false}
|
||||
onNameChange={handleNameChange}
|
||||
/>
|
||||
```
|
||||
|
||||
### Callback Props Pattern
|
||||
|
||||
Use callbacks for child-to-parent communication:
|
||||
|
||||
```typescript
|
||||
// Parent
|
||||
const GenericNode = () => {
|
||||
const [showDescription, setShowDescription] = useState(false)
|
||||
|
||||
return (
|
||||
<div>
|
||||
<NodeHeader
|
||||
displayName={data.node?.display_name ?? ""}
|
||||
onToggleDescription={() => setShowDescription((prev) => !prev)}
|
||||
/>
|
||||
{showDescription && (
|
||||
<NodeDescription
|
||||
description={data.node?.description ?? ""}
|
||||
onChange={handleDescriptionChange}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Child
|
||||
interface NodeHeaderProps {
|
||||
displayName: string
|
||||
onToggleDescription: () => void
|
||||
}
|
||||
|
||||
const NodeHeader: FC<NodeHeaderProps> = ({ displayName, onToggleDescription }) => {
|
||||
return (
|
||||
<div className="flex items-center justify-between">
|
||||
<span>{displayName}</span>
|
||||
<button onClick={onToggleDescription}>Toggle Description</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
### Render Props for Flexibility
|
||||
|
||||
When sub-components need parent context:
|
||||
|
||||
```typescript
|
||||
interface FieldListProps<T> {
|
||||
fields: T[]
|
||||
renderField: (field: T, index: number) => React.ReactNode
|
||||
renderEmpty?: () => React.ReactNode
|
||||
}
|
||||
|
||||
function FieldList<T>({ fields, renderField, renderEmpty }: FieldListProps<T>) {
|
||||
if (fields.length === 0 && renderEmpty) {
|
||||
return <>{renderEmpty()}</>
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-2">
|
||||
{fields.map((field, index) => renderField(field, index))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Usage
|
||||
<FieldList
|
||||
fields={visibleFields}
|
||||
renderField={(field, i) => (
|
||||
<NodeInputField key={field.name ?? i} field={field} onChange={handleChange} />
|
||||
)}
|
||||
renderEmpty={() => <span className="text-muted-foreground">No fields</span>}
|
||||
/>
|
||||
```
|
||||
|
||||
## Langflow-Specific Splitting Guidelines
|
||||
|
||||
### Splitting GenericNode Components
|
||||
|
||||
The `GenericNode` component is one of the most complex components in Langflow. When splitting:
|
||||
|
||||
1. Keep the main `index.tsx` as an orchestrator that composes sub-components.
|
||||
2. Parameter rendering goes in `components/NodeInputField/`.
|
||||
3. Handle rendering goes in `components/HandleRenderComponent/`.
|
||||
4. Status display goes in `components/NodeStatus/`.
|
||||
5. Keep node-level state in the parent; pass callbacks to children.
|
||||
|
||||
### Splitting Flow Page Components
|
||||
|
||||
The flow editor page has multiple distinct regions:
|
||||
|
||||
1. **Sidebar** - Component library, search, categories
|
||||
2. **Canvas** - ReactFlow canvas with nodes and edges
|
||||
3. **Toolbar** - Build, save, export, share actions
|
||||
4. **Inspect Panel** - Node details when selected
|
||||
5. **Playground** - Chat/run interface
|
||||
6. **Modals** - Export, share, API, settings
|
||||
|
||||
Each region should be its own component with clearly defined props.
|
||||
|
||||
### Splitting Store-Connected Components
|
||||
|
||||
When a component reads from multiple Zustand stores:
|
||||
|
||||
1. Keep store selectors in the parent orchestrator.
|
||||
2. Pass data as props to presentational children.
|
||||
3. This makes children testable without mocking stores.
|
||||
|
||||
```typescript
|
||||
// Parent: reads from stores
|
||||
const FlowToolbar = () => {
|
||||
const isBuilding = useFlowStore((state) => state.isBuilding)
|
||||
const currentFlow = useFlowsManagerStore((state) => state.currentFlow)
|
||||
const isAuthenticated = useAuthStore((state) => state.isAuthenticated)
|
||||
|
||||
return (
|
||||
<ToolbarActions
|
||||
isBuilding={isBuilding}
|
||||
flowName={currentFlow?.name ?? ""}
|
||||
canBuild={isAuthenticated && !isBuilding}
|
||||
onBuild={handleBuild}
|
||||
onSave={handleSave}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
// Child: pure presentational
|
||||
const ToolbarActions: FC<ToolbarActionsProps> = ({
|
||||
isBuilding,
|
||||
flowName,
|
||||
canBuild,
|
||||
onBuild,
|
||||
onSave,
|
||||
}) => {
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<span>{flowName}</span>
|
||||
<Button onClick={onBuild} disabled={!canBuild}>
|
||||
{isBuilding ? "Building..." : "Build"}
|
||||
</Button>
|
||||
<Button onClick={onSave}>Save</Button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
```
|
||||
@@ -0,0 +1,470 @@
|
||||
# Hook Extraction Patterns
|
||||
|
||||
This document provides detailed guidance on extracting custom hooks from complex components in Langflow.
|
||||
|
||||
## When to Extract Hooks
|
||||
|
||||
Extract a custom hook when you identify:
|
||||
|
||||
1. **Coupled state groups** - Multiple `useState` hooks that are always used together
|
||||
1. **Complex effects** - `useEffect` with multiple dependencies or cleanup logic
|
||||
1. **Business logic** - Data transformations, validations, or calculations
|
||||
1. **Reusable patterns** - Logic that appears in multiple components
|
||||
|
||||
## Extraction Process
|
||||
|
||||
### Step 1: Identify State Groups
|
||||
|
||||
Look for state variables that are logically related:
|
||||
|
||||
```typescript
|
||||
// These belong together - extract to hook
|
||||
const [nodes, setNodes] = useState<Node[]>([])
|
||||
const [edges, setEdges] = useState<Edge[]>([])
|
||||
const [viewport, setViewport] = useState<Viewport>({ x: 0, y: 0, zoom: 1 })
|
||||
|
||||
// These are canvas-related state that should be in useCanvasState()
|
||||
```
|
||||
|
||||
### Step 2: Identify Related Effects
|
||||
|
||||
Find effects that modify the grouped state:
|
||||
|
||||
```typescript
|
||||
// These effects belong with the state above
|
||||
useEffect(() => {
|
||||
if (flowData?.nodes) {
|
||||
setNodes(flowData.nodes)
|
||||
setEdges(flowData.edges ?? [])
|
||||
}
|
||||
}, [flowData])
|
||||
|
||||
useEffect(() => {
|
||||
if (fitViewOnLoad && nodes.length > 0) {
|
||||
reactFlowInstance?.fitView()
|
||||
}
|
||||
}, [nodes.length, fitViewOnLoad, reactFlowInstance])
|
||||
```
|
||||
|
||||
### Step 3: Create the Hook
|
||||
|
||||
```typescript
|
||||
// hooks/use-canvas-state.ts
|
||||
import type { Edge, Node, Viewport } from "@xyflow/react"
|
||||
import { useEffect, useState } from "react"
|
||||
import type { FlowType } from "@/types/flow"
|
||||
|
||||
interface UseCanvasStateParams {
|
||||
flowData: FlowType | undefined
|
||||
fitViewOnLoad?: boolean
|
||||
reactFlowInstance?: any
|
||||
}
|
||||
|
||||
interface UseCanvasStateReturn {
|
||||
nodes: Node[]
|
||||
setNodes: React.Dispatch<React.SetStateAction<Node[]>>
|
||||
edges: Edge[]
|
||||
setEdges: React.Dispatch<React.SetStateAction<Edge[]>>
|
||||
viewport: Viewport
|
||||
setViewport: React.Dispatch<React.SetStateAction<Viewport>>
|
||||
}
|
||||
|
||||
export const useCanvasState = ({
|
||||
flowData,
|
||||
fitViewOnLoad = false,
|
||||
reactFlowInstance,
|
||||
}: UseCanvasStateParams): UseCanvasStateReturn => {
|
||||
const [nodes, setNodes] = useState<Node[]>([])
|
||||
const [edges, setEdges] = useState<Edge[]>([])
|
||||
const [viewport, setViewport] = useState<Viewport>({ x: 0, y: 0, zoom: 1 })
|
||||
|
||||
// Sync flow data to canvas state
|
||||
useEffect(() => {
|
||||
if (flowData?.nodes) {
|
||||
setNodes(flowData.nodes)
|
||||
setEdges(flowData.edges ?? [])
|
||||
}
|
||||
}, [flowData])
|
||||
|
||||
// Fit view on initial load
|
||||
useEffect(() => {
|
||||
if (fitViewOnLoad && nodes.length > 0) {
|
||||
reactFlowInstance?.fitView()
|
||||
}
|
||||
}, [nodes.length, fitViewOnLoad, reactFlowInstance])
|
||||
|
||||
return {
|
||||
nodes,
|
||||
setNodes,
|
||||
edges,
|
||||
setEdges,
|
||||
viewport,
|
||||
setViewport,
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Step 4: Update Component
|
||||
|
||||
```typescript
|
||||
// Before: 50+ lines of state management
|
||||
const FlowPage: FC = () => {
|
||||
const [nodes, setNodes] = useState<Node[]>([])
|
||||
// ... lots of related state and effects
|
||||
}
|
||||
|
||||
// After: Clean component
|
||||
const FlowPage: FC = () => {
|
||||
const {
|
||||
nodes,
|
||||
setNodes,
|
||||
edges,
|
||||
setEdges,
|
||||
viewport,
|
||||
} = useCanvasState({
|
||||
flowData,
|
||||
fitViewOnLoad: true,
|
||||
reactFlowInstance,
|
||||
})
|
||||
|
||||
// Component now focuses on UI
|
||||
}
|
||||
```
|
||||
|
||||
## Naming Conventions
|
||||
|
||||
### Hook Names
|
||||
|
||||
- Use `use` prefix: `useFlowState`, `useNodeDrag`, `useBuildStatus`
|
||||
- Be specific: `useRefreshModelInputs` not `useRefresh`
|
||||
- Include domain: `useFlowStore`, `useGlobalVariables`, `useAddComponent`
|
||||
- Match existing Langflow patterns: `useFlowsManagerStore`, `useFlowStore`
|
||||
|
||||
### File Names
|
||||
|
||||
- Kebab-case: `use-flow-state.ts`, `use-node-drag.ts`
|
||||
- Place in `hooks/` directory for globally reusable hooks: `src/frontend/src/hooks/use-debounce.ts`
|
||||
- Place alongside component for single-use hooks
|
||||
- Place in component's `hooks/` subdirectory when multiple hooks exist for one feature
|
||||
|
||||
### Return Type Names
|
||||
|
||||
- Suffix with `Return`: `UseCanvasStateReturn`
|
||||
- Suffix params with `Params`: `UseCanvasStateParams`
|
||||
|
||||
## Common Hook Patterns in Langflow
|
||||
|
||||
### 1. Zustand Store Derived State Hook
|
||||
|
||||
When you need to compute derived state from a Zustand store, extract a hook rather than computing in the component.
|
||||
|
||||
```typescript
|
||||
// Pattern: Derived state from store
|
||||
export const useFlowValidation = () => {
|
||||
const nodes = useFlowStore((state) => state.nodes)
|
||||
const edges = useFlowStore((state) => state.edges)
|
||||
|
||||
const hasErrors = useMemo(
|
||||
() => nodes.some((n) => n.data?.node?.error),
|
||||
[nodes],
|
||||
)
|
||||
|
||||
const hasDisconnectedInputs = useMemo(
|
||||
() =>
|
||||
nodes.some((n) => {
|
||||
const requiredInputs = Object.values(n.data?.node?.template ?? {}).filter(
|
||||
(field) => field.required && field.show,
|
||||
)
|
||||
return requiredInputs.some(
|
||||
(input) =>
|
||||
!edges.some(
|
||||
(e) => e.target === n.id && e.targetHandle === input.name,
|
||||
),
|
||||
)
|
||||
}),
|
||||
[nodes, edges],
|
||||
)
|
||||
|
||||
return {
|
||||
hasErrors,
|
||||
hasDisconnectedInputs,
|
||||
isValid: !hasErrors && !hasDisconnectedInputs,
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 2. API Data Hooks
|
||||
|
||||
When hook extraction touches query or mutation code, do not use this reference as the source of truth for data-layer patterns.
|
||||
|
||||
- Use `frontend-query-mutation` for `UseRequestProcessor`, query patterns, cache invalidation, and mutation error handling.
|
||||
- Do not create thin passthrough `useQuery` hooks; only extract orchestration hooks that combine multiple queries or shared derived state.
|
||||
- API hooks live in `controllers/API/queries/{domain}/` and follow the `UseRequestProcessor` pattern.
|
||||
|
||||
Example of an orchestration hook (acceptable to extract):
|
||||
|
||||
```typescript
|
||||
// hooks/use-flow-with-variables.ts
|
||||
// This combines multiple API queries with derived state - worth extracting
|
||||
export const useFlowWithVariables = (flowId: string) => {
|
||||
const { data: flow } = useGetFlow({ id: flowId })
|
||||
const { data: globalVariables } = useGetGlobalVariables()
|
||||
|
||||
const resolvedVariables = useMemo(() => {
|
||||
if (!flow || !globalVariables) return {}
|
||||
return resolveFlowVariables(flow, globalVariables)
|
||||
}, [flow, globalVariables])
|
||||
|
||||
return {
|
||||
flow,
|
||||
globalVariables,
|
||||
resolvedVariables,
|
||||
isLoading: !flow || !globalVariables,
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 3. Form State Hook
|
||||
|
||||
```typescript
|
||||
// Pattern: Form state + validation + submission
|
||||
export const useFlowSettingsForm = (initialValues: FlowSettings) => {
|
||||
const [values, setValues] = useState(initialValues)
|
||||
const [errors, setErrors] = useState<Record<string, string>>({})
|
||||
const [isSubmitting, setIsSubmitting] = useState(false)
|
||||
|
||||
const validate = useCallback(() => {
|
||||
const newErrors: Record<string, string> = {}
|
||||
if (!values.name?.trim()) newErrors.name = "Name is required"
|
||||
if (values.endpoint_name && !/^[a-z0-9_-]+$/.test(values.endpoint_name)) {
|
||||
newErrors.endpoint_name = "Must be lowercase alphanumeric with hyphens or underscores"
|
||||
}
|
||||
setErrors(newErrors)
|
||||
return Object.keys(newErrors).length === 0
|
||||
}, [values])
|
||||
|
||||
const handleChange = useCallback((field: string, value: any) => {
|
||||
setValues((prev) => ({ ...prev, [field]: value }))
|
||||
// Clear error on field change
|
||||
setErrors((prev) => {
|
||||
const next = { ...prev }
|
||||
delete next[field]
|
||||
return next
|
||||
})
|
||||
}, [])
|
||||
|
||||
const handleSubmit = useCallback(
|
||||
async (onSubmit: (values: FlowSettings) => Promise<void>) => {
|
||||
if (!validate()) return
|
||||
setIsSubmitting(true)
|
||||
try {
|
||||
await onSubmit(values)
|
||||
} finally {
|
||||
setIsSubmitting(false)
|
||||
}
|
||||
},
|
||||
[values, validate],
|
||||
)
|
||||
|
||||
return { values, errors, isSubmitting, handleChange, handleSubmit }
|
||||
}
|
||||
```
|
||||
|
||||
### 4. Modal State Hook
|
||||
|
||||
```typescript
|
||||
// Pattern: Multiple modal management
|
||||
type ModalType = "edit" | "delete" | "duplicate" | "export" | null
|
||||
|
||||
export const useModalState = <T = any>() => {
|
||||
const [activeModal, setActiveModal] = useState<ModalType>(null)
|
||||
const [modalData, setModalData] = useState<T | null>(null)
|
||||
|
||||
const openModal = useCallback((type: ModalType, data?: T) => {
|
||||
setActiveModal(type)
|
||||
setModalData(data ?? null)
|
||||
}, [])
|
||||
|
||||
const closeModal = useCallback(() => {
|
||||
setActiveModal(null)
|
||||
setModalData(null)
|
||||
}, [])
|
||||
|
||||
return {
|
||||
activeModal,
|
||||
modalData,
|
||||
openModal,
|
||||
closeModal,
|
||||
isOpen: useCallback(
|
||||
(type: ModalType) => activeModal === type,
|
||||
[activeModal],
|
||||
),
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 5. Toggle/Boolean Hook
|
||||
|
||||
```typescript
|
||||
// Pattern: Boolean state with convenience methods
|
||||
export const useToggle = (initialValue = false) => {
|
||||
const [value, setValue] = useState(initialValue)
|
||||
|
||||
const toggle = useCallback(() => setValue((v) => !v), [])
|
||||
const setTrue = useCallback(() => setValue(true), [])
|
||||
const setFalse = useCallback(() => setValue(false), [])
|
||||
|
||||
return [value, { toggle, setTrue, setFalse, set: setValue }] as const
|
||||
}
|
||||
|
||||
// Usage
|
||||
const [isExpanded, { toggle, setTrue: expand, setFalse: collapse }] = useToggle()
|
||||
```
|
||||
|
||||
### 6. Keyboard Shortcut Hook
|
||||
|
||||
Langflow has keyboard shortcut support. Extract shortcut handling to hooks.
|
||||
|
||||
```typescript
|
||||
// Pattern: Keyboard shortcut registration
|
||||
export const useFlowShortcuts = (handlers: {
|
||||
onSave?: () => void
|
||||
onUndo?: () => void
|
||||
onRedo?: () => void
|
||||
onDelete?: () => void
|
||||
}) => {
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
const isModKey = event.metaKey || event.ctrlKey
|
||||
|
||||
if (isModKey && event.key === "s") {
|
||||
event.preventDefault()
|
||||
handlers.onSave?.()
|
||||
} else if (isModKey && event.key === "z" && !event.shiftKey) {
|
||||
event.preventDefault()
|
||||
handlers.onUndo?.()
|
||||
} else if (isModKey && event.key === "z" && event.shiftKey) {
|
||||
event.preventDefault()
|
||||
handlers.onRedo?.()
|
||||
} else if (event.key === "Delete" || event.key === "Backspace") {
|
||||
handlers.onDelete?.()
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener("keydown", handleKeyDown)
|
||||
return () => document.removeEventListener("keydown", handleKeyDown)
|
||||
}, [handlers])
|
||||
}
|
||||
```
|
||||
|
||||
## Testing Extracted Hooks
|
||||
|
||||
After extraction, test hooks in isolation using `@testing-library/react`:
|
||||
|
||||
```typescript
|
||||
// use-canvas-state.test.ts
|
||||
import { act, renderHook } from "@testing-library/react"
|
||||
import { useCanvasState } from "./use-canvas-state"
|
||||
|
||||
describe("useCanvasState", () => {
|
||||
it("should initialize with empty state", () => {
|
||||
const { result } = renderHook(() =>
|
||||
useCanvasState({
|
||||
flowData: undefined,
|
||||
fitViewOnLoad: false,
|
||||
}),
|
||||
)
|
||||
|
||||
expect(result.current.nodes).toEqual([])
|
||||
expect(result.current.edges).toEqual([])
|
||||
expect(result.current.viewport).toEqual({ x: 0, y: 0, zoom: 1 })
|
||||
})
|
||||
|
||||
it("should sync flow data to canvas state", () => {
|
||||
const flowData = {
|
||||
nodes: [{ id: "node-1", type: "genericNode", position: { x: 0, y: 0 }, data: {} }],
|
||||
edges: [{ id: "edge-1", source: "node-1", target: "node-2" }],
|
||||
}
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useCanvasState({
|
||||
flowData: flowData as any,
|
||||
fitViewOnLoad: false,
|
||||
}),
|
||||
)
|
||||
|
||||
expect(result.current.nodes).toEqual(flowData.nodes)
|
||||
expect(result.current.edges).toEqual(flowData.edges)
|
||||
})
|
||||
|
||||
it("should update nodes via setNodes", () => {
|
||||
const { result } = renderHook(() =>
|
||||
useCanvasState({
|
||||
flowData: undefined,
|
||||
fitViewOnLoad: false,
|
||||
}),
|
||||
)
|
||||
|
||||
act(() => {
|
||||
result.current.setNodes([
|
||||
{ id: "new-node", type: "genericNode", position: { x: 100, y: 200 }, data: {} } as any,
|
||||
])
|
||||
})
|
||||
|
||||
expect(result.current.nodes).toHaveLength(1)
|
||||
expect(result.current.nodes[0].id).toBe("new-node")
|
||||
})
|
||||
})
|
||||
```
|
||||
|
||||
## Anti-Patterns to Avoid
|
||||
|
||||
### Do Not Wrap Store Selectors
|
||||
|
||||
```typescript
|
||||
// Do not create hooks that just forward store selectors
|
||||
const useNodes = () => useFlowStore((state) => state.nodes)
|
||||
const useEdges = () => useFlowStore((state) => state.edges)
|
||||
|
||||
// Instead, use selectors directly in the component
|
||||
const Component = () => {
|
||||
const nodes = useFlowStore((state) => state.nodes)
|
||||
const edges = useFlowStore((state) => state.edges)
|
||||
}
|
||||
```
|
||||
|
||||
### Do Not Wrap Single API Calls
|
||||
|
||||
```typescript
|
||||
// Do not create thin wrappers around UseRequestProcessor queries
|
||||
const useGetFlow = (flowId: string) => {
|
||||
const { query } = UseRequestProcessor()
|
||||
return query(["useGetFlow", flowId], () => api.get(`${getURL("FLOWS")}/${flowId}`))
|
||||
}
|
||||
|
||||
// These already exist in controllers/API/queries/ - use them directly
|
||||
import { useGetFlow } from "@/controllers/API/queries/flows/use-get-flow"
|
||||
```
|
||||
|
||||
### Do Extract Orchestration Hooks
|
||||
|
||||
```typescript
|
||||
// Orchestrating multiple queries and derived state is a valid hook extraction
|
||||
const useFlowBuildState = (flowId: string) => {
|
||||
const { data: flow } = useGetFlow({ id: flowId })
|
||||
const { data: builds } = useGetBuilds({ flowId })
|
||||
const isBuilding = useFlowStore((state) => state.isBuilding)
|
||||
|
||||
const lastBuild = useMemo(
|
||||
() => builds?.sort((a, b) => b.timestamp.localeCompare(a.timestamp))[0],
|
||||
[builds],
|
||||
)
|
||||
|
||||
const buildProgress = useMemo(() => {
|
||||
if (!isBuilding) return null
|
||||
// ... compute progress from build state
|
||||
}, [isBuilding, builds])
|
||||
|
||||
return { flow, lastBuild, isBuilding, buildProgress }
|
||||
}
|
||||
```
|
||||
@@ -0,0 +1,354 @@
|
||||
---
|
||||
name: e2e-testing
|
||||
description: Write and review Playwright E2E tests for Langflow. Trigger when the user asks to write, fix, or review E2E tests, spec files, Playwright tests, or integration tests that exercise the full UI. Also trigger when modifying data-testid attributes, test helpers in tests/utils/, or fixture configuration.
|
||||
---
|
||||
|
||||
# Langflow E2E Testing (Playwright)
|
||||
|
||||
## When to Apply
|
||||
|
||||
- User asks to **write E2E tests** for a feature or flow
|
||||
- User asks to **fix a failing E2E test**
|
||||
- User asks to **review E2E test coverage**
|
||||
- User modifies `data-testid` attributes in components (may break existing tests)
|
||||
- User changes test utilities in `src/frontend/tests/utils/`
|
||||
|
||||
**Do NOT apply** when:
|
||||
- User asks about unit tests (use `frontend-testing` skill for Jest)
|
||||
- User asks about backend tests (use `backend-code-review` skill for pytest)
|
||||
|
||||
## Tech Stack
|
||||
|
||||
| Tool | Version | Purpose |
|
||||
|------|---------|---------|
|
||||
| Playwright | 1.59.1 | E2E test runner + browser automation |
|
||||
| Chromium | (bundled) | Default browser (Firefox/Safari disabled) |
|
||||
| Custom fixtures | `tests/fixtures.ts` | Auto-detects API errors and flow execution failures |
|
||||
|
||||
## Key Commands
|
||||
|
||||
```bash
|
||||
# Run all E2E tests
|
||||
npx playwright test
|
||||
|
||||
# Run tests filtered by tag
|
||||
npx playwright test --grep "@release"
|
||||
npx playwright test --grep "@workspace"
|
||||
npx playwright test --grep "@starter-projects"
|
||||
|
||||
# Run a specific test file
|
||||
npx playwright test tests/core/features/run-flow.spec.ts
|
||||
|
||||
# Debug mode (headed browser + step through)
|
||||
npx playwright test --debug
|
||||
|
||||
# Show HTML report after run
|
||||
npx playwright show-report
|
||||
|
||||
# Update snapshots (if used)
|
||||
npx playwright test --update-snapshots
|
||||
```
|
||||
|
||||
## Configuration
|
||||
|
||||
**File**: `src/frontend/playwright.config.ts`
|
||||
|
||||
| Setting | Value | Why |
|
||||
|---------|-------|-----|
|
||||
| `fullyParallel` | `true` | Tests run in parallel for speed |
|
||||
| `timeout` | 5 minutes | Flow builds can be slow; prevents false timeouts |
|
||||
| `retries` | 3 (local), 2 (CI) | Flaky network/rendering issues; retries catch them |
|
||||
| `workers` | 2 | Balances speed and resource usage |
|
||||
| `actionTimeout` | 20s | Individual action timeout (click, fill, etc.) |
|
||||
| `trace` | `on-first-retry` | Captures trace on failures for debugging |
|
||||
| `baseURL` | `http://localhost:3000` | Vite dev server |
|
||||
|
||||
**WebServer**: Playwright auto-starts backend (uvicorn on 7860) + frontend (npm start on 3000).
|
||||
|
||||
## Directory Structure
|
||||
|
||||
```
|
||||
src/frontend/tests/
|
||||
├── fixtures.ts # Custom test fixture with error detection
|
||||
├── globalTeardown.ts # Cleanup (removes temp DB after tests)
|
||||
├── core/
|
||||
│ ├── features/ # Main feature tests (run-flow, playground, etc.)
|
||||
│ ├── integrations/ # Starter project / template tests
|
||||
│ ├── regression/ # Bug regression tests
|
||||
│ └── unit/ # Component-level Playwright tests
|
||||
├── extended/
|
||||
│ ├── features/ # Extended features (MCP, auto-save, etc.)
|
||||
│ ├── integrations/ # Extended integrations
|
||||
│ └── regression/ # Extended regressions
|
||||
└── utils/ # 37+ shared helper functions
|
||||
```
|
||||
|
||||
## File Naming
|
||||
|
||||
- **kebab-case** with `.spec.ts` suffix: `run-flow.spec.ts`, `playground.spec.ts`, `flow-lock.spec.ts`
|
||||
- Template tests may use spaces: `Document QA.spec.ts`, `Social Media Agent.spec.ts`
|
||||
- Sharded tests for parallelization: `chatInputOutputUser-shard-0.spec.ts`
|
||||
|
||||
> **Note**: E2E tests use `.spec.ts` (Playwright convention). Unit tests use `.test.tsx` (Jest convention). Do not mix them.
|
||||
|
||||
## Test Anatomy
|
||||
|
||||
### Basic Test
|
||||
|
||||
```typescript
|
||||
import { expect, test } from "../../fixtures";
|
||||
import { awaitBootstrapTest } from "../../utils/await-bootstrap-test";
|
||||
|
||||
test(
|
||||
"user should be able to run a flow successfully",
|
||||
{ tag: ["@release", "@workspace"] },
|
||||
async ({ page }) => {
|
||||
await awaitBootstrapTest(page);
|
||||
|
||||
// Arrange: Create a flow
|
||||
await page.getByTestId("blank-flow").click();
|
||||
|
||||
// Act: Add components and run
|
||||
await page.getByTestId("sidebar-search-input").fill("Chat Output");
|
||||
// ... setup ...
|
||||
|
||||
// Assert: Verify result
|
||||
await expect(page.getByTestId("build-status-success")).toBeVisible({ timeout: 30000 });
|
||||
},
|
||||
);
|
||||
```
|
||||
|
||||
### With test.describe
|
||||
|
||||
```typescript
|
||||
test.describe("Flow Lock Feature", () => {
|
||||
test(
|
||||
"should lock and unlock a flow",
|
||||
{ tag: ["@release", "@api"] },
|
||||
async ({ page }) => {
|
||||
// ...
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
"should prevent editing when locked",
|
||||
{ tag: ["@release"] },
|
||||
async ({ page }) => {
|
||||
// ...
|
||||
},
|
||||
);
|
||||
});
|
||||
```
|
||||
|
||||
### With Serial Mode (tests that depend on order)
|
||||
|
||||
```typescript
|
||||
test.describe.configure({ mode: "serial" });
|
||||
|
||||
test("step 1: create flow", async ({ page }) => { /* ... */ });
|
||||
test("step 2: edit flow", async ({ page }) => { /* ... */ });
|
||||
test("step 3: delete flow", async ({ page }) => { /* ... */ });
|
||||
```
|
||||
|
||||
### With Event Delivery Modes (streaming/polling/direct)
|
||||
|
||||
```typescript
|
||||
import { withEventDeliveryModes } from "../../utils/withEventDeliveryModes";
|
||||
|
||||
withEventDeliveryModes(
|
||||
"Document Q&A should work",
|
||||
{ tag: ["@release", "@starter-projects"] },
|
||||
async ({ page }) => {
|
||||
// This test runs 3 times: streaming, polling, direct
|
||||
// Each mode is configured automatically via route interception
|
||||
},
|
||||
);
|
||||
```
|
||||
|
||||
## Tags System
|
||||
|
||||
Every test MUST have at least one tag. Tags enable filtering and CI pipeline configuration.
|
||||
|
||||
| Tag | Purpose | When to Use |
|
||||
|-----|---------|-------------|
|
||||
| `@release` | Tests that must pass before release | Critical user flows |
|
||||
| `@workspace` | Workspace/flow management | Creating, editing, deleting flows |
|
||||
| `@api` | API-dependent features | Tests that call backend endpoints |
|
||||
| `@database` | Database operations | Tests involving persistence |
|
||||
| `@components` | Component-level tests | Individual component behavior |
|
||||
| `@starter-projects` | Template/starter project tests | Pre-built flow templates |
|
||||
| `@regression` | Bug regression tests | Tests for specific fixed bugs |
|
||||
|
||||
```typescript
|
||||
// Right: tag your test
|
||||
test("my feature test", { tag: ["@release", "@workspace"] }, async ({ page }) => { ... });
|
||||
|
||||
// Wrong: no tags — test can't be filtered
|
||||
test("my feature test", async ({ page }) => { ... });
|
||||
```
|
||||
|
||||
## Custom Fixtures: Error Detection
|
||||
|
||||
**Always import `test` and `expect` from `../../fixtures`, NOT from `@playwright/test`.**
|
||||
|
||||
```typescript
|
||||
// Right
|
||||
import { expect, test } from "../../fixtures";
|
||||
|
||||
// Wrong — bypasses error detection
|
||||
import { expect, test } from "@playwright/test";
|
||||
```
|
||||
|
||||
Why: The custom fixture automatically monitors all `/api/` responses and fails the test if:
|
||||
- HTTP 400, 404, 422, or 500 errors occur
|
||||
- Flow execution returns `error: true` in event streams
|
||||
- Python exceptions appear in streamed responses
|
||||
|
||||
To opt-in to expected errors (e.g., testing error handling):
|
||||
```typescript
|
||||
test("should show error on invalid input", { tag: ["@release"] }, async ({ page }) => {
|
||||
page.allowFlowErrors(); // Allow flow errors for this test
|
||||
// ... test that expects errors ...
|
||||
});
|
||||
```
|
||||
|
||||
## Selector Strategy
|
||||
|
||||
### Priority (in order of preference)
|
||||
|
||||
1. **`getByTestId`** — Most stable, used 95% of the time in Langflow
|
||||
2. **`getByRole`** — For buttons, headings, and form elements
|
||||
3. **`getByText`** — For visible text content
|
||||
4. **`waitForSelector`** — For CSS selectors and dynamic elements
|
||||
5. **`locator`** — For complex selectors (CSS, XPath)
|
||||
|
||||
### Common data-testid Patterns
|
||||
|
||||
**Canvas & Navigation:**
|
||||
- `blank-flow` — New blank flow button
|
||||
- `sidebar-search-input` — Component search
|
||||
- `canvas_controls_dropdown` — Canvas controls menu
|
||||
- `fit_view`, `zoom_out`, `zoom_in` — Canvas controls
|
||||
- `react-flow-id` — ReactFlow canvas container
|
||||
|
||||
**Component Fields:**
|
||||
- `popover-anchor-input-{fieldname}` — Input field for a component parameter
|
||||
- `input-chat-playground` — Playground chat input
|
||||
- `div-chat-message` — Chat message in playground
|
||||
|
||||
**Actions:**
|
||||
- `add-component-button-{component}` — Add component to canvas
|
||||
- `button-send` — Send chat message
|
||||
- `button_run_{component}` — Run specific component
|
||||
- `publish-button`, `save-flow-button` — Flow actions
|
||||
- `edit-fields-button` — Toggle inspection panel field editor
|
||||
|
||||
**Modals & Panels:**
|
||||
- `modal-title` — Modal heading
|
||||
- `icon-Globe` — Global variables
|
||||
- `icon-Lock` — Flow lock toggle
|
||||
- `session-selector` — Playground session switcher
|
||||
|
||||
### Important: Global Variables and Badges
|
||||
|
||||
When a component field has a global variable selected (`load_from_db: true` + `value: "OPENAI_API_KEY"`), the field renders a **badge** instead of an `<input>` element. This means `getByTestId("popover-anchor-input-api_key")` will NOT find the element — it doesn't exist in the DOM.
|
||||
|
||||
Templates with global variables pre-selected: Market Research, Price Deal Finder, Research Agent.
|
||||
Templates without (input IS rendered): Instagram Copywriter.
|
||||
|
||||
## Core Helper Functions
|
||||
|
||||
Located in `src/frontend/tests/utils/`:
|
||||
|
||||
| Function | What it Does | When to Use |
|
||||
|----------|-------------|-------------|
|
||||
| `awaitBootstrapTest(page)` | Waits for app to fully load | **Start of every test** |
|
||||
| `initialGPTsetup(page)` | Full setup: adjustView → updateComponents → selectModel → addKey → adjustView → unselectNodes | Tests that need OpenAI configured |
|
||||
| `adjustScreenView(page, opts?)` | Fit view + zoom out | After adding components to canvas |
|
||||
| `zoomOut(page, times)` | Zoom out N times | When components are too small |
|
||||
| `selectGptModel(page)` | Selects gpt-4o-mini for all Language Model nodes | GPT-dependent tests |
|
||||
| `addOpenAiInputKey(page)` | Fills OPENAI_API_KEY for all openai_api_key fields | Tests requiring API key |
|
||||
| `enableInspectPanel(page)` | Toggles inspection panel ON | **MUST call before `edit-fields-button`** |
|
||||
| `disableInspectPanel(page)` | Toggles inspection panel OFF | Cleanup after inspection |
|
||||
| `updateOldComponents(page)` | Clicks "Update all" if outdated components exist | After loading saved flows |
|
||||
| `unselectNodes(page)` | Clicks empty canvas area to deselect all nodes | After node operations |
|
||||
| `renameFlow(page, { flowName })` | Renames the current flow | Flow management tests |
|
||||
| `uploadFile(page, filename)` | Uploads a file from test assets | File upload tests |
|
||||
| `withEventDeliveryModes(...)` | Runs test 3x: streaming, polling, direct | Starter project tests |
|
||||
|
||||
### initialGPTsetup Options
|
||||
|
||||
```typescript
|
||||
await initialGPTsetup(page); // All steps
|
||||
|
||||
await initialGPTsetup(page, {
|
||||
skipAdjustScreenView: true,
|
||||
skipUpdateOldComponents: true,
|
||||
skipSelectGptModel: true,
|
||||
});
|
||||
```
|
||||
|
||||
### Inspection Panel Pattern (CRITICAL)
|
||||
|
||||
```typescript
|
||||
// MUST enable inspection panel FIRST
|
||||
await enableInspectPanel(page);
|
||||
|
||||
// Click a node to select it
|
||||
await page.getByTestId("title-OpenAI").click();
|
||||
|
||||
// Open field editor
|
||||
await page.getByTestId("edit-fields-button").click();
|
||||
|
||||
// Toggle field visibility
|
||||
await page.getByTestId("showmodel_name").click();
|
||||
|
||||
// Close field editor
|
||||
await page.getByTestId("edit-fields-button").click();
|
||||
```
|
||||
|
||||
**If you skip `enableInspectPanel(page)`, the `edit-fields-button` will NOT be visible.**
|
||||
|
||||
## Skip Patterns
|
||||
|
||||
```typescript
|
||||
// Skip test if env var missing
|
||||
test.skip(!process?.env?.OPENAI_API_KEY, "OPENAI_API_KEY required to run this test");
|
||||
|
||||
// Skip test unconditionally with reason
|
||||
test.skip(true, "Feature not yet implemented with new designs");
|
||||
```
|
||||
|
||||
## Writing Good E2E Tests
|
||||
|
||||
### Do:
|
||||
- **Tag every test** with at least one tag
|
||||
- **Import from `../../fixtures`**, not `@playwright/test`
|
||||
- **Start with `awaitBootstrapTest(page)`** — always
|
||||
- **Use `getByTestId`** for stable selectors
|
||||
- **Set explicit timeouts** on `waitForSelector` and `expect(...).toBeVisible()` for async operations
|
||||
- **Test the complete user flow**: setup → action → verification
|
||||
- **Use `withEventDeliveryModes`** for tests that involve flow execution (chat, build)
|
||||
|
||||
### Don't:
|
||||
- **Don't use `page.waitForTimeout()`** unless absolutely necessary — prefer `waitForSelector` or `expect().toBeVisible()`
|
||||
- **Don't hardcode API keys** — read from `process.env.OPENAI_API_KEY`
|
||||
- **Don't skip tests without a reason** — always provide the second argument to `test.skip()`
|
||||
- **Don't import from `@playwright/test`** — use the custom fixtures
|
||||
- **Don't forget `enableInspectPanel(page)`** before accessing `edit-fields-button`
|
||||
- **Don't assume input fields exist** when global variables are selected (badge renders instead)
|
||||
|
||||
### Challenge Tests (Apply Here Too)
|
||||
|
||||
E2E tests should also cover adversarial scenarios:
|
||||
- **Invalid input**: paste 10K characters, special characters (`<script>alert(1)</script>`), empty submissions
|
||||
- **Network interruption**: what happens if the user loses connection mid-build?
|
||||
- **Permission boundaries**: can a user access another user's flow via direct URL?
|
||||
- **Concurrent actions**: double-click delete, rapid chat messages
|
||||
- **Error recovery**: does the UI recover gracefully from a 500 error?
|
||||
|
||||
## References
|
||||
|
||||
- [Selector Patterns](references/selectors.md) — Complete data-testid catalog
|
||||
- [Helper Functions](references/helpers.md) — Detailed documentation of all 37+ utility functions
|
||||
- [Test Fixtures](references/fixtures.md) — Custom fixture error detection behavior
|
||||
@@ -0,0 +1,83 @@
|
||||
# Custom Test Fixtures
|
||||
|
||||
## Overview
|
||||
|
||||
Langflow extends Playwright's default `test` and `expect` with a custom fixture in `src/frontend/tests/fixtures.ts`. This fixture adds **automatic error detection** that fails tests when unexpected API errors or flow execution errors occur.
|
||||
|
||||
## Why Custom Fixtures Exist
|
||||
|
||||
Without the custom fixture, a test could pass even though:
|
||||
- The backend returned a 500 Internal Server Error (test only checked for UI text)
|
||||
- A flow build silently failed with a Python exception (test only checked button state)
|
||||
- An API call returned 404 because the resource was deleted (test didn't check the response)
|
||||
|
||||
The custom fixture catches these silent failures by monitoring ALL `/api/` responses during the test.
|
||||
|
||||
## Import Rule
|
||||
|
||||
**Always import from `../../fixtures`, never from `@playwright/test`:**
|
||||
|
||||
```typescript
|
||||
// Right — includes error detection
|
||||
import { expect, test } from "../../fixtures";
|
||||
|
||||
// Wrong — NO error detection, silent failures possible
|
||||
import { expect, test } from "@playwright/test";
|
||||
```
|
||||
|
||||
## What the Fixture Detects
|
||||
|
||||
### HTTP Error Responses
|
||||
|
||||
The fixture intercepts all responses from `/api/` endpoints and fails the test if:
|
||||
|
||||
| Status Code | Meaning | Why it Fails |
|
||||
|-------------|---------|-------------|
|
||||
| 400 | Bad Request | Client sent invalid data — likely a bug in the frontend |
|
||||
| 404 | Not Found | Resource doesn't exist — likely a stale ID or missing setup |
|
||||
| 422 | Validation Error | Pydantic validation failed — likely a schema mismatch |
|
||||
| 500 | Internal Server Error | Backend crash — always a bug |
|
||||
|
||||
### Flow Execution Errors
|
||||
|
||||
For streaming responses (build, chat), the fixture parses the event stream and fails if:
|
||||
- JSON payload contains `"error": true`
|
||||
- Response body contains Python exception patterns (`Traceback`, `Error`, etc.)
|
||||
- The stream indicates a component build failure
|
||||
|
||||
## Allowing Expected Errors
|
||||
|
||||
Some tests intentionally trigger errors (testing error handling, validation feedback, etc.). Use `page.allowFlowErrors()` to prevent the fixture from failing on flow execution errors:
|
||||
|
||||
```typescript
|
||||
test("should show error message on invalid component config", { tag: ["@release"] }, async ({ page }) => {
|
||||
page.allowFlowErrors(); // Allow flow errors for THIS test only
|
||||
|
||||
await awaitBootstrapTest(page);
|
||||
// ... test that triggers an error ...
|
||||
|
||||
await expect(page.getByText(/error/i)).toBeVisible();
|
||||
});
|
||||
```
|
||||
|
||||
**Note**: `allowFlowErrors()` only suppresses flow execution errors. HTTP 500 errors will still fail the test — those indicate a backend crash, not expected behavior.
|
||||
|
||||
## Error Reporting
|
||||
|
||||
When the fixture detects an error, it adds it to an internal list. After the test function completes, the fixture checks the list and fails with a descriptive message:
|
||||
|
||||
```
|
||||
Test failed due to unexpected API errors:
|
||||
- POST /api/v1/build/abc123/flow → 500 Internal Server Error
|
||||
- Flow execution error: Traceback (most recent call last)...
|
||||
```
|
||||
|
||||
This makes it easy to identify the root cause without adding manual response checks to every test.
|
||||
|
||||
## Timeout Behavior
|
||||
|
||||
The fixture reads response bodies with a 2-second timeout. If the body can't be read within 2 seconds (e.g., streaming response still in progress), it skips body parsing for that response. This prevents the fixture from blocking indefinitely on long-running streams.
|
||||
|
||||
## Global Teardown
|
||||
|
||||
`src/frontend/tests/globalTeardown.ts` runs after all tests complete and removes the temporary test database. This ensures a clean state for the next test run.
|
||||
@@ -0,0 +1,180 @@
|
||||
# E2E Test Helper Functions
|
||||
|
||||
All helpers are located in `src/frontend/tests/utils/`. Import them by name.
|
||||
|
||||
## Bootstrap & Setup
|
||||
|
||||
### `awaitBootstrapTest(page, options?)`
|
||||
|
||||
**Call this at the start of EVERY test.** Waits for the app to fully load and optionally opens the new project modal.
|
||||
|
||||
```typescript
|
||||
import { awaitBootstrapTest } from "../../utils/await-bootstrap-test";
|
||||
|
||||
// Default: waits for load + opens new project modal
|
||||
await awaitBootstrapTest(page);
|
||||
|
||||
// Skip opening modal (for tests that start on an existing page)
|
||||
await awaitBootstrapTest(page, { skipModal: true });
|
||||
```
|
||||
|
||||
Why: Without this, tests race against the app's initialization. Components may not be rendered, stores may not be hydrated, and API calls may not be complete. Every flaky "element not found" failure is likely a missing `awaitBootstrapTest`.
|
||||
|
||||
### `initialGPTsetup(page, options?)`
|
||||
|
||||
Full OpenAI setup pipeline. Calls 6 steps in order:
|
||||
1. `adjustScreenView` — fit view + zoom out
|
||||
2. `updateOldComponents` — update any outdated components
|
||||
3. `selectGptModel` — select gpt-4o-mini for all Language Model nodes
|
||||
4. `addOpenAiInputKey` — fill OPENAI_API_KEY in all openai_api_key fields
|
||||
5. `adjustScreenView` — fit again (components may have moved)
|
||||
6. `unselectNodes` — click empty canvas to deselect
|
||||
|
||||
```typescript
|
||||
// All steps
|
||||
await initialGPTsetup(page);
|
||||
|
||||
// Skip specific steps
|
||||
await initialGPTsetup(page, {
|
||||
skipAdjustScreenView: true,
|
||||
skipUpdateOldComponents: true,
|
||||
skipSelectGptModel: true,
|
||||
});
|
||||
```
|
||||
|
||||
Why: Many tests need a working OpenAI-connected flow. This helper standardizes the setup so test authors don't reinvent it. The order matters — updating components before selecting models prevents stale model dropdowns.
|
||||
|
||||
## Canvas Controls
|
||||
|
||||
### `adjustScreenView(page, options?)`
|
||||
|
||||
Clicks "fit view" then zooms out. Ensures all nodes are visible and interactable.
|
||||
|
||||
```typescript
|
||||
await adjustScreenView(page); // Default: fit + 1 zoom out
|
||||
await adjustScreenView(page, { numberOfZoomOut: 3 }); // Fit + 3 zoom outs
|
||||
```
|
||||
|
||||
Why: After adding components, some may be off-screen or overlapping. Fit view centers them; zoom out ensures click targets are large enough for Playwright to hit reliably.
|
||||
|
||||
### `zoomOut(page, times)`
|
||||
|
||||
Zooms out the canvas a specific number of times.
|
||||
|
||||
```typescript
|
||||
await zoomOut(page, 5);
|
||||
```
|
||||
|
||||
### `unselectNodes(page)`
|
||||
|
||||
Clicks the empty canvas area at position (0, 0) to deselect all nodes.
|
||||
|
||||
```typescript
|
||||
await unselectNodes(page);
|
||||
```
|
||||
|
||||
Why: Selected nodes show selection UI (toolbars, handles) that can overlay other elements. Deselecting before interacting with other elements prevents click interception.
|
||||
|
||||
## Component Setup
|
||||
|
||||
### `selectGptModel(page)`
|
||||
|
||||
Finds all Language Model nodes and selects `gpt-4o-mini` in their model dropdown.
|
||||
|
||||
Why: Tests that require LLM execution need a specific model. Using `gpt-4o-mini` keeps costs low and responses fast.
|
||||
|
||||
### `addOpenAiInputKey(page)`
|
||||
|
||||
Finds all fields with `data-testid="popover-anchor-input-openai_api_key"` and fills them with `process.env.OPENAI_API_KEY`.
|
||||
|
||||
**Warning**: Only works when the field renders as an `<input>`. If a global variable is selected (badge mode), this helper won't find the field. Check the template's `load_from_db` setting.
|
||||
|
||||
### `updateOldComponents(page)`
|
||||
|
||||
If the canvas shows an "Update all" button (outdated components), clicks it and waits for update to complete.
|
||||
|
||||
Why: Saved flows from older versions may have outdated component definitions. Updating them ensures the test runs against current component behavior, not stale cached versions.
|
||||
|
||||
## Inspection Panel
|
||||
|
||||
### `enableInspectPanel(page)`
|
||||
|
||||
Opens the canvas controls dropdown and toggles the inspection panel ON.
|
||||
|
||||
**MUST be called BEFORE any interaction with `edit-fields-button`.** Without it, the inspection panel is hidden and `edit-fields-button` does not exist in the DOM.
|
||||
|
||||
```typescript
|
||||
await enableInspectPanel(page);
|
||||
await page.getByTestId("title-OpenAI").click(); // Select a node
|
||||
await page.getByTestId("edit-fields-button").click(); // Now visible
|
||||
```
|
||||
|
||||
### `disableInspectPanel(page)`
|
||||
|
||||
Toggles the inspection panel OFF. Use for cleanup or when testing canvas-only behavior.
|
||||
|
||||
## Flow Management
|
||||
|
||||
### `renameFlow(page, options)`
|
||||
|
||||
Renames the current flow in the workspace.
|
||||
|
||||
```typescript
|
||||
await renameFlow(page, { flowName: "My Test Flow" });
|
||||
```
|
||||
|
||||
### `uploadFile(page, filename)`
|
||||
|
||||
Uploads a file from the `tests/assets/` directory.
|
||||
|
||||
```typescript
|
||||
await uploadFile(page, "test-document.pdf");
|
||||
```
|
||||
|
||||
## Event Delivery Modes
|
||||
|
||||
### `withEventDeliveryModes(title, config, testFn, options?)`
|
||||
|
||||
Wraps a test function to run it 3 times: once for each event delivery mode (streaming, polling, direct). Each mode is configured by intercepting the `/api/v1/config` route.
|
||||
|
||||
```typescript
|
||||
import { withEventDeliveryModes } from "../../utils/withEventDeliveryModes";
|
||||
|
||||
withEventDeliveryModes(
|
||||
"Document Q&A should process and respond",
|
||||
{ tag: ["@release", "@starter-projects"] },
|
||||
async ({ page }) => {
|
||||
// Test body — runs 3 times with different delivery modes
|
||||
await page.getByTestId("input-chat-playground").fill("What is this about?");
|
||||
await page.keyboard.press("Enter");
|
||||
await expect(page.getByTestId("div-chat-message")).toBeVisible({ timeout: 60000 });
|
||||
},
|
||||
{ timeout: 10000 }, // Optional delay between mode switches
|
||||
);
|
||||
```
|
||||
|
||||
Why: Langflow supports 3 event delivery modes. A bug that only appears in polling mode would be missed if tests only run in streaming mode. This helper ensures all modes are covered without writing 3x the tests.
|
||||
|
||||
## Advanced Patterns
|
||||
|
||||
### `openAdvancedOptions(page)` (LEGACY)
|
||||
|
||||
Opens the old edit modal for component configuration. **Deprecated** — use `enableInspectPanel` + `edit-fields-button` for new tests.
|
||||
|
||||
### `closeAdvancedOptions(page)` (LEGACY)
|
||||
|
||||
Closes the old edit modal. **Deprecated**.
|
||||
|
||||
## Creating New Helpers
|
||||
|
||||
When to create a new helper:
|
||||
- The same 5+ lines of setup appear in 3+ test files (DRY)
|
||||
- The pattern involves complex waits or retries that are easy to get wrong
|
||||
- The helper encapsulates Langflow-specific knowledge (e.g., global variable badge behavior)
|
||||
|
||||
When NOT to create a helper:
|
||||
- For a single test's setup (keep it inline)
|
||||
- For generic Playwright operations (use Playwright API directly)
|
||||
- For assertions (assertions should be explicit in the test, not hidden in helpers)
|
||||
|
||||
Place new helpers in `src/frontend/tests/utils/` with kebab-case naming: `my-new-helper.ts`.
|
||||
@@ -0,0 +1,111 @@
|
||||
# Langflow E2E Selector Catalog
|
||||
|
||||
This is the canonical reference for `data-testid` selectors used in Langflow E2E tests. When adding new interactive elements, follow these naming conventions and add the element to this catalog.
|
||||
|
||||
## Naming Convention
|
||||
|
||||
All `data-testid` values use kebab-case with a prefix that indicates the element type:
|
||||
|
||||
| Prefix | Element Type | Example |
|
||||
|--------|-------------|---------|
|
||||
| `input-` | Text inputs | `input-chat-playground`, `input-flow-name` |
|
||||
| `button-` / `button_` | Action buttons | `button-send`, `button_run_chat output` |
|
||||
| `icon-` | Icon buttons | `icon-Globe`, `icon-Lock`, `icon-ChevronLeft` |
|
||||
| `popover-anchor-input-` | Component parameter fields | `popover-anchor-input-openai_api_key` |
|
||||
| `add-component-button-` | Drag-to-add buttons | `add-component-button-chat-output` |
|
||||
| `card-` | Flow/component cards | `card-my-flow-name` |
|
||||
| `title-` | Node titles on canvas | `title-OpenAI`, `title-Chat Output` |
|
||||
| `handle-` | Connection handles | `handle-{component}-{shownode}-{field}-{direction}` |
|
||||
| `div-chat-message` | Chat messages | `div-chat-message` |
|
||||
| `show` | Field visibility toggles | `showmodel_name`, `showtemperature` |
|
||||
|
||||
## Canvas & Navigation
|
||||
|
||||
| Selector | Element | Notes |
|
||||
|----------|---------|-------|
|
||||
| `blank-flow` | "New Blank Flow" button | On project creation modal |
|
||||
| `sidebar-search-input` | Component search input | Sidebar search bar |
|
||||
| `sidebar-nav-add_note` | Sticky note button | Sidebar navigation |
|
||||
| `sidebar-add-sticky-note-button` | Add sticky note (new) | Updated button name |
|
||||
| `react-flow-id` | ReactFlow canvas container | Use for drag targets |
|
||||
| `canvas_controls_dropdown` | Canvas controls dropdown | Opens zoom/fit/inspector menu |
|
||||
| `fit_view` | Fit view button | Inside canvas controls |
|
||||
| `zoom_out` | Zoom out button | Inside canvas controls |
|
||||
| `zoom_in` | Zoom in button | Inside canvas controls |
|
||||
| `inspector-toggle` | Inspection panel toggle | Inside canvas controls dropdown |
|
||||
|
||||
## Component Fields
|
||||
|
||||
| Selector | Element | Notes |
|
||||
|----------|---------|-------|
|
||||
| `popover-anchor-input-{name}` | Component input field | `name` matches the field's `name` attribute |
|
||||
| `popover-anchor-input-openai_api_key` | OpenAI API key field | Only visible when no global variable selected |
|
||||
| `input_output{component}` | Output connection handle area | e.g., `input_outputChat Output` |
|
||||
|
||||
**Important**: When `load_from_db: true` and a global variable is selected, the field renders as a **badge** (not an `<input>`). The `popover-anchor-input-{name}` selector will NOT exist in the DOM for those fields.
|
||||
|
||||
## Actions & Buttons
|
||||
|
||||
| Selector | Element | Notes |
|
||||
|----------|---------|-------|
|
||||
| `button-send` | Send message button | Playground chat |
|
||||
| `button_run_{component}` | Run component button | e.g., `button_run_chat output` |
|
||||
| `publish-button` | Publish/deploy flow | Top toolbar |
|
||||
| `save-flow-button` | Save flow | Top toolbar |
|
||||
| `edit-fields-button` | Toggle field editor | Inspection panel — requires `enableInspectPanel()` first |
|
||||
| `playground-btn-flow-io` | Playground button | Use `dispatchEvent("click")` to close (not `.click()`) |
|
||||
| `manage-model-providers` | Model providers button | Settings |
|
||||
|
||||
## Modals & Panels
|
||||
|
||||
| Selector | Element | Notes |
|
||||
|----------|---------|-------|
|
||||
| `modal-title` | Modal heading | Generic modal title |
|
||||
| `edit-button-modal` | Edit button (legacy) | Old modal pattern |
|
||||
| `edit-button-close` | Close edit modal | Old modal pattern |
|
||||
| `lock-flow-switch` | Flow lock toggle | Flow settings |
|
||||
| `input-flow-name` | Flow name input | Flow settings modal |
|
||||
| `input-flow-description` | Flow description input | Flow settings modal |
|
||||
| `session-selector` | Session selector | Playground session switcher |
|
||||
|
||||
## Icons (as buttons)
|
||||
|
||||
| Selector | Action |
|
||||
|----------|--------|
|
||||
| `icon-Globe` | Open global variables |
|
||||
| `icon-Lock` | Toggle flow lock |
|
||||
| `icon-ChevronLeft` | Navigate back |
|
||||
| `icon-Trash2` | Delete action |
|
||||
| `icon-Plus` | Add/create action |
|
||||
|
||||
## Inspection Panel Field Toggles
|
||||
|
||||
These selectors toggle field visibility in the inspection panel. The format is `show{fieldname}` (no separator):
|
||||
|
||||
| Selector | Field |
|
||||
|----------|-------|
|
||||
| `showmodel_name` | Model name field |
|
||||
| `showtemperature` | Temperature field |
|
||||
| `showmax_tokens` | Max tokens field |
|
||||
| `showopenai_api_key` | OpenAI API key field |
|
||||
|
||||
## When to Add New data-testid
|
||||
|
||||
Add `data-testid` to an element when:
|
||||
1. E2E tests need to interact with it (click, fill, assert visibility)
|
||||
2. The element has no stable `role` or `text` alternative
|
||||
3. The element is dynamically rendered and needs a stable anchor
|
||||
|
||||
Format: `{type}-{descriptive-name}` in kebab-case. Keep it descriptive enough that a test author understands the element without reading the component source.
|
||||
|
||||
```tsx
|
||||
// Right — descriptive, kebab-case
|
||||
<button data-testid="save-flow-button">Save</button>
|
||||
<input data-testid="input-flow-name" />
|
||||
<div data-testid="card-flow-summary" />
|
||||
|
||||
// Wrong — too generic
|
||||
<button data-testid="btn1">Save</button>
|
||||
<input data-testid="input" />
|
||||
<div data-testid="wrapper" />
|
||||
```
|
||||
@@ -0,0 +1,143 @@
|
||||
---
|
||||
name: frontend-code-review
|
||||
description: "Review frontend code (.tsx, .ts, .js files) for quality, performance, and correctness against Langflow's frontend conventions. Supports pending-change reviews and file-targeted reviews."
|
||||
---
|
||||
|
||||
# Frontend Code Review
|
||||
|
||||
## When to use this skill
|
||||
|
||||
Use this skill whenever the user asks to **review, analyze, or improve** frontend code (`.tsx`, `.ts`, `.js` files) under the `src/frontend/` directory. Supports the following review modes:
|
||||
|
||||
1. **Pending-change review** -- inspect staged or working-tree files slated for commit and flag checklist violations before submission.
|
||||
2. **File-targeted review** -- review the specific file(s) the user names and report the relevant checklist findings.
|
||||
|
||||
Do NOT use this skill when:
|
||||
|
||||
- The request is about backend code (`.py` files under `src/backend/`).
|
||||
- The user is not asking for a review/analysis/improvement of frontend code.
|
||||
- The scope is outside `src/frontend/` (unless the user explicitly asks to review frontend-related changes elsewhere).
|
||||
|
||||
## How to use this skill
|
||||
|
||||
Follow these steps when using this skill:
|
||||
|
||||
1. **Identify the review mode** (pending-change vs file-targeted) based on the user's input. Keep the scope tight: review only what the user provided or explicitly referenced.
|
||||
2. Follow the rules defined in the **Checklist** to perform the review. If no checklist rule matches, apply **General Review Rules** as a fallback.
|
||||
3. Compose the final output strictly following the **Required Output Format**.
|
||||
|
||||
Notes when using this skill:
|
||||
|
||||
- Always include actionable fixes or suggestions (including possible code snippets).
|
||||
- Use `File:Line` references when a file path and line numbers are available; otherwise, use the most specific identifier you can.
|
||||
- The Langflow frontend uses React 19, TypeScript 5.4, Vite 7 with SWC, Zustand for state management, TanStack React Query for server state, @xyflow/react v12 for graph visualization, Radix UI + shadcn-ui components, Tailwind CSS v3, and Biome for linting/formatting.
|
||||
|
||||
## Checklist
|
||||
|
||||
- **Code quality**: For any reviewed file, follow [references/code-quality.md](references/code-quality.md) to check styling conventions, TypeScript usage, Biome compliance, and component patterns.
|
||||
- **Performance**: If the review scope involves React components, hooks, Zustand stores, React Query usage, or @xyflow/react node rendering, follow [references/performance.md](references/performance.md) to check for re-render issues, memoization, and data flow patterns.
|
||||
- **Business logic**: If the review scope involves custom nodes (GenericNode), flow state, API calls, the component system, global variables, or the inspection panel, follow [references/business-logic.md](references/business-logic.md) to check for Langflow-specific correctness.
|
||||
|
||||
## General Review Rules
|
||||
|
||||
### 1. Security Review
|
||||
|
||||
Check for:
|
||||
- XSS vulnerabilities (dangerouslySetInnerHTML, unescaped user input)
|
||||
- Sensitive data exposure in client-side code
|
||||
- Insecure direct object references in API calls
|
||||
- Hardcoded secrets, tokens, or API keys
|
||||
|
||||
### 2. Accessibility Review
|
||||
|
||||
Check for:
|
||||
- Missing aria labels on interactive elements
|
||||
- Keyboard navigation support
|
||||
- Proper use of semantic HTML elements
|
||||
- Color contrast issues in custom styling
|
||||
|
||||
### 3. Code Quality Review
|
||||
|
||||
Check for:
|
||||
- Code duplication (DRY violations — extract at 3+ identical usages)
|
||||
- Functions/components doing too much (SRP violations — if you need "and" to describe it, split it)
|
||||
- Deep nesting or complex conditionals (prefer early returns and guard clauses)
|
||||
- Magic numbers/strings without named constants
|
||||
- Poor naming: generic names (`data`, `result`, `temp`), missing verb prefixes on functions, missing `is`/`has`/`can`/`should` prefixes on booleans
|
||||
- Missing error handling or error boundaries
|
||||
- Incomplete TypeScript type coverage (no `any`, no `as any` casts)
|
||||
- Comments that explain WHAT instead of WHY
|
||||
- Commented-out code (use version control)
|
||||
- Boolean parameters that switch component behavior (use two components instead)
|
||||
- Mutable patterns where `const` or immutable alternatives exist
|
||||
- Production files exceeding ~500 lines (red flag at 600+)
|
||||
- `console.log` in production code (Biome flags this)
|
||||
|
||||
### 4. Testing Impact Review
|
||||
|
||||
Check for:
|
||||
- Changes to data-testid attributes that may break E2E tests
|
||||
- Modified component interfaces that require test updates
|
||||
- New interactive elements missing data-testid attributes
|
||||
|
||||
### 5. Pre-Commit Verification
|
||||
|
||||
For pending-change reviews, verify:
|
||||
- `npm run format` (Biome formatter) — zero diffs
|
||||
- `npm run lint` (Biome linter) — zero errors
|
||||
- `npm test` (Jest) — zero failures
|
||||
|
||||
## Required Output Format
|
||||
|
||||
When this skill is invoked, the response must exactly follow one of the two templates:
|
||||
|
||||
### Template A (any findings)
|
||||
|
||||
```markdown
|
||||
# Code Review
|
||||
|
||||
Found <N> urgent issues that need to be fixed:
|
||||
|
||||
## 1. <brief description of issue>
|
||||
|
||||
FilePath: <path> line <line>
|
||||
<relevant code snippet or pointer>
|
||||
|
||||
|
||||
### Suggested fix
|
||||
|
||||
<brief description of suggested fix with code example>
|
||||
|
||||
---
|
||||
|
||||
... (repeat for each urgent issue) ...
|
||||
|
||||
Found <M> suggestions for improvement:
|
||||
|
||||
## 1. <brief description of suggestion>
|
||||
|
||||
FilePath: <path> line <line>
|
||||
<relevant code snippet or pointer>
|
||||
|
||||
|
||||
### Suggested fix
|
||||
|
||||
<brief description of suggested fix with code example>
|
||||
|
||||
---
|
||||
|
||||
... (repeat for each suggestion) ...
|
||||
```
|
||||
|
||||
- If there are no urgent issues, omit that section. If there are no suggestions, omit that section.
|
||||
- If the issue count exceeds 10, summarize as "10+ urgent issues" or "10+ suggestions" and output only the first 10 items.
|
||||
- Do not compress the blank lines between sections; keep them as-is for readability.
|
||||
- If Template A is used (there are issues to fix) and at least one issue requires code changes, append a brief follow-up question after the structured output asking whether the user wants the suggested fixes applied. For example: "Would you like me to apply the suggested fixes to address these issues?"
|
||||
|
||||
### Template B (no issues)
|
||||
|
||||
```markdown
|
||||
## Code Review
|
||||
|
||||
No issues found.
|
||||
```
|
||||
@@ -0,0 +1,253 @@
|
||||
# Rule Catalog -- Business Logic
|
||||
|
||||
Update this file when adding, editing, or removing Business Logic rules so the catalog remains accurate.
|
||||
|
||||
## Custom node components must not import stores directly outside the provider tree
|
||||
|
||||
IsUrgent: True
|
||||
Category: Business Logic
|
||||
|
||||
### Description
|
||||
|
||||
File path pattern of node components: `src/frontend/src/CustomNodes/GenericNode/` and sub-components.
|
||||
|
||||
GenericNode and its child components render inside the @xyflow/react canvas, which has its own React context. These components may also render in contexts where certain store providers are not available (e.g., preview panels, template views). Directly importing and calling Zustand store hooks that depend on a specific provider context can cause blank screens or runtime errors when the component mounts outside that provider.
|
||||
|
||||
### Suggested Fix
|
||||
|
||||
Use the store hooks that are available globally (like `useFlowStore`, `useTypesStore`) or pass data as props from the parent that has provider access. If imperative store access is needed, use the store's `getState()` method which does not require a provider.
|
||||
|
||||
```tsx
|
||||
// Wrong -- may fail outside provider tree
|
||||
import { useSpecialContext } from "@/contexts/specialContext";
|
||||
|
||||
function NodeField() {
|
||||
const { config } = useSpecialContext(); // crashes if provider missing
|
||||
return <div>{config.label}</div>;
|
||||
}
|
||||
|
||||
// Right -- use props or global store
|
||||
function NodeField({ config }: { config: FieldConfig }) {
|
||||
return <div>{config.label}</div>;
|
||||
}
|
||||
```
|
||||
|
||||
## Flow state management: use `flowStore` for current flow state
|
||||
|
||||
IsUrgent: True
|
||||
Category: Business Logic
|
||||
|
||||
### Description
|
||||
|
||||
The current flow's state (nodes, edges, flow metadata) is managed through `useFlowStore` (located in `src/frontend/src/stores/flowStore.ts`). Always use this store for reading and modifying the active flow. Do not create parallel state tracking for flow data, and do not cache flow state in component-local state unless there is a specific performance reason (document it if so).
|
||||
|
||||
### Suggested Fix
|
||||
|
||||
```tsx
|
||||
// Wrong -- local state duplicating flow data
|
||||
const [nodes, setNodes] = useState(initialNodes);
|
||||
|
||||
// Right -- use the flow store
|
||||
const nodes = useFlowStore((state) => state.nodes);
|
||||
const setNodes = useFlowStore((state) => state.setNodes);
|
||||
```
|
||||
|
||||
## API interceptor patterns: do not duplicate auth/retry logic
|
||||
|
||||
IsUrgent: True
|
||||
Category: Business Logic
|
||||
|
||||
### Description
|
||||
|
||||
The Axios HTTP client in `src/frontend/src/controllers/API/` is configured with interceptors that handle authentication headers, token refresh, retry logic, and error normalization. Do not duplicate any of this logic in individual API call sites or components. Use the configured Axios instance (via the API controller helpers) for all backend requests. Do not create new Axios instances or use raw `fetch()` for API calls to the Langflow backend.
|
||||
|
||||
### Suggested Fix
|
||||
|
||||
```tsx
|
||||
// Wrong -- raw fetch with manual auth
|
||||
const response = await fetch("/api/v1/flows", {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
});
|
||||
|
||||
// Wrong -- new Axios instance bypassing interceptors
|
||||
const client = axios.create({ baseURL: "/api/v1" });
|
||||
const response = await client.get("/flows");
|
||||
|
||||
// Right -- use the project's API utilities
|
||||
import { api } from "@/controllers/API/api";
|
||||
|
||||
const response = await api.get("/flows");
|
||||
```
|
||||
|
||||
## Component data-testid patterns
|
||||
|
||||
IsUrgent: False
|
||||
Category: Business Logic
|
||||
|
||||
### Description
|
||||
|
||||
Interactive components must include `data-testid` attributes following the project's established naming conventions. This is critical for E2E test stability. The primary patterns are:
|
||||
|
||||
- Input fields: `popover-anchor-input-{name}` (generated via `StrRenderComponent` -> `InputComponent` -> `CustomInputPopover`)
|
||||
- Buttons: descriptive kebab-case, e.g., `edit-fields-button`, `playground-btn-flow-io`
|
||||
- Sidebar items: `sidebar-nav-{item}`, `sidebar-add-sticky-note-button`
|
||||
- Node-related: `title-{display_name}`, `handle-{id}`
|
||||
|
||||
When adding new interactive elements, follow the existing patterns. When modifying elements that have `data-testid`, preserve the attribute and its value to avoid breaking E2E tests.
|
||||
|
||||
### Suggested Fix
|
||||
|
||||
```tsx
|
||||
// Wrong -- missing data-testid on interactive element
|
||||
<button onClick={handleSave}>Save</button>
|
||||
|
||||
// Right -- includes data-testid
|
||||
<button data-testid="save-flow-button" onClick={handleSave}>Save</button>
|
||||
```
|
||||
|
||||
## Global variables: understand `load_from_db` and `value` pattern
|
||||
|
||||
IsUrgent: True
|
||||
Category: Business Logic
|
||||
|
||||
### Description
|
||||
|
||||
Template fields in starter projects use the `load_from_db` + `value` pattern to reference global variables. When `load_from_db: true` and `value` is set to a key like `"OPENAI_API_KEY"`, the field displays a badge (via `InputGlobalComponent`) instead of a text input. The actual input element (`<input>`) does not exist in the DOM when a global variable is selected.
|
||||
|
||||
This has implications for:
|
||||
- **Component rendering**: Do not assume an input element exists when `load_from_db` is true and a global variable is set.
|
||||
- **E2E tests**: `getByTestId("popover-anchor-input-api_key")` will not find an element when a global variable badge is displayed.
|
||||
- **Templates affected**: Market Research, Price Deal Finder, Research Agent have `load_from_db: true` with global variable values.
|
||||
|
||||
### Suggested Fix
|
||||
|
||||
When writing components that interact with template fields, check the `load_from_db` state:
|
||||
|
||||
```tsx
|
||||
// Wrong -- assumes input always exists
|
||||
const apiKeyInput = data.node.template.api_key;
|
||||
// Directly rendering an <input> without checking load_from_db
|
||||
|
||||
// Right -- handle both states
|
||||
if (field.load_from_db && field.value) {
|
||||
// Render global variable badge
|
||||
return <GlobalVariableBadge name={field.value} />;
|
||||
}
|
||||
// Render normal input
|
||||
return <InputComponent value={field.value} onChange={handleChange} />;
|
||||
```
|
||||
|
||||
## Inspection panel: use `enableInspectPanel` before accessing panel elements
|
||||
|
||||
IsUrgent: True
|
||||
Category: Business Logic
|
||||
|
||||
### Description
|
||||
|
||||
The inspection panel (added in the panel migration from the old edit modal pattern) must be explicitly enabled before its elements are accessible. In E2E tests and in code that interacts with the panel programmatically:
|
||||
|
||||
1. Call `enableInspectPanel(page)` before attempting to access `edit-fields-button` or other panel controls.
|
||||
2. Click a node to select it, then use `edit-fields-button` to toggle field editing.
|
||||
3. The old `openAdvancedOptions(page)` / `closeAdvancedOptions(page)` pattern is deprecated.
|
||||
4. The `editNode` flag is always `false`; `-edit` suffix testids no longer exist.
|
||||
|
||||
This rule is especially important when writing or modifying E2E tests that interact with node configuration.
|
||||
|
||||
## Node field visibility rules
|
||||
|
||||
IsUrgent: False
|
||||
Category: Business Logic
|
||||
|
||||
### Description
|
||||
|
||||
Node fields have different visibility rules depending on where they are rendered. Understanding these rules is critical when modifying field display logic:
|
||||
|
||||
- **Canvas view**: Shows non-advanced, non-hidden fields. Controlled by `isCanvasVisible()` in `src/frontend/src/CustomNodes/helpers/parameter-filtering.ts`.
|
||||
- **Inspection panel**: Shows only advanced fields. Controlled by `shouldRenderInspectionPanelField()` in the same file.
|
||||
- **`showNode` flag**: Defaults to `true`. When set to `false`, fields receive the `hidden` CSS class rather than being removed from the DOM.
|
||||
|
||||
When adding new fields or modifying visibility logic, ensure both canvas and inspection panel rendering paths are considered.
|
||||
|
||||
### Suggested Fix
|
||||
|
||||
```tsx
|
||||
// Wrong -- only checking one visibility context
|
||||
if (!field.advanced) {
|
||||
return <FieldComponent />;
|
||||
}
|
||||
|
||||
// Right -- check the appropriate visibility function for the context
|
||||
import { isCanvasVisible, shouldRenderInspectionPanelField } from "@/CustomNodes/helpers/parameter-filtering";
|
||||
|
||||
// In canvas context
|
||||
if (isCanvasVisible(field)) {
|
||||
return <FieldComponent />;
|
||||
}
|
||||
|
||||
// In inspection panel context
|
||||
if (shouldRenderInspectionPanelField(field)) {
|
||||
return <FieldComponent />;
|
||||
}
|
||||
```
|
||||
|
||||
## Customization layer usage
|
||||
|
||||
IsUrgent: False
|
||||
Category: Business Logic
|
||||
|
||||
### Description
|
||||
|
||||
Langflow has a customization layer in `src/frontend/src/customization/` that allows overriding default behaviors and components. When adding new features that may need to be customized (e.g., branding, feature flags, custom components), use the customization layer rather than hardcoding values. Check `src/frontend/src/customization/` for existing customization points before adding new ones.
|
||||
|
||||
## React Hook Form + Zod for form validation
|
||||
|
||||
IsUrgent: False
|
||||
Category: Business Logic
|
||||
|
||||
### Description
|
||||
|
||||
Forms that require validation should use React Hook Form with Zod schemas. Do not implement manual form state tracking or validation logic when React Hook Form can handle it. Define Zod schemas for form data types and use `zodResolver` to integrate them with React Hook Form.
|
||||
|
||||
### Suggested Fix
|
||||
|
||||
```tsx
|
||||
// Wrong -- manual form state and validation
|
||||
const [name, setName] = useState("");
|
||||
const [error, setError] = useState("");
|
||||
|
||||
const handleSubmit = () => {
|
||||
if (!name) {
|
||||
setError("Name is required");
|
||||
return;
|
||||
}
|
||||
// submit
|
||||
};
|
||||
|
||||
// Right -- React Hook Form + Zod
|
||||
import { useForm } from "react-hook-form";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { z } from "zod";
|
||||
|
||||
const schema = z.object({
|
||||
name: z.string().min(1, "Name is required"),
|
||||
});
|
||||
|
||||
type FormData = z.infer<typeof schema>;
|
||||
|
||||
function MyForm() {
|
||||
const { register, handleSubmit, formState: { errors } } = useForm<FormData>({
|
||||
resolver: zodResolver(schema),
|
||||
});
|
||||
|
||||
const onSubmit = (data: FormData) => {
|
||||
// submit
|
||||
};
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit(onSubmit)}>
|
||||
<input {...register("name")} />
|
||||
{errors.name && <span>{errors.name.message}</span>}
|
||||
</form>
|
||||
);
|
||||
}
|
||||
```
|
||||
@@ -0,0 +1,475 @@
|
||||
# Rule Catalog -- Code Quality
|
||||
|
||||
Update this file when adding, editing, or removing Code Quality rules so the catalog remains accurate.
|
||||
|
||||
## Use `cn()` for conditional class names
|
||||
|
||||
IsUrgent: True
|
||||
Category: Code Quality
|
||||
|
||||
### Description
|
||||
|
||||
All conditional CSS class logic must use the `cn()` utility from `@/utils` instead of manual string concatenation, template literals, or ternary expressions that build class strings. The `cn()` function wraps `clsx` and `tailwind-merge`, ensuring proper deduplication and conflict resolution of Tailwind classes.
|
||||
|
||||
### Suggested Fix
|
||||
|
||||
```tsx
|
||||
// Wrong -- manual concatenation
|
||||
<div className={`px-4 ${isActive ? "text-primary" : "text-muted-foreground"}`}>
|
||||
|
||||
// Wrong -- ternary without cn()
|
||||
<div className={isActive ? "px-4 text-primary" : "px-4 text-muted-foreground"}>
|
||||
|
||||
// Right
|
||||
import { cn } from "@/utils/utils";
|
||||
|
||||
<div className={cn("px-4", isActive ? "text-primary" : "text-muted-foreground")}>
|
||||
```
|
||||
|
||||
## Tailwind-first styling
|
||||
|
||||
IsUrgent: True
|
||||
Category: Code Quality
|
||||
|
||||
### Description
|
||||
|
||||
Favor Tailwind CSS utility classes for all styling. Do not introduce new CSS modules or custom CSS files unless a Tailwind combination cannot achieve the required styling. Keeping styles in Tailwind improves consistency, reduces bundle size, and simplifies maintenance. The project uses Tailwind v3 with a custom theme configuration.
|
||||
|
||||
Why this matters: Introducing custom CSS files creates a parallel styling system that falls out of sync with Tailwind tokens. When the design team changes a border color, they update the CSS variable in `index.css` — but your CSS module keeps the old hardcoded color. Custom CSS also bypasses Tailwind's purging, increasing bundle size. In Langflow, with 300+ components sharing a design system, inconsistent styling compounds fast.
|
||||
|
||||
### Suggested Fix
|
||||
|
||||
```tsx
|
||||
// Wrong -- adding a CSS module for simple styling
|
||||
import styles from "./Button.module.css";
|
||||
<div className={styles.container}>
|
||||
|
||||
// Right -- Tailwind utilities
|
||||
<div className="flex items-center gap-2 rounded-md border px-4 py-2">
|
||||
```
|
||||
|
||||
## Use design tokens instead of raw Tailwind colors
|
||||
|
||||
IsUrgent: True
|
||||
Category: Code Quality
|
||||
|
||||
### Description
|
||||
|
||||
**Never use raw Tailwind color classes** like `text-red-500`, `bg-blue-100`, `border-gray-300`, etc. The project defines a complete design token system via CSS custom properties in `src/frontend/src/style/index.css` and maps them to Tailwind classes in `tailwind.config.mjs`. These tokens support both light and dark mode automatically. Using raw colors bypasses the theme system, breaks dark mode, and creates visual inconsistency.
|
||||
|
||||
### Available Design Token Classes
|
||||
|
||||
**Core semantic colors** (use these for general UI):
|
||||
|
||||
| Purpose | Text | Background | Border |
|
||||
|---------|------|------------|--------|
|
||||
| Primary text/bg | `text-foreground` | `bg-background` | `border-border` |
|
||||
| Primary action | `text-primary` | `bg-primary` | — |
|
||||
| Primary action text | `text-primary-foreground` | — | — |
|
||||
| Secondary | `text-secondary-foreground` | `bg-secondary` | — |
|
||||
| Muted/subtle | `text-muted-foreground` | `bg-muted` | — |
|
||||
| Destructive/danger | `text-destructive` | `bg-destructive` | — |
|
||||
| Accent | `text-accent-foreground` | `bg-accent` | — |
|
||||
| Cards | `text-card-foreground` | `bg-card` | — |
|
||||
| Popovers | `text-popover-foreground` | `bg-popover` | — |
|
||||
| Tooltips | `text-tooltip-foreground` | `bg-tooltip` | — |
|
||||
| Input fields | — | — | `border-input` |
|
||||
| Focus rings | — | — | `ring-ring` |
|
||||
| Placeholder | `text-placeholder-foreground` | — | — |
|
||||
|
||||
**Status colors** (for build state, node status, alerts):
|
||||
|
||||
| Status | Class |
|
||||
|--------|-------|
|
||||
| Success | `bg-success-background`, `text-success-foreground` |
|
||||
| Error | `bg-error-background`, `text-error-foreground`, `bg-error` |
|
||||
| Info | `bg-info-background`, `text-info-foreground` |
|
||||
| Warning | `bg-warning`, `text-warning-foreground` |
|
||||
| Status indicators | `bg-status-green`, `bg-status-red`, `bg-status-yellow`, `bg-status-blue`, `bg-status-gray` |
|
||||
|
||||
**Accent colors** (for highlights, badges, categories):
|
||||
|
||||
| Accent | Background | Foreground |
|
||||
|--------|------------|------------|
|
||||
| Emerald | `bg-accent-emerald` | `text-accent-emerald-foreground` |
|
||||
| Indigo | `bg-accent-indigo` | `text-accent-indigo-foreground` |
|
||||
| Blue | `bg-accent-blue` | `text-accent-blue-foreground` |
|
||||
| Pink | `bg-accent-pink` | `text-accent-pink-foreground` |
|
||||
| Amber | `bg-accent-amber` | `text-accent-amber-foreground` |
|
||||
|
||||
**Data type colors** (for flow node field types):
|
||||
|
||||
| Type | Background | Foreground |
|
||||
|------|------------|------------|
|
||||
| String | `bg-datatype-yellow` | `text-datatype-yellow-foreground` |
|
||||
| Number | `bg-datatype-blue` | `text-datatype-blue-foreground` |
|
||||
| Boolean | `bg-datatype-lime` | `text-datatype-lime-foreground` |
|
||||
| List | `bg-datatype-purple` | `text-datatype-purple-foreground` |
|
||||
| Dict | `bg-datatype-indigo` | `text-datatype-indigo-foreground` |
|
||||
| Object | `bg-datatype-gray` | `text-datatype-gray-foreground` |
|
||||
| Error | `bg-datatype-red` | `text-datatype-red-foreground` |
|
||||
| Message | `bg-datatype-emerald` | `text-datatype-emerald-foreground` |
|
||||
|
||||
**Sticky note colors**:
|
||||
`bg-note-amber`, `bg-note-neutral`, `bg-note-rose`, `bg-note-blue`, `bg-note-lime`
|
||||
|
||||
**Other tokens**:
|
||||
`bg-canvas` (flow canvas background), `text-node-ring` (node selection ring), `bg-code-background` / `text-code-foreground` (code blocks), `bg-hover` (hover state), `text-hard-zinc`, `bg-smooth-red`, `text-placeholder`
|
||||
|
||||
### Suggested Fix
|
||||
|
||||
```tsx
|
||||
// Wrong -- raw Tailwind colors (breaks dark mode, no theme consistency)
|
||||
<span className="text-red-500">Error</span>
|
||||
<div className="bg-gray-100 border-gray-300">
|
||||
<span className="text-blue-600">Link</span>
|
||||
<div className="bg-green-50 text-green-800">Success</div>
|
||||
|
||||
// Right -- design tokens (automatic dark mode, theme-consistent)
|
||||
<span className="text-destructive">Error</span>
|
||||
<div className="bg-muted border-border">
|
||||
<span className="text-accent-blue-foreground">Link</span>
|
||||
<div className="bg-success-background text-success-foreground">Success</div>
|
||||
|
||||
// Wrong -- hardcoded hex or rgb values
|
||||
<div style={{ color: "#ef4444" }}>
|
||||
<div className="text-[#2563eb]">
|
||||
|
||||
// Right -- use CSS variable if no Tailwind class exists
|
||||
<div className="text-status-red">
|
||||
<div className="text-status-blue">
|
||||
```
|
||||
|
||||
### When Raw Colors Are Acceptable
|
||||
|
||||
Raw Tailwind colors are only acceptable when:
|
||||
1. Building a one-off demo or prototype (tag with `// TODO: replace with design token`)
|
||||
2. The color is truly static and theme-independent (extremely rare)
|
||||
3. An exact design spec requires a color with no matching token — in that case, **add a new CSS variable** to `src/frontend/src/style/index.css` (both `:root` and `.dark` variants) and register it in `tailwind.config.mjs` instead of using the raw color inline
|
||||
|
||||
## Class name ordering for overridable components
|
||||
|
||||
IsUrgent: False
|
||||
Category: Code Quality
|
||||
|
||||
### Description
|
||||
|
||||
When a component accepts a `className` prop, always place it last inside `cn()` so downstream consumers can override or extend the component's default styling. The component's own classes come first, then the incoming `className`.
|
||||
|
||||
### Suggested Fix
|
||||
|
||||
```tsx
|
||||
import { cn } from "@/utils/utils";
|
||||
|
||||
const Card = ({ className }: { className?: string }) => {
|
||||
return (
|
||||
<div className={cn("rounded-lg border bg-background p-4 shadow-sm", className)}>
|
||||
{/* content */}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
```
|
||||
|
||||
Why: If the component's own classes come after `className`, a consumer passing `className='bg-destructive'` gets silently overridden by the component's `bg-background`. The consumer's intent is lost. By placing `className` last in `cn()`, Tailwind Merge resolves conflicts in favor of the consumer.
|
||||
|
||||
## Strong TypeScript typing -- no `any`
|
||||
|
||||
IsUrgent: True
|
||||
Category: Code Quality
|
||||
|
||||
### Description
|
||||
|
||||
Never use `any` as a type annotation. Using `any` disables TypeScript's ability to catch type mismatches at compile time — bugs that would be caught by the compiler instead crash in production. In Langflow, flow data structures are deeply nested (`NodeDataType.node.template[fieldName].value`); an `any` at any level silently allows accessing non-existent properties, causing 'Cannot read properties of undefined' in the canvas. Use `unknown` with type guards for genuinely unknown data, or specific types/generics for everything else.
|
||||
|
||||
### Suggested Fix
|
||||
|
||||
```ts
|
||||
// Wrong
|
||||
function processData(data: any) { ... }
|
||||
|
||||
// Right
|
||||
function processData(data: Record<string, unknown>) { ... }
|
||||
|
||||
// Right -- with type guard
|
||||
function processData(data: unknown) {
|
||||
if (isFlowData(data)) { ... }
|
||||
}
|
||||
```
|
||||
|
||||
## Biome formatting conventions
|
||||
|
||||
IsUrgent: False
|
||||
Category: Code Quality
|
||||
|
||||
### Description
|
||||
|
||||
The project uses Biome (not ESLint/Prettier) for linting and formatting. Follow Biome conventions: double quotes for strings, 2-space indentation, trailing commas in multi-line structures. Run `biome check` or `biome format` to verify compliance. Do not add ESLint or Prettier configuration files.
|
||||
|
||||
### Suggested Fix
|
||||
|
||||
```ts
|
||||
// Wrong -- single quotes, 4-space indent
|
||||
const name = 'flow'
|
||||
const config = {
|
||||
key: 'value'
|
||||
}
|
||||
|
||||
// Right -- double quotes, 2-space indent, trailing comma
|
||||
const name = "flow";
|
||||
const config = {
|
||||
key: "value",
|
||||
};
|
||||
```
|
||||
|
||||
## Prefer Radix UI primitives for accessible components
|
||||
|
||||
IsUrgent: False
|
||||
Category: Code Quality
|
||||
|
||||
### Description
|
||||
|
||||
When building interactive UI elements (dialogs, dropdowns, tooltips, popovers, tabs, etc.), use Radix UI primitives via the shadcn-ui wrappers in `components/ui/`. Custom implementations of these patterns consistently miss: keyboard navigation (arrow keys in dropdowns), focus trapping (Tab stays inside dialogs), screen reader announcements (ARIA live regions), and Escape key handling. Rebuilding these from scratch introduces accessibility regressions that affect keyboard-only and screen reader users. Langflow is used by enterprise teams that may require WCAG compliance — every custom dropdown without proper focus management is a compliance risk.
|
||||
|
||||
### Suggested Fix
|
||||
|
||||
```tsx
|
||||
// Wrong -- custom dropdown implementation
|
||||
const [open, setOpen] = useState(false);
|
||||
<div onBlur={() => setOpen(false)}>
|
||||
<button onClick={() => setOpen(!open)}>Menu</button>
|
||||
{open && <div className="absolute">{/* items */}</div>}
|
||||
</div>
|
||||
|
||||
// Right -- use shadcn/Radix DropdownMenu
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger>Menu</DropdownMenuTrigger>
|
||||
<DropdownMenuContent>
|
||||
<DropdownMenuItem>Item 1</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
```
|
||||
|
||||
## State management: Zustand for client, React Query for server
|
||||
|
||||
IsUrgent: True
|
||||
Category: Code Quality
|
||||
|
||||
### Description
|
||||
|
||||
Use Zustand stores (in `src/frontend/src/stores/`) for client-side UI state and TanStack React Query (via hooks in `src/frontend/src/controllers/API/`) for server state (data fetching, caching, mutations). Do not store fetched server data in Zustand; let React Query manage it. Do not use React Query for purely client-side state.
|
||||
|
||||
### Suggested Fix
|
||||
|
||||
```ts
|
||||
// Wrong -- storing fetched data in Zustand
|
||||
const useFlowStore = create((set) => ({
|
||||
flows: [],
|
||||
fetchFlows: async () => {
|
||||
const data = await api.getFlows();
|
||||
set({ flows: data });
|
||||
},
|
||||
}));
|
||||
|
||||
// Right -- React Query for server state
|
||||
const useFlows = () => {
|
||||
return useQueryFunctionType<FlowType[]>(["flows"], api.getFlows);
|
||||
};
|
||||
|
||||
// Right -- Zustand for client UI state
|
||||
const useFlowStore = create((set) => ({
|
||||
selectedNodeId: null,
|
||||
setSelectedNodeId: (id: string | null) => set({ selectedNodeId: id }),
|
||||
}));
|
||||
```
|
||||
|
||||
## No `console.log` in production code
|
||||
|
||||
IsUrgent: True
|
||||
Category: Code Quality
|
||||
|
||||
### Description
|
||||
|
||||
Do not leave `console.log`, `console.warn`, or `console.error` statements in production code. Biome enforces this rule. Use proper error handling, error boundaries, or toast notifications for user-facing errors. If logging is truly needed for debugging, remove it before committing.
|
||||
|
||||
Why: `console.log` in production code writes to the browser's developer console. In Langflow, component data often includes API keys stored in global variables — a `console.log(data)` accidentally leaks credentials to anyone who opens DevTools. Biome catches this at lint time; if Biome is not catching it, the linting step was skipped.
|
||||
|
||||
## Prefer `const` and immutable patterns
|
||||
|
||||
IsUrgent: False
|
||||
Category: Code Quality
|
||||
|
||||
### Description
|
||||
|
||||
Use `const` by default. Only use `let` when reassignment is genuinely needed. Never use `var`. Prefer immutable update patterns (spread operator, `map`, `filter`) over mutation when working with arrays and objects, especially in state updates.
|
||||
|
||||
### Suggested Fix
|
||||
|
||||
```ts
|
||||
// Wrong
|
||||
let items = getItems();
|
||||
items.push(newItem);
|
||||
setState({ items });
|
||||
|
||||
// Right
|
||||
const items = getItems();
|
||||
setState({ items: [...items, newItem] });
|
||||
```
|
||||
|
||||
Why: Mutation creates invisible dependencies between code paths. If function A mutates an array that function B reads, changing A's behavior silently breaks B with no compile error. React also relies on reference equality for re-renders — mutating an object in state doesn't trigger a re-render because `oldObj === newObj` is still `true`. Spread/map/filter create new references that React can detect.
|
||||
|
||||
## Functional components with hooks only
|
||||
|
||||
IsUrgent: True
|
||||
Category: Code Quality
|
||||
|
||||
### Description
|
||||
|
||||
All React components must be functional components using hooks. Do not use class components, `React.Component`, or lifecycle methods (`componentDidMount`, etc.). Use `useEffect`, `useState`, `useMemo`, `useCallback`, and custom hooks instead.
|
||||
|
||||
Why: Class components have a larger API surface (lifecycle methods, `this` binding, constructor), making them harder to test and reason about. Hooks compose better — a custom hook can combine state, effects, and context without nesting HOCs. The entire Langflow codebase uses functional components; introducing a class component breaks consistency and forces other developers to context-switch between paradigms.
|
||||
|
||||
## Early returns for guard clauses
|
||||
|
||||
IsUrgent: False
|
||||
Category: Code Quality
|
||||
|
||||
### Description
|
||||
|
||||
Use early returns to handle edge cases and guard conditions at the top of functions and components. This reduces nesting, improves readability, and makes the main logic path clear.
|
||||
|
||||
Deep nesting increases cognitive load: each additional level of indentation requires the reader to hold one more condition in working memory. Studies show that code with 3+ nesting levels has significantly higher defect rates. Guard clauses at the top make preconditions explicit and keep the main logic at the lowest indentation — the 'happy path' is visually clear.
|
||||
|
||||
### Suggested Fix
|
||||
|
||||
```tsx
|
||||
// Wrong -- deeply nested
|
||||
function NodeComponent({ data }: NodeProps) {
|
||||
if (data) {
|
||||
if (data.node) {
|
||||
if (data.node.template) {
|
||||
return <div>{/* main content */}</div>;
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// Right -- early returns
|
||||
function NodeComponent({ data }: NodeProps) {
|
||||
if (!data?.node?.template) return null;
|
||||
|
||||
return <div>{/* main content */}</div>;
|
||||
}
|
||||
```
|
||||
|
||||
## Use Lucide React for icons
|
||||
|
||||
IsUrgent: False
|
||||
Category: Code Quality
|
||||
|
||||
### Description
|
||||
|
||||
The project standardizes on Lucide React for icons. Do not import icons from other libraries (heroicons, react-icons, font-awesome, etc.) unless Lucide does not have an equivalent. Check the Lucide icon set first at https://lucide.dev before introducing a new icon dependency.
|
||||
|
||||
Why: Multiple icon libraries bloat the bundle (each library ships its own SVG set). Lucide is tree-shakeable — unused icons are removed at build time. Mixing libraries also creates visual inconsistency (different stroke widths, sizing conventions, visual weight). Langflow standardized on Lucide; check https://lucide.dev before adding a new dependency.
|
||||
|
||||
### Suggested Fix
|
||||
|
||||
```tsx
|
||||
// Wrong
|
||||
import { AiOutlineCheck } from "react-icons/ai";
|
||||
|
||||
// Right
|
||||
import { Check } from "lucide-react";
|
||||
```
|
||||
|
||||
## Follow the project's naming and folder conventions
|
||||
|
||||
IsUrgent: True
|
||||
Category: Code Quality
|
||||
|
||||
### Description
|
||||
|
||||
All **new** frontend code must use **kebab-case** file names with descriptive names. The legacy codebase has mixed conventions (`index.tsx`, camelCase, PascalCase) — do NOT follow those patterns for new code.
|
||||
|
||||
**The standard for NEW code:**
|
||||
|
||||
- **All files**: kebab-case with a name that describes what the file does. **Never name a file `index.tsx`** — this is a legacy pattern that makes navigation and search harder.
|
||||
- **Folders**: kebab-case, named after the feature or component.
|
||||
- **Hooks**: kebab-case with `use-` prefix (`use-add-flow.ts`, `use-debounce.ts`).
|
||||
- **Stores**: camelCase with "Store" suffix (`flowStore.ts`, `alertStore.ts`) — existing convention, keep it.
|
||||
- **Types**: kebab-case folders with descriptive file names.
|
||||
|
||||
| What | Convention | Example |
|
||||
|------|-----------|---------|
|
||||
| Component file | kebab-case, descriptive | `flow-settings-panel.tsx`, `node-input-field.tsx` |
|
||||
| Component folder | kebab-case | `flow-settings/`, `node-toolbar/` |
|
||||
| Hook file | kebab-case, `use-` prefix | `use-save-flow.ts`, `use-debounce.ts` |
|
||||
| Helper file | kebab-case, descriptive | `column-defs.ts`, `format-data.ts` |
|
||||
| Type file | kebab-case | `flow-types.ts`, `api-types.ts` |
|
||||
| Store file | camelCase + "Store" | `flowStore.ts` (existing convention) |
|
||||
| UI component (shadcn) | kebab-case | `dropdown-menu.tsx` (shadcn standard) |
|
||||
|
||||
**Never `index.tsx`:**
|
||||
|
||||
```
|
||||
// Wrong (legacy pattern — do NOT use for new code)
|
||||
my-component/
|
||||
├── index.tsx ← ambiguous, hard to find in search/tabs
|
||||
|
||||
// Right (new standard)
|
||||
my-component/
|
||||
├── my-component.tsx ← clear, searchable, self-documenting
|
||||
├── components/
|
||||
│ ├── sub-part.tsx
|
||||
│ └── another-part.tsx
|
||||
├── helpers/
|
||||
│ ├── column-defs.ts
|
||||
│ └── format-data.ts
|
||||
├── hooks/
|
||||
│ └── use-local-state.ts
|
||||
└── types/
|
||||
└── my-component-types.ts
|
||||
```
|
||||
|
||||
**Key rule: helpers are LOCAL, not centralized.** Each component owns its own `helpers/` folder. Do not create shared helper files in unrelated directories.
|
||||
|
||||
### Suggested Fix
|
||||
|
||||
```tsx
|
||||
// Wrong -- index.tsx (legacy pattern)
|
||||
// src/frontend/src/components/core/myComponent/index.tsx
|
||||
|
||||
// Wrong -- centralized helper
|
||||
// src/frontend/src/helpers/flowHelpers.ts
|
||||
|
||||
// Right -- kebab-case, descriptive name, local helpers
|
||||
// src/frontend/src/components/core/my-component/my-component.tsx
|
||||
// src/frontend/src/pages/FlowPage/components/flow-sidebar/helpers/format-flow.ts
|
||||
```
|
||||
|
||||
## Use available shadcn components from `components/ui/`
|
||||
|
||||
IsUrgent: False
|
||||
Category: Code Quality
|
||||
|
||||
### Description
|
||||
|
||||
Before building a custom interactive UI element, check if a shadcn/Radix component already exists in `src/frontend/src/components/ui/`. The project has 39+ base components available:
|
||||
|
||||
**Layout & Containers:** `accordion`, `card`, `dialog`, `dialog-with-no-close`, `disclosure`, `popover`, `separator`, `sidebar`, `simple-sidebar`, `tabs`, `tabs-button`
|
||||
|
||||
**Forms & Inputs:** `button`, `checkbox`, `input`, `label`, `radio-group`, `select`, `select-custom`, `switch`, `textarea`
|
||||
|
||||
**Feedback & Overlay:** `alert`, `badge`, `command`, `context-menu`, `dropdown-menu`, `loading`, `skeleton`, `skeletonGroup`, `tooltip`
|
||||
|
||||
**Visual:** `animated-close`, `background-gradient`, `checkmark`, `dot-background`, `refreshButton`, `table`, `text-loop`, `textAnimation`, `TextShimmer`, `xmark`
|
||||
|
||||
Do not recreate these patterns manually. Import from `@/components/ui/` instead.
|
||||
@@ -0,0 +1,265 @@
|
||||
# Rule Catalog -- Performance
|
||||
|
||||
Update this file when adding, editing, or removing Performance rules so the catalog remains accurate.
|
||||
|
||||
## @xyflow/react data usage
|
||||
|
||||
IsUrgent: True
|
||||
Category: Performance
|
||||
|
||||
### Description
|
||||
|
||||
When working with the flow graph powered by @xyflow/react v12, use `useNodes()` and `useEdges()` hooks for rendering UI that displays node/edge data. For callbacks that mutate or read node/edge state imperatively (e.g., inside event handlers), use `useReactFlow()` or `useStoreApi()` to access the store directly. Avoid pulling the full node/edge arrays in callbacks, as this creates unnecessary subscriptions and re-renders.
|
||||
|
||||
### Suggested Fix
|
||||
|
||||
```tsx
|
||||
// Wrong -- subscribes to all node changes in a callback-only context
|
||||
function MyPanel() {
|
||||
const nodes = useNodes();
|
||||
const handleExport = () => {
|
||||
const data = nodes.map((n) => n.data);
|
||||
exportFlow(data);
|
||||
};
|
||||
return <button onClick={handleExport}>Export</button>;
|
||||
}
|
||||
|
||||
// Right -- use store API for imperative access
|
||||
function MyPanel() {
|
||||
const { getNodes } = useReactFlow();
|
||||
const handleExport = () => {
|
||||
const data = getNodes().map((n) => n.data);
|
||||
exportFlow(data);
|
||||
};
|
||||
return <button onClick={handleExport}>Export</button>;
|
||||
}
|
||||
```
|
||||
|
||||
## Complex prop memoization with `useMemo`
|
||||
|
||||
IsUrgent: True
|
||||
Category: Performance
|
||||
|
||||
### Description
|
||||
|
||||
Wrap complex prop values (objects, arrays, computed structures) in `useMemo` before passing them to child components. Without memoization, a new reference is created on every render, causing children wrapped in `React.memo` to re-render unnecessarily. This is especially critical in the flow canvas where hundreds of nodes may be rendered simultaneously.
|
||||
|
||||
### Suggested Fix
|
||||
|
||||
```tsx
|
||||
// Wrong -- new object on every render
|
||||
<GenericNode
|
||||
config={{
|
||||
template: data.node.template,
|
||||
display_name: data.node.display_name,
|
||||
}}
|
||||
/>
|
||||
|
||||
// Right -- stable reference
|
||||
const config = useMemo(
|
||||
() => ({
|
||||
template: data.node.template,
|
||||
display_name: data.node.display_name,
|
||||
}),
|
||||
[data.node.template, data.node.display_name],
|
||||
);
|
||||
|
||||
<GenericNode config={config} />
|
||||
```
|
||||
|
||||
## Avoid re-renders with `React.memo` and `useCallback`
|
||||
|
||||
IsUrgent: True
|
||||
Category: Performance
|
||||
|
||||
### Description
|
||||
|
||||
Use `React.memo` for components that receive stable props and should not re-render when their parent re-renders. Use `useCallback` for callback functions passed as props to memoized children. This is critical for node components on the canvas: GenericNode and its sub-components should be memoized to avoid cascading re-renders when the flow state changes.
|
||||
|
||||
### Suggested Fix
|
||||
|
||||
```tsx
|
||||
// Wrong -- inline callback causes child re-render
|
||||
function ParentNode({ id }: { id: string }) {
|
||||
return (
|
||||
<ChildComponent
|
||||
onUpdate={(value: string) => updateNodeField(id, value)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// Right -- stable callback reference
|
||||
function ParentNode({ id }: { id: string }) {
|
||||
const handleUpdate = useCallback(
|
||||
(value: string) => updateNodeField(id, value),
|
||||
[id],
|
||||
);
|
||||
return <ChildComponent onUpdate={handleUpdate} />;
|
||||
}
|
||||
|
||||
const ChildComponent = React.memo(({ onUpdate }: Props) => {
|
||||
// ...
|
||||
});
|
||||
```
|
||||
|
||||
## Lazy loading for heavy pages
|
||||
|
||||
IsUrgent: False
|
||||
Category: Performance
|
||||
|
||||
### Description
|
||||
|
||||
Use `React.lazy()` with `<Suspense>` for route-level code splitting of heavy pages (flow editor, settings, store, etc.). This reduces the initial bundle size and improves first-load performance. Route definitions should use lazy imports rather than static imports for page components.
|
||||
|
||||
### Suggested Fix
|
||||
|
||||
```tsx
|
||||
// Wrong -- static import of heavy page
|
||||
import FlowEditor from "@/pages/FlowEditor";
|
||||
|
||||
// Right -- lazy loaded
|
||||
const FlowEditor = React.lazy(() => import("@/pages/FlowEditor"));
|
||||
|
||||
<Suspense fallback={<LoadingSpinner />}>
|
||||
<FlowEditor />
|
||||
</Suspense>
|
||||
```
|
||||
|
||||
## No expensive operations in render functions
|
||||
|
||||
IsUrgent: True
|
||||
Category: Performance
|
||||
|
||||
### Description
|
||||
|
||||
Do not perform expensive computations (deep object traversals, array sorting/filtering of large datasets, JSON parsing, regex on large strings) directly in the render body of a component. Move these into `useMemo` with appropriate dependencies, or into event handlers / effects if they produce side effects. The flow canvas may trigger frequent re-renders; keeping the render path lightweight is critical.
|
||||
|
||||
### Suggested Fix
|
||||
|
||||
```tsx
|
||||
// Wrong -- sorts on every render
|
||||
function NodeList({ nodes }: { nodes: NodeType[] }) {
|
||||
const sorted = nodes
|
||||
.filter((n) => n.data.showNode)
|
||||
.sort((a, b) => a.data.node.display_name.localeCompare(b.data.node.display_name));
|
||||
|
||||
return <>{sorted.map((n) => <NodeCard key={n.id} node={n} />)}</>;
|
||||
}
|
||||
|
||||
// Right -- memoized computation
|
||||
function NodeList({ nodes }: { nodes: NodeType[] }) {
|
||||
const sorted = useMemo(
|
||||
() =>
|
||||
nodes
|
||||
.filter((n) => n.data.showNode)
|
||||
.sort((a, b) =>
|
||||
a.data.node.display_name.localeCompare(b.data.node.display_name),
|
||||
),
|
||||
[nodes],
|
||||
);
|
||||
|
||||
return <>{sorted.map((n) => <NodeCard key={n.id} node={n} />)}</>;
|
||||
}
|
||||
```
|
||||
|
||||
## Zustand selector optimization
|
||||
|
||||
IsUrgent: True
|
||||
Category: Performance
|
||||
|
||||
### Description
|
||||
|
||||
When reading from Zustand stores, always use selectors to subscribe to only the specific slices of state the component needs. Selecting the entire store object causes the component to re-render on every store update, even for unrelated state changes. Langflow has 15+ Zustand stores; careless subscriptions can create cascading performance problems.
|
||||
|
||||
### Suggested Fix
|
||||
|
||||
```ts
|
||||
// Wrong -- subscribes to entire store, re-renders on any change
|
||||
const store = useFlowStore();
|
||||
const nodes = store.nodes;
|
||||
|
||||
// Wrong -- inline object selector creates new reference each render
|
||||
const { nodes, edges } = useFlowStore((state) => ({
|
||||
nodes: state.nodes,
|
||||
edges: state.edges,
|
||||
}));
|
||||
|
||||
// Right -- individual selectors for each value
|
||||
const nodes = useFlowStore((state) => state.nodes);
|
||||
const edges = useFlowStore((state) => state.edges);
|
||||
|
||||
// Right -- use useShallow for multiple values if needed
|
||||
import { useShallow } from "zustand/react/shallow";
|
||||
|
||||
const { nodes, edges } = useFlowStore(
|
||||
useShallow((state) => ({ nodes: state.nodes, edges: state.edges })),
|
||||
);
|
||||
```
|
||||
|
||||
## React Query: proper staleTime and gcTime configuration
|
||||
|
||||
IsUrgent: False
|
||||
Category: Performance
|
||||
|
||||
### Description
|
||||
|
||||
Configure `staleTime` and `gcTime` (formerly `cacheTime`) appropriately for each query based on how frequently the data changes. Avoid leaving defaults (0ms staleTime) for data that rarely changes (e.g., component type definitions, user settings), as this causes unnecessary refetches. Conversely, do not set excessively long stale times for data that changes frequently (e.g., flow execution status).
|
||||
|
||||
### Suggested Fix
|
||||
|
||||
```ts
|
||||
// Wrong -- default staleTime of 0, refetches on every mount
|
||||
const { data } = useQuery({
|
||||
queryKey: ["componentTypes"],
|
||||
queryFn: getComponentTypes,
|
||||
});
|
||||
|
||||
// Right -- component types rarely change, cache for 5 minutes
|
||||
const { data } = useQuery({
|
||||
queryKey: ["componentTypes"],
|
||||
queryFn: getComponentTypes,
|
||||
staleTime: 5 * 60 * 1000,
|
||||
});
|
||||
```
|
||||
|
||||
## Avoid creating new objects/arrays inline in JSX
|
||||
|
||||
IsUrgent: False
|
||||
Category: Performance
|
||||
|
||||
### Description
|
||||
|
||||
Do not create new object or array literals directly inside JSX props, especially for configuration objects, data arrays, or callback payloads. Each render creates a new reference, which defeats `React.memo` and `useMemo` optimizations in child components. Extract these to constants outside the component, or memoize them inside the component.
|
||||
|
||||
**Note**: Since this project uses Tailwind CSS, inline `style={}` props should be extremely rare. Use Tailwind classes with design tokens instead (see code-quality.md). This rule applies primarily to non-style object props like `config`, `data`, `options`, `columns`, etc.
|
||||
|
||||
### Suggested Fix
|
||||
|
||||
```tsx
|
||||
// Wrong -- new config object every render, child re-renders even if data hasn't changed
|
||||
function FlowSettings({ flowId }: { flowId: string }) {
|
||||
return (
|
||||
<SettingsPanel
|
||||
config={{ flowId, autoSave: true, showMinimap: false }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// Right -- constant outside component for static values
|
||||
const DEFAULT_SETTINGS = { autoSave: true, showMinimap: false } as const;
|
||||
|
||||
function FlowSettings({ flowId }: { flowId: string }) {
|
||||
const config = useMemo(
|
||||
() => ({ flowId, ...DEFAULT_SETTINGS }),
|
||||
[flowId],
|
||||
);
|
||||
return <SettingsPanel config={config} />;
|
||||
}
|
||||
|
||||
// Wrong -- new array every render
|
||||
<SelectComponent options={["option1", "option2", "option3"]} />
|
||||
|
||||
// Right -- constant outside component
|
||||
const OPTIONS = ["option1", "option2", "option3"] as const;
|
||||
<SelectComponent options={OPTIONS} />
|
||||
```
|
||||
@@ -0,0 +1,72 @@
|
||||
---
|
||||
name: frontend-query-mutation
|
||||
description: Guide for implementing Langflow frontend query and mutation patterns with Axios and TanStack React Query v5. Trigger when creating or updating API hooks in controllers/API/queries, consuming UseRequestProcessor in components, deciding whether to use useQuery or useMutation, handling conditional queries, cache invalidation, mutation error handling, or migrating legacy API calls to the query hook pattern.
|
||||
---
|
||||
|
||||
# Frontend Query & Mutation
|
||||
|
||||
## Intent
|
||||
|
||||
- Keep API hooks in `controllers/API/queries/{domain}/` as the organized source of truth.
|
||||
- Use `UseRequestProcessor` for consistent retry, invalidation, and error handling.
|
||||
- Use Axios `api` instance for all HTTP calls (never raw `fetch` for REST).
|
||||
- Use `performStreamingRequest()` for streaming operations (build, chat).
|
||||
- Keep cache invalidation in mutation `onSettled` callbacks.
|
||||
|
||||
## Architecture Overview
|
||||
|
||||
Langflow uses **Axios + TanStack React Query v5** for data fetching. There is no oRPC or contract layer. The architecture is:
|
||||
|
||||
```
|
||||
Component
|
||||
-> API hook (controllers/API/queries/{domain}/use-{verb}-{resource}.ts)
|
||||
-> UseRequestProcessor (controllers/API/services/request-processor.ts)
|
||||
-> useQuery / useMutation (TanStack React Query)
|
||||
-> queryFn / mutationFn
|
||||
-> api.get/post/patch/delete (Axios instance from controllers/API/api.tsx)
|
||||
```
|
||||
|
||||
### Key Files
|
||||
|
||||
| File | Purpose |
|
||||
|------|---------|
|
||||
| `controllers/API/api.tsx` | Axios instance, `ApiInterceptor` component, `performStreamingRequest()` |
|
||||
| `controllers/API/services/request-processor.ts` | `UseRequestProcessor` hook wrapping `useQuery`/`useMutation` with retry defaults |
|
||||
| `controllers/API/helpers/constants.ts` | URL constants (`URLs`) and `getURL()` helper |
|
||||
| `controllers/API/queries/{domain}/` | Domain-organized query and mutation hooks |
|
||||
| `types/api/index.ts` | `useQueryFunctionType`, `useMutationFunctionType` type helpers |
|
||||
| `stores/` | Zustand stores for client-side state |
|
||||
|
||||
## Workflow
|
||||
|
||||
1. Identify the change surface.
|
||||
- Read `references/query-patterns.md` for hook structure, naming, URL patterns, and query/mutation call-site shape.
|
||||
- Read `references/runtime-rules.md` for conditional queries, invalidation, error handling, and streaming.
|
||||
- Read both references when a task spans hook creation and runtime behavior.
|
||||
2. Implement the hook following existing patterns.
|
||||
- Use `UseRequestProcessor` for `query()` and `mutate()` wrappers.
|
||||
- Use the `api` Axios instance for all HTTP calls.
|
||||
- Use `getURL()` for constructing API paths.
|
||||
- Type the hook with `useQueryFunctionType` or `useMutationFunctionType`.
|
||||
3. Preserve Langflow conventions.
|
||||
- Query keys are arrays with the hook name: `["useGetFlows"]`.
|
||||
- Mutations invalidate related queries in `onSettled`.
|
||||
- Zustand store updates happen in the query/mutation function, not in components.
|
||||
- `UseRequestProcessor` provides default retry (5 for queries, 3 for mutations) with exponential backoff.
|
||||
|
||||
## Files Commonly Touched
|
||||
|
||||
- `controllers/API/queries/{domain}/use-get-*.ts` (query hooks)
|
||||
- `controllers/API/queries/{domain}/use-post-*.ts` (create mutations)
|
||||
- `controllers/API/queries/{domain}/use-patch-*.ts` (update mutations)
|
||||
- `controllers/API/queries/{domain}/use-delete-*.ts` (delete mutations)
|
||||
- `controllers/API/helpers/constants.ts` (URL constants)
|
||||
- `types/api/index.ts` (API type definitions)
|
||||
- `stores/` (Zustand store updates triggered by queries)
|
||||
|
||||
## References
|
||||
|
||||
- Use `references/query-patterns.md` for hook structure, naming conventions, directory layout, and anti-patterns.
|
||||
- Use `references/runtime-rules.md` for conditional queries, invalidation, `mutate` versus `mutateAsync`, error handling, and streaming requests.
|
||||
|
||||
Treat this skill as the single query and mutation entry point for Langflow frontend work. Keep detailed rules in the reference files instead of duplicating them in project docs.
|
||||
@@ -0,0 +1,472 @@
|
||||
# Query and Mutation Patterns
|
||||
|
||||
## Table of Contents
|
||||
|
||||
- Intent
|
||||
- Directory structure
|
||||
- Query hook structure
|
||||
- Mutation hook structure
|
||||
- URL constants
|
||||
- Query keys
|
||||
- Type signatures
|
||||
- Anti-patterns
|
||||
|
||||
## Intent
|
||||
|
||||
- Keep API hooks in `controllers/API/queries/{domain}/` organized by domain.
|
||||
- Use `UseRequestProcessor` for consistent behavior across all hooks.
|
||||
- Use the shared `api` Axios instance for all REST calls.
|
||||
- Type hooks with `useQueryFunctionType` or `useMutationFunctionType` from `types/api/`.
|
||||
|
||||
## Directory Structure
|
||||
|
||||
```text
|
||||
controllers/API/
|
||||
api.tsx # Axios instance, interceptors, streaming
|
||||
helpers/
|
||||
constants.ts # URL constants and getURL()
|
||||
services/
|
||||
request-processor.ts # UseRequestProcessor hook
|
||||
queries/
|
||||
flows/
|
||||
use-get-flow.ts
|
||||
use-get-refresh-flows-query.ts
|
||||
use-post-add-flow.ts
|
||||
use-delete-delete-flows.ts
|
||||
use-get-download-flows.ts
|
||||
index.ts
|
||||
folders/
|
||||
use-get-folder.ts
|
||||
use-get-folders.ts
|
||||
use-post-folders.ts
|
||||
use-patch-folders.ts
|
||||
use-delete-folders.ts
|
||||
index.ts
|
||||
variables/
|
||||
use-get-global-variables.ts
|
||||
use-post-global-variables.ts
|
||||
use-patch-global-variables.ts
|
||||
index.ts
|
||||
auth/
|
||||
use-get-autologin.ts
|
||||
use-post-login-user.ts
|
||||
use-post-logout.ts
|
||||
use-post-refresh-access.ts
|
||||
index.ts
|
||||
messages/
|
||||
use-get-messages.ts
|
||||
use-get-messages-polling.ts
|
||||
use-delete-messages.ts
|
||||
index.ts
|
||||
```
|
||||
|
||||
### Naming Convention
|
||||
|
||||
- **Queries**: `use-get-{resource}.ts` (e.g., `use-get-flow.ts`, `use-get-global-variables.ts`)
|
||||
- **Create mutations**: `use-post-{resource}.ts` (e.g., `use-post-add-flow.ts`)
|
||||
- **Update mutations**: `use-patch-{resource}.ts` (e.g., `use-patch-global-variables.ts`)
|
||||
- **Delete mutations**: `use-delete-{resource}.ts` (e.g., `use-delete-messages.ts`)
|
||||
- **Other mutations**: `use-{action}-{resource}.ts` (e.g., `use-rename-session.ts`)
|
||||
|
||||
## Query Hook Structure
|
||||
|
||||
Every query hook follows this structure:
|
||||
|
||||
```typescript
|
||||
import type { UseQueryResult } from "@tanstack/react-query"
|
||||
import type { useQueryFunctionType } from "@/types/api"
|
||||
import { api } from "../../api"
|
||||
import { getURL } from "../../helpers/constants"
|
||||
import { UseRequestProcessor } from "../../services/request-processor"
|
||||
|
||||
// 1. Define the response type (or import from types/)
|
||||
interface GlobalVariable {
|
||||
id: string
|
||||
name: string
|
||||
value: string
|
||||
type: string
|
||||
}
|
||||
|
||||
// 2. Export the hook typed with useQueryFunctionType
|
||||
export const useGetGlobalVariables: useQueryFunctionType<
|
||||
undefined,
|
||||
GlobalVariable[]
|
||||
> = (options?) => {
|
||||
// 3. Get query helper from UseRequestProcessor
|
||||
const { query } = UseRequestProcessor()
|
||||
|
||||
// 4. Define the query function using the api Axios instance
|
||||
const getGlobalVariablesFn = async (): Promise<GlobalVariable[]> => {
|
||||
const res = await api.get(`${getURL("VARIABLES")}/`)
|
||||
return res.data
|
||||
}
|
||||
|
||||
// 5. Return the query result
|
||||
const queryResult: UseQueryResult<GlobalVariable[], Error> = query(
|
||||
["useGetGlobalVariables"],
|
||||
getGlobalVariablesFn,
|
||||
{
|
||||
refetchOnWindowFocus: false,
|
||||
...options,
|
||||
},
|
||||
)
|
||||
|
||||
return queryResult
|
||||
}
|
||||
```
|
||||
|
||||
### Query Hook with Parameters
|
||||
|
||||
When a query takes parameters, use the first type argument:
|
||||
|
||||
```typescript
|
||||
export const useGetFolder: useQueryFunctionType<
|
||||
{ id: string },
|
||||
FolderResponse
|
||||
> = (params, options?) => {
|
||||
const { query } = UseRequestProcessor()
|
||||
|
||||
const getFolderFn = async (): Promise<FolderResponse> => {
|
||||
const res = await api.get(`${getURL("FOLDERS")}/${params.id}`)
|
||||
return res.data
|
||||
}
|
||||
|
||||
return query(["useGetFolder", params.id], getFolderFn, {
|
||||
...options,
|
||||
})
|
||||
}
|
||||
```
|
||||
|
||||
### Important: Some "get" Hooks Use Mutation
|
||||
|
||||
In the Langflow codebase, some hooks named `use-get-*` (e.g., `useGetFlow`) are actually **mutation hooks** typed with `useMutationFunctionType`. This happens when the "get" operation is triggered imperatively (on demand) rather than declaratively (on mount/re-render). Check the actual type signature before following the query pattern — if a hook uses `mutate` from `UseRequestProcessor`, follow the Mutation Hook Structure below instead.
|
||||
|
||||
### Query Hook with Store Updates
|
||||
|
||||
Many Langflow queries update Zustand stores as a side effect within the query function:
|
||||
|
||||
```typescript
|
||||
export const useGetGlobalVariables: useQueryFunctionType<
|
||||
undefined,
|
||||
GlobalVariable[]
|
||||
> = (options?) => {
|
||||
const { query } = UseRequestProcessor()
|
||||
|
||||
// Access store setters
|
||||
const setGlobalVariablesEntries = useGlobalVariablesStore(
|
||||
(state) => state.setGlobalVariablesEntries,
|
||||
)
|
||||
|
||||
const getGlobalVariablesFn = async (): Promise<GlobalVariable[]> => {
|
||||
const res = await api.get(`${getURL("VARIABLES")}/`)
|
||||
// Update store as side effect of fetching
|
||||
setGlobalVariablesEntries(res.data.map((entry) => entry.name))
|
||||
return res.data
|
||||
}
|
||||
|
||||
return query(["useGetGlobalVariables"], getGlobalVariablesFn, {
|
||||
refetchOnWindowFocus: false,
|
||||
...options,
|
||||
})
|
||||
}
|
||||
```
|
||||
|
||||
## Mutation Hook Structure
|
||||
|
||||
Every mutation hook follows this structure:
|
||||
|
||||
```typescript
|
||||
import type { UseMutationResult } from "@tanstack/react-query"
|
||||
import type { useMutationFunctionType } from "@/types/api"
|
||||
import { api } from "../../api"
|
||||
import { getURL } from "../../helpers/constants"
|
||||
import { UseRequestProcessor } from "../../services/request-processor"
|
||||
|
||||
// 1. Define the payload type
|
||||
interface PostAddFlowPayload {
|
||||
name: string
|
||||
data: ReactFlowJsonObject
|
||||
description: string
|
||||
folder_id: string
|
||||
}
|
||||
|
||||
// 2. Export the hook typed with useMutationFunctionType
|
||||
export const usePostAddFlow: useMutationFunctionType<
|
||||
undefined, // Params (undefined if none)
|
||||
PostAddFlowPayload // Variables (mutation payload)
|
||||
> = (options?) => {
|
||||
// 3. Get mutate helper and queryClient from UseRequestProcessor
|
||||
const { mutate, queryClient } = UseRequestProcessor()
|
||||
|
||||
// 4. Define the mutation function using the api Axios instance
|
||||
const postAddFlowFn = async (payload: PostAddFlowPayload): Promise<any> => {
|
||||
const response = await api.post(`${getURL("FLOWS")}/`, {
|
||||
name: payload.name,
|
||||
data: payload.data,
|
||||
description: payload.description,
|
||||
folder_id: payload.folder_id || null,
|
||||
})
|
||||
return response.data
|
||||
}
|
||||
|
||||
// 5. Return the mutation with cache invalidation in onSettled
|
||||
// NOTE: Place hook-specific options (onSettled, retry) BEFORE ...options
|
||||
// so consumers can override them if needed. This matches the codebase convention.
|
||||
const mutation: UseMutationResult<any, any, PostAddFlowPayload> = mutate(
|
||||
["usePostAddFlow"],
|
||||
postAddFlowFn,
|
||||
{
|
||||
onSettled: (response) => {
|
||||
if (response) {
|
||||
// Invalidate related queries so they refetch
|
||||
queryClient.refetchQueries({
|
||||
queryKey: ["useGetRefreshFlowsQuery", { get_all: true, header_flows: true }],
|
||||
})
|
||||
queryClient.refetchQueries({
|
||||
queryKey: ["useGetFolder", response.folder_id],
|
||||
})
|
||||
}
|
||||
},
|
||||
...options, // Consumer options come LAST (can override onSettled, retry, etc.)
|
||||
},
|
||||
)
|
||||
|
||||
return mutation
|
||||
}
|
||||
```
|
||||
|
||||
### Delete Mutation Example
|
||||
|
||||
```typescript
|
||||
export const useDeleteMessages: useMutationFunctionType<undefined, string[]> = (
|
||||
options?,
|
||||
) => {
|
||||
const { mutate, queryClient } = UseRequestProcessor()
|
||||
|
||||
const deleteMessagesFn = async (messageIds: string[]): Promise<void> => {
|
||||
await api.delete(`${getURL("MESSAGES")}/`, {
|
||||
data: messageIds,
|
||||
})
|
||||
}
|
||||
|
||||
const mutation = mutate(["useDeleteMessages"], deleteMessagesFn, {
|
||||
onSettled: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["useGetMessages"] })
|
||||
},
|
||||
...options,
|
||||
})
|
||||
|
||||
return mutation
|
||||
}
|
||||
```
|
||||
|
||||
### Patch Mutation Example
|
||||
|
||||
```typescript
|
||||
export const usePatchGlobalVariables: useMutationFunctionType<
|
||||
undefined,
|
||||
{ id: string; name: string; value: string; type: string }
|
||||
> = (options?) => {
|
||||
const { mutate, queryClient } = UseRequestProcessor()
|
||||
|
||||
const patchGlobalVariablesFn = async (payload: {
|
||||
id: string
|
||||
name: string
|
||||
value: string
|
||||
type: string
|
||||
}): Promise<GlobalVariable> => {
|
||||
const response = await api.patch(
|
||||
`${getURL("VARIABLES")}/${payload.id}`,
|
||||
payload,
|
||||
)
|
||||
return response.data
|
||||
}
|
||||
|
||||
const mutation = mutate(["usePatchGlobalVariables"], patchGlobalVariablesFn, {
|
||||
onSettled: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["useGetGlobalVariables"] })
|
||||
},
|
||||
retry: false,
|
||||
...options,
|
||||
})
|
||||
|
||||
return mutation
|
||||
}
|
||||
```
|
||||
|
||||
## URL Constants
|
||||
|
||||
All API paths are defined in `controllers/API/helpers/constants.ts`:
|
||||
|
||||
```typescript
|
||||
export const URLs = {
|
||||
FLOWS: "flows",
|
||||
FOLDERS: "projects",
|
||||
VARIABLES: "variables",
|
||||
MESSAGES: "monitor/messages",
|
||||
BUILDS: "monitor/builds",
|
||||
API_KEY: "api_key",
|
||||
FILES: "files",
|
||||
// ...
|
||||
} as const
|
||||
```
|
||||
|
||||
Use `getURL()` to construct full API paths:
|
||||
|
||||
```typescript
|
||||
import { getURL } from "../../helpers/constants"
|
||||
|
||||
// Basic: /api/v1/flows
|
||||
const url = getURL("FLOWS")
|
||||
|
||||
// With params: /api/v1/flows/{flowId}
|
||||
const url = `${getURL("FLOWS")}/${flowId}`
|
||||
|
||||
// With v2 flag: /api/v2/flows
|
||||
const url = getURL("FLOWS", {}, true)
|
||||
```
|
||||
|
||||
When adding new endpoints, add the constant to `URLs` first, then use `getURL()` in the hook.
|
||||
|
||||
## Query Keys
|
||||
|
||||
Query keys are arrays that uniquely identify cached data. Langflow conventions:
|
||||
|
||||
```typescript
|
||||
// Hook name as key (no params)
|
||||
["useGetGlobalVariables"]
|
||||
|
||||
// Hook name + params for cache isolation
|
||||
["useGetFlow", flowId]
|
||||
["useGetFolder", folderId]
|
||||
|
||||
// Hook name + object params for complex queries
|
||||
["useGetRefreshFlowsQuery", { get_all: true, header_flows: true }]
|
||||
["useGetMessages", { flowId, sessionId, page }]
|
||||
```
|
||||
|
||||
Rules:
|
||||
- The first element is always the hook name as a string.
|
||||
- Additional elements are parameters that differentiate cache entries.
|
||||
- Use objects for multi-parameter queries.
|
||||
- Keep keys consistent so invalidation works correctly.
|
||||
|
||||
## Type Signatures
|
||||
|
||||
### useQueryFunctionType
|
||||
|
||||
Used for query hooks. Defined in `types/api/index.ts`:
|
||||
|
||||
```typescript
|
||||
// Without params: useQueryFunctionType<undefined, ResponseType>
|
||||
// The hook signature becomes: (options?) => UseQueryResult<ResponseType>
|
||||
|
||||
// With params: useQueryFunctionType<ParamsType, ResponseType>
|
||||
// The hook signature becomes: (params, options?) => UseQueryResult<ResponseType>
|
||||
```
|
||||
|
||||
### useMutationFunctionType
|
||||
|
||||
Used for mutation hooks:
|
||||
|
||||
```typescript
|
||||
// Without params: useMutationFunctionType<undefined, VariablesType>
|
||||
// The hook signature becomes: (options?) => UseMutationResult<Data, Error, Variables>
|
||||
|
||||
// With params: useMutationFunctionType<ParamsType, VariablesType>
|
||||
// The hook signature becomes: (params, options?) => UseMutationResult<Data, Error, Variables>
|
||||
```
|
||||
|
||||
## Anti-Patterns
|
||||
|
||||
### Do Not Bypass UseRequestProcessor
|
||||
|
||||
```typescript
|
||||
// Do not call useQuery/useMutation directly
|
||||
const result = useQuery({
|
||||
queryKey: ["flows"],
|
||||
queryFn: () => api.get("/api/v1/flows"),
|
||||
})
|
||||
|
||||
// Use UseRequestProcessor for consistent retry and error handling
|
||||
const { query } = UseRequestProcessor()
|
||||
const result = query(["useGetFlows"], () => api.get(getURL("FLOWS")))
|
||||
```
|
||||
|
||||
### Do Not Bypass the api Instance
|
||||
|
||||
```typescript
|
||||
// Do not use raw axios or fetch for REST calls
|
||||
const result = await axios.get("/api/v1/flows")
|
||||
const result = await fetch("/api/v1/flows")
|
||||
|
||||
// Use the shared api instance (has interceptors for auth, headers, error handling)
|
||||
const result = await api.get(`${getURL("FLOWS")}/`)
|
||||
```
|
||||
|
||||
### Do Not Hardcode URLs
|
||||
|
||||
```typescript
|
||||
// Do not hardcode API paths
|
||||
const result = await api.get("/api/v1/variables/")
|
||||
|
||||
// Use getURL() helper
|
||||
const result = await api.get(`${getURL("VARIABLES")}/`)
|
||||
```
|
||||
|
||||
### Do Not Duplicate Query Keys
|
||||
|
||||
```typescript
|
||||
// Do not use different key strings for the same query
|
||||
query(["getFlows"], fetchFn) // in hook A
|
||||
query(["useGetFlows"], fetchFn) // in hook B
|
||||
query(["flows-list"], fetchFn) // in hook C
|
||||
|
||||
// Use one canonical key matching the hook name
|
||||
query(["useGetFlows"], fetchFn)
|
||||
```
|
||||
|
||||
### Do Not Invalidate from Components
|
||||
|
||||
```typescript
|
||||
// Do not put invalidation logic in components
|
||||
const Component = () => {
|
||||
const queryClient = useQueryClient()
|
||||
const { mutate } = useDeleteFlow()
|
||||
|
||||
const handleDelete = () => {
|
||||
mutate(flowId, {
|
||||
onSuccess: () => {
|
||||
// Avoid: invalidation knowledge leaks into component
|
||||
queryClient.invalidateQueries({ queryKey: ["useGetFlows"] })
|
||||
queryClient.invalidateQueries({ queryKey: ["useGetFolder"] })
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Put invalidation in the mutation hook's onSettled
|
||||
export const useDeleteFlow = (options?) => {
|
||||
const { mutate, queryClient } = UseRequestProcessor()
|
||||
|
||||
return mutate(["useDeleteFlow"], deleteFlowFn, {
|
||||
onSettled: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["useGetFlows"] })
|
||||
queryClient.invalidateQueries({ queryKey: ["useGetFolder"] })
|
||||
},
|
||||
...options, // Consumer options come LAST
|
||||
})
|
||||
}
|
||||
```
|
||||
|
||||
### Do Not Create Thin Wrapper Hooks
|
||||
|
||||
```typescript
|
||||
// Do not create hooks that just re-export an existing query hook
|
||||
const useFlows = () => {
|
||||
return useGetRefreshFlowsQuery({ get_all: true })
|
||||
}
|
||||
|
||||
// Import the existing hook directly where needed
|
||||
import { useGetRefreshFlowsQuery } from "@/controllers/API/queries/flows"
|
||||
```
|
||||
@@ -0,0 +1,415 @@
|
||||
# Runtime Rules
|
||||
|
||||
## Table of Contents
|
||||
|
||||
- Conditional queries
|
||||
- Cache invalidation
|
||||
- Query key conventions
|
||||
- `mutate` vs `mutateAsync`
|
||||
- Error handling
|
||||
- Streaming requests
|
||||
- UseRequestProcessor defaults
|
||||
|
||||
## Conditional Queries
|
||||
|
||||
Use the `enabled` option to conditionally run queries. Langflow hooks pass through `options` to `UseRequestProcessor`, which passes them to `useQuery`.
|
||||
|
||||
```typescript
|
||||
// Pattern: Disable query when not authenticated
|
||||
export const useGetGlobalVariables: useQueryFunctionType<
|
||||
undefined,
|
||||
GlobalVariable[]
|
||||
> = (options?) => {
|
||||
const { query } = UseRequestProcessor()
|
||||
const isAuthenticated = useAuthStore((state) => state.isAuthenticated)
|
||||
|
||||
const getGlobalVariablesFn = async (): Promise<GlobalVariable[]> => {
|
||||
if (!isAuthenticated) return []
|
||||
const res = await api.get(`${getURL("VARIABLES")}/`)
|
||||
return res.data
|
||||
}
|
||||
|
||||
return query(["useGetGlobalVariables"], getGlobalVariablesFn, {
|
||||
refetchOnWindowFocus: false,
|
||||
enabled: isAuthenticated && (options?.enabled ?? true),
|
||||
...options,
|
||||
})
|
||||
}
|
||||
```
|
||||
|
||||
```typescript
|
||||
// Pattern: Disable query when required param is missing
|
||||
export const useGetFlow: useQueryFunctionType<{ id: string }, FlowResponse> = (
|
||||
params,
|
||||
options?,
|
||||
) => {
|
||||
const { query } = UseRequestProcessor()
|
||||
|
||||
const getFlowFn = async (): Promise<FlowResponse> => {
|
||||
const res = await api.get(`${getURL("FLOWS")}/${params.id}`)
|
||||
return res.data
|
||||
}
|
||||
|
||||
return query(["useGetFlow", params.id], getFlowFn, {
|
||||
enabled: !!params.id && (options?.enabled ?? true),
|
||||
...options,
|
||||
})
|
||||
}
|
||||
```
|
||||
|
||||
```typescript
|
||||
// Pattern: Consumer disables query via options
|
||||
const { data: flow } = useGetFlow(
|
||||
{ id: flowId },
|
||||
{ enabled: showFlowDetails },
|
||||
)
|
||||
```
|
||||
|
||||
Rules:
|
||||
- Always check `options?.enabled ?? true` when combining with other conditions, so consumers can also disable the query.
|
||||
- Guard early in the query function when auth or required data might be missing.
|
||||
- Do not use non-null assertions on params to work around missing data; use `enabled` instead.
|
||||
|
||||
## Cache Invalidation
|
||||
|
||||
Bind invalidation in the mutation hook definition. Components should only add UI feedback (toasts, navigation) in call-site callbacks, not decide which queries to invalidate.
|
||||
|
||||
### Invalidation via onSettled
|
||||
|
||||
The `UseRequestProcessor.mutate()` wrapper already calls `queryClient.invalidateQueries({ queryKey: mutationKey })` in its default `onSettled`. For additional invalidation, extend `onSettled`:
|
||||
|
||||
```typescript
|
||||
export const usePostAddFlow: useMutationFunctionType<
|
||||
undefined,
|
||||
PostAddFlowPayload
|
||||
> = (options?) => {
|
||||
const { mutate, queryClient } = UseRequestProcessor()
|
||||
const myCollectionId = useFolderStore((state) => state.myCollectionId)
|
||||
|
||||
const postAddFlowFn = async (payload: PostAddFlowPayload): Promise<any> => {
|
||||
const response = await api.post(`${getURL("FLOWS")}/`, payload)
|
||||
return response.data
|
||||
}
|
||||
|
||||
return mutate(["usePostAddFlow"], postAddFlowFn, {
|
||||
onSettled: (response) => {
|
||||
if (response) {
|
||||
queryClient.refetchQueries({
|
||||
queryKey: ["useGetRefreshFlowsQuery", { get_all: true, header_flows: true }],
|
||||
})
|
||||
queryClient.refetchQueries({
|
||||
queryKey: ["useGetFolder", response.folder_id ?? myCollectionId],
|
||||
})
|
||||
}
|
||||
},
|
||||
...options, // Consumer options come LAST
|
||||
})
|
||||
}
|
||||
```
|
||||
|
||||
### Invalidation Patterns
|
||||
|
||||
```typescript
|
||||
// Broad invalidation: all queries for a domain
|
||||
queryClient.invalidateQueries({ queryKey: ["useGetFlows"] })
|
||||
|
||||
// Specific invalidation: single cache entry
|
||||
queryClient.invalidateQueries({ queryKey: ["useGetFlow", flowId] })
|
||||
|
||||
// Refetch instead of invalidate when you need data immediately
|
||||
queryClient.refetchQueries({ queryKey: ["useGetFolder", folderId] })
|
||||
```
|
||||
|
||||
### Component-side callbacks are for UI only
|
||||
|
||||
```typescript
|
||||
// Component only adds UI behavior
|
||||
const { mutate: addFlow } = usePostAddFlow()
|
||||
|
||||
const handleCreate = () => {
|
||||
addFlow(flowData, {
|
||||
onSuccess: (response) => {
|
||||
// UI-only: navigate, show toast
|
||||
navigate(`/flow/${response.id}`)
|
||||
setSuccessData({ title: "Flow created successfully" })
|
||||
},
|
||||
onError: (error) => {
|
||||
setErrorData({
|
||||
title: "Failed to create flow",
|
||||
list: [error.message],
|
||||
})
|
||||
},
|
||||
})
|
||||
}
|
||||
```
|
||||
|
||||
## Query Key Conventions
|
||||
|
||||
```typescript
|
||||
// Base key: hook name
|
||||
["useGetGlobalVariables"]
|
||||
|
||||
// Parameterized key: hook name + params
|
||||
["useGetFlow", flowId]
|
||||
["useGetFolder", folderId]
|
||||
|
||||
// Complex key: hook name + param object
|
||||
["useGetRefreshFlowsQuery", { get_all: true, header_flows: true }]
|
||||
["useGetMessages", { flowId, sessionId }]
|
||||
["useGetBuilds", { flowId }]
|
||||
|
||||
// Mutation key: hook name (used for automatic invalidation by UseRequestProcessor)
|
||||
["usePostAddFlow"]
|
||||
["useDeleteMessages"]
|
||||
```
|
||||
|
||||
Rules:
|
||||
- First element is always the hook name as a string.
|
||||
- Keep mutation keys matching the hook name.
|
||||
- `UseRequestProcessor.mutate()` auto-invalidates the `mutationKey` on settled.
|
||||
- Additional invalidation targets must be explicitly added in `onSettled`.
|
||||
|
||||
## `mutate` vs `mutateAsync`
|
||||
|
||||
Prefer `mutate` by default. Use `mutateAsync` only when Promise semantics are truly required.
|
||||
|
||||
Rules:
|
||||
- Event handlers should call `mutate(...)` with `onSuccess` or `onError`.
|
||||
- Every `await mutateAsync(...)` must be wrapped in `try/catch`.
|
||||
- Do not use `mutateAsync` when callbacks already express the flow clearly.
|
||||
|
||||
```typescript
|
||||
// Default: use mutate with callbacks
|
||||
const { mutate: deleteFlow } = useDeleteFlow()
|
||||
|
||||
const handleDelete = () => {
|
||||
deleteFlow(flowId, {
|
||||
onSuccess: () => {
|
||||
navigate("/flows")
|
||||
setSuccessData({ title: "Flow deleted" })
|
||||
},
|
||||
onError: (error) => {
|
||||
setErrorData({ title: "Delete failed", list: [error.message] })
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// Exception: Promise semantics needed for sequential operations
|
||||
const handleDuplicateAndOpen = async () => {
|
||||
try {
|
||||
const newFlow = await duplicateFlow.mutateAsync(flowData)
|
||||
await renameFlow.mutateAsync({ id: newFlow.id, name: `${flowData.name} (copy)` })
|
||||
navigate(`/flow/${newFlow.id}`)
|
||||
} catch (error) {
|
||||
setErrorData({
|
||||
title: "Failed to duplicate flow",
|
||||
list: [error instanceof Error ? error.message : "Unknown error"],
|
||||
})
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Error Handling
|
||||
|
||||
### API Interceptor Handles Auth Errors
|
||||
|
||||
The `ApiInterceptor` in `controllers/API/api.tsx` automatically handles:
|
||||
- **401 Unauthorized**: Attempts token refresh via `useRefreshAccessToken`, then retries the request.
|
||||
- **403 Forbidden**: Same auth error flow as 401.
|
||||
- **After 3+ auth errors**: Logs the user out automatically.
|
||||
- **500 errors**: Clears build vertex state in the flow store.
|
||||
|
||||
You do not need to handle 401/403 errors in individual hooks or components.
|
||||
|
||||
### Mutation Error Handling
|
||||
|
||||
Handle errors at the call site for user-facing feedback:
|
||||
|
||||
```typescript
|
||||
const { mutate: saveFlow } = useSaveFlow()
|
||||
|
||||
const handleSave = () => {
|
||||
saveFlow(flowData, {
|
||||
onError: (error) => {
|
||||
setErrorData({
|
||||
title: "Failed to save flow",
|
||||
list: [error.response?.data?.detail ?? error.message],
|
||||
})
|
||||
},
|
||||
})
|
||||
}
|
||||
```
|
||||
|
||||
### Query Error Handling
|
||||
|
||||
For queries, React Query's retry logic (5 retries with exponential backoff via `UseRequestProcessor`) handles transient failures. For permanent errors, use `onError` in options or error boundaries:
|
||||
|
||||
```typescript
|
||||
const { data, error, isError } = useGetFlow(
|
||||
{ id: flowId },
|
||||
{
|
||||
retry: false, // Override default retry for known-missing resources
|
||||
onError: (error) => {
|
||||
if (error.response?.status === 404) {
|
||||
navigate("/flows")
|
||||
}
|
||||
},
|
||||
},
|
||||
)
|
||||
```
|
||||
|
||||
## Streaming Requests
|
||||
|
||||
Langflow uses `performStreamingRequest()` from `controllers/API/api.tsx` for build and chat streaming. This uses the browser `fetch` API (not Axios) with Server-Sent Events (SSE) parsing.
|
||||
|
||||
### Streaming Architecture
|
||||
|
||||
```typescript
|
||||
import { performStreamingRequest } from "@/controllers/API/api"
|
||||
|
||||
const buildController = new AbortController()
|
||||
|
||||
await performStreamingRequest({
|
||||
method: "POST",
|
||||
url: `${baseURL}/api/v1/build/${flowId}/flow`,
|
||||
body: { inputs, files },
|
||||
buildController,
|
||||
onData: async (event) => {
|
||||
// Process individual SSE events
|
||||
// Return true to continue, false to abort
|
||||
return true
|
||||
},
|
||||
onDataBatch: async (events) => {
|
||||
// Process batch of events from a single chunk (more efficient)
|
||||
// Return true to continue, false to abort
|
||||
return true
|
||||
},
|
||||
onError: (statusCode) => {
|
||||
// Handle HTTP error status
|
||||
},
|
||||
onNetworkError: (error) => {
|
||||
// Handle network-level errors
|
||||
},
|
||||
})
|
||||
```
|
||||
|
||||
### Streaming vs REST Decision
|
||||
|
||||
| Operation | Method |
|
||||
|-----------|--------|
|
||||
| Build flow | `performStreamingRequest()` with SSE |
|
||||
| Chat interaction | `performStreamingRequest()` with SSE |
|
||||
| CRUD operations (flows, folders, variables) | Axios `api` instance via query/mutation hooks |
|
||||
| File upload/download | Axios `api` instance |
|
||||
| Auth operations | Axios `api` instance |
|
||||
|
||||
### Aborting Streams
|
||||
|
||||
Use `AbortController` to cancel streaming operations:
|
||||
|
||||
```typescript
|
||||
const buildController = useRef(new AbortController())
|
||||
|
||||
const handleStopBuild = () => {
|
||||
buildController.current.abort()
|
||||
buildController.current = new AbortController()
|
||||
}
|
||||
```
|
||||
|
||||
## UseRequestProcessor Defaults
|
||||
|
||||
The `UseRequestProcessor` hook in `controllers/API/services/request-processor.ts` provides:
|
||||
|
||||
### Query Defaults
|
||||
|
||||
```typescript
|
||||
{
|
||||
retry: 5,
|
||||
retryDelay: (attemptIndex) => Math.min(1000 * 2 ** attemptIndex, 30000),
|
||||
// 1s, 2s, 4s, 8s, 16s (capped at 30s)
|
||||
}
|
||||
```
|
||||
|
||||
### Mutation Defaults
|
||||
|
||||
The actual `mutate()` implementation in `request-processor.ts`:
|
||||
|
||||
```typescript
|
||||
function mutate(mutationKey, mutationFn, options = {}) {
|
||||
return useMutation({
|
||||
mutationKey,
|
||||
mutationFn,
|
||||
onSettled: (data, error, variables, context) => {
|
||||
queryClient.invalidateQueries({ queryKey: mutationKey });
|
||||
options.onSettled && options.onSettled(data, error, variables, context);
|
||||
},
|
||||
...options, // Spreads AFTER the wrapper onSettled
|
||||
retry: options.retry ?? 3, // Comes AFTER the spread
|
||||
retryDelay: (attemptIndex) => Math.min(1000 * 2 ** attemptIndex, 30000),
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
**How onSettled actually works (important subtlety):**
|
||||
|
||||
1. `UseRequestProcessor.mutate()` defines a wrapper `onSettled` that (a) auto-invalidates the `mutationKey`, then (b) calls the hook's `options.onSettled`.
|
||||
2. **However**, the `...options` spread comes AFTER the wrapper, so if the hook's `options` object contains its own `onSettled`, it **overrides** the wrapper. This means the auto-invalidation is effectively **skipped** when hooks provide their own `onSettled`.
|
||||
3. In practice, most hooks DO provide their own `onSettled` with specific refetch/invalidation logic, so the auto-invalidation of the mutation key rarely runs.
|
||||
4. This is fine because invalidating a mutation key is usually unnecessary — mutations don't have cached entries like queries do.
|
||||
|
||||
**Hook convention: place custom `onSettled` BEFORE `...options`:**
|
||||
|
||||
```typescript
|
||||
mutate(["usePostAddFlow"], fn, {
|
||||
onSettled: () => { queryClient.refetchQueries(...) }, // Hook-specific invalidation
|
||||
retry: false, // Override default retry if needed
|
||||
...options, // Consumer options come LAST (can override onSettled, retry, etc.)
|
||||
})
|
||||
```
|
||||
|
||||
### When to Use `retry: false`
|
||||
|
||||
Override the default `retry: 3` with `retry: false` for mutations where retrying would cause problems:
|
||||
|
||||
- **Create operations** that are not idempotent (duplicate creation risk)
|
||||
- **Update operations** on global variables or settings (stale data risk)
|
||||
- **Delete operations** where the resource may already be gone
|
||||
|
||||
```typescript
|
||||
// Example: POST that creates a resource (not safe to retry)
|
||||
const mutation = mutate(["usePostGlobalVariables"], postFn, {
|
||||
onSettled: () => { queryClient.refetchQueries({ queryKey: ["useGetGlobalVariables"] }) },
|
||||
retry: false,
|
||||
...options,
|
||||
})
|
||||
```
|
||||
|
||||
## Polling Patterns
|
||||
|
||||
For queries that need periodic refreshing (build status, messages):
|
||||
|
||||
```typescript
|
||||
export const useGetMessagesPolling: useQueryFunctionType<
|
||||
{ flowId: string; sessionId: string },
|
||||
Message[]
|
||||
> = (params, options?) => {
|
||||
const { query } = UseRequestProcessor()
|
||||
|
||||
const getMessagesFn = async (): Promise<Message[]> => {
|
||||
const res = await api.get(
|
||||
`${getURL("MESSAGES")}/?flow_id=${params.flowId}&session_id=${params.sessionId}`,
|
||||
)
|
||||
return res.data
|
||||
}
|
||||
|
||||
return query(
|
||||
["useGetMessagesPolling", params.flowId, params.sessionId],
|
||||
getMessagesFn,
|
||||
{
|
||||
refetchInterval: 3000, // Poll every 3 seconds
|
||||
refetchIntervalInBackground: false,
|
||||
...options,
|
||||
},
|
||||
)
|
||||
}
|
||||
```
|
||||
@@ -0,0 +1,296 @@
|
||||
# Frontend Testing Skill - Langflow
|
||||
|
||||
## When to Apply
|
||||
|
||||
Activate this skill when:
|
||||
- Writing new unit or integration tests for React components, hooks, utilities, or Zustand stores
|
||||
- Reviewing existing tests for correctness, coverage, or best practices
|
||||
- Improving test coverage for under-tested modules
|
||||
- Debugging flaky or failing tests
|
||||
- Refactoring test code for maintainability
|
||||
|
||||
## Tech Stack
|
||||
|
||||
| Technology | Version | Purpose |
|
||||
|---|---|---|
|
||||
| Jest | 30.x | Test runner and assertion framework |
|
||||
| ts-jest | 29.x | TypeScript transform for Jest |
|
||||
| React Testing Library | 16.x | Component rendering and DOM queries |
|
||||
| @testing-library/user-event | 14.x | Realistic user interaction simulation |
|
||||
| @testing-library/jest-dom | 6.x | Extended DOM matchers |
|
||||
| jsdom | (via jest-environment-jsdom 30.x) | Browser environment simulation |
|
||||
| React | 19.x | UI framework |
|
||||
| TypeScript | 5.4 | Type safety |
|
||||
| Zustand | 4.x | State management |
|
||||
| React Router DOM | 6.x | Client-side routing |
|
||||
| @tanstack/react-query | 5.x | Server state management |
|
||||
| Axios | 1.x | HTTP client |
|
||||
|
||||
## Project Configuration
|
||||
|
||||
- **Jest config**: `src/frontend/jest.config.js`
|
||||
- **Setup files**: `src/frontend/jest.setup.js` (globals/mocks) and `src/frontend/src/setupTests.ts` (DOM matchers, ResizeObserver, IntersectionObserver, matchMedia)
|
||||
- **Path alias**: `@/` maps to `<rootDir>/src/`
|
||||
- **Test match patterns**: `src/**/__tests__/**/*.{test,spec}.{ts,tsx}` and `src/**/*.{test,spec}.{ts,tsx}`
|
||||
- **Transform**: Custom `transform-import-meta.js` handles `import.meta` for Jest compatibility
|
||||
- **Global mocks** (in `jest.setup.js`): `@radix-ui/react-form`, `react-markdown`, `lucide-react/dynamicIconImports`, `@/components/common/genericIconComponent`, `@/icons/BotMessageSquare`, `@/stores/darkStore`, `localStorage`, `sessionStorage`, `crypto`
|
||||
|
||||
## Key Commands
|
||||
|
||||
```bash
|
||||
# Run all tests
|
||||
npm test
|
||||
|
||||
# Run a specific test file
|
||||
npm test -- path/to/file.test.tsx
|
||||
|
||||
# Run tests matching a pattern
|
||||
npm test -- --testPathPattern="alertStore"
|
||||
|
||||
# Run tests in watch mode
|
||||
npm run test:watch
|
||||
|
||||
# Run tests with coverage
|
||||
npm run test:coverage
|
||||
|
||||
# Run a single test file with coverage
|
||||
npm test -- --coverage --collectCoverageFrom='src/path/to/source.ts' path/to/__tests__/source.test.ts
|
||||
```
|
||||
|
||||
## File Naming and Location
|
||||
|
||||
Test files follow one of two patterns:
|
||||
|
||||
1. **Dedicated `__tests__` directory** (preferred for components and modules):
|
||||
```
|
||||
src/components/core/my-component/
|
||||
├── my-component.tsx
|
||||
└── __tests__/
|
||||
└── my-component.test.tsx
|
||||
```
|
||||
|
||||
2. **Co-located test file** (acceptable for utilities and simple modules):
|
||||
```
|
||||
src/utils/
|
||||
├── myUtil.ts
|
||||
└── myUtil.test.ts
|
||||
```
|
||||
|
||||
Naming convention: `ComponentName.test.tsx` for components, `hook-name.test.ts` for hooks, `util-name.test.ts` for utilities.
|
||||
|
||||
**Do NOT use `.spec.tsx`** -- while technically matched, the project convention is `.test.tsx`.
|
||||
|
||||
## Test Structure Template
|
||||
|
||||
```tsx
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import MyComponent from "../MyComponent";
|
||||
|
||||
// Mock dependencies (use jest.mock, NOT vi.mock)
|
||||
jest.mock("@/controllers/API/api", () => ({
|
||||
get: jest.fn(),
|
||||
post: jest.fn(),
|
||||
}));
|
||||
|
||||
describe("MyComponent", () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
describe("rendering", () => {
|
||||
it("should render the component with default props", () => {
|
||||
// Arrange
|
||||
render(<MyComponent />);
|
||||
|
||||
// Act - (none for render test)
|
||||
|
||||
// Assert
|
||||
expect(screen.getByRole("button", { name: /submit/i })).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("user interactions", () => {
|
||||
it("should call onSubmit when the form is submitted", async () => {
|
||||
// Arrange
|
||||
const user = userEvent.setup();
|
||||
const onSubmit = jest.fn();
|
||||
render(<MyComponent onSubmit={onSubmit} />);
|
||||
|
||||
// Act
|
||||
await user.click(screen.getByRole("button", { name: /submit/i }));
|
||||
|
||||
// Assert
|
||||
expect(onSubmit).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
## Incremental Testing Workflow
|
||||
|
||||
When testing a directory with multiple files, follow this order:
|
||||
|
||||
1. **Identify all source files** in the target directory
|
||||
2. **Order by complexity** (simplest first):
|
||||
- Pure utility functions (no React, no side effects)
|
||||
- Constants and configuration objects
|
||||
- Custom hooks (no UI rendering)
|
||||
- Simple presentational components (no state, no side effects)
|
||||
- Stateful components with local state
|
||||
- Components using Zustand stores
|
||||
- Components with API calls or complex async behavior
|
||||
- Integration-level components that compose many children
|
||||
3. **For each file**:
|
||||
a. Read the source file completely
|
||||
b. Identify all exported functions, components, and types
|
||||
c. Write tests covering all branches and edge cases
|
||||
d. Run the tests and fix any failures
|
||||
e. Check coverage and add tests for uncovered lines
|
||||
f. Move to the next file
|
||||
4. **Run full directory coverage** at the end to verify
|
||||
|
||||
## Complexity-Based Test Ordering
|
||||
|
||||
Within a single test file, order test cases from simplest to most complex:
|
||||
|
||||
1. Default rendering / initial state
|
||||
2. Props variations and conditional rendering
|
||||
3. User interactions (clicks, typing, form submission)
|
||||
4. Async operations (API calls, timers)
|
||||
5. Error states and edge cases
|
||||
6. Integration with stores or context
|
||||
7. Cleanup and unmount behavior
|
||||
|
||||
## Core Principles
|
||||
|
||||
### Arrange-Act-Assert (AAA)
|
||||
Every test should have a clear three-phase structure. Use blank lines to separate each phase for readability.
|
||||
|
||||
### Black-Box Testing
|
||||
Test the component from the user's perspective. Query by role, label, text, or `data-testid` -- never by CSS class, internal state variable, or implementation detail.
|
||||
|
||||
### Single Behavior Per Test
|
||||
Each `it()` block should verify exactly one behavior. If you need to write "and" in the test name, split it into two tests.
|
||||
|
||||
### Semantic Test Names
|
||||
Use descriptive names that explain the expected behavior:
|
||||
- Good: `"should disable the submit button when the form is invalid"`
|
||||
- Bad: `"button test"` or `"test 1"`
|
||||
|
||||
Format: `"should [expected behavior] when [condition]"`
|
||||
|
||||
## Required Test Scenarios
|
||||
|
||||
For every component, cover at minimum:
|
||||
|
||||
### Rendering
|
||||
- Default render with no optional props
|
||||
- Render with all optional props provided
|
||||
- Conditional rendering branches (if/else in JSX)
|
||||
|
||||
### Props and State
|
||||
- Each prop variation that changes rendered output
|
||||
- Default prop values
|
||||
- State transitions triggered by user actions
|
||||
|
||||
### User Interactions
|
||||
- Click handlers
|
||||
- Form input and submission
|
||||
- Keyboard navigation (if applicable)
|
||||
- Hover/focus states (if applicable)
|
||||
|
||||
### Challenge Tests (MANDATORY — not optional)
|
||||
|
||||
**Happy path tests alone are NOT enough.** They only confirm the code works when everything is perfect. Real bugs hide in the cracks. You MUST write tests that actively TRY TO BREAK the code:
|
||||
|
||||
**Unexpected inputs:**
|
||||
- `null`, `undefined`, `""`, `[]`, `{}`, `0`, `-1`, `NaN`, `Infinity`
|
||||
- What happens when a required prop is missing?
|
||||
- What happens when data from the API comes back with missing fields?
|
||||
|
||||
**Boundary values:**
|
||||
- Max length strings (paste 10,000 chars in an input)
|
||||
- Exactly at the limit, one past the limit
|
||||
- Zero items, one item, maximum items
|
||||
- First page, last page, out-of-range page
|
||||
|
||||
**Malformed data:**
|
||||
- API returns `{ data: null }` instead of `{ data: [] }`
|
||||
- JSON with extra unexpected fields
|
||||
- Dates in wrong format, numbers as strings
|
||||
|
||||
**Error states:**
|
||||
- Network failure (API rejects with 500)
|
||||
- Authentication expired mid-action (401)
|
||||
- Resource not found (404)
|
||||
- Permission denied (403)
|
||||
- Timeout
|
||||
|
||||
**What should NOT happen:**
|
||||
- Verify that deleting a flow does NOT delete flows from other users
|
||||
- Verify that a read-only user CANNOT trigger write mutations
|
||||
- Verify that XSS payloads in user input are sanitized
|
||||
|
||||
**Rapid/concurrent actions:**
|
||||
- Double-click on submit button
|
||||
- Rapid repeated API calls
|
||||
- Unmount component while async operation is in flight
|
||||
|
||||
**Write tests based on REQUIREMENTS, not on what the source code does.** This is how you catch bugs where the code diverges from expected behavior.
|
||||
|
||||
**When a test fails:** first ask if the CODE is wrong, not the test. Do NOT silently change a failing assertion to match the current code without understanding WHY.
|
||||
|
||||
### Async Behavior
|
||||
- Loading states
|
||||
- Success states
|
||||
- Error states
|
||||
- Timeout/retry behavior
|
||||
|
||||
## Coverage Goals
|
||||
|
||||
Per source file:
|
||||
- **Function coverage**: 100%
|
||||
- **Branch coverage**: > 95%
|
||||
- **Line coverage**: > 95%
|
||||
- **Statement coverage**: > 95%
|
||||
|
||||
Run coverage for a specific file:
|
||||
```bash
|
||||
npm test -- --coverage --collectCoverageFrom='src/path/to/file.ts' src/path/to/__tests__/file.test.ts
|
||||
```
|
||||
|
||||
## Important Rules
|
||||
|
||||
1. **Never use Vitest APIs**: Use `jest.fn()`, `jest.mock()`, `jest.spyOn()`, `jest.mocked()` -- never `vi.*` equivalents.
|
||||
2. **Never mock base UI components** from `@/components/ui/` -- render them as-is.
|
||||
3. **Check `jest.setup.js` before mocking**: Many modules are already globally mocked (darkStore, genericIconComponent, react-markdown, radix-form, etc.). Do not re-mock them.
|
||||
4. **Use `@testing-library/user-event`** over `fireEvent` for user interactions.
|
||||
5. **Wrap state updates in `act()`** when testing Zustand stores or React state changes.
|
||||
6. **Clean up after each test**: Use `beforeEach(() => jest.clearAllMocks())` and `afterEach` for timers.
|
||||
7. **Always write both happy path AND adversarial tests** (null, undefined, empty values, boundary conditions, error states).
|
||||
8. **Minimum coverage: 75%** (target 80%). Below 75% the task is not complete.
|
||||
|
||||
## Forbidden Test Anti-Patterns
|
||||
|
||||
| Pattern | Problem | How to Detect |
|
||||
|---------|---------|---------------|
|
||||
| **The Liar** | Test passes but doesn't verify the behavior it claims to test | Assertions don't match the test name |
|
||||
| **The Mirror** | Test reads source code and asserts exactly what the code does — finds zero bugs | Test would never fail even if logic changes |
|
||||
| **The Giant** | 50+ lines of setup, multiple acts, dozens of assertions | Should be 5+ separate tests |
|
||||
| **The Mockery** | So many mocks that the test only tests the mock setup | Count mocks — if > 3 deep, rethink |
|
||||
| **The Inspector** | Coupled to implementation details, breaks on any refactor | Tests internal state instead of behavior |
|
||||
| **The Chain Gang** | Tests depend on execution order or share mutable state | Tests fail when run in isolation |
|
||||
| **The Flaky** | Sometimes passes, sometimes fails with no code changes | Non-deterministic assertions or timing issues |
|
||||
|
||||
## References
|
||||
|
||||
- [Incremental Testing Workflow](references/workflow.md) - Step-by-step process for testing directories
|
||||
- [Mocking Guide](references/mocking.md) - Patterns for mocking APIs, stores, routers, and context
|
||||
- [Async Testing](references/async-testing.md) - Patterns for async operations, timers, and waitFor
|
||||
- [Common Patterns](references/common-patterns.md) - Query priority, events, forms, modals, data-driven tests
|
||||
- [Test Checklist](references/checklist.md) - Pre-submission verification checklist
|
||||
- [Domain Components](references/domain-components.md) - Langflow-specific component testing patterns
|
||||
- [Component Template](assets/component-test.template.tsx) - Starter template for component tests
|
||||
- [Hook Template](assets/hook-test.template.ts) - Starter template for hook tests
|
||||
- [Utility Template](assets/utility-test.template.ts) - Starter template for utility tests
|
||||
@@ -0,0 +1,203 @@
|
||||
/**
|
||||
* Test template for React components in Langflow.
|
||||
*
|
||||
* Usage:
|
||||
* 1. Copy this file to `__tests__/ComponentName.test.tsx`
|
||||
* 2. Replace all TEMPLATE_* placeholders with actual values
|
||||
* 3. Remove unused sections
|
||||
* 4. Run: npm test -- path/to/__tests__/ComponentName.test.tsx
|
||||
*/
|
||||
|
||||
import { render, screen, waitFor } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
// Replace with actual component import path
|
||||
import TEMPLATE_COMPONENT from "../TEMPLATE_COMPONENT";
|
||||
|
||||
// ============================================================
|
||||
// Mocks
|
||||
// ============================================================
|
||||
|
||||
// Mock API calls (if component fetches data)
|
||||
// jest.mock("@/controllers/API/api", () => ({
|
||||
// __esModule: true,
|
||||
// default: {
|
||||
// get: jest.fn(),
|
||||
// post: jest.fn(),
|
||||
// },
|
||||
// }));
|
||||
|
||||
// Mock Zustand stores (if component reads from stores)
|
||||
// jest.mock("@/stores/flowStore", () => ({
|
||||
// __esModule: true,
|
||||
// default: (selector?: (state: any) => any) =>
|
||||
// selector ? selector({ nodes: [], edges: [] }) : {},
|
||||
// }));
|
||||
|
||||
// Mock child components (only if they have complex dependencies)
|
||||
// jest.mock("../ChildComponent", () => ({
|
||||
// __esModule: true,
|
||||
// default: (props: any) => <div data-testid="mock-child">{props.label}</div>,
|
||||
// }));
|
||||
|
||||
// ============================================================
|
||||
// Test Data
|
||||
// ============================================================
|
||||
|
||||
const defaultProps = {
|
||||
// Replace with actual default props for the component
|
||||
// title: "Test Title",
|
||||
// onClick: jest.fn(),
|
||||
};
|
||||
|
||||
// ============================================================
|
||||
// Helper Functions
|
||||
// ============================================================
|
||||
|
||||
function renderComponent(overrides = {}) {
|
||||
const props = { ...defaultProps, ...overrides };
|
||||
return render(<TEMPLATE_COMPONENT {...props} />);
|
||||
}
|
||||
|
||||
// Use this if the component requires providers (router, query client, context)
|
||||
// function renderWithProviders(overrides = {}) {
|
||||
// const props = { ...defaultProps, ...overrides };
|
||||
// const queryClient = new QueryClient({
|
||||
// defaultOptions: { queries: { retry: false } },
|
||||
// });
|
||||
// return render(
|
||||
// <QueryClientProvider client={queryClient}>
|
||||
// <MemoryRouter>
|
||||
// <TEMPLATE_COMPONENT {...props} />
|
||||
// </MemoryRouter>
|
||||
// </QueryClientProvider>,
|
||||
// );
|
||||
// }
|
||||
|
||||
// ============================================================
|
||||
// Tests
|
||||
// ============================================================
|
||||
|
||||
describe("TEMPLATE_COMPONENT", () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
// ----------------------------------------------------------
|
||||
// Rendering
|
||||
// ----------------------------------------------------------
|
||||
|
||||
describe("rendering", () => {
|
||||
it("should render with default props", () => {
|
||||
renderComponent();
|
||||
|
||||
// Replace with actual assertions
|
||||
// expect(screen.getByRole("button")).toBeInTheDocument();
|
||||
// expect(screen.getByText("Test Title")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should render with all optional props", () => {
|
||||
renderComponent({
|
||||
// Provide all optional props
|
||||
});
|
||||
|
||||
// Assert all optional elements are rendered
|
||||
});
|
||||
|
||||
it("should apply conditional rendering based on props", () => {
|
||||
// Test each conditional branch in the JSX
|
||||
});
|
||||
});
|
||||
|
||||
// ----------------------------------------------------------
|
||||
// User Interactions
|
||||
// ----------------------------------------------------------
|
||||
|
||||
describe("user interactions", () => {
|
||||
it("should handle click events", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onClick = jest.fn();
|
||||
renderComponent({ onClick });
|
||||
|
||||
await user.click(screen.getByRole("button"));
|
||||
|
||||
expect(onClick).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("should handle text input", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onChange = jest.fn();
|
||||
renderComponent({ onChange });
|
||||
|
||||
await user.type(screen.getByRole("textbox"), "test input");
|
||||
|
||||
expect(onChange).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
// ----------------------------------------------------------
|
||||
// Async Behavior
|
||||
// ----------------------------------------------------------
|
||||
|
||||
// describe("async behavior", () => {
|
||||
// it("should show loading state initially", () => {
|
||||
// renderComponent();
|
||||
// expect(screen.getByTestId("loading-spinner")).toBeInTheDocument();
|
||||
// });
|
||||
//
|
||||
// it("should display data after loading", async () => {
|
||||
// jest.mocked(api.get).mockResolvedValueOnce({
|
||||
// data: { items: [{ id: "1", name: "Test" }] },
|
||||
// });
|
||||
//
|
||||
// renderComponent();
|
||||
//
|
||||
// await waitFor(() => {
|
||||
// expect(screen.getByText("Test")).toBeInTheDocument();
|
||||
// });
|
||||
// });
|
||||
//
|
||||
// it("should handle API errors gracefully", async () => {
|
||||
// jest.mocked(api.get).mockRejectedValueOnce(new Error("Network error"));
|
||||
//
|
||||
// renderComponent();
|
||||
//
|
||||
// await waitFor(() => {
|
||||
// expect(screen.getByText(/error/i)).toBeInTheDocument();
|
||||
// });
|
||||
// });
|
||||
// });
|
||||
|
||||
// ----------------------------------------------------------
|
||||
// Edge Cases
|
||||
// ----------------------------------------------------------
|
||||
|
||||
describe("edge cases", () => {
|
||||
it("should handle empty data", () => {
|
||||
renderComponent({
|
||||
// Pass empty arrays, empty strings, etc.
|
||||
});
|
||||
|
||||
// Assert empty state rendering
|
||||
});
|
||||
|
||||
it("should handle undefined optional props", () => {
|
||||
renderComponent({
|
||||
// Explicitly pass undefined for optional props
|
||||
});
|
||||
|
||||
// Assert fallback behavior
|
||||
});
|
||||
});
|
||||
|
||||
// ----------------------------------------------------------
|
||||
// Cleanup
|
||||
// ----------------------------------------------------------
|
||||
|
||||
// describe("cleanup", () => {
|
||||
// it("should clean up on unmount", () => {
|
||||
// const { unmount } = renderComponent();
|
||||
// unmount();
|
||||
// // Assert cleanup (e.g., clearInterval called, event listeners removed)
|
||||
// });
|
||||
// });
|
||||
});
|
||||
@@ -0,0 +1,240 @@
|
||||
/**
|
||||
* Test template for custom React hooks in Langflow.
|
||||
*
|
||||
* Usage:
|
||||
* 1. Copy this file to `__tests__/use-hook-name.test.ts`
|
||||
* 2. Replace all TEMPLATE_* placeholders with actual values
|
||||
* 3. Remove unused sections
|
||||
* 4. Run: npm test -- path/to/__tests__/use-hook-name.test.ts
|
||||
*/
|
||||
|
||||
import { act, renderHook, waitFor } from "@testing-library/react";
|
||||
// Replace with actual hook import path
|
||||
import { TEMPLATE_HOOK } from "../TEMPLATE_HOOK";
|
||||
|
||||
// ============================================================
|
||||
// Mocks
|
||||
// ============================================================
|
||||
|
||||
// Mock dependencies (if the hook calls APIs, reads stores, etc.)
|
||||
// jest.mock("@/controllers/API/api", () => ({
|
||||
// __esModule: true,
|
||||
// default: {
|
||||
// get: jest.fn(),
|
||||
// post: jest.fn(),
|
||||
// },
|
||||
// }));
|
||||
|
||||
// Mock Zustand store (if the hook reads from a store)
|
||||
// import useMyStore from "@/stores/myStore";
|
||||
// beforeEach(() => {
|
||||
// useMyStore.setState({ items: [], loading: false });
|
||||
// });
|
||||
|
||||
// ============================================================
|
||||
// Tests
|
||||
// ============================================================
|
||||
|
||||
describe("TEMPLATE_HOOK", () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
// ----------------------------------------------------------
|
||||
// Initial State
|
||||
// ----------------------------------------------------------
|
||||
|
||||
describe("initial state", () => {
|
||||
it("should return the correct initial value", () => {
|
||||
const { result } = renderHook(() => TEMPLATE_HOOK());
|
||||
|
||||
// Replace with actual initial state assertions
|
||||
// expect(result.current.value).toBe(initialValue);
|
||||
// expect(result.current.loading).toBe(false);
|
||||
// expect(result.current.error).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
// ----------------------------------------------------------
|
||||
// State Updates
|
||||
// ----------------------------------------------------------
|
||||
|
||||
describe("state updates", () => {
|
||||
it("should update state when action is called", () => {
|
||||
const { result } = renderHook(() => TEMPLATE_HOOK());
|
||||
|
||||
act(() => {
|
||||
// Call the hook's update function
|
||||
// result.current.setValue("new value");
|
||||
});
|
||||
|
||||
// Assert the new state
|
||||
// expect(result.current.value).toBe("new value");
|
||||
});
|
||||
|
||||
it("should handle multiple sequential updates", () => {
|
||||
const { result } = renderHook(() => TEMPLATE_HOOK());
|
||||
|
||||
act(() => {
|
||||
// result.current.increment();
|
||||
// result.current.increment();
|
||||
// result.current.increment();
|
||||
});
|
||||
|
||||
// expect(result.current.count).toBe(3);
|
||||
});
|
||||
});
|
||||
|
||||
// ----------------------------------------------------------
|
||||
// Parameters
|
||||
// ----------------------------------------------------------
|
||||
|
||||
describe("parameters", () => {
|
||||
it("should accept and use initial parameters", () => {
|
||||
const { result } = renderHook(() =>
|
||||
TEMPLATE_HOOK(/* initial params */),
|
||||
);
|
||||
|
||||
// Assert hook uses the provided parameters
|
||||
});
|
||||
|
||||
it("should react to parameter changes on rerender", () => {
|
||||
const { result, rerender } = renderHook(
|
||||
({ param }) => TEMPLATE_HOOK(param),
|
||||
{ initialProps: { param: "initial" } },
|
||||
);
|
||||
|
||||
// Assert initial state
|
||||
// expect(result.current.value).toBe("initial");
|
||||
|
||||
// Rerender with new parameter
|
||||
rerender({ param: "updated" });
|
||||
|
||||
// Assert updated state
|
||||
// expect(result.current.value).toBe("updated");
|
||||
});
|
||||
});
|
||||
|
||||
// ----------------------------------------------------------
|
||||
// Async Behavior
|
||||
// ----------------------------------------------------------
|
||||
|
||||
// describe("async behavior", () => {
|
||||
// it("should fetch data on mount", async () => {
|
||||
// jest.mocked(api.get).mockResolvedValueOnce({
|
||||
// data: { items: [{ id: "1" }] },
|
||||
// });
|
||||
//
|
||||
// const { result } = renderHook(() => TEMPLATE_HOOK());
|
||||
//
|
||||
// // Initially loading
|
||||
// expect(result.current.loading).toBe(true);
|
||||
//
|
||||
// // Wait for data
|
||||
// await waitFor(() => {
|
||||
// expect(result.current.loading).toBe(false);
|
||||
// });
|
||||
//
|
||||
// expect(result.current.data).toEqual([{ id: "1" }]);
|
||||
// });
|
||||
//
|
||||
// it("should handle fetch errors", async () => {
|
||||
// jest.mocked(api.get).mockRejectedValueOnce(new Error("Failed"));
|
||||
//
|
||||
// const { result } = renderHook(() => TEMPLATE_HOOK());
|
||||
//
|
||||
// await waitFor(() => {
|
||||
// expect(result.current.error).toBeTruthy();
|
||||
// });
|
||||
// });
|
||||
// });
|
||||
|
||||
// ----------------------------------------------------------
|
||||
// Debounced/Throttled Behavior
|
||||
// ----------------------------------------------------------
|
||||
|
||||
// describe("debounced behavior", () => {
|
||||
// beforeEach(() => {
|
||||
// jest.useFakeTimers();
|
||||
// });
|
||||
//
|
||||
// afterEach(() => {
|
||||
// jest.runOnlyPendingTimers();
|
||||
// jest.useRealTimers();
|
||||
// });
|
||||
//
|
||||
// it("should debounce the callback", () => {
|
||||
// const callback = jest.fn();
|
||||
// const { result } = renderHook(() => TEMPLATE_HOOK(callback, 500));
|
||||
//
|
||||
// result.current("arg1");
|
||||
// result.current("arg2");
|
||||
// result.current("arg3");
|
||||
//
|
||||
// expect(callback).not.toHaveBeenCalled();
|
||||
//
|
||||
// jest.advanceTimersByTime(500);
|
||||
//
|
||||
// expect(callback).toHaveBeenCalledTimes(1);
|
||||
// expect(callback).toHaveBeenCalledWith("arg3");
|
||||
// });
|
||||
// });
|
||||
|
||||
// ----------------------------------------------------------
|
||||
// Memoization
|
||||
// ----------------------------------------------------------
|
||||
|
||||
describe("memoization", () => {
|
||||
it("should return stable reference when dependencies do not change", () => {
|
||||
const { result, rerender } = renderHook(() => TEMPLATE_HOOK());
|
||||
|
||||
const firstResult = result.current;
|
||||
rerender();
|
||||
const secondResult = result.current;
|
||||
|
||||
// For functions/objects that should be memoized:
|
||||
// expect(firstResult.handler).toBe(secondResult.handler);
|
||||
});
|
||||
});
|
||||
|
||||
// ----------------------------------------------------------
|
||||
// Edge Cases
|
||||
// ----------------------------------------------------------
|
||||
|
||||
describe("edge cases", () => {
|
||||
it("should handle empty input", () => {
|
||||
const { result } = renderHook(() => TEMPLATE_HOOK(/* empty/null */));
|
||||
|
||||
// Assert graceful handling
|
||||
});
|
||||
|
||||
it("should handle rapid calls", () => {
|
||||
const { result } = renderHook(() => TEMPLATE_HOOK());
|
||||
|
||||
act(() => {
|
||||
for (let i = 0; i < 100; i++) {
|
||||
// result.current.action();
|
||||
}
|
||||
});
|
||||
|
||||
// Assert correct final state
|
||||
});
|
||||
});
|
||||
|
||||
// ----------------------------------------------------------
|
||||
// Cleanup
|
||||
// ----------------------------------------------------------
|
||||
|
||||
// describe("cleanup", () => {
|
||||
// it("should clean up resources on unmount", () => {
|
||||
// const removeListenerSpy = jest.fn();
|
||||
// jest.spyOn(window, "addEventListener").mockImplementation(() => {});
|
||||
// jest.spyOn(window, "removeEventListener").mockImplementation(removeListenerSpy);
|
||||
//
|
||||
// const { unmount } = renderHook(() => TEMPLATE_HOOK());
|
||||
// unmount();
|
||||
//
|
||||
// expect(removeListenerSpy).toHaveBeenCalled();
|
||||
// });
|
||||
// });
|
||||
});
|
||||
@@ -0,0 +1,185 @@
|
||||
/**
|
||||
* Test template for utility/helper functions in Langflow.
|
||||
*
|
||||
* Usage:
|
||||
* 1. Copy this file to `__tests__/util-name.test.ts`
|
||||
* 2. Replace all TEMPLATE_* placeholders with actual values
|
||||
* 3. Remove unused sections
|
||||
* 4. Run: npm test -- path/to/__tests__/util-name.test.ts
|
||||
*/
|
||||
|
||||
// Replace with actual utility import path
|
||||
import {
|
||||
TEMPLATE_FUNCTION_A,
|
||||
TEMPLATE_FUNCTION_B,
|
||||
} from "../TEMPLATE_UTIL_FILE";
|
||||
|
||||
// ============================================================
|
||||
// Mocks (only if the utility has side effects or external deps)
|
||||
// ============================================================
|
||||
|
||||
// jest.mock("@/controllers/API/api", () => ({
|
||||
// __esModule: true,
|
||||
// default: { get: jest.fn(), post: jest.fn() },
|
||||
// }));
|
||||
|
||||
// ============================================================
|
||||
// Test Data
|
||||
// ============================================================
|
||||
|
||||
// Define reusable test fixtures
|
||||
// const VALID_INPUT = { id: "1", name: "Test" };
|
||||
// const EMPTY_INPUT = {};
|
||||
// const INVALID_INPUT = null;
|
||||
|
||||
// ============================================================
|
||||
// Tests
|
||||
// ============================================================
|
||||
|
||||
describe("TEMPLATE_FUNCTION_A", () => {
|
||||
// ----------------------------------------------------------
|
||||
// Normal Cases
|
||||
// ----------------------------------------------------------
|
||||
|
||||
describe("normal cases", () => {
|
||||
it("should return expected output for standard input", () => {
|
||||
// const result = TEMPLATE_FUNCTION_A(VALID_INPUT);
|
||||
// expect(result).toEqual(expectedOutput);
|
||||
});
|
||||
|
||||
it("should handle multiple valid inputs", () => {
|
||||
// Test with different valid inputs
|
||||
});
|
||||
});
|
||||
|
||||
// ----------------------------------------------------------
|
||||
// Data-Driven Tests
|
||||
// ----------------------------------------------------------
|
||||
|
||||
// Use it.each for functions with many input/output combinations
|
||||
// it.each([
|
||||
// { input: "hello", expected: "HELLO", description: "lowercase string" },
|
||||
// { input: "Hello", expected: "HELLO", description: "mixed case string" },
|
||||
// { input: "", expected: "", description: "empty string" },
|
||||
// { input: "HELLO", expected: "HELLO", description: "already uppercase" },
|
||||
// ])("should return $expected for $description", ({ input, expected }) => {
|
||||
// expect(TEMPLATE_FUNCTION_A(input)).toBe(expected);
|
||||
// });
|
||||
|
||||
// Alternatively, use array format for simpler cases:
|
||||
// it.each([
|
||||
// [0, "0s"],
|
||||
// [500, "0.5s"],
|
||||
// [1000, "1.0s"],
|
||||
// [60000, "1m 0s"],
|
||||
// ])("should format %i ms as %s", (input, expected) => {
|
||||
// expect(TEMPLATE_FUNCTION_A(input)).toBe(expected);
|
||||
// });
|
||||
|
||||
// ----------------------------------------------------------
|
||||
// Edge Cases
|
||||
// ----------------------------------------------------------
|
||||
|
||||
describe("edge cases", () => {
|
||||
it("should handle empty input", () => {
|
||||
// const result = TEMPLATE_FUNCTION_A("");
|
||||
// expect(result).toBe(expectedDefault);
|
||||
});
|
||||
|
||||
it("should handle null or undefined", () => {
|
||||
// expect(TEMPLATE_FUNCTION_A(null)).toBe(fallbackValue);
|
||||
// expect(TEMPLATE_FUNCTION_A(undefined)).toBe(fallbackValue);
|
||||
});
|
||||
|
||||
it("should handle boundary values", () => {
|
||||
// Test with minimum/maximum values
|
||||
// expect(TEMPLATE_FUNCTION_A(0)).toBe(expectedForZero);
|
||||
// expect(TEMPLATE_FUNCTION_A(Number.MAX_SAFE_INTEGER)).toBe(expectedForMax);
|
||||
});
|
||||
|
||||
it("should handle special characters", () => {
|
||||
// Test with unicode, emojis, special chars if applicable
|
||||
});
|
||||
});
|
||||
|
||||
// ----------------------------------------------------------
|
||||
// Error Cases
|
||||
// ----------------------------------------------------------
|
||||
|
||||
describe("error cases", () => {
|
||||
it("should throw for invalid input", () => {
|
||||
// expect(() => TEMPLATE_FUNCTION_A(invalidInput)).toThrow("Expected error message");
|
||||
});
|
||||
|
||||
it("should return fallback for malformed data", () => {
|
||||
// const result = TEMPLATE_FUNCTION_A(malformedData);
|
||||
// expect(result).toBe(fallbackValue);
|
||||
});
|
||||
});
|
||||
|
||||
// ----------------------------------------------------------
|
||||
// Type-Specific Tests
|
||||
// ----------------------------------------------------------
|
||||
|
||||
// describe("with arrays", () => {
|
||||
// it("should handle empty array", () => {
|
||||
// expect(TEMPLATE_FUNCTION_A([])).toEqual([]);
|
||||
// });
|
||||
//
|
||||
// it("should handle single element", () => {
|
||||
// expect(TEMPLATE_FUNCTION_A([1])).toEqual([expectedSingle]);
|
||||
// });
|
||||
//
|
||||
// it("should preserve order", () => {
|
||||
// const input = [3, 1, 2];
|
||||
// const result = TEMPLATE_FUNCTION_A(input);
|
||||
// expect(result).toEqual([expectedOrdered]);
|
||||
// });
|
||||
//
|
||||
// it("should not mutate the original array", () => {
|
||||
// const input = [1, 2, 3];
|
||||
// const inputCopy = [...input];
|
||||
// TEMPLATE_FUNCTION_A(input);
|
||||
// expect(input).toEqual(inputCopy);
|
||||
// });
|
||||
// });
|
||||
|
||||
// describe("with objects", () => {
|
||||
// it("should handle nested objects", () => {
|
||||
// const input = { a: { b: { c: "deep" } } };
|
||||
// expect(TEMPLATE_FUNCTION_A(input)).toBe(expectedDeep);
|
||||
// });
|
||||
//
|
||||
// it("should not mutate the original object", () => {
|
||||
// const input = { key: "value" };
|
||||
// const inputCopy = { ...input };
|
||||
// TEMPLATE_FUNCTION_A(input);
|
||||
// expect(input).toEqual(inputCopy);
|
||||
// });
|
||||
// });
|
||||
});
|
||||
|
||||
// ============================================================
|
||||
// Second Function (if the file exports multiple functions)
|
||||
// ============================================================
|
||||
|
||||
describe("TEMPLATE_FUNCTION_B", () => {
|
||||
it("should return expected output for standard input", () => {
|
||||
// const result = TEMPLATE_FUNCTION_B(input);
|
||||
// expect(result).toBe(expected);
|
||||
});
|
||||
|
||||
// Add more tests following the same pattern as above
|
||||
});
|
||||
|
||||
// ============================================================
|
||||
// Integration Tests (if functions work together)
|
||||
// ============================================================
|
||||
|
||||
// describe("integration", () => {
|
||||
// it("should compose TEMPLATE_FUNCTION_A and TEMPLATE_FUNCTION_B correctly", () => {
|
||||
// const intermediate = TEMPLATE_FUNCTION_A(rawInput);
|
||||
// const result = TEMPLATE_FUNCTION_B(intermediate);
|
||||
// expect(result).toBe(expectedFinalOutput);
|
||||
// });
|
||||
// });
|
||||
@@ -0,0 +1,347 @@
|
||||
# Async Testing Patterns
|
||||
|
||||
Patterns for testing asynchronous behavior in Langflow with Jest and React Testing Library.
|
||||
|
||||
## waitFor
|
||||
|
||||
Use `waitFor` when you need to wait for an asynchronous operation to complete before making assertions.
|
||||
|
||||
```typescript
|
||||
import { render, screen, waitFor } from "@testing-library/react";
|
||||
|
||||
it("should load and display data", async () => {
|
||||
jest.mocked(api.get).mockResolvedValueOnce({
|
||||
data: { items: [{ id: "1", name: "Test Item" }] },
|
||||
});
|
||||
|
||||
render(<ItemList />);
|
||||
|
||||
// Wait for the async data to appear
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Test Item")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
### waitFor Options
|
||||
|
||||
```typescript
|
||||
await waitFor(
|
||||
() => {
|
||||
expect(screen.getByText("loaded")).toBeInTheDocument();
|
||||
},
|
||||
{
|
||||
timeout: 3000, // Max time to wait (default: 1000ms)
|
||||
interval: 50, // Polling interval (default: 50ms)
|
||||
},
|
||||
);
|
||||
```
|
||||
|
||||
### waitFor Best Practices
|
||||
|
||||
- Put only ONE assertion inside `waitFor`. Multiple assertions can cause misleading failures.
|
||||
- Use `waitFor` for appearance, not disappearance. Use `waitForElementToBeRemoved` for that.
|
||||
- Do not use `waitFor` for synchronous operations -- it adds unnecessary delay.
|
||||
|
||||
```typescript
|
||||
// GOOD: Single assertion in waitFor
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Data loaded")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// BAD: Multiple assertions -- if second fails, first may have been true
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Data loaded")).toBeInTheDocument();
|
||||
expect(screen.getByText("5 items")).toBeInTheDocument(); // unreliable
|
||||
});
|
||||
|
||||
// GOOD: Chain waitFor calls
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Data loaded")).toBeInTheDocument();
|
||||
});
|
||||
expect(screen.getByText("5 items")).toBeInTheDocument();
|
||||
```
|
||||
|
||||
## waitForElementToBeRemoved
|
||||
|
||||
```typescript
|
||||
it("should hide loading spinner after data loads", async () => {
|
||||
jest.mocked(api.get).mockResolvedValueOnce({ data: [] });
|
||||
|
||||
render(<ItemList />);
|
||||
|
||||
// Spinner appears immediately
|
||||
expect(screen.getByTestId("loading-spinner")).toBeInTheDocument();
|
||||
|
||||
// Wait for it to disappear
|
||||
await waitForElementToBeRemoved(() =>
|
||||
screen.queryByTestId("loading-spinner"),
|
||||
);
|
||||
|
||||
// Now assert the loaded state
|
||||
expect(screen.getByText("No items found")).toBeInTheDocument();
|
||||
});
|
||||
```
|
||||
|
||||
## findBy Queries
|
||||
|
||||
`findBy*` queries return a promise that resolves when the element appears. They are shorthand for `waitFor` + `getBy*`.
|
||||
|
||||
```typescript
|
||||
it("should display async content", async () => {
|
||||
jest.mocked(api.get).mockResolvedValueOnce({ data: { name: "Test" } });
|
||||
|
||||
render(<AsyncComponent />);
|
||||
|
||||
// findByText = waitFor(() => getByText(...))
|
||||
const element = await screen.findByText("Test");
|
||||
expect(element).toBeInTheDocument();
|
||||
});
|
||||
```
|
||||
|
||||
### findBy with Custom Timeout
|
||||
|
||||
```typescript
|
||||
const element = await screen.findByText("Slow content", {}, { timeout: 5000 });
|
||||
```
|
||||
|
||||
## Fake Timers
|
||||
|
||||
Use fake timers for components that use `setTimeout`, `setInterval`, or `Date.now`.
|
||||
|
||||
```typescript
|
||||
describe("TimerComponent", () => {
|
||||
beforeEach(() => {
|
||||
jest.useFakeTimers();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.runOnlyPendingTimers();
|
||||
jest.useRealTimers();
|
||||
});
|
||||
|
||||
it("should update after interval", () => {
|
||||
render(<TimerComponent interval={1000} />);
|
||||
|
||||
expect(screen.getByText("0 seconds")).toBeInTheDocument();
|
||||
|
||||
act(() => {
|
||||
jest.advanceTimersByTime(3000);
|
||||
});
|
||||
|
||||
expect(screen.getByText("3 seconds")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should clean up interval on unmount", () => {
|
||||
const clearIntervalSpy = jest.spyOn(global, "clearInterval");
|
||||
|
||||
const { unmount } = render(<TimerComponent interval={1000} />);
|
||||
unmount();
|
||||
|
||||
expect(clearIntervalSpy).toHaveBeenCalled();
|
||||
clearIntervalSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
### Combining Fake Timers with Async Operations
|
||||
|
||||
When a component uses both timers and async operations (API calls), you need special handling:
|
||||
|
||||
```typescript
|
||||
it("should poll for updates", async () => {
|
||||
jest.useFakeTimers();
|
||||
|
||||
const mockGet = jest.mocked(api.get);
|
||||
mockGet
|
||||
.mockResolvedValueOnce({ data: { status: "pending" } })
|
||||
.mockResolvedValueOnce({ data: { status: "complete" } });
|
||||
|
||||
render(<PollingComponent />);
|
||||
|
||||
// First fetch happens immediately
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("pending")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Advance past the polling interval
|
||||
act(() => {
|
||||
jest.advanceTimersByTime(5000);
|
||||
});
|
||||
|
||||
// Second fetch resolves
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("complete")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
jest.useRealTimers();
|
||||
});
|
||||
```
|
||||
|
||||
### Mocking Date.now
|
||||
|
||||
For components that use `Date.now()` for elapsed time:
|
||||
|
||||
```typescript
|
||||
it("should track elapsed time", async () => {
|
||||
const realDateNow = Date.now;
|
||||
let mockTime = 1000000;
|
||||
Date.now = jest.fn(() => mockTime);
|
||||
|
||||
jest.useFakeTimers();
|
||||
|
||||
render(<ElapsedTimer />);
|
||||
|
||||
// Advance mock time by 2 seconds
|
||||
mockTime += 2000;
|
||||
act(() => {
|
||||
jest.advanceTimersByTime(100); // Trigger interval callback
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("2.0s")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
Date.now = realDateNow;
|
||||
jest.useRealTimers();
|
||||
});
|
||||
```
|
||||
|
||||
## Testing Promise Rejection
|
||||
|
||||
```typescript
|
||||
it("should display error message on API failure", async () => {
|
||||
jest.mocked(api.get).mockRejectedValueOnce(new Error("Server error"));
|
||||
|
||||
render(<DataComponent />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/server error/i)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
## Testing Debounced Functions
|
||||
|
||||
```typescript
|
||||
it("should debounce search input", async () => {
|
||||
jest.useFakeTimers();
|
||||
const user = userEvent.setup({ advanceTimers: jest.advanceTimersByTime });
|
||||
|
||||
render(<SearchInput onSearch={mockSearch} debounceMs={300} />);
|
||||
|
||||
const input = screen.getByRole("textbox");
|
||||
await user.type(input, "hello");
|
||||
|
||||
// Should not have called search yet
|
||||
expect(mockSearch).not.toHaveBeenCalled();
|
||||
|
||||
// Advance past debounce delay
|
||||
act(() => {
|
||||
jest.advanceTimersByTime(300);
|
||||
});
|
||||
|
||||
expect(mockSearch).toHaveBeenCalledWith("hello");
|
||||
|
||||
jest.useRealTimers();
|
||||
});
|
||||
```
|
||||
|
||||
**Important**: When using `userEvent` with fake timers, pass `advanceTimers: jest.advanceTimersByTime` to the setup options. This allows userEvent to advance fake timers during its internal delays.
|
||||
|
||||
## Testing Event Streams / WebSockets
|
||||
|
||||
For components that consume SSE or WebSocket events:
|
||||
|
||||
```typescript
|
||||
it("should handle streaming messages", async () => {
|
||||
const mockEventSource = {
|
||||
addEventListener: jest.fn(),
|
||||
removeEventListener: jest.fn(),
|
||||
close: jest.fn(),
|
||||
};
|
||||
|
||||
jest.spyOn(global, "EventSource" as any).mockImplementation(
|
||||
() => mockEventSource,
|
||||
);
|
||||
|
||||
render(<StreamingChat />);
|
||||
|
||||
// Simulate incoming message
|
||||
const onMessage = mockEventSource.addEventListener.mock.calls.find(
|
||||
([event]: [string]) => event === "message",
|
||||
)?.[1];
|
||||
|
||||
act(() => {
|
||||
onMessage?.({ data: JSON.stringify({ text: "Hello from stream" }) });
|
||||
});
|
||||
|
||||
expect(screen.getByText("Hello from stream")).toBeInTheDocument();
|
||||
});
|
||||
```
|
||||
|
||||
## Testing React Query Mutations
|
||||
|
||||
```typescript
|
||||
it("should save and show success", async () => {
|
||||
const user = userEvent.setup();
|
||||
jest.mocked(api.post).mockResolvedValueOnce({ data: { id: "new-1" } });
|
||||
|
||||
render(
|
||||
<QueryClientProvider client={createTestQueryClient()}>
|
||||
<CreateForm />
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
|
||||
await user.type(screen.getByLabelText("Name"), "New Item");
|
||||
await user.click(screen.getByRole("button", { name: /save/i }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/saved successfully/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
expect(api.post).toHaveBeenCalledWith("/api/v1/items", {
|
||||
name: "New Item",
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
## Testing Loading States
|
||||
|
||||
```typescript
|
||||
it("should show loading then content", async () => {
|
||||
let resolvePromise: (value: any) => void;
|
||||
const promise = new Promise((resolve) => {
|
||||
resolvePromise = resolve;
|
||||
});
|
||||
|
||||
jest.mocked(api.get).mockReturnValueOnce(promise as any);
|
||||
|
||||
render(<DataDisplay />);
|
||||
|
||||
// Loading state
|
||||
expect(screen.getByTestId("loading-spinner")).toBeInTheDocument();
|
||||
expect(screen.queryByText("Data content")).not.toBeInTheDocument();
|
||||
|
||||
// Resolve the promise
|
||||
await act(async () => {
|
||||
resolvePromise!({ data: { content: "Data content" } });
|
||||
});
|
||||
|
||||
// Loaded state
|
||||
expect(screen.queryByTestId("loading-spinner")).not.toBeInTheDocument();
|
||||
expect(screen.getByText("Data content")).toBeInTheDocument();
|
||||
});
|
||||
```
|
||||
|
||||
## Common Pitfalls
|
||||
|
||||
1. **Forgetting `act()` with timers**: Always wrap `jest.advanceTimersByTime()` in `act()` when it triggers React state updates.
|
||||
|
||||
2. **Not cleaning up timers**: Always call `jest.runOnlyPendingTimers()` then `jest.useRealTimers()` in `afterEach` to prevent timer leaks between tests.
|
||||
|
||||
3. **Using `waitFor` for sync assertions**: If the element is already in the DOM after render, use `getBy*` directly. `waitFor` adds unnecessary polling.
|
||||
|
||||
4. **Missing `await` on async operations**: Forgetting `await` on `waitFor`, `findBy*`, or `userEvent` methods causes tests to pass vacuously.
|
||||
|
||||
5. **Fake timers breaking promises**: If promises are not resolving with fake timers, try `jest.advanceTimersByTime()` inside `act(async () => { ... })`.
|
||||
@@ -0,0 +1,98 @@
|
||||
# Test Generation Checklist
|
||||
|
||||
Verify each item before submitting test code for Langflow frontend.
|
||||
|
||||
## Pre-Writing
|
||||
|
||||
- [ ] Read the source file completely before writing any tests
|
||||
- [ ] Identify all exported functions, components, hooks, and types
|
||||
- [ ] Identify all conditional branches (if/else, ternary, switch, early returns, `&&`/`||` short-circuits)
|
||||
- [ ] Identify all dependencies that need mocking
|
||||
- [ ] Check `jest.setup.js` for pre-existing global mocks to avoid duplication
|
||||
- [ ] Check if there is an existing test file to extend (rather than creating a new one)
|
||||
|
||||
## File Structure
|
||||
|
||||
- [ ] File is in `__tests__/` directory or co-located with source
|
||||
- [ ] File name follows `ComponentName.test.tsx` or `util-name.test.ts` convention
|
||||
- [ ] Imports use `@/` path alias consistently
|
||||
- [ ] `jest.mock()` calls are at the top of the file, after imports
|
||||
- [ ] `beforeEach` includes `jest.clearAllMocks()`
|
||||
- [ ] `beforeEach` resets Zustand store state (if using stores)
|
||||
- [ ] `afterEach` cleans up fake timers (if used)
|
||||
|
||||
## Test Quality
|
||||
|
||||
- [ ] Each `it()` block tests exactly ONE behavior
|
||||
- [ ] Test names follow `"should [behavior] when [condition]"` format
|
||||
- [ ] Tests are ordered from simplest to most complex
|
||||
- [ ] Tests use Arrange-Act-Assert (AAA) pattern with blank line separation
|
||||
- [ ] No implementation details tested (no internal state, no CSS classes, no private functions)
|
||||
- [ ] No test depends on another test's side effects (test isolation)
|
||||
|
||||
## Queries and Assertions
|
||||
|
||||
- [ ] Queries follow RTL priority: `getByRole` > `getByLabelText` > `getByText` > `getByTestId`
|
||||
- [ ] `queryBy*` used for asserting absence (`expect(queryByText("x")).not.toBeInTheDocument()`)
|
||||
- [ ] `findBy*` or `waitFor` used for async elements
|
||||
- [ ] `screen` object used for all queries (not destructured from `render`)
|
||||
- [ ] Assertions use `jest-dom` matchers: `toBeInTheDocument()`, `toHaveValue()`, `toBeDisabled()`, etc.
|
||||
- [ ] No use of `.innerHTML`, `.className`, or `.style` for assertions
|
||||
|
||||
## User Interactions
|
||||
|
||||
- [ ] `userEvent.setup()` used (not `fireEvent`)
|
||||
- [ ] All `userEvent` calls are `await`ed
|
||||
- [ ] `userEvent.setup({ advanceTimers: jest.advanceTimersByTime })` used when combining with fake timers
|
||||
|
||||
## Mocking
|
||||
|
||||
- [ ] Uses `jest.fn()`, `jest.mock()`, `jest.spyOn()` (NEVER `vi.*`)
|
||||
- [ ] Only mocks what is necessary (prefer real implementations)
|
||||
- [ ] Does NOT mock base UI components from `@/components/ui/`
|
||||
- [ ] Does NOT re-mock modules already mocked in `jest.setup.js`
|
||||
- [ ] Mock return values match the actual API shape (type-safe)
|
||||
- [ ] `jest.mocked()` used for type-safe mock assertions
|
||||
|
||||
## Async
|
||||
|
||||
- [ ] All `waitFor` calls contain a single assertion
|
||||
- [ ] All promises are properly `await`ed
|
||||
- [ ] `act()` wraps state-updating operations (store updates, timer advances)
|
||||
- [ ] Fake timers cleaned up in `afterEach`: `jest.runOnlyPendingTimers()` then `jest.useRealTimers()`
|
||||
- [ ] No unhandled promise rejections
|
||||
|
||||
## Challenge Tests (MANDATORY — not just happy paths)
|
||||
|
||||
- [ ] Happy path tests exist (baseline — code works under normal conditions)
|
||||
- [ ] **Unexpected inputs tested**: `null`, `undefined`, `""`, `[]`, `{}`, `0`, `-1`
|
||||
- [ ] **Boundary values tested**: max length, exactly at limit, one past limit, zero items, max items
|
||||
- [ ] **Malformed data tested**: missing fields, extra fields, wrong types
|
||||
- [ ] **Error states tested**: API 500, 404, 401, network failure, timeout
|
||||
- [ ] **What should NOT happen tested**: forbidden actions are rejected, wrong user can't access resources
|
||||
- [ ] **Error messages verified**: not just that it fails, but HOW it fails (correct message, correct type)
|
||||
- [ ] Tests written based on **requirements/spec**, not copied from source code logic
|
||||
|
||||
## Coverage
|
||||
|
||||
- [ ] All exported functions/components are tested
|
||||
- [ ] All conditional branches are covered (both sides of if/else, all catch blocks)
|
||||
- [ ] Function coverage: 100%
|
||||
- [ ] Branch coverage: > 95%
|
||||
- [ ] Line coverage: > 95%
|
||||
|
||||
## Execution
|
||||
|
||||
- [ ] Test file runs without errors: `npm test -- path/to/file.test.tsx`
|
||||
- [ ] No console warnings or errors in test output (or they are intentionally suppressed)
|
||||
- [ ] Tests pass in isolation (can run the file alone)
|
||||
- [ ] Tests pass with the full suite (no cross-file contamination)
|
||||
- [ ] Coverage verified: `npm test -- --coverage --collectCoverageFrom='src/path/to/source.ts' path/to/test.test.ts`
|
||||
|
||||
## Final Review
|
||||
|
||||
- [ ] No `console.log` left in test code
|
||||
- [ ] No `.only` or `.skip` left on tests
|
||||
- [ ] No hardcoded timeouts longer than 5000ms
|
||||
- [ ] No `any` type assertions that could be properly typed
|
||||
- [ ] Test file is not in `testPathIgnorePatterns` (not named `test-utils.tsx`)
|
||||
@@ -0,0 +1,411 @@
|
||||
# Common Testing Patterns
|
||||
|
||||
Frequently used patterns for testing Langflow React components with Jest and React Testing Library.
|
||||
|
||||
## Query Priority
|
||||
|
||||
Use queries in this priority order (most to least preferred):
|
||||
|
||||
| Priority | Query | When to Use |
|
||||
|---|---|---|
|
||||
| 1 | `getByRole` | Buttons, inputs, headings, links, checkboxes |
|
||||
| 2 | `getByLabelText` | Form inputs associated with labels |
|
||||
| 3 | `getByPlaceholderText` | Inputs with placeholder text |
|
||||
| 4 | `getByText` | Non-interactive content, paragraphs, spans |
|
||||
| 5 | `getByDisplayValue` | Filled input/textarea/select values |
|
||||
| 6 | `getByAltText` | Images |
|
||||
| 7 | `getByTitle` | Elements with title attribute |
|
||||
| 8 | `getByTestId` | Last resort -- when no semantic query works |
|
||||
|
||||
### Query Variants
|
||||
|
||||
| Variant | Throws if not found | Returns | Use When |
|
||||
|---|---|---|---|
|
||||
| `getBy*` | Yes | Element | Element should exist |
|
||||
| `queryBy*` | No | Element or null | Asserting element does NOT exist |
|
||||
| `findBy*` | Yes (after timeout) | Promise<Element> | Element appears asynchronously |
|
||||
| `getAllBy*` | Yes | Element[] | Multiple elements expected |
|
||||
| `queryAllBy*` | No | Element[] (may be empty) | Counting or absence of multiple |
|
||||
| `findAllBy*` | Yes (after timeout) | Promise<Element[]> | Multiple elements appear async |
|
||||
|
||||
### Examples
|
||||
|
||||
```typescript
|
||||
// Preferred: query by role
|
||||
screen.getByRole("button", { name: /save/i });
|
||||
screen.getByRole("textbox", { name: /search/i });
|
||||
screen.getByRole("heading", { level: 2 });
|
||||
screen.getByRole("checkbox", { name: /agree/i });
|
||||
screen.getByRole("combobox");
|
||||
|
||||
// Assert absence
|
||||
expect(screen.queryByText("Error")).not.toBeInTheDocument();
|
||||
|
||||
// Async appearance
|
||||
const element = await screen.findByText("Loaded");
|
||||
```
|
||||
|
||||
## User Events
|
||||
|
||||
Always use `@testing-library/user-event` over `fireEvent`:
|
||||
|
||||
```typescript
|
||||
import userEvent from "@testing-library/user-event";
|
||||
|
||||
describe("UserInteractions", () => {
|
||||
it("should handle click", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onClick = jest.fn();
|
||||
|
||||
render(<button onClick={onClick}>Click me</button>);
|
||||
await user.click(screen.getByRole("button"));
|
||||
|
||||
expect(onClick).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("should handle typing", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onChange = jest.fn();
|
||||
|
||||
render(<input onChange={onChange} />);
|
||||
await user.type(screen.getByRole("textbox"), "hello");
|
||||
|
||||
expect(onChange).toHaveBeenCalledTimes(5); // One per character
|
||||
});
|
||||
|
||||
it("should handle clearing and typing", async () => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
render(<input defaultValue="old value" />);
|
||||
const input = screen.getByRole("textbox");
|
||||
|
||||
await user.clear(input);
|
||||
await user.type(input, "new value");
|
||||
|
||||
expect(input).toHaveValue("new value");
|
||||
});
|
||||
|
||||
it("should handle keyboard navigation", async () => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
render(
|
||||
<div>
|
||||
<input data-testid="input-1" />
|
||||
<input data-testid="input-2" />
|
||||
</div>,
|
||||
);
|
||||
|
||||
await user.tab();
|
||||
expect(screen.getByTestId("input-1")).toHaveFocus();
|
||||
|
||||
await user.tab();
|
||||
expect(screen.getByTestId("input-2")).toHaveFocus();
|
||||
});
|
||||
|
||||
it("should handle select/dropdown", async () => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
render(
|
||||
<select>
|
||||
<option value="a">Option A</option>
|
||||
<option value="b">Option B</option>
|
||||
</select>,
|
||||
);
|
||||
|
||||
await user.selectOptions(screen.getByRole("combobox"), "b");
|
||||
expect(screen.getByRole("combobox")).toHaveValue("b");
|
||||
});
|
||||
|
||||
it("should handle hover", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onMouseEnter = jest.fn();
|
||||
|
||||
render(<div onMouseEnter={onMouseEnter}>Hover me</div>);
|
||||
await user.hover(screen.getByText("Hover me"));
|
||||
|
||||
expect(onMouseEnter).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
## Form Testing
|
||||
|
||||
```typescript
|
||||
describe("LoginForm", () => {
|
||||
it("should submit form with valid data", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onSubmit = jest.fn();
|
||||
|
||||
render(<LoginForm onSubmit={onSubmit} />);
|
||||
|
||||
await user.type(screen.getByLabelText(/username/i), "testuser");
|
||||
await user.type(screen.getByLabelText(/password/i), "password123");
|
||||
await user.click(screen.getByRole("button", { name: /sign in/i }));
|
||||
|
||||
expect(onSubmit).toHaveBeenCalledWith({
|
||||
username: "testuser",
|
||||
password: "password123",
|
||||
});
|
||||
});
|
||||
|
||||
it("should show validation errors for empty fields", async () => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
render(<LoginForm onSubmit={jest.fn()} />);
|
||||
|
||||
await user.click(screen.getByRole("button", { name: /sign in/i }));
|
||||
|
||||
expect(screen.getByText(/username is required/i)).toBeInTheDocument();
|
||||
expect(screen.getByText(/password is required/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should disable submit button while submitting", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onSubmit = jest.fn(() => new Promise(() => {})); // Never resolves
|
||||
|
||||
render(<LoginForm onSubmit={onSubmit} />);
|
||||
|
||||
await user.type(screen.getByLabelText(/username/i), "testuser");
|
||||
await user.type(screen.getByLabelText(/password/i), "password123");
|
||||
await user.click(screen.getByRole("button", { name: /sign in/i }));
|
||||
|
||||
expect(screen.getByRole("button", { name: /sign in/i })).toBeDisabled();
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
## Modal/Dialog Testing
|
||||
|
||||
Langflow uses Radix UI dialogs:
|
||||
|
||||
```typescript
|
||||
describe("ConfirmDialog", () => {
|
||||
it("should open and close the dialog", async () => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
render(<ConfirmDialog trigger={<button>Open</button>} />);
|
||||
|
||||
// Dialog should not be visible initially
|
||||
expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
|
||||
|
||||
// Open dialog
|
||||
await user.click(screen.getByRole("button", { name: /open/i }));
|
||||
|
||||
// Dialog should be visible
|
||||
expect(screen.getByRole("dialog")).toBeInTheDocument();
|
||||
expect(screen.getByText(/are you sure/i)).toBeInTheDocument();
|
||||
|
||||
// Close dialog by clicking cancel
|
||||
await user.click(screen.getByRole("button", { name: /cancel/i }));
|
||||
|
||||
// Dialog should be hidden
|
||||
await waitForElementToBeRemoved(() => screen.queryByRole("dialog"));
|
||||
});
|
||||
|
||||
it("should call onConfirm when confirmed", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onConfirm = jest.fn();
|
||||
|
||||
render(
|
||||
<ConfirmDialog trigger={<button>Open</button>} onConfirm={onConfirm} />,
|
||||
);
|
||||
|
||||
await user.click(screen.getByRole("button", { name: /open/i }));
|
||||
await user.click(screen.getByRole("button", { name: /confirm/i }));
|
||||
|
||||
expect(onConfirm).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
## Data-Driven Tests (Parameterized)
|
||||
|
||||
Use `it.each` for testing multiple inputs with the same logic:
|
||||
|
||||
```typescript
|
||||
describe("formatDuration", () => {
|
||||
it.each([
|
||||
[0, "0s"],
|
||||
[500, "0.5s"],
|
||||
[1000, "1.0s"],
|
||||
[1500, "1.5s"],
|
||||
[60000, "1m 0s"],
|
||||
[90000, "1m 30s"],
|
||||
[3600000, "1h 0m"],
|
||||
])("should format %i ms as %s", (input, expected) => {
|
||||
expect(formatDuration(input)).toBe(expected);
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
### Named Parameters with Objects
|
||||
|
||||
```typescript
|
||||
it.each([
|
||||
{ input: "", expected: false, description: "empty string" },
|
||||
{ input: "valid@email.com", expected: true, description: "valid email" },
|
||||
{ input: "no-at-sign", expected: false, description: "missing @" },
|
||||
{ input: "@no-local", expected: false, description: "missing local part" },
|
||||
])("should return $expected for $description", ({ input, expected }) => {
|
||||
expect(isValidEmail(input)).toBe(expected);
|
||||
});
|
||||
```
|
||||
|
||||
## Snapshot Testing
|
||||
|
||||
Use sparingly -- only for stable, presentational components:
|
||||
|
||||
```typescript
|
||||
it("should match snapshot", () => {
|
||||
const { container } = render(<Badge variant="success" label="Active" />);
|
||||
expect(container.firstChild).toMatchSnapshot();
|
||||
});
|
||||
```
|
||||
|
||||
Prefer explicit assertions over snapshots. Snapshots are fragile and do not communicate test intent.
|
||||
|
||||
## Testing Conditional Rendering
|
||||
|
||||
```typescript
|
||||
describe("StatusBadge", () => {
|
||||
it("should render success variant", () => {
|
||||
render(<StatusBadge status="success" />);
|
||||
expect(screen.getByText("Success")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should render error variant", () => {
|
||||
render(<StatusBadge status="error" />);
|
||||
expect(screen.getByText("Error")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should render nothing for unknown status", () => {
|
||||
const { container } = render(<StatusBadge status="unknown" />);
|
||||
expect(container).toBeEmptyDOMElement();
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
## Testing Lists and Tables
|
||||
|
||||
```typescript
|
||||
describe("ItemList", () => {
|
||||
it("should render all items", () => {
|
||||
const items = [
|
||||
{ id: "1", name: "Item 1" },
|
||||
{ id: "2", name: "Item 2" },
|
||||
{ id: "3", name: "Item 3" },
|
||||
];
|
||||
|
||||
render(<ItemList items={items} />);
|
||||
|
||||
const listItems = screen.getAllByRole("listitem");
|
||||
expect(listItems).toHaveLength(3);
|
||||
expect(listItems[0]).toHaveTextContent("Item 1");
|
||||
expect(listItems[1]).toHaveTextContent("Item 2");
|
||||
expect(listItems[2]).toHaveTextContent("Item 3");
|
||||
});
|
||||
|
||||
it("should show empty state when no items", () => {
|
||||
render(<ItemList items={[]} />);
|
||||
|
||||
expect(screen.getByText(/no items/i)).toBeInTheDocument();
|
||||
expect(screen.queryByRole("listitem")).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
## Testing Tooltips
|
||||
|
||||
Langflow uses Radix tooltips which require hover:
|
||||
|
||||
```typescript
|
||||
it("should show tooltip on hover", async () => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
render(<TooltipButton label="Delete" tooltip="Delete this item" />);
|
||||
|
||||
await user.hover(screen.getByRole("button", { name: /delete/i }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole("tooltip")).toHaveTextContent("Delete this item");
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
## Testing Error Boundaries
|
||||
|
||||
```typescript
|
||||
describe("ErrorBoundary", () => {
|
||||
// Suppress console.error for expected errors
|
||||
const originalError = console.error;
|
||||
beforeAll(() => {
|
||||
console.error = jest.fn();
|
||||
});
|
||||
afterAll(() => {
|
||||
console.error = originalError;
|
||||
});
|
||||
|
||||
it("should catch errors and show fallback UI", () => {
|
||||
const ThrowError = () => {
|
||||
throw new Error("Test error");
|
||||
};
|
||||
|
||||
render(
|
||||
<ErrorBoundary fallback={<div>Something went wrong</div>}>
|
||||
<ThrowError />
|
||||
</ErrorBoundary>,
|
||||
);
|
||||
|
||||
expect(screen.getByText("Something went wrong")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
## Testing Custom data-testid Attributes
|
||||
|
||||
Langflow components frequently use `data-testid` for testing. Common patterns:
|
||||
|
||||
```typescript
|
||||
// Input components
|
||||
screen.getByTestId("popover-anchor-input-api_key");
|
||||
|
||||
// Buttons
|
||||
screen.getByTestId("sidebar-nav-add_note");
|
||||
|
||||
// Modal elements
|
||||
screen.getByTestId("modal-title");
|
||||
|
||||
// Flow elements
|
||||
screen.getByTestId("handle-source-bottom");
|
||||
```
|
||||
|
||||
## Testing with Zustand Store Updates
|
||||
|
||||
```typescript
|
||||
it("should react to store changes", async () => {
|
||||
render(<NotificationBanner />);
|
||||
|
||||
// Initially no notification
|
||||
expect(screen.queryByText("Error occurred")).not.toBeInTheDocument();
|
||||
|
||||
// Update store
|
||||
act(() => {
|
||||
useAlertStore.setState({
|
||||
errorData: { title: "Error occurred", list: [] },
|
||||
});
|
||||
});
|
||||
|
||||
// Notification should appear
|
||||
expect(screen.getByText("Error occurred")).toBeInTheDocument();
|
||||
});
|
||||
```
|
||||
|
||||
## Cleanup
|
||||
|
||||
React Testing Library automatically cleans up after each test (unmounts rendered components). You do not need to call `cleanup()` manually.
|
||||
|
||||
However, you DO need to clean up:
|
||||
- Fake timers: `jest.useRealTimers()` in `afterEach`
|
||||
- Spies: `spy.mockRestore()` or `jest.restoreAllMocks()` in `afterEach`
|
||||
- Store state: Reset via `store.setState()` in `beforeEach`
|
||||
- Global overrides: Restore original values in `afterEach`
|
||||
@@ -0,0 +1,598 @@
|
||||
# Domain-Specific Component Testing for Langflow
|
||||
|
||||
Patterns for testing Langflow-specific components that have unique architectural concerns.
|
||||
|
||||
## Flow/Graph Components
|
||||
|
||||
Langflow's flow editor is built on `@xyflow/react` (React Flow). These components require specific mocking strategies.
|
||||
|
||||
### GenericNode
|
||||
|
||||
`GenericNode` is the main node component rendered in the flow canvas. Located at `src/frontend/src/CustomNodes/GenericNode/`.
|
||||
|
||||
```typescript
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import GenericNode from "@/CustomNodes/GenericNode";
|
||||
|
||||
// Mock @xyflow/react
|
||||
jest.mock("@xyflow/react", () => ({
|
||||
Handle: ({ type, position, id }: any) => (
|
||||
<div data-testid={`handle-${type}-${id}`} data-position={position} />
|
||||
),
|
||||
Position: { Top: "top", Bottom: "bottom", Left: "left", Right: "right" },
|
||||
useUpdateNodeInternals: () => jest.fn(),
|
||||
useReactFlow: () => ({
|
||||
getNodes: jest.fn().mockReturnValue([]),
|
||||
getEdges: jest.fn().mockReturnValue([]),
|
||||
setNodes: jest.fn(),
|
||||
setEdges: jest.fn(),
|
||||
}),
|
||||
}));
|
||||
|
||||
// Mock the flow store
|
||||
jest.mock("@/stores/flowStore", () => ({
|
||||
__esModule: true,
|
||||
default: (selector?: (state: any) => any) =>
|
||||
selector
|
||||
? selector({
|
||||
nodes: [],
|
||||
edges: [],
|
||||
setNodes: jest.fn(),
|
||||
onNodesChange: jest.fn(),
|
||||
})
|
||||
: {},
|
||||
}));
|
||||
|
||||
const mockNodeData = {
|
||||
id: "node-1",
|
||||
type: "genericNode",
|
||||
data: {
|
||||
node: {
|
||||
display_name: "OpenAI",
|
||||
description: "OpenAI language model",
|
||||
template: {
|
||||
api_key: {
|
||||
type: "str",
|
||||
required: true,
|
||||
show: true,
|
||||
name: "api_key",
|
||||
display_name: "API Key",
|
||||
password: true,
|
||||
value: "",
|
||||
advanced: false,
|
||||
},
|
||||
model_name: {
|
||||
type: "str",
|
||||
required: false,
|
||||
show: true,
|
||||
name: "model_name",
|
||||
display_name: "Model Name",
|
||||
options: ["gpt-4", "gpt-3.5-turbo"],
|
||||
value: "gpt-4",
|
||||
advanced: false,
|
||||
},
|
||||
},
|
||||
output_types: ["Message"],
|
||||
input_types: ["Text"],
|
||||
},
|
||||
showNode: true,
|
||||
},
|
||||
position: { x: 100, y: 200 },
|
||||
};
|
||||
|
||||
describe("GenericNode", () => {
|
||||
it("should render the node with display name", () => {
|
||||
render(<GenericNode data={mockNodeData.data} id="node-1" />);
|
||||
expect(screen.getByText("OpenAI")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should render input handles for each visible input field", () => {
|
||||
render(<GenericNode data={mockNodeData.data} id="node-1" />);
|
||||
// Verify handles are rendered based on template fields
|
||||
expect(screen.getByTestId("handle-target-api_key")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
### Edge Components
|
||||
|
||||
Test edge rendering and connection validation:
|
||||
|
||||
```typescript
|
||||
describe("CustomEdge", () => {
|
||||
it("should render a connection between compatible types", () => {
|
||||
render(
|
||||
<CustomEdge
|
||||
id="edge-1"
|
||||
source="node-1"
|
||||
target="node-2"
|
||||
sourceHandle="output-Message"
|
||||
targetHandle="input-Text"
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByTestId("edge-edge-1")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
### Connection Validation
|
||||
|
||||
Test type compatibility logic (typically a pure function):
|
||||
|
||||
```typescript
|
||||
import { isValidConnection } from "@/CustomNodes/helpers/connection-validation";
|
||||
|
||||
describe("isValidConnection", () => {
|
||||
it("should allow connection between compatible types", () => {
|
||||
expect(
|
||||
isValidConnection({
|
||||
sourceType: "Message",
|
||||
targetType: "Text",
|
||||
compatibleTypes: { Text: ["Message", "Text"] },
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("should reject connection between incompatible types", () => {
|
||||
expect(
|
||||
isValidConnection({
|
||||
sourceType: "DataFrame",
|
||||
targetType: "Text",
|
||||
compatibleTypes: { Text: ["Message", "Text"] },
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
### Handle Rendering
|
||||
|
||||
Test the handle display logic for minimized/expanded nodes:
|
||||
|
||||
```typescript
|
||||
import { computeDisplayHandle } from "@/CustomNodes/GenericNode/components/RenderInputParameters/helpers";
|
||||
|
||||
describe("computeDisplayHandle", () => {
|
||||
it("should show handles for non-advanced visible fields", () => {
|
||||
const result = computeDisplayHandle({
|
||||
showNode: true,
|
||||
isAdvanced: false,
|
||||
isHidden: false,
|
||||
});
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
it("should hide handles when node is minimized", () => {
|
||||
const result = computeDisplayHandle({
|
||||
showNode: false,
|
||||
isAdvanced: false,
|
||||
isHidden: false,
|
||||
});
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
## Component Configuration Panels
|
||||
|
||||
Components for editing node parameters in the inspection panel or modal.
|
||||
|
||||
### Parameter Render Components
|
||||
|
||||
Located at `src/frontend/src/components/core/parameterRenderComponent/`:
|
||||
|
||||
```typescript
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import InputComponent from "@/components/core/parameterRenderComponent/components/inputComponent";
|
||||
|
||||
describe("InputComponent", () => {
|
||||
const defaultProps = {
|
||||
value: "",
|
||||
onChange: jest.fn(),
|
||||
name: "api_key",
|
||||
id: "input-api_key",
|
||||
password: false,
|
||||
placeholder: "Enter API key",
|
||||
};
|
||||
|
||||
it("should render input with placeholder", () => {
|
||||
render(<InputComponent {...defaultProps} />);
|
||||
expect(screen.getByPlaceholderText("Enter API key")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should call onChange when user types", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onChange = jest.fn();
|
||||
render(<InputComponent {...defaultProps} onChange={onChange} />);
|
||||
|
||||
await user.type(screen.getByRole("textbox"), "sk-123");
|
||||
expect(onChange).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should mask input when password is true", () => {
|
||||
render(<InputComponent {...defaultProps} password={true} />);
|
||||
expect(screen.getByTestId("popover-anchor-input-api_key")).toHaveAttribute(
|
||||
"type",
|
||||
"password",
|
||||
);
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
### Dropdown/Select Components
|
||||
|
||||
```typescript
|
||||
describe("DropdownComponent", () => {
|
||||
it("should render options and allow selection", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onChange = jest.fn();
|
||||
|
||||
render(
|
||||
<DropdownComponent
|
||||
value="gpt-4"
|
||||
options={["gpt-4", "gpt-3.5-turbo", "gpt-4-turbo"]}
|
||||
onChange={onChange}
|
||||
/>,
|
||||
);
|
||||
|
||||
// Open dropdown
|
||||
await user.click(screen.getByRole("combobox"));
|
||||
|
||||
// Select an option
|
||||
await user.click(screen.getByText("gpt-3.5-turbo"));
|
||||
|
||||
expect(onChange).toHaveBeenCalledWith("gpt-3.5-turbo");
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
## Chat Components
|
||||
|
||||
Located at `src/frontend/src/components/core/chatComponents/` and `src/frontend/src/modals/IOModal/components/chatView/`.
|
||||
|
||||
### Message Display
|
||||
|
||||
```typescript
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import ChatMessage from "@/components/core/chatComponents/ChatMessage";
|
||||
|
||||
describe("ChatMessage", () => {
|
||||
it("should render user message", () => {
|
||||
render(
|
||||
<ChatMessage
|
||||
message={{ text: "Hello!", sender: "User", sender_name: "User" }}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByText("Hello!")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should render bot message with different styling", () => {
|
||||
render(
|
||||
<ChatMessage
|
||||
message={{ text: "Hi there!", sender: "Machine", sender_name: "AI" }}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByText("Hi there!")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should render markdown content", () => {
|
||||
render(
|
||||
<ChatMessage
|
||||
message={{
|
||||
text: "Here is **bold** text",
|
||||
sender: "Machine",
|
||||
sender_name: "AI",
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
|
||||
// react-markdown is mocked globally, so test the text content
|
||||
expect(screen.getByText(/bold/)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
### Message Sorting
|
||||
|
||||
Test the message sorting utility (pure function):
|
||||
|
||||
```typescript
|
||||
import { sortMessages } from "@/modals/IOModal/components/chatView/helpers";
|
||||
|
||||
describe("sortMessages", () => {
|
||||
it("should sort messages by timestamp ascending", () => {
|
||||
const messages = [
|
||||
{ id: "3", timestamp: "2024-01-03T00:00:00Z" },
|
||||
{ id: "1", timestamp: "2024-01-01T00:00:00Z" },
|
||||
{ id: "2", timestamp: "2024-01-02T00:00:00Z" },
|
||||
];
|
||||
|
||||
const sorted = sortMessages(messages);
|
||||
|
||||
expect(sorted.map((m) => m.id)).toEqual(["1", "2", "3"]);
|
||||
});
|
||||
|
||||
it("should handle empty array", () => {
|
||||
expect(sortMessages([])).toEqual([]);
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
### Chat Input
|
||||
|
||||
```typescript
|
||||
describe("ChatInput", () => {
|
||||
it("should send message on Enter key", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onSend = jest.fn();
|
||||
|
||||
render(<ChatInput onSend={onSend} />);
|
||||
|
||||
const textarea = screen.getByRole("textbox");
|
||||
await user.type(textarea, "Hello{Enter}");
|
||||
|
||||
expect(onSend).toHaveBeenCalledWith("Hello");
|
||||
});
|
||||
|
||||
it("should not send empty message", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onSend = jest.fn();
|
||||
|
||||
render(<ChatInput onSend={onSend} />);
|
||||
|
||||
const textarea = screen.getByRole("textbox");
|
||||
await user.type(textarea, "{Enter}");
|
||||
|
||||
expect(onSend).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should disable input while message is being sent", () => {
|
||||
render(<ChatInput onSend={jest.fn()} isLoading={true} />);
|
||||
|
||||
expect(screen.getByRole("textbox")).toBeDisabled();
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
## Global Variable Components
|
||||
|
||||
Located at `src/frontend/src/components/core/GlobalVariableModal/` and `src/frontend/src/pages/SettingsPage/pages/GlobalVariablesPage/`.
|
||||
|
||||
### Global Variable Modal
|
||||
|
||||
```typescript
|
||||
import { render, screen, waitFor } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import GlobalVariableModal from "@/components/core/GlobalVariableModal";
|
||||
|
||||
// Mock the API
|
||||
jest.mock("@/controllers/API/api", () => ({
|
||||
__esModule: true,
|
||||
default: {
|
||||
get: jest.fn(),
|
||||
post: jest.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
describe("GlobalVariableModal", () => {
|
||||
it("should render the form fields", () => {
|
||||
render(
|
||||
<GlobalVariableModal
|
||||
open={true}
|
||||
onClose={jest.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByLabelText(/variable name/i)).toBeInTheDocument();
|
||||
expect(screen.getByLabelText(/value/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should validate required fields", async () => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
render(
|
||||
<GlobalVariableModal
|
||||
open={true}
|
||||
onClose={jest.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
await user.click(screen.getByRole("button", { name: /save/i }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/name is required/i)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
### Global Variable in Node Fields
|
||||
|
||||
When a global variable is selected for a node field, the input renders as a badge instead of a text input. This is important for testing:
|
||||
|
||||
```typescript
|
||||
describe("InputGlobalComponent", () => {
|
||||
it("should show badge when global variable is selected", () => {
|
||||
render(
|
||||
<InputGlobalComponent
|
||||
name="api_key"
|
||||
value="OPENAI_API_KEY"
|
||||
load_from_db={true}
|
||||
/>,
|
||||
);
|
||||
|
||||
// Badge is rendered instead of input
|
||||
expect(screen.queryByTestId("popover-anchor-input-api_key")).not.toBeInTheDocument();
|
||||
// Look for the global variable badge instead
|
||||
expect(screen.getByText("OPENAI_API_KEY")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should show input when no global variable is selected", () => {
|
||||
render(
|
||||
<InputGlobalComponent
|
||||
name="api_key"
|
||||
value=""
|
||||
load_from_db={false}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByTestId("popover-anchor-input-api_key")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
## Playground Components
|
||||
|
||||
Located at `src/frontend/src/components/core/playgroundComponent/`.
|
||||
|
||||
### Playground View
|
||||
|
||||
```typescript
|
||||
// Mock stores needed by playground
|
||||
jest.mock("@/stores/flowStore", () => ({
|
||||
__esModule: true,
|
||||
default: (selector?: (state: any) => any) =>
|
||||
selector
|
||||
? selector({
|
||||
nodes: [],
|
||||
edges: [],
|
||||
inputs: [{ id: "input-1", type: "ChatInput" }],
|
||||
outputs: [{ id: "output-1", type: "ChatOutput" }],
|
||||
})
|
||||
: {},
|
||||
}));
|
||||
|
||||
jest.mock("@/stores/playgroundStore", () => ({
|
||||
__esModule: true,
|
||||
default: (selector?: (state: any) => any) =>
|
||||
selector
|
||||
? selector({
|
||||
isPlaygroundOpen: true,
|
||||
setIsPlaygroundOpen: jest.fn(),
|
||||
})
|
||||
: {},
|
||||
}));
|
||||
|
||||
describe("PlaygroundComponent", () => {
|
||||
it("should render chat interface when flow has chat I/O", () => {
|
||||
render(<PlaygroundComponent />);
|
||||
expect(screen.getByRole("textbox")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
### Duration Display
|
||||
|
||||
The `DurationDisplay` component shows elapsed time during message processing. It uses `Date.now()` and `setInterval`:
|
||||
|
||||
```typescript
|
||||
// See DurationDisplay.test.tsx in the codebase for a comprehensive example
|
||||
// Key patterns:
|
||||
// 1. Mock Date.now for deterministic time
|
||||
// 2. Use jest.useFakeTimers() for interval control
|
||||
// 3. Use act() when advancing timers
|
||||
// 4. Test remount behavior (playground open/close)
|
||||
```
|
||||
|
||||
## Sidebar Components
|
||||
|
||||
Located at `src/frontend/src/pages/FlowPage/components/flowSidebarComponent/`.
|
||||
|
||||
### Sidebar Search and Filtering
|
||||
|
||||
```typescript
|
||||
describe("SidebarSearch", () => {
|
||||
it("should filter components by search term", async () => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
render(
|
||||
<FlowSidebar
|
||||
components={[
|
||||
{ name: "OpenAI", category: "LLMs" },
|
||||
{ name: "Pinecone", category: "Vector Stores" },
|
||||
{ name: "ChatInput", category: "Inputs" },
|
||||
]}
|
||||
/>,
|
||||
);
|
||||
|
||||
await user.type(screen.getByPlaceholderText(/search/i), "open");
|
||||
|
||||
expect(screen.getByText("OpenAI")).toBeInTheDocument();
|
||||
expect(screen.queryByText("Pinecone")).not.toBeInTheDocument();
|
||||
expect(screen.queryByText("ChatInput")).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
### Draggable Components
|
||||
|
||||
```typescript
|
||||
describe("SidebarDraggableComponent", () => {
|
||||
it("should have draggable attributes", () => {
|
||||
render(
|
||||
<SidebarDraggableComponent
|
||||
name="OpenAI"
|
||||
type="llm"
|
||||
draggable={true}
|
||||
/>,
|
||||
);
|
||||
|
||||
const draggable = screen.getByTestId("sidebar-draggable-openai");
|
||||
expect(draggable).toHaveAttribute("draggable", "true");
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
## API Code Generation (Modals)
|
||||
|
||||
Located at `src/frontend/src/modals/apiModal/utils/`.
|
||||
|
||||
These are pure functions that generate code snippets -- ideal for data-driven tests:
|
||||
|
||||
```typescript
|
||||
import { getPythonApiCode } from "@/modals/apiModal/utils/get-python-api-code";
|
||||
|
||||
describe("getPythonApiCode", () => {
|
||||
it("should generate valid Python code for a flow", () => {
|
||||
const code = getPythonApiCode({
|
||||
flowId: "flow-123",
|
||||
tweaks: { "node-1": { model_name: "gpt-4" } },
|
||||
isAuth: true,
|
||||
});
|
||||
|
||||
expect(code).toContain("flow-123");
|
||||
expect(code).toContain("gpt-4");
|
||||
expect(code).toContain("Authorization");
|
||||
});
|
||||
|
||||
it("should omit auth header when isAuth is false", () => {
|
||||
const code = getPythonApiCode({
|
||||
flowId: "flow-123",
|
||||
tweaks: {},
|
||||
isAuth: false,
|
||||
});
|
||||
|
||||
expect(code).not.toContain("Authorization");
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
## Testing Tips for Langflow Components
|
||||
|
||||
1. **Node template data**: Create reusable mock node data objects. The template structure is deeply nested and used across many components.
|
||||
|
||||
2. **Store interdependencies**: Many components read from multiple stores (flowStore, typesStore, alertStore). Mock or initialize all relevant stores.
|
||||
|
||||
3. **data-testid conventions**: Langflow uses `data-testid` extensively. Common patterns:
|
||||
- `popover-anchor-input-{name}` for input fields
|
||||
- `handle-{type}-{position}` for flow handles
|
||||
- `sidebar-nav-{action}` for sidebar buttons
|
||||
- `edit-fields-button` for inspection panel
|
||||
|
||||
4. **Global variable awareness**: When testing components that render node fields, be aware that fields with `load_from_db: true` and a global variable value render as badges, not inputs. The input element will not exist in the DOM.
|
||||
|
||||
5. **Inspection panel vs. canvas**: Fields render differently depending on context. Canvas shows non-advanced fields; the inspection panel shows advanced fields. Use `shouldRenderInspectionPanelField()` and `isCanvasVisible()` from `src/frontend/src/CustomNodes/helpers/parameter-filtering.ts`.
|
||||
@@ -0,0 +1,458 @@
|
||||
# Mocking Guide for Langflow Tests
|
||||
|
||||
All mocking in Langflow uses Jest APIs. Never use Vitest (`vi.*`) APIs.
|
||||
|
||||
## Quick Reference
|
||||
|
||||
| Vitest (DO NOT USE) | Jest (USE THIS) |
|
||||
|---|---|
|
||||
| `vi.fn()` | `jest.fn()` |
|
||||
| `vi.mock()` | `jest.mock()` |
|
||||
| `vi.spyOn()` | `jest.spyOn()` |
|
||||
| `vi.mocked()` | `jest.mocked()` |
|
||||
| `vi.clearAllMocks()` | `jest.clearAllMocks()` |
|
||||
| `vi.useFakeTimers()` | `jest.useFakeTimers()` |
|
||||
| `vi.useRealTimers()` | `jest.useRealTimers()` |
|
||||
| `vi.advanceTimersByTime()` | `jest.advanceTimersByTime()` |
|
||||
|
||||
## Pre-Existing Global Mocks
|
||||
|
||||
These modules are already mocked in `jest.setup.js`. Do NOT re-mock them unless you need different behavior:
|
||||
|
||||
- `@radix-ui/react-form` (all exports render children)
|
||||
- `react-markdown` (renders null)
|
||||
- `lucide-react/dynamicIconImports` (empty object)
|
||||
- `@/components/common/genericIconComponent` (renders null)
|
||||
- `@/icons/BotMessageSquare` (renders null)
|
||||
- `@/stores/darkStore` (returns default state)
|
||||
- `localStorage` and `sessionStorage` (jest.fn() stubs)
|
||||
|
||||
To use the real implementation instead:
|
||||
```typescript
|
||||
jest.unmock("@/stores/darkStore");
|
||||
```
|
||||
|
||||
## API Mocking
|
||||
|
||||
### Mocking Axios Calls
|
||||
|
||||
Langflow uses Axios via a configured API instance. Mock the API module:
|
||||
|
||||
```typescript
|
||||
import api from "@/controllers/API/api";
|
||||
|
||||
jest.mock("@/controllers/API/api", () => ({
|
||||
__esModule: true,
|
||||
default: {
|
||||
get: jest.fn(),
|
||||
post: jest.fn(),
|
||||
put: jest.fn(),
|
||||
patch: jest.fn(),
|
||||
delete: jest.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
describe("MyComponent", () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
it("should fetch data on mount", async () => {
|
||||
jest.mocked(api.get).mockResolvedValueOnce({
|
||||
data: { items: [{ id: "1", name: "Test" }] },
|
||||
});
|
||||
|
||||
render(<MyComponent />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Test")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
expect(api.get).toHaveBeenCalledWith("/api/v1/items");
|
||||
});
|
||||
|
||||
it("should handle API errors", async () => {
|
||||
jest.mocked(api.get).mockRejectedValueOnce(new Error("Network error"));
|
||||
|
||||
render(<MyComponent />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/error/i)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
### Mocking Specific API Query Hooks
|
||||
|
||||
For components using React Query hooks from `@/controllers/API/queries/`:
|
||||
|
||||
```typescript
|
||||
jest.mock("@/controllers/API/queries/flows", () => ({
|
||||
useGetFlowsQuery: jest.fn().mockReturnValue({
|
||||
data: [{ id: "flow-1", name: "My Flow" }],
|
||||
isLoading: false,
|
||||
error: null,
|
||||
refetch: jest.fn(),
|
||||
}),
|
||||
}));
|
||||
```
|
||||
|
||||
### Mocking Individual API Functions
|
||||
|
||||
```typescript
|
||||
jest.mock("@/controllers/API", () => ({
|
||||
getFlows: jest.fn().mockResolvedValue([]),
|
||||
saveFlow: jest.fn().mockResolvedValue({ id: "new-flow" }),
|
||||
deleteFlow: jest.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
```
|
||||
|
||||
## Zustand Store Mocking
|
||||
|
||||
Langflow does NOT have a global Zustand auto-mock. You have two options:
|
||||
|
||||
### Option 1: Use Real Stores with `setState()` (Preferred)
|
||||
|
||||
```typescript
|
||||
import useAlertStore from "@/stores/alertStore";
|
||||
|
||||
describe("Alert-dependent component", () => {
|
||||
beforeEach(() => {
|
||||
// Reset store to known state before each test
|
||||
useAlertStore.setState({
|
||||
errorData: { title: "", list: [] },
|
||||
noticeData: { title: "", link: "" },
|
||||
successData: { title: "" },
|
||||
notificationCenter: false,
|
||||
notificationList: [],
|
||||
tempNotificationList: [],
|
||||
});
|
||||
});
|
||||
|
||||
it("should display error notification", () => {
|
||||
// Pre-set store state
|
||||
useAlertStore.setState({
|
||||
errorData: { title: "Something went wrong", list: ["Detail"] },
|
||||
});
|
||||
|
||||
render(<NotificationBanner />);
|
||||
|
||||
expect(screen.getByText("Something went wrong")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
### Option 2: Mock the Store Module
|
||||
|
||||
Use when the real store has complex initialization or `import.meta` dependencies:
|
||||
|
||||
```typescript
|
||||
const mockFlowStore = {
|
||||
nodes: [],
|
||||
edges: [],
|
||||
setNodes: jest.fn(),
|
||||
setEdges: jest.fn(),
|
||||
onNodesChange: jest.fn(),
|
||||
onEdgesChange: jest.fn(),
|
||||
onConnect: jest.fn(),
|
||||
};
|
||||
|
||||
jest.mock("@/stores/flowStore", () => ({
|
||||
__esModule: true,
|
||||
default: (selector?: (state: any) => any) =>
|
||||
selector ? selector(mockFlowStore) : mockFlowStore,
|
||||
}));
|
||||
|
||||
describe("FlowCanvas", () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
mockFlowStore.nodes = [];
|
||||
mockFlowStore.edges = [];
|
||||
});
|
||||
|
||||
it("should render nodes from the store", () => {
|
||||
mockFlowStore.nodes = [
|
||||
{ id: "node-1", type: "genericNode", data: { node: { display_name: "OpenAI" } }, position: { x: 0, y: 0 } },
|
||||
];
|
||||
|
||||
render(<FlowCanvas />);
|
||||
|
||||
expect(screen.getByText("OpenAI")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
### Option 3: Use `renderHook` for Store Tests
|
||||
|
||||
For testing the store itself:
|
||||
|
||||
```typescript
|
||||
import { act, renderHook } from "@testing-library/react";
|
||||
import useMyStore from "../myStore";
|
||||
|
||||
describe("useMyStore", () => {
|
||||
beforeEach(() => {
|
||||
useMyStore.setState({ count: 0 });
|
||||
});
|
||||
|
||||
it("should increment count", () => {
|
||||
const { result } = renderHook(() => useMyStore());
|
||||
|
||||
act(() => {
|
||||
result.current.increment();
|
||||
});
|
||||
|
||||
expect(result.current.count).toBe(1);
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
## React Router Mocking
|
||||
|
||||
Langflow uses `react-router-dom` v6 (NOT Next.js routing).
|
||||
|
||||
### Wrapping with MemoryRouter
|
||||
|
||||
```typescript
|
||||
import { MemoryRouter } from "react-router-dom";
|
||||
|
||||
it("should render the page", () => {
|
||||
render(
|
||||
<MemoryRouter initialEntries={["/flows/flow-123"]}>
|
||||
<FlowPage />
|
||||
</MemoryRouter>,
|
||||
);
|
||||
});
|
||||
```
|
||||
|
||||
### Mocking useNavigate
|
||||
|
||||
```typescript
|
||||
const mockNavigate = jest.fn();
|
||||
|
||||
jest.mock("react-router-dom", () => ({
|
||||
...jest.requireActual("react-router-dom"),
|
||||
useNavigate: () => mockNavigate,
|
||||
}));
|
||||
|
||||
it("should navigate to flow page on click", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(
|
||||
<MemoryRouter>
|
||||
<FlowCard flow={mockFlow} />
|
||||
</MemoryRouter>,
|
||||
);
|
||||
|
||||
await user.click(screen.getByText("Open Flow"));
|
||||
expect(mockNavigate).toHaveBeenCalledWith("/flow/flow-123");
|
||||
});
|
||||
```
|
||||
|
||||
### Mocking useParams
|
||||
|
||||
```typescript
|
||||
jest.mock("react-router-dom", () => ({
|
||||
...jest.requireActual("react-router-dom"),
|
||||
useParams: () => ({ flowId: "flow-123" }),
|
||||
}));
|
||||
```
|
||||
|
||||
### Mocking useSearchParams
|
||||
|
||||
```typescript
|
||||
jest.mock("react-router-dom", () => ({
|
||||
...jest.requireActual("react-router-dom"),
|
||||
useSearchParams: () => [new URLSearchParams("tab=settings"), jest.fn()],
|
||||
}));
|
||||
```
|
||||
|
||||
## React Query Mocking
|
||||
|
||||
### Creating a Test QueryClient
|
||||
|
||||
```typescript
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
|
||||
function createTestQueryClient() {
|
||||
return new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
retry: false,
|
||||
gcTime: 0,
|
||||
},
|
||||
mutations: {
|
||||
retry: false,
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function renderWithQueryClient(ui: React.ReactElement) {
|
||||
const queryClient = createTestQueryClient();
|
||||
return render(
|
||||
<QueryClientProvider client={queryClient}>{ui}</QueryClientProvider>,
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### Mocking useMutation
|
||||
|
||||
```typescript
|
||||
jest.mock("@tanstack/react-query", () => ({
|
||||
...jest.requireActual("@tanstack/react-query"),
|
||||
useMutation: jest.fn().mockReturnValue({
|
||||
mutate: jest.fn(),
|
||||
mutateAsync: jest.fn(),
|
||||
isPending: false,
|
||||
isError: false,
|
||||
error: null,
|
||||
data: undefined,
|
||||
}),
|
||||
}));
|
||||
```
|
||||
|
||||
## Context Provider Mocking
|
||||
|
||||
### Wrapping with Providers
|
||||
|
||||
For components that depend on React context:
|
||||
|
||||
```typescript
|
||||
import { AuthContext } from "@/contexts/authContext";
|
||||
|
||||
const mockAuthContext = {
|
||||
isAuthenticated: true,
|
||||
userData: { id: "user-1", username: "testuser" },
|
||||
login: jest.fn(),
|
||||
logout: jest.fn(),
|
||||
getAuthentication: jest.fn(),
|
||||
autoLogin: false,
|
||||
};
|
||||
|
||||
it("should show user name when authenticated", () => {
|
||||
render(
|
||||
<AuthContext.Provider value={mockAuthContext}>
|
||||
<UserMenu />
|
||||
</AuthContext.Provider>,
|
||||
);
|
||||
|
||||
expect(screen.getByText("testuser")).toBeInTheDocument();
|
||||
});
|
||||
```
|
||||
|
||||
### Mocking useContext Directly
|
||||
|
||||
```typescript
|
||||
jest.mock("react", () => ({
|
||||
...jest.requireActual("react"),
|
||||
useContext: jest.fn().mockReturnValue({
|
||||
isAuthenticated: true,
|
||||
userData: { username: "testuser" },
|
||||
}),
|
||||
}));
|
||||
```
|
||||
|
||||
## Component Mocking
|
||||
|
||||
### Mocking Child Components
|
||||
|
||||
When a child component is complex or has side effects:
|
||||
|
||||
```typescript
|
||||
jest.mock("@/components/core/chatView/ChatView", () => ({
|
||||
__esModule: true,
|
||||
default: ({ onSend }: any) => (
|
||||
<div data-testid="mock-chat-view">
|
||||
<button onClick={() => onSend("test message")}>Send</button>
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
```
|
||||
|
||||
### DO NOT Mock Base UI Components
|
||||
|
||||
Never mock components from `@/components/ui/`:
|
||||
- `Button`, `Input`, `Select`, `Dialog`, `Popover`, etc.
|
||||
- These are simple Radix/shadcn wrappers and should be rendered as-is
|
||||
- Mocking them hides real rendering issues
|
||||
|
||||
### Mocking Third-Party Components
|
||||
|
||||
```typescript
|
||||
// Mock @xyflow/react for flow-related tests
|
||||
jest.mock("@xyflow/react", () => ({
|
||||
ReactFlow: ({ children }: any) => <div data-testid="react-flow">{children}</div>,
|
||||
useReactFlow: () => ({
|
||||
getNodes: jest.fn().mockReturnValue([]),
|
||||
getEdges: jest.fn().mockReturnValue([]),
|
||||
setNodes: jest.fn(),
|
||||
setEdges: jest.fn(),
|
||||
fitView: jest.fn(),
|
||||
zoomIn: jest.fn(),
|
||||
zoomOut: jest.fn(),
|
||||
}),
|
||||
useNodesState: jest.fn().mockReturnValue([[], jest.fn(), jest.fn()]),
|
||||
useEdgesState: jest.fn().mockReturnValue([[], jest.fn(), jest.fn()]),
|
||||
Background: () => null,
|
||||
Controls: () => null,
|
||||
MiniMap: () => null,
|
||||
Handle: ({ type, position }: any) => (
|
||||
<div data-testid={`handle-${type}-${position}`} />
|
||||
),
|
||||
Position: { Top: "top", Bottom: "bottom", Left: "left", Right: "right" },
|
||||
MarkerType: { ArrowClosed: "arrowclosed" },
|
||||
}));
|
||||
```
|
||||
|
||||
## Window and DOM Mocking
|
||||
|
||||
### Already Mocked in Setup Files
|
||||
|
||||
These are globally mocked in `setupTests.ts` -- do not re-mock:
|
||||
- `ResizeObserver`
|
||||
- `IntersectionObserver`
|
||||
- `window.matchMedia`
|
||||
|
||||
### Mocking window.location
|
||||
|
||||
```typescript
|
||||
const originalLocation = window.location;
|
||||
|
||||
beforeEach(() => {
|
||||
Object.defineProperty(window, "location", {
|
||||
value: { ...originalLocation, href: "http://localhost:3000", assign: jest.fn() },
|
||||
writable: true,
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
Object.defineProperty(window, "location", {
|
||||
value: originalLocation,
|
||||
writable: true,
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
### Mocking Clipboard
|
||||
|
||||
```typescript
|
||||
Object.assign(navigator, {
|
||||
clipboard: {
|
||||
writeText: jest.fn().mockResolvedValue(undefined),
|
||||
readText: jest.fn().mockResolvedValue("clipboard content"),
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
## Common Pitfalls
|
||||
|
||||
1. **Mocking before imports**: `jest.mock()` calls are hoisted automatically by Jest. You do not need to place them before import statements (but it is conventional to do so for readability).
|
||||
|
||||
2. **Forgetting `__esModule: true`**: When mocking a module with default exports, include `__esModule: true` in the mock factory.
|
||||
|
||||
3. **Over-mocking**: Only mock what is necessary. If the real module works in jsdom, prefer using it.
|
||||
|
||||
4. **Not resetting mocks**: Always use `jest.clearAllMocks()` in `beforeEach` to prevent test contamination.
|
||||
|
||||
5. **Mocking globally-mocked modules**: Check `jest.setup.js` first. Duplicating a mock may cause unexpected behavior.
|
||||
@@ -0,0 +1,197 @@
|
||||
# Incremental Testing Workflow
|
||||
|
||||
Step-by-step process for systematically testing a directory of source files in Langflow.
|
||||
|
||||
## Phase 1: Discovery
|
||||
|
||||
### 1.1 Identify Source Files
|
||||
|
||||
List all `.ts` and `.tsx` files in the target directory (excluding existing test files):
|
||||
|
||||
```bash
|
||||
find src/frontend/src/path/to/directory -name '*.ts' -o -name '*.tsx' | grep -v '__tests__' | grep -v '.test.' | grep -v '.spec.' | sort
|
||||
```
|
||||
|
||||
### 1.2 Categorize by Complexity
|
||||
|
||||
Sort files into tiers:
|
||||
|
||||
| Tier | Description | Examples |
|
||||
|---|---|---|
|
||||
| 1 | Pure functions, constants, types | `utils.ts`, `constants.ts`, `types.ts` |
|
||||
| 2 | Custom hooks (no UI) | `useDebounce.ts`, `useLocalStorage.ts` |
|
||||
| 3 | Simple presentational components | `Badge.tsx`, `EmptyState.tsx` |
|
||||
| 4 | Stateful components | `SearchInput.tsx`, `FilterPanel.tsx` |
|
||||
| 5 | Store-connected components | `NodeToolbar.tsx`, `SidebarHeader.tsx` |
|
||||
| 6 | Async/API components | `FlowList.tsx`, `GlobalVariablesPage.tsx` |
|
||||
| 7 | Integration components | `FlowPage.tsx`, `ChatView.tsx` |
|
||||
|
||||
### 1.3 Check Existing Coverage
|
||||
|
||||
```bash
|
||||
npm test -- --coverage --collectCoverageFrom='src/path/to/directory/**/*.{ts,tsx}' src/path/to/directory/__tests__/
|
||||
```
|
||||
|
||||
## Phase 2: Write Tests (Per File)
|
||||
|
||||
For each source file, starting from Tier 1:
|
||||
|
||||
### 2.1 Read the Source
|
||||
|
||||
Read the entire source file. Identify:
|
||||
- All exported functions, components, hooks, and types
|
||||
- All conditional branches (if/else, ternary, switch, early returns)
|
||||
- All side effects (API calls, store mutations, DOM manipulation)
|
||||
- All dependencies that need mocking
|
||||
|
||||
### 2.2 Create the Test File
|
||||
|
||||
Create `__tests__/SourceFileName.test.tsx` (or `.test.ts` for non-JSX files).
|
||||
|
||||
### 2.3 Write Tests in Order
|
||||
|
||||
1. **Imports and mocks** at the top
|
||||
2. **describe block** named after the exported entity
|
||||
3. **beforeEach** with `jest.clearAllMocks()` and any store resets
|
||||
4. **Rendering tests** first
|
||||
5. **Props/state variation tests** next
|
||||
6. **Interaction tests** next
|
||||
7. **Async/error tests** last
|
||||
|
||||
### 2.4 Run and Verify
|
||||
|
||||
```bash
|
||||
# Run the single test file
|
||||
npm test -- src/path/to/__tests__/SourceFileName.test.tsx
|
||||
|
||||
# Check coverage for the source file
|
||||
npm test -- --coverage --collectCoverageFrom='src/path/to/SourceFileName.tsx' src/path/to/__tests__/SourceFileName.test.tsx
|
||||
```
|
||||
|
||||
### 2.5 Fix Failures
|
||||
|
||||
Common issues:
|
||||
- **Missing mock**: Add `jest.mock()` for the dependency
|
||||
- **Act warning**: Wrap state updates in `act()` or use `await waitFor()`
|
||||
- **Element not found**: Check query strategy -- use `screen.debug()` to inspect DOM
|
||||
- **Globally mocked module**: Check `jest.setup.js` -- the module may already be mocked
|
||||
|
||||
### 2.6 Reach Coverage Targets
|
||||
|
||||
If coverage is below targets (100% function, >95% branch/line):
|
||||
1. Identify uncovered lines in the coverage report
|
||||
2. Write additional test cases targeting those branches
|
||||
3. Re-run coverage to confirm improvement
|
||||
|
||||
## Phase 3: Directory-Level Verification
|
||||
|
||||
After all files in the directory have tests:
|
||||
|
||||
### 3.1 Run All Tests Together
|
||||
|
||||
```bash
|
||||
npm test -- --testPathPattern="src/path/to/directory"
|
||||
```
|
||||
|
||||
### 3.2 Check Combined Coverage
|
||||
|
||||
```bash
|
||||
npm test -- --coverage --collectCoverageFrom='src/path/to/directory/**/*.{ts,tsx}' --testPathPattern="src/path/to/directory"
|
||||
```
|
||||
|
||||
### 3.3 Review Test Quality
|
||||
|
||||
For each test file, verify:
|
||||
- [ ] No implementation details tested (no internal state inspection)
|
||||
- [ ] No redundant tests (each test adds unique coverage)
|
||||
- [ ] All async operations properly awaited
|
||||
- [ ] Cleanup performed (timers, spies, store resets)
|
||||
- [ ] Test names are descriptive and follow the `"should ... when ..."` pattern
|
||||
|
||||
## Phase 4: Cleanup
|
||||
|
||||
### 4.1 Remove Unnecessary Mocks
|
||||
|
||||
If a mock is not actually needed (the real implementation works in jsdom), remove it. Fewer mocks means more realistic tests.
|
||||
|
||||
### 4.2 Extract Shared Test Utilities
|
||||
|
||||
If multiple test files use the same setup patterns, extract them into a `test-utils.tsx` file in the `__tests__` directory:
|
||||
|
||||
```tsx
|
||||
// __tests__/test-utils.tsx
|
||||
import { render, type RenderOptions } from "@testing-library/react";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { MemoryRouter } from "react-router-dom";
|
||||
|
||||
function createTestQueryClient() {
|
||||
return new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: { retry: false, gcTime: 0 },
|
||||
mutations: { retry: false },
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function renderWithProviders(
|
||||
ui: React.ReactElement,
|
||||
options?: RenderOptions & { route?: string },
|
||||
) {
|
||||
const queryClient = createTestQueryClient();
|
||||
return render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<MemoryRouter initialEntries={[options?.route ?? "/"]}>
|
||||
{ui}
|
||||
</MemoryRouter>
|
||||
</QueryClientProvider>,
|
||||
options,
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
Note: The Jest config ignores files named `test-utils.tsx` via `testPathIgnorePatterns`.
|
||||
|
||||
### 4.3 Final Verification
|
||||
|
||||
```bash
|
||||
# Run full test suite to ensure nothing is broken
|
||||
npm test
|
||||
|
||||
# Run with coverage for a summary report
|
||||
npm run test:coverage
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### "Cannot find module" Errors
|
||||
|
||||
The `@/` alias maps to `src/frontend/src/`. If a module is not resolved, check `jest.config.js` `moduleNameMapper`.
|
||||
|
||||
### "import.meta" Errors
|
||||
|
||||
The project uses `transform-import-meta.js` to handle Vite's `import.meta.env`. If you encounter issues, check that the transform is applied in `jest.config.js`.
|
||||
|
||||
### Global Mocks Conflicting
|
||||
|
||||
Modules mocked in `jest.setup.js` are mocked globally. If you need the real implementation in a specific test:
|
||||
|
||||
```typescript
|
||||
// At the top of your test file, before other imports
|
||||
jest.unmock("@/stores/darkStore");
|
||||
```
|
||||
|
||||
### Zustand Store State Leaking Between Tests
|
||||
|
||||
Always reset store state in `beforeEach`:
|
||||
|
||||
```typescript
|
||||
import useMyStore from "@/stores/myStore";
|
||||
|
||||
beforeEach(() => {
|
||||
useMyStore.setState({
|
||||
// Reset to initial state
|
||||
items: [],
|
||||
loading: false,
|
||||
});
|
||||
});
|
||||
```
|
||||
+2
-1
@@ -36,7 +36,8 @@ reviews:
|
||||
ignore_title_keywords: []
|
||||
labels: []
|
||||
drafts: false
|
||||
base_branches: []
|
||||
base_branches:
|
||||
- "release-.*"
|
||||
finishing_touches:
|
||||
docstrings:
|
||||
enabled: true
|
||||
|
||||
@@ -1,268 +0,0 @@
|
||||
---
|
||||
description: "Guidelines for backend development in Langflow, focusing on Python components, FastAPI services, and backend testing."
|
||||
globs:
|
||||
- "src/backend/**/*.py"
|
||||
- "tests/**/*.py"
|
||||
- "Makefile"
|
||||
- "pyproject.toml"
|
||||
- "uv.lock"
|
||||
alwaysApply: false
|
||||
---
|
||||
|
||||
|
||||
# Backend Development Guidelines
|
||||
|
||||
## Purpose
|
||||
Guidelines for backend development in Langflow, focusing on Python components, FastAPI services, and backend testing.
|
||||
|
||||
---
|
||||
|
||||
## 1. Backend Environment Setup
|
||||
|
||||
### Prerequisites
|
||||
- **Python Package Manager:** `uv` (>=0.4) for dependency management
|
||||
- **Database:** SQLite for development, PostgreSQL for production
|
||||
- **Development Tools:** `make` for build coordination
|
||||
|
||||
### Backend Service
|
||||
```bash
|
||||
make backend # Start FastAPI backend on port 7860
|
||||
```
|
||||
- Auto-reloads on file changes
|
||||
- Health check: http://localhost:7860/health
|
||||
- Backend components: `src/backend/base/langflow/`
|
||||
|
||||
---
|
||||
|
||||
## 2. Component Development
|
||||
|
||||
### Component Structure
|
||||
```
|
||||
src/backend/base/langflow/components/
|
||||
├── agents/ # Agent components
|
||||
├── data/ # Data processing components
|
||||
├── embeddings/ # Embedding components
|
||||
├── input_output/ # Input/output components
|
||||
├── models/ # Language model components
|
||||
├── processing/ # Text processing components
|
||||
├── prompts/ # Prompt components
|
||||
├── tools/ # Tool components
|
||||
└── vectorstores/ # Vector store components
|
||||
```
|
||||
|
||||
### Adding New Components
|
||||
1. **Location:** Add to appropriate subdirectory under `src/backend/base/langflow/components/`
|
||||
2. **Import:** Update `__init__.py` with alphabetical imports:
|
||||
```python
|
||||
from .my_component import MyComponent
|
||||
|
||||
__all__ = [
|
||||
"ExistingComponent",
|
||||
"MyComponent", # Add alphabetically
|
||||
]
|
||||
```
|
||||
3. **Auto-restart:** Backend auto-restarts on save
|
||||
4. **Browser refresh:** Refresh browser to see component changes
|
||||
|
||||
### Component Testing
|
||||
- **Unit Tests:** `src/backend/tests/unit/components/`
|
||||
- **Test Structure:** Mirror component directory structure
|
||||
- **Test Base Classes:** Use `ComponentTestBaseWithClient` or `ComponentTestBaseWithoutClient`
|
||||
- **Version Testing:** Provide `file_names_mapping` for backward compatibility
|
||||
|
||||
### Development Tips
|
||||
- **Fast iteration:** Edit component in UI first, then save to source
|
||||
- **Component updates:** Old components show "Updates Available" after backend restart
|
||||
- **Testing:** Create comprehensive unit tests for all new components
|
||||
|
||||
---
|
||||
|
||||
## 3. Backend Code Quality
|
||||
|
||||
### Formatting (CRITICAL)
|
||||
```bash
|
||||
make format_backend # Format Python code
|
||||
```
|
||||
**Important:** Run `make format_backend` _early and often_ (ideally before running linting or committing changes). It auto-corrects the majority of style issues, preventing lengthy manual fixes when lint errors surface later.
|
||||
|
||||
### Linting
|
||||
```bash
|
||||
make lint # Run linting checks
|
||||
```
|
||||
|
||||
### Testing
|
||||
```bash
|
||||
make unit_tests # Run backend unit tests
|
||||
```
|
||||
|
||||
### Pre-commit Workflow
|
||||
1. **Run `make format_backend`** (FIRST - saves time on lint fixes)
|
||||
2. Run `make lint`
|
||||
3. Run `make unit_tests`
|
||||
4. Commit changes
|
||||
|
||||
---
|
||||
|
||||
## 4. FastAPI Development
|
||||
|
||||
### API Structure
|
||||
```
|
||||
src/backend/base/langflow/api/
|
||||
├── v1/ # API version 1
|
||||
│ ├── chat.py # Chat endpoints
|
||||
│ ├── flows.py # Flow management
|
||||
│ ├── users.py # User management
|
||||
│ └── ...
|
||||
└── v2/ # API version 2 (future)
|
||||
```
|
||||
|
||||
### Testing APIs
|
||||
- Use `client` fixture from `conftest.py`
|
||||
- Test with `logged_in_headers` for authenticated endpoints
|
||||
- Example:
|
||||
```python
|
||||
async def test_flows_endpoint(client, logged_in_headers):
|
||||
response = await client.post(
|
||||
"api/v1/flows/",
|
||||
json=flow_data,
|
||||
headers=logged_in_headers
|
||||
)
|
||||
assert response.status_code == 201
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. Database Development
|
||||
|
||||
### Models Location
|
||||
```
|
||||
src/backend/base/langflow/services/database/models/
|
||||
├── api_key/ # API key models
|
||||
├── flow/ # Flow models
|
||||
├── folder/ # Folder models
|
||||
├── user/ # User models
|
||||
└── ...
|
||||
```
|
||||
|
||||
### Database Testing
|
||||
- Use in-memory SQLite for tests
|
||||
- Database tests may fail in batch runs - run individually if needed:
|
||||
```bash
|
||||
uv run pytest src/backend/tests/unit/test_database.py
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 6. Async Development Patterns
|
||||
|
||||
### Component Async Methods
|
||||
```python
|
||||
async def run(self) -> MessageType:
|
||||
"""Main component execution method."""
|
||||
# Use await for async operations
|
||||
result = await self.async_operation()
|
||||
return result
|
||||
|
||||
async def message_response(self) -> Message:
|
||||
"""Return a Message object for chat components."""
|
||||
return Message(
|
||||
text=self.input_value,
|
||||
sender=self.sender,
|
||||
session_id=self.session_id,
|
||||
)
|
||||
```
|
||||
|
||||
### Background Tasks
|
||||
```python
|
||||
import asyncio
|
||||
|
||||
async def process_in_background(self):
|
||||
"""Process items without blocking."""
|
||||
# Use asyncio.create_task for background work
|
||||
task = asyncio.create_task(self.heavy_operation())
|
||||
|
||||
# Ensure proper cleanup
|
||||
try:
|
||||
result = await task
|
||||
return result
|
||||
except asyncio.CancelledError:
|
||||
# Handle cancellation gracefully
|
||||
await self.cleanup()
|
||||
raise
|
||||
```
|
||||
|
||||
### Queue Operations
|
||||
```python
|
||||
async def queue_processing(self):
|
||||
"""Non-blocking queue operations."""
|
||||
queue = asyncio.Queue()
|
||||
|
||||
# Non-blocking put
|
||||
queue.put_nowait(data)
|
||||
|
||||
# Timeout-controlled get
|
||||
try:
|
||||
result = await asyncio.wait_for(queue.get(), timeout=5.0)
|
||||
return result
|
||||
except asyncio.TimeoutError:
|
||||
# Handle timeout appropriately
|
||||
raise ComponentError("Processing timeout")
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 7. Component Integration Testing
|
||||
|
||||
### Flow Testing
|
||||
```python
|
||||
from tests.unit.build_utils import create_flow, build_flow, get_build_events
|
||||
|
||||
async def test_component_in_flow(client, json_flow, logged_in_headers):
|
||||
"""Test component within a complete flow."""
|
||||
flow_id = await create_flow(client, json_flow, logged_in_headers)
|
||||
build_response = await build_flow(client, flow_id, logged_in_headers)
|
||||
|
||||
# Validate flow execution
|
||||
job_id = build_response["job_id"]
|
||||
events_response = await get_build_events(client, job_id, logged_in_headers)
|
||||
assert events_response.status_code == 200
|
||||
```
|
||||
|
||||
### External API Testing
|
||||
```python
|
||||
@pytest.mark.api_key_required
|
||||
@pytest.mark.no_blockbuster
|
||||
async def test_with_real_api(self):
|
||||
"""Test component with external service."""
|
||||
api_key = os.getenv("OPENAI_API_KEY")
|
||||
component = MyComponent(api_key=api_key, model="gpt-4o")
|
||||
|
||||
response = await component.run()
|
||||
assert response is not None
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 8. Known Backend Issues
|
||||
|
||||
### Testing Quirks
|
||||
- `test_database.py` may fail in batch runs but pass individually
|
||||
- Use `@pytest.mark.no_blockbuster` to skip blockbuster plugin when needed
|
||||
- Context variables may not propagate correctly in `asyncio.to_thread` - test both patterns
|
||||
|
||||
### File Changes
|
||||
- Starter project files auto-format after `langflow run`
|
||||
- These formatting changes can be committed or ignored
|
||||
|
||||
---
|
||||
|
||||
## Backend Development Checklist
|
||||
- [ ] Component added to appropriate subdirectory
|
||||
- [ ] `__init__.py` updated with alphabetical imports
|
||||
- [ ] Code formatted with `make format_backend` (FIRST)
|
||||
- [ ] Linting passed with `make lint`
|
||||
- [ ] Unit tests created and passing with `make unit_tests`
|
||||
- [ ] Component tested in UI with backend restart + browser refresh
|
||||
- [ ] Version mapping provided for backward compatibility
|
||||
- [ ] Async patterns implemented correctly with proper cleanup
|
||||
- [ ] External API calls use appropriate pytest markers
|
||||
@@ -1,82 +0,0 @@
|
||||
---
|
||||
description: "Guide for creating a basic Langflow component, including requirements gathering, component structure, and implementation best practices."
|
||||
globs:
|
||||
- "src/backend/**/components/**/*.py"
|
||||
- "src/backend/base/langflow/components/**/*.py"
|
||||
alwaysApply: false
|
||||
---
|
||||
|
||||
|
||||
# Rule: How to Create a Basic Langflow Component
|
||||
|
||||
## Purpose
|
||||
Guide for Creating a Langflow Component
|
||||
|
||||
---
|
||||
|
||||
### 1. Gather Requirements
|
||||
|
||||
Ask the user for:
|
||||
- **Component Name:** What should the component be called?
|
||||
- **Description:** What does the component do?
|
||||
- **Inputs:** What are the required inputs? (e.g., text, dropdown, boolean, etc.)
|
||||
- **Outputs:** What should the component output? (e.g., a message, a value, etc.)
|
||||
- **Category:** Which component category should this component be stored under in `langflow/src/backend/base/langflow/components`
|
||||
|
||||
### 2. Define the Component
|
||||
|
||||
- Inherit from `Component`.
|
||||
- Set `display_name`, `description`, `icon`.
|
||||
- Define the `inputs` and `outputs` as lists of input/output field objects (e.g., `DropdownInput`, `MessageTextInput`, `Output`).
|
||||
- Implement the main logic as a method (e.g., `get_current_date`, `true_response`, etc.).
|
||||
|
||||
### 3. Example: Conditional If-Else Component
|
||||
|
||||
```python
|
||||
class ConditionalRouterComponent(Component):
|
||||
display_name = "If-Else"
|
||||
description = "Routes an input message to a corresponding output based on text comparison."
|
||||
icon = "split"
|
||||
name = "ConditionalRouter"
|
||||
inputs = [
|
||||
# Define your inputs here
|
||||
]
|
||||
outputs = [
|
||||
# Define your outputs here
|
||||
]
|
||||
# Implement your logic methods here
|
||||
```
|
||||
|
||||
### 4. Example: Current Date Component
|
||||
|
||||
```python
|
||||
class CurrentDateComponent(Component):
|
||||
display_name = "Current Date"
|
||||
description = "Returns the current date and time in the selected timezone."
|
||||
icon = "clock"
|
||||
name = "CurrentDate"
|
||||
inputs = [
|
||||
# Define your inputs here
|
||||
]
|
||||
outputs = [
|
||||
# Define your outputs here
|
||||
]
|
||||
# Implement your logic methods here
|
||||
```
|
||||
|
||||
### 5. Best Practices
|
||||
|
||||
- Use clear and descriptive names for inputs and outputs.
|
||||
- Provide helpful `info` for each input to guide users.
|
||||
- Handle errors gracefully and provide meaningful error messages.
|
||||
- Use appropriate icons to visually represent the component's function.
|
||||
- Use a Lucide icon or if you want a custom icon follow the icon rules (`./cursor/rules/icons.mdc`)
|
||||
|
||||
---
|
||||
|
||||
## Checklist for Creating a Component
|
||||
- [ ] Ask the user for component name, description, inputs, and outputs.
|
||||
- [ ] Define the component class with the required fields.
|
||||
- [ ] Implement the main logic.
|
||||
- [ ] Add helpful info and error handling.
|
||||
- [ ] Test the component.
|
||||
@@ -1,443 +0,0 @@
|
||||
---
|
||||
description: "Guidelines for developing and maintaining Langflow documentation using Docusaurus, including content structure, style, and deployment processes."
|
||||
globs:
|
||||
- "docs/**/*.{md,mdx}"
|
||||
- "docs/**/*.{js,jsx,ts,tsx}"
|
||||
- "docs/package*.json"
|
||||
- "docs/docusaurus.config.js"
|
||||
- "docs/sidebars.js"
|
||||
- "docs/static/**/*"
|
||||
alwaysApply: false
|
||||
---
|
||||
|
||||
|
||||
# Documentation Development Guidelines
|
||||
|
||||
## Purpose
|
||||
Guidelines for developing and maintaining Langflow documentation using Docusaurus, including content structure, style, and deployment processes.
|
||||
|
||||
---
|
||||
|
||||
## 1. Documentation Environment Setup
|
||||
|
||||
### Prerequisites
|
||||
- **Node.js:** v22.12 LTS for runtime
|
||||
- **Package Manager:** npm for dependency management
|
||||
- **Documentation Framework:** Docusaurus v3
|
||||
|
||||
### Documentation Service
|
||||
```bash
|
||||
cd docs
|
||||
npm install # Install dependencies
|
||||
npm run start # Start dev server (usually port 3001)
|
||||
```
|
||||
- Auto-reloads on documentation changes
|
||||
- Access at: http://localhost:3001/
|
||||
- Documentation source: `docs/`
|
||||
|
||||
---
|
||||
|
||||
## 2. Documentation Structure
|
||||
|
||||
### Directory Layout
|
||||
```
|
||||
docs/
|
||||
├── docs/ # Main documentation content
|
||||
│ ├── agents/ # Agent and MCP guides
|
||||
│ ├── get-started/ # Getting started guides
|
||||
│ ├── tutorials/ # Langflow tutorials
|
||||
│ ├── components/ # Component documentation
|
||||
│ ├── flows/ # Guides to build, run, and test flows
|
||||
│ ├── deployment/ # Guides for deploying and hosting a Langflow server
|
||||
│ ├── develop/ # Guides for developing apps with Langflow
|
||||
│ ├── support/ # Help and release notes
|
||||
│ ├── contributing/ # Contribution guidelines
|
||||
│ └── api-reference/ # API documentation
|
||||
├── src/ # Custom React components
|
||||
├── static/ # Static assets (images, etc.)
|
||||
├── sidebars.js # Sidebar configuration
|
||||
├── docusaurus.config.js # Main configuration
|
||||
└── package.json # Dependencies
|
||||
```
|
||||
|
||||
### Content Types
|
||||
- **Guides:** Step-by-step tutorials (`docs/getting-started/`)
|
||||
- **Reference:** API and component reference (`docs/api-reference/`)
|
||||
- **How-to:** Problem-solving articles (`docs/components/`)
|
||||
- **Concepts:** Explanatory articles about Langflow concepts
|
||||
- **Blog:** Release notes, announcements (`blog/`)
|
||||
|
||||
---
|
||||
|
||||
## 3. Writing Documentation
|
||||
|
||||
### Markdown Conventions
|
||||
```markdown
|
||||
---
|
||||
title: Page Title
|
||||
description: Brief description for SEO
|
||||
sidebar_position: 1
|
||||
---
|
||||
|
||||
# Page Title
|
||||
|
||||
Brief introduction paragraph.
|
||||
|
||||
## Section Header
|
||||
|
||||
Content with proper formatting.
|
||||
|
||||
### Subsection
|
||||
|
||||
More detailed content.
|
||||
|
||||
:::tip
|
||||
Use admonitions for important information.
|
||||
:::
|
||||
|
||||
:::warning
|
||||
Use warnings for potential issues.
|
||||
:::
|
||||
|
||||
:::danger
|
||||
Use danger for critical warnings.
|
||||
:::
|
||||
```
|
||||
|
||||
### Code Blocks
|
||||
````markdown
|
||||
```python title="component_example.py"
|
||||
from langflow.components.base import Component
|
||||
|
||||
class MyComponent(Component):
|
||||
display_name = "My Component"
|
||||
description = "Example component"
|
||||
|
||||
def run(self):
|
||||
return "Hello, World!"
|
||||
```
|
||||
````
|
||||
|
||||
### Images and Assets
|
||||
```markdown
|
||||
<!-- Images go in static/img/ -->
|
||||

|
||||
|
||||
<!-- Use descriptive alt text -->
|
||||

|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. Component Documentation
|
||||
|
||||
### Component Page Template
|
||||
```markdown
|
||||
---
|
||||
title: Component Name
|
||||
description: Brief description of what the component does
|
||||
sidebar_position: 1
|
||||
---
|
||||
|
||||
# Component Name
|
||||
|
||||
Brief overview of the component's purpose.
|
||||
|
||||
## Overview
|
||||
|
||||
What this component does and when to use it.
|
||||
|
||||
## Configuration
|
||||
|
||||
### Inputs
|
||||
|
||||
| Input | Type | Required | Description |
|
||||
|-------|------|----------|-------------|
|
||||
| `input_text` | String | Yes | The text to process |
|
||||
| `model_name` | String | No | Model to use (default: gpt-3.5-turbo) |
|
||||
|
||||
### Outputs
|
||||
|
||||
| Output | Type | Description |
|
||||
|--------|------|-------------|
|
||||
| `result` | Message | Processed result |
|
||||
|
||||
## Usage Example
|
||||
|
||||
```python
|
||||
# Example of using the component
|
||||
component = MyComponent(
|
||||
input_text="Hello, world!",
|
||||
model_name="gpt-4"
|
||||
)
|
||||
result = component.run()
|
||||
```
|
||||
|
||||
## Common Issues
|
||||
|
||||
### Issue: Component not loading
|
||||
|
||||
**Solution:** Check that all required inputs are provided.
|
||||
|
||||
### Issue: API key errors
|
||||
|
||||
**Solution:** Ensure your API key is properly configured.
|
||||
```
|
||||
|
||||
### API Documentation
|
||||
```markdown
|
||||
---
|
||||
title: API Endpoint
|
||||
description: REST API endpoint documentation
|
||||
---
|
||||
|
||||
# API Endpoint Name
|
||||
|
||||
## Endpoint
|
||||
|
||||
`POST /api/v1/endpoint`
|
||||
|
||||
## Request
|
||||
|
||||
### Headers
|
||||
```json
|
||||
{
|
||||
"Authorization": "Bearer <token>",
|
||||
"Content-Type": "application/json"
|
||||
}
|
||||
```
|
||||
|
||||
### Body
|
||||
```json
|
||||
{
|
||||
"parameter": "value",
|
||||
"optional_param": "optional_value"
|
||||
}
|
||||
```
|
||||
|
||||
## Response
|
||||
|
||||
### Success (200)
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"data": {
|
||||
"result": "success"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Error (400)
|
||||
```json
|
||||
{
|
||||
"success": false,
|
||||
"error": "Error message"
|
||||
}
|
||||
```
|
||||
|
||||
## Example
|
||||
|
||||
```bash
|
||||
curl -X POST http://localhost:7860/api/v1/endpoint \
|
||||
-H "Authorization: Bearer your-token" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"parameter": "value"}'
|
||||
```
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. Blog Posts and Announcements
|
||||
|
||||
### Blog Post Template
|
||||
```markdown
|
||||
---
|
||||
title: "Release: Langflow v1.1.0"
|
||||
description: "New features and improvements in Langflow v1.1.0"
|
||||
authors: [author-name]
|
||||
date: 2024-01-15
|
||||
tags: [release, features]
|
||||
---
|
||||
|
||||
# Release: Langflow v1.1.0
|
||||
|
||||
Brief introduction to the release.
|
||||
|
||||
## New Features
|
||||
|
||||
### Feature 1
|
||||
Description of the feature and how to use it.
|
||||
|
||||
### Feature 2
|
||||
Another feature description.
|
||||
|
||||
## Improvements
|
||||
|
||||
- List of improvements
|
||||
- Bug fixes
|
||||
- Performance enhancements
|
||||
|
||||
## Breaking Changes
|
||||
|
||||
:::warning
|
||||
List any breaking changes that require user action.
|
||||
:::
|
||||
|
||||
## Migration Guide
|
||||
|
||||
Steps to migrate from previous versions.
|
||||
```
|
||||
|
||||
### Announcement Posts
|
||||
```markdown
|
||||
---
|
||||
title: "Announcement: New Integration"
|
||||
description: "Langflow now supports integration with XYZ service"
|
||||
authors: [author-name]
|
||||
date: 2024-01-15
|
||||
tags: [announcement, integration]
|
||||
---
|
||||
|
||||
Brief announcement content with clear call-to-action.
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 6. Configuration
|
||||
|
||||
### Sidebar Configuration (`sidebars.js`)
|
||||
```javascript
|
||||
module.exports = {
|
||||
docs: [
|
||||
'introduction',
|
||||
{
|
||||
type: 'category',
|
||||
label: 'Getting Started',
|
||||
items: [
|
||||
'getting-started/installation',
|
||||
'getting-started/quickstart',
|
||||
'getting-started/first-flow',
|
||||
],
|
||||
},
|
||||
{
|
||||
type: 'category',
|
||||
label: 'Components',
|
||||
items: [
|
||||
'components/overview',
|
||||
'components/inputs',
|
||||
'components/outputs',
|
||||
'components/processing',
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
```
|
||||
|
||||
### Site Configuration (`docusaurus.config.js`)
|
||||
```javascript
|
||||
module.exports = {
|
||||
title: 'Langflow Documentation',
|
||||
tagline: 'Build AI flows visually',
|
||||
url: 'https://docs.langflow.org',
|
||||
baseUrl: '/',
|
||||
|
||||
themeConfig: {
|
||||
navbar: {
|
||||
title: 'Langflow',
|
||||
logo: {
|
||||
alt: 'Langflow Logo',
|
||||
src: 'img/logo.svg',
|
||||
},
|
||||
items: [
|
||||
{
|
||||
type: 'doc',
|
||||
docId: 'introduction',
|
||||
position: 'left',
|
||||
label: 'Docs',
|
||||
},
|
||||
{
|
||||
to: '/blog',
|
||||
label: 'Blog',
|
||||
position: 'left'
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
};
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 7. Documentation Testing
|
||||
|
||||
### Link Checking
|
||||
```bash
|
||||
# Check for broken internal links
|
||||
npm run build
|
||||
npm run serve
|
||||
# Manual testing or use link checker tools
|
||||
```
|
||||
|
||||
### Content Review
|
||||
- **Accuracy:** Verify all code examples work
|
||||
- **Completeness:** Ensure all features are documented
|
||||
- **Clarity:** Review for clear, concise language
|
||||
- **Navigation:** Test sidebar and cross-references
|
||||
|
||||
### Screenshots
|
||||
- Keep screenshots up-to-date with current UI
|
||||
- Use consistent browser/OS for screenshots
|
||||
- Highlight relevant UI elements
|
||||
- Use descriptive file names
|
||||
|
||||
---
|
||||
|
||||
## 8. Style Guide
|
||||
|
||||
### Writing Style
|
||||
- **Tone:** Professional but approachable
|
||||
- **Voice:** Second person ("you") for instructions
|
||||
- **Tense:** Present tense for current features
|
||||
- **Length:** Keep paragraphs short and scannable
|
||||
|
||||
### Formatting
|
||||
- **Headers:** Use sentence case
|
||||
- **Code:** Inline code with `backticks`
|
||||
- **Emphasis:** Use **bold** for UI elements, *italic* for emphasis
|
||||
- **Lists:** Use parallel structure
|
||||
|
||||
### Terminology
|
||||
- **Langflow:** Always capitalize
|
||||
- **Component:** Capitalize when referring to Langflow components
|
||||
- **Flow:** Capitalize when referring to Langflow flows
|
||||
- **API:** Always uppercase
|
||||
- **JSON:** Always uppercase
|
||||
|
||||
---
|
||||
|
||||
## 9. Deployment
|
||||
|
||||
### Local Testing
|
||||
```bash
|
||||
npm run build # Build static site
|
||||
npm run serve # Serve built site locally
|
||||
```
|
||||
|
||||
### Production Deployment
|
||||
- Documentation is automatically deployed on commit to main branch
|
||||
- Build artifacts go to `build/` directory
|
||||
- Static site is served via CDN
|
||||
|
||||
---
|
||||
|
||||
## Documentation Development Checklist
|
||||
- [ ] Documentation service running with `npm run start`
|
||||
- [ ] Content follows markdown conventions
|
||||
- [ ] Code examples are tested and working
|
||||
- [ ] Images have descriptive alt text
|
||||
- [ ] Internal links are functional
|
||||
- [ ] Sidebar navigation is updated
|
||||
- [ ] Content follows style guide
|
||||
- [ ] Screenshots are current and properly formatted
|
||||
- [ ] Cross-references between related topics
|
||||
- [ ] Build succeeds with `npm run build`
|
||||
@@ -1,374 +0,0 @@
|
||||
---
|
||||
description: "Guidelines for frontend development in Langflow, focusing on React/TypeScript UI components, build processes, and frontend testing."
|
||||
globs:
|
||||
- "src/frontend/**/*.{ts,tsx,js,jsx}"
|
||||
- "src/frontend/**/*.{css,scss,json}"
|
||||
- "src/frontend/package*.json"
|
||||
- "src/frontend/vite.config.*"
|
||||
- "src/frontend/tailwind.config.*"
|
||||
- "src/frontend/tsconfig.json"
|
||||
alwaysApply: false
|
||||
---
|
||||
|
||||
## 1. Frontend Environment Setup
|
||||
|
||||
### Prerequisites
|
||||
- **Node.js:** v22.12 LTS for JavaScript runtime
|
||||
- **Package Manager:** npm (v10.9) for dependency management
|
||||
- **Development Tools:** Vite for build tooling
|
||||
|
||||
### Frontend Service
|
||||
```bash
|
||||
make frontend # Start Vite dev server on port 3000
|
||||
```
|
||||
- Hot-reload enabled for UI changes
|
||||
- Access at: http://localhost:3000/
|
||||
- Frontend source: `src/frontend/`
|
||||
|
||||
---
|
||||
|
||||
## 2. Frontend Structure
|
||||
|
||||
### Directory Layout
|
||||
```
|
||||
src/frontend/src/
|
||||
├── components/ # Reusable UI components
|
||||
├── pages/ # Page-level components
|
||||
├── icons/ # Component icons and lazy loading
|
||||
├── stores/ # State management (Zustand)
|
||||
├── types/ # TypeScript type definitions
|
||||
├── utils/ # Utility functions
|
||||
├── hooks/ # Custom React hooks
|
||||
├── services/ # API service functions
|
||||
└── assets/ # Static assets
|
||||
```
|
||||
|
||||
### Key Technologies
|
||||
- **React 18** with TypeScript
|
||||
- **Vite** for build tooling and dev server
|
||||
- **Tailwind CSS** for styling
|
||||
- **Zustand** for state management
|
||||
- **React Flow** for flow graph visualization
|
||||
- **Lucide React** for icons
|
||||
|
||||
---
|
||||
|
||||
## 3. Frontend Code Quality
|
||||
|
||||
### Formatting
|
||||
```bash
|
||||
make format_frontend # Format TypeScript/JavaScript code
|
||||
```
|
||||
|
||||
### Linting
|
||||
```bash
|
||||
make lint # Run ESLint and TypeScript checks
|
||||
```
|
||||
|
||||
### Testing
|
||||
```bash
|
||||
make tests_frontend # Run frontend tests (requires additional setup)
|
||||
```
|
||||
|
||||
### Pre-commit Workflow
|
||||
1. Run `make format_frontend`
|
||||
2. Run `make lint`
|
||||
3. Test changes in browser
|
||||
4. Commit changes
|
||||
|
||||
---
|
||||
|
||||
## 4. State Management
|
||||
|
||||
### Zustand Stores
|
||||
```typescript
|
||||
// stores/myStore.ts
|
||||
import { create } from 'zustand';
|
||||
|
||||
interface MyState {
|
||||
value: string;
|
||||
setValue: (value: string) => void;
|
||||
}
|
||||
|
||||
export const useMyStore = create<MyState>((set) => ({
|
||||
value: '',
|
||||
setValue: (value) => set({ value }),
|
||||
}));
|
||||
```
|
||||
|
||||
### Using Stores in Components
|
||||
```typescript
|
||||
// components/MyComponent.tsx
|
||||
import { useMyStore } from '@/stores/myStore';
|
||||
|
||||
export function MyComponent() {
|
||||
const { value, setValue } = useMyStore();
|
||||
|
||||
return (
|
||||
<input
|
||||
value={value}
|
||||
onChange={(e) => setValue(e.target.value)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. API Integration
|
||||
|
||||
### Service Functions
|
||||
```typescript
|
||||
// services/api.ts
|
||||
import { api } from '@/controllers/API';
|
||||
|
||||
export async function createFlow(flowData: FlowData) {
|
||||
const response = await api.post('/flows/', flowData);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
export async function getFlows() {
|
||||
const response = await api.get('/flows/');
|
||||
return response.data;
|
||||
}
|
||||
```
|
||||
|
||||
### Error Handling
|
||||
```typescript
|
||||
// hooks/useApi.ts
|
||||
import { useState, useCallback } from 'react';
|
||||
|
||||
export function useApi<T>(apiFunction: (...args: any[]) => Promise<T>) {
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const execute = useCallback(async (...args: any[]) => {
|
||||
try {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
const result = await apiFunction(...args);
|
||||
return result;
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Unknown error');
|
||||
throw err;
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [apiFunction]);
|
||||
|
||||
return { execute, loading, error };
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 6. React Flow Integration
|
||||
|
||||
### Flow Graph Components
|
||||
```typescript
|
||||
// components/FlowGraph.tsx
|
||||
import ReactFlow, {
|
||||
Node,
|
||||
Edge,
|
||||
Controls,
|
||||
Background
|
||||
} from 'reactflow';
|
||||
|
||||
interface FlowGraphProps {
|
||||
nodes: Node[];
|
||||
edges: Edge[];
|
||||
onNodesChange: (changes: NodeChange[]) => void;
|
||||
onEdgesChange: (changes: EdgeChange[]) => void;
|
||||
}
|
||||
|
||||
export function FlowGraph({
|
||||
nodes,
|
||||
edges,
|
||||
onNodesChange,
|
||||
onEdgesChange
|
||||
}: FlowGraphProps) {
|
||||
return (
|
||||
<div className="w-full h-full">
|
||||
<ReactFlow
|
||||
nodes={nodes}
|
||||
edges={edges}
|
||||
onNodesChange={onNodesChange}
|
||||
onEdgesChange={onEdgesChange}
|
||||
fitView
|
||||
>
|
||||
<Controls />
|
||||
<Background />
|
||||
</ReactFlow>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### Custom Node Types
|
||||
```typescript
|
||||
// components/nodes/ComponentNode.tsx
|
||||
import { memo } from 'react';
|
||||
import { Handle, Position } from 'reactflow';
|
||||
|
||||
interface ComponentNodeProps {
|
||||
data: {
|
||||
label: string;
|
||||
icon?: string;
|
||||
};
|
||||
}
|
||||
|
||||
export const ComponentNode = memo(({ data }: ComponentNodeProps) => {
|
||||
return (
|
||||
<div className="px-4 py-2 shadow-md rounded-md bg-white border">
|
||||
<Handle type="target" position={Position.Top} />
|
||||
|
||||
<div className="flex items-center">
|
||||
{data.icon && (
|
||||
<img src={data.icon} alt="" className="w-4 h-4 mr-2" />
|
||||
)}
|
||||
<span className="text-sm">{data.label}</span>
|
||||
</div>
|
||||
|
||||
<Handle type="source" position={Position.Bottom} />
|
||||
</div>
|
||||
);
|
||||
});
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 7. Styling with Tailwind
|
||||
|
||||
### Component Styling
|
||||
```typescript
|
||||
// components/Button.tsx
|
||||
import { cn } from '@/utils/cn';
|
||||
|
||||
interface ButtonProps {
|
||||
variant?: 'primary' | 'secondary';
|
||||
size?: 'sm' | 'md' | 'lg';
|
||||
children: React.ReactNode;
|
||||
onClick?: () => void;
|
||||
}
|
||||
|
||||
export function Button({
|
||||
variant = 'primary',
|
||||
size = 'md',
|
||||
children,
|
||||
onClick
|
||||
}: ButtonProps) {
|
||||
return (
|
||||
<button
|
||||
className={cn(
|
||||
'rounded-md font-medium transition-colors',
|
||||
{
|
||||
'bg-blue-600 hover:bg-blue-700 text-white': variant === 'primary',
|
||||
'bg-gray-200 hover:bg-gray-300 text-gray-900': variant === 'secondary',
|
||||
'px-2 py-1 text-sm': size === 'sm',
|
||||
'px-4 py-2 text-base': size === 'md',
|
||||
'px-6 py-3 text-lg': size === 'lg',
|
||||
}
|
||||
)}
|
||||
onClick={onClick}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### Dark Mode Support
|
||||
```typescript
|
||||
// hooks/useDarkMode.ts
|
||||
import { useDarkStore } from '@/stores/darkStore';
|
||||
|
||||
export function useDarkMode() {
|
||||
const { dark, setDark } = useDarkStore();
|
||||
|
||||
const toggle = () => setDark(!dark);
|
||||
|
||||
return { isDark: dark, toggle };
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 8. Frontend Testing
|
||||
|
||||
### Component Testing
|
||||
```typescript
|
||||
// __tests__/Button.test.tsx
|
||||
import { render, screen, fireEvent } from '@testing-library/react';
|
||||
import { Button } from '@/components/Button';
|
||||
|
||||
describe('Button', () => {
|
||||
it('renders with correct text', () => {
|
||||
render(<Button>Click me</Button>);
|
||||
expect(screen.getByText('Click me')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('calls onClick when clicked', () => {
|
||||
const handleClick = jest.fn();
|
||||
render(<Button onClick={handleClick}>Click me</Button>);
|
||||
|
||||
fireEvent.click(screen.getByText('Click me'));
|
||||
expect(handleClick).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
### Integration Testing
|
||||
```typescript
|
||||
// __tests__/FlowEditor.test.tsx
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { FlowEditor } from '@/pages/FlowEditor';
|
||||
|
||||
describe('FlowEditor', () => {
|
||||
it('loads flow data correctly', async () => {
|
||||
render(<FlowEditor flowId="test-flow-id" />);
|
||||
|
||||
// Wait for flow to load
|
||||
await screen.findByText('Flow loaded');
|
||||
|
||||
expect(screen.getByTestId('flow-canvas')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 9. Build and Deployment
|
||||
|
||||
### Development Build
|
||||
```bash
|
||||
make build_frontend # Build frontend static files
|
||||
```
|
||||
|
||||
### Production Build
|
||||
```bash
|
||||
cd src/frontend
|
||||
npm run build # Creates dist/ directory
|
||||
```
|
||||
|
||||
### Build Integration
|
||||
- Frontend builds to `src/frontend/dist/`
|
||||
- Backend serves static files from this directory in production
|
||||
- `make init` includes frontend build step
|
||||
---
|
||||
|
||||
## Frontend Development Checklist
|
||||
- [ ] Frontend service running with `make frontend`
|
||||
- [ ] Changes hot-reload correctly in browser
|
||||
- [ ] State management uses Zustand stores
|
||||
- [ ] API calls use proper error handling
|
||||
- [ ] Components styled with Tailwind CSS
|
||||
- [ ] Dark mode support implemented where needed
|
||||
- [ ] Code formatted with `make format_frontend`
|
||||
- [ ] Linting passed with `make lint`
|
||||
- [ ] Changes tested in both light and dark mode
|
||||
|
||||
- [ ] Components styled with Tailwind CSS
|
||||
- [ ] Dark mode support implemented where needed
|
||||
- [ ] Code formatted with `make format_frontend`
|
||||
- [ ] Changes tested in both light and dark mode
|
||||
|
||||
@@ -1,137 +0,0 @@
|
||||
---
|
||||
description: "Rules for creating and implementing component icons in Langflow, covering both backend Python component icon attributes and frontend React icon implementation."
|
||||
globs:
|
||||
- "src/frontend/src/icons/**/*"
|
||||
- "src/frontend/src/icons/lazyIconImports.ts"
|
||||
- "src/backend/**/*component*.py"
|
||||
- "src/backend/**/components/**/*.py"
|
||||
alwaysApply: false
|
||||
---
|
||||
|
||||
# Component Icon Rules
|
||||
|
||||
## Purpose
|
||||
|
||||
To ensure consistent, clear, and functional icon usage for components, covering both backend (Python) and frontend (React/TypeScript) steps.
|
||||
|
||||
---
|
||||
|
||||
## 1. Backend (Python) — Setting the Icon Name
|
||||
|
||||
- **Where:** In your component class (e.g., in `src/backend/base/langflow/components/vectorstores/astradb.py`)
|
||||
- **How:**
|
||||
Set the `icon` attribute to a string matching the icon you want to use.
|
||||
```python
|
||||
icon = "AstraDB"
|
||||
```
|
||||
- **Tip:**
|
||||
The string must match the frontend icon mapping exactly (case-sensitive).
|
||||
|
||||
---
|
||||
|
||||
## 2. Frontend (React/TypeScript) — Adding the Icon
|
||||
|
||||
### a. Create the Icon Component
|
||||
|
||||
- **Where:**
|
||||
In a new directory for your icon, e.g., `src/frontend/src/icons/AstraDB/`.
|
||||
- **How:**
|
||||
|
||||
- Add your SVG as a React component, e.g., `AstraSVG` in `AstraDB.jsx`.
|
||||
```jsx
|
||||
const AstraSVG = (props) => (
|
||||
<svg {...props}>
|
||||
<path
|
||||
// ...
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
```
|
||||
- Create an `index.tsx` that exports your icon using `forwardRef`:
|
||||
|
||||
```tsx
|
||||
import React, { forwardRef } from "react";
|
||||
import AstraSVG from "./AstraDB";
|
||||
|
||||
export const AstraDBIcon = forwardRef<
|
||||
SVGSVGElement,
|
||||
React.PropsWithChildren<{}>
|
||||
>((props, ref) => {
|
||||
return <AstraSVG ref={ref} isDark={isDark} {...props} />;
|
||||
});
|
||||
```
|
||||
|
||||
#### Supporting Light and Dark Mode Icons
|
||||
|
||||
- **How:**
|
||||
- In your SVG component (e.g., `AstraDB.jsx`), use the `isDark` prop to switch colors:
|
||||
```jsx
|
||||
const AstraSVG = (props) => (
|
||||
<svg {...props}>
|
||||
<path
|
||||
fill={props.isDark ? "#ffffff" : "#0A0A0A"}
|
||||
// ...
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
```
|
||||
- The `isDark` prop is passed from the icon wrapper (see above) and should be used to toggle between light and dark color schemes.
|
||||
- You can use a utility like `stringToBool` to ensure the prop is interpreted correctly.
|
||||
|
||||
### b. Add to Lazy Icon Imports
|
||||
|
||||
- **Where:**
|
||||
In `src/frontend/src/icons/lazyIconImports.ts`
|
||||
- **How:**
|
||||
Add an entry to the `lazyIconsMapping` object:
|
||||
```ts
|
||||
AstraDB: () =>
|
||||
import("@/icons/AstraDB").then((mod) => ({ default: mod.AstraDBIcon })),
|
||||
```
|
||||
- **Tip:**
|
||||
The key (`AstraDB`) must match the string used in the backend.
|
||||
|
||||
---
|
||||
|
||||
## 3. Best Practices
|
||||
|
||||
- **Naming:**
|
||||
Use clear, recognizable names (e.g., `"AstraDB"`, `"Postgres"`, `"OpenAI"`).
|
||||
- **Consistency:**
|
||||
Always use the same icon name for the same service across backend and frontend.
|
||||
- **Missing Icon:**
|
||||
If no icon exists, use a [lucide icon](https://lucide.dev/icons)
|
||||
- **Light/Dark Mode:**
|
||||
Always support both light and dark mode for custom icons by using the `isDark` prop in your SVG.
|
||||
|
||||
---
|
||||
|
||||
## 4. Checklist for Adding a New Icon
|
||||
|
||||
- [ ] Decide on a clear, descriptive icon name (e.g., `AstraDB`).
|
||||
- [ ] In your Python component, set `icon = "YourIconName"`.
|
||||
- [ ] Create a new icon directory in `src/frontend/src/icons/YourIconName/`.
|
||||
- [ ] Add your SVG as a React component (e.g., `YourIconNameIcon.jsx`).
|
||||
- [ ] Create an `index.tsx` that exports your icon using `forwardRef` and passes the `isDark` prop.
|
||||
- [ ] Add your icon to `lazyIconsMapping` in `src/frontend/src/icons/lazyIconImports.ts` with the exact same name.
|
||||
- [ ] Verify the icon appears correctly in the UI in both light and dark mode.
|
||||
- [ ] If no suitable icon exists, use a generic icon and request a new one if needed.
|
||||
|
||||
---
|
||||
|
||||
**Example for AstraDB:**
|
||||
|
||||
- Backend:
|
||||
```python
|
||||
icon = "AstraDB"
|
||||
```
|
||||
- Frontend:
|
||||
- `src/icons/AstraDB/AstraDB.jsx` (SVG as React component, uses `isDark` prop)
|
||||
- `src/icons/AstraDB/index.tsx` (exports `AstraDBIcon` and passes `isDark`)
|
||||
- Add to `lazyIconImports.ts`:
|
||||
```ts
|
||||
AstraDB: () =>
|
||||
import("@/icons/AstraDB").then((mod) => ({ default: mod.AstraDBIcon })),
|
||||
```
|
||||
|
||||
---
|
||||
@@ -1,561 +0,0 @@
|
||||
---
|
||||
description: "Guidelines for testing Langflow components and backend code, with emphasis on async patterns and robust, well-documented testing practices."
|
||||
globs:
|
||||
- "src/backend/tests/**/*.py"
|
||||
- "src/frontend/**/*.test.{ts,tsx,js,jsx}"
|
||||
- "src/frontend/**/*.spec.{ts,tsx,js,jsx}"
|
||||
- "tests/**/*.py"
|
||||
- "conftest.py"
|
||||
- "pytest.ini"
|
||||
alwaysApply: false
|
||||
---
|
||||
|
||||
# Testing Guidelines for Langflow
|
||||
|
||||
## Purpose
|
||||
Guidelines for testing Langflow components and backend code, with emphasis on async patterns and robust, well-documented testing practices.
|
||||
|
||||
---
|
||||
|
||||
## 1. Testing Structure
|
||||
|
||||
### Backend Tests Location
|
||||
- **Unit Tests:** `src/backend/tests/`
|
||||
- **Component Tests:** `src/backend/tests/unit/components/` (organized by component subdirectory)
|
||||
- **Integration Tests:** Available via `make integration_tests` (requires additional setup)
|
||||
|
||||
### Test File Naming
|
||||
- Use same filename as component with appropriate test prefix/suffix
|
||||
- Example: `my_component.py` → `test_my_component.py`
|
||||
|
||||
---
|
||||
|
||||
## 2. Built-in Fixtures & Base Classes
|
||||
|
||||
### `client` Fixture (FastAPI Test Client)
|
||||
- Defined in `src/backend/tests/conftest.py`
|
||||
- Provides an **async** `httpx.AsyncClient` connected to the full application via `ASGITransport` + `LifespanManager`.
|
||||
- Use it for API tests:
|
||||
```python
|
||||
async def test_login_endpoint(client):
|
||||
response = await client.post("api/v1/login", data={"username": "foo", "password": "bar"})
|
||||
assert response.status_code == 200
|
||||
```
|
||||
- Automatically configured with an **in-memory SQLite database** and mocked environment variables. No additional setup needed in individual tests.
|
||||
- Skip client creation by marking the test with `@pytest.mark.noclient`.
|
||||
|
||||
### `ComponentTestBase` Family
|
||||
Located in `src/backend/tests/base.py`.
|
||||
|
||||
| Base Class | Creates `client`? | Typical Use | Notes |
|
||||
|------------|------------------|-------------|-------|
|
||||
| `ComponentTestBase` | No | Core logic for component version testing | Requires you to provide fixtures described below. |
|
||||
| `ComponentTestBaseWithClient` | Yes (`@pytest.mark.usefixtures("client")`) | Components that need API access during `run()` | Inherit when the component interacts with backend services. |
|
||||
| `ComponentTestBaseWithoutClient` | No | Pure-logic components with no API calls | Lightweight alternative. |
|
||||
|
||||
Required fixtures for subclasses:
|
||||
1. `component_class` → the component **class** under test.
|
||||
2. `default_kwargs` → dict of kwargs to instantiate the component (can be empty).
|
||||
3. `file_names_mapping` → list of `VersionComponentMapping` dicts mapping historical **Langflow versions** to module/file names.
|
||||
|
||||
Example skeleton:
|
||||
```python
|
||||
from tests.base import ComponentTestBaseWithClient, VersionComponentMapping, DID_NOT_EXIST
|
||||
from langflow.components.my_namespace import MyComponent
|
||||
|
||||
class TestMyComponent(ComponentTestBaseWithClient):
|
||||
@pytest.fixture
|
||||
def component_class(self):
|
||||
return MyComponent
|
||||
|
||||
@pytest.fixture
|
||||
def default_kwargs(self):
|
||||
return {"foo": "bar"}
|
||||
|
||||
@pytest.fixture
|
||||
def file_names_mapping(self):
|
||||
return [
|
||||
VersionComponentMapping(version="1.1.1", module="my_module", file_name="my_component.py"),
|
||||
# Older versions can be mapped or DID_NOT_EXIST
|
||||
VersionComponentMapping(version="1.0.19", module="my_module", file_name=DID_NOT_EXIST),
|
||||
]
|
||||
```
|
||||
|
||||
`ComponentTestBase` automatically provides:
|
||||
- `test_latest_version` → Instantiates component via `component_class` and asserts `run()` doesn't return `None`.
|
||||
- `test_all_versions_have_a_file_name_defined` → Ensures mapping completeness vs `SUPPORTED_VERSIONS` constant (`src/backend/tests/constants.py`).
|
||||
- `test_component_versions` (parametrised) → Builds component from source for each supported version and asserts successful execution.
|
||||
|
||||
When adding a new component test, **inherit from the correct base class and provide the three fixtures**. This greatly reduces boilerplate and enforces version compatibility.
|
||||
|
||||
---
|
||||
|
||||
## 3. Component Testing Requirements
|
||||
|
||||
### Minimum Testing Requirements
|
||||
- **Unit Tests:** Create comprehensive unit tests for all new components
|
||||
- **Manual Test Documentation:** If unit tests are incomplete, create a Markdown file with manual testing steps
|
||||
- Location: Same directory as unit tests
|
||||
- Filename: Same as component but with `.md` extension
|
||||
- Content: Detailed manual testing steps and expected outcomes
|
||||
|
||||
### Testing Best Practices
|
||||
- Test both sync and async code paths
|
||||
- Mock external dependencies appropriately
|
||||
- Test error handling and edge cases
|
||||
- Validate input/output behavior
|
||||
- Test component initialization and configuration
|
||||
|
||||
---
|
||||
|
||||
## 4. Async Testing Patterns
|
||||
|
||||
### Async Component Testing
|
||||
```python
|
||||
import pytest
|
||||
import asyncio
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_async_component():
|
||||
# Test async component methods
|
||||
result = await component.async_method()
|
||||
assert result is not None
|
||||
```
|
||||
|
||||
### Testing Background Tasks
|
||||
```python
|
||||
@pytest.mark.asyncio
|
||||
async def test_background_task_completion():
|
||||
# Ensure background tasks complete properly
|
||||
task = asyncio.create_task(component.background_operation())
|
||||
result = await asyncio.wait_for(task, timeout=5.0)
|
||||
assert result.success
|
||||
```
|
||||
|
||||
### Testing Queue Operations
|
||||
```python
|
||||
@pytest.mark.asyncio
|
||||
async def test_queue_operations():
|
||||
# Test queue put/get operations without blocking
|
||||
queue = asyncio.Queue()
|
||||
|
||||
# Non-blocking put
|
||||
queue.put_nowait(test_data)
|
||||
|
||||
# Verify queue processing
|
||||
result = await asyncio.wait_for(queue.get(), timeout=1.0)
|
||||
assert result == test_data
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. Special Testing Considerations
|
||||
|
||||
### Blockbuster Plugin
|
||||
- Use `no_blockbuster` pytest marker to skip blockbuster plugin in tests
|
||||
- Example: `@pytest.mark.no_blockbuster`
|
||||
|
||||
### Database Tests
|
||||
- `test_database.py` may fail in batch runs but pass individually
|
||||
- Run problematic tests sequentially: `uv run pytest src/backend/tests/unit/test_database.py`
|
||||
- Consider this behavior when writing database-related tests
|
||||
|
||||
### Context Variables in Async Tests
|
||||
- Be aware of ContextVar propagation in async tests
|
||||
- Test both direct event loop execution and `asyncio.to_thread` scenarios
|
||||
- Ensure proper context isolation between test cases
|
||||
|
||||
---
|
||||
|
||||
## 6. Test Execution
|
||||
|
||||
### Running Tests
|
||||
```bash
|
||||
make unit_tests # Run all backend unit tests
|
||||
make integration_tests # Run integration tests (requires additional setup)
|
||||
make coverage # Run tests with coverage (requires additional setup)
|
||||
make tests_frontend # Run frontend tests (requires additional setup)
|
||||
```
|
||||
|
||||
### Individual Test Execution
|
||||
```bash
|
||||
# Run specific test file
|
||||
uv run pytest src/backend/tests/unit/test_specific_component.py
|
||||
|
||||
# Run specific test method
|
||||
uv run pytest src/backend/tests/unit/test_component.py::test_specific_method
|
||||
|
||||
# Run with verbose output
|
||||
uv run pytest -v src/backend/tests/unit/
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 7. Robust Testing Patterns
|
||||
|
||||
### Error Handling Tests
|
||||
```python
|
||||
def test_component_error_handling():
|
||||
"""Test that component handles errors gracefully."""
|
||||
with pytest.raises(ExpectedError):
|
||||
component.method_that_should_fail(invalid_input)
|
||||
|
||||
# Test error message quality
|
||||
try:
|
||||
component.risky_operation()
|
||||
except ComponentError as e:
|
||||
assert "helpful error message" in str(e)
|
||||
```
|
||||
|
||||
### Resource Cleanup Tests
|
||||
```python
|
||||
@pytest.fixture
|
||||
async def component_with_cleanup():
|
||||
"""Fixture ensuring proper resource cleanup."""
|
||||
component = MyComponent()
|
||||
await component.initialize()
|
||||
try:
|
||||
yield component
|
||||
finally:
|
||||
await component.cleanup()
|
||||
```
|
||||
|
||||
### Timeout and Performance Tests
|
||||
```python
|
||||
@pytest.mark.asyncio
|
||||
async def test_operation_timeout():
|
||||
"""Test that operations respect timeout constraints."""
|
||||
start_time = time.time()
|
||||
|
||||
with pytest.raises(asyncio.TimeoutError):
|
||||
await asyncio.wait_for(
|
||||
component.long_running_operation(),
|
||||
timeout=1.0
|
||||
)
|
||||
|
||||
elapsed = time.time() - start_time
|
||||
assert elapsed < 1.5 # Allow some tolerance
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 8. Documentation in Tests
|
||||
|
||||
### Test Documentation Standards
|
||||
- Each test should have a clear docstring explaining its purpose
|
||||
- Complex test setups should be commented
|
||||
- Mock usage should be documented
|
||||
- Expected behaviors should be explicitly stated
|
||||
|
||||
### Example Well-Documented Test
|
||||
```python
|
||||
async def test_component_background_processing():
|
||||
"""
|
||||
Test that component processes items in background without blocking.
|
||||
|
||||
This test verifies:
|
||||
1. Items are added to processing queue immediately
|
||||
2. Background processing completes successfully
|
||||
3. No deadlocks occur during shutdown
|
||||
4. All tasks are properly cleaned up
|
||||
"""
|
||||
# Setup component with background processing
|
||||
component = BackgroundComponent()
|
||||
await component.start()
|
||||
|
||||
try:
|
||||
# Add items for processing
|
||||
for i in range(10):
|
||||
await component.add_item(f"item_{i}")
|
||||
|
||||
# Verify items are queued (non-blocking)
|
||||
assert component.queue_size() == 10
|
||||
|
||||
# Wait for processing to complete
|
||||
await component.wait_for_completion(timeout=5.0)
|
||||
|
||||
# Verify all items processed
|
||||
assert component.processed_count() == 10
|
||||
|
||||
finally:
|
||||
# Ensure clean shutdown
|
||||
await component.stop()
|
||||
assert component.is_stopped()
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Testing Checklist
|
||||
- [ ] Unit tests created for all new components
|
||||
- [ ] Async patterns tested appropriately
|
||||
- [ ] Error handling and edge cases covered
|
||||
- [ ] Manual testing documentation created (if unit tests incomplete)
|
||||
- [ ] Background tasks and queues tested for proper cleanup
|
||||
- [ ] Database tests considered for sequential execution if needed
|
||||
- [ ] Appropriate pytest markers used (`no_blockbuster`, etc.)
|
||||
- [ ] Tests are well-documented with clear docstrings
|
||||
- [ ] Resource cleanup properly handled in fixtures
|
||||
- [ ] Timeout and performance constraints validated
|
||||
|
||||
---
|
||||
|
||||
## 9. Langflow-Specific Testing Patterns
|
||||
|
||||
### Message Testing
|
||||
Test Langflow's `Message` objects and chat functionality:
|
||||
|
||||
```python
|
||||
from langflow.schema.message import Message
|
||||
from langflow.utils.constants import MESSAGE_SENDER_AI, MESSAGE_SENDER_USER
|
||||
|
||||
async def test_message_response(self, component_class, default_kwargs):
|
||||
"""Test Message object creation and properties."""
|
||||
component = component_class(**default_kwargs)
|
||||
message = await component.message_response()
|
||||
|
||||
assert isinstance(message, Message)
|
||||
assert message.text == default_kwargs["input_value"]
|
||||
assert message.sender == default_kwargs["sender"]
|
||||
assert message.sender_name == default_kwargs["sender_name"]
|
||||
assert message.session_id == default_kwargs["session_id"]
|
||||
assert message.files == default_kwargs["files"]
|
||||
# Test message properties structure
|
||||
assert message.properties.model_dump() == {
|
||||
"background_color": default_kwargs["background_color"],
|
||||
"text_color": default_kwargs["text_color"],
|
||||
"icon": default_kwargs["chat_icon"],
|
||||
# ... other expected properties
|
||||
}
|
||||
```
|
||||
|
||||
### Flow Testing with JSON Data
|
||||
Use predefined JSON flows and utility functions:
|
||||
|
||||
```python
|
||||
from tests.unit.build_utils import create_flow, build_flow, get_build_events, consume_and_assert_stream
|
||||
|
||||
async def test_flow_execution(client, json_memory_chatbot_no_llm, logged_in_headers):
|
||||
"""Test flow creation, building, and execution."""
|
||||
# Create flow from JSON data
|
||||
flow_id = await create_flow(client, json_memory_chatbot_no_llm, logged_in_headers)
|
||||
|
||||
# Start the build process
|
||||
build_response = await build_flow(client, flow_id, logged_in_headers)
|
||||
job_id = build_response["job_id"]
|
||||
|
||||
# Get and validate event stream
|
||||
events_response = await get_build_events(client, job_id, logged_in_headers)
|
||||
assert events_response.status_code == codes.OK
|
||||
|
||||
# Process events and validate structure
|
||||
await consume_and_assert_stream(events_response, job_id)
|
||||
```
|
||||
|
||||
### Component Testing with Real Services
|
||||
Test components that need external APIs with proper markers:
|
||||
|
||||
```python
|
||||
@pytest.mark.api_key_required
|
||||
@pytest.mark.no_blockbuster
|
||||
async def test_component_with_external_api(self):
|
||||
"""Test component with external API integration."""
|
||||
api_key = os.getenv("OPENAI_API_KEY")
|
||||
|
||||
component = MyAPIComponent(
|
||||
api_key=api_key,
|
||||
model_name="gpt-4o",
|
||||
input_value="test input",
|
||||
session_id=str(uuid4()),
|
||||
)
|
||||
|
||||
response = await component.message_response()
|
||||
assert response.data.get("text") is not None
|
||||
```
|
||||
|
||||
### Mocking Language Models
|
||||
Use `MockLanguageModel` for testing without external APIs:
|
||||
|
||||
```python
|
||||
from tests.unit.mock_language_model import MockLanguageModel
|
||||
|
||||
@pytest.fixture
|
||||
def default_kwargs(self):
|
||||
return {
|
||||
"agent_llm": MockLanguageModel(),
|
||||
"input_value": "test message",
|
||||
"session_id": str(uuid4()),
|
||||
# ... other component-specific kwargs
|
||||
}
|
||||
```
|
||||
|
||||
### File Handling in Tests
|
||||
Use `anyio` and `aiofiles` for async file operations:
|
||||
|
||||
```python
|
||||
import aiofiles
|
||||
import anyio
|
||||
|
||||
async def test_file_operations(self, component_class, tmp_path):
|
||||
"""Test component file handling."""
|
||||
# Create temporary test file
|
||||
test_file = anyio.Path(tmp_path) / "test.txt"
|
||||
await test_file.write_text("Test content", encoding="utf-8")
|
||||
|
||||
# Test with file input
|
||||
component = component_class(files=[str(test_file)])
|
||||
result = await component.run()
|
||||
|
||||
# Verify file was processed
|
||||
assert len(result.files) == 1
|
||||
assert await test_file.exists()
|
||||
```
|
||||
|
||||
### API Endpoint Testing
|
||||
Test Langflow's REST API endpoints:
|
||||
|
||||
```python
|
||||
async def test_flows_endpoint(client, logged_in_headers):
|
||||
"""Test flow creation via API."""
|
||||
flow_data = {
|
||||
"name": "Test Flow",
|
||||
"description": "Test description",
|
||||
"data": {"nodes": [], "edges": []}
|
||||
}
|
||||
|
||||
response = await client.post(
|
||||
"api/v1/flows/",
|
||||
json=flow_data,
|
||||
headers=logged_in_headers
|
||||
)
|
||||
assert response.status_code == codes.CREATED
|
||||
|
||||
flow = response.json()
|
||||
assert flow["name"] == "Test Flow"
|
||||
assert "id" in flow
|
||||
```
|
||||
|
||||
### Build Config Testing
|
||||
Test component configuration updates:
|
||||
|
||||
```python
|
||||
async def test_build_config_updates(self, component_class, default_kwargs):
|
||||
"""Test dynamic build configuration updates."""
|
||||
component = await self.component_setup(component_class, default_kwargs)
|
||||
frontend_node = component.to_frontend_node()
|
||||
build_config = frontend_node["data"]["node"]["template"]
|
||||
|
||||
# Test configuration update
|
||||
component.set(llm_provider="OpenAI")
|
||||
updated_config = await component.update_build_config(
|
||||
build_config, "OpenAI", "llm_provider"
|
||||
)
|
||||
|
||||
assert updated_config["llm_provider"]["value"] == "OpenAI"
|
||||
assert "gpt-4o" in updated_config["model_name"]["options"]
|
||||
```
|
||||
|
||||
### Testing Event Streams
|
||||
Test real-time event streaming endpoints:
|
||||
|
||||
```python
|
||||
async def consume_and_validate_events(response, expected_job_id):
|
||||
"""Consume NDJSON event stream and validate structure."""
|
||||
count = 0
|
||||
first_event_seen = False
|
||||
end_event_seen = False
|
||||
|
||||
async for line in response.aiter_lines():
|
||||
if not line:
|
||||
continue
|
||||
|
||||
parsed = json.loads(line)
|
||||
|
||||
# Validate job_id in events
|
||||
if "job_id" in parsed:
|
||||
assert parsed["job_id"] == expected_job_id
|
||||
|
||||
# First event should be vertices_sorted
|
||||
if not first_event_seen:
|
||||
assert parsed["event"] == "vertices_sorted"
|
||||
first_event_seen = True
|
||||
|
||||
# Track end event
|
||||
elif parsed["event"] == "end":
|
||||
end_event_seen = True
|
||||
|
||||
count += 1
|
||||
|
||||
assert first_event_seen and end_event_seen
|
||||
return count
|
||||
```
|
||||
|
||||
### Testing Component Versioning
|
||||
Test backward compatibility across Langflow versions:
|
||||
|
||||
```python
|
||||
@pytest.fixture
|
||||
def file_names_mapping(self):
|
||||
"""Map component files across Langflow versions."""
|
||||
return [
|
||||
VersionComponentMapping(
|
||||
version="1.1.1",
|
||||
module="inputs",
|
||||
file_name="chat"
|
||||
),
|
||||
VersionComponentMapping(
|
||||
version="1.1.0",
|
||||
module="inputs",
|
||||
file_name="chat"
|
||||
),
|
||||
VersionComponentMapping(
|
||||
version="1.0.19",
|
||||
module="inputs",
|
||||
file_name="ChatInput"
|
||||
),
|
||||
]
|
||||
```
|
||||
|
||||
### Webhook Testing
|
||||
Test webhook endpoints with proper payload handling:
|
||||
|
||||
```python
|
||||
async def test_webhook_endpoint(client, added_webhook_test):
|
||||
"""Test webhook flow execution."""
|
||||
endpoint_name = added_webhook_test["endpoint_name"]
|
||||
endpoint = f"api/v1/webhook/{endpoint_name}"
|
||||
|
||||
payload = {"path": "/tmp/test_file.txt"}
|
||||
|
||||
response = await client.post(endpoint, json=payload)
|
||||
assert response.status_code == 202
|
||||
|
||||
# Verify webhook processed the payload
|
||||
# ... additional validation logic
|
||||
```
|
||||
|
||||
### Monkeypatch for Testing Errors
|
||||
Test error handling by mocking internal functions:
|
||||
|
||||
```python
|
||||
async def test_cancellation_error(client, flow_id, logged_in_headers, monkeypatch):
|
||||
"""Test error handling during flow cancellation."""
|
||||
import langflow.api.v1.chat
|
||||
|
||||
async def mock_cancel_with_error(*args, **kwargs):
|
||||
raise RuntimeError("Cancellation failed")
|
||||
|
||||
monkeypatch.setattr(
|
||||
langflow.api.v1.chat,
|
||||
"cancel_flow_build",
|
||||
mock_cancel_with_error
|
||||
)
|
||||
|
||||
response = await client.post(
|
||||
f"api/v1/build/{job_id}/cancel",
|
||||
headers=logged_in_headers
|
||||
)
|
||||
assert response.status_code == codes.INTERNAL_SERVER_ERROR
|
||||
assert "Cancellation failed" in response.json()["detail"]
|
||||
```
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
FROM ghcr.io/astral-sh/uv:python3.12-bookworm-slim
|
||||
FROM ghcr.io/astral-sh/uv:python3.14-bookworm-slim
|
||||
|
||||
# Set timezone
|
||||
ENV TZ=UTC
|
||||
|
||||
@@ -135,6 +135,20 @@ LANGFLOW_API_KEY=
|
||||
# Values: true, false
|
||||
HANZOFLOW_STORE_ENVIRONMENT_VARIABLES=
|
||||
|
||||
# SSRF (Server-Side Request Forgery) Protection
|
||||
# When enabled, blocks requests to private IP ranges, localhost, and cloud metadata endpoints
|
||||
# Values: true, false
|
||||
# Default: false (for backward compatibility)
|
||||
# Set to true to enable protection against SSRF attacks
|
||||
LANGFLOW_SSRF_PROTECTION_ENABLED=
|
||||
|
||||
# SSRF Allowed Hosts
|
||||
# Comma-separated list of hosts/IPs/CIDR ranges to allow despite SSRF protection
|
||||
# Supports: exact hostnames, wildcard domains (*.example.com), exact IPs, CIDR ranges
|
||||
# Example: LANGFLOW_SSRF_ALLOWED_HOSTS=internal-api.company.local,192.168.1.0/24,*.dev.internal
|
||||
# Note: Only takes effect when LANGFLOW_SSRF_PROTECTION_ENABLED=true
|
||||
LANGFLOW_SSRF_ALLOWED_HOSTS=
|
||||
|
||||
# Should enable the MCP composer feature in MCP projects
|
||||
# Values: true, false
|
||||
# Default: true
|
||||
|
||||
@@ -6,7 +6,6 @@ python:
|
||||
- "pyproject.toml"
|
||||
- "uv.lock"
|
||||
- "src/backend/base/pyproject.toml"
|
||||
- "src/backend/base/uv.lock"
|
||||
- "src/lfx/pyproject.toml"
|
||||
- "**/python_test.yml"
|
||||
- ".github/workflows/ci.yml"
|
||||
@@ -110,3 +109,7 @@ database:
|
||||
|
||||
mainpage:
|
||||
- "src/frontend/src/pages/MainPage/**"
|
||||
|
||||
design-md:
|
||||
- "DESIGN.md"
|
||||
- ".github/workflows/ci.yml"
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
name: CI Scripts Tests
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
paths:
|
||||
- "scripts/ci/**"
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
name: Test CI release scripts
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: "3.13"
|
||||
|
||||
- name: Install dependencies
|
||||
run: pip install pytest requests packaging
|
||||
|
||||
- name: Run CI script tests
|
||||
run: python -m pytest scripts/ci/ -v
|
||||
+125
-17
@@ -11,7 +11,7 @@ on:
|
||||
description: "Python Versions"
|
||||
required: false
|
||||
type: string
|
||||
default: "['3.10']"
|
||||
default: "['3.10', '3.14']"
|
||||
frontend-tests-folder:
|
||||
description: "Frontend Tests Folder"
|
||||
required: false
|
||||
@@ -50,7 +50,7 @@ on:
|
||||
description: "Python Versions"
|
||||
required: false
|
||||
type: string
|
||||
default: "['3.10']"
|
||||
default: "['3.10', '3.14']"
|
||||
runs-on:
|
||||
description: "Runner to use for the tests"
|
||||
required: false
|
||||
@@ -88,6 +88,22 @@ jobs:
|
||||
echo " run-all-tests: ${{ inputs.run-all-tests }}"
|
||||
echo " runs-on: ${{ inputs.runs-on }}"
|
||||
|
||||
validate-pr-title:
|
||||
name: Validate PR Title
|
||||
if: ${{ github.event_name == 'pull_request' }}
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
PR_TITLE: ${{ github.event.pull_request.title }}
|
||||
steps:
|
||||
- name: Reject titles ending in ellipsis
|
||||
run: |
|
||||
echo "PR title: $PR_TITLE"
|
||||
if [[ "$PR_TITLE" == *"..." || "$PR_TITLE" == *"…" ]]; then
|
||||
echo "❌ PR title ends with ellipsis. Please write a complete, descriptive title."
|
||||
exit 1
|
||||
fi
|
||||
echo "✅ PR title is complete."
|
||||
|
||||
check-nightly-status:
|
||||
name: Check PyPI Version Update
|
||||
runs-on: ubuntu-latest
|
||||
@@ -186,6 +202,7 @@ jobs:
|
||||
api: ${{ steps.filter.outputs.api }}
|
||||
database: ${{ steps.filter.outputs.database }}
|
||||
docker: ${{ steps.filter.outputs.docker }}
|
||||
design-md: ${{ steps.filter.outputs.design-md }}
|
||||
docs-only: ${{
|
||||
steps.filter.outputs.docs == 'true' &&
|
||||
steps.filter.outputs.python != 'true' &&
|
||||
@@ -239,7 +256,7 @@ jobs:
|
||||
needs.set-ci-condition.outputs.should-run-tests == 'true'
|
||||
uses: ./.github/workflows/python_test.yml
|
||||
with:
|
||||
python-versions: ${{ inputs.python-versions || '["3.10"]' }}
|
||||
python-versions: ${{ inputs.python-versions || '["3.10", "3.14"]' }}
|
||||
runs-on: ${{ inputs['runs-on'] || github.event.inputs['runs-on'] || 'ubuntu-latest' }}
|
||||
ref: ${{ inputs.ref || github.ref }}
|
||||
secrets:
|
||||
@@ -247,6 +264,20 @@ jobs:
|
||||
ANTHROPIC_API_KEY: "${{ secrets.ANTHROPIC_API_KEY }}"
|
||||
CODECOV_TOKEN: "${{ secrets.CODECOV_TOKEN }}"
|
||||
|
||||
lint-frontend:
|
||||
needs: [path-filter, set-ci-condition]
|
||||
name: Lint Frontend
|
||||
if: |
|
||||
always() &&
|
||||
!cancelled() &&
|
||||
needs.set-ci-condition.outputs.should-run-ci == 'true' &&
|
||||
(
|
||||
inputs.run-all-tests ||
|
||||
(needs.path-filter.result != 'skipped' &&
|
||||
needs.path-filter.outputs.frontend == 'true')
|
||||
)
|
||||
uses: ./.github/workflows/lint-js.yml
|
||||
|
||||
test-frontend-unit:
|
||||
needs: [path-filter, set-ci-condition]
|
||||
name: Run Frontend Unit Tests
|
||||
@@ -279,15 +310,6 @@ jobs:
|
||||
ANTHROPIC_API_KEY: "${{ secrets.ANTHROPIC_API_KEY }}"
|
||||
TAVILY_API_KEY: "${{ secrets.TAVILY_API_KEY }}"
|
||||
|
||||
lint-backend:
|
||||
needs: path-filter
|
||||
if: |
|
||||
always() &&
|
||||
!cancelled() &&
|
||||
(inputs.run-all-tests || needs.path-filter.outputs.python == 'true')
|
||||
name: Lint Backend
|
||||
uses: ./.github/workflows/lint-py.yml
|
||||
|
||||
test-docs-build:
|
||||
needs: path-filter
|
||||
if: |
|
||||
@@ -339,21 +361,105 @@ jobs:
|
||||
DOCKERHUB_USERNAME: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
DOCKERHUB_TOKEN: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
|
||||
lint-design-md:
|
||||
needs: path-filter
|
||||
name: Lint DESIGN.md
|
||||
if: ${{ inputs.run-all-tests || needs.path-filter.outputs.design-md == 'true' }}
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
DESIGN_MD_CLI_VERSION: "0.1.1"
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
ref: ${{ inputs.ref || github.ref }}
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: "22"
|
||||
|
||||
- name: Lint DESIGN.md
|
||||
run: npx --yes @google/design.md@${{ env.DESIGN_MD_CLI_VERSION }} lint DESIGN.md
|
||||
|
||||
merge-frontend-coverage:
|
||||
name: Merge Frontend Jest + Playwright Coverage Reports
|
||||
needs: [path-filter, set-ci-condition, test-frontend-unit, test-frontend]
|
||||
if: |
|
||||
always() &&
|
||||
!cancelled() &&
|
||||
needs.set-ci-condition.outputs.should-run-tests == 'true' &&
|
||||
(
|
||||
inputs.run-all-tests ||
|
||||
(needs.path-filter.result != 'skipped' &&
|
||||
needs.path-filter.outputs.docs-only != 'true')
|
||||
)
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Download Jest Coverage
|
||||
uses: actions/download-artifact@v4
|
||||
continue-on-error: true
|
||||
with:
|
||||
name: jest-coverage
|
||||
path: coverage/jest
|
||||
|
||||
- name: Download Playwright Coverage
|
||||
uses: actions/download-artifact@v4
|
||||
continue-on-error: true
|
||||
with:
|
||||
pattern: playwright-coverage-*
|
||||
path: coverage/playwright
|
||||
merge-multiple: true
|
||||
|
||||
- name: Merge Coverage
|
||||
run: |
|
||||
mkdir -p coverage/combined
|
||||
npx nyc merge coverage/playwright/individual-test coverage/combined/playwright.json || echo "Failed to merge Playwright coverage"
|
||||
npx nyc merge coverage/jest coverage/combined/jest.json || echo "Failed to merge Jest coverage"
|
||||
npx nyc merge coverage/combined coverage/combined/total-frontend-coverage.json || echo "Failed to combine Playwright + Jest coverage"
|
||||
|
||||
- name: Generate Combined Report
|
||||
run: |
|
||||
npx nyc report \
|
||||
--reporter=html \
|
||||
--reporter=text \
|
||||
--temp-dir coverage/combined \
|
||||
--report-dir coverage/final
|
||||
|
||||
- name: Upload Merged Frontend Coverage to Codecov
|
||||
if: env.CODECOV_TOKEN != ''
|
||||
env:
|
||||
CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }}
|
||||
uses: codecov/codecov-action@v5
|
||||
with:
|
||||
token: ${{ env.CODECOV_TOKEN }}
|
||||
files: coverage/combined/total-frontend-coverage.json
|
||||
flags: frontend
|
||||
fail_ci_if_error: false
|
||||
|
||||
# https://github.com/langchain-ai/langchain/blob/master/.github/workflows/check_diffs.yml
|
||||
ci_success:
|
||||
name: "CI Success"
|
||||
# Note: `lint-frontend` is intentionally NOT in this list — the Biome
|
||||
# check runs on every PR and shows up in the checks list, but its failure
|
||||
# does NOT block merge (informational only).
|
||||
needs:
|
||||
[
|
||||
validate-pr-title,
|
||||
test-backend,
|
||||
test-frontend-unit,
|
||||
test-frontend,
|
||||
lint-backend,
|
||||
test-docs-build,
|
||||
test-templates,
|
||||
test-docker,
|
||||
lint-design-md,
|
||||
set-ci-condition,
|
||||
path-filter,
|
||||
check-nightly-status,
|
||||
merge-frontend-coverage,
|
||||
]
|
||||
|
||||
if: ${{ always() }}
|
||||
@@ -365,7 +471,7 @@ jobs:
|
||||
DOCS_ONLY: ${{ needs.path-filter.outputs.docs-only }}
|
||||
EXIT_CODE: ${{ (contains(needs.*.result, 'failure') ||
|
||||
contains(needs.*.result, 'cancelled') ||
|
||||
(needs.check-nightly-status.outputs.should-proceed != 'true' && github.event_name != 'workflow_dispatch' && needs.path-filter.outputs.docs-only != 'true'))
|
||||
(needs.check-nightly-status.outputs.should-proceed != 'true' && github.event_name != 'workflow_dispatch' && github.event_name != 'merge_group' && needs.path-filter.outputs.docs-only != 'true' && !contains(github.event.pull_request.labels.*.name, 'skip-nightly-check')))
|
||||
&& '1' || '0' }}
|
||||
steps:
|
||||
- name: "CI Success"
|
||||
@@ -392,20 +498,22 @@ jobs:
|
||||
to_entries[]
|
||||
| select(.value.result=="failure")
|
||||
| .key as $job
|
||||
| if $job == "test-backend" then
|
||||
| if $job == "validate-pr-title" then
|
||||
" - PR Title Validation: Title ends with ellipsis (...). Use a complete, descriptive title."
|
||||
elif $job == "test-backend" then
|
||||
" - Backend Tests: Check Python code, tests, and dependencies"
|
||||
elif $job == "test-frontend-unit" then
|
||||
" - Frontend Unit Tests: Check React components and unit test logic"
|
||||
elif $job == "test-frontend" then
|
||||
" - Frontend E2E Tests: Check integration tests and UI functionality"
|
||||
elif $job == "lint-backend" then
|
||||
" - Backend Linting: Run '\''make format_backend'\'' then '\''make lint'\'' to fix code style issues"
|
||||
elif $job == "test-docs-build" then
|
||||
" - Documentation Build: Check documentation syntax and build process"
|
||||
elif $job == "test-templates" then
|
||||
" - Template Tests: Check starter project templates"
|
||||
elif $job == "test-docker" then
|
||||
" - Docker Tests: Check Docker image builds and version verification"
|
||||
elif $job == "lint-design-md" then
|
||||
" - DESIGN.md Lint: Check token references, WCAG contrast, and spec compliance"
|
||||
elif $job == "path-filter" then
|
||||
" - Path Filter: File path filtering failed"
|
||||
elif $job == "set-ci-condition" then
|
||||
|
||||
@@ -31,6 +31,9 @@ jobs:
|
||||
outputs:
|
||||
base-artifact-name: ${{ steps.set-names.outputs.base-artifact-name }}
|
||||
main-artifact-name: ${{ steps.set-names.outputs.main-artifact-name }}
|
||||
lfx-artifact-name: ${{ steps.set-names.outputs.lfx-artifact-name }}
|
||||
sdk-artifact-name: ${{ steps.set-names.outputs.sdk-artifact-name }}
|
||||
bundles-artifact-name: ${{ steps.set-names.outputs.bundles-artifact-name }}
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v6
|
||||
@@ -53,8 +56,45 @@ jobs:
|
||||
# Base package builds to dist/ but should be in src/backend/base/dist/
|
||||
mkdir -p src/backend/base/dist
|
||||
mv dist/langflow_base*.whl src/backend/base/dist/
|
||||
- name: Build LFX package
|
||||
run: |
|
||||
cd src/lfx
|
||||
uv build --wheel --out-dir dist
|
||||
- name: Build SDK package
|
||||
run: |
|
||||
cd src/sdk
|
||||
uv build --wheel --out-dir dist
|
||||
- name: Build main package
|
||||
run: make build_langflow args="--wheel"
|
||||
- name: Build bundle wheels
|
||||
id: build-bundles
|
||||
run: |
|
||||
shopt -s nullglob
|
||||
bundles=(src/bundles/*/pyproject.toml)
|
||||
if [ ${#bundles[@]} -eq 0 ]; then
|
||||
echo "No bundles found under src/bundles/*/"
|
||||
echo "bundles-found=0" >> $GITHUB_OUTPUT
|
||||
exit 0
|
||||
fi
|
||||
mkdir -p bundles-dist
|
||||
BUNDLES_DIST="$PWD/bundles-dist"
|
||||
for bundle_pyproject in "${bundles[@]}"; do
|
||||
bundle_dir=$(dirname "$bundle_pyproject")
|
||||
echo "Building wheel for $bundle_dir"
|
||||
(cd "$bundle_dir" && uv build --wheel --out-dir "$BUNDLES_DIST")
|
||||
done
|
||||
echo "bundles-found=1" >> $GITHUB_OUTPUT
|
||||
ls -la bundles-dist/
|
||||
- name: Upload lfx artifact
|
||||
uses: actions/upload-artifact@v6
|
||||
with:
|
||||
name: adhoc-dist-lfx
|
||||
path: src/lfx/dist
|
||||
- name: Upload SDK artifact
|
||||
uses: actions/upload-artifact@v6
|
||||
with:
|
||||
name: adhoc-dist-sdk
|
||||
path: src/sdk/dist
|
||||
- name: Upload base artifact
|
||||
uses: actions/upload-artifact@v6
|
||||
with:
|
||||
@@ -65,11 +105,24 @@ jobs:
|
||||
with:
|
||||
name: adhoc-dist-main
|
||||
path: dist
|
||||
- name: Upload bundles artifact
|
||||
if: steps.build-bundles.outputs.bundles-found == '1'
|
||||
uses: actions/upload-artifact@v6
|
||||
with:
|
||||
name: adhoc-dist-bundles
|
||||
path: bundles-dist
|
||||
- name: Set artifact names
|
||||
id: set-names
|
||||
run: |
|
||||
echo "base-artifact-name=adhoc-dist-base" >> $GITHUB_OUTPUT
|
||||
echo "main-artifact-name=adhoc-dist-main" >> $GITHUB_OUTPUT
|
||||
echo "lfx-artifact-name=adhoc-dist-lfx" >> $GITHUB_OUTPUT
|
||||
echo "sdk-artifact-name=adhoc-dist-sdk" >> $GITHUB_OUTPUT
|
||||
if [ "${{ steps.build-bundles.outputs.bundles-found }}" = "1" ]; then
|
||||
echo "bundles-artifact-name=adhoc-dist-bundles" >> $GITHUB_OUTPUT
|
||||
else
|
||||
echo "bundles-artifact-name=" >> $GITHUB_OUTPUT
|
||||
fi
|
||||
|
||||
test-installation-stable:
|
||||
name: Install & Run - ${{ matrix.os }} ${{ matrix.arch }} ${{ matrix.python-version }}
|
||||
@@ -156,11 +209,18 @@ jobs:
|
||||
shell: bash
|
||||
|
||||
# Download artifacts for wheel installation
|
||||
- name: Download LFX package artifact
|
||||
if: steps.install-method.outputs.method == 'wheel' && inputs.lfx-artifact-name != ''
|
||||
- name: Download langflow-sdk package artifact
|
||||
if: steps.install-method.outputs.method == 'wheel' && (inputs.sdk-artifact-name != '' || needs.build-if-needed.outputs.sdk-artifact-name != '')
|
||||
uses: actions/download-artifact@v7
|
||||
with:
|
||||
name: ${{ inputs.lfx-artifact-name }}
|
||||
name: ${{ inputs.sdk-artifact-name || needs.build-if-needed.outputs.sdk-artifact-name || 'adhoc-dist-sdk' }}
|
||||
path: ./sdk-dist
|
||||
|
||||
- name: Download LFX package artifact
|
||||
if: steps.install-method.outputs.method == 'wheel' && (inputs.lfx-artifact-name != '' || needs.build-if-needed.outputs.lfx-artifact-name != '')
|
||||
uses: actions/download-artifact@v7
|
||||
with:
|
||||
name: ${{ inputs.lfx-artifact-name || needs.build-if-needed.outputs.lfx-artifact-name || 'adhoc-dist-lfx' }}
|
||||
path: ./lfx-dist
|
||||
|
||||
- name: Download base package artifact
|
||||
@@ -177,94 +237,81 @@ jobs:
|
||||
name: ${{ inputs.main-artifact-name || needs.build-if-needed.outputs.main-artifact-name || 'adhoc-dist-main' }}
|
||||
path: ./main-dist
|
||||
|
||||
- name: Download bundles package artifact
|
||||
if: steps.install-method.outputs.method == 'wheel' && (inputs.bundles-artifact-name != '' || needs.build-if-needed.outputs.bundles-artifact-name != '')
|
||||
uses: actions/download-artifact@v7
|
||||
with:
|
||||
name: ${{ inputs.bundles-artifact-name || needs.build-if-needed.outputs.bundles-artifact-name }}
|
||||
path: ./bundles-dist
|
||||
|
||||
- name: Create fresh virtual environment
|
||||
run: |
|
||||
uv venv test-env --seed
|
||||
shell: bash
|
||||
|
||||
# Wheel installation steps
|
||||
- name: Install LFX package from wheel (Windows)
|
||||
if: steps.install-method.outputs.method == 'wheel' && matrix.os == 'windows' && inputs.lfx-artifact-name != ''
|
||||
- name: Install packages from wheel
|
||||
if: steps.install-method.outputs.method == 'wheel'
|
||||
run: |
|
||||
ls -la ./lfx-dist/
|
||||
find ./lfx-dist -name "*.whl" -type f
|
||||
WHEEL_FILE=$(find ./lfx-dist -name "*.whl" -type f | head -1)
|
||||
if [ -n "$WHEEL_FILE" ]; then
|
||||
uv pip install --prerelease=allow --python ./test-env/Scripts/python.exe "$WHEEL_FILE"
|
||||
else
|
||||
echo "No wheel file found in ./lfx-dist/"
|
||||
exit 1
|
||||
fi
|
||||
shell: bash
|
||||
set -eo pipefail
|
||||
shopt -s nullglob
|
||||
|
||||
- name: Install base package from wheel (Windows)
|
||||
if: steps.install-method.outputs.method == 'wheel' && matrix.os == 'windows'
|
||||
run: |
|
||||
ls -la ./base-dist/
|
||||
find ./base-dist -name "*.whl" -type f
|
||||
WHEEL_FILE=$(find ./base-dist -name "*.whl" -type f | head -1)
|
||||
if [ -n "$WHEEL_FILE" ]; then
|
||||
uv pip install --prerelease=allow --python ./test-env/Scripts/python.exe "$WHEEL_FILE"
|
||||
if [ "${{ matrix.os }}" = "windows" ]; then
|
||||
PYTHON=./test-env/Scripts/python.exe
|
||||
else
|
||||
echo "No wheel file found in ./base-dist/"
|
||||
exit 1
|
||||
PYTHON=./test-env/bin/python
|
||||
fi
|
||||
shell: bash
|
||||
|
||||
- name: Install main package from wheel (Windows)
|
||||
if: steps.install-method.outputs.method == 'wheel' && matrix.os == 'windows'
|
||||
run: |
|
||||
ls -la ./main-dist/
|
||||
find ./main-dist -name "*.whl" -type f
|
||||
WHEEL_FILE=$(find ./main-dist -name "*.whl" -type f | head -1)
|
||||
if [ -n "$WHEEL_FILE" ]; then
|
||||
uv pip install --prerelease=allow --python ./test-env/Scripts/python.exe "$WHEEL_FILE"
|
||||
else
|
||||
echo "No wheel file found in ./main-dist/"
|
||||
exit 1
|
||||
fi
|
||||
shell: bash
|
||||
refresh_find_links() {
|
||||
FIND_LINKS=()
|
||||
for wheel_dir in ./sdk-dist ./lfx-dist ./base-dist ./bundles-dist; do
|
||||
if [ -d "$wheel_dir" ]; then
|
||||
FIND_LINKS+=(--find-links "$wheel_dir")
|
||||
fi
|
||||
done
|
||||
}
|
||||
|
||||
- name: Install LFX package from wheel (Unix)
|
||||
if: steps.install-method.outputs.method == 'wheel' && matrix.os != 'windows' && inputs.lfx-artifact-name != ''
|
||||
run: |
|
||||
ls -la ./lfx-dist/
|
||||
find ./lfx-dist -name "*.whl" -type f
|
||||
WHEEL_FILE=$(find ./lfx-dist -name "*.whl" -type f | head -1)
|
||||
if [ -n "$WHEEL_FILE" ]; then
|
||||
uv pip install --prerelease=allow --python ./test-env/bin/python "$WHEEL_FILE"
|
||||
else
|
||||
echo "No wheel file found in ./lfx-dist/"
|
||||
exit 1
|
||||
fi
|
||||
shell: bash
|
||||
install_wheels() {
|
||||
label="$1"
|
||||
shift
|
||||
if [ "$#" -eq 0 ]; then
|
||||
echo "No $label wheel found"
|
||||
exit 1
|
||||
fi
|
||||
printf 'Installing %s wheel(s):\n' "$label"
|
||||
printf '%s\n' "$@"
|
||||
refresh_find_links
|
||||
# Use `if-necessary-or-explicit` (not `allow`): nightly langflow* wheels
|
||||
# are .dev pre-releases so we need to allow them explicitly, but `allow`
|
||||
# leaks to transitive deps and resolves pre-release pydantic alphas
|
||||
# (e.g. 2.14.0a1) which break langchain-core's module-level
|
||||
# `RunnablePassthrough()` call at import time.
|
||||
uv pip install --prerelease=if-necessary-or-explicit --python "$PYTHON" "${FIND_LINKS[@]}" "$@"
|
||||
}
|
||||
|
||||
- name: Install base package from wheel (Unix)
|
||||
if: steps.install-method.outputs.method == 'wheel' && matrix.os != 'windows'
|
||||
run: |
|
||||
ls -la ./base-dist/
|
||||
find ./base-dist -name "*.whl" -type f
|
||||
WHEEL_FILE=$(find ./base-dist -name "*.whl" -type f | head -1)
|
||||
if [ -n "$WHEEL_FILE" ]; then
|
||||
uv pip install --prerelease=allow --python ./test-env/bin/python "$WHEEL_FILE"
|
||||
else
|
||||
echo "No wheel file found in ./base-dist/"
|
||||
exit 1
|
||||
SDK_WHEELS=(./sdk-dist/*.whl)
|
||||
LFX_WHEELS=(./lfx-dist/*.whl)
|
||||
if [ ${#SDK_WHEELS[@]} -gt 0 ] || [ ${#LFX_WHEELS[@]} -gt 0 ]; then
|
||||
install_wheels "SDK/LFX" "${SDK_WHEELS[@]}" "${LFX_WHEELS[@]}"
|
||||
fi
|
||||
shell: bash
|
||||
|
||||
- name: Install main package from wheel (Unix)
|
||||
if: steps.install-method.outputs.method == 'wheel' && matrix.os != 'windows'
|
||||
run: |
|
||||
ls -la ./main-dist/
|
||||
find ./main-dist -name "*.whl" -type f
|
||||
WHEEL_FILE=$(find ./main-dist -name "*.whl" -type f | head -1)
|
||||
if [ -n "$WHEEL_FILE" ]; then
|
||||
uv pip install --prerelease=allow --python ./test-env/bin/python "$WHEEL_FILE"
|
||||
else
|
||||
echo "No wheel file found in ./main-dist/"
|
||||
BASE_WHEELS=(./base-dist/*.whl)
|
||||
install_wheels "base" "${BASE_WHEELS[@]}"
|
||||
|
||||
if [ -d ./bundles-dist ]; then
|
||||
BUNDLE_WHEELS=(./bundles-dist/*.whl)
|
||||
install_wheels "bundle" "${BUNDLE_WHEELS[@]}"
|
||||
fi
|
||||
|
||||
MAIN_WHEELS=()
|
||||
while IFS= read -r wheel; do
|
||||
MAIN_WHEELS+=("$wheel")
|
||||
done < <(find ./main-dist -name "langflow*.whl" ! -name "langflow_base*.whl" -type f | sort)
|
||||
if [ ${#MAIN_WHEELS[@]} -ne 1 ]; then
|
||||
echo "Expected exactly one langflow wheel in ./main-dist, found ${#MAIN_WHEELS[@]}"
|
||||
find ./main-dist -name "*.whl" -type f | sort
|
||||
exit 1
|
||||
fi
|
||||
install_wheels "main" "${MAIN_WHEELS[@]}"
|
||||
shell: bash
|
||||
|
||||
# PyPI installation steps
|
||||
@@ -455,11 +502,18 @@ jobs:
|
||||
shell: bash
|
||||
|
||||
# Download artifacts for wheel installation
|
||||
- name: Download LFX package artifact
|
||||
if: steps.install-method.outputs.method == 'wheel' && inputs.lfx-artifact-name != ''
|
||||
- name: Download langflow-sdk package artifact
|
||||
if: steps.install-method.outputs.method == 'wheel' && (inputs.sdk-artifact-name != '' || needs.build-if-needed.outputs.sdk-artifact-name != '')
|
||||
uses: actions/download-artifact@v7
|
||||
with:
|
||||
name: ${{ inputs.lfx-artifact-name }}
|
||||
name: ${{ inputs.sdk-artifact-name || needs.build-if-needed.outputs.sdk-artifact-name || 'adhoc-dist-sdk' }}
|
||||
path: ./sdk-dist
|
||||
|
||||
- name: Download LFX package artifact
|
||||
if: steps.install-method.outputs.method == 'wheel' && (inputs.lfx-artifact-name != '' || needs.build-if-needed.outputs.lfx-artifact-name != '')
|
||||
uses: actions/download-artifact@v7
|
||||
with:
|
||||
name: ${{ inputs.lfx-artifact-name || needs.build-if-needed.outputs.lfx-artifact-name || 'adhoc-dist-lfx' }}
|
||||
path: ./lfx-dist
|
||||
|
||||
- name: Download base package artifact
|
||||
@@ -476,109 +530,101 @@ jobs:
|
||||
name: ${{ inputs.main-artifact-name || needs.build-if-needed.outputs.main-artifact-name || 'adhoc-dist-main' }}
|
||||
path: ./main-dist
|
||||
|
||||
- name: Download bundles package artifact
|
||||
if: steps.install-method.outputs.method == 'wheel' && (inputs.bundles-artifact-name != '' || needs.build-if-needed.outputs.bundles-artifact-name != '')
|
||||
uses: actions/download-artifact@v7
|
||||
with:
|
||||
name: ${{ inputs.bundles-artifact-name || needs.build-if-needed.outputs.bundles-artifact-name }}
|
||||
path: ./bundles-dist
|
||||
|
||||
- name: Create fresh virtual environment
|
||||
run: |
|
||||
uv venv test-env --seed
|
||||
shell: bash
|
||||
|
||||
# Wheel installation steps
|
||||
- name: Install LFX package from wheel (Windows)
|
||||
if: steps.install-method.outputs.method == 'wheel' && matrix.os == 'windows' && inputs.lfx-artifact-name != ''
|
||||
- name: Install packages from wheel
|
||||
if: steps.install-method.outputs.method == 'wheel'
|
||||
run: |
|
||||
ls -la ./lfx-dist/
|
||||
find ./lfx-dist -name "*.whl" -type f
|
||||
WHEEL_FILE=$(find ./lfx-dist -name "*.whl" -type f | head -1)
|
||||
if [ -n "$WHEEL_FILE" ]; then
|
||||
uv pip install --prerelease=allow --python ./test-env/Scripts/python.exe "$WHEEL_FILE"
|
||||
else
|
||||
echo "No wheel file found in ./lfx-dist/"
|
||||
exit 1
|
||||
fi
|
||||
shell: bash
|
||||
set -eo pipefail
|
||||
shopt -s nullglob
|
||||
|
||||
- name: Install base package from wheel (Windows)
|
||||
if: steps.install-method.outputs.method == 'wheel' && matrix.os == 'windows'
|
||||
run: |
|
||||
ls -la ./base-dist/
|
||||
find ./base-dist -name "*.whl" -type f
|
||||
WHEEL_FILE=$(find ./base-dist -name "*.whl" -type f | head -1)
|
||||
if [ -n "$WHEEL_FILE" ]; then
|
||||
uv pip install --prerelease=allow --python ./test-env/Scripts/python.exe "$WHEEL_FILE"
|
||||
if [ "${{ matrix.os }}" = "windows" ]; then
|
||||
PYTHON=./test-env/Scripts/python.exe
|
||||
else
|
||||
echo "No wheel file found in ./base-dist/"
|
||||
exit 1
|
||||
PYTHON=./test-env/bin/python
|
||||
fi
|
||||
shell: bash
|
||||
|
||||
- name: Install main package from wheel (Windows)
|
||||
if: steps.install-method.outputs.method == 'wheel' && matrix.os == 'windows'
|
||||
run: |
|
||||
ls -la ./main-dist/
|
||||
find ./main-dist -name "*.whl" -type f
|
||||
WHEEL_FILE=$(find ./main-dist -name "*.whl" -type f | head -1)
|
||||
if [ -n "$WHEEL_FILE" ]; then
|
||||
uv pip install --prerelease=allow --python ./test-env/Scripts/python.exe "$WHEEL_FILE"
|
||||
else
|
||||
echo "No wheel file found in ./main-dist/"
|
||||
exit 1
|
||||
fi
|
||||
shell: bash
|
||||
refresh_find_links() {
|
||||
FIND_LINKS=()
|
||||
for wheel_dir in ./sdk-dist ./lfx-dist ./base-dist ./bundles-dist; do
|
||||
if [ -d "$wheel_dir" ]; then
|
||||
FIND_LINKS+=(--find-links "$wheel_dir")
|
||||
fi
|
||||
done
|
||||
}
|
||||
|
||||
- name: Install LFX package from wheel (Unix)
|
||||
if: steps.install-method.outputs.method == 'wheel' && matrix.os != 'windows' && inputs.lfx-artifact-name != ''
|
||||
run: |
|
||||
ls -la ./lfx-dist/
|
||||
find ./lfx-dist -name "*.whl" -type f
|
||||
WHEEL_FILE=$(find ./lfx-dist -name "*.whl" -type f | head -1)
|
||||
if [ -n "$WHEEL_FILE" ]; then
|
||||
uv pip install --prerelease=allow --python ./test-env/bin/python "$WHEEL_FILE"
|
||||
else
|
||||
echo "No wheel file found in ./lfx-dist/"
|
||||
exit 1
|
||||
fi
|
||||
shell: bash
|
||||
install_wheels() {
|
||||
label="$1"
|
||||
shift
|
||||
if [ "$#" -eq 0 ]; then
|
||||
echo "No $label wheel found"
|
||||
exit 1
|
||||
fi
|
||||
printf 'Installing %s wheel(s):\n' "$label"
|
||||
printf '%s\n' "$@"
|
||||
refresh_find_links
|
||||
# See stable job above for rationale on --prerelease=if-necessary-or-explicit.
|
||||
uv pip install --prerelease=if-necessary-or-explicit --python "$PYTHON" "${FIND_LINKS[@]}" "$@"
|
||||
}
|
||||
|
||||
- name: Install base package from wheel (Unix)
|
||||
if: steps.install-method.outputs.method == 'wheel' && matrix.os != 'windows'
|
||||
run: |
|
||||
ls -la ./base-dist/
|
||||
find ./base-dist -name "*.whl" -type f
|
||||
WHEEL_FILE=$(find ./base-dist -name "*.whl" -type f | head -1)
|
||||
if [ -n "$WHEEL_FILE" ]; then
|
||||
uv pip install --prerelease=allow --python ./test-env/bin/python "$WHEEL_FILE"
|
||||
else
|
||||
echo "No wheel file found in ./base-dist/"
|
||||
exit 1
|
||||
SDK_WHEELS=(./sdk-dist/*.whl)
|
||||
LFX_WHEELS=(./lfx-dist/*.whl)
|
||||
if [ ${#SDK_WHEELS[@]} -gt 0 ] || [ ${#LFX_WHEELS[@]} -gt 0 ]; then
|
||||
install_wheels "SDK/LFX" "${SDK_WHEELS[@]}" "${LFX_WHEELS[@]}"
|
||||
fi
|
||||
shell: bash
|
||||
|
||||
- name: Install main package from wheel (Unix)
|
||||
if: steps.install-method.outputs.method == 'wheel' && matrix.os != 'windows'
|
||||
run: |
|
||||
ls -la ./main-dist/
|
||||
find ./main-dist -name "*.whl" -type f
|
||||
WHEEL_FILE=$(find ./main-dist -name "*.whl" -type f | head -1)
|
||||
if [ -n "$WHEEL_FILE" ]; then
|
||||
uv pip install --prerelease=allow --python ./test-env/bin/python "$WHEEL_FILE"
|
||||
else
|
||||
echo "No wheel file found in ./main-dist/"
|
||||
BASE_WHEELS=(./base-dist/*.whl)
|
||||
install_wheels "base" "${BASE_WHEELS[@]}"
|
||||
|
||||
if [ -d ./bundles-dist ]; then
|
||||
BUNDLE_WHEELS=(./bundles-dist/*.whl)
|
||||
install_wheels "bundle" "${BUNDLE_WHEELS[@]}"
|
||||
fi
|
||||
|
||||
MAIN_WHEELS=()
|
||||
while IFS= read -r wheel; do
|
||||
MAIN_WHEELS+=("$wheel")
|
||||
done < <(find ./main-dist -name "langflow*.whl" ! -name "langflow_base*.whl" -type f | sort)
|
||||
if [ ${#MAIN_WHEELS[@]} -ne 1 ]; then
|
||||
echo "Expected exactly one langflow wheel in ./main-dist, found ${#MAIN_WHEELS[@]}"
|
||||
find ./main-dist -name "*.whl" -type f | sort
|
||||
exit 1
|
||||
fi
|
||||
install_wheels "main" "${MAIN_WHEELS[@]}"
|
||||
shell: bash
|
||||
|
||||
- name: Force reinstall local wheels to prevent downgrades (Windows)
|
||||
if: steps.install-method.outputs.method == 'wheel' && matrix.os == 'windows'
|
||||
run: |
|
||||
# Reinstall LFX if it was provided
|
||||
if [ -n "${{ inputs.lfx-artifact-name }}" ]; then
|
||||
if [ -n "${{ inputs.lfx-artifact-name || needs.build-if-needed.outputs.lfx-artifact-name }}" ]; then
|
||||
LFX_WHEEL=$(find ./lfx-dist -name "*.whl" -type f | head -1)
|
||||
if [ -n "$LFX_WHEEL" ]; then
|
||||
echo "Force reinstalling LFX: $LFX_WHEEL"
|
||||
INSTALL_WHEELS=("$LFX_WHEEL")
|
||||
SDK_WHEEL=""
|
||||
if [ -d ./sdk-dist ]; then
|
||||
SDK_WHEEL=$(find ./sdk-dist -name "*.whl" -type f | head -1)
|
||||
fi
|
||||
if [ -n "$SDK_WHEEL" ]; then
|
||||
echo "Force reinstalling langflow-sdk with LFX: $SDK_WHEEL"
|
||||
INSTALL_WHEELS=("$SDK_WHEEL" "$LFX_WHEEL")
|
||||
fi
|
||||
NO_DEPS="--no-deps"
|
||||
if [ "${{ inputs.pre_release }}" = "true" ]; then
|
||||
NO_DEPS=""
|
||||
fi
|
||||
uv pip install --force-reinstall $NO_DEPS --prerelease=allow --python ./test-env/Scripts/python.exe "$LFX_WHEEL"
|
||||
uv pip install --force-reinstall $NO_DEPS --prerelease=if-necessary-or-explicit --python ./test-env/Scripts/python.exe "${INSTALL_WHEELS[@]}"
|
||||
fi
|
||||
fi
|
||||
|
||||
@@ -590,7 +636,7 @@ jobs:
|
||||
if [ "${{ inputs.pre_release }}" = "true" ]; then
|
||||
NO_DEPS=""
|
||||
fi
|
||||
uv pip install --force-reinstall $NO_DEPS --prerelease=allow --python ./test-env/Scripts/python.exe "$BASE_WHEEL"
|
||||
uv pip install --force-reinstall $NO_DEPS --prerelease=if-necessary-or-explicit --python ./test-env/Scripts/python.exe "$BASE_WHEEL"
|
||||
fi
|
||||
shell: bash
|
||||
|
||||
@@ -598,15 +644,24 @@ jobs:
|
||||
if: steps.install-method.outputs.method == 'wheel' && matrix.os != 'windows'
|
||||
run: |
|
||||
# Reinstall LFX if it was provided
|
||||
if [ -n "${{ inputs.lfx-artifact-name }}" ]; then
|
||||
if [ -n "${{ inputs.lfx-artifact-name || needs.build-if-needed.outputs.lfx-artifact-name }}" ]; then
|
||||
LFX_WHEEL=$(find ./lfx-dist -name "*.whl" -type f | head -1)
|
||||
if [ -n "$LFX_WHEEL" ]; then
|
||||
echo "Force reinstalling LFX: $LFX_WHEEL"
|
||||
INSTALL_WHEELS=("$LFX_WHEEL")
|
||||
SDK_WHEEL=""
|
||||
if [ -d ./sdk-dist ]; then
|
||||
SDK_WHEEL=$(find ./sdk-dist -name "*.whl" -type f | head -1)
|
||||
fi
|
||||
if [ -n "$SDK_WHEEL" ]; then
|
||||
echo "Force reinstalling langflow-sdk with LFX: $SDK_WHEEL"
|
||||
INSTALL_WHEELS=("$SDK_WHEEL" "$LFX_WHEEL")
|
||||
fi
|
||||
NO_DEPS="--no-deps"
|
||||
if [ "${{ inputs.pre_release }}" = "true" ]; then
|
||||
NO_DEPS=""
|
||||
fi
|
||||
uv pip install --force-reinstall $NO_DEPS --prerelease=allow --python ./test-env/bin/python "$LFX_WHEEL"
|
||||
uv pip install --force-reinstall $NO_DEPS --prerelease=if-necessary-or-explicit --python ./test-env/bin/python "${INSTALL_WHEELS[@]}"
|
||||
fi
|
||||
fi
|
||||
|
||||
@@ -618,7 +673,7 @@ jobs:
|
||||
if [ "${{ inputs.pre_release }}" = "true" ]; then
|
||||
NO_DEPS=""
|
||||
fi
|
||||
uv pip install --force-reinstall $NO_DEPS --prerelease=allow --python ./test-env/bin/python "$BASE_WHEEL"
|
||||
uv pip install --force-reinstall $NO_DEPS --prerelease=if-necessary-or-explicit --python ./test-env/bin/python "$BASE_WHEEL"
|
||||
fi
|
||||
shell: bash
|
||||
|
||||
|
||||
@@ -0,0 +1,491 @@
|
||||
name: DB Migration Validation
|
||||
|
||||
on:
|
||||
workflow_call:
|
||||
inputs:
|
||||
nightly_tag:
|
||||
description: "Nightly tag to test migration to"
|
||||
required: true
|
||||
type: string
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
nightly_tag:
|
||||
description: "Nightly tag to test migration to (e.g., langflowai/langflow-nightly:latest)"
|
||||
required: false
|
||||
type: string
|
||||
default: "langflowai/langflow-nightly:latest"
|
||||
# Note: This workflow is called by nightly_build.yml after Docker images are built
|
||||
|
||||
env:
|
||||
PYTHON_VERSION: "3.13"
|
||||
POSTGRES_DB: langflow_test
|
||||
POSTGRES_USER: langflow
|
||||
POSTGRES_PASSWORD: langflow_test_pass # pragma: allowlist secret
|
||||
|
||||
jobs:
|
||||
migration-pip-venv:
|
||||
name: "Migration Test: pip/venv (stable → nightly)"
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 30
|
||||
|
||||
services:
|
||||
postgres:
|
||||
image: postgres:16
|
||||
env:
|
||||
POSTGRES_DB: ${{ env.POSTGRES_DB }}
|
||||
POSTGRES_USER: ${{ env.POSTGRES_USER }}
|
||||
POSTGRES_PASSWORD: ${{ env.POSTGRES_PASSWORD }}
|
||||
options: >-
|
||||
--health-cmd pg_isready
|
||||
--health-interval 10s
|
||||
--health-timeout 5s
|
||||
--health-retries 5
|
||||
ports:
|
||||
- 5432:5432
|
||||
|
||||
steps:
|
||||
- name: Setup Python
|
||||
uses: actions/setup-python@v6
|
||||
with:
|
||||
python-version: ${{ env.PYTHON_VERSION }}
|
||||
|
||||
- name: Install uv
|
||||
uses: astral-sh/setup-uv@v6
|
||||
with:
|
||||
enable-cache: false
|
||||
|
||||
- name: Create test directory
|
||||
run: |
|
||||
mkdir -p migration-test
|
||||
cd migration-test
|
||||
|
||||
- name: Install latest stable Langflow with PostgreSQL
|
||||
working-directory: migration-test
|
||||
run: |
|
||||
echo "Installing latest stable langflow[postgresql]..."
|
||||
uv venv
|
||||
source .venv/bin/activate
|
||||
uv pip install "langflow[postgresql]"
|
||||
|
||||
# Verify installation
|
||||
python -c 'from importlib.metadata import version; print("Installed Langflow version:", version("langflow"))'
|
||||
|
||||
- name: Initialize database with stable version
|
||||
working-directory: migration-test
|
||||
env:
|
||||
LANGFLOW_DATABASE_URL: postgresql://${{ env.POSTGRES_USER }}:${{ env.POSTGRES_PASSWORD }}@localhost:5432/${{ env.POSTGRES_DB }} # pragma: allowlist secret
|
||||
run: |
|
||||
source .venv/bin/activate
|
||||
|
||||
echo "Starting Langflow to initialize database..."
|
||||
# shellcheck disable=SC2016
|
||||
timeout 120 bash -c '
|
||||
python -m langflow run --host 127.0.0.1 --port 7860 --backend-only &
|
||||
LANGFLOW_PID=$!
|
||||
until curl -f http://127.0.0.1:7860/health_check 2>/dev/null; do
|
||||
sleep 2
|
||||
done
|
||||
kill $LANGFLOW_PID
|
||||
wait $LANGFLOW_PID 2>/dev/null || true
|
||||
' || {
|
||||
echo "Failed to start Langflow stable version"
|
||||
exit 1
|
||||
}
|
||||
|
||||
echo "Database initialized successfully with stable version"
|
||||
|
||||
- name: Create witness flow
|
||||
working-directory: migration-test
|
||||
env:
|
||||
LANGFLOW_DATABASE_URL: postgresql://${{ env.POSTGRES_USER }}:${{ env.POSTGRES_PASSWORD }}@localhost:5432/${{ env.POSTGRES_DB }}
|
||||
run: |
|
||||
source .venv/bin/activate
|
||||
|
||||
# Start Langflow briefly to create test data
|
||||
python -m langflow run --host 127.0.0.1 --port 7860 --backend-only &
|
||||
LANGFLOW_PID=$!
|
||||
|
||||
# Wait for startup
|
||||
timeout 60 bash -c 'until curl -f http://127.0.0.1:7860/health_check 2>/dev/null; do sleep 2; done'
|
||||
|
||||
# Get auth token via auto_login (works under default AUTO_LOGIN=true, no credentials needed)
|
||||
TOKEN=$(curl -fsS http://127.0.0.1:7860/api/v1/auto_login | python3 -c "import json,sys; print(json.load(sys.stdin)['access_token'])")
|
||||
if [ -z "$TOKEN" ] || [ "$TOKEN" = "null" ]; then
|
||||
echo "❌ Failed to get authentication token"
|
||||
kill $LANGFLOW_PID
|
||||
exit 1
|
||||
fi
|
||||
echo "✅ Authentication token obtained"
|
||||
|
||||
# Create a witness flow (proves row persistence)
|
||||
curl -fsSL -X POST http://127.0.0.1:7860/api/v1/flows/ \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "Authorization: Bearer $TOKEN" \
|
||||
-d '{
|
||||
"name": "Migration Witness Flow",
|
||||
"description": "Test flow to verify data persistence across migration",
|
||||
"data": {"nodes": [], "edges": []}
|
||||
}' > flow_response.json
|
||||
|
||||
if ! grep -q '"id"' flow_response.json; then
|
||||
echo "❌ Flow creation failed - no id in response"
|
||||
cat flow_response.json
|
||||
exit 1
|
||||
fi
|
||||
echo "✅ Witness flow created successfully"
|
||||
|
||||
# Create a witness credential (type=Credential stores encrypted value, exercises encrypted-column migrations)
|
||||
curl -fsSL -X POST http://127.0.0.1:7860/api/v1/variables/ \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "Authorization: Bearer $TOKEN" \
|
||||
-d '{"name": "migration_witness_secret", "value": "witness_value_12345", "type": "Credential", "default_fields": []}' > variable_response.json # pragma: allowlist secret
|
||||
|
||||
if ! grep -q '"id"' variable_response.json; then
|
||||
echo "❌ Credential creation failed - no id in response"
|
||||
cat variable_response.json
|
||||
exit 1
|
||||
fi
|
||||
echo "✅ Witness credential created successfully"
|
||||
|
||||
# Stop Langflow
|
||||
kill $LANGFLOW_PID
|
||||
wait $LANGFLOW_PID 2>/dev/null || true
|
||||
|
||||
echo "Witness data created (flow + credential)"
|
||||
|
||||
- name: Upgrade to nightly version
|
||||
working-directory: migration-test
|
||||
run: |
|
||||
source .venv/bin/activate
|
||||
|
||||
NIGHTLY_TAG="${{ inputs.nightly_tag || 'langflowai/langflow-nightly:latest' }}"
|
||||
echo "Upgrading to nightly version: $NIGHTLY_TAG"
|
||||
|
||||
# Remove stable distributions before installing nightly.
|
||||
# langflow and langflow-nightly share langflow-base which writes to the
|
||||
# same site-packages/langflow/ namespace. If stable stays installed,
|
||||
# python -m langflow still boots the stable version and no real
|
||||
# nightly migration is exercised (false-positive test).
|
||||
echo "Removing stable langflow to avoid namespace collision with nightly..."
|
||||
uv pip uninstall -y langflow langflow-base || true
|
||||
|
||||
# Extract version from Docker tag (format: langflowai/langflow-nightly:v1.10.0.dev20260522)
|
||||
if [[ "$NIGHTLY_TAG" == *":"* ]]; then
|
||||
VERSION="${NIGHTLY_TAG##*:}"
|
||||
echo "Extracted version from tag: $VERSION"
|
||||
|
||||
# Strip 'v' prefix if present (PyPI doesn't use 'v' prefix)
|
||||
VERSION="${VERSION#v}"
|
||||
echo "Version for PyPI: $VERSION"
|
||||
|
||||
if [[ "$VERSION" == "latest" ]]; then
|
||||
# Install latest nightly from PyPI
|
||||
uv pip install --upgrade 'langflow-nightly[postgresql]'
|
||||
else
|
||||
# Install specific version
|
||||
uv pip install --upgrade "langflow-nightly[postgresql]==$VERSION"
|
||||
fi
|
||||
else
|
||||
# Direct version string (strip 'v' prefix if present)
|
||||
VERSION="${NIGHTLY_TAG#v}"
|
||||
uv pip install --upgrade "langflow-nightly[postgresql]==$VERSION"
|
||||
fi
|
||||
|
||||
# Verify upgrade
|
||||
python -c 'from importlib.metadata import version; print("Upgraded to Langflow Nightly version:", version("langflow-nightly"))'
|
||||
|
||||
- name: Run migration and verify startup
|
||||
working-directory: migration-test
|
||||
env:
|
||||
LANGFLOW_DATABASE_URL: postgresql://${{ env.POSTGRES_USER }}:${{ env.POSTGRES_PASSWORD }}@localhost:5432/${{ env.POSTGRES_DB }} # pragma: allowlist secret
|
||||
run: |
|
||||
source .venv/bin/activate
|
||||
|
||||
echo "Starting Langflow nightly to run migrations..."
|
||||
# shellcheck disable=SC2016
|
||||
timeout 180 bash -c '
|
||||
python -m langflow run --host 127.0.0.1 --port 7860 --backend-only > langflow_nightly.log 2>&1 &
|
||||
LANGFLOW_PID=$!
|
||||
|
||||
until curl -f http://127.0.0.1:7860/health_check 2>/dev/null; do
|
||||
if ! kill -0 $LANGFLOW_PID 2>/dev/null; then
|
||||
echo "Langflow process died during startup"
|
||||
cat langflow_nightly.log
|
||||
exit 1
|
||||
fi
|
||||
sleep 2
|
||||
done
|
||||
|
||||
echo "Langflow nightly started successfully"
|
||||
kill $LANGFLOW_PID
|
||||
wait $LANGFLOW_PID 2>/dev/null || true
|
||||
' || {
|
||||
echo "Failed to start Langflow nightly version"
|
||||
cat langflow_nightly.log || true
|
||||
exit 1
|
||||
}
|
||||
|
||||
- name: Verify witness data persisted
|
||||
working-directory: migration-test
|
||||
env:
|
||||
LANGFLOW_DATABASE_URL: postgresql://${{ env.POSTGRES_USER }}:${{ env.POSTGRES_PASSWORD }}@localhost:5432/${{ env.POSTGRES_DB }}
|
||||
run: |
|
||||
source .venv/bin/activate
|
||||
|
||||
# Start Langflow to query data
|
||||
python -m langflow run --host 127.0.0.1 --port 7860 --backend-only &
|
||||
LANGFLOW_PID=$!
|
||||
|
||||
timeout 60 bash -c 'until curl -f http://127.0.0.1:7860/health_check 2>/dev/null; do sleep 2; done'
|
||||
|
||||
# Get auth token via auto_login (works under default AUTO_LOGIN=true, no credentials needed)
|
||||
TOKEN=$(curl -fsS http://127.0.0.1:7860/api/v1/auto_login | python3 -c "import json,sys; print(json.load(sys.stdin)['access_token'])")
|
||||
if [ -z "$TOKEN" ] || [ "$TOKEN" = "null" ]; then
|
||||
echo "❌ Failed to get authentication token"
|
||||
kill $LANGFLOW_PID
|
||||
exit 1
|
||||
fi
|
||||
echo "✅ Authentication token obtained"
|
||||
|
||||
# Verify witness flow persisted
|
||||
curl -fsSL --compressed http://127.0.0.1:7860/api/v1/flows/ \
|
||||
-H "Authorization: Bearer $TOKEN" > flows_after_migration.json
|
||||
|
||||
if grep -q "Migration Witness Flow" flows_after_migration.json; then
|
||||
echo "✅ Witness flow found after migration"
|
||||
else
|
||||
echo "❌ Witness flow NOT found after migration"
|
||||
cat flows_after_migration.json
|
||||
kill $LANGFLOW_PID
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Verify witness credential persisted (confirms encrypted-column migration ran without data loss)
|
||||
curl -fsSL --compressed http://127.0.0.1:7860/api/v1/variables/ \
|
||||
-H "Authorization: Bearer $TOKEN" > variables_after_migration.json
|
||||
|
||||
if grep -q "migration_witness_secret" variables_after_migration.json; then
|
||||
echo "✅ Witness credential found after migration"
|
||||
else
|
||||
echo "❌ Witness credential NOT found after migration"
|
||||
cat variables_after_migration.json
|
||||
kill $LANGFLOW_PID
|
||||
exit 1
|
||||
fi
|
||||
|
||||
kill $LANGFLOW_PID
|
||||
wait $LANGFLOW_PID 2>/dev/null || true
|
||||
|
||||
- name: Upload logs on failure
|
||||
if: failure()
|
||||
uses: actions/upload-artifact@v6
|
||||
with:
|
||||
name: migration-pip-venv-logs
|
||||
path: |
|
||||
migration-test/*.log
|
||||
migration-test/*.json
|
||||
retention-days: 7
|
||||
|
||||
migration-docker-compose:
|
||||
name: "Migration Test: Docker Compose (stable → nightly)"
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 30
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Create Docker Compose test directory
|
||||
run: |
|
||||
mkdir -p docker-migration-test
|
||||
cd docker-migration-test
|
||||
|
||||
- name: Create Docker Compose file for stable
|
||||
working-directory: docker-migration-test
|
||||
run: | # pragma: allowlist secret
|
||||
cat > docker-compose.yml <<'EOF'
|
||||
services:
|
||||
langflow:
|
||||
image: langflowai/langflow:latest
|
||||
ports:
|
||||
- "7860:7860"
|
||||
environment: # pragma: allowlist secret
|
||||
- LANGFLOW_DATABASE_URL=postgresql://langflow:langflow@postgres:5432/langflow # pragma: allowlist secret
|
||||
depends_on:
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
healthcheck:
|
||||
test: ["CMD", "curl", "-f", "http://localhost:7860/health_check"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 10
|
||||
|
||||
postgres:
|
||||
image: postgres:16
|
||||
environment: # pragma: allowlist secret
|
||||
- POSTGRES_USER=langflow
|
||||
- POSTGRES_PASSWORD=langflow # pragma: allowlist secret
|
||||
- POSTGRES_DB=langflow
|
||||
volumes:
|
||||
- langflow_postgres_data:/var/lib/postgresql/data
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U langflow"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
|
||||
volumes:
|
||||
langflow_postgres_data:
|
||||
EOF
|
||||
|
||||
- name: Start stable Langflow with Docker Compose
|
||||
working-directory: docker-migration-test
|
||||
run: |
|
||||
echo "Starting Langflow stable version..."
|
||||
docker compose up -d
|
||||
|
||||
echo "Waiting for Langflow to be healthy..."
|
||||
timeout 180 bash -c 'until docker compose exec -T langflow curl -f http://localhost:7860/health_check 2>/dev/null; do sleep 5; done' || {
|
||||
echo "Langflow stable failed to start"
|
||||
docker compose logs
|
||||
exit 1
|
||||
}
|
||||
|
||||
echo "Langflow stable is running"
|
||||
|
||||
- name: Create witness flow via API
|
||||
working-directory: docker-migration-test
|
||||
run: |
|
||||
# Get auth token via auto_login (works under default AUTO_LOGIN=true, no credentials needed)
|
||||
TOKEN=$(curl -fsS http://localhost:7860/api/v1/auto_login | python3 -c "import json,sys; print(json.load(sys.stdin)['access_token'])")
|
||||
if [ -z "$TOKEN" ] || [ "$TOKEN" = "null" ]; then
|
||||
echo "❌ Failed to get authentication token"
|
||||
exit 1
|
||||
fi
|
||||
echo "✅ Authentication token obtained"
|
||||
|
||||
echo "Creating witness flow..."
|
||||
curl -fsSL -X POST http://localhost:7860/api/v1/flows/ \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "Authorization: Bearer $TOKEN" \
|
||||
-d '{
|
||||
"name": "Docker Migration Witness Flow",
|
||||
"description": "Test flow for Docker Compose migration",
|
||||
"data": {"nodes": [], "edges": []}
|
||||
}' > flow_response.json
|
||||
|
||||
if ! grep -q '"id"' flow_response.json; then
|
||||
echo "❌ Flow creation failed - no id in response"
|
||||
cat flow_response.json
|
||||
exit 1
|
||||
fi
|
||||
echo "✅ Witness flow created successfully"
|
||||
|
||||
echo "Creating witness credential (exercises encrypted-column migrations)..."
|
||||
curl -fsSL -X POST http://localhost:7860/api/v1/variables/ \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "Authorization: Bearer $TOKEN" \
|
||||
-d '{"name": "migration_witness_secret", "value": "witness_value_12345", "type": "Credential", "default_fields": []}' > variable_response.json # pragma: allowlist secret
|
||||
|
||||
if ! grep -q '"id"' variable_response.json; then
|
||||
echo "❌ Credential creation failed - no id in response"
|
||||
cat variable_response.json
|
||||
exit 1
|
||||
fi
|
||||
echo "✅ Witness credential created successfully"
|
||||
|
||||
- name: Stop stable Langflow (keep PostgreSQL volume)
|
||||
working-directory: docker-migration-test
|
||||
run: |
|
||||
echo "Stopping Langflow stable..."
|
||||
docker compose stop langflow
|
||||
docker compose rm -f langflow
|
||||
|
||||
- name: Update to nightly image
|
||||
working-directory: docker-migration-test
|
||||
run: |
|
||||
NIGHTLY_TAG="${{ inputs.nightly_tag || 'langflowai/langflow-nightly:latest' }}"
|
||||
# Strip 'v' prefix from version part — Docker images are published without it
|
||||
# e.g. langflowai/langflow-nightly:v1.10.0.dev20260522 → langflowai/langflow-nightly:1.10.0.dev20260522
|
||||
VERSION_PART="${NIGHTLY_TAG##*:}"
|
||||
IMAGE_NAME="${NIGHTLY_TAG%%:*}"
|
||||
DOCKER_TAG="${IMAGE_NAME}:${VERSION_PART#v}"
|
||||
echo "Updating to nightly: $DOCKER_TAG"
|
||||
|
||||
# Update docker-compose.yml to use nightly image
|
||||
sed -i "s|image: langflowai/langflow:latest|image: $DOCKER_TAG|" docker-compose.yml
|
||||
|
||||
cat docker-compose.yml
|
||||
|
||||
- name: Start nightly Langflow with same PostgreSQL volume
|
||||
working-directory: docker-migration-test
|
||||
run: |
|
||||
echo "Starting Langflow nightly version..."
|
||||
docker compose up -d langflow
|
||||
|
||||
echo "Waiting for Langflow nightly to be healthy..."
|
||||
timeout 180 bash -c 'until docker compose exec -T langflow curl -f http://localhost:7860/health_check 2>/dev/null; do sleep 5; done' || {
|
||||
echo "Langflow nightly failed to start"
|
||||
docker compose logs langflow
|
||||
exit 1
|
||||
}
|
||||
|
||||
echo "Langflow nightly started successfully"
|
||||
|
||||
- name: Verify witness data persisted
|
||||
working-directory: docker-migration-test
|
||||
run: |
|
||||
# Get auth token via auto_login (works under default AUTO_LOGIN=true, no credentials needed)
|
||||
TOKEN=$(curl -fsS http://localhost:7860/api/v1/auto_login | python3 -c "import json,sys; print(json.load(sys.stdin)['access_token'])")
|
||||
if [ -z "$TOKEN" ] || [ "$TOKEN" = "null" ]; then
|
||||
echo "❌ Failed to get authentication token"
|
||||
exit 1
|
||||
fi
|
||||
echo "✅ Authentication token obtained"
|
||||
|
||||
echo "Verifying witness flow persisted..."
|
||||
curl -fsSL --compressed http://localhost:7860/api/v1/flows/ \
|
||||
-H "Authorization: Bearer $TOKEN" > flows_after_migration.json
|
||||
|
||||
if grep -q "Docker Migration Witness Flow" flows_after_migration.json; then
|
||||
echo "✅ Witness flow found after Docker migration"
|
||||
else
|
||||
echo "❌ Witness flow NOT found after Docker migration"
|
||||
cat flows_after_migration.json
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "Verifying witness credential persisted (confirms encrypted-column migration ran without data loss)..."
|
||||
curl -fsSL --compressed http://localhost:7860/api/v1/variables/ \
|
||||
-H "Authorization: Bearer $TOKEN" > variables_after_migration.json
|
||||
|
||||
if grep -q "migration_witness_secret" variables_after_migration.json; then
|
||||
echo "✅ Witness credential found after Docker migration"
|
||||
else
|
||||
echo "❌ Witness credential NOT found after Docker migration"
|
||||
cat variables_after_migration.json
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Collect logs on failure
|
||||
if: failure()
|
||||
working-directory: docker-migration-test
|
||||
run: |
|
||||
docker compose logs > docker-compose-logs.txt
|
||||
|
||||
- name: Upload logs on failure
|
||||
if: failure()
|
||||
uses: actions/upload-artifact@v6
|
||||
with:
|
||||
name: migration-docker-compose-logs
|
||||
path: |
|
||||
docker-migration-test/*.log
|
||||
docker-migration-test/*.json
|
||||
docker-migration-test/*.txt
|
||||
retention-days: 7
|
||||
|
||||
- name: Cleanup
|
||||
if: always()
|
||||
working-directory: docker-migration-test
|
||||
run: |
|
||||
docker compose down -v
|
||||
@@ -28,6 +28,8 @@ jobs:
|
||||
cache-dependency-path: ./docs/package-lock.json
|
||||
|
||||
- name: Validate Branch Names
|
||||
env:
|
||||
BRANCH_REF: ${{ github.event.pull_request.head.ref }}
|
||||
run: |
|
||||
# Check if branch names contain invalid characters. Only alphanumeric, _, -, ., and / are allowed.
|
||||
validate_branch_name() {
|
||||
@@ -37,10 +39,12 @@ jobs:
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
validate_branch_name "${{ github.event.pull_request.head.ref }}"
|
||||
validate_branch_name "$BRANCH_REF"
|
||||
|
||||
- name: Extract Branch Names
|
||||
id: extract_branch
|
||||
env:
|
||||
BRANCH_REF: ${{ github.event.pull_request.head.ref }}
|
||||
run: |
|
||||
# Extract and transform branch names
|
||||
extract_branch() {
|
||||
@@ -56,7 +60,7 @@ jobs:
|
||||
}
|
||||
|
||||
# Transform branch names in form of `refs/heads/main` to `main`
|
||||
draft_branch=$(extract_branch "${{ github.event.pull_request.head.ref }}")
|
||||
draft_branch=$(extract_branch "$BRANCH_REF")
|
||||
|
||||
# Replace / with - in the draft branch name to use as a directory name
|
||||
draft_directory=$(echo "$draft_branch" | tr / -)
|
||||
|
||||
@@ -124,7 +124,7 @@ jobs:
|
||||
- name: Determine version
|
||||
id: version
|
||||
run: |
|
||||
version=$(uv tree 2>/dev/null | grep '^langflow' | grep -v '^langflow-base' | cut -d' ' -f2 | sed 's/^v//')
|
||||
version=$(uv tree 2>/dev/null | grep -E '^langflow(-nightly)?[[:space:]]' | cut -d' ' -f2 | sed 's/^v//')
|
||||
echo "Main version from pyproject.toml: $version"
|
||||
|
||||
if [ ${{inputs.pre_release}} == "true" ]; then
|
||||
@@ -137,7 +137,8 @@ jobs:
|
||||
echo "Latest main release version: $last_released_version"
|
||||
fi
|
||||
|
||||
if [ "$version" = "$last_released_version" ]; then
|
||||
# Skip check only applies to main image, not backend/frontend which are separate images
|
||||
if [ "$version" = "$last_released_version" ] && [[ "${{ inputs.release_type }}" != "main-backend" ]] && [[ "${{ inputs.release_type }}" != "main-frontend" ]]; then
|
||||
echo "Main docker version $version is already released. Skipping release."
|
||||
echo skipped=true >> $GITHUB_OUTPUT
|
||||
exit 0
|
||||
@@ -603,7 +604,7 @@ jobs:
|
||||
if [[ "${{ inputs.release_type }}" == "base" ]]; then
|
||||
version=$(uv tree 2>/dev/null | grep 'langflow-base' | awk '{print $3}' | sed 's/^v//' | head -n 1)
|
||||
else
|
||||
version=$(uv tree 2>/dev/null | grep '^langflow' | grep -v '^langflow-base' | cut -d' ' -f2 | sed 's/^v//')
|
||||
version=$(uv tree 2>/dev/null | grep -E '^langflow(-nightly)?[[:space:]]' | cut -d' ' -f2 | sed 's/^v//')
|
||||
fi
|
||||
echo "Using version: $version"
|
||||
echo version=$version >> $GITHUB_OUTPUT
|
||||
|
||||
@@ -150,7 +150,7 @@ jobs:
|
||||
if: ${{ inputs.main_version == '' && (inputs.release_type == 'main' || inputs.release_type == 'main-ep' || inputs.release_type == 'nightly-main' || inputs.release_type == 'main-all' || inputs.release_type == 'nightly-main-all') }}
|
||||
id: get-version-main
|
||||
run: |
|
||||
version=$(uv tree | grep 'langflow' | grep -v 'langflow-base' | awk '{print $2}' | sed 's/^v//')
|
||||
version=$(uv tree | grep -E '^langflow(-nightly)?[[:space:]]' | awk '{print $2}' | sed 's/^v//')
|
||||
echo version=$version
|
||||
echo version=$version >> $GITHUB_OUTPUT
|
||||
setup:
|
||||
|
||||
@@ -117,6 +117,7 @@ jobs:
|
||||
with:
|
||||
context: .
|
||||
push: ${{ inputs.push_to_registry }}
|
||||
pull: true
|
||||
file: ./docker/build_and_push_base.Dockerfile
|
||||
tags: ${{ steps.tags.outputs.docker_tags }}
|
||||
platforms: linux/${{ matrix.arch }}
|
||||
@@ -129,6 +130,7 @@ jobs:
|
||||
with:
|
||||
context: .
|
||||
push: ${{ inputs.push_to_registry }}
|
||||
pull: true
|
||||
file: ./docker/build_and_push_base.Dockerfile
|
||||
tags: ${{ steps.tags.outputs.ghcr_tags }}
|
||||
platforms: linux/${{ matrix.arch }}
|
||||
@@ -166,7 +168,7 @@ jobs:
|
||||
id: version
|
||||
run: |
|
||||
echo "Extracting main version from pyproject.toml"
|
||||
version=$(uv tree 2>/dev/null | grep '^langflow' | grep -v '^langflow-base' | cut -d' ' -f2 | sed 's/^v//')
|
||||
version=$(uv tree 2>/dev/null | grep -E '^langflow(-nightly)?[[:space:]]' | cut -d' ' -f2 | sed 's/^v//')
|
||||
|
||||
# Verify nightly tag format
|
||||
if [[ ! "$version" =~ ^[0-9]+\.[0-9]+\.[0-9]+\.dev[0-9]+$ ]]; then
|
||||
@@ -210,6 +212,7 @@ jobs:
|
||||
with:
|
||||
context: .
|
||||
push: ${{ inputs.push_to_registry }}
|
||||
pull: true
|
||||
file: ./docker/build_and_push.Dockerfile
|
||||
tags: ${{ steps.tags.outputs.docker_tags }}
|
||||
platforms: linux/${{ matrix.arch }}
|
||||
@@ -222,6 +225,7 @@ jobs:
|
||||
with:
|
||||
context: .
|
||||
push: ${{ inputs.push_to_registry }}
|
||||
pull: true
|
||||
file: ./docker/build_and_push.Dockerfile
|
||||
tags: ${{ steps.tags.outputs.ghcr_tags }}
|
||||
platforms: linux/${{ matrix.arch }}
|
||||
@@ -259,7 +263,7 @@ jobs:
|
||||
id: version
|
||||
run: |
|
||||
echo "Extracting main version from pyproject.toml"
|
||||
version=$(uv tree 2>/dev/null | grep '^langflow' | grep -v '^langflow-base' | cut -d' ' -f2 | sed 's/^v//')
|
||||
version=$(uv tree 2>/dev/null | grep -E '^langflow(-nightly)?[[:space:]]' | cut -d' ' -f2 | sed 's/^v//')
|
||||
|
||||
# Verify nightly tag format
|
||||
if [[ ! "$version" =~ ^[0-9]+\.[0-9]+\.[0-9]+\.dev[0-9]+$ ]]; then
|
||||
@@ -303,6 +307,7 @@ jobs:
|
||||
with:
|
||||
context: .
|
||||
push: ${{ inputs.push_to_registry }}
|
||||
pull: true
|
||||
file: ./docker/build_and_push_with_extras.Dockerfile
|
||||
tags: ${{ steps.tags.outputs.docker_tags }}
|
||||
platforms: linux/${{ matrix.arch }}
|
||||
@@ -315,6 +320,7 @@ jobs:
|
||||
with:
|
||||
context: .
|
||||
push: ${{ inputs.push_to_registry }}
|
||||
pull: true
|
||||
file: ./docker/build_and_push_with_extras.Dockerfile
|
||||
tags: ${{ steps.tags.outputs.ghcr_tags }}
|
||||
platforms: linux/${{ matrix.arch }}
|
||||
@@ -347,7 +353,7 @@ jobs:
|
||||
if [[ "${{ inputs.release_type }}" == "nightly-base" ]]; then
|
||||
version=$(uv tree 2>/dev/null | grep 'langflow-base' | awk '{print $3}' | sed 's/^v//' | head -n 1)
|
||||
else
|
||||
version=$(uv tree 2>/dev/null | grep '^langflow' | grep -v '^langflow-base' | cut -d' ' -f2 | sed 's/^v//')
|
||||
version=$(uv tree 2>/dev/null | grep -E '^langflow(-nightly)?[[:space:]]' | cut -d' ' -f2 | sed 's/^v//')
|
||||
fi
|
||||
echo "Using version: $version"
|
||||
echo version=$version >> $GITHUB_OUTPUT
|
||||
|
||||
@@ -1,124 +0,0 @@
|
||||
name: Update OpenAPI Spec
|
||||
|
||||
on:
|
||||
schedule:
|
||||
- cron: "0 20 * * 1" # Monday 4pm EST
|
||||
workflow_dispatch: # Allow manual trigger
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
pull-requests: write
|
||||
|
||||
jobs:
|
||||
check-openapi-updates:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Install jq
|
||||
run: sudo apt-get install -y jq
|
||||
|
||||
- name: Check if there is already an open pull request
|
||||
id: check_pull_request
|
||||
run: |
|
||||
if [[ -n $(gh pr list --state open --repo ${{ github.repository }} | grep "docs: OpenAPI spec") ]]; then
|
||||
echo "There is already an open PR with updates to the OpenAPI spec. Merge or close that PR first. Skipping."
|
||||
echo "pr_exists=true" >> $GITHUB_OUTPUT
|
||||
else
|
||||
echo "pr_exists=false" >> $GITHUB_OUTPUT
|
||||
fi
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Run Langflow container and get OpenAPI spec
|
||||
if: steps.check_pull_request.outputs.pr_exists != 'true'
|
||||
run: |
|
||||
# Start the container in the background
|
||||
docker run -d --name langflow -p 7860:7860 ghcr.io/hanzoai/flow:latest
|
||||
|
||||
# Wait for the service to be ready (adjust timeout as needed)
|
||||
echo "Waiting for Langflow to start..."
|
||||
timeout=60
|
||||
elapsed=0
|
||||
while ! curl -s http://localhost:7860/health > /dev/null; do
|
||||
sleep 2
|
||||
elapsed=$((elapsed+2))
|
||||
if [ "$elapsed" -ge "$timeout" ]; then
|
||||
echo "Timed out waiting for Langflow to start"
|
||||
exit 1
|
||||
fi
|
||||
echo "Still waiting... ($elapsed seconds)"
|
||||
done
|
||||
|
||||
# Get the OpenAPI spec and save to file
|
||||
echo "Fetching OpenAPI spec..."
|
||||
curl -s http://localhost:7860/openapi.json > docs/openapi/new_openapi.json
|
||||
|
||||
# Verify file was created
|
||||
ls -la docs/openapi/new_openapi.json
|
||||
|
||||
# stop the container when done
|
||||
docker stop langflow
|
||||
|
||||
- name: Compare OpenAPI files
|
||||
if: steps.check_pull_request.outputs.pr_exists != 'true'
|
||||
id: compare
|
||||
run: |
|
||||
# Extract versions
|
||||
NEW_VERSION=$(jq -r '.info.version' docs/openapi/new_openapi.json)
|
||||
CURRENT_VERSION=$(jq -r '.info.version' docs/openapi/openapi.json)
|
||||
|
||||
echo "Current version: $CURRENT_VERSION"
|
||||
echo "New version: $NEW_VERSION"
|
||||
|
||||
# Compare file content (normalize by sorting keys)
|
||||
jq --sort-keys . docs/openapi/new_openapi.json > docs/openapi/sorted_new.json
|
||||
jq --sort-keys . docs/openapi/openapi.json > docs/openapi/sorted_current.json
|
||||
|
||||
if ! cmp -s docs/openapi/sorted_new.json docs/openapi/sorted_current.json; then
|
||||
echo "OpenAPI spec content has changed."
|
||||
|
||||
# Clean the new spec for ReDoc formatting
|
||||
echo "Cleaning OpenAPI spec formatting..."
|
||||
python3 docs/scripts/clean_openapi_formatting.py docs/openapi/new_openapi.json
|
||||
|
||||
# Compare versions (assuming semantic versioning)
|
||||
if [ "$(printf '%s\n' "$CURRENT_VERSION" "$NEW_VERSION" | sort -V | tail -n1)" == "$NEW_VERSION" ] && [ "$CURRENT_VERSION" != "$NEW_VERSION" ]; then
|
||||
echo "New version detected. Creating PR."
|
||||
echo "NEEDS_UPDATE=true" >> $GITHUB_ENV
|
||||
echo "NEW_VERSION=$NEW_VERSION" >> $GITHUB_ENV
|
||||
echo "UPDATE_REASON=version upgraded from $CURRENT_VERSION to $NEW_VERSION" >> $GITHUB_ENV
|
||||
else
|
||||
echo "File content changed but version remains the same. Creating PR."
|
||||
echo "NEEDS_UPDATE=true" >> $GITHUB_ENV
|
||||
echo "NEW_VERSION=$CURRENT_VERSION" >> $GITHUB_ENV
|
||||
echo "UPDATE_REASON=content updated without version change" >> $GITHUB_ENV
|
||||
fi
|
||||
|
||||
cat docs/openapi/new_openapi.json | jq > docs/openapi/openapi.json
|
||||
else
|
||||
echo "No changes detected in OpenAPI spec content."
|
||||
echo "NEEDS_UPDATE=false" >> $GITHUB_ENV
|
||||
fi
|
||||
|
||||
# Clean up
|
||||
rm docs/openapi/new_openapi.json docs/openapi/sorted_new.json docs/openapi/sorted_current.json
|
||||
|
||||
- name: Create Pull Request
|
||||
if: env.NEEDS_UPDATE == 'true' && steps.check_pull_request.outputs.pr_exists != 'true'
|
||||
uses: peter-evans/create-pull-request@v8
|
||||
with:
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
commit-message: "docs: OpenAPI spec ${{ env.UPDATE_REASON }}"
|
||||
title: "docs: OpenAPI spec ${{ env.UPDATE_REASON }}"
|
||||
body: |
|
||||
This PR updates the OpenAPI spec.
|
||||
|
||||
Update reason: ${{ env.UPDATE_REASON }}
|
||||
Version: ${{ env.NEW_VERSION }}
|
||||
branch: update-openapi-spec
|
||||
branch-suffix: timestamp
|
||||
delete-branch: true
|
||||
reviewers: mendonk
|
||||
@@ -0,0 +1,107 @@
|
||||
name: GP Download Translations
|
||||
|
||||
on:
|
||||
schedule:
|
||||
- cron: "0 23 * * *" # 11 PM UTC — 1h before nightly build at midnight
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
download:
|
||||
runs-on: ubuntu-latest
|
||||
name: Download translations from Globalization Pipeline
|
||||
environment: GP-test
|
||||
permissions:
|
||||
contents: write
|
||||
pull-requests: write
|
||||
|
||||
steps:
|
||||
- name: Resolve latest release branch
|
||||
id: resolve-branch
|
||||
run: |
|
||||
BRANCH=$(git ls-remote --heads https://github.com/${{ github.repository }} 'refs/heads/release-*' \
|
||||
| awk '{print $2}' \
|
||||
| sed 's|refs/heads/||' \
|
||||
| grep -E '^release-[0-9]+\.[0-9]+\.[0-9]+$' \
|
||||
| sort -V \
|
||||
| tail -n 1)
|
||||
if [ -z "$BRANCH" ]; then
|
||||
echo "No release-* branch found in ${{ github.repository }}"
|
||||
exit 1
|
||||
fi
|
||||
echo "branch=${BRANCH}" >> "$GITHUB_OUTPUT"
|
||||
echo "Using release branch: $BRANCH"
|
||||
|
||||
- name: Checkout release branch
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
ref: ${{ steps.resolve-branch.outputs.branch }}
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: "3.12"
|
||||
|
||||
- name: Install dependencies
|
||||
run: pip install -r scripts/gp/requirements.txt
|
||||
|
||||
- name: Download translations from GP
|
||||
working-directory: scripts/gp
|
||||
env:
|
||||
GP_ADMIN_USER_ID: ${{ secrets.GP_ADMIN_USER_ID }}
|
||||
GP_ADMIN_PASSWORD: ${{ secrets.GP_ADMIN_PASSWORD }}
|
||||
GP_INSTANCE: ${{ vars.GP_INSTANCE }}
|
||||
GP_BUNDLE: ${{ vars.GP_BUNDLE }}
|
||||
run: python download_translations.py --output ../../src/frontend/src/locales/
|
||||
|
||||
- name: Close stale translation PRs
|
||||
uses: actions/github-script@v8
|
||||
with:
|
||||
github-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
script: |
|
||||
const { data: pulls } = await github.rest.pulls.list({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
state: 'open'
|
||||
});
|
||||
for (const pull of pulls) {
|
||||
if (pull.title === "chore: update translations from Globalization Pipeline") {
|
||||
await github.rest.pulls.update({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
pull_number: pull.number,
|
||||
state: 'closed'
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
- name: Create Pull Request
|
||||
id: create-pr
|
||||
uses: peter-evans/create-pull-request@v8
|
||||
with:
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
branch-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
base: ${{ steps.resolve-branch.outputs.branch }}
|
||||
commit-message: "chore: update translations from Globalization Pipeline [skip ci]"
|
||||
title: "chore: update translations from Globalization Pipeline"
|
||||
body: |
|
||||
Automated PR to update locale files from IBM Globalization Pipeline.
|
||||
|
||||
This PR was automatically created by the `gp-download` workflow.
|
||||
If no files changed, this PR will be empty and can be closed.
|
||||
branch: translations/update-gp
|
||||
branch-suffix: timestamp
|
||||
delete-branch: true
|
||||
maintainer-can-modify: true
|
||||
add-paths: src/frontend/src/locales/*.json
|
||||
|
||||
- name: Enable auto-merge
|
||||
if: steps.create-pr.outputs.pull-request-number != ''
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
gh pr merge ${{ steps.create-pr.outputs.pull-request-number }} --auto --squash \
|
||||
--repo ${{ github.repository }}
|
||||
|
||||
- name: Report GP download failure
|
||||
if: failure()
|
||||
run: echo "::warning::GP translation download failed — translations may be out of date. Check the 'Download translations from GP' step above for details."
|
||||
@@ -0,0 +1,27 @@
|
||||
name: GP Scripts Tests
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
paths:
|
||||
- "scripts/gp/**"
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
name: Test Globalization Pipeline scripts
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: "3.12"
|
||||
|
||||
- name: Install dependencies
|
||||
run: pip install -r scripts/gp/requirements.txt pytest
|
||||
|
||||
- name: Run GP script tests
|
||||
run: python -m pytest scripts/gp/tests/ -v
|
||||
@@ -0,0 +1,56 @@
|
||||
name: GP Upload Strings
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- "release-*"
|
||||
paths:
|
||||
- "src/frontend/src/locales/en.json"
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
upload:
|
||||
runs-on: ubuntu-latest
|
||||
name: Upload English strings to Globalization Pipeline
|
||||
environment: GP-test
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Check if this is the latest release branch
|
||||
id: check-branch
|
||||
run: |
|
||||
LATEST=$(git ls-remote --heads https://github.com/${{ github.repository }} 'refs/heads/release-*' \
|
||||
| awk '{print $2}' \
|
||||
| sed 's|refs/heads/||' \
|
||||
| grep -E '^release-[0-9]+\.[0-9]+\.[0-9]+$' \
|
||||
| sort -V \
|
||||
| tail -n 1)
|
||||
CURRENT="${{ github.ref_name }}"
|
||||
if [ "$CURRENT" != "$LATEST" ]; then
|
||||
echo "skip=true" >> "$GITHUB_OUTPUT"
|
||||
echo "::notice::Skipping upload — $CURRENT is not the latest release branch ($LATEST)"
|
||||
else
|
||||
echo "skip=false" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
|
||||
- name: Set up Python
|
||||
if: steps.check-branch.outputs.skip != 'true'
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: "3.12"
|
||||
|
||||
- name: Install dependencies
|
||||
if: steps.check-branch.outputs.skip != 'true'
|
||||
run: pip install -r scripts/gp/requirements.txt
|
||||
|
||||
- name: Upload strings to GP
|
||||
if: steps.check-branch.outputs.skip != 'true'
|
||||
working-directory: scripts/gp
|
||||
env:
|
||||
GP_ADMIN_USER_ID: ${{ secrets.GP_ADMIN_USER_ID }}
|
||||
GP_ADMIN_PASSWORD: ${{ secrets.GP_ADMIN_PASSWORD }}
|
||||
GP_INSTANCE: ${{ vars.GP_INSTANCE }}
|
||||
GP_BUNDLE: ${{ vars.GP_BUNDLE }}
|
||||
run: python upload_strings.py --source ../../src/frontend/src/locales/en.json
|
||||
@@ -13,7 +13,7 @@ on:
|
||||
description: "(Optional) Python versions to test"
|
||||
required: true
|
||||
type: string
|
||||
default: "['3.10', '3.11', '3.12', '3.13']"
|
||||
default: "['3.10', '3.11', '3.12', '3.13', '3.14']"
|
||||
ref:
|
||||
description: "(Optional) ref to checkout"
|
||||
required: false
|
||||
|
||||
@@ -45,20 +45,20 @@ jobs:
|
||||
- name: Run Frontend Unit Tests
|
||||
run: make test_frontend_ci
|
||||
|
||||
- name: Publish Test Results
|
||||
- name: Publish Jest Test Results
|
||||
uses: mikepenz/action-junit-report@v5
|
||||
if: always()
|
||||
with:
|
||||
report_paths: 'src/frontend/test-results/junit.xml'
|
||||
check_name: 'Frontend Unit Test Results'
|
||||
report_paths: "src/frontend/test-results/junit.xml"
|
||||
check_name: "Frontend Unit Test Results"
|
||||
fail_on_failure: true
|
||||
require_tests: true
|
||||
|
||||
- name: Add Coverage PR Comment
|
||||
- name: Add Jest Coverage PR Comment
|
||||
uses: MishaKav/jest-coverage-comment@main
|
||||
if: github.event_name == 'pull_request'
|
||||
with:
|
||||
coverage-summary-path: src/frontend/coverage/coverage-summary.json
|
||||
coverage-summary-path: src/frontend/coverage/jest/coverage-summary.json
|
||||
title: Frontend Unit Test Coverage Report
|
||||
summary-title: Coverage Summary
|
||||
badge-title: Coverage
|
||||
@@ -68,20 +68,10 @@ jobs:
|
||||
junitxml-title: Unit Test Results
|
||||
junitxml-path: src/frontend/test-results/junit.xml
|
||||
|
||||
- name: Upload coverage to Codecov
|
||||
uses: codecov/codecov-action@v5
|
||||
with:
|
||||
token: ${{ secrets.CODECOV_TOKEN }}
|
||||
files: src/frontend/coverage/lcov.info
|
||||
flags: frontend
|
||||
name: frontend-coverage
|
||||
fail_ci_if_error: false
|
||||
directory: src/frontend/coverage/
|
||||
|
||||
- name: Upload Coverage Reports
|
||||
uses: actions/upload-artifact@v6
|
||||
- name: Upload Jest Coverage Artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
if: always()
|
||||
with:
|
||||
name: frontend-coverage-report
|
||||
path: src/frontend/coverage/
|
||||
retention-days: 30
|
||||
name: jest-coverage
|
||||
path: src/frontend/coverage/jest
|
||||
retention-days: 30
|
||||
|
||||
@@ -25,6 +25,8 @@ jobs:
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
ref: ${{ inputs.branch || github.ref }}
|
||||
# Need full history so biome can diff against the PR base
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v6
|
||||
@@ -47,7 +49,41 @@ jobs:
|
||||
npm install
|
||||
if: ${{ steps.setup-node.outputs.cache-hit != 'true' }}
|
||||
|
||||
- name: Run Biome
|
||||
- name: Run Biome on PR-changed files
|
||||
run: |
|
||||
# Determine the branch this PR targets (falls back to `main` for
|
||||
# workflow_dispatch / non-PR contexts).
|
||||
BASE_REF="${{ github.base_ref || 'main' }}"
|
||||
echo "=== Debug ==="
|
||||
echo "HEAD: $(git rev-parse HEAD)"
|
||||
echo "BASE_REF: $BASE_REF"
|
||||
echo "origin/$BASE_REF: $(git rev-parse "origin/$BASE_REF" 2>/dev/null || echo NOT_FOUND)"
|
||||
|
||||
# Merge-base = the commit where this branch forked off its target.
|
||||
# Diffing against it yields ONLY the files this PR introduced,
|
||||
# ignoring unrelated upstream changes merged into the base since.
|
||||
MERGE_BASE=$(git merge-base HEAD "origin/$BASE_REF")
|
||||
echo "MERGE_BASE: $MERGE_BASE"
|
||||
|
||||
# Collect frontend files modified in this PR. Filter to file types
|
||||
# biome can process (ts/tsx/js/jsx/json/jsonc and the like).
|
||||
CHANGED_FILES=$(git diff --name-only --diff-filter=ACMR \
|
||||
"$MERGE_BASE"...HEAD -- 'src/frontend/' \
|
||||
| grep -E '\.(tsx?|jsx?|c(js|ts)|m(js|ts)|jsonc?)$' \
|
||||
|| true)
|
||||
|
||||
if [ -z "$CHANGED_FILES" ]; then
|
||||
echo "No frontend files changed in this PR — skipping biome."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
echo "=== Files to lint ==="
|
||||
echo "$CHANGED_FILES"
|
||||
|
||||
# Biome runs from src/frontend; strip that prefix from paths.
|
||||
RELATIVE_FILES=$(echo "$CHANGED_FILES" | sed 's|^src/frontend/||')
|
||||
|
||||
cd src/frontend
|
||||
npx @biomejs/biome check --changed
|
||||
echo "$RELATIVE_FILES" | xargs npx @biomejs/biome check \
|
||||
--files-ignore-unknown=true \
|
||||
--diagnostic-level=error
|
||||
|
||||
@@ -19,6 +19,7 @@ jobs:
|
||||
strategy:
|
||||
matrix:
|
||||
python-version:
|
||||
- "3.14"
|
||||
- "3.13"
|
||||
- "3.12"
|
||||
- "3.11"
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
name: "Mend"
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: ["release-*", "main"]
|
||||
pull_request:
|
||||
branches: ["release-*", "main"]
|
||||
|
||||
concurrency:
|
||||
group: mend-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
env:
|
||||
NODE_VERSION: "22"
|
||||
|
||||
jobs:
|
||||
mend-scan:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
steps:
|
||||
- name: Checkout Repository
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Setup Node.js Environment
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: ${{ env.NODE_VERSION }}
|
||||
cache: "npm"
|
||||
cache-dependency-path: ./src/frontend/package-lock.json
|
||||
|
||||
- name: Install Frontend Dependencies
|
||||
working-directory: ./src/frontend
|
||||
run: npm ci
|
||||
|
||||
- name: Set up Java (Semeru)
|
||||
uses: actions/setup-java@v5
|
||||
with:
|
||||
distribution: "semeru"
|
||||
java-version: "21"
|
||||
|
||||
- name: Download Mend Unified Agent
|
||||
run: |
|
||||
curl --fail --show-error --retry 3 --location -o wss-unified-agent.jar https://unified-agent.s3.amazonaws.com/wss-unified-agent.jar
|
||||
|
||||
- name: Run Mend Scan
|
||||
#prevent forks from running mend scans
|
||||
if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository
|
||||
env:
|
||||
WS_APIKEY: ${{ secrets.WS_APIKEY }}
|
||||
WS_WSS_URL: ${{ secrets.WS_WSS_URL }}
|
||||
WS_USERKEY: ${{ secrets.WS_USERKEY }}
|
||||
WS_PRODUCTNAME: ${{ secrets.WS_PRODUCTNAME }}
|
||||
WS_PROJECTNAME: ${{ secrets.WS_PROJECTNAME }}
|
||||
run: |
|
||||
java -jar wss-unified-agent.jar -d src -logLevel info
|
||||
@@ -3,11 +3,59 @@ name: Database Migration Validation
|
||||
on:
|
||||
pull_request:
|
||||
paths:
|
||||
- 'src/backend/base/langflow/alembic/versions/*.py'
|
||||
- 'alembic/versions/*.py'
|
||||
- 'src/backend/base/langflow/alembic/**'
|
||||
- 'src/backend/base/langflow/services/database/models/**'
|
||||
- 'src/backend/base/langflow/services/database/service.py'
|
||||
- 'src/backend/tests/unit/alembic/**'
|
||||
- '.github/workflows/migration-validation.yml'
|
||||
|
||||
jobs:
|
||||
model-migration-consistency:
|
||||
name: Model/Migration Consistency
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
services:
|
||||
postgres:
|
||||
image: postgres:16
|
||||
env:
|
||||
POSTGRES_USER: langflow
|
||||
POSTGRES_PASSWORD: langflow
|
||||
POSTGRES_DB: langflow
|
||||
ports:
|
||||
- 5432:5432
|
||||
options: >-
|
||||
--health-cmd="pg_isready -U langflow"
|
||||
--health-interval=10s
|
||||
--health-timeout=5s
|
||||
--health-retries=5
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Install uv
|
||||
uses: astral-sh/setup-uv@v6
|
||||
|
||||
- name: Setup Python
|
||||
uses: actions/setup-python@v6
|
||||
with:
|
||||
python-version: '3.12'
|
||||
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
uv sync --extra postgresql
|
||||
|
||||
- name: Check model/migration consistency
|
||||
env:
|
||||
MIGRATION_VALIDATION_CI: "true"
|
||||
LANGFLOW_TEST_DATABASE_URI: "postgresql://langflow:langflow@localhost:5432/langflow"
|
||||
run: |
|
||||
uv run pytest src/backend/tests/unit/alembic/test_migration_execution.py -x -v
|
||||
|
||||
validate-migration:
|
||||
name: Migration Pattern Validation
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
@@ -19,47 +67,41 @@ jobs:
|
||||
- name: Setup Python
|
||||
uses: actions/setup-python@v6
|
||||
with:
|
||||
python-version: '3.11'
|
||||
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
pip install sqlalchemy alembic
|
||||
python-version: '3.12'
|
||||
|
||||
- name: Get changed migration files
|
||||
id: changed-files
|
||||
run: |
|
||||
# Get all changed Python files in alembic/versions directories
|
||||
|
||||
# CHANGED_FILES=$(git diff --name-only origin/main...HEAD | grep -E '(alembic|migrations)/versions/.*\.py$' || echo "")
|
||||
|
||||
# Exclude test migrations, as they are not part of the main codebase
|
||||
CHANGED_FILES=$(git diff --name-only origin/main...HEAD | grep -E '(alembic|migrations)/versions/.*\.py$' | grep -v 'test_migrations/' || echo "")
|
||||
set -euo pipefail
|
||||
CHANGED_FILES=$(git diff --name-only origin/main...HEAD | grep -E 'src/backend/base/langflow/alembic/versions/.*\.py$' | grep -v 'test_migrations/' || echo "")
|
||||
|
||||
if [ -z "$CHANGED_FILES" ]; then
|
||||
echo "No migration files changed"
|
||||
echo "files=" >> $GITHUB_OUTPUT
|
||||
echo "files=" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "Changed migration files:"
|
||||
echo "$CHANGED_FILES"
|
||||
# Convert newlines to spaces for passing as arguments
|
||||
echo "files=$(echo $CHANGED_FILES | tr '\n' ' ')" >> $GITHUB_OUTPUT
|
||||
echo "files=$(printf '%s' "$CHANGED_FILES" | tr '\n' ' ')" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
|
||||
- name: Validate migrations
|
||||
- name: Validate migration patterns
|
||||
if: steps.changed-files.outputs.files != ''
|
||||
env:
|
||||
MIGRATION_FILES: ${{ steps.changed-files.outputs.files }}
|
||||
run: |
|
||||
python src/backend/base/langflow/alembic/migration_validator.py ${{ steps.changed-files.outputs.files }}
|
||||
|
||||
# - name: Check migration phase sequence
|
||||
# if: steps.changed-files.outputs.files != ''
|
||||
# run: |
|
||||
# python scripts/check_phase_sequence.py ${{ steps.changed-files.outputs.files }}
|
||||
python src/backend/base/langflow/alembic/migration_validator.py $MIGRATION_FILES
|
||||
|
||||
- name: Generate validation report
|
||||
if: always() && steps.changed-files.outputs.files != ''
|
||||
env:
|
||||
MIGRATION_FILES: ${{ steps.changed-files.outputs.files }}
|
||||
run: |
|
||||
python src/backend/base/langflow/alembic/migration_validator.py \
|
||||
--json ${{ steps.changed-files.outputs.files }} > validation-report.json || true
|
||||
--json $MIGRATION_FILES > validation-report.json 2> validation-stderr.txt || true
|
||||
if [ ! -s validation-report.json ]; then
|
||||
echo "::error::Validator produced no output. Stderr:"
|
||||
cat validation-stderr.txt
|
||||
fi
|
||||
|
||||
- name: Post PR comment with results
|
||||
if: always() && steps.changed-files.outputs.files != ''
|
||||
@@ -110,7 +152,7 @@ jobs:
|
||||
}
|
||||
}
|
||||
|
||||
message += `### 📚 Resources\n`;
|
||||
message += `### Resources\n`;
|
||||
message += `- Review the [DB Migration Guide](./src/backend/base/langflow/alembic/DB-MIGRATION-GUIDE.MD)\n`;
|
||||
message += `- Use \`python scripts/generate_migration.py --help\` to generate compliant migrations\n\n`;
|
||||
|
||||
@@ -123,37 +165,42 @@ jobs:
|
||||
} catch (error) {
|
||||
message = `⚠️ **Migration validation check failed to run properly**\n`;
|
||||
message += `Error: ${error.message}\n`;
|
||||
validationPassed = false;
|
||||
}
|
||||
|
||||
// Post or update comment
|
||||
const { data: comments } = await github.rest.issues.listComments({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: context.issue.number,
|
||||
});
|
||||
|
||||
const botComment = comments.find(comment =>
|
||||
comment.user.type === 'Bot' &&
|
||||
comment.body.includes('Migration Validation')
|
||||
);
|
||||
|
||||
if (botComment) {
|
||||
await github.rest.issues.updateComment({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
comment_id: botComment.id,
|
||||
body: message
|
||||
});
|
||||
} else {
|
||||
await github.rest.issues.createComment({
|
||||
// Post or update comment (non-critical — don't let API errors mask validation results)
|
||||
try {
|
||||
const { data: comments } = await github.rest.issues.listComments({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: context.issue.number,
|
||||
body: message
|
||||
});
|
||||
|
||||
const botComment = comments.find(comment =>
|
||||
comment.user.type === 'Bot' &&
|
||||
comment.body.includes('Migration Validation')
|
||||
);
|
||||
|
||||
if (botComment) {
|
||||
await github.rest.issues.updateComment({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
comment_id: botComment.id,
|
||||
body: message
|
||||
});
|
||||
} else {
|
||||
await github.rest.issues.createComment({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: context.issue.number,
|
||||
body: message
|
||||
});
|
||||
}
|
||||
} catch (apiError) {
|
||||
core.warning(`Failed to post PR comment: ${apiError.message}`);
|
||||
}
|
||||
|
||||
// Fail the workflow if validation didn't pass
|
||||
if (!validationPassed) {
|
||||
core.setFailed('Migration validation failed');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -68,6 +68,7 @@ jobs:
|
||||
main_tag: ${{ steps.generate_main_tag.outputs.main_tag }}
|
||||
base_tag: ${{ steps.set_base_tag.outputs.base_tag }}
|
||||
lfx_tag: ${{ steps.generate_lfx_tag.outputs.lfx_tag }}
|
||||
sdk_tag: ${{ steps.generate_sdk_tag.outputs.sdk_tag }}
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v6
|
||||
@@ -86,6 +87,9 @@ jobs:
|
||||
- name: Generate main nightly tag
|
||||
id: generate_main_tag
|
||||
run: |
|
||||
# langflow-nightly pins langflow-base-nightly[complete]==<exact dev>, so both tags
|
||||
# MUST be identical. Compute them in ONE invocation (single PyPI snapshot) so they
|
||||
# cannot drift across separate calls.
|
||||
# NOTE: This outputs the tag with the `v` prefix.
|
||||
MAIN_TAG="$(uv run ./scripts/ci/pypi_nightly_tag.py main)"
|
||||
echo "main_tag=$MAIN_TAG" >> $GITHUB_OUTPUT
|
||||
@@ -99,14 +103,6 @@ jobs:
|
||||
git push --delete origin ${{ steps.generate_main_tag.outputs.main_tag }} || true
|
||||
echo "main_tag_exists=false" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Generate base nightly tag
|
||||
id: generate_base_tag
|
||||
run: |
|
||||
# NOTE: This outputs the tag with the `v` prefix.
|
||||
BASE_TAG="$(uv run ./scripts/ci/pypi_nightly_tag.py base)"
|
||||
echo "base_tag=$BASE_TAG" >> $GITHUB_OUTPUT
|
||||
echo "base_tag=$BASE_TAG"
|
||||
|
||||
- name: Generate LFX nightly tag
|
||||
id: generate_lfx_tag
|
||||
run: |
|
||||
@@ -115,6 +111,14 @@ jobs:
|
||||
echo "lfx_tag=$LFX_TAG" >> $GITHUB_OUTPUT
|
||||
echo "lfx_tag=$LFX_TAG"
|
||||
|
||||
- name: Generate SDK nightly tag
|
||||
id: generate_sdk_tag
|
||||
run: |
|
||||
# NOTE: This outputs the tag with the `v` prefix.
|
||||
SDK_TAG="$(uv run ./scripts/ci/sdk_nightly_tag.py)"
|
||||
echo "sdk_tag=$SDK_TAG" >> $GITHUB_OUTPUT
|
||||
echo "sdk_tag=$SDK_TAG"
|
||||
|
||||
- name: Commit tag
|
||||
id: commit_tag
|
||||
run: |
|
||||
@@ -126,16 +130,24 @@ jobs:
|
||||
MAIN_TAG="${{ steps.generate_main_tag.outputs.main_tag }}"
|
||||
BASE_TAG="${{ steps.generate_base_tag.outputs.base_tag }}"
|
||||
LFX_TAG="${{ steps.generate_lfx_tag.outputs.lfx_tag }}"
|
||||
SDK_TAG="${{ steps.generate_sdk_tag.outputs.sdk_tag }}"
|
||||
echo "Updating SDK project version to $SDK_TAG"
|
||||
uv run --no-sync ./scripts/ci/update_sdk_version.py $SDK_TAG
|
||||
echo "Updating LFX project version to $LFX_TAG"
|
||||
uv run ./scripts/ci/update_lfx_version.py $LFX_TAG
|
||||
echo "Updating base project version to $BASE_TAG and updating main project version to $MAIN_TAG"
|
||||
uv run --no-sync ./scripts/ci/update_pyproject_combined.py main $MAIN_TAG $BASE_TAG $LFX_TAG
|
||||
|
||||
uv lock
|
||||
cd src/backend/base && uv lock && cd ../../..
|
||||
cd src/lfx && uv lock && cd ../..
|
||||
|
||||
git add pyproject.toml src/backend/base/pyproject.toml src/lfx/pyproject.toml uv.lock src/backend/base/uv.lock
|
||||
git add pyproject.toml src/backend/base/pyproject.toml src/lfx/pyproject.toml src/sdk/pyproject.toml uv.lock
|
||||
# update_lfx_version.py re-pins each bundle's `lfx` dep to
|
||||
# lfx-nightly==<dev>, so any modified bundle pyprojects need to
|
||||
# ride the same commit/tag as the rest of the version bumps.
|
||||
if compgen -G "src/bundles/*/pyproject.toml" > /dev/null; then
|
||||
git add src/bundles/*/pyproject.toml
|
||||
fi
|
||||
git commit -m "Update version and project name"
|
||||
|
||||
echo "Tagging main with $MAIN_TAG"
|
||||
@@ -172,8 +184,8 @@ jobs:
|
||||
BASE_TAG="${{ steps.retrieve_base_tag.outputs.base_tag }}"
|
||||
echo "base_tag=$BASE_TAG" >> $GITHUB_OUTPUT
|
||||
echo "base_tag=$BASE_TAG"
|
||||
elif [ "${{ steps.commit_tag.conclusion }}" != "skipped" ] && [ "${{ steps.generate_base_tag.outputs.base_tag }}" ]; then
|
||||
BASE_TAG="${{ steps.generate_base_tag.outputs.base_tag }}"
|
||||
elif [ "${{ steps.commit_tag.conclusion }}" != "skipped" ] && [ "${{ steps.generate_shared_tag.outputs.base_tag }}" ]; then
|
||||
BASE_TAG="${{ steps.generate_shared_tag.outputs.base_tag }}"
|
||||
echo "base_tag=$BASE_TAG" >> $GITHUB_OUTPUT
|
||||
echo "base_tag=$BASE_TAG"
|
||||
else
|
||||
@@ -286,7 +298,7 @@ jobs:
|
||||
needs: create-nightly-tag
|
||||
uses: ./.github/workflows/python_test.yml
|
||||
with:
|
||||
python-versions: '["3.10", "3.11", "3.12", "3.13"]'
|
||||
python-versions: '["3.10", "3.11", "3.12", "3.13", "3.14"]'
|
||||
runs-on: ${{ inputs['runs_on'] || github.event.inputs['runs_on'] || 'ubuntu-latest' }}
|
||||
secrets:
|
||||
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
|
||||
@@ -316,10 +328,22 @@ jobs:
|
||||
nightly_tag_main: ${{ needs.create-nightly-tag.outputs.main_tag }}
|
||||
nightly_tag_base: ${{ needs.create-nightly-tag.outputs.base_tag }}
|
||||
nightly_tag_lfx: ${{ needs.create-nightly-tag.outputs.lfx_tag }}
|
||||
nightly_tag_sdk: ${{ needs.create-nightly-tag.outputs.sdk_tag }}
|
||||
# When triggered by schedule, inputs.push_to_registry is not set, so default to true
|
||||
# When triggered manually, use the provided value (default is also true)
|
||||
push_to_registry: ${{ github.event_name == 'schedule' || inputs.push_to_registry != false }}
|
||||
secrets: inherit
|
||||
db-migration-validation:
|
||||
name: Run DB Migration Validation
|
||||
needs: [create-nightly-tag, release-nightly-build]
|
||||
if: ${{ github.repository == 'langflow-ai/langflow' && always() && needs.release-nightly-build.result == 'success' }}
|
||||
uses: ./.github/workflows/db-migration-validation.yml
|
||||
with:
|
||||
# Pass Docker image reference for docker-compose and version without 'v' prefix for pip
|
||||
# release_tag format: v1.10.0.dev20260522
|
||||
# We need: langflowai/langflow-nightly:1.10.0.dev20260522 for Docker
|
||||
nightly_tag: langflowai/langflow-nightly:${{ needs.create-nightly-tag.outputs.release_tag }}
|
||||
secrets: inherit
|
||||
|
||||
slack-notification:
|
||||
name: Send Slack Notification
|
||||
@@ -529,7 +553,7 @@ jobs:
|
||||
fi
|
||||
|
||||
- name: Send success notification to Slack
|
||||
if: ${{ !inputs.skip_slack && needs.release-nightly-build.result == 'success' }}
|
||||
if: ${{ !inputs.skip_slack && needs.release-nightly-build.result == 'success' && needs.db-migration-validation.result != 'failure' }}
|
||||
run: |
|
||||
curl -X POST -H 'Content-type: application/json' \
|
||||
--data '{
|
||||
|
||||
@@ -5,6 +5,7 @@ on:
|
||||
- "**/*.py"
|
||||
- "src/lfx/src/lfx/components/**"
|
||||
- "scripts/build_component_index.py"
|
||||
types: [opened, synchronize, reopened, labeled]
|
||||
env:
|
||||
PYTHON_VERSION: "3.13"
|
||||
|
||||
@@ -12,7 +13,7 @@ env:
|
||||
jobs:
|
||||
lint:
|
||||
name: Run Ruff Check and Format
|
||||
if: ${{ github.actor != 'github-actions[bot]' }}
|
||||
if: github.event.action != 'labeled' && github.actor != 'github-actions[bot]'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
@@ -30,7 +31,7 @@ jobs:
|
||||
|
||||
update-starter-projects:
|
||||
name: Update Starter Projects
|
||||
if: ${{ github.actor != 'github-actions[bot]' }}
|
||||
if: github.event.action != 'labeled' && github.actor != 'github-actions[bot]'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
@@ -55,10 +56,36 @@ jobs:
|
||||
|
||||
update-component-index:
|
||||
name: Update Component Index
|
||||
if: ${{ github.actor != 'github-actions[bot]' }}
|
||||
if: >-
|
||||
github.actor != 'github-actions[bot]'
|
||||
&& (github.event.action != 'labeled' || github.event.label.name == 'fix-index')
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
with:
|
||||
repository: ${{ github.event.pull_request.head.repo.full_name }}
|
||||
ref: ${{ github.event.pull_request.head.ref }}
|
||||
fetch-depth: 0
|
||||
- name: Merge base branch
|
||||
run: |
|
||||
git config user.name "github-actions[bot]"
|
||||
git config user.email "github-actions[bot]@users.noreply.github.com"
|
||||
git merge origin/${{ github.base_ref }} --no-edit || {
|
||||
# Check which files have conflicts
|
||||
CONFLICTED=$(git diff --name-only --diff-filter=U)
|
||||
|
||||
# If any non-generated files conflict, bail out — needs manual resolution
|
||||
if echo "$CONFLICTED" | grep -v component_index.json | grep -q .; then
|
||||
echo "::error::Merge conflicts in non-generated files require manual resolution"
|
||||
git merge --abort
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Only component_index.json conflicts — resolve and finish merge
|
||||
git checkout --theirs src/lfx/src/lfx/_assets/component_index.json
|
||||
git add src/lfx/src/lfx/_assets/component_index.json
|
||||
GIT_EDITOR=true git merge --continue
|
||||
}
|
||||
- name: "Setup Environment"
|
||||
uses: astral-sh/setup-uv@v6
|
||||
with:
|
||||
@@ -76,6 +103,3 @@ jobs:
|
||||
run: make build_component_index
|
||||
|
||||
- uses: autofix-ci/action@635ffb0c9798bd160680f18fd73371e355b85f27
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -14,7 +14,7 @@ on:
|
||||
description: "(Optional) Python versions to test"
|
||||
required: true
|
||||
type: string
|
||||
default: "['3.10', '3.11', '3.12', '3.13']"
|
||||
default: "['3.10', '3.11', '3.12', '3.13', '3.14']"
|
||||
ref:
|
||||
description: "(Optional) ref to checkout"
|
||||
required: false
|
||||
@@ -35,7 +35,7 @@ on:
|
||||
description: "(Optional) Python versions to test"
|
||||
required: true
|
||||
type: string
|
||||
default: "['3.10', '3.11', '3.12', '3.13']"
|
||||
default: "['3.10', '3.11', '3.12', '3.13', '3.14']"
|
||||
runs-on:
|
||||
description: "Runner to use for the tests"
|
||||
required: false
|
||||
@@ -58,7 +58,7 @@ jobs:
|
||||
runs-on: ${{ (inputs['runs-on'] && startsWith(format('{0}', inputs['runs-on']), '[') && fromJSON(inputs['runs-on'])) || inputs['runs-on'] || github.event.inputs['runs-on'] || 'ubuntu-latest' }}
|
||||
strategy:
|
||||
matrix:
|
||||
python-version: ${{ fromJson(inputs.python-versions || '["3.10", "3.11", "3.12", "3.13"]' ) }}
|
||||
python-version: ${{ fromJson(inputs.python-versions || '["3.10", "3.11", "3.12", "3.13", "3.14"]') }}
|
||||
splitCount: [5]
|
||||
group: [1, 2, 3, 4, 5]
|
||||
steps:
|
||||
@@ -93,7 +93,7 @@ jobs:
|
||||
- name: Run unit tests
|
||||
uses: nick-fields/retry@v3
|
||||
with:
|
||||
timeout_minutes: 12
|
||||
timeout_minutes: 40
|
||||
max_attempts: 2
|
||||
command: make unit_tests args="-x -vv --splits ${{ matrix.splitCount }} --group ${{ matrix.group }} --reruns 5 --cov --cov-config=src/backend/.coveragerc --cov-report=xml --cov-report=html"
|
||||
|
||||
@@ -123,7 +123,7 @@ jobs:
|
||||
runs-on: ${{ (inputs['runs-on'] && startsWith(format('{0}', inputs['runs-on']), '[') && fromJSON(inputs['runs-on'])) || inputs['runs-on'] || github.event.inputs['runs-on'] || 'ubuntu-latest' }}
|
||||
strategy:
|
||||
matrix:
|
||||
python-version: ${{ fromJson(inputs.python-versions || '["3.10", "3.11", "3.12", "3.13"]' ) }}
|
||||
python-version: ${{ fromJson(inputs.python-versions || '["3.10", "3.11", "3.12", "3.13", "3.14"]') }}
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
with:
|
||||
@@ -148,7 +148,7 @@ jobs:
|
||||
runs-on: ${{ (inputs['runs-on'] && startsWith(format('{0}', inputs['runs-on']), '[') && fromJSON(inputs['runs-on'])) || inputs['runs-on'] || github.event.inputs['runs-on'] || 'ubuntu-latest' }}
|
||||
strategy:
|
||||
matrix:
|
||||
python-version: ${{ fromJson(inputs.python-versions || '["3.10", "3.11", "3.12", "3.13"]' ) }}
|
||||
python-version: ${{ fromJson(inputs.python-versions || '["3.10", "3.11", "3.12", "3.13", "3.14"]') }}
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
with:
|
||||
@@ -161,7 +161,7 @@ jobs:
|
||||
python-version: ${{ matrix.python-version }}
|
||||
prune-cache: false
|
||||
- name: Run lfx tests
|
||||
run: uv run make lfx_tests
|
||||
run: make lfx_tests
|
||||
env:
|
||||
DO_NOT_TRACK: true # disable telemetry reporting
|
||||
|
||||
@@ -191,7 +191,7 @@ jobs:
|
||||
runs-on: ${{ (inputs['runs-on'] && startsWith(format('{0}', inputs['runs-on']), '[') && fromJSON(inputs['runs-on'])) || inputs['runs-on'] || github.event.inputs['runs-on'] || 'ubuntu-latest' }}
|
||||
strategy:
|
||||
matrix:
|
||||
python-version: ${{ fromJson(inputs.python-versions || '["3.10", "3.11", "3.12", "3.13"]') }}
|
||||
python-version: ${{ fromJson(inputs.python-versions || '["3.10", "3.11", "3.12", "3.13", "3.14"]') }}
|
||||
steps:
|
||||
- name: Check out the code at a specific ref
|
||||
uses: actions/checkout@v6
|
||||
|
||||
@@ -92,7 +92,7 @@ jobs:
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
python-version: ["3.10", "3.11", "3.12", "3.13"]
|
||||
python-version: ["3.10", "3.11", "3.12", "3.13", "3.14"]
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v6
|
||||
|
||||
+422
-19
@@ -23,6 +23,16 @@ on:
|
||||
required: false
|
||||
type: boolean
|
||||
default: false
|
||||
release_sdk:
|
||||
description: "Release langflow-sdk package (required when releasing LFX)"
|
||||
required: false
|
||||
type: boolean
|
||||
default: false
|
||||
release_bundles:
|
||||
description: "Release every src/bundles/* package (required when releasing main, since main pins exact bundle versions)"
|
||||
required: false
|
||||
type: boolean
|
||||
default: false
|
||||
build_docker_base:
|
||||
description: "Build Docker Image for Langflow Base"
|
||||
required: true
|
||||
@@ -48,6 +58,11 @@ on:
|
||||
required: false
|
||||
type: boolean
|
||||
default: true
|
||||
run_pip_check:
|
||||
description: "Whether to run pip check - ONLY SET TO FALSE ONCE YOU HAVE ALREADY ATTEMPTED AND FAILED A RUN THEN MADE SURE DEPS DO NOT CAUSE FAILURES"
|
||||
required: false
|
||||
type: boolean
|
||||
default: true
|
||||
|
||||
jobs:
|
||||
echo-inputs:
|
||||
@@ -60,6 +75,8 @@ jobs:
|
||||
echo "release_package_base: ${{ inputs.release_package_base }}"
|
||||
echo "release_package_main: ${{ inputs.release_package_main }}"
|
||||
echo "release_lfx: ${{ inputs.release_lfx }}"
|
||||
echo "release_sdk: ${{ inputs.release_sdk }}"
|
||||
echo "release_bundles: ${{ inputs.release_bundles }}"
|
||||
echo "build_docker_base: ${{ inputs.build_docker_base }}"
|
||||
echo "build_docker_main: ${{ inputs.build_docker_main }}"
|
||||
echo "pre_release: ${{ inputs.pre_release }}"
|
||||
@@ -92,10 +109,50 @@ jobs:
|
||||
|
||||
echo "Validated: '${{ inputs.release_tag }}' is a valid tag."
|
||||
|
||||
validate-tag-format:
|
||||
name: Validate Tag Format and Check for Duplicates
|
||||
runs-on: ubuntu-latest
|
||||
needs: [validate-tag]
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Validate Tag Has v Prefix
|
||||
run: |
|
||||
TAG="${{ inputs.release_tag }}"
|
||||
if [[ ! "$TAG" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
|
||||
echo "❌ Error: Tag must start with 'v' and follow semver format (v1.2.3)"
|
||||
echo " Got: $TAG"
|
||||
echo ""
|
||||
echo "This is required to ensure GitHub's generateReleaseNotes uses the correct base comparison."
|
||||
exit 1
|
||||
fi
|
||||
echo "✅ Tag format is valid: $TAG"
|
||||
|
||||
- name: Check for Duplicate Tag Without v Prefix
|
||||
run: |
|
||||
TAG="${{ inputs.release_tag }}"
|
||||
TAG_WITHOUT_V="${TAG#v}"
|
||||
|
||||
if git rev-parse "$TAG_WITHOUT_V" >/dev/null 2>&1; then
|
||||
echo "❌ Error: Duplicate tag without 'v' prefix exists: $TAG_WITHOUT_V"
|
||||
echo " This will cause release notes to use the wrong base comparison."
|
||||
echo ""
|
||||
echo " The tag '$TAG_WITHOUT_V' points to commit: $(git rev-parse --short $TAG_WITHOUT_V)"
|
||||
echo " The tag '$TAG' points to commit: $(git rev-parse --short $TAG)"
|
||||
echo ""
|
||||
echo " To fix this, delete the duplicate tag:"
|
||||
echo " git push origin :refs/tags/$TAG_WITHOUT_V"
|
||||
exit 1
|
||||
fi
|
||||
echo "✅ No duplicate tag found"
|
||||
|
||||
validate-dependencies:
|
||||
name: Validate Release Dependencies
|
||||
runs-on: ubuntu-latest
|
||||
if: ${{ inputs.release_package_base || inputs.release_package_main || inputs.release_lfx || inputs.build_docker_base || inputs.build_docker_main }}
|
||||
if: ${{ inputs.release_package_base || inputs.release_package_main || inputs.release_lfx || inputs.release_sdk || inputs.build_docker_base || inputs.build_docker_main }}
|
||||
needs: [validate-tag]
|
||||
steps:
|
||||
- name: Validate that build-base is enabled if build-main is enabled
|
||||
@@ -106,6 +163,18 @@ jobs:
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [ "${{ inputs.release_lfx }}" = "true" ] && [ "${{ inputs.release_sdk }}" = "false" ]; then
|
||||
echo "Error: Cannot release LFX without releasing langflow-sdk."
|
||||
echo "LFX depends on langflow-sdk. Please enable 'release_sdk' or disable 'release_lfx'."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [ "${{ inputs.release_package_main }}" = "true" ] && [ "${{ inputs.release_bundles }}" = "false" ]; then
|
||||
echo "Error: Cannot release Langflow Main without releasing bundles."
|
||||
echo "Langflow Main pins exact bundle versions (lfx-arxiv, lfx-duckduckgo, ...). Please enable 'release_bundles' or disable 'release_package_main'."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "✅ Release dependencies validated successfully."
|
||||
|
||||
determine-base-version:
|
||||
@@ -188,7 +257,7 @@ jobs:
|
||||
- name: Determine version
|
||||
id: version
|
||||
run: |
|
||||
version=$(uv tree | grep 'langflow' | grep -v 'langflow-base' | awk '{print $2}' | sed 's/^v//')
|
||||
version=$(uv tree | grep -E '^langflow(-nightly)?[[:space:]]' | awk '{print $2}' | sed 's/^v//')
|
||||
echo "Main version from pyproject.toml: $version"
|
||||
|
||||
if [ ${{inputs.pre_release}} == "true" ]; then
|
||||
@@ -259,23 +328,84 @@ jobs:
|
||||
echo skipped=false >> $GITHUB_OUTPUT
|
||||
fi
|
||||
|
||||
determine-sdk-version:
|
||||
name: Determine SDK Version
|
||||
needs: [validate-tag, validate-dependencies]
|
||||
if: ${{ inputs.release_sdk || inputs.release_lfx }}
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
version: ${{ steps.version.outputs.version }}
|
||||
skipped: ${{ steps.version.outputs.skipped }}
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
ref: ${{ inputs.release_tag }}
|
||||
|
||||
- name: Setup Environment
|
||||
uses: astral-sh/setup-uv@v6
|
||||
with:
|
||||
enable-cache: true
|
||||
cache-dependency-glob: "uv.lock"
|
||||
python-version: "3.13"
|
||||
prune-cache: false
|
||||
|
||||
- name: Install SDK dependencies
|
||||
run: uv sync --dev --package langflow-sdk
|
||||
|
||||
- name: Determine version
|
||||
id: version
|
||||
run: |
|
||||
version=$(python3 -c "
|
||||
import tomllib, pathlib
|
||||
data = tomllib.loads(pathlib.Path('src/sdk/pyproject.toml').read_text())
|
||||
print(data['project']['version'])
|
||||
")
|
||||
echo "SDK version from pyproject.toml: $version"
|
||||
|
||||
# Check if langflow-sdk exists on PyPI (may be first release)
|
||||
pypi_response=$(curl -s -o /dev/null -w "%{http_code}" "https://pypi.org/pypi/langflow-sdk/json")
|
||||
if [ "$pypi_response" = "404" ]; then
|
||||
echo "langflow-sdk has never been published to PyPI. This will be the first release."
|
||||
last_released_version=""
|
||||
else
|
||||
if [ ${{inputs.pre_release}} == "true" ]; then
|
||||
last_released_version=$(curl -s "https://pypi.org/pypi/langflow-sdk/json" | jq -r '.releases | keys | .[]' | grep -E '(a|b|rc|dev|alpha|beta)' | sort -V | tail -n 1)
|
||||
version="$(uv run ./scripts/ci/langflow_pre_release_tag.py "$version" "$last_released_version")"
|
||||
echo "Latest SDK pre-release version: $last_released_version"
|
||||
echo "SDK pre-release version to be released: $version"
|
||||
else
|
||||
last_released_version=$(curl -s "https://pypi.org/pypi/langflow-sdk/json" | jq -r '.releases | keys | .[]' | grep -vE '(a|b|rc|dev|alpha|beta)' | sort -V | tail -n 1)
|
||||
echo "Latest SDK release version: $last_released_version"
|
||||
fi
|
||||
fi
|
||||
|
||||
if [ "$version" = "$last_released_version" ]; then
|
||||
echo "SDK pypi version $version is already released. Skipping release."
|
||||
echo skipped=true >> $GITHUB_OUTPUT
|
||||
exit 1
|
||||
else
|
||||
echo version=$version >> $GITHUB_OUTPUT
|
||||
echo skipped=false >> $GITHUB_OUTPUT
|
||||
fi
|
||||
|
||||
ci:
|
||||
name: CI
|
||||
needs: [validate-tag, validate-dependencies]
|
||||
uses: ./.github/workflows/ci.yml
|
||||
with:
|
||||
ref: ${{ inputs.release_tag }}
|
||||
python-versions: "['3.10', '3.11', '3.12', '3.13']"
|
||||
python-versions: "['3.10', '3.11', '3.12', '3.13', '3.14']"
|
||||
frontend-tests-folder: "tests"
|
||||
release: true
|
||||
run-all-tests: true
|
||||
runs-on: ubuntu-latest
|
||||
secrets: inherit
|
||||
|
||||
build-lfx:
|
||||
name: Build LFX
|
||||
needs: [determine-lfx-version]
|
||||
if: ${{ needs.determine-lfx-version.outputs.skipped == 'false' }}
|
||||
build-sdk:
|
||||
name: Build langflow-sdk
|
||||
needs: [determine-sdk-version]
|
||||
if: ${{ needs.determine-sdk-version.outputs.skipped == 'false' }}
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout code
|
||||
@@ -289,6 +419,81 @@ jobs:
|
||||
cache-dependency-glob: "uv.lock"
|
||||
python-version: "3.13"
|
||||
prune-cache: false
|
||||
- name: Set version for pre-release
|
||||
if: ${{ inputs.pre_release }}
|
||||
run: |
|
||||
VERSION="${{ needs.determine-sdk-version.outputs.version }}"
|
||||
echo "Setting SDK pre-release version to: $VERSION"
|
||||
cd src/sdk
|
||||
|
||||
# Update version in SDK pyproject.toml
|
||||
sed -i.bak "s/^version = .*/version = \"$VERSION\"/" pyproject.toml
|
||||
|
||||
# Verify the change
|
||||
echo "Updated pyproject.toml version:"
|
||||
grep "^version" pyproject.toml
|
||||
- name: Build project for distribution
|
||||
run: make sdk_build args="--wheel"
|
||||
- name: Verify built version
|
||||
run: |
|
||||
EXPECTED_VERSION="${{ needs.determine-sdk-version.outputs.version }}"
|
||||
WHEEL_FILE=$(ls src/sdk/dist/*.whl)
|
||||
echo "Built wheel: $WHEEL_FILE"
|
||||
|
||||
NORMALIZED_VERSION=$(echo "$EXPECTED_VERSION" | sed 's/\.rc/rc/g; s/\.a/a/g; s/\.b/b/g; s/\.dev/dev/g')
|
||||
echo "Expected version: $EXPECTED_VERSION"
|
||||
echo "Normalized for wheel: $NORMALIZED_VERSION"
|
||||
|
||||
if [[ ! "$WHEEL_FILE" =~ $NORMALIZED_VERSION ]]; then
|
||||
echo "❌ Error: Wheel version doesn't match expected version"
|
||||
echo "Expected: $EXPECTED_VERSION (normalized: $NORMALIZED_VERSION)"
|
||||
echo "Wheel file: $WHEEL_FILE"
|
||||
exit 1
|
||||
fi
|
||||
echo "✅ Version verified: $EXPECTED_VERSION"
|
||||
- name: Test SDK import
|
||||
run: |
|
||||
cd src/sdk
|
||||
uv venv test-env --seed
|
||||
uv pip install --python ./test-env/bin/python dist/*.whl
|
||||
EXPECTED_VERSION="${{ needs.determine-sdk-version.outputs.version }}"
|
||||
# importlib.metadata returns the PEP 440 canonical form, so normalize
|
||||
# the expected version the same way the wheel filename check does.
|
||||
NORMALIZED_VERSION=$(echo "$EXPECTED_VERSION" | sed 's/\.rc/rc/g; s/\.a/a/g; s/\.b/b/g; s/\.dev/dev/g')
|
||||
./test-env/bin/python -c "from importlib.metadata import version; import langflow_sdk; installed_version = version('langflow-sdk'); print(f'langflow-sdk {installed_version} imported successfully'); assert installed_version == '$NORMALIZED_VERSION', f'Installed version {installed_version} does not match expected version $NORMALIZED_VERSION'"
|
||||
- name: Upload Artifact
|
||||
uses: actions/upload-artifact@v6
|
||||
with:
|
||||
name: dist-sdk
|
||||
path: src/sdk/dist
|
||||
|
||||
build-lfx:
|
||||
name: Build LFX
|
||||
needs: [determine-lfx-version, determine-sdk-version, build-sdk]
|
||||
if: |
|
||||
always() &&
|
||||
!cancelled() &&
|
||||
needs.determine-lfx-version.result == 'success' &&
|
||||
needs.determine-lfx-version.outputs.skipped == 'false'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
ref: ${{ inputs.release_tag }}
|
||||
- name: Setup Environment
|
||||
uses: astral-sh/setup-uv@v6
|
||||
with:
|
||||
enable-cache: true
|
||||
cache-dependency-glob: "uv.lock"
|
||||
python-version: "3.13"
|
||||
prune-cache: false
|
||||
- name: Download SDK artifact
|
||||
if: needs.build-sdk.result == 'success'
|
||||
uses: actions/download-artifact@v7
|
||||
with:
|
||||
name: dist-sdk
|
||||
path: ./sdk-dist
|
||||
- name: Install LFX dependencies
|
||||
run: uv sync --dev --package lfx
|
||||
- name: Set version for pre-release
|
||||
@@ -304,6 +509,31 @@ jobs:
|
||||
# Verify the change
|
||||
echo "Updated pyproject.toml version:"
|
||||
grep "^version" pyproject.toml
|
||||
- name: Update langflow-sdk dependency for pre-release
|
||||
if: ${{ inputs.pre_release && needs.build-sdk.result == 'success' }}
|
||||
run: |
|
||||
SDK_VERSION="${{ needs.determine-sdk-version.outputs.version }}"
|
||||
echo "Updating langflow-sdk dependency to allow pre-release version: $SDK_VERSION"
|
||||
cd src/lfx
|
||||
|
||||
# Extract current langflow-sdk constraint from pyproject.toml
|
||||
CURRENT_CONSTRAINT=$(grep -E '^\s*"langflow-sdk' pyproject.toml | head -1)
|
||||
echo "Current constraint: $CURRENT_CONSTRAINT"
|
||||
|
||||
# Create new constraint using the next major derived from SDK_VERSION
|
||||
SDK_BASE_VERSION=$(echo "$SDK_VERSION" | sed 's/[^0-9.].*$//')
|
||||
SDK_MAJOR=$(echo "$SDK_BASE_VERSION" | cut -d. -f1)
|
||||
NEXT_MAJOR=$((SDK_MAJOR + 1))
|
||||
NEW_CONSTRAINT="\"langflow-sdk>=$SDK_VERSION,<$NEXT_MAJOR.dev0\""
|
||||
|
||||
echo "New constraint: $NEW_CONSTRAINT"
|
||||
|
||||
# Replace the constraint
|
||||
sed -i.bak "s|\"langflow-sdk[^\"]*\"|$NEW_CONSTRAINT|" pyproject.toml
|
||||
|
||||
# Verify the change
|
||||
echo "Updated langflow-sdk dependency:"
|
||||
grep "langflow-sdk" pyproject.toml
|
||||
- name: Build project for distribution
|
||||
run: |
|
||||
cd src/lfx
|
||||
@@ -329,8 +559,16 @@ jobs:
|
||||
- name: Test CLI
|
||||
run: |
|
||||
cd src/lfx
|
||||
uv pip install dist/*.whl --force-reinstall
|
||||
uv run lfx --help
|
||||
# Use a clean venv and install both SDK and LFX wheels together
|
||||
# so uv can resolve the local SDK dependency without falling back to PyPI.
|
||||
uv venv test-env --seed
|
||||
if [ -d "../../sdk-dist" ]; then
|
||||
SDK_WHEEL=$(find ../../sdk-dist -name "*.whl" -type f | head -1)
|
||||
uv pip install --python ./test-env/bin/python --force-reinstall "$SDK_WHEEL" dist/*.whl
|
||||
else
|
||||
uv pip install --python ./test-env/bin/python --force-reinstall dist/*.whl
|
||||
fi
|
||||
./test-env/bin/lfx --help
|
||||
- name: Upload Artifact
|
||||
uses: actions/upload-artifact@v6
|
||||
with:
|
||||
@@ -339,8 +577,14 @@ jobs:
|
||||
|
||||
build-base:
|
||||
name: Build Langflow Base
|
||||
needs: [build-lfx, determine-base-version, determine-lfx-version]
|
||||
if: ${{ inputs.release_package_base && needs.determine-base-version.outputs.skipped == 'false' }}
|
||||
needs: [build-lfx, build-sdk, determine-base-version, determine-lfx-version]
|
||||
if: |
|
||||
always() &&
|
||||
!cancelled() &&
|
||||
inputs.release_package_base &&
|
||||
needs.determine-base-version.result == 'success' &&
|
||||
needs.determine-base-version.outputs.skipped == 'false' &&
|
||||
needs.build-lfx.result == 'success'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout code
|
||||
@@ -359,13 +603,24 @@ jobs:
|
||||
with:
|
||||
name: dist-lfx
|
||||
path: ./lfx-dist
|
||||
- name: Install dependencies with local LFX wheel
|
||||
- name: Download SDK artifact
|
||||
if: needs.build-sdk.result == 'success'
|
||||
uses: actions/download-artifact@v7
|
||||
with:
|
||||
name: dist-sdk
|
||||
path: ./sdk-dist
|
||||
- name: Install dependencies with local LFX and SDK wheels
|
||||
run: |
|
||||
# Create virtual environment
|
||||
uv venv
|
||||
# Install using pip with local wheel directory as find-links
|
||||
uv pip install --find-links ./lfx-dist --prerelease=allow -e src/backend/base
|
||||
# Install using pip with local wheel directories as find-links
|
||||
FIND_LINKS="--find-links ./lfx-dist"
|
||||
if [ -d "./sdk-dist" ]; then
|
||||
FIND_LINKS="$FIND_LINKS --find-links ./sdk-dist"
|
||||
fi
|
||||
uv pip install $FIND_LINKS --prerelease=allow -e src/backend/base
|
||||
- name: Check for dependency incompatibility
|
||||
if: ${{ inputs.run_pip_check }}
|
||||
run: uv pip check
|
||||
- name: Set version for pre-release
|
||||
if: ${{ inputs.pre_release }}
|
||||
@@ -460,11 +715,19 @@ jobs:
|
||||
[
|
||||
build-base,
|
||||
build-lfx,
|
||||
build-sdk,
|
||||
determine-base-version,
|
||||
determine-main-version,
|
||||
determine-lfx-version,
|
||||
]
|
||||
if: ${{ inputs.release_package_main && needs.determine-main-version.outputs.skipped == 'false' }}
|
||||
if: |
|
||||
always() &&
|
||||
!cancelled() &&
|
||||
inputs.release_package_main &&
|
||||
needs.determine-main-version.result == 'success' &&
|
||||
needs.determine-main-version.outputs.skipped == 'false' &&
|
||||
needs.build-base.result == 'success' &&
|
||||
needs.build-lfx.result == 'success'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout code
|
||||
@@ -483,6 +746,12 @@ jobs:
|
||||
with:
|
||||
name: dist-lfx
|
||||
path: ./lfx-dist
|
||||
- name: Download SDK artifact
|
||||
if: needs.build-sdk.result == 'success'
|
||||
uses: actions/download-artifact@v7
|
||||
with:
|
||||
name: dist-sdk
|
||||
path: ./sdk-dist
|
||||
- name: Download base artifact
|
||||
uses: actions/download-artifact@v7
|
||||
with:
|
||||
@@ -493,8 +762,13 @@ jobs:
|
||||
# Create virtual environment
|
||||
uv venv
|
||||
# Install using pip with local wheel directories as find-links
|
||||
uv pip install --find-links ./lfx-dist --find-links ./base-dist --prerelease=allow -e .
|
||||
FIND_LINKS="--find-links ./lfx-dist --find-links ./base-dist"
|
||||
if [ -d "./sdk-dist" ]; then
|
||||
FIND_LINKS="$FIND_LINKS --find-links ./sdk-dist"
|
||||
fi
|
||||
uv pip install $FIND_LINKS --prerelease=allow -e .
|
||||
- name: Check for dependency incompatibility
|
||||
if: ${{ inputs.run_pip_check }}
|
||||
run: uv pip check
|
||||
- name: Set version for pre-release
|
||||
if: ${{ inputs.pre_release }}
|
||||
@@ -582,9 +856,63 @@ jobs:
|
||||
name: dist-main
|
||||
path: dist
|
||||
|
||||
build-bundles:
|
||||
name: Build Langflow Bundles
|
||||
needs: [build-lfx, determine-lfx-version]
|
||||
if: |
|
||||
always() &&
|
||||
!cancelled() &&
|
||||
inputs.release_bundles &&
|
||||
needs.build-lfx.result == 'success'
|
||||
runs-on: ubuntu-latest
|
||||
defaults:
|
||||
run:
|
||||
shell: bash
|
||||
outputs:
|
||||
bundles-found: ${{ steps.build.outputs.bundles-found }}
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
ref: ${{ inputs.release_tag }}
|
||||
- name: Setup Environment
|
||||
uses: astral-sh/setup-uv@v6
|
||||
with:
|
||||
enable-cache: true
|
||||
cache-dependency-glob: "uv.lock"
|
||||
python-version: "3.13"
|
||||
prune-cache: false
|
||||
- name: Install the project
|
||||
run: uv sync
|
||||
- name: Build bundle wheels
|
||||
id: build
|
||||
run: |
|
||||
shopt -s nullglob
|
||||
bundles=(src/bundles/*/pyproject.toml)
|
||||
if [ ${#bundles[@]} -eq 0 ]; then
|
||||
echo "No bundles found under src/bundles/*/"
|
||||
echo "bundles-found=0" >> $GITHUB_OUTPUT
|
||||
exit 0
|
||||
fi
|
||||
mkdir -p bundles-dist
|
||||
BUNDLES_DIST="$PWD/bundles-dist"
|
||||
for bundle_pyproject in "${bundles[@]}"; do
|
||||
bundle_dir=$(dirname "$bundle_pyproject")
|
||||
echo "Building wheel for $bundle_dir"
|
||||
(cd "$bundle_dir" && uv build --wheel --out-dir "$BUNDLES_DIST")
|
||||
done
|
||||
echo "bundles-found=1" >> $GITHUB_OUTPUT
|
||||
ls -la bundles-dist/
|
||||
- name: Upload bundles artifact
|
||||
if: steps.build.outputs.bundles-found == '1'
|
||||
uses: actions/upload-artifact@v6
|
||||
with:
|
||||
name: dist-bundles
|
||||
path: bundles-dist
|
||||
|
||||
test-cross-platform:
|
||||
name: Test Cross-Platform Installation
|
||||
needs: [build-base, build-main, build-lfx]
|
||||
needs: [build-base, build-main, build-lfx, build-sdk, build-bundles]
|
||||
if: |
|
||||
always() &&
|
||||
!cancelled() &&
|
||||
@@ -618,6 +946,46 @@ jobs:
|
||||
run: |
|
||||
cd src/backend/base && uv publish dist/*.whl
|
||||
|
||||
publish-bundles:
|
||||
name: Publish Bundles to PyPI
|
||||
needs: [build-bundles, test-cross-platform, ci, publish-lfx]
|
||||
if: |
|
||||
always() &&
|
||||
!cancelled() &&
|
||||
inputs.release_bundles &&
|
||||
needs.build-bundles.result == 'success' &&
|
||||
needs.build-bundles.outputs.bundles-found == '1' &&
|
||||
needs.test-cross-platform.result == 'success' &&
|
||||
needs.ci.result == 'success' &&
|
||||
(needs.publish-lfx.result == 'success' || needs.publish-lfx.result == 'skipped')
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Download bundles artifact
|
||||
uses: actions/download-artifact@v7
|
||||
with:
|
||||
name: dist-bundles
|
||||
path: bundles-dist
|
||||
- name: Setup Environment
|
||||
uses: astral-sh/setup-uv@v6
|
||||
with:
|
||||
enable-cache: false
|
||||
python-version: "3.13"
|
||||
- name: Publish bundles to PyPI
|
||||
if: ${{ !inputs.dry_run }}
|
||||
env:
|
||||
UV_PUBLISH_TOKEN: ${{ secrets.PYPI_API_TOKEN }}
|
||||
run: |
|
||||
shopt -s nullglob
|
||||
wheels=(bundles-dist/*.whl)
|
||||
if [ ${#wheels[@]} -eq 0 ]; then
|
||||
echo "No bundle wheels to publish."
|
||||
exit 0
|
||||
fi
|
||||
for wheel in "${wheels[@]}"; do
|
||||
echo "Publishing $wheel"
|
||||
uv publish "$wheel"
|
||||
done
|
||||
|
||||
publish-main:
|
||||
name: Publish Langflow Main to PyPI
|
||||
if: ${{ inputs.release_package_main }}
|
||||
@@ -641,6 +1009,39 @@ jobs:
|
||||
run: |
|
||||
uv publish dist/*.whl
|
||||
|
||||
publish-sdk:
|
||||
name: Publish langflow-sdk to PyPI
|
||||
needs: [build-sdk, test-cross-platform, ci]
|
||||
if: |
|
||||
always() &&
|
||||
!cancelled() &&
|
||||
inputs.release_sdk &&
|
||||
needs.build-sdk.result == 'success' &&
|
||||
needs.ci.result == 'success' &&
|
||||
(needs.test-cross-platform.result == 'success' || needs.test-cross-platform.result == 'skipped')
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
ref: ${{ inputs.release_tag }}
|
||||
- name: Download SDK artifact
|
||||
uses: actions/download-artifact@v7
|
||||
with:
|
||||
name: dist-sdk
|
||||
path: src/sdk/dist
|
||||
- name: Setup Environment
|
||||
uses: astral-sh/setup-uv@v6
|
||||
with:
|
||||
enable-cache: false
|
||||
python-version: "3.13"
|
||||
- name: Publish SDK to PyPI
|
||||
if: ${{ !inputs.dry_run }}
|
||||
env:
|
||||
UV_PUBLISH_TOKEN: ${{ secrets.PYPI_API_TOKEN }}
|
||||
run: |
|
||||
make sdk_publish
|
||||
|
||||
publish-lfx:
|
||||
name: Publish LFX to PyPI
|
||||
if: ${{ inputs.release_lfx }}
|
||||
@@ -739,14 +1140,16 @@ jobs:
|
||||
create_release:
|
||||
name: Create Release
|
||||
runs-on: ubuntu-latest
|
||||
needs: [determine-main-version, build-main, publish-main]
|
||||
needs:
|
||||
[determine-main-version, build-main, publish-main, validate-tag-format]
|
||||
if: |
|
||||
always() &&
|
||||
!cancelled() &&
|
||||
!inputs.dry_run &&
|
||||
inputs.create_release &&
|
||||
needs.build-main.result == 'success' &&
|
||||
needs.publish-main.result == 'success'
|
||||
needs.publish-main.result == 'success' &&
|
||||
needs.validate-tag-format.result == 'success'
|
||||
steps:
|
||||
- uses: actions/download-artifact@v4
|
||||
with:
|
||||
|
||||
@@ -0,0 +1,223 @@
|
||||
name: Release Bundles
|
||||
|
||||
# Manually-triggered workflow for publishing stable `lfx-<name>` bundle wheels
|
||||
# from src/bundles/* to PyPI. Bundles change infrequently, so we release them
|
||||
# on demand rather than on a schedule. Re-running the workflow without a
|
||||
# version bump is a no-op (PyPI rejects the duplicate upload and we tolerate
|
||||
# the conflict).
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
release_tag:
|
||||
description: "Git ref to build from (branch, tag, or SHA). Defaults to the workflow's checked-out ref."
|
||||
required: false
|
||||
type: string
|
||||
default: ""
|
||||
dry_run:
|
||||
description: "Build and test only; skip the PyPI publish step."
|
||||
required: false
|
||||
type: boolean
|
||||
default: false
|
||||
|
||||
env:
|
||||
PYTHON_VERSION: "3.13"
|
||||
|
||||
jobs:
|
||||
build-bundles:
|
||||
name: Build Langflow Bundles
|
||||
runs-on: ubuntu-latest
|
||||
defaults:
|
||||
run:
|
||||
shell: bash
|
||||
outputs:
|
||||
bundles-found: ${{ steps.build.outputs.bundles-found }}
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
ref: ${{ inputs.release_tag || github.ref }}
|
||||
- name: Setup Environment
|
||||
uses: astral-sh/setup-uv@v6
|
||||
with:
|
||||
enable-cache: true
|
||||
cache-dependency-glob: "uv.lock"
|
||||
python-version: ${{ env.PYTHON_VERSION }}
|
||||
prune-cache: false
|
||||
- name: Install the project
|
||||
run: uv sync
|
||||
- name: Build bundle wheels
|
||||
id: build
|
||||
run: |
|
||||
shopt -s nullglob
|
||||
bundles=(src/bundles/*/pyproject.toml)
|
||||
if [ ${#bundles[@]} -eq 0 ]; then
|
||||
echo "No bundles found under src/bundles/*/"
|
||||
echo "bundles-found=0" >> $GITHUB_OUTPUT
|
||||
exit 0
|
||||
fi
|
||||
mkdir -p bundles-dist
|
||||
BUNDLES_DIST="$PWD/bundles-dist"
|
||||
for bundle_pyproject in "${bundles[@]}"; do
|
||||
bundle_dir=$(dirname "$bundle_pyproject")
|
||||
echo "Building wheel for $bundle_dir"
|
||||
(cd "$bundle_dir" && uv build --wheel --out-dir "$BUNDLES_DIST")
|
||||
done
|
||||
echo "bundles-found=1" >> $GITHUB_OUTPUT
|
||||
ls -la bundles-dist/
|
||||
# Bundles pin lfx (e.g. lfx>=0.5.0,<0.6.0). The smoke test below
|
||||
# installs the bundles in a fresh venv, so it needs an lfx wheel that
|
||||
# satisfies the pin available locally — PyPI may not yet have a
|
||||
# matching lfx published when bundles are released ahead of a main
|
||||
# release. We build the lfx wheel from the current ref so the smoke
|
||||
# test resolves against it; this artifact is not published.
|
||||
- name: Build lfx wheel for smoke test
|
||||
if: steps.build.outputs.bundles-found == '1'
|
||||
run: |
|
||||
mkdir -p lfx-dist
|
||||
LFX_DIST="$PWD/lfx-dist"
|
||||
(cd src/lfx && uv build --wheel --out-dir "$LFX_DIST")
|
||||
ls -la lfx-dist/
|
||||
- name: Upload bundles artifact
|
||||
if: steps.build.outputs.bundles-found == '1'
|
||||
uses: actions/upload-artifact@v6
|
||||
with:
|
||||
name: dist-bundles
|
||||
path: bundles-dist
|
||||
- name: Upload lfx artifact (smoke-test only; not published)
|
||||
if: steps.build.outputs.bundles-found == '1'
|
||||
uses: actions/upload-artifact@v6
|
||||
with:
|
||||
name: dist-lfx-smoketest
|
||||
path: lfx-dist
|
||||
|
||||
test-install:
|
||||
name: Install Smoke Test - ${{ matrix.os }} ${{ matrix.python-version }}
|
||||
needs: [build-bundles]
|
||||
if: needs.build-bundles.outputs.bundles-found == '1'
|
||||
runs-on: ${{ matrix.runner }}
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- os: linux
|
||||
runner: ubuntu-latest
|
||||
python-version: "3.10"
|
||||
- os: linux
|
||||
runner: ubuntu-latest
|
||||
python-version: "3.12"
|
||||
- os: macos
|
||||
runner: macos-latest
|
||||
python-version: "3.12"
|
||||
- os: windows
|
||||
runner: windows-latest
|
||||
python-version: "3.12"
|
||||
steps:
|
||||
- name: Setup Python
|
||||
uses: actions/setup-python@v6
|
||||
with:
|
||||
python-version: ${{ matrix.python-version }}
|
||||
- name: Setup UV
|
||||
uses: astral-sh/setup-uv@v6
|
||||
with:
|
||||
enable-cache: false
|
||||
ignore-empty-workdir: true
|
||||
- name: Download bundles artifact
|
||||
uses: actions/download-artifact@v7
|
||||
with:
|
||||
name: dist-bundles
|
||||
path: ./bundles-dist
|
||||
- name: Download lfx artifact
|
||||
uses: actions/download-artifact@v7
|
||||
with:
|
||||
name: dist-lfx-smoketest
|
||||
path: ./lfx-dist
|
||||
- name: Create fresh virtual environment
|
||||
run: uv venv test-env --seed
|
||||
shell: bash
|
||||
- name: Install lfx + bundle wheels (Unix)
|
||||
if: matrix.os != 'windows'
|
||||
run: |
|
||||
shopt -s nullglob
|
||||
BUNDLE_WHEELS=(./bundles-dist/*.whl)
|
||||
LFX_WHEELS=(./lfx-dist/*.whl)
|
||||
if [ ${#BUNDLE_WHEELS[@]} -eq 0 ]; then
|
||||
echo "No bundle wheels found in ./bundles-dist/"
|
||||
exit 1
|
||||
fi
|
||||
if [ ${#LFX_WHEELS[@]} -eq 0 ]; then
|
||||
echo "No lfx wheel found in ./lfx-dist/"
|
||||
exit 1
|
||||
fi
|
||||
printf 'Installing: %s\n' "${LFX_WHEELS[@]}" "${BUNDLE_WHEELS[@]}"
|
||||
uv pip install --prerelease=allow --python ./test-env/bin/python "${LFX_WHEELS[@]}" "${BUNDLE_WHEELS[@]}"
|
||||
shell: bash
|
||||
- name: Install lfx + bundle wheels (Windows)
|
||||
if: matrix.os == 'windows'
|
||||
run: |
|
||||
shopt -s nullglob
|
||||
BUNDLE_WHEELS=(./bundles-dist/*.whl)
|
||||
LFX_WHEELS=(./lfx-dist/*.whl)
|
||||
if [ ${#BUNDLE_WHEELS[@]} -eq 0 ]; then
|
||||
echo "No bundle wheels found in ./bundles-dist/"
|
||||
exit 1
|
||||
fi
|
||||
if [ ${#LFX_WHEELS[@]} -eq 0 ]; then
|
||||
echo "No lfx wheel found in ./lfx-dist/"
|
||||
exit 1
|
||||
fi
|
||||
printf 'Installing: %s\n' "${LFX_WHEELS[@]}" "${BUNDLE_WHEELS[@]}"
|
||||
uv pip install --prerelease=allow --python ./test-env/Scripts/python.exe "${LFX_WHEELS[@]}" "${BUNDLE_WHEELS[@]}"
|
||||
shell: bash
|
||||
|
||||
publish-bundles:
|
||||
name: Publish Bundles to PyPI
|
||||
needs: [build-bundles, test-install]
|
||||
if: |
|
||||
always() &&
|
||||
!cancelled() &&
|
||||
!inputs.dry_run &&
|
||||
needs.build-bundles.result == 'success' &&
|
||||
needs.build-bundles.outputs.bundles-found == '1' &&
|
||||
needs.test-install.result == 'success'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Download bundles artifact
|
||||
uses: actions/download-artifact@v7
|
||||
with:
|
||||
name: dist-bundles
|
||||
path: bundles-dist
|
||||
- name: Setup Environment
|
||||
uses: astral-sh/setup-uv@v6
|
||||
with:
|
||||
enable-cache: false
|
||||
python-version: ${{ env.PYTHON_VERSION }}
|
||||
- name: Publish bundles to PyPI
|
||||
env:
|
||||
UV_PUBLISH_TOKEN: ${{ secrets.PYPI_API_TOKEN }}
|
||||
run: |
|
||||
shopt -s nullglob
|
||||
wheels=(bundles-dist/*.whl)
|
||||
if [ ${#wheels[@]} -eq 0 ]; then
|
||||
echo "No bundle wheels to publish."
|
||||
exit 0
|
||||
fi
|
||||
failed=0
|
||||
for wheel in "${wheels[@]}"; do
|
||||
echo "Publishing $wheel"
|
||||
# Capture stderr so we can tolerate "already exists" without failing
|
||||
# the whole run — re-running this workflow without bumping a bundle
|
||||
# version should be a no-op for that bundle.
|
||||
if ! err=$(uv publish "$wheel" 2>&1); then
|
||||
echo "$err"
|
||||
if echo "$err" | grep -qiE 'already exists|file already exists|HTTP 400|duplicate'; then
|
||||
echo "Skipping $wheel: already published."
|
||||
else
|
||||
echo "Publish failed for $wheel"
|
||||
failed=1
|
||||
fi
|
||||
fi
|
||||
done
|
||||
if [ "$failed" = "1" ]; then
|
||||
exit 1
|
||||
fi
|
||||
@@ -36,6 +36,10 @@ on:
|
||||
description: "Tag for the nightly LFX build"
|
||||
required: false
|
||||
type: string
|
||||
nightly_tag_sdk:
|
||||
description: "Tag for the nightly SDK build"
|
||||
required: false
|
||||
type: string
|
||||
push_to_registry:
|
||||
description: "Whether to push images to registries. Set to false for testing builds without publishing."
|
||||
required: true
|
||||
@@ -72,6 +76,20 @@ jobs:
|
||||
- name: Install LFX dependencies
|
||||
run: cd src/lfx && uv sync
|
||||
|
||||
- name: Verify SDK Nightly Name and Version
|
||||
if: ${{ inputs.build_lfx }}
|
||||
run: |
|
||||
name=$(grep '^name = "' src/sdk/pyproject.toml | head -n 1 | sed 's/.*"\(.*\)".*/\1/')
|
||||
version=$(grep '^version = "' src/sdk/pyproject.toml | head -n 1 | sed 's/.*"\(.*\)".*/\1/')
|
||||
if [ "$name" != "langflow-sdk-nightly" ]; then
|
||||
echo "Name $name does not match langflow-sdk-nightly. Exiting the workflow."
|
||||
exit 1
|
||||
fi
|
||||
if [ "v$version" != "${{ inputs.nightly_tag_sdk }}" ]; then
|
||||
echo "Version v$version does not match nightly tag ${{ inputs.nightly_tag_sdk }}. Exiting the workflow."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Verify Nightly Name and Version
|
||||
id: verify
|
||||
run: |
|
||||
@@ -90,6 +108,10 @@ jobs:
|
||||
version=$(echo $version | sed 's/^v//')
|
||||
echo "version=$version" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Build langflow-sdk for distribution
|
||||
run: |
|
||||
make sdk_build args="--wheel"
|
||||
|
||||
- name: Build LFX for distribution
|
||||
run: |
|
||||
cd src/lfx
|
||||
@@ -99,12 +121,21 @@ jobs:
|
||||
- name: Test LFX CLI
|
||||
run: |
|
||||
cd src/lfx
|
||||
uv pip install dist/*.whl --force-reinstall
|
||||
uv run lfx --help
|
||||
# Use a clean venv and install both wheels together so uv can resolve
|
||||
# the local SDK dependency without falling back to PyPI.
|
||||
uv venv test-env --seed
|
||||
uv pip install --python ./test-env/bin/python --force-reinstall ../sdk/dist/*.whl dist/*.whl
|
||||
./test-env/bin/lfx --help
|
||||
echo "LFX CLI test completed successfully"
|
||||
|
||||
# PyPI publishing moved to after cross-platform testing
|
||||
|
||||
- name: Upload langflow-sdk Artifact
|
||||
uses: actions/upload-artifact@v6
|
||||
with:
|
||||
name: dist-nightly-sdk
|
||||
path: src/sdk/dist
|
||||
|
||||
- name: Upload LFX Artifact
|
||||
uses: actions/upload-artifact@v6
|
||||
with:
|
||||
@@ -163,15 +194,18 @@ jobs:
|
||||
name_without_extras=$(echo $name | sed 's/\[.*\]//')
|
||||
if [ "$name_without_extras" != "langflow-base-nightly" ]; then
|
||||
echo "Name $name_without_extras does not match langflow-base-nightly. Exiting the workflow."
|
||||
echo "skipped=true" >> $GITHUB_OUTPUT
|
||||
exit 1
|
||||
fi
|
||||
if [ "$version" != "${{ inputs.nightly_tag_base }}" ]; then
|
||||
echo "Version $version does not match nightly tag ${{ inputs.nightly_tag_base }}. Exiting the workflow."
|
||||
echo "skipped=true" >> $GITHUB_OUTPUT
|
||||
exit 1
|
||||
fi
|
||||
# Strip the leading `v` from the version
|
||||
version=$(echo $version | sed 's/^v//')
|
||||
echo "version=$version" >> $GITHUB_OUTPUT
|
||||
echo "skipped=false" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Build Langflow Base for distribution
|
||||
run: |
|
||||
@@ -263,8 +297,8 @@ jobs:
|
||||
- name: Verify Nightly Name and Version
|
||||
id: verify
|
||||
run: |
|
||||
name=$(uv tree | grep 'langflow' | grep -v 'langflow-base' | awk '{print $1}')
|
||||
version=$(uv tree | grep 'langflow' | grep -v 'langflow-base' | awk '{print $2}')
|
||||
name=$(uv tree | grep -E '^langflow(-nightly)?[[:space:]]' | awk '{print $1}')
|
||||
version=$(uv tree | grep -E '^langflow(-nightly)?[[:space:]]' | awk '{print $2}')
|
||||
if [ "$name" != "langflow-nightly" ]; then
|
||||
echo "Name $name does not match langflow-nightly. Exiting the workflow."
|
||||
exit 1
|
||||
@@ -300,13 +334,50 @@ jobs:
|
||||
echo "Server terminated successfully"
|
||||
fi
|
||||
|
||||
- name: Prepare Langflow Main artifact
|
||||
run: |
|
||||
shopt -s nullglob
|
||||
MAIN_WHEELS=(dist/langflow_nightly-*.whl)
|
||||
if [ ${#MAIN_WHEELS[@]} -ne 1 ]; then
|
||||
echo "Expected exactly one langflow-nightly wheel in dist/, found ${#MAIN_WHEELS[@]}"
|
||||
ls -la dist/
|
||||
exit 1
|
||||
fi
|
||||
rm -rf main-dist
|
||||
mkdir -p main-dist
|
||||
cp "${MAIN_WHEELS[0]}" main-dist/
|
||||
|
||||
- name: Build bundle wheels for cross-platform test
|
||||
run: |
|
||||
shopt -s nullglob
|
||||
bundles=(src/bundles/*/pyproject.toml)
|
||||
if [ ${#bundles[@]} -eq 0 ]; then
|
||||
echo "No bundles found under src/bundles/*/"
|
||||
exit 1
|
||||
fi
|
||||
rm -rf bundles-dist
|
||||
mkdir -p bundles-dist
|
||||
BUNDLES_DIST="$PWD/bundles-dist"
|
||||
for bundle_pyproject in "${bundles[@]}"; do
|
||||
bundle_dir=$(dirname "$bundle_pyproject")
|
||||
echo "Building wheel for $bundle_dir"
|
||||
(cd "$bundle_dir" && uv build --wheel --out-dir "$BUNDLES_DIST")
|
||||
done
|
||||
ls -la bundles-dist/
|
||||
|
||||
# PyPI publishing moved to after cross-platform testing
|
||||
|
||||
- name: Upload Artifact
|
||||
uses: actions/upload-artifact@v6
|
||||
with:
|
||||
name: dist-nightly-main
|
||||
path: dist
|
||||
path: main-dist
|
||||
|
||||
- name: Upload bundles artifact
|
||||
uses: actions/upload-artifact@v6
|
||||
with:
|
||||
name: dist-nightly-bundles
|
||||
path: bundles-dist
|
||||
|
||||
test-cross-platform:
|
||||
name: Test Cross-Platform Installation
|
||||
@@ -316,10 +387,12 @@ jobs:
|
||||
base-artifact-name: "dist-nightly-base"
|
||||
main-artifact-name: "dist-nightly-main"
|
||||
lfx-artifact-name: "dist-nightly-lfx"
|
||||
sdk-artifact-name: "dist-nightly-sdk"
|
||||
bundles-artifact-name: "dist-nightly-bundles"
|
||||
|
||||
publish-nightly-lfx:
|
||||
name: Publish LFX Nightly to PyPI
|
||||
needs: [build-nightly-lfx, test-cross-platform]
|
||||
needs: [build-nightly-lfx, test-cross-platform, publish-nightly-sdk]
|
||||
if: ${{ inputs.build_lfx }}
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
@@ -345,6 +418,34 @@ jobs:
|
||||
run: |
|
||||
make lfx_publish
|
||||
|
||||
publish-nightly-sdk:
|
||||
name: Publish SDK Nightly to PyPI
|
||||
needs: [build-nightly-lfx, test-cross-platform]
|
||||
if: ${{ inputs.build_lfx }}
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Check out the code
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
ref: ${{ inputs.nightly_tag_release }}
|
||||
persist-credentials: true
|
||||
- name: Download langflow-sdk artifact
|
||||
uses: actions/download-artifact@v7
|
||||
with:
|
||||
name: dist-nightly-sdk
|
||||
path: src/sdk/dist
|
||||
- name: Setup Environment
|
||||
uses: astral-sh/setup-uv@v6
|
||||
with:
|
||||
enable-cache: false
|
||||
python-version: "3.13"
|
||||
- name: Publish SDK to PyPI
|
||||
env:
|
||||
POETRY_PYPI_TOKEN_PYPI: ${{ secrets.PYPI_API_TOKEN }}
|
||||
UV_PUBLISH_TOKEN: ${{ secrets.PYPI_API_TOKEN }}
|
||||
run: |
|
||||
make sdk_publish
|
||||
|
||||
publish-nightly-base:
|
||||
name: Publish Langflow Base Nightly to PyPI
|
||||
needs: [build-nightly-base, test-cross-platform, publish-nightly-lfx]
|
||||
@@ -374,9 +475,60 @@ jobs:
|
||||
run: |
|
||||
make publish base=true
|
||||
|
||||
publish-nightly-bundles:
|
||||
name: Publish Langflow Bundles Nightly to PyPI
|
||||
# langflow-nightly pins each `lfx-<name>-nightly==<dev>` exactly, so the
|
||||
# bundles must be on PyPI before main is published (publish-nightly-main
|
||||
# gates on this job). They depend on `lfx-nightly`, so gate on the lfx
|
||||
# publish too (or skip it when lfx isn't being rebuilt).
|
||||
needs: [build-nightly-main, test-cross-platform, publish-nightly-lfx]
|
||||
if: ${{ always() && needs.build-nightly-main.result == 'success' && needs.test-cross-platform.result == 'success' && (needs.publish-nightly-lfx.result == 'success' || inputs.build_lfx == false) }}
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Download bundles artifact
|
||||
uses: actions/download-artifact@v7
|
||||
with:
|
||||
name: dist-nightly-bundles
|
||||
path: bundles-dist
|
||||
- name: Setup Environment
|
||||
uses: astral-sh/setup-uv@v6
|
||||
with:
|
||||
enable-cache: false
|
||||
python-version: "3.13"
|
||||
- name: Publish bundles to PyPI
|
||||
env:
|
||||
POETRY_PYPI_TOKEN_PYPI: ${{ secrets.PYPI_API_TOKEN }}
|
||||
UV_PUBLISH_TOKEN: ${{ secrets.PYPI_API_TOKEN }}
|
||||
run: |
|
||||
shopt -s nullglob
|
||||
wheels=(bundles-dist/*.whl)
|
||||
if [ ${#wheels[@]} -eq 0 ]; then
|
||||
echo "No bundle wheels to publish."
|
||||
exit 1
|
||||
fi
|
||||
failed=0
|
||||
for wheel in "${wheels[@]}"; do
|
||||
echo "Publishing $wheel"
|
||||
# Capture stderr so a re-run without a version bump is a no-op for
|
||||
# an already-published bundle instead of failing the whole job.
|
||||
if ! err=$(uv publish "$wheel" 2>&1); then
|
||||
echo "$err"
|
||||
if echo "$err" | grep -qiE 'already exists|file already exists|HTTP 400|duplicate'; then
|
||||
echo "Skipping $wheel: already published."
|
||||
else
|
||||
echo "Publish failed for $wheel"
|
||||
failed=1
|
||||
fi
|
||||
fi
|
||||
done
|
||||
if [ "$failed" = "1" ]; then
|
||||
exit 1
|
||||
fi
|
||||
|
||||
publish-nightly-main:
|
||||
name: Publish Langflow Main Nightly to PyPI
|
||||
needs: [build-nightly-main, test-cross-platform, publish-nightly-base]
|
||||
needs: [build-nightly-main, test-cross-platform, publish-nightly-base, publish-nightly-bundles]
|
||||
if: ${{ always() && needs.build-nightly-main.result == 'success' && needs.test-cross-platform.result == 'success' && needs.publish-nightly-base.result == 'success' && needs.publish-nightly-bundles.result == 'success' }}
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout code
|
||||
|
||||
@@ -0,0 +1,175 @@
|
||||
# Advisory-only check: flags PRs that change source code (.py / .ts / .tsx)
|
||||
# without touching any test file. It NEVER fails and is NOT a required check,
|
||||
# so it cannot block other jobs or the merge — it only posts a sticky PR
|
||||
# comment + job summary so the author knows tests appear to be missing.
|
||||
name: Test Coverage Advisor
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
types: [opened, synchronize, reopened, ready_for_review]
|
||||
|
||||
# Dedicated concurrency group so this never cancels or queues behind CI.
|
||||
concurrency:
|
||||
group: test-coverage-advisor-${{ github.event.pull_request.number }}
|
||||
cancel-in-progress: true
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: write
|
||||
|
||||
jobs:
|
||||
advise:
|
||||
name: Test Coverage Advisor
|
||||
# Skip drafts to avoid noise; they re-run on "ready_for_review".
|
||||
if: github.event.pull_request.draft == false
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
# Full history so merge-base against the PR target works.
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Detect missing tests
|
||||
id: detect
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
BASE_REF="${{ github.event.pull_request.base.ref }}"
|
||||
git fetch --no-tags --depth=50 origin "$BASE_REF" || true
|
||||
MERGE_BASE=$(git merge-base HEAD "origin/$BASE_REF" || git rev-parse HEAD~1)
|
||||
|
||||
echo "BASE_REF=$BASE_REF"
|
||||
echo "MERGE_BASE=$MERGE_BASE"
|
||||
|
||||
CHANGED=$(git diff --name-only --diff-filter=ACMR "$MERGE_BASE"...HEAD || true)
|
||||
echo "=== Changed files ==="
|
||||
echo "$CHANGED"
|
||||
|
||||
# --- Backend Python ---------------------------------------------
|
||||
# Source: .py under src/backend or src/lfx, excluding tests,
|
||||
# migrations and dunder/init plumbing.
|
||||
PY_SRC=$(echo "$CHANGED" \
|
||||
| grep -E '^(src/backend|src/lfx)/.*\.py$' \
|
||||
| grep -Ev '(^|/)tests?/' \
|
||||
| grep -Ev '(^|/)(test_[^/]+|conftest)\.py$' \
|
||||
| grep -Ev '/alembic/versions/' \
|
||||
| grep -Ev '/__init__\.py$' \
|
||||
|| true)
|
||||
PY_TEST=$(echo "$CHANGED" \
|
||||
| grep -E '\.py$' \
|
||||
| grep -E '((^|/)tests?/|(^|/)test_[^/]+\.py$|(^|/)conftest\.py$)' \
|
||||
|| true)
|
||||
|
||||
# --- Frontend TS/TSX --------------------------------------------
|
||||
# Source: .ts/.tsx under src/frontend/src, excluding *.test.* /
|
||||
# *.spec.* / __tests__ and type declaration files.
|
||||
FE_SRC=$(echo "$CHANGED" \
|
||||
| grep -E '^src/frontend/src/.*\.(ts|tsx)$' \
|
||||
| grep -Ev '\.(test|spec)\.(ts|tsx)$' \
|
||||
| grep -Ev '(^|/)__tests__/' \
|
||||
| grep -Ev '\.d\.ts$' \
|
||||
|| true)
|
||||
FE_TEST=$(echo "$CHANGED" \
|
||||
| grep -E '\.(ts|tsx)$' \
|
||||
| grep -E '(^src/frontend/tests/|\.(test|spec)\.(ts|tsx)$|(^|/)__tests__/)' \
|
||||
|| true)
|
||||
|
||||
need_be=false
|
||||
need_fe=false
|
||||
[ -n "$PY_SRC" ] && [ -z "$PY_TEST" ] && need_be=true
|
||||
[ -n "$FE_SRC" ] && [ -z "$FE_TEST" ] && need_fe=true
|
||||
|
||||
{
|
||||
echo "need_be=$need_be"
|
||||
echo "need_fe=$need_fe"
|
||||
} >> "$GITHUB_OUTPUT"
|
||||
|
||||
# Multiline outputs for the comment body.
|
||||
{
|
||||
echo "py_src<<EOF"
|
||||
echo "$PY_SRC"
|
||||
echo "EOF"
|
||||
echo "fe_src<<EOF"
|
||||
echo "$FE_SRC"
|
||||
echo "EOF"
|
||||
} >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Build advisory message
|
||||
id: msg
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
NEED_BE="${{ steps.detect.outputs.need_be }}"
|
||||
NEED_FE="${{ steps.detect.outputs.need_fe }}"
|
||||
|
||||
BODY_FILE="$(mktemp)"
|
||||
echo '<!-- test-coverage-advisor -->' > "$BODY_FILE"
|
||||
|
||||
if [ "$NEED_BE" != "true" ] && [ "$NEED_FE" != "true" ]; then
|
||||
{
|
||||
echo '### ✅ Test Coverage Advisor'
|
||||
echo
|
||||
echo 'No source changes detected without accompanying tests. Thanks for keeping coverage up! 🎉'
|
||||
echo
|
||||
echo '> _Advisory check only — never blocks merge._'
|
||||
} >> "$BODY_FILE"
|
||||
else
|
||||
{
|
||||
echo '### ⚠️ Test Coverage Advisor — tests appear to be missing'
|
||||
echo
|
||||
echo 'This PR changes source code but does **not** add or modify any test files.'
|
||||
echo 'This is an **advisory** check: it will **not** block this PR or any other job —'
|
||||
echo 'please add tests if the change is testable.'
|
||||
echo
|
||||
} >> "$BODY_FILE"
|
||||
if [ "$NEED_BE" = "true" ]; then
|
||||
{
|
||||
echo '#### Backend (Python) — no `test_*.py` / `tests/` change found'
|
||||
echo '```'
|
||||
echo '${{ steps.detect.outputs.py_src }}'
|
||||
echo '```'
|
||||
} >> "$BODY_FILE"
|
||||
fi
|
||||
if [ "$NEED_FE" = "true" ]; then
|
||||
{
|
||||
echo '#### Frontend (TS/TSX) — no `*.test.*` / `*.spec.*` / `tests/` change found'
|
||||
echo '```'
|
||||
echo '${{ steps.detect.outputs.fe_src }}'
|
||||
echo '```'
|
||||
} >> "$BODY_FILE"
|
||||
fi
|
||||
{
|
||||
echo
|
||||
echo '> _Advisory check only — it always passes and is never a required status._'
|
||||
} >> "$BODY_FILE"
|
||||
fi
|
||||
|
||||
# Mirror into the run's Job Summary (works even on fork PRs).
|
||||
cat "$BODY_FILE" >> "$GITHUB_STEP_SUMMARY"
|
||||
|
||||
echo "body_file=$BODY_FILE" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Find existing advisory comment
|
||||
id: find
|
||||
uses: peter-evans/find-comment@v3
|
||||
continue-on-error: true
|
||||
with:
|
||||
issue-number: ${{ github.event.pull_request.number }}
|
||||
comment-author: github-actions[bot]
|
||||
body-includes: "<!-- test-coverage-advisor -->"
|
||||
|
||||
- name: Upsert advisory comment
|
||||
uses: peter-evans/create-or-update-comment@v4
|
||||
# Fork PRs get a read-only token — don't fail the run if so.
|
||||
continue-on-error: true
|
||||
with:
|
||||
comment-id: ${{ steps.find.outputs.comment-id }}
|
||||
issue-number: ${{ github.event.pull_request.number }}
|
||||
body-path: ${{ steps.msg.outputs.body_file }}
|
||||
edit-mode: replace
|
||||
|
||||
- name: Always succeed
|
||||
# Explicit: this job never blocks anything.
|
||||
run: 'echo "Advisory check complete — this status is informational only."'
|
||||
@@ -69,7 +69,8 @@ env:
|
||||
# Define the directory where Playwright browsers will be installed.
|
||||
# This path is used for caching across workflows
|
||||
PLAYWRIGHT_BROWSERS_PATH: "ms-playwright"
|
||||
PLAYWRIGHT_VERSION: "1.57.0"
|
||||
PLAYWRIGHT_VERSION: "1.59.1"
|
||||
LANGFLOW_FEATURE_WXO_DEPLOYMENTS: "true"
|
||||
|
||||
jobs:
|
||||
determine-test-suite:
|
||||
@@ -224,8 +225,22 @@ jobs:
|
||||
id: setup-node
|
||||
with:
|
||||
node-version: ${{ env.NODE_VERSION }}
|
||||
cache: "npm"
|
||||
cache-dependency-path: ./src/frontend/package-lock.json
|
||||
|
||||
# npm caching is decoupled from setup-node so a transient cache-service
|
||||
# hiccup (frequent on Windows runners) never fails the job. See #25 / #1.
|
||||
- name: Get npm cache directory
|
||||
id: npm-cache-dir
|
||||
shell: bash
|
||||
run: echo "dir=$(npm config get cache)" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Cache npm dependencies
|
||||
uses: actions/cache@v5
|
||||
continue-on-error: true
|
||||
with:
|
||||
path: ${{ steps.npm-cache-dir.outputs.dir }}
|
||||
key: ${{ runner.os }}-npm-${{ env.NODE_VERSION }}-${{ hashFiles('src/frontend/package-lock.json') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-npm-${{ env.NODE_VERSION }}-
|
||||
|
||||
- name: Install Frontend Dependencies
|
||||
run: npm ci
|
||||
@@ -294,8 +309,23 @@ jobs:
|
||||
id: setup-node
|
||||
with:
|
||||
node-version: ${{ env.NODE_VERSION }}
|
||||
cache: "npm"
|
||||
cache-dependency-path: ./src/frontend/package-lock.json
|
||||
|
||||
# npm caching is decoupled from setup-node so a transient cache-service
|
||||
# hiccup (frequent on Windows runners) never fails the job. See #25 / #1.
|
||||
- name: Get npm cache directory
|
||||
id: npm-cache-dir
|
||||
shell: bash
|
||||
run: echo "dir=$(npm config get cache)" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Cache npm dependencies
|
||||
uses: actions/cache@v5
|
||||
continue-on-error: true
|
||||
with:
|
||||
path: ${{ steps.npm-cache-dir.outputs.dir }}
|
||||
key: ${{ runner.os }}-npm-${{ env.NODE_VERSION }}-${{ hashFiles('src/frontend/package-lock.json') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-npm-${{ env.NODE_VERSION }}-
|
||||
|
||||
- name: Install Frontend Dependencies
|
||||
run: npm ci
|
||||
working-directory: ./src/frontend
|
||||
@@ -304,6 +334,7 @@ jobs:
|
||||
- name: Cache Playwright Browsers
|
||||
id: cache-playwright
|
||||
uses: actions/cache@v5
|
||||
continue-on-error: true
|
||||
with:
|
||||
path: ${{ env.PLAYWRIGHT_BROWSERS_PATH }}
|
||||
key: playwright-${{ env.PLAYWRIGHT_VERSION }}-chromium-${{ runner.os }}
|
||||
@@ -341,7 +372,12 @@ jobs:
|
||||
- name: Execute Playwright Tests
|
||||
uses: nick-fields/retry@v3
|
||||
with:
|
||||
timeout_minutes: 12
|
||||
# Bumped from 12 → 18 minutes. Shard 38 carries all four
|
||||
# starter-projects-shardN tests + the sentiment streaming test;
|
||||
# iterating 8 templates per test under Linux nightly load
|
||||
# consistently runs past 12 minutes once a single iteration
|
||||
# triggers a per-test Playwright retry.
|
||||
timeout_minutes: 18
|
||||
max_attempts: 2
|
||||
command: |
|
||||
cd src/frontend
|
||||
@@ -354,11 +390,23 @@ jobs:
|
||||
|
||||
- name: Upload Test Results
|
||||
if: always()
|
||||
continue-on-error: true
|
||||
uses: actions/upload-artifact@v6
|
||||
with:
|
||||
name: blob-report-${{ matrix.shardIndex }}
|
||||
path: src/frontend/blob-report
|
||||
retention-days: 1
|
||||
overwrite: true
|
||||
|
||||
- name: Upload Playwright Coverage Artifact
|
||||
if: always() && hashFiles('src/frontend/coverage/playwright/**') != ''
|
||||
continue-on-error: true
|
||||
uses: actions/upload-artifact@v6
|
||||
with:
|
||||
name: playwright-coverage-${{ matrix.shardIndex }}
|
||||
path: src/frontend/coverage/playwright
|
||||
retention-days: 7
|
||||
overwrite: true
|
||||
|
||||
- name: Cleanup UV Cache
|
||||
run: uv cache prune --ci
|
||||
|
||||
@@ -19,12 +19,14 @@ repos:
|
||||
language: system
|
||||
types_or: [python, pyi]
|
||||
args: [--fix]
|
||||
exclude: ^docs/.*/API-Reference/python-examples/
|
||||
- id: ruff-format
|
||||
name: ruff format
|
||||
entry: uv run ruff format
|
||||
language: system
|
||||
types_or: [python, pyi]
|
||||
args: [--config, pyproject.toml]
|
||||
exclude: ^docs/.*/API-Reference/python-examples/
|
||||
- id: validate-migrations
|
||||
name: Validate Alembic Migrations (Expand-Contract)
|
||||
entry: python src/backend/base/langflow/alembic/migration_validator.py
|
||||
@@ -45,7 +47,7 @@ repos:
|
||||
hooks:
|
||||
- id: detect-secrets
|
||||
args: ["--baseline", ".secrets.baseline"]
|
||||
exclude: '(^docs/|^SECURITY\.md$|src/lfx/src/lfx/_assets/component_index\.json$)'
|
||||
exclude: '(^docs/|^SECURITY\.md$|src/lfx/src/lfx/_assets/component_index\.json$|src/lfx/README\.md$)'
|
||||
- repo: local
|
||||
hooks:
|
||||
- id: local-biome-check
|
||||
|
||||
+3390
-792
File diff suppressed because it is too large
Load Diff
Vendored
+3
-1
@@ -24,7 +24,9 @@
|
||||
"--reload-include",
|
||||
"./src/lfx/*",
|
||||
"--reload-exclude",
|
||||
"*.db*"
|
||||
"*.db*",
|
||||
"--reload-exclude",
|
||||
"./tmp_toolguard/*"
|
||||
],
|
||||
"jinja": true,
|
||||
"justMyCode": false,
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"settingsInheritedFrom": "whitesource-config/whitesource-config@master",
|
||||
"scanSettings": {
|
||||
"dependencyScopes": [
|
||||
"prod"
|
||||
],
|
||||
"baseBranches": [
|
||||
"main",
|
||||
"release-*"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
# AGENTS.md
|
||||
|
||||
Guidance for AI coding agents working in this repository.
|
||||
|
||||
> **Before you write code, read [docs/agents/PHILOSOPHY.md](./docs/agents/PHILOSOPHY.md).** Most off-narrative work comes from skipping it.
|
||||
|
||||
## What Langflow is
|
||||
|
||||
Langflow is a **visual flow builder first**. Users drag components onto a canvas, wire them together, and either run the resulting flow in the Playground, deploy it as an API, or expose it as an MCP server. Saved flows are persistent JSON artifacts running in production — backwards compatibility for components is non-negotiable.
|
||||
|
||||
The repo is three Python packages and one frontend:
|
||||
|
||||
- **`lfx`** (`src/lfx/`): the executor core. Component base classes, the graph engine, and built-in components live here. Should not depend on `langflow` or `langflow-base` — see [docs/agents/ARCHITECTURE.md](./docs/agents/ARCHITECTURE.md) for the ~14 known violations to fix-not-extend.
|
||||
- **`langflow-base`** (`src/backend/base/langflow/`): the platform. FastAPI routes, auth, persistence, alembic, services. May import `lfx`.
|
||||
- **`langflow`**: the integration distribution that ships everything together.
|
||||
- **`frontend`** (`src/frontend/`): React 19 + TypeScript + Vite + Zustand + `@xyflow/react`. Talks to the backend over HTTP/WebSocket only.
|
||||
|
||||
Dependencies flow one way: `frontend → langflow → langflow-base → lfx`. See [docs/agents/ARCHITECTURE.md](./docs/agents/ARCHITECTURE.md).
|
||||
|
||||
## Non-negotiable tenets
|
||||
|
||||
These are extracted from [PHILOSOPHY.md](./docs/agents/PHILOSOPHY.md). The full file has the rest; these are the ones every change must respect.
|
||||
|
||||
1. **Flows are user artifacts.** Component class names, `name` attributes, input/output names, and input types are frozen once shipped. Mark old components `legacy=True` instead of editing.
|
||||
2. **Every backend feature must land on the canvas.** If it can't be a component or a property of one, it's an SDK feature and belongs in `lfx`, not `langflow-base`.
|
||||
3. **Components are the unit of work.** Don't add a route, store, or service unless a component or UI page consumes it.
|
||||
4. **Visible data flow beats clever magic.** Pass data through inputs and outputs. No hidden globals or side-channel state.
|
||||
5. **Composition over capability.** One job per component. Split before you add a tenth input.
|
||||
6. **It is not a fix without evidence.** Adding error handling, retries, type widening, or skipping a flaky test does not constitute a fix.
|
||||
|
||||
## Documentation map
|
||||
|
||||
Read the file that matches your task before you write code.
|
||||
|
||||
| Topic | File | When to read |
|
||||
|---|---|---|
|
||||
| Project story, design tenets | [docs/agents/PHILOSOPHY.md](./docs/agents/PHILOSOPHY.md) | Before any non-trivial change |
|
||||
| Package boundaries, where code goes, API versioning | [docs/agents/ARCHITECTURE.md](./docs/agents/ARCHITECTURE.md) | Before adding a file or endpoint |
|
||||
| Component dev: scope, breaking changes, conventions, icons | [docs/agents/COMPONENTS.md](./docs/agents/COMPONENTS.md) | Before adding or editing a component |
|
||||
| User-facing contracts: flow JSON, REST API, MCP, env vars | [docs/agents/CONTRACTS.md](./docs/agents/CONTRACTS.md) | Before changing anything user-visible |
|
||||
| Test patterns, fixtures, mocking policy | [docs/agents/TESTING.md](./docs/agents/TESTING.md) | Before writing or changing tests |
|
||||
| Don't/do rules, "fixes that aren't," before-claiming-done checklist | [docs/agents/ANTI-PATTERNS.md](./docs/agents/ANTI-PATTERNS.md) | Before claiming work is done |
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- **Python:** 3.10–3.13
|
||||
- **uv:** ≥0.4 (always use `uv run` for Python commands)
|
||||
- **Node.js:** ≥20.19.0 (v22.12 LTS recommended)
|
||||
- **npm:** v10.9+
|
||||
- **make:** for build coordination
|
||||
|
||||
## Common commands
|
||||
|
||||
### Development setup
|
||||
|
||||
```bash
|
||||
make init # Install all dependencies + pre-commit hooks
|
||||
make run_cli # Build and run Langflow (http://localhost:7860)
|
||||
make run_clic # Clean build and run (use when frontend issues occur)
|
||||
```
|
||||
|
||||
### Development mode (hot reload)
|
||||
|
||||
```bash
|
||||
make backend # FastAPI on port 7860 (terminal 1)
|
||||
make frontend # Vite dev server on port 3000 (terminal 2)
|
||||
```
|
||||
|
||||
For component development with dynamic loading:
|
||||
|
||||
```bash
|
||||
LFX_DEV=1 make backend # Load all components dynamically
|
||||
LFX_DEV=mistral,openai make backend # Load only specific modules
|
||||
```
|
||||
|
||||
### Code quality
|
||||
|
||||
```bash
|
||||
make format_backend # Format Python (ruff) — run FIRST before lint
|
||||
make format_frontend # Format TypeScript (biome)
|
||||
make format # Both
|
||||
make lint # mypy type checking
|
||||
```
|
||||
|
||||
### Testing
|
||||
|
||||
```bash
|
||||
make unit_tests # Backend unit tests (pytest, parallel)
|
||||
make unit_tests async=false # Sequential
|
||||
uv run pytest path/to/test.py # Single test file
|
||||
uv run pytest path/to/test.py::test_name # Single test
|
||||
|
||||
make test_frontend # Jest unit tests
|
||||
make tests_frontend # Playwright e2e tests
|
||||
|
||||
# lfx tests specifically — run uv sync inside src/lfx (not src/lfx/src/lfx)
|
||||
cd src/lfx && uv sync && uv run pytest
|
||||
|
||||
# Sub-package tests (langflow-base, lfx) — sync that package's dev group first,
|
||||
# otherwise dev-only deps like fakeredis stay uninstalled.
|
||||
uv sync --group dev --package langflow-base
|
||||
```
|
||||
|
||||
See [docs/agents/TESTING.md](./docs/agents/TESTING.md) for fixtures, base classes, and the graph testing pattern.
|
||||
|
||||
### Database migrations
|
||||
|
||||
```bash
|
||||
make alembic-revision message="Description" # Create migration
|
||||
make alembic-upgrade # Apply migrations
|
||||
make alembic-downgrade # Rollback one version
|
||||
```
|
||||
|
||||
Never edit a past migration. Run `test_database.py` sequentially after any DB change.
|
||||
|
||||
### Version management
|
||||
|
||||
```bash
|
||||
make patch v=1.5.0 # Update version across all packages
|
||||
```
|
||||
|
||||
## Pre-commit workflow
|
||||
|
||||
Pre-commit hooks run ruff and biome automatically on `git commit`, so manual formatting isn't required. To avoid an extra commit cycle:
|
||||
|
||||
1. Run `make format_backend` once before staging — fixes most ruff issues up front.
|
||||
2. Run `uv run git commit` (the `uv run` ensures pre-commit finds the right Python).
|
||||
3. If you touched backend code, run `make unit_tests` locally for faster feedback than CI.
|
||||
|
||||
## Pull request guidelines
|
||||
|
||||
- Follow [semantic commit conventions](https://www.conventionalcommits.org/).
|
||||
- Reference issues fixed (`Fixes #1234`).
|
||||
- Target the active `release-X.Y.Z` branch, not `main`. See [CONTRIBUTING.md](./CONTRIBUTING.md).
|
||||
- Don't push or open PRs without explicit user direction.
|
||||
- No "Generated with Claude Code" / `Co-Authored-By: Claude` trailers.
|
||||
- No test-plan checklists or Jira links in PR descriptions.
|
||||
|
||||
## Documentation
|
||||
|
||||
Documentation uses Docusaurus and lives in `docs/`:
|
||||
|
||||
```bash
|
||||
cd docs
|
||||
yarn install
|
||||
yarn start # Dev server on port 3000 (3001 if 3000 is in use)
|
||||
```
|
||||
@@ -0,0 +1,5 @@
|
||||
# CLAUDE.md
|
||||
|
||||
@AGENTS.md
|
||||
|
||||
This project uses [AGENTS.md](https://agents.md/) as the standard for providing context to AI coding agents. The `@AGENTS.md` import above tells Claude Code to load `AGENTS.md` automatically; other tools that natively support `AGENTS.md` will pick it up directly.
|
||||
@@ -0,0 +1,752 @@
|
||||
---
|
||||
version: alpha
|
||||
name: Langflow
|
||||
description: Visual AI workflow builder for composing LLM-powered applications
|
||||
colors:
|
||||
# Core UI palette
|
||||
primary: "#000000"
|
||||
primary-foreground: "#FFFFFF"
|
||||
primary-hover: "#27272A"
|
||||
background: "#FFFFFF"
|
||||
foreground: "#000000"
|
||||
muted: "#F4F4F5"
|
||||
muted-foreground: "#71717A"
|
||||
border: "#E4E4E7"
|
||||
input: "#E4E4E7"
|
||||
ring: "#000000"
|
||||
card: "#FFFFFF"
|
||||
card-foreground: "#000000"
|
||||
popover: "#FFFFFF"
|
||||
popover-foreground: "#000000"
|
||||
secondary: "#FFFFFF"
|
||||
secondary-foreground: "#27272A"
|
||||
secondary-hover: "#E4E4E7"
|
||||
accent: "#F4F4F5"
|
||||
accent-foreground: "#000000"
|
||||
placeholder: "#A1A1AA"
|
||||
placeholder-foreground: "#A1A1AA"
|
||||
tooltip: "#000000"
|
||||
tooltip-foreground: "#FFFFFF"
|
||||
|
||||
# Semantic status
|
||||
destructive: "#DC2626"
|
||||
destructive-foreground: "#FFFFFF"
|
||||
error: "#991B1B"
|
||||
error-background: "#FEF2F2"
|
||||
error-foreground: "#991B1B"
|
||||
success-background: "#F0FDF4"
|
||||
success-foreground: "#14532D"
|
||||
info-background: "#F0F4FD"
|
||||
info-foreground: "#141653"
|
||||
warning: "#FCE68A"
|
||||
warning-foreground: "#18181B"
|
||||
warning-text: "#FFFFFF"
|
||||
|
||||
# Status indicators
|
||||
status-red: "#EF4444"
|
||||
status-green: "#4ADE80"
|
||||
status-yellow: "#EAB308"
|
||||
status-blue: "#2563EB"
|
||||
status-gray: "#6B7280"
|
||||
|
||||
# Accent families (background / foreground pairs)
|
||||
accent-emerald: "#D1F9E4"
|
||||
accent-emerald-foreground: "#047857"
|
||||
accent-emerald-hover: "#A7F3D0"
|
||||
accent-indigo: "#E0E7FF"
|
||||
accent-indigo-foreground: "#4F46E5"
|
||||
accent-pink: "#FCE8F3"
|
||||
accent-pink-foreground: "#BE185D"
|
||||
accent-amber: "#FCE68A"
|
||||
accent-amber-foreground: "#B45309"
|
||||
|
||||
# Standalone accent references (not bg/fg pairs -- used for text, icons, links)
|
||||
accent-blue: "#3B82F6"
|
||||
accent-blue-muted: "#D5E4FF"
|
||||
accent-blue-muted-foreground: "#51A2FF"
|
||||
accent-purple-foreground: "#9333EA"
|
||||
accent-purple-muted: "#EDD5FF"
|
||||
accent-purple-muted-foreground: "#C27AFF"
|
||||
accent-red-foreground: "#DC2626"
|
||||
|
||||
# Indigo scale
|
||||
high-indigo: "#4338CA"
|
||||
medium-indigo: "#6366F1"
|
||||
low-indigo: "#E0E7FF"
|
||||
|
||||
# Canvas and node system
|
||||
canvas: "#F4F4F5"
|
||||
canvas-dot: "#A1A1AA"
|
||||
node-selected: "#4F46E5"
|
||||
node-ring: "#E4E4E7"
|
||||
connection: "#555555"
|
||||
hover: "#F2F4F5"
|
||||
selected: "#2196F3"
|
||||
|
||||
# Code blocks
|
||||
code-background: "#18181B"
|
||||
code-foreground: "#E4E4E7"
|
||||
|
||||
# Utility
|
||||
hard-zinc: "#51515A"
|
||||
smooth-red: "#FDE1E1"
|
||||
|
||||
# Note colors (sticky notes on canvas)
|
||||
note-amber: "#FCE68A"
|
||||
note-neutral: "#E4E4E7"
|
||||
note-rose: "#FECDD3"
|
||||
note-blue: "#BFDBFE"
|
||||
note-lime: "#D9F99D"
|
||||
|
||||
# Beta feature badge
|
||||
beta-background: "#DBEAFE"
|
||||
beta-foreground: "#2563EB"
|
||||
|
||||
# Data type colors (node port type indicators -- light mode)
|
||||
# In light mode: base = saturated, foreground = light tint
|
||||
datatype-pink: "#DB2777"
|
||||
datatype-pink-foreground: "#FBE6F2"
|
||||
datatype-rose: "#E11D48"
|
||||
datatype-rose-foreground: "#FFE4E6"
|
||||
datatype-yellow: "#CA8A04"
|
||||
datatype-yellow-foreground: "#FEF9C3"
|
||||
datatype-blue: "#2563EB"
|
||||
datatype-blue-foreground: "#DBEAFE"
|
||||
datatype-gray: "#4B5563"
|
||||
datatype-gray-foreground: "#F3F4F6"
|
||||
datatype-lime: "#65A30D"
|
||||
datatype-lime-foreground: "#ECFCCB"
|
||||
datatype-red: "#DC2626"
|
||||
datatype-red-foreground: "#FEE2E2"
|
||||
datatype-violet: "#7C3AED"
|
||||
datatype-violet-foreground: "#EDE9FE"
|
||||
datatype-emerald: "#059669"
|
||||
datatype-emerald-foreground: "#D1FAE5"
|
||||
datatype-fuchsia: "#C026D3"
|
||||
datatype-fuchsia-foreground: "#FAE8FF"
|
||||
datatype-purple: "#9333EA"
|
||||
datatype-purple-foreground: "#F3E8FF"
|
||||
datatype-cyan: "#0891B2"
|
||||
datatype-cyan-foreground: "#CFFAFE"
|
||||
datatype-indigo: "#4F46E5"
|
||||
datatype-indigo-foreground: "#E0E7FF"
|
||||
datatype-orange: "#EA580C"
|
||||
datatype-orange-foreground: "#FFEDD5"
|
||||
|
||||
# Gradient / neon accents (flow gradients, tool-mode effects)
|
||||
neon-fuchsia: "#FF3276"
|
||||
digital-orchid: "#F480FF"
|
||||
plasma-purple: "#7C3AED"
|
||||
electric-blue: "#3B10FD"
|
||||
holo-frost: "#9AF3FD"
|
||||
terminal-green: "#9AFDA9"
|
||||
cosmic-void: "#1A0250"
|
||||
|
||||
# Brand-specific UI
|
||||
component-icon: "#D8598A"
|
||||
flow-icon: "#2F67D0"
|
||||
chat-bot-icon: "#AFE6EF"
|
||||
chat-user-icon: "#AFACE9"
|
||||
build-trigger: "#DC735B"
|
||||
chat-trigger: "#5C8BE1"
|
||||
chat-send: "#000000"
|
||||
ice: "#31A3CC"
|
||||
discord: "#5765F2"
|
||||
|
||||
# Sidebar (dark mode only -- light mode inherits core tokens)
|
||||
sidebar-background: "#18181B"
|
||||
sidebar-foreground: "#F4F4F5"
|
||||
sidebar-primary: "#1D4ED8"
|
||||
sidebar-primary-foreground: "#FFFFFF"
|
||||
sidebar-accent: "#27272A"
|
||||
sidebar-accent-foreground: "#F4F4F5"
|
||||
sidebar-border: "#27272A"
|
||||
sidebar-ring: "#3B82F6"
|
||||
|
||||
typography:
|
||||
headline-lg:
|
||||
fontFamily: Inter
|
||||
fontSize: 30px
|
||||
fontWeight: 700
|
||||
lineHeight: 1.2
|
||||
letterSpacing: -0.02em
|
||||
headline-md:
|
||||
fontFamily: Inter
|
||||
fontSize: 24px
|
||||
fontWeight: 600
|
||||
lineHeight: 1.3
|
||||
headline-sm:
|
||||
fontFamily: Inter
|
||||
fontSize: 20px
|
||||
fontWeight: 600
|
||||
lineHeight: 1.4
|
||||
body-lg:
|
||||
fontFamily: Inter
|
||||
fontSize: 16px
|
||||
fontWeight: 400
|
||||
lineHeight: 1.6
|
||||
body-md:
|
||||
fontFamily: Inter
|
||||
fontSize: 14px
|
||||
fontWeight: 400
|
||||
lineHeight: 1.5
|
||||
body-sm:
|
||||
fontFamily: Inter
|
||||
fontSize: 13px
|
||||
fontWeight: 400
|
||||
lineHeight: 1.5
|
||||
body-xs:
|
||||
fontFamily: Inter
|
||||
fontSize: 12px
|
||||
fontWeight: 400
|
||||
lineHeight: 1.5
|
||||
body-xxs:
|
||||
fontFamily: Inter
|
||||
fontSize: 11px
|
||||
fontWeight: 400
|
||||
lineHeight: 1.4
|
||||
label-lg:
|
||||
fontFamily: Inter
|
||||
fontSize: 14px
|
||||
fontWeight: 500
|
||||
lineHeight: 1.4
|
||||
label-md:
|
||||
fontFamily: Inter
|
||||
fontSize: 13px
|
||||
fontWeight: 500
|
||||
lineHeight: 1.4
|
||||
label-sm:
|
||||
fontFamily: Inter
|
||||
fontSize: 12px
|
||||
fontWeight: 500
|
||||
lineHeight: 1.3
|
||||
label-xs:
|
||||
fontFamily: Inter
|
||||
fontSize: 11px
|
||||
fontWeight: 500
|
||||
lineHeight: 1.3
|
||||
code-md:
|
||||
fontFamily: JetBrains Mono
|
||||
fontSize: 14px
|
||||
fontWeight: 400
|
||||
lineHeight: 1.6
|
||||
code-sm:
|
||||
fontFamily: JetBrains Mono
|
||||
fontSize: 12px
|
||||
fontWeight: 400
|
||||
lineHeight: 1.5
|
||||
display:
|
||||
fontFamily: Chivo
|
||||
fontSize: 36px
|
||||
fontWeight: 700
|
||||
lineHeight: 1.1
|
||||
|
||||
rounded:
|
||||
none: 0px
|
||||
sm: 4px
|
||||
md: 6px
|
||||
lg: 8px
|
||||
xl: 12px
|
||||
full: 9999px
|
||||
|
||||
spacing:
|
||||
unit: 4px
|
||||
xs: 4px
|
||||
sm: 8px
|
||||
md: 16px
|
||||
lg: 24px
|
||||
xl: 32px
|
||||
2xl: 48px
|
||||
3xl: 64px
|
||||
|
||||
components:
|
||||
# Buttons
|
||||
button-default:
|
||||
backgroundColor: "{colors.primary}"
|
||||
textColor: "{colors.primary-foreground}"
|
||||
typography: "{typography.label-md}"
|
||||
rounded: "{rounded.lg}"
|
||||
height: 40px
|
||||
padding: 8px 16px
|
||||
button-default-hover:
|
||||
backgroundColor: "{colors.primary-hover}"
|
||||
button-secondary:
|
||||
backgroundColor: "{colors.muted}"
|
||||
textColor: "{colors.secondary-foreground}"
|
||||
typography: "{typography.label-md}"
|
||||
rounded: "{rounded.lg}"
|
||||
height: 40px
|
||||
padding: 8px 16px
|
||||
button-secondary-hover:
|
||||
backgroundColor: "{colors.secondary-hover}"
|
||||
button-destructive:
|
||||
backgroundColor: "{colors.destructive}"
|
||||
textColor: "{colors.destructive-foreground}"
|
||||
typography: "{typography.label-md}"
|
||||
rounded: "{rounded.lg}"
|
||||
height: 40px
|
||||
padding: 8px 16px
|
||||
button-outline:
|
||||
backgroundColor: transparent
|
||||
textColor: "{colors.foreground}"
|
||||
typography: "{typography.label-md}"
|
||||
rounded: "{rounded.lg}"
|
||||
height: 40px
|
||||
padding: 8px 16px
|
||||
button-outline-hover:
|
||||
backgroundColor: "{colors.input}"
|
||||
button-ghost:
|
||||
backgroundColor: transparent
|
||||
textColor: "{colors.foreground}"
|
||||
typography: "{typography.label-md}"
|
||||
rounded: "{rounded.lg}"
|
||||
height: 40px
|
||||
padding: 8px 16px
|
||||
button-ghost-hover:
|
||||
backgroundColor: "{colors.accent}"
|
||||
button-warning:
|
||||
backgroundColor: "{colors.warning-foreground}"
|
||||
textColor: "{colors.warning-text}"
|
||||
typography: "{typography.label-md}"
|
||||
rounded: "{rounded.lg}"
|
||||
height: 40px
|
||||
padding: 8px 16px
|
||||
button-link:
|
||||
backgroundColor: transparent
|
||||
textColor: "{colors.primary}"
|
||||
typography: "{typography.label-md}"
|
||||
button-icon-md:
|
||||
backgroundColor: transparent
|
||||
rounded: "{rounded.md}"
|
||||
padding: 6px
|
||||
button-icon-sm:
|
||||
backgroundColor: transparent
|
||||
rounded: "{rounded.md}"
|
||||
padding: 2px
|
||||
|
||||
# Cards
|
||||
card-default:
|
||||
backgroundColor: "{colors.muted}"
|
||||
textColor: "{colors.card-foreground}"
|
||||
rounded: "{rounded.lg}"
|
||||
padding: 16px
|
||||
card-title:
|
||||
textColor: "{colors.foreground}"
|
||||
typography: "{typography.label-lg}"
|
||||
card-description:
|
||||
textColor: "{colors.muted-foreground}"
|
||||
typography: "{typography.body-md}"
|
||||
|
||||
# Inputs
|
||||
input-field:
|
||||
backgroundColor: "{colors.background}"
|
||||
textColor: "{colors.foreground}"
|
||||
typography: "{typography.body-md}"
|
||||
rounded: "{rounded.lg}"
|
||||
height: 40px
|
||||
padding: 0 12px
|
||||
input-field-hover:
|
||||
backgroundColor: "{colors.background}"
|
||||
input-node:
|
||||
backgroundColor: "{colors.background}"
|
||||
textColor: "{colors.foreground}"
|
||||
typography: "{typography.body-md}"
|
||||
rounded: "{rounded.lg}"
|
||||
padding: 2px 12px
|
||||
|
||||
# Badges
|
||||
badge-default:
|
||||
backgroundColor: "{colors.primary}"
|
||||
textColor: "{colors.primary-foreground}"
|
||||
rounded: "{rounded.full}"
|
||||
padding: 0 10px
|
||||
badge-secondary:
|
||||
backgroundColor: "{colors.muted}"
|
||||
textColor: "{colors.secondary-foreground}"
|
||||
rounded: "{rounded.full}"
|
||||
padding: 0 10px
|
||||
badge-emerald:
|
||||
backgroundColor: "{colors.accent-emerald}"
|
||||
textColor: "{colors.accent-emerald-foreground}"
|
||||
rounded: "{rounded.full}"
|
||||
padding: 0 10px
|
||||
badge-destructive:
|
||||
backgroundColor: "{colors.destructive}"
|
||||
textColor: "{colors.destructive-foreground}"
|
||||
rounded: "{rounded.full}"
|
||||
padding: 0 10px
|
||||
badge-pink:
|
||||
backgroundColor: "{colors.accent-pink}"
|
||||
textColor: "{colors.accent-pink-foreground}"
|
||||
rounded: "{rounded.full}"
|
||||
padding: 0 10px
|
||||
badge-purple:
|
||||
backgroundColor: "{colors.background}"
|
||||
textColor: "{colors.accent-purple-foreground}"
|
||||
rounded: "{rounded.full}"
|
||||
padding: 0 10px
|
||||
badge-error:
|
||||
backgroundColor: "{colors.error-background}"
|
||||
textColor: "{colors.error-foreground}"
|
||||
rounded: "{rounded.full}"
|
||||
padding: 0 10px
|
||||
|
||||
# Tooltips
|
||||
tooltip-default:
|
||||
backgroundColor: "{colors.popover}"
|
||||
textColor: "{colors.popover-foreground}"
|
||||
typography: "{typography.body-md}"
|
||||
rounded: "{rounded.md}"
|
||||
padding: 6px 12px
|
||||
|
||||
# Dialogs
|
||||
dialog-content:
|
||||
backgroundColor: "{colors.background}"
|
||||
textColor: "{colors.foreground}"
|
||||
rounded: "{rounded.xl}"
|
||||
padding: 24px
|
||||
dialog-title:
|
||||
textColor: "{colors.foreground}"
|
||||
typography: "{typography.headline-sm}"
|
||||
dialog-description:
|
||||
textColor: "{colors.muted-foreground}"
|
||||
typography: "{typography.body-md}"
|
||||
|
||||
# Select / Dropdown
|
||||
select-trigger:
|
||||
backgroundColor: transparent
|
||||
textColor: "{colors.primary}"
|
||||
typography: "{typography.body-md}"
|
||||
rounded: "{rounded.lg}"
|
||||
height: 32px
|
||||
padding: 8px 16px
|
||||
select-content:
|
||||
backgroundColor: "{colors.popover}"
|
||||
textColor: "{colors.popover-foreground}"
|
||||
rounded: "{rounded.lg}"
|
||||
padding: 4px
|
||||
select-item:
|
||||
backgroundColor: transparent
|
||||
textColor: "{colors.foreground}"
|
||||
typography: "{typography.body-md}"
|
||||
rounded: "{rounded.sm}"
|
||||
padding: 6px 32px
|
||||
select-item-hover:
|
||||
backgroundColor: "{colors.accent}"
|
||||
|
||||
# Tabs
|
||||
tab-trigger:
|
||||
backgroundColor: transparent
|
||||
textColor: "{colors.muted-foreground}"
|
||||
typography: "{typography.label-md}"
|
||||
padding: 12px 6px
|
||||
tab-trigger-active:
|
||||
textColor: "{colors.primary}"
|
||||
|
||||
# Form controls
|
||||
checkbox:
|
||||
backgroundColor: transparent
|
||||
textColor: "{colors.primary}"
|
||||
rounded: "{rounded.sm}"
|
||||
size: 16px
|
||||
checkbox-checked:
|
||||
backgroundColor: "{colors.primary}"
|
||||
textColor: "{colors.primary-foreground}"
|
||||
switch-track:
|
||||
backgroundColor: "{colors.input}"
|
||||
rounded: "{rounded.full}"
|
||||
width: 44px
|
||||
height: 24px
|
||||
switch-track-checked:
|
||||
backgroundColor: "{colors.primary}"
|
||||
switch-thumb:
|
||||
backgroundColor: "{colors.background}"
|
||||
rounded: "{rounded.full}"
|
||||
size: 16px
|
||||
|
||||
# Alerts
|
||||
alert-default:
|
||||
backgroundColor: "{colors.background}"
|
||||
textColor: "{colors.foreground}"
|
||||
rounded: "{rounded.lg}"
|
||||
padding: 16px
|
||||
alert-destructive:
|
||||
backgroundColor: "{colors.background}"
|
||||
textColor: "{colors.destructive}"
|
||||
rounded: "{rounded.lg}"
|
||||
padding: 16px
|
||||
|
||||
# Canvas nodes
|
||||
node-card:
|
||||
backgroundColor: "{colors.background}"
|
||||
textColor: "{colors.foreground}"
|
||||
rounded: "{rounded.lg}"
|
||||
node-card-selected:
|
||||
backgroundColor: "{colors.background}"
|
||||
node-toolbar:
|
||||
backgroundColor: "{colors.background}"
|
||||
rounded: "{rounded.xl}"
|
||||
padding: 4px
|
||||
node-toolbar-button:
|
||||
backgroundColor: transparent
|
||||
rounded: "{rounded.md}"
|
||||
padding: 6px
|
||||
|
||||
# Sidebar
|
||||
sidebar-menu-button:
|
||||
backgroundColor: transparent
|
||||
textColor: "{colors.secondary-foreground}"
|
||||
typography: "{typography.body-md}"
|
||||
rounded: "{rounded.lg}"
|
||||
height: 32px
|
||||
padding: 8px
|
||||
sidebar-menu-button-active:
|
||||
backgroundColor: "{colors.accent}"
|
||||
textColor: "{colors.accent-foreground}"
|
||||
sidebar-menu-button-hover:
|
||||
backgroundColor: "{colors.accent}"
|
||||
---
|
||||
|
||||
## Overview
|
||||
|
||||
Langflow is a visual AI workflow builder where users compose LLM-powered applications by dragging, connecting, and configuring modular components on an infinite canvas. The design language is that of a professional developer tool -- clean, information-dense, and deliberately restrained. Color is earned, not decorative; every hue in the system carries semantic meaning.
|
||||
|
||||
The aesthetic sits between a code editor and a node-based creative tool. The core UI is strictly monochromatic -- black primary on white, with a warm zinc-gray scale for layering. This neutral foundation lets the **data type color system** (14 distinct hues encoding connection compatibility) and **accent families** (emerald for success, indigo for selection, pink for components) communicate meaning without competing for attention. The result is an interface that feels focused and engineered: complexity emerges from the user's composition, not from the UI itself.
|
||||
|
||||
Light and dark themes are fully supported via class-based toggling (`.dark` on root). The dark theme uses a deep zinc-black (#18181B) as its foundation, not a tinted dark blue, keeping the neutral character consistent across both modes.
|
||||
|
||||
## Colors
|
||||
|
||||
The palette is built in concentric layers: a monochromatic core, semantic accent families, status colors, and the domain-specific data type system.
|
||||
|
||||
### Core Palette
|
||||
|
||||
- **Primary (#000000):** Solid black for primary buttons, text, focus rings, and tooltips. Inverts to white (#FFFFFF) in dark mode. This is the only "strong" color in structural UI.
|
||||
- **Background (#FFFFFF):** Pure white for cards, popovers, dialogs, and content areas. Dark mode uses #18181B (zinc-900).
|
||||
- **Muted (#F4F4F5):** Light zinc-gray for secondary surfaces -- canvas background, card fills, inactive states. Dark mode: #27272A.
|
||||
- **Muted Foreground (#71717A):** Medium gray for captions, descriptions, metadata, and secondary text.
|
||||
- **Border (#E4E4E7):** Subtle zinc border shared by inputs, cards, dividers, and node rings. Dark mode: #3F3F46.
|
||||
- **Placeholder (#A1A1AA):** Input placeholder text and canvas grid dots.
|
||||
|
||||
### Accent Families
|
||||
|
||||
Accents that serve as **background / foreground pairs** for badges, tags, and contextual surfaces. Both colors in each pair must meet WCAG AA contrast (4.5:1):
|
||||
|
||||
- **Emerald** (#D1F9E4 / #047857): Success, completion, enabled states, "built" indicators. Hover: #A7F3D0.
|
||||
- **Indigo** (#E0E7FF / #4F46E5): Node selection, active filters, focus indicators. Also used for the indigo scale (high: #4338CA, medium: #6366F1, low: #E0E7FF).
|
||||
- **Pink** (#FCE8F3 / #BE185D): Component icons, feminine-coded accents, static badges.
|
||||
- **Amber** (#FCE68A / #B45309): Warnings, caution badges. The background is a pale yellow; the foreground is burnt orange.
|
||||
|
||||
**Standalone accent references** (used for text color, icons, and links -- not as bg/fg pairs):
|
||||
|
||||
- **Blue** (#3B82F6): Links, informational highlights, chat triggers, sidebar primary. Always used on a white/dark background, never as a surface fill. Muted variant (#D5E4FF) available for subtle backgrounds.
|
||||
- **Purple** (#9333EA): AI/agent-related highlights. Muted variant (#EDD5FF) for subtle backgrounds.
|
||||
- **Red** (#DC2626): Error-related text and icons on neutral surfaces.
|
||||
|
||||
### Status Colors
|
||||
|
||||
Used for build status indicators, connection health, and real-time feedback dots:
|
||||
|
||||
- **Red** (#EF4444): Error, failed, disconnected.
|
||||
- **Green** (#4ADE80): Success, connected, running.
|
||||
- **Yellow** (#EAB308): Warning, building, pending.
|
||||
- **Blue** (#2563EB): Info, selected, active.
|
||||
- **Gray** (#6B7280): Inactive, unknown, disabled.
|
||||
|
||||
### Data Type Color System
|
||||
|
||||
Langflow uses **14 distinct hues** to encode the type of data flowing through node connections. Each type has a saturated foreground and a light-tint background. In **light mode**, the base token is the saturated color (used for port dots and connection lines) and the `-foreground` token is the light tint (used for backgrounds). In **dark mode, these roles swap** -- the base becomes the light tint and the foreground becomes the saturated color. This ensures readability against both light and dark surfaces.
|
||||
|
||||
| Data Type | Color Name | Hex (saturated) | Used For |
|
||||
|:----------|:-----------|:-----------------|:---------|
|
||||
| str / Text / Message | indigo | #4F46E5 | String data, text, messages |
|
||||
| Document | lime | #65A30D | Document objects |
|
||||
| Data / JSON | red | #DC2626 | Structured data, JSON |
|
||||
| Embeddings | emerald | #059669 | Vector embeddings |
|
||||
| LanguageModel | fuchsia | #C026D3 | LLM model objects |
|
||||
| Prompt | violet | #7C3AED | Prompt templates |
|
||||
| Tool | cyan | #0891B2 | Tool definitions |
|
||||
| Agent | purple | #9333EA | Agent objects |
|
||||
| number | purple | #9333EA | Numeric values |
|
||||
| DataFrame / Table | pink | #DB2777 | Tabular data |
|
||||
| chains | orange | #EA580C | Chain compositions |
|
||||
| memories | yellow | #CA8A04 | Memory objects |
|
||||
| unknown | gray | #4B5563 | Untyped or unknown |
|
||||
| inputs | emerald | #059669 | Input components |
|
||||
|
||||
### Note Colors
|
||||
|
||||
Sticky notes on the canvas use soft pastel backgrounds: amber (#FCE68A), neutral (#E4E4E7), rose (#FECDD3), blue (#BFDBFE), lime (#D9F99D).
|
||||
|
||||
### Neon / Gradient Accents
|
||||
|
||||
Used for flow icon gradients, tool-mode indicators, and decorative color swatches. These are high-saturation, high-energy colors intentionally outside the normal UI palette:
|
||||
|
||||
- **Neon Fuchsia** (#FF3276) to **Digital Orchid** (#F480FF): Tool-mode gradient.
|
||||
- **Plasma Purple** (#7C3AED), **Electric Blue** (#3B10FD): Deep saturated anchors.
|
||||
- **Holo Frost** (#9AF3FD), **Terminal Green** (#9AFDA9): Light luminous accents.
|
||||
- **Cosmic Void** (#1A0250): Ultra-dark purple for contrast backgrounds.
|
||||
|
||||
## Typography
|
||||
|
||||
Three font families serve distinct roles in the interface.
|
||||
|
||||
- **Inter** (sans-serif, variable, 14-32 optical size): The primary UI typeface. Used for all headings, body text, labels, navigation, buttons, and form elements. Its large optical-size range and variable weight (100-900) provide precise control without font-switching. The neutral, geometric character keeps the UI professional and out of the way.
|
||||
|
||||
- **JetBrains Mono** (monospace, variable): Used exclusively for code -- code blocks, JSON editors, API keys, component IDs, and any machine-readable content. Its programming ligatures and distinct character shapes (especially `0` vs `O`, `1` vs `l`) aid readability in dense technical contexts. Never used for UI labels or headings.
|
||||
|
||||
- **Chivo** (display, variable): Reserved for marketing-facing or splash contexts (landing pages, onboarding screens). Its slightly condensed, geometric character adds personality at large sizes. Never used within the application workspace.
|
||||
|
||||
The type scale is compact. The application uses 11px (`xxs`) through 16px (`base`) for the vast majority of UI. Headlines rarely exceed 24px inside the workspace. Density is preferred over visual hierarchy through size alone -- hierarchy is communicated through weight, color, and spatial grouping.
|
||||
|
||||
## Layout
|
||||
|
||||
The application follows a **sidebar + infinite canvas** model. A collapsible left sidebar (19rem when expanded, 4rem when collapsed to icons) holds navigation, component search, and category panels. The main area is a pannable, zoomable ReactFlow canvas where nodes live.
|
||||
|
||||
Spacing follows a strict **4px base grid**:
|
||||
|
||||
- **4px** (`xs`): Micro-adjustments -- icon padding within buttons, badge margins, tight inline spacing.
|
||||
- **8px** (`sm`): Standard internal padding for compact components, gaps between inline elements.
|
||||
- **16px** (`md`): Card content padding, standard gaps between sibling elements, default component padding.
|
||||
- **24px** (`lg`): Section margins, dialog padding, sidebar header/footer padding.
|
||||
- **32px** (`xl`): Major layout divisions, generous whitespace between sections.
|
||||
- **48px - 64px** (`2xl` - `3xl`): Page-level margins, hero spacing.
|
||||
|
||||
**Breakpoints:**
|
||||
- `mdd: 45rem` (720px) -- medium-density layout shift
|
||||
- `xl: 1200px` -- wide layout
|
||||
- `2xl: 1400px` -- container max-width
|
||||
- `3xl: 1500px` -- extra-wide
|
||||
|
||||
The canvas itself is unbounded -- nodes can be placed anywhere. The sidebar is the only fixed-width structural element.
|
||||
|
||||
## Elevation & Depth
|
||||
|
||||
Elevation is minimal and functional. The design avoids heavy shadows, instead using **border-based containment** and **tonal shifts** to communicate layering. Depth is communicated through three levels:
|
||||
|
||||
### Level 0: Canvas
|
||||
|
||||
The infinite workspace background. A dotted grid pattern provides spatial orientation without visual noise.
|
||||
|
||||
- **Light mode:** Zinc-gray (#F4F4F5) background with medium-gray dots (#A1A1AA).
|
||||
- **Dark mode:** Pure black (#000000) background with dark zinc dots (#3F3F46).
|
||||
|
||||
### Level 1: Nodes & Cards
|
||||
|
||||
Content containers that sit on the canvas. White background with a soft ambient shadow.
|
||||
|
||||
- **Shadow:** `0 0px 15px -3px rgba(0,0,0,0.1), 0 0px 6px -4px rgba(0,0,0,0.1)` -- barely perceptible, just enough to lift nodes off the grid.
|
||||
- **Selected state:** An indigo ring (#4F46E5) replaces the default border, immediately communicating focus.
|
||||
- **Frozen state:** A special icy glow effect -- `0 0 10px 2px rgba(128,190,230,0.5)` shadow with a 2px `rgba(128,190,219,0.86)` border and frosted overlay. Signals the node is locked from editing.
|
||||
|
||||
### Level 2: Popovers & Modals
|
||||
|
||||
Overlaid surfaces that demand attention. White background with `shadow-lg` and a border.
|
||||
|
||||
- **Entry animation:** Scale from 0.95 with a clip-path reveal, 400ms duration, `cubic-bezier(0.16, 1, 0.3, 1)` easing (spring-like overshoot).
|
||||
- **Exit animation:** Reverse at 500ms for a slightly more deliberate dismissal.
|
||||
- **Overlay:** Semi-transparent background to dim the canvas.
|
||||
|
||||
### Active States
|
||||
|
||||
Interactive feedback is communicated through micro-animations rather than shadow changes:
|
||||
|
||||
- **Button press:** `active:scale-[0.97]` -- a subtle inward squeeze.
|
||||
- **Hover:** Background color shift to accent or muted, never shadow addition.
|
||||
- **Focus ring:** 1px ring using the `ring` token (black in light, white in dark).
|
||||
|
||||
## Shapes
|
||||
|
||||
The shape language is subtly rounded -- not pill-shaped, not sharp. The base radius is **8px** (`--radius: 0.5rem`), which gives cards, buttons, and containers a modern but not toy-like feel.
|
||||
|
||||
- **lg (8px):** The default. Cards, modals, buttons, inputs, dropdown content, node containers.
|
||||
- **md (6px):** Nested or secondary elements -- tooltip content, compact controls, dialog close buttons.
|
||||
- **sm (4px):** Tight elements -- badges, checkbox corners, compact inline inputs, select items.
|
||||
- **xl (12px):** Prominent containers -- dialog content, node toolbars, sidebar inset panels.
|
||||
- **full (9999px):** Circular elements -- avatars, status dots, switch tracks, scrollbar thumbs, badge pills.
|
||||
|
||||
Borders are thin. Standard weight is 1px. Occasional 1.5px or 1.75px for emphasis on selected or focus states. All borders use the `border` token color -- never a hard black or white line.
|
||||
|
||||
## Components
|
||||
|
||||
### Buttons
|
||||
|
||||
The button system uses 7 semantic variants:
|
||||
|
||||
- **Default (primary):** Solid black background, white text. The highest-emphasis action on any screen. Hover darkens to #27272A.
|
||||
- **Secondary:** Muted background with border, dark text. For secondary actions alongside a primary button.
|
||||
- **Destructive:** Red background, white text. Reserved for delete, remove, and irreversible actions.
|
||||
- **Outline:** Transparent with border. For medium-emphasis actions. Hover fills with the input color.
|
||||
- **Ghost:** Transparent, no border. For toolbar actions and icon buttons. Hover fills with accent background.
|
||||
- **Warning:** Dark warning-foreground background. For caution-related actions.
|
||||
- **Link:** Underlined text with no background. For inline text links.
|
||||
|
||||
All buttons share: 40px default height, `rounded-lg` (8px), `font-medium` (500 weight), `text-sm` (14px). Focus state is a 1px ring. Disabled state is `opacity-70` with `pointer-events-none`. The `active:scale-[0.97]` press effect is optional but recommended for primary actions.
|
||||
|
||||
**Size variants:** `lg` (44px), `default` (40px), `md` (32px), `sm` (36px), `xs` (compact), and three icon sizes (`iconMd` 6px padding, `icon` 4px padding, `iconSm` 2px padding).
|
||||
|
||||
### Badges
|
||||
|
||||
Inline status indicators and tags. Always pill-shaped (`rounded-full`) with `font-semibold` text. Available in 6 semantic variants: default (primary fill), gray, secondary (muted fill), destructive, emerald (success), pink, purple (outlined), and error (light red fill). Three size tiers: `sm` (16px height, 12px text), `md` (20px height, 14px text), `lg` (24px height, 16px text), plus `tag` (18px height, 11px text) for compact inline labels.
|
||||
|
||||
### Cards
|
||||
|
||||
Content containers on surfaces. `rounded-lg` border on muted background with `shadow-sm`. Internal structure: header (16px padding, title in semi-bold 16px, optional description in muted 14px), content area (16px horizontal padding), footer (16px padding, flex row). The title uses `leading-tight tracking-tight` for density.
|
||||
|
||||
### Inputs
|
||||
|
||||
Text inputs use a bordered white background with rounded-lg corners. Placeholder text is in muted gray (#A1A1AA). Three interaction states: default (border color), hover (border darkens to `muted-foreground`), focus (border goes to `foreground`, placeholder becomes transparent). Disabled inputs get muted background and reduced text. A compact `input-edit-node` variant is used for inline editing within nodes.
|
||||
|
||||
### Selects & Dropdowns
|
||||
|
||||
Select triggers are 32px height with border and right-aligned chevron. Dropdown content uses the popover surface with `shadow-md`, `rounded-md`, and slide-in animation from the opening side. Items highlight with the accent background on focus. Separators use a 1px muted line.
|
||||
|
||||
### Dialogs & Modals
|
||||
|
||||
Fixed-position overlays with a white `rounded-xl` container, 24px padding, and `shadow-lg`. Enter with a 400ms spring animation (scale 0.95 to 1.0 with clip-path reveal). Close button in the top-right corner. Title in 18px semi-bold, description in muted 14px. Footer uses `flex-row` with right-aligned actions.
|
||||
|
||||
### Tabs
|
||||
|
||||
Inline tab bars with no background. The active tab gets a 2px bottom border in `currentColor` (primary). Inactive tabs show text in `muted-foreground`, hovering to `primary`. No rounded backgrounds on tab triggers -- the underline is the sole active indicator.
|
||||
|
||||
### Form Controls
|
||||
|
||||
**Checkbox:** 16px square, `rounded-sm` (4px), border in muted-foreground. Checked state fills with primary and shows a white check icon. **Switch:** 44x24px track with a 16px thumb. Unchecked track uses the `input` color; checked track uses `primary`. Thumb slides 20px with a transition. **Radio:** 16px circle with primary border, filled center dot when selected.
|
||||
|
||||
### Tooltips
|
||||
|
||||
Bordered popover surface (not solid black as one might expect) with `rounded-md`, `shadow-md`, 14px text, and directional slide-in animation. Content is `z-[99]` to sit above all other UI.
|
||||
|
||||
### Alerts
|
||||
|
||||
Rounded bordered containers with icon + text layout. Default variant uses standard foreground colors. Destructive variant tints the border and text with the destructive red, and the icon inherits that color.
|
||||
|
||||
### Canvas Nodes
|
||||
|
||||
The core UI element of Langflow. Each node is a white rounded card on the canvas containing typed input/output ports, configuration fields, and a status indicator.
|
||||
|
||||
- **Port dots** use the data type color system to visually encode connection compatibility. When two ports share a compatible type, their colors match, providing immediate visual feedback.
|
||||
- **Selected nodes** show an indigo ring (`node-selected: #4F46E5`) replacing the standard border.
|
||||
- **Frozen nodes** display the icy glow effect with a frosted overlay, signaling they are locked from modification.
|
||||
- **Node toolbar** appears on hover/selection: a floating `rounded-xl` container with icon buttons, 1px border, and `shadow-sm`.
|
||||
- **Status indicators:** Green (built successfully), red (error), yellow (building), gray (idle). A spinner animation shows during active builds.
|
||||
|
||||
### Sidebar
|
||||
|
||||
Collapsible panel (19rem expanded, 4rem icon-only) with smooth 300ms width transitions. Menu buttons are 32px height, `rounded-md`, with accent background on hover and active states. Supports nested sub-menus with left border indicators. In dark mode, the sidebar has its own dedicated color tokens (darker surface, brighter primary) for visual separation from the canvas.
|
||||
|
||||
## Do's and Don'ts
|
||||
|
||||
- Do use the monochromatic core palette for all structural UI -- reserve color for semantic meaning
|
||||
- Do maintain WCAG AA contrast ratios (4.5:1 for normal text, 3:1 for large text and interactive elements)
|
||||
- Do use Inter for all application UI text; JetBrains Mono only for code and machine-readable content
|
||||
- Do keep data type colors visually distinct -- they encode connection compatibility and must be recognizable at small sizes (port dots, connection lines)
|
||||
- Do support both light and dark themes -- every color token must have a dark-mode equivalent
|
||||
- Do use the 4px base grid for all spacing decisions
|
||||
- Don't introduce new accent colors without a clear semantic role in the system
|
||||
- Don't use shadows heavier than the node shadow -- the UI should feel flat, layered through tone rather than depth
|
||||
- Don't mix border-radius values within the same visual group (all buttons in a toolbar share the same radius)
|
||||
- Don't use Chivo inside the application workspace -- it is reserved for marketing and onboarding contexts
|
||||
- Don't use opacity below 0.7 for disabled states -- the element must remain readable
|
||||
- Don't use solid color fills for hover states on ghost/outline buttons -- use the accent or muted surface tokens
|
||||
- Don't place data type colors on structural UI elements (borders, backgrounds) outside of the node/port system
|
||||
@@ -171,7 +171,7 @@ unit_tests_looponfail:
|
||||
lfx_tests: ## run lfx package unit tests
|
||||
@echo 'Running LFX Package Tests...'
|
||||
@cd src/lfx && \
|
||||
uv sync && \
|
||||
uv sync --dev && \
|
||||
uv run pytest tests/unit -v --cov=src/lfx --cov-report=xml --cov-report=html --cov-report=term-missing $(args)
|
||||
|
||||
integration_tests:
|
||||
@@ -398,14 +398,13 @@ dcdev_up:
|
||||
$(DOCKER) compose -f docker/dev.docker-compose.yml up --remove-orphans
|
||||
|
||||
lock_base:
|
||||
cd src/backend/base && uv lock
|
||||
uv lock
|
||||
|
||||
lock_flow:
|
||||
uv lock
|
||||
|
||||
lock: ## lock dependencies
|
||||
@echo 'Locking dependencies'
|
||||
cd src/backend/base && uv lock
|
||||
uv lock
|
||||
|
||||
update: ## update dependencies
|
||||
@@ -490,6 +489,38 @@ lfx_docker_test: ## run LFX tests in Docker
|
||||
@echo 'Running LFX tests in Docker'
|
||||
@cd src/lfx && make docker_test
|
||||
|
||||
######################
|
||||
# SDK PACKAGE
|
||||
######################
|
||||
|
||||
sdk_build: ## build the SDK package
|
||||
@echo 'Building SDK package'
|
||||
@cd src/sdk && make build
|
||||
|
||||
sdk_publish: ## publish SDK package to PyPI
|
||||
@echo 'Publishing SDK package'
|
||||
@cd src/sdk && make publish
|
||||
|
||||
sdk_publish_testpypi: ## publish SDK package to test PyPI
|
||||
@echo 'Publishing SDK package to test PyPI'
|
||||
@cd src/sdk && make publish_test
|
||||
|
||||
sdk_test: ## run SDK tests
|
||||
@echo 'Running SDK tests'
|
||||
@cd src/sdk && make test
|
||||
|
||||
sdk_format: ## format SDK code
|
||||
@echo 'Formatting SDK code'
|
||||
@cd src/sdk && make format
|
||||
|
||||
sdk_lint: ## lint SDK code
|
||||
@echo 'Linting SDK code'
|
||||
@cd src/sdk && make lint
|
||||
|
||||
sdk_clean: ## clean SDK build artifacts
|
||||
@echo 'Cleaning SDK build artifacts'
|
||||
@cd src/sdk && make clean
|
||||
|
||||
# example make alembic-revision message="Add user table"
|
||||
alembic-revision: ## generate a new migration
|
||||
@echo 'Generating a new Alembic revision'
|
||||
@@ -842,7 +873,7 @@ help_backend: ## show backend-specific commands
|
||||
@echo "$(GREEN)Code Quality:$(NC)"
|
||||
@echo " $(GREEN)make format_backend$(NC) - Format backend code (ruff)"
|
||||
@echo " $(GREEN)make format_frontend_check$(NC) - Check frontend formatting (biome)"
|
||||
@echo " $(GREEN)make lint$(NC) - Run backend linters (mypy)"
|
||||
@echo " $(GREEN)make lint$(NC) - Run backend linters"
|
||||
@echo " $(GREEN)make codespell$(NC) - Check spelling errors"
|
||||
@echo " $(GREEN)make fix_codespell$(NC) - Fix spelling errors automatically"
|
||||
@echo " $(GREEN)make unsafe_fix$(NC) - Run ruff with unsafe fixes"
|
||||
@@ -877,6 +908,14 @@ help_backend: ## show backend-specific commands
|
||||
@echo " $(GREEN)make lfx_docker_dev$(NC) - Start LFX development environment"
|
||||
@echo " $(GREEN)make lfx_docker_test$(NC) - Run LFX tests in Docker"
|
||||
@echo ''
|
||||
@echo "$(GREEN)SDK Package Commands:$(NC)"
|
||||
@echo " $(GREEN)make sdk_build$(NC) - Build SDK package"
|
||||
@echo " $(GREEN)make sdk_test$(NC) - Run SDK tests"
|
||||
@echo " $(GREEN)make sdk_format$(NC) - Format SDK code"
|
||||
@echo " $(GREEN)make sdk_lint$(NC) - Lint SDK code"
|
||||
@echo " $(GREEN)make sdk_clean$(NC) - Clean SDK build artifacts"
|
||||
@echo " $(GREEN)make sdk_publish$(NC) - Publish SDK to PyPI"
|
||||
@echo ''
|
||||
@echo "$(GREEN)═══════════════════════════════════════════════════════════════════$(NC)"
|
||||
@echo ''
|
||||
|
||||
@@ -902,6 +941,7 @@ help_test: ## show testing commands
|
||||
@echo "$(GREEN)Combined Tests:$(NC)"
|
||||
@echo " $(GREEN)make tests$(NC) - Run all tests (unit + integration + coverage)"
|
||||
@echo " $(GREEN)make coverage$(NC) - Run tests and generate coverage report"
|
||||
@echo " $(GREEN)make test_frontend_coverage_full$(NC) - Run tests and generate coverage report"
|
||||
@echo ''
|
||||
@echo "$(GREEN)Frontend Tests:$(NC)"
|
||||
@echo " $(GREEN)make tests_frontend$(NC) - Run Playwright e2e tests"
|
||||
@@ -919,6 +959,9 @@ help_test: ## show testing commands
|
||||
@echo " $(GREEN)make test_frontend_snapshots$(NC) - Update Jest snapshots"
|
||||
@echo " $(GREEN)make test_frontend_config$(NC) - Show Jest configuration"
|
||||
@echo ''
|
||||
@echo "$(GREEN)Combined Frontend Test Coverage:$(NC)"
|
||||
@echo " $(GREEN)make test_frontend_coverage_full$(NC) - Run frontend tests and generate coverage report"
|
||||
@echo ''
|
||||
@echo "$(GREEN)Load Testing:$(NC)"
|
||||
@echo " $(GREEN)make locust$(NC) - Run locust load tests"
|
||||
@echo " Options: locust_users=10 locust_spawn_rate=1 locust_host=http://localhost:7860"
|
||||
@@ -979,6 +1022,8 @@ help_advanced: ## show advanced and miscellaneous commands
|
||||
@echo " $(GREEN)make publish_flow$(NC) - Publish langflow to PyPI"
|
||||
@echo " $(GREEN)make lfx_publish$(NC) - Publish LFX package to PyPI"
|
||||
@echo " $(GREEN)make lfx_publish_testpypi$(NC) - Publish LFX to test PyPI"
|
||||
@echo " $(GREEN)make sdk_publish$(NC) - Publish SDK package to PyPI"
|
||||
@echo " $(GREEN)make sdk_publish_testpypi$(NC) - Publish SDK to test PyPI"
|
||||
@echo ''
|
||||
@echo "$(GREEN)Lock Files:$(NC)"
|
||||
@echo " $(GREEN)make lock$(NC) - Lock all dependencies"
|
||||
@@ -1026,6 +1071,18 @@ docs_serve: docs_build ## build and serve documentation locally
|
||||
@echo "$(GREEN)Serving built documentation...$(NC)"
|
||||
@cd docs && npm run serve -- --port $(docs_port)
|
||||
|
||||
# Comma-separated list; override e.g. suites=curl,javascript,python
|
||||
# Note: $(or $(suites),a,b,c) is wrong here — GNU make's `or` returns only the first non-empty token.
|
||||
suites ?= curl,python,javascript
|
||||
|
||||
api_examples_local: ## run docs API sample files against a local Langflow server
|
||||
@echo "$(GREEN)Running docs API examples locally...$(NC)"
|
||||
@SUITES="$(suites)" EXECUTE_MODE=true ./scripts/test-api-examples-local.sh
|
||||
|
||||
api_examples_local_syntax: ## syntax-check docs API sample files locally without execution
|
||||
@echo "$(GREEN)Running docs API example syntax checks locally...$(NC)"
|
||||
@SUITES="$(suites)" EXECUTE_MODE=false ./scripts/test-api-examples-local.sh
|
||||
|
||||
######################
|
||||
# INCLUDE FRONTEND MAKEFILE
|
||||
######################
|
||||
|
||||
@@ -166,6 +166,39 @@ test_frontend_coverage_open: test_frontend_coverage ## run tests with coverage a
|
||||
echo "Coverage report generated at: $(FRONTEND_DIR)/coverage/lcov-report/index.html"; \
|
||||
fi
|
||||
|
||||
######################
|
||||
# FRONTEND TESTS COVERAGE (JEST + PLAYWRIGHT COMBINED )
|
||||
######################
|
||||
# Run full frontend tests (Jest + Playwright) with coverage and merge into HTML
|
||||
test_frontend_coverage_full: frontend_deps_check
|
||||
@echo "=== Running Jest unit tests with coverage ==="
|
||||
@(cd $(FRONTEND_DIR) && npx jest --coverage); echo $$? > /tmp/jest_exit
|
||||
|
||||
@echo "=== Running Playwright E2E tests ==="
|
||||
@(cd $(FRONTEND_DIR) && CI=1 npx playwright test --project=chromium); echo $$? > /tmp/playwright_exit
|
||||
|
||||
@echo "=== Merging coverage reports ==="
|
||||
@mkdir -p $(FRONTEND_DIR)/coverage/combined
|
||||
|
||||
# Merge Playwright raw coverage files
|
||||
@cd $(FRONTEND_DIR) && npx nyc merge coverage/playwright/individual-test coverage/combined/playwright.json || echo "Failed to merge Playwright coverage"
|
||||
# Generate Playwright HTML report
|
||||
@cd $(FRONTEND_DIR) && npx nyc report --reporter=html --reporter=text --temp-dir coverage/playwright/individual-test --report-dir coverage/playwright/html-report || echo "Failed to generate Playwright HTML coverage"
|
||||
# Migrate Jest coverage
|
||||
@cp $(FRONTEND_DIR)/coverage/jest/coverage-final.json $(FRONTEND_DIR)/coverage/combined/jest.json || echo "Failed to migrate Jest coverage"
|
||||
# Merge both into final
|
||||
@cd $(FRONTEND_DIR) && npx nyc merge coverage/combined coverage/combined/total-frontend-coverage.json || echo "Failed to combine Playwright + Jest coverage"
|
||||
|
||||
@echo "=== Generating HTML coverage report ==="
|
||||
@cd $(FRONTEND_DIR) && npx nyc report --reporter=html --reporter=text --temp-dir coverage/combined --report-dir coverage/final-coverage-report || echo "Failed to generate Playwright + Jest coverage html report"
|
||||
|
||||
|
||||
@echo "=== Checking test results ==="
|
||||
@JEST=$$(cat /tmp/jest_exit); \
|
||||
PLAY=$$(cat /tmp/playwright_exit); \
|
||||
rm -f /tmp/jest_exit /tmp/playwright_exit; \
|
||||
test $$JEST -eq 0 -a $$PLAY -eq 0
|
||||
|
||||
######################
|
||||
# STORYBOOK
|
||||
######################
|
||||
|
||||
+2
-2
@@ -6,8 +6,8 @@ This security policy applies to the Hanzo Flow project on GitHub. We prioritize
|
||||
|
||||
### Security/Bugfix Versions
|
||||
|
||||
- Fixes are released either as part of the next minor version (e.g., 1.3.0 → 1.4.0) or as an on-demand patch version (e.g., 1.3.0 → 1.3.1)
|
||||
- Security fixes are given priority and might be enough to cause a new version to be released
|
||||
- Fixes are released either as part of the next minor version (e.g., 1.3.0 → 1.4.0) or as an on-demand patch version (e.g., 1.3.0 → 1.3.1).
|
||||
- Security fixes are given priority and might be enough to cause a new version to be released.
|
||||
|
||||
## Reporting a Vulnerability
|
||||
|
||||
|
||||
@@ -0,0 +1,381 @@
|
||||
# Can We Skip CI for Pre-Release Builds? Analysis
|
||||
|
||||
**Issue:** [LE-517](https://datastax.jira.com/browse/LE-517)
|
||||
**Problem:** Pre-release builds (PyPI + Docker) require 45-60 min CI wait
|
||||
|
||||
---
|
||||
|
||||
## Current Workflow
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ Phase 1: Build Packages (15-20 min) - NO CI DEPENDENCY │
|
||||
├─────────────────────────────────────────────────────────────┤
|
||||
│ • build-lfx │
|
||||
│ • build-base │
|
||||
│ • build-main │
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
↓
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ Phase 2: Cross-Platform Tests (15 min) - NO CI DEPENDENCY │
|
||||
├─────────────────────────────────────────────────────────────┤
|
||||
│ • Test on Linux (amd64, arm64) │
|
||||
│ • Test on macOS (amd64, arm64) │
|
||||
│ • Test on Windows (amd64) │
|
||||
│ • Python 3.10, 3.12, 3.13 │
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
↓
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ Phase 3: CI Suite (45-60 min) ⚠️ BOTTLENECK │
|
||||
├─────────────────────────────────────────────────────────────┤
|
||||
│ • Backend tests (Python 3.10-3.13, 5 groups = 20 jobs) │
|
||||
│ • Frontend E2E tests (Playwright) │
|
||||
│ • Frontend unit tests (Jest) │
|
||||
│ • Linting (mypy) │
|
||||
│ • Docs build │
|
||||
│ • Template tests │
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
↓
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ Phase 4: Publish & Build (12-17 min) - BLOCKED BY CI │
|
||||
├─────────────────────────────────────────────────────────────┤
|
||||
│ • publish-base (line 602: needs ci) │
|
||||
│ • publish-main (line 625: needs ci) │
|
||||
│ • publish-lfx (line 648: needs ci) │
|
||||
│ • call_docker_build_base (line 671: needs ci) │
|
||||
│ • call_docker_build_main (line 683: needs ci) │
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
|
||||
TOTAL TIME: 87-112 minutes
|
||||
TIME SPENT WAITING FOR CI: 45-60 minutes (53-67% of total time)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## The Core Question
|
||||
|
||||
**Can we skip CI and still safely publish pre-releases?**
|
||||
|
||||
### What CI Validates
|
||||
|
||||
| Test Type | Time | Catches | Critical for Pre-Release? |
|
||||
|-----------|------|---------|---------------------------|
|
||||
| **Backend Unit Tests** | 15-20 min | Logic errors, edge cases | ⚠️ MEDIUM |
|
||||
| **Frontend E2E Tests** | 20-30 min | UI workflows, integrations | ⚠️ MEDIUM |
|
||||
| **Frontend Unit Tests** | 5-10 min | Component logic | ⚠️ LOW |
|
||||
| **Linting (mypy)** | 5 min | Type errors | ❌ LOW |
|
||||
| **Docs Build** | 5 min | Documentation issues | ❌ NONE |
|
||||
| **Template Tests** | 5 min | Starter project issues | ❌ LOW |
|
||||
|
||||
### What Cross-Platform Tests Already Validate
|
||||
|
||||
✅ **Installation works** on all platforms
|
||||
✅ **Dependencies resolve** correctly
|
||||
✅ **Basic imports** don't fail
|
||||
✅ **Server starts** successfully
|
||||
|
||||
**Key Insight:** Cross-platform tests already catch the most critical issues (broken installation, missing dependencies, import errors).
|
||||
|
||||
---
|
||||
|
||||
## Risk Analysis
|
||||
|
||||
### If We Skip CI for Pre-Releases
|
||||
|
||||
**What Could Go Wrong:**
|
||||
|
||||
1. **Broken functionality** (Medium risk)
|
||||
- Some features don't work
|
||||
- Customer discovers during testing
|
||||
- **Mitigation:** That's the point of pre-release testing
|
||||
|
||||
2. **Integration issues** (Low risk)
|
||||
- Components don't work together
|
||||
- Customer discovers during testing
|
||||
- **Mitigation:** Pre-release is for finding these issues
|
||||
|
||||
3. **Type errors** (Low risk)
|
||||
- Runtime errors from type mismatches
|
||||
- May or may not surface
|
||||
- **Mitigation:** Not critical for pre-release
|
||||
|
||||
4. **Complete failure** (Very low risk)
|
||||
- Package won't install or start
|
||||
- **Already caught by cross-platform tests**
|
||||
- Cross-platform tests include server startup validation
|
||||
|
||||
**What Won't Go Wrong:**
|
||||
|
||||
❌ Installation failures → Caught by cross-platform tests
|
||||
❌ Missing dependencies → Caught by cross-platform tests
|
||||
❌ Import errors → Caught by cross-platform tests
|
||||
❌ Server won't start → Caught by cross-platform tests
|
||||
|
||||
---
|
||||
|
||||
## The Real Question
|
||||
|
||||
**Not "Can we skip CI?" but "What's the purpose of pre-releases?"**
|
||||
|
||||
### Pre-Release Purpose
|
||||
|
||||
Pre-releases exist to:
|
||||
1. Get customer feedback on new features
|
||||
2. Test in real-world environments
|
||||
3. Find integration issues
|
||||
4. Validate before production release
|
||||
|
||||
**Key Point:** Customers testing pre-releases EXPECT to find issues. That's why they're testing.
|
||||
|
||||
### Production Release vs Pre-Release
|
||||
|
||||
| Aspect | Production Release | Pre-Release |
|
||||
|--------|-------------------|-------------|
|
||||
| **Quality Bar** | Must be stable | Can have issues |
|
||||
| **Testing** | Exhaustive | Sufficient |
|
||||
| **Speed** | Can wait | Time-sensitive |
|
||||
| **Risk Tolerance** | Zero | Acceptable |
|
||||
| **CI Required** | YES | DEBATABLE |
|
||||
|
||||
---
|
||||
|
||||
## Scenarios
|
||||
|
||||
### Scenario 1: Code from RC Branch ✅ SAFE TO SKIP
|
||||
|
||||
**Context:** Release candidate branch that already passed CI
|
||||
|
||||
**Analysis:**
|
||||
- Code was validated when merged to RC
|
||||
- No changes since last CI run
|
||||
- Just packaging existing tested code
|
||||
|
||||
**Risk:** **VERY LOW** (code already tested)
|
||||
|
||||
**Recommendation:** **Skip CI entirely**
|
||||
|
||||
---
|
||||
|
||||
### Scenario 2: New Bug Fix ⚠️ ACCEPTABLE RISK
|
||||
|
||||
**Context:** Urgent fix needs customer validation
|
||||
|
||||
**Analysis:**
|
||||
- Cross-platform tests catch critical issues
|
||||
- Customers expect pre-releases may have issues
|
||||
- Faster feedback loop (hours vs days)
|
||||
- Can rollback if needed
|
||||
|
||||
**Risk:** **MEDIUM** (untested code, but pre-release context)
|
||||
|
||||
**Recommendation:** **Skip CI with notification**
|
||||
|
||||
---
|
||||
|
||||
### Scenario 3: Major Refactoring ❌ DON'T SKIP
|
||||
|
||||
**Context:** Large code changes, architectural changes
|
||||
|
||||
**Risk:** **HIGH** (many potential issues)
|
||||
|
||||
**Recommendation:** **Run full CI**
|
||||
|
||||
---
|
||||
|
||||
## Recommendation
|
||||
|
||||
### YES, we can skip CI for pre-releases
|
||||
|
||||
**Rationale:**
|
||||
|
||||
1. **Cross-platform tests already validate critical issues**
|
||||
- Installation works
|
||||
- Dependencies resolve
|
||||
- Server starts
|
||||
- Basic functionality works
|
||||
|
||||
2. **Pre-releases are for finding issues**
|
||||
- Customers expect potential problems
|
||||
- That's why they're testing pre-releases
|
||||
- Not production-quality requirements
|
||||
|
||||
3. **Time savings are significant**
|
||||
- 45-60 minutes saved (53-67% faster)
|
||||
- Enables rapid iteration
|
||||
- Better customer experience
|
||||
|
||||
4. **Risk is acceptable**
|
||||
- Worst case: broken pre-release
|
||||
- Impact: Customer reports issue, we fix
|
||||
- Mitigation: Clear pre-release labeling
|
||||
|
||||
### Proposed Implementation
|
||||
|
||||
```yaml
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
skip_ci:
|
||||
description: "Skip CI for pre-release (use with caution)"
|
||||
type: boolean
|
||||
default: false
|
||||
skip_ci_reason:
|
||||
description: "Required: Why skip CI?"
|
||||
type: string
|
||||
|
||||
jobs:
|
||||
validate-skip-ci:
|
||||
if: ${{ inputs.skip_ci }}
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Require justification
|
||||
run: |
|
||||
if [ -z "${{ inputs.skip_ci_reason }}" ]; then
|
||||
echo "Error: Must provide skip_ci_reason"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Notify team
|
||||
run: |
|
||||
echo "⚠️ CI SKIPPED for ${{ inputs.release_tag }}"
|
||||
echo "Reason: ${{ inputs.skip_ci_reason }}"
|
||||
# Send to Slack/Discord
|
||||
|
||||
ci:
|
||||
if: ${{ !inputs.skip_ci }}
|
||||
uses: ./.github/workflows/ci.yml
|
||||
# ... existing CI
|
||||
|
||||
publish-base:
|
||||
needs: [build-base, test-cross-platform] # Remove 'ci'
|
||||
if: |
|
||||
${{ inputs.release_package_base &&
|
||||
(inputs.skip_ci || needs.ci.result == 'success') }}
|
||||
# ... existing publish
|
||||
|
||||
publish-main:
|
||||
needs: [build-main, test-cross-platform, publish-base] # Remove 'ci'
|
||||
if: |
|
||||
${{ inputs.release_package_main &&
|
||||
(inputs.skip_ci || needs.ci.result == 'success') }}
|
||||
# ... existing publish
|
||||
|
||||
call_docker_build_main:
|
||||
needs: [] # Remove 'ci' dependency
|
||||
if: |
|
||||
${{ inputs.build_docker_main &&
|
||||
(inputs.skip_ci || needs.ci.result == 'success') }}
|
||||
# ... existing Docker build
|
||||
```
|
||||
|
||||
### Safeguards
|
||||
|
||||
1. **Explicit opt-in:** `skip_ci=false` by default
|
||||
2. **Required justification:** Must explain why skipping
|
||||
3. **Team notification:** Alert via Slack/Discord
|
||||
4. **Audit trail:** Log all skip decisions
|
||||
5. **Clear labeling:** Pre-release tags (rc, beta, alpha)
|
||||
|
||||
### Usage Guidelines
|
||||
|
||||
**When to skip CI:**
|
||||
- ✅ Code from RC branch (already tested)
|
||||
- ✅ Urgent customer testing needed
|
||||
- ✅ Internal testing only
|
||||
- ✅ Time-sensitive pre-release
|
||||
|
||||
**When NOT to skip CI:**
|
||||
- ❌ Production releases (never skip)
|
||||
- ❌ Major refactoring
|
||||
- ❌ First pre-release of version
|
||||
- ❌ Dependency updates
|
||||
|
||||
---
|
||||
|
||||
## Expected Results
|
||||
|
||||
### Time Savings
|
||||
|
||||
**Current:**
|
||||
```
|
||||
Build (20 min) + Cross-platform (15 min) + CI (50 min) + Publish (15 min) = 100 min
|
||||
```
|
||||
|
||||
**With skip_ci:**
|
||||
```
|
||||
Build (20 min) + Cross-platform (15 min) + Publish (15 min) = 50 min
|
||||
```
|
||||
|
||||
**Savings: 50 minutes (50% faster)**
|
||||
|
||||
### Risk Assessment
|
||||
|
||||
**Probability of broken pre-release:** ~10-15%
|
||||
**Impact if broken:** Low (pre-release context, quick rollback)
|
||||
**Mitigation:** Cross-platform tests catch 80-90% of critical issues
|
||||
|
||||
**Trade-off:** 50 min saved vs 10-15% risk of non-critical issues
|
||||
|
||||
---
|
||||
|
||||
## Alternative: Better Release Planning?
|
||||
|
||||
**The issue suggests:** "investigate if the real solution is better planned release cycles"
|
||||
|
||||
**Analysis:**
|
||||
|
||||
**Better planning helps but doesn't solve:**
|
||||
- ❌ Urgent bug fixes
|
||||
- ❌ Customer-specific issues
|
||||
- ❌ Unexpected problems
|
||||
- ❌ Last-minute changes
|
||||
|
||||
**Better planning + skip_ci option:**
|
||||
- ✅ Planned releases use full CI
|
||||
- ✅ Urgent releases use skip_ci
|
||||
- ✅ Best of both worlds
|
||||
|
||||
**Conclusion:** Better planning is good practice, but skip_ci option is still valuable for urgent scenarios.
|
||||
|
||||
---
|
||||
|
||||
## Final Recommendation
|
||||
|
||||
**Implement `skip_ci` parameter with safeguards.**
|
||||
|
||||
**Why:**
|
||||
1. Cross-platform tests already validate critical issues
|
||||
2. Pre-releases are for finding issues (acceptable risk)
|
||||
3. 50% time savings for urgent scenarios
|
||||
4. Explicit opt-in with justification required
|
||||
5. Doesn't prevent better release planning
|
||||
|
||||
**Implementation Priority:**
|
||||
|
||||
**Week 1:** Add `skip_ci` parameter and validation
|
||||
**Week 2:** Add team notifications and audit logging
|
||||
**Week 3:** Document usage guidelines
|
||||
**Week 4:** Monitor usage and adjust as needed
|
||||
|
||||
**Success Metrics:**
|
||||
- Time to pre-release: < 60 minutes (from 100+ minutes)
|
||||
- Pre-release quality: > 85% work without issues
|
||||
- Usage: 2-3 times per release cycle for urgent needs
|
||||
|
||||
---
|
||||
|
||||
## Conclusion
|
||||
|
||||
**YES, we can skip CI for pre-releases.**
|
||||
|
||||
The 45-60 minute CI wait is unnecessary when:
|
||||
- Cross-platform tests already validate installation
|
||||
- Pre-releases are explicitly for testing
|
||||
- Time savings enable better customer experience
|
||||
- Risk is acceptable with proper safeguards
|
||||
|
||||
**This isn't about lowering quality standards** - it's about recognizing that pre-releases and production releases have different requirements. Pre-releases are for finding issues; production releases are for stability.
|
||||
|
||||
**The solution is both:**
|
||||
- Better release planning (reduce urgent needs)
|
||||
- skip_ci option (handle urgent needs when they arise)
|
||||
@@ -9,7 +9,7 @@
|
||||
# 1. use python:3.12.3-slim as the base image until https://github.com/pydantic/pydantic-core/issues/1292 gets resolved
|
||||
# 2. do not add --platform=$BUILDPLATFORM because the pydantic binaries must be resolved for the final architecture
|
||||
# Use a Python image with uv pre-installed
|
||||
FROM ghcr.io/astral-sh/uv:python3.12-bookworm-slim AS builder
|
||||
FROM ghcr.io/astral-sh/uv:python3.14-trixie-slim AS builder
|
||||
|
||||
# Install the project into `/app`
|
||||
WORKDIR /app
|
||||
@@ -29,13 +29,17 @@ RUN apt-get update \
|
||||
# deps for building python deps
|
||||
build-essential \
|
||||
git \
|
||||
# npm
|
||||
npm \
|
||||
# gcc
|
||||
gcc \
|
||||
curl \
|
||||
&& curl -fsSL https://deb.nodesource.com/setup_20.x | bash - \
|
||||
&& apt-get install -y nodejs \
|
||||
&& ARCH=$(dpkg --print-architecture) \
|
||||
&& if [ "$ARCH" = "amd64" ]; then NODE_ARCH="x64"; \
|
||||
elif [ "$ARCH" = "arm64" ]; then NODE_ARCH="arm64"; \
|
||||
else NODE_ARCH="$ARCH"; fi \
|
||||
&& NODE_VERSION="22.14.0" \
|
||||
&& curl -fsSL "https://nodejs.org/dist/v${NODE_VERSION}/node-v${NODE_VERSION}-linux-${NODE_ARCH}.tar.xz" \
|
||||
| tar -xJ -C /usr/local --strip-components=1 \
|
||||
&& npm install -g npm@latest \
|
||||
&& apt-get clean \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
@@ -44,10 +48,11 @@ COPY ./uv.lock /app/uv.lock
|
||||
COPY ./README.md /app/README.md
|
||||
COPY ./pyproject.toml /app/pyproject.toml
|
||||
COPY ./src/backend/base/README.md /app/src/backend/base/README.md
|
||||
COPY ./src/backend/base/uv.lock /app/src/backend/base/uv.lock
|
||||
COPY ./src/backend/base/pyproject.toml /app/src/backend/base/pyproject.toml
|
||||
COPY ./src/lfx/README.md /app/src/lfx/README.md
|
||||
COPY ./src/lfx/pyproject.toml /app/src/lfx/pyproject.toml
|
||||
COPY ./src/sdk/README.md /app/src/sdk/README.md
|
||||
COPY ./src/sdk/pyproject.toml /app/src/sdk/pyproject.toml
|
||||
|
||||
RUN --mount=type=cache,target=/root/.cache/uv \
|
||||
RUSTFLAGS='--cfg reqwest_unstable' \
|
||||
@@ -73,7 +78,7 @@ RUN --mount=type=cache,target=/root/.cache/uv \
|
||||
# RUNTIME
|
||||
# Setup user, utilities and copy the virtual environment only
|
||||
################################
|
||||
FROM python:3.12.12-slim-trixie AS runtime
|
||||
FROM python:3.14-slim-trixie AS runtime
|
||||
|
||||
|
||||
RUN apt-get update \
|
||||
@@ -88,18 +93,25 @@ RUN ARCH=$(dpkg --print-architecture) \
|
||||
elif [ "$ARCH" = "arm64" ]; then NODE_ARCH="arm64"; \
|
||||
else NODE_ARCH="$ARCH"; fi \
|
||||
&& NODE_VERSION=$(curl -fsSL https://nodejs.org/dist/latest-v22.x/ \
|
||||
| grep -oP "node-v\K[0-9]+\.[0-9]+\.[0-9]+(?=-linux-${NODE_ARCH}\.tar\.xz)" \
|
||||
| sed -nE "s/.*node-v([0-9]+\.[0-9]+\.[0-9]+)-linux-${NODE_ARCH}\.tar\.xz.*/\1/p" \
|
||||
| head -1) \
|
||||
&& if [ -z "$NODE_VERSION" ]; then echo "ERROR: Could not determine Node.js version" && exit 1; fi \
|
||||
&& curl -fsSL "https://nodejs.org/dist/v${NODE_VERSION}/node-v${NODE_VERSION}-linux-${NODE_ARCH}.tar.xz" \
|
||||
| tar -xJ -C /usr/local --strip-components=1 \
|
||||
&& npm install -g npm@latest \
|
||||
&& npm cache clean --force
|
||||
&& npm install -g npm@latest
|
||||
RUN useradd user -u 1000 -g 0 --no-create-home --home-dir /app/data
|
||||
|
||||
COPY --from=builder --chown=1000 /app/.venv /app/.venv
|
||||
ENV PATH="/app/.venv/bin:$PATH"
|
||||
RUN /app/.venv/bin/pip install --upgrade playwright \
|
||||
&& /app/.venv/bin/playwright install
|
||||
|
||||
# Pre-create LANGFLOW_CONFIG_DIR (the default location used by the docker_example
|
||||
# compose file) with the non-root user as owner. When the official compose mounts
|
||||
# a fresh named volume at /app/langflow, Docker copies this directory's ownership
|
||||
# and permissions into the new volume, so the in-container uid=1000 user can
|
||||
# write secret_key, profile_pictures, etc. Without this, the volume is created
|
||||
# as root:root and Langflow crashes during startup with PermissionError on
|
||||
# /app/langflow/secret_key. See https://github.com/langflow-ai/langflow/issues/10437
|
||||
RUN mkdir -p /app/langflow && chown -R 1000:0 /app/langflow && chmod -R g+rwX /app/langflow
|
||||
|
||||
LABEL org.opencontainers.image.title=langflow
|
||||
LABEL org.opencontainers.image.authors=['Langflow']
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
################################
|
||||
# BUILDER
|
||||
################################
|
||||
FROM ghcr.io/astral-sh/uv:python3.12-bookworm-slim AS builder
|
||||
FROM ghcr.io/astral-sh/uv:python3.14-trixie-slim AS builder
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
@@ -29,6 +29,7 @@ RUN apt-get update \
|
||||
# Copy only backend source (excludes frontend)
|
||||
COPY ./src/backend ./src/backend
|
||||
COPY ./src/lfx ./src/lfx
|
||||
COPY ./src/sdk ./src/sdk
|
||||
|
||||
# Create venv and install langflow-base with dependencies
|
||||
# Using uv pip instead of uv sync to avoid workspace complexities
|
||||
@@ -36,13 +37,14 @@ RUN uv venv /app/.venv
|
||||
ENV PATH="/app/.venv/bin:$PATH"
|
||||
ENV VIRTUAL_ENV="/app/.venv"
|
||||
|
||||
# Install langflow-base with all extras except dev (which includes Playwright)
|
||||
RUN --mount=type=cache,target=/root/.cache/uv \
|
||||
uv pip install ./src/lfx "./src/backend/base[complete,postgresql]"
|
||||
uv pip install ./src/sdk ./src/lfx "./src/backend/base[complete,postgresql]"
|
||||
|
||||
################################
|
||||
# RUNTIME
|
||||
################################
|
||||
FROM python:3.12.12-slim-trixie AS runtime
|
||||
FROM python:3.14-slim-trixie AS runtime
|
||||
|
||||
# Install minimal runtime dependencies
|
||||
RUN apt-get update \
|
||||
@@ -63,12 +65,11 @@ RUN ARCH=$(dpkg --print-architecture) \
|
||||
elif [ "$ARCH" = "arm64" ]; then NODE_ARCH="arm64"; \
|
||||
else NODE_ARCH="$ARCH"; fi \
|
||||
&& NODE_VERSION=$(curl -fsSL https://nodejs.org/dist/latest-v22.x/ \
|
||||
| grep -oP "node-v\K[0-9]+\.[0-9]+\.[0-9]+(?=-linux-${NODE_ARCH}\.tar\.xz)" \
|
||||
| sed -nE "s/.*node-v([0-9]+\.[0-9]+\.[0-9]+)-linux-${NODE_ARCH}\.tar\.xz.*/\1/p" \
|
||||
| head -1) \
|
||||
&& if [ -z "$NODE_VERSION" ]; then echo "ERROR: Could not determine Node.js version" && exit 1; fi \
|
||||
&& curl -fsSL "https://nodejs.org/dist/v${NODE_VERSION}/node-v${NODE_VERSION}-linux-${NODE_ARCH}.tar.xz" \
|
||||
| tar -xJ -C /usr/local --strip-components=1 \
|
||||
&& npm install -g npm@latest \
|
||||
&& npm cache clean --force
|
||||
| tar -xJ -C /usr/local --strip-components=1
|
||||
|
||||
# Create non-root user
|
||||
RUN useradd --uid 1000 --gid 0 --no-create-home --home-dir /app/data user
|
||||
@@ -78,9 +79,18 @@ COPY --from=builder --chown=1000:0 /app/.venv /app/.venv
|
||||
ENV PATH="/app/.venv/bin:$PATH"
|
||||
|
||||
# Create home directory and ensure proper ownership
|
||||
# The user needs write access to /app/data (home) and /app (workdir)
|
||||
# The user needs write access to /app/data (home) and /app (workdir).
|
||||
# Also pre-create /app/langflow (LANGFLOW_CONFIG_DIR used by the docker_example
|
||||
# compose file) with the non-root user as owner, so a fresh named volume mounted
|
||||
# at /app/langflow inherits the correct ownership/permissions and the in-container
|
||||
# uid=1000 user can write secret_key, profile_pictures, etc. Without this, the
|
||||
# volume would be initialized as root:root and Langflow would crash with
|
||||
# PermissionError on /app/langflow/secret_key (issue #10437).
|
||||
# Note: .venv is already owned by 1000:0 via COPY --chown above, so no recursive chown needed
|
||||
RUN mkdir -p /app/data && chown -R 1000:0 /app/data && chown 1000:0 /app
|
||||
RUN mkdir -p /app/data /app/langflow \
|
||||
&& chown -R 1000:0 /app/data /app/langflow \
|
||||
&& chmod -R g+rwX /app/langflow \
|
||||
&& chown 1000:0 /app
|
||||
|
||||
LABEL org.opencontainers.image.title=langflow-backend
|
||||
LABEL org.opencontainers.image.authors=['Langflow']
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
# 1. use python:3.12.3-slim as the base image until https://github.com/pydantic/pydantic-core/issues/1292 gets resolved
|
||||
# 2. do not add --platform=$BUILDPLATFORM because the pydantic binaries must be resolved for the final architecture
|
||||
# Use a Python image with uv pre-installed
|
||||
FROM ghcr.io/astral-sh/uv:python3.12-bookworm-slim AS builder
|
||||
FROM ghcr.io/astral-sh/uv:python3.14-trixie-slim AS builder
|
||||
|
||||
# Install the project into `/app`
|
||||
WORKDIR /app
|
||||
@@ -42,11 +42,13 @@ COPY ./uv.lock /app/uv.lock
|
||||
COPY ./README.md /app/README.md
|
||||
COPY ./pyproject.toml /app/pyproject.toml
|
||||
COPY ./src/backend/base/README.md /app/src/backend/base/README.md
|
||||
COPY ./src/backend/base/uv.lock /app/src/backend/base/uv.lock
|
||||
COPY ./src/backend/base/pyproject.toml /app/src/backend/base/pyproject.toml
|
||||
# Copy lfx metadata files since it's a workspace member
|
||||
COPY ./src/lfx/pyproject.toml /app/src/lfx/pyproject.toml
|
||||
COPY ./src/lfx/README.md /app/src/lfx/README.md
|
||||
# Copy sdk metadata files since it's a workspace member
|
||||
COPY ./src/sdk/pyproject.toml /app/src/sdk/pyproject.toml
|
||||
COPY ./src/sdk/README.md /app/src/sdk/README.md
|
||||
|
||||
# Install the project's dependencies using the lockfile and settings
|
||||
# We need to mount the root uv.lock and pyproject.toml to build the base with uv because we're still using uv workspaces
|
||||
@@ -74,7 +76,7 @@ RUN --mount=type=cache,target=/root/.cache/uv \
|
||||
# RUNTIME
|
||||
# Setup user, utilities and copy the virtual environment only
|
||||
################################
|
||||
FROM python:3.12.12-slim-trixie AS runtime
|
||||
FROM python:3.14-slim-trixie AS runtime
|
||||
|
||||
|
||||
RUN apt-get update \
|
||||
@@ -89,18 +91,24 @@ RUN ARCH=$(dpkg --print-architecture) \
|
||||
elif [ "$ARCH" = "arm64" ]; then NODE_ARCH="arm64"; \
|
||||
else NODE_ARCH="$ARCH"; fi \
|
||||
&& NODE_VERSION=$(curl -fsSL https://nodejs.org/dist/latest-v22.x/ \
|
||||
| grep -oP "node-v\K[0-9]+\.[0-9]+\.[0-9]+(?=-linux-${NODE_ARCH}\.tar\.xz)" \
|
||||
| sed -nE "s/.*node-v([0-9]+\.[0-9]+\.[0-9]+)-linux-${NODE_ARCH}\.tar\.xz.*/\1/p" \
|
||||
| head -1) \
|
||||
&& if [ -z "$NODE_VERSION" ]; then echo "ERROR: Could not determine Node.js version" && exit 1; fi \
|
||||
&& curl -fsSL "https://nodejs.org/dist/v${NODE_VERSION}/node-v${NODE_VERSION}-linux-${NODE_ARCH}.tar.xz" \
|
||||
| tar -xJ -C /usr/local --strip-components=1 \
|
||||
&& npm install -g npm@latest \
|
||||
&& npm cache clean --force
|
||||
| tar -xJ -C /usr/local --strip-components=1
|
||||
RUN useradd user -u 1000 -g 0 --no-create-home --home-dir /app/data
|
||||
|
||||
COPY --from=builder --chown=1000 /app/.venv /app/.venv
|
||||
ENV PATH="/app/.venv/bin:$PATH"
|
||||
RUN /app/.venv/bin/pip install --upgrade playwright \
|
||||
&& /app/.venv/bin/playwright install
|
||||
|
||||
# Pre-create LANGFLOW_CONFIG_DIR (the default location used by the docker_example
|
||||
# compose file) with the non-root user as owner. When the official compose mounts
|
||||
# a fresh named volume at /app/langflow, Docker copies this directory's ownership
|
||||
# and permissions into the new volume, so the in-container uid=1000 user can
|
||||
# write secret_key, profile_pictures, etc. Without this, the volume is created
|
||||
# as root:root and Langflow crashes during startup with PermissionError on
|
||||
# /app/langflow/secret_key. See https://github.com/langflow-ai/langflow/issues/10437
|
||||
RUN mkdir -p /app/langflow && chown -R 1000:0 /app/langflow && chmod -R g+rwX /app/langflow
|
||||
|
||||
LABEL org.opencontainers.image.title=langflow
|
||||
LABEL org.opencontainers.image.authors=['Langflow']
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
# 1. use python:3.12.3-slim as the base image until https://github.com/pydantic/pydantic-core/issues/1292 gets resolved
|
||||
# 2. do not add --platform=$BUILDPLATFORM because the pydantic binaries must be resolved for the final architecture
|
||||
# Use a Python image with uv pre-installed
|
||||
FROM ghcr.io/astral-sh/uv:python3.12-bookworm-slim AS builder
|
||||
FROM ghcr.io/astral-sh/uv:python3.14-trixie-slim AS builder
|
||||
|
||||
# Install the project into `/app`
|
||||
WORKDIR /app
|
||||
@@ -41,10 +41,11 @@ COPY ./uv.lock /app/uv.lock
|
||||
COPY ./README.md /app/README.md
|
||||
COPY ./pyproject.toml /app/pyproject.toml
|
||||
COPY ./src/backend/base/README.md /app/src/backend/base/README.md
|
||||
COPY ./src/backend/base/uv.lock /app/src/backend/base/uv.lock
|
||||
COPY ./src/backend/base/pyproject.toml /app/src/backend/base/pyproject.toml
|
||||
COPY ./src/lfx/README.md /app/src/lfx/README.md
|
||||
COPY ./src/lfx/pyproject.toml /app/src/lfx/pyproject.toml
|
||||
COPY ./src/sdk/README.md /app/src/sdk/README.md
|
||||
COPY ./src/sdk/pyproject.toml /app/src/sdk/pyproject.toml
|
||||
|
||||
RUN --mount=type=cache,target=/root/.cache/uv \
|
||||
RUSTFLAGS='--cfg reqwest_unstable' \
|
||||
@@ -70,7 +71,7 @@ RUN --mount=type=cache,target=/root/.cache/uv \
|
||||
# RUNTIME
|
||||
# Setup user, utilities and copy the virtual environment only
|
||||
################################
|
||||
FROM python:3.12.12-slim-trixie AS runtime
|
||||
FROM python:3.14-slim-trixie AS runtime
|
||||
|
||||
RUN apt-get update \
|
||||
&& apt-get upgrade -y \
|
||||
@@ -84,18 +85,24 @@ RUN ARCH=$(dpkg --print-architecture) \
|
||||
elif [ "$ARCH" = "arm64" ]; then NODE_ARCH="arm64"; \
|
||||
else NODE_ARCH="$ARCH"; fi \
|
||||
&& NODE_VERSION=$(curl -fsSL https://nodejs.org/dist/latest-v22.x/ \
|
||||
| grep -oP "node-v\K[0-9]+\.[0-9]+\.[0-9]+(?=-linux-${NODE_ARCH}\.tar\.xz)" \
|
||||
| sed -nE "s/.*node-v([0-9]+\.[0-9]+\.[0-9]+)-linux-${NODE_ARCH}\.tar\.xz.*/\1/p" \
|
||||
| head -1) \
|
||||
&& if [ -z "$NODE_VERSION" ]; then echo "ERROR: Could not determine Node.js version" && exit 1; fi \
|
||||
&& curl -fsSL "https://nodejs.org/dist/v${NODE_VERSION}/node-v${NODE_VERSION}-linux-${NODE_ARCH}.tar.xz" \
|
||||
| tar -xJ -C /usr/local --strip-components=1 \
|
||||
&& npm install -g npm@latest \
|
||||
&& npm cache clean --force
|
||||
| tar -xJ -C /usr/local --strip-components=1
|
||||
RUN useradd user -u 1000 -g 0 --no-create-home --home-dir /app/data
|
||||
|
||||
COPY --from=builder --chown=1000 /app/.venv /app/.venv
|
||||
ENV PATH="/app/.venv/bin:$PATH"
|
||||
RUN /app/.venv/bin/pip install --upgrade playwright \
|
||||
&& /app/.venv/bin/playwright install
|
||||
|
||||
# Pre-create LANGFLOW_CONFIG_DIR (the default location used by the docker_example
|
||||
# compose file) with the non-root user as owner. When the official compose mounts
|
||||
# a fresh named volume at /app/langflow, Docker copies this directory's ownership
|
||||
# and permissions into the new volume, so the in-container uid=1000 user can
|
||||
# write secret_key, profile_pictures, etc. Without this, the volume is created
|
||||
# as root:root and Langflow crashes during startup with PermissionError on
|
||||
# /app/langflow/secret_key. See https://github.com/langflow-ai/langflow/issues/10437
|
||||
RUN mkdir -p /app/langflow && chown -R 1000:0 /app/langflow && chmod -R g+rwX /app/langflow
|
||||
|
||||
LABEL org.opencontainers.image.title=langflow
|
||||
LABEL org.opencontainers.image.authors=['Langflow']
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
# 1. use python:3.12.3-slim as the base image until https://github.com/pydantic/pydantic-core/issues/1292 gets resolved
|
||||
# 2. do not add --platform=$BUILDPLATFORM because the pydantic binaries must be resolved for the final architecture
|
||||
# Use a Python image with uv pre-installed
|
||||
FROM ghcr.io/astral-sh/uv:python3.12-bookworm-slim AS builder
|
||||
FROM ghcr.io/astral-sh/uv:python3.14-trixie-slim AS builder
|
||||
|
||||
# Install the project into `/app`
|
||||
WORKDIR /app
|
||||
@@ -41,10 +41,11 @@ COPY ./uv.lock /app/uv.lock
|
||||
COPY ./README.md /app/README.md
|
||||
COPY ./pyproject.toml /app/pyproject.toml
|
||||
COPY ./src/backend/base/README.md /app/src/backend/base/README.md
|
||||
COPY ./src/backend/base/uv.lock /app/src/backend/base/uv.lock
|
||||
COPY ./src/backend/base/pyproject.toml /app/src/backend/base/pyproject.toml
|
||||
COPY ./src/lfx/README.md /app/src/lfx/README.md
|
||||
COPY ./src/lfx/pyproject.toml /app/src/lfx/pyproject.toml
|
||||
COPY ./src/sdk/README.md /app/src/sdk/README.md
|
||||
COPY ./src/sdk/pyproject.toml /app/src/sdk/pyproject.toml
|
||||
|
||||
RUN --mount=type=cache,target=/root/.cache/uv \
|
||||
RUSTFLAGS='--cfg reqwest_unstable' \
|
||||
@@ -70,7 +71,7 @@ RUN --mount=type=cache,target=/root/.cache/uv \
|
||||
# RUNTIME
|
||||
# Setup user, utilities and copy the virtual environment only
|
||||
################################
|
||||
FROM python:3.12.12-slim-trixie AS runtime
|
||||
FROM python:3.14-slim-trixie AS runtime
|
||||
|
||||
|
||||
RUN apt-get update \
|
||||
@@ -85,18 +86,24 @@ RUN ARCH=$(dpkg --print-architecture) \
|
||||
elif [ "$ARCH" = "arm64" ]; then NODE_ARCH="arm64"; \
|
||||
else NODE_ARCH="$ARCH"; fi \
|
||||
&& NODE_VERSION=$(curl -fsSL https://nodejs.org/dist/latest-v22.x/ \
|
||||
| grep -oP "node-v\K[0-9]+\.[0-9]+\.[0-9]+(?=-linux-${NODE_ARCH}\.tar\.xz)" \
|
||||
| sed -nE "s/.*node-v([0-9]+\.[0-9]+\.[0-9]+)-linux-${NODE_ARCH}\.tar\.xz.*/\1/p" \
|
||||
| head -1) \
|
||||
&& if [ -z "$NODE_VERSION" ]; then echo "ERROR: Could not determine Node.js version" && exit 1; fi \
|
||||
&& curl -fsSL "https://nodejs.org/dist/v${NODE_VERSION}/node-v${NODE_VERSION}-linux-${NODE_ARCH}.tar.xz" \
|
||||
| tar -xJ -C /usr/local --strip-components=1 \
|
||||
&& npm install -g npm@latest \
|
||||
&& npm cache clean --force
|
||||
| tar -xJ -C /usr/local --strip-components=1
|
||||
RUN useradd user -u 1000 -g 0 --no-create-home --home-dir /app/data
|
||||
|
||||
COPY --from=builder --chown=1000 /app/.venv /app/.venv
|
||||
ENV PATH="/app/.venv/bin:$PATH"
|
||||
RUN /app/.venv/bin/pip install --upgrade playwright \
|
||||
&& /app/.venv/bin/playwright install
|
||||
|
||||
# Pre-create LANGFLOW_CONFIG_DIR (the default location used by the docker_example
|
||||
# compose file) with the non-root user as owner. When the official compose mounts
|
||||
# a fresh named volume at /app/langflow, Docker copies this directory's ownership
|
||||
# and permissions into the new volume, so the in-container uid=1000 user can
|
||||
# write secret_key, profile_pictures, etc. Without this, the volume is created
|
||||
# as root:root and Langflow crashes during startup with PermissionError on
|
||||
# /app/langflow/secret_key. See https://github.com/langflow-ai/langflow/issues/10437
|
||||
RUN mkdir -p /app/langflow && chown -R 1000:0 /app/langflow && chmod -R g+rwX /app/langflow
|
||||
|
||||
LABEL org.opencontainers.image.title=langflow
|
||||
LABEL org.opencontainers.image.authors=['Langflow']
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
FROM ghcr.io/astral-sh/uv:python3.12-bookworm-slim
|
||||
FROM ghcr.io/astral-sh/uv:python3.14-bookworm-slim
|
||||
ENV TZ=UTC
|
||||
|
||||
WORKDIR /app
|
||||
@@ -21,10 +21,11 @@ RUN --mount=type=cache,target=/root/.cache/uv \
|
||||
--mount=type=bind,source=README.md,target=README.md \
|
||||
--mount=type=bind,source=pyproject.toml,target=pyproject.toml \
|
||||
--mount=type=bind,source=src/backend/base/README.md,target=src/backend/base/README.md \
|
||||
--mount=type=bind,source=src/backend/base/uv.lock,target=src/backend/base/uv.lock \
|
||||
--mount=type=bind,source=src/backend/base/pyproject.toml,target=src/backend/base/pyproject.toml \
|
||||
--mount=type=bind,source=src/lfx/README.md,target=src/lfx/README.md \
|
||||
--mount=type=bind,source=src/lfx/pyproject.toml,target=src/lfx/pyproject.toml \
|
||||
--mount=type=bind,source=src/sdk/README.md,target=src/sdk/README.md \
|
||||
--mount=type=bind,source=src/sdk/pyproject.toml,target=src/sdk/pyproject.toml \
|
||||
uv sync --frozen --no-install-project --no-dev --extra postgresql
|
||||
|
||||
EXPOSE 7860
|
||||
|
||||
@@ -48,7 +48,7 @@ Volumes:
|
||||
|
||||
### PostgreSQL Service
|
||||
|
||||
The `postgres` service uses the `postgres:16` Docker image and exposes port 5432.
|
||||
The `postgres` service uses the `postgres:16-trixie` Docker image and exposes port 5432. The image is pinned to a specific Debian base (`trixie`, Debian 13) so the `postgres:16` tag cannot silently roll its underlying OS, which would otherwise produce a glibc collation version mismatch warning on existing data volumes.
|
||||
|
||||
Environment variables:
|
||||
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
services:
|
||||
langflow:
|
||||
image: langflowai/langflow:latest # or another version tag on https://hub.docker.com/r/langflowai/langflow
|
||||
pull_policy: always # set to 'always' when using 'latest' image
|
||||
ports:
|
||||
- "7860:7860"
|
||||
depends_on:
|
||||
- postgres
|
||||
environment:
|
||||
- LANGFLOW_DATABASE_URL=postgresql://langflow:langflow@postgres:5432/langflow
|
||||
# This variable defines where the logs, file storage, monitor data and secret keys are stored.
|
||||
- LANGFLOW_CONFIG_DIR=/app/langflow
|
||||
volumes:
|
||||
- langflow-data:/app/langflow
|
||||
|
||||
postgres:
|
||||
# Pinned to a specific Debian base (trixie) so the postgres:16 tag does not
|
||||
# silently roll its OS underneath us — that roll causes a glibc collation
|
||||
# version mismatch warning on existing volumes. Matches the langflow image
|
||||
# base. See https://github.com/langflow-ai/langflow/issues/9608
|
||||
image: postgres:16-trixie
|
||||
environment:
|
||||
POSTGRES_USER: langflow
|
||||
POSTGRES_PASSWORD: langflow
|
||||
POSTGRES_DB: langflow
|
||||
ports:
|
||||
- "5432:5432"
|
||||
volumes:
|
||||
- langflow-postgres:/var/lib/postgresql/data
|
||||
|
||||
volumes:
|
||||
langflow-postgres:
|
||||
langflow-data:
|
||||
@@ -15,7 +15,11 @@ services:
|
||||
- hanzoflow-data:/app/hanzoflow
|
||||
|
||||
postgres:
|
||||
image: postgres:16
|
||||
# Pinned to a specific Debian base (trixie) so the postgres:16 tag does not
|
||||
# silently roll its OS underneath us — that roll causes a glibc collation
|
||||
# version mismatch warning on existing volumes. Matches the langflow image
|
||||
# base. See https://github.com/langflow-ai/langflow/issues/9608
|
||||
image: postgres:16-trixie
|
||||
environment:
|
||||
POSTGRES_USER: hanzoflow
|
||||
POSTGRES_PASSWORD: hanzoflow
|
||||
|
||||
@@ -0,0 +1,310 @@
|
||||
# Investigation: Deprecated macOS Support — Impact on Langflow
|
||||
|
||||
**Jira:** LE-265
|
||||
**Date:** 2026-04-02
|
||||
**Status:** Complete
|
||||
**Related:** LE-172 (PyTorch macOS x86_64 + Python 3.13 — resolved in PR #12469)
|
||||
|
||||
---
|
||||
|
||||
## TL;DR
|
||||
|
||||
macOS Intel (x86_64) is in its final phase of deprecation across the entire ecosystem — Apple hardware, operating system, GitHub Actions runners, PyTorch, and the broader ML/AI stack. Langflow already has platform exclusion markers for the most critical torch-dependent extras (PR #12469), but additional deprecations in GitHub Actions runners, macOS versions, and upstream dependencies will require further action over the next 12–18 months.
|
||||
|
||||
**Key decision required:** Set a timeline to officially drop macOS x86_64 from Langflow's support matrix (recommended: Langflow 2.0 or Q4 2026, whichever comes first).
|
||||
|
||||
---
|
||||
|
||||
## 1. Deprecation Landscape
|
||||
|
||||
### 1.1 Apple Hardware & OS Timeline
|
||||
|
||||
| Event | Date | Impact |
|
||||
|-------|------|--------|
|
||||
| Last Intel MacBook Pro shipped | June 2022 | No new Intel Mac hardware |
|
||||
| macOS Ventura 13 (last Intel-supported release rumoured) | Oct 2022 | Still receiving security updates as of early 2026 |
|
||||
| macOS Sequoia 15 released | Sept 2024 | Supports some Intel Macs — expected to be last |
|
||||
| macOS 26 (Tahoe) expected | June 2026 | Likely final macOS version supporting any Intel Mac |
|
||||
| macOS 27 expected | 2027 | **Expected to drop all Intel Mac support** |
|
||||
|
||||
**Key fact:** Apple's Rosetta 2 translates x86_64 binaries on Apple Silicon, but native ARM64 builds are preferred. Rosetta support for macOS is expected to continue but individual frameworks (e.g., Metal 3) are ARM-only.
|
||||
|
||||
### 1.2 GitHub Actions Runner Deprecation
|
||||
|
||||
| Runner | Architecture | Status | Deprecation |
|
||||
|--------|-------------|--------|-------------|
|
||||
| `macos-13` | Intel x86_64 | **Deprecated** | Removed Dec 2025 |
|
||||
| `macos-14` | ARM64 (M1) | Active | Current |
|
||||
| `macos-15` | ARM64 (M1/M2) | Active | Current |
|
||||
| `macos-latest` | ARM64 | Active | Points to macos-15 |
|
||||
| `macos-latest-large` | Intel x86_64 | **Active but costly** | At risk — GitHub shifting to ARM |
|
||||
| `macos-26` (Tahoe) | ARM64 | Available | Current |
|
||||
|
||||
**Key fact:** `macos-latest-large` is the **only** Intel x86_64 runner available. It is one of the most expensive GitHub Actions runners. GitHub has signaled the shift toward ARM-only macOS runners.
|
||||
|
||||
### 1.3 PyTorch macOS x86_64 Deprecation
|
||||
|
||||
| Event | Date |
|
||||
|-------|------|
|
||||
| RFC to deprecate macOS x86_64 builds | Nov 2023 |
|
||||
| PyTorch 2.2.2 — last Intel Mac wheels (`cp310`, `cp311`, `cp312`) | March 2024 |
|
||||
| PyTorch 2.3.0 — no Intel Mac wheels at all | April 2024 |
|
||||
| Python 3.13 released (no PyTorch wheels for Intel Mac) | Oct 2024 |
|
||||
|
||||
**Result:** No PyTorch wheel exists for macOS x86_64 + Python ≥ 3.13. PyTorch 2.2.2 is the last version with Intel Mac support, and it only covers Python 3.10–3.12.
|
||||
|
||||
### 1.4 Broader ML Ecosystem
|
||||
|
||||
| Library | macOS x86_64 Support | Notes |
|
||||
|---------|---------------------|-------|
|
||||
| PyTorch | ❌ Dropped (after 2.2.2) | No wheels for Python 3.13+ on Intel |
|
||||
| MLX | ❌ Never supported | Apple Silicon only (arm64 + Python 3.12+) |
|
||||
| MLX-VLM | ❌ Never supported | Apple Silicon only |
|
||||
| Metal SDK | ❌ ARM64 only | Metal 3 requires Apple Silicon |
|
||||
| TensorFlow | ⚠️ Limited | `tensorflow-macos` deprecated, replaced by `tensorflow-metal` (ARM64) |
|
||||
| ONNX Runtime | ✅ Still supported | Cross-platform, but CoreML EP is ARM64-optimized |
|
||||
| sentence-transformers | ⚠️ Transitively broken | Depends on PyTorch — fails on Intel Mac + Python 3.13 |
|
||||
| Hugging Face Transformers | ⚠️ Transitively broken | Same PyTorch dependency chain |
|
||||
|
||||
---
|
||||
|
||||
## 2. Current State of Langflow macOS Support
|
||||
|
||||
### 2.1 Platform Exclusion Markers in `pyproject.toml`
|
||||
|
||||
The following extras have platform markers excluding macOS x86_64 (**as of PR #12469**):
|
||||
|
||||
| Extra | Marker | Reason |
|
||||
|-------|--------|--------|
|
||||
| `altk` | `sys_platform != 'darwin' or platform_machine != 'x86_64'` | PyTorch direct + transitive dep (PR #12469) |
|
||||
| `langchain-huggingface` | `sys_platform != 'darwin' or platform_machine != 'x86_64'` | sentence-transformers → torch (PR #12469) |
|
||||
| `docling` (binary) | `sys_platform != 'darwin' or platform_machine != 'x86_64'` | docling-ibm-models → torch (pre-existing) |
|
||||
| `easyocr` | `sys_platform != 'darwin' or platform_machine != 'x86_64'` | easyocr → torch (pre-existing) |
|
||||
| `cuga` | `sys_platform == 'darwin' and platform_machine == 'arm64'` | CUDA alternative, ARM64 only on macOS |
|
||||
| `mlx` | `sys_platform == 'darwin' and platform_machine == 'arm64' and python_version >= '3.12'` | Apple Silicon ML framework |
|
||||
| `metal` | No marker (correct — cross-platform) | `metal_sdk==2.5.1` is [getmetal.io](https://getmetal.io) cloud SDK, not Apple Metal |
|
||||
|
||||
**Extras with NO macOS exclusion that still work on Intel:**
|
||||
|
||||
| Extra | Status | Notes |
|
||||
|-------|--------|-------|
|
||||
| `docling-core` | ✅ Works | Metadata-only, no torch dependency |
|
||||
| `ocrmac` | ✅ Works | `sys_platform == 'darwin'` — uses native Vision framework |
|
||||
| `langchain-unstructured` | ✅ Works | No torch dependency |
|
||||
| `graph-retriever` | ✅ Works | No platform-specific deps |
|
||||
| All non-ML extras | ✅ Works | Database, API, monitoring, etc. |
|
||||
|
||||
### 2.2 macOS-Specific Runtime Workarounds
|
||||
|
||||
| Workaround | Location | Purpose |
|
||||
|------------|----------|---------|
|
||||
| `OBJC_DISABLE_INITIALIZE_FORK_SAFETY=YES` | `__main__.py`, `langflow_launcher.py`, CI workflows | Prevents Objective-C fork safety crashes with gunicorn multiprocessing |
|
||||
| `os.environ["no_proxy"] = "*"` | `__main__.py`, `langflow_launcher.py` | Avoids proxy-related errors with gunicorn on macOS |
|
||||
| `os.execv()` re-exec pattern | `langflow_launcher.py` | Sets env vars before Objective-C runtime initializes |
|
||||
| `brew install protobuf` | CI workflow (AMD64 only) | protoc not available on Intel CI runners |
|
||||
|
||||
### 2.3 CI Matrix Coverage
|
||||
|
||||
| Platform | Python 3.10 | Python 3.12 | Python 3.13 |
|
||||
|----------|-------------|-------------|-------------|
|
||||
| macOS ARM64 | ✅ Stable | ✅ Stable | ✅ Experimental |
|
||||
| macOS AMD64 (Intel) | ✅ Stable | ✅ Stable | ⚠️ Experimental (`continue-on-error`) |
|
||||
| Linux AMD64 | ✅ Stable | ✅ Stable | ✅ Experimental |
|
||||
| Windows AMD64 | ✅ Stable | ✅ Stable | ✅ Experimental |
|
||||
|
||||
---
|
||||
|
||||
## 3. Impact Assessment
|
||||
|
||||
### 3.1 What Works on macOS x86_64 Today
|
||||
|
||||
Langflow **core functionality** (flow builder, API, database, all non-ML components) works on macOS Intel with Python 3.10–3.13. The following features are **unavailable**:
|
||||
|
||||
- ALTK (Agent Lifecycle Toolkit) components
|
||||
- HuggingFace/sentence-transformers embeddings
|
||||
- EasyOCR
|
||||
- Docling document processing (binary; docling-core metadata still works)
|
||||
- MLX inference
|
||||
- Metal GPU acceleration
|
||||
- CUGA
|
||||
|
||||
**This is acceptable** because Intel Macs lack the GPU capabilities (Metal 3, Neural Engine) that make these ML features performant.
|
||||
|
||||
### 3.2 What Will Break Next
|
||||
|
||||
| Timeline | Event | Impact on Langflow |
|
||||
|----------|-------|--------------------|
|
||||
| **Now** | PyTorch has no Intel Mac wheels for Python 3.13+ | Already mitigated (PR #12469) |
|
||||
| **Q3 2026** | GitHub may deprecate `macos-latest-large` (Intel) | Stable CI tests for macOS Intel would need migration or removal |
|
||||
| **Q4 2026** | macOS 26 (Tahoe) release — potentially last Intel-supporting macOS | Users on Intel Macs stop receiving macOS updates |
|
||||
| **2027** | macOS 27 drops Intel Mac support | Intel Mac users stuck on macOS 26; system Python/Homebrew may stop building for x86_64 macOS |
|
||||
| **2027+** | Homebrew, Python.org may drop macOS x86_64 builds | Intel Mac users can't easily install Python 3.14+ |
|
||||
|
||||
### 3.3 CI Cost Exposure
|
||||
|
||||
`macos-latest-large` (Intel x86_64) is the most expensive runner in the matrix:
|
||||
|
||||
- **GitHub Actions pricing:** macOS large runners cost ~$0.12/min (10× Linux)
|
||||
- **Current usage:** 4 stable jobs + 1 experimental job = 5 Intel Mac CI jobs per workflow run
|
||||
- **Estimated cost:** ~$15–25 per full CI run for Intel Mac jobs alone
|
||||
|
||||
---
|
||||
|
||||
## 4. Recommendations
|
||||
|
||||
### 4.1 Immediate (No Action Needed)
|
||||
|
||||
PR #12469 has already addressed the most critical issue:
|
||||
- ✅ `altk` and `langchain-huggingface` excluded on macOS x86_64
|
||||
- ✅ `docling` and `easyocr` already excluded (pre-existing)
|
||||
- ✅ `mlx`, `cuga` are ARM64-only
|
||||
- ✅ Experimental CI uses `continue-on-error: true`
|
||||
|
||||
### 4.2 Short-Term (Langflow 1.10 / Q2 2026)
|
||||
|
||||
#### ~~R1: Add `platform_machine` marker to `metal` extra~~ — NOT NEEDED
|
||||
|
||||
`metal_sdk` is the SDK for [getmetal.io](https://getmetal.io) (a cloud vector search/embedding service), **not** Apple's Metal GPU framework. It's a pure Python package (`py3-none-any.whl`) with no platform-specific dependencies. No marker change needed.
|
||||
|
||||
#### R2: Reduce macOS Intel CI to Python 3.12 only
|
||||
|
||||
Remove the Python 3.10 stable test on macOS AMD64. Python 3.12 is the most commonly deployed version and provides sufficient coverage:
|
||||
|
||||
```yaml
|
||||
# Remove from stable matrix:
|
||||
# - os: macos
|
||||
# arch: amd64
|
||||
# runner: macos-latest-large
|
||||
# python-version: "3.10"
|
||||
```
|
||||
|
||||
**Saves:** ~$5–7 per CI run, reduces alert noise.
|
||||
|
||||
#### R3: Document macOS support matrix in user-facing docs
|
||||
|
||||
Add a support matrix page to `docs/` clarifying which features require Apple Silicon vs. work on Intel, so users have clear expectations.
|
||||
|
||||
### 4.3 Medium-Term (Langflow 2.0 / Q4 2026)
|
||||
|
||||
#### R4: Drop macOS x86_64 from CI entirely
|
||||
|
||||
Once GitHub deprecates `macos-latest-large` or macOS 26 ships:
|
||||
|
||||
1. Remove all `macos-latest-large` entries from `cross-platform-test.yml`
|
||||
2. Remove the `brew install protobuf` step (only needed for Intel runners)
|
||||
3. Keep `macos-latest` (ARM64) as the sole macOS test target
|
||||
|
||||
**Saves:** ~$15–25 per CI run. Reduces matrix complexity.
|
||||
|
||||
#### R5: Audit and clean up OBJC fork safety workaround
|
||||
|
||||
The `OBJC_DISABLE_INITIALIZE_FORK_SAFETY` workaround in `__main__.py` and `langflow_launcher.py` applies to all macOS, not just Intel. However, once Intel is dropped:
|
||||
- Verify if the issue persists on Apple Silicon with current gunicorn/uvicorn
|
||||
- If ARM64-only deployments don't trigger the issue, consider removing the workaround
|
||||
- If still needed, keep it but document why
|
||||
|
||||
#### R6: Officially deprecate macOS x86_64 in release notes
|
||||
|
||||
Add a deprecation notice in the Langflow 2.0 release notes:
|
||||
|
||||
> **macOS Intel (x86_64) support is deprecated.** Langflow 2.0 is the last version tested on Intel Macs. Future releases will only be tested on Apple Silicon (ARM64). Core functionality may continue to work, but ML features require Apple Silicon.
|
||||
|
||||
### 4.4 Long-Term (Langflow 2.x+ / 2027)
|
||||
|
||||
#### R7: Remove all macOS x86_64 platform markers
|
||||
|
||||
Once Intel Mac support is officially dropped, simplify `pyproject.toml` by removing the `platform_machine != 'x86_64'` guards — they become unnecessary clutter:
|
||||
|
||||
```toml
|
||||
# Before (with Intel exclusion)
|
||||
altk = ["agent-lifecycle-toolkit>=0.10.1,<1.0; sys_platform != 'darwin' or platform_machine != 'x86_64'"]
|
||||
|
||||
# After (Intel dropped from support matrix)
|
||||
altk = ["agent-lifecycle-toolkit>=0.10.1,<1.0"]
|
||||
```
|
||||
|
||||
#### R8: Evaluate ARM64-only macOS features
|
||||
|
||||
With Intel out of the picture, lean into Apple Silicon capabilities:
|
||||
- MLX-based local inference as a first-class feature
|
||||
- Metal GPU acceleration for embedding generation
|
||||
- Neural Engine integration for on-device ML
|
||||
- CoreML model support via ONNX Runtime CoreML EP
|
||||
|
||||
---
|
||||
|
||||
## 5. Risk Matrix
|
||||
|
||||
| Risk | Probability | Impact | Mitigation |
|
||||
|------|------------|--------|------------|
|
||||
| GitHub removes `macos-latest-large` runner | High (12–18 months) | CI breaks for Intel tests | R4: Drop Intel from CI proactively |
|
||||
| User complaints about missing ML features on Intel Mac | Low | User frustration | R3: Document support matrix clearly |
|
||||
| ~~`metal_sdk` fails on Intel Mac install~~ | N/A | N/A | `metal_sdk` is getmetal.io cloud SDK — cross-platform, no issue |
|
||||
| Python 3.14 drops macOS x86_64 support | Low (2027+) | Core Langflow broken on Intel | R6: Deprecation notice well in advance |
|
||||
| OBJC fork safety workaround breaks on new macOS | Low | Server startup crash | R5: Audit and test on macOS 26 |
|
||||
| Upstream deps (numpy, scipy) drop Intel Mac wheels | Medium (2027+) | Broad installation failures | R6/R7: Official deprecation covers this |
|
||||
|
||||
---
|
||||
|
||||
## 6. Recommended macOS Support Matrix
|
||||
|
||||
### Current (Langflow 1.9.x)
|
||||
|
||||
| Feature Category | macOS ARM64 (Apple Silicon) | macOS x86_64 (Intel) |
|
||||
|-----------------|---------------------------|---------------------|
|
||||
| Core (flows, API, database) | ✅ Full support | ✅ Full support |
|
||||
| ML/AI components (ALTK, HuggingFace) | ✅ Full support | ❌ Excluded (no PyTorch wheels) |
|
||||
| Document processing (Docling, EasyOCR) | ✅ Full support | ❌ Excluded (no PyTorch wheels) |
|
||||
| Local inference (MLX, MLX-VLM) | ✅ Full support (Python 3.12+) | ❌ Not available (ARM64 only) |
|
||||
| GPU acceleration (Metal, CUGA) | ✅ Full support | ❌ Not available (ARM64 only) |
|
||||
| Native OCR (ocrmac) | ✅ Full support | ✅ Full support |
|
||||
|
||||
### Proposed (Langflow 2.0+)
|
||||
|
||||
| Feature Category | macOS ARM64 (Apple Silicon) | macOS x86_64 (Intel) |
|
||||
|-----------------|---------------------------|---------------------|
|
||||
| All features | ✅ Full support | ⚠️ Deprecated — untested, best-effort |
|
||||
|
||||
---
|
||||
|
||||
## 7. Summary of Action Items
|
||||
|
||||
| # | Action | Target Release | Priority | Effort |
|
||||
|---|--------|---------------|----------|--------|
|
||||
| — | ~~Exclude `altk` and `langchain-huggingface` on macOS x86_64~~ | ~~1.9.x~~ | ~~Critical~~ | ✅ Done (PR #12469) |
|
||||
| ~~R1~~ | ~~Add ARM64 marker to `metal` extra~~ | — | — | ❌ N/A (`metal_sdk` is getmetal.io cloud SDK, not Apple Metal) |
|
||||
| R2 | Remove Python 3.10 macOS Intel from stable CI | 1.10 | Low | 4 lines YAML |
|
||||
| R3 | Document macOS support matrix for users | 1.10 | Medium | New docs page |
|
||||
| R4 | Drop macOS x86_64 from CI entirely | 2.0 | Medium | ~20 lines YAML |
|
||||
| R5 | Audit OBJC fork safety on Apple Silicon | 2.0 | Low | Investigation |
|
||||
| R6 | Official deprecation notice in release notes | 2.0 | Medium | Release docs |
|
||||
| R7 | Remove x86_64 platform markers from pyproject.toml | 2.x | Low | Cleanup |
|
||||
| R8 | Evaluate ARM64-only macOS capabilities | 2.x | Low | Feature planning |
|
||||
|
||||
---
|
||||
|
||||
## Appendix A: Complete Platform Marker Inventory
|
||||
|
||||
All `sys_platform` and `platform_machine` markers in `src/backend/base/pyproject.toml`:
|
||||
|
||||
```
|
||||
Line 53: jq → sys_platform != 'win32'
|
||||
Line 96: ocrmac → sys_platform == 'darwin' (root pyproject.toml)
|
||||
Line 258: metal → (no marker — correct; getmetal.io cloud SDK, not Apple Metal)
|
||||
Line 293: altk → sys_platform != 'darwin' or platform_machine != 'x86_64'
|
||||
Line 297: langchain-huggingface → sys_platform != 'darwin' or platform_machine != 'x86_64'
|
||||
Line 310: docling → sys_platform != 'darwin' or platform_machine != 'x86_64'
|
||||
Line 312: easyocr → sys_platform != 'darwin' or platform_machine != 'x86_64'
|
||||
Line 327: cuga → sys_platform != 'darwin' (non-Mac) + sys_platform == 'darwin' and platform_machine == 'arm64' (Mac ARM)
|
||||
Line 331: mlx → sys_platform == 'darwin' and platform_machine == 'arm64' and python_version >= '3.12'
|
||||
Line 345: gassist → sys_platform == 'win32'
|
||||
```
|
||||
|
||||
## Appendix B: Related Issues & PRs
|
||||
|
||||
| Reference | Description | Status |
|
||||
|-----------|-------------|--------|
|
||||
| LE-172 | PyTorch macOS x86_64 + Python 3.13 CI failure | ✅ Resolved (PR #12469) |
|
||||
| LE-265 | Broader deprecated macOS support investigation | This document |
|
||||
| [pytorch/pytorch#114602](https://github.com/pytorch/pytorch/issues/114602) | PyTorch RFC: Deprecate macOS x86_64 builds | Implemented |
|
||||
| PR #12469 | Exclude torch-dependent extras on macOS x86_64 | Merged/Open |
|
||||
@@ -43,6 +43,57 @@ import customComponent from "!!raw-loader!@hanzoflow/src/lfx/src/lfx/custom/cust
|
||||
/>
|
||||
```
|
||||
|
||||
## Docusaurus Versioning
|
||||
|
||||
The versioning configuration is found in `docusaurus.config.js`.
|
||||
|
||||
Versioning example for release version `1.9.x` on top of `1.8.x`:
|
||||
|
||||
1. Before release, the docs in the active release branch should already be set to `1.8.x`, the current version.
|
||||
2. When ready to release `1.9.x`, create a branch and run `npm run docs:version -- 1.9.0` to snapshot the current docs.
|
||||
3. After creating a new version, update `docusaurus.config.js` to include the 1.9.0 release:
|
||||
|
||||
```javascript
|
||||
docs: {
|
||||
lastVersion: '1.9.0',
|
||||
versions: {
|
||||
'1.9.0': {
|
||||
label: '1.9.x',
|
||||
path: '1.9.0',
|
||||
},
|
||||
'1.8.0': {
|
||||
label: '1.8.x',
|
||||
path: '1.8.0',
|
||||
},
|
||||
},
|
||||
},
|
||||
```
|
||||
|
||||
4. Test the deployment locally:
|
||||
|
||||
```bash
|
||||
npm run build
|
||||
npm run serve
|
||||
```
|
||||
|
||||
5. Create a pull request to main, and merge to create your new release.
|
||||
6. To create version 2.0.x, repeat the process: update the active release branch docs to `2.0.x` when you begin working on it, then when ready to release, run `npm run docs:version -- 2.0.0`, update `docusaurus.config.js` with labels using `.x` notation, and merge to main.
|
||||
|
||||
- `lastVersion` = the most recent released version (shown as "latest" in the UI).
|
||||
|
||||
See the [Docusaurus docs](https://docusaurus.io/docs/versioning) for more info.
|
||||
|
||||
### Disable versioning
|
||||
|
||||
1. Remove the versions configuration from `docusaurus.config.js`.
|
||||
2. Delete the `docs/versioned_docs/` and `docs/versioned_sidebars/` directories.
|
||||
3. Delete `docs/versions.json`.
|
||||
|
||||
### References
|
||||
|
||||
- [Official Docusaurus Versioning Documentation](https://docusaurus.io/docs/versioning)
|
||||
- [Docusaurus Versioning Best Practices](https://docusaurus.io/docs/versioning#versioning-best-practices)
|
||||
|
||||
### Deployment
|
||||
|
||||
Using SSH:
|
||||
|
||||
@@ -0,0 +1,189 @@
|
||||
# Investigation: PyTorch on macOS AMD64 (x86_64) + Python 3.13
|
||||
|
||||
**Jira:** LE-172
|
||||
**Date:** 2026-04-02
|
||||
**Status:** Complete
|
||||
|
||||
---
|
||||
|
||||
## TL;DR
|
||||
|
||||
**PyTorch officially dropped macOS x86_64 (Intel) binary support after version 2.2.2** (milestone: PyTorch 2.3, March 2024). Since Python 3.13 was released in October 2024 — after this cutoff — **no official PyTorch wheel exists for macOS x86_64 + Python 3.13**. This is an upstream, permanent gap — not a bug in Langflow.
|
||||
|
||||
The failing CI job is `test-installation-experimental` on `macos-latest-large` (AMD64) with Python 3.13. The `continue-on-error: true` flag means it doesn't block releases, but the failure is persistent and expected.
|
||||
|
||||
---
|
||||
|
||||
## Root Cause Analysis
|
||||
|
||||
### 1. PyTorch macOS x86_64 Deprecation Timeline
|
||||
|
||||
| Event | Date | Reference |
|
||||
|-------|------|-----------|
|
||||
| RFC opened to deprecate macOS x86_64 builds | Nov 2023 | [pytorch/pytorch#114602](https://github.com/pytorch/pytorch/issues/114602) |
|
||||
| PyTorch 2.2.2 released (last x86_64 macOS wheels) | March 2024 | Last version with `cp310`, `cp311`, `cp312` macOS x86_64 wheels |
|
||||
| PyTorch 2.3.0 released (no macOS x86_64 wheels) | April 2024 | Milestone for x86_64 deprecation |
|
||||
| Python 3.13 released | Oct 2024 | After PyTorch dropped macOS x86_64 |
|
||||
|
||||
**Key fact:** PyTorch 2.2.2 has macOS x86_64 wheels for Python 3.10, 3.11, 3.12 — but **not 3.13**. And PyTorch 2.3+ does not publish macOS x86_64 wheels at all.
|
||||
|
||||
### 2. What the UV Resolver Does
|
||||
|
||||
When resolving for `macOS x86_64 + Python 3.13`, the uv lockfile shows:
|
||||
|
||||
```
|
||||
torch 2.2.2 → Python < 3.13, macOS x86_64 (has wheels: cp310, cp311, cp312)
|
||||
torch 2.2.2+cpu → Python >= 3.13, macOS x86_64 (NO WHEELS listed!)
|
||||
torch 2.10.0 → macOS arm64 (all Python versions)
|
||||
torch 2.10.0+cpu → Linux/Windows (all Python versions)
|
||||
```
|
||||
|
||||
The resolver picks `torch 2.2.2+cpu` for macOS x86_64 + Python 3.13, but **this version has zero available wheels** for that platform. Installation fails because there's no compatible binary.
|
||||
|
||||
### 3. How Torch Gets Pulled In
|
||||
|
||||
The main `langflow` package depends on `langflow-base[complete]`, which includes multiple extras that transitively require torch:
|
||||
|
||||
#### Dependency Chains to Torch
|
||||
|
||||
| Extra | Chain | macOS x86_64 Excluded? |
|
||||
|-------|-------|----------------------|
|
||||
| `altk` | `agent-lifecycle-toolkit` → torch (direct dep) | **No** |
|
||||
| `altk` | `agent-lifecycle-toolkit` → `sentence-transformers` → torch | **No** |
|
||||
| `altk` | `agent-lifecycle-toolkit` → `trl` → `accelerate` → torch | **No** |
|
||||
| `docling` | `docling` → `docling-ibm-models` → torch | Partially (docling binary excluded, but `docling-core` is not) |
|
||||
| `easyocr` | `easyocr` → torch | **Yes** (fully excluded on macOS x86_64) |
|
||||
| `langchain-huggingface` | `langchain-huggingface` → `sentence-transformers` → torch | **No** |
|
||||
|
||||
**The ALTK extra is the primary unguarded path** that forces torch installation on macOS x86_64. The issue was initially noticed with ALTK but the root cause is PyTorch's platform support.
|
||||
|
||||
### 4. Existing Mitigations
|
||||
|
||||
The codebase already excludes some torch-dependent packages on macOS x86_64:
|
||||
|
||||
```toml
|
||||
# src/backend/base/pyproject.toml
|
||||
docling = [
|
||||
"docling-core>=2.36.1,<3.0.0",
|
||||
"docling>=2.36.1,<3.0.0; sys_platform != 'darwin' or platform_machine != 'x86_64'",
|
||||
]
|
||||
easyocr = ["easyocr>=1.7.2,<2.0.0; sys_platform != 'darwin' or platform_machine != 'x86_64'"]
|
||||
```
|
||||
|
||||
However, **ALTK** and **langchain-huggingface** (which depends on sentence-transformers → torch) have **no such platform exclusion**.
|
||||
|
||||
### 5. CI Configuration
|
||||
|
||||
The experimental job in `.github/workflows/cross-platform-test.yml`:
|
||||
- Tests Python 3.13 on all platforms including macOS AMD64
|
||||
- Uses `continue-on-error: true` so failures don't block releases
|
||||
- The test-summary job considers experimental failures "acceptable"
|
||||
|
||||
---
|
||||
|
||||
## Recommendations
|
||||
|
||||
### Option A: Exclude torch-dependent extras on macOS x86_64 (Recommended)
|
||||
|
||||
Add platform markers to all extras that transitively depend on torch, similar to what's done for `easyocr`:
|
||||
|
||||
```toml
|
||||
# In src/backend/base/pyproject.toml
|
||||
altk = ["agent-lifecycle-toolkit~=0.4.4; sys_platform != 'darwin' or platform_machine != 'x86_64'"]
|
||||
langchain-huggingface = ["langchain-huggingface==0.3.1; sys_platform != 'darwin' or platform_machine != 'x86_64'"]
|
||||
```
|
||||
|
||||
**Pros:**
|
||||
- CI will pass on all experimental platforms
|
||||
- macOS x86_64 users on Python 3.13 can still use Langflow (just without ALTK/HuggingFace features)
|
||||
- Consistent with existing pattern for docling/easyocr
|
||||
|
||||
**Cons:**
|
||||
- Reduces feature availability on macOS x86_64
|
||||
- All Python versions on macOS x86_64 lose these features (not just 3.13)
|
||||
|
||||
### Option B: Add Python version + platform compound markers
|
||||
|
||||
More surgical approach — only exclude on the exact broken combination:
|
||||
|
||||
```toml
|
||||
altk = ["agent-lifecycle-toolkit~=0.4.4; (sys_platform != 'darwin' or platform_machine != 'x86_64' or python_version < '3.13')"]
|
||||
```
|
||||
|
||||
**Pros:**
|
||||
- Preserves functionality on macOS x86_64 + Python 3.10-3.12
|
||||
- Only excludes the broken combination
|
||||
|
||||
**Cons:**
|
||||
- More complex markers
|
||||
- PEP 508 marker evaluation of compound OR conditions can be tricky across tools
|
||||
|
||||
### Option C: Remove macOS AMD64 from experimental CI matrix (Simplest)
|
||||
|
||||
Since macOS x86_64 is a dying platform (Apple stopped selling Intel Macs in 2022), remove it from CI:
|
||||
|
||||
```yaml
|
||||
# Remove this entry from the experimental matrix:
|
||||
# - os: macos
|
||||
# arch: amd64
|
||||
# runner: macos-latest-large
|
||||
# python-version: "3.13"
|
||||
```
|
||||
|
||||
**Pros:**
|
||||
- Simplest fix (one line change)
|
||||
- Saves CI runner costs (macos-latest-large is expensive)
|
||||
- Reflects reality that macOS Intel is EOL
|
||||
|
||||
**Cons:**
|
||||
- Loses visibility into macOS x86_64 regressions
|
||||
- Some users may still be on Intel Macs
|
||||
|
||||
### Option D: Keep as-is (Status Quo)
|
||||
|
||||
The experimental job already uses `continue-on-error: true` and the test-summary job reports it as acceptable. This failure is a known, documented limitation.
|
||||
|
||||
**Pros:**
|
||||
- No code changes needed
|
||||
- Maintains CI visibility
|
||||
|
||||
**Cons:**
|
||||
- Persistent red CI job creates alert fatigue
|
||||
- Could mask other real failures in the experimental matrix
|
||||
|
||||
---
|
||||
|
||||
## Recommendation
|
||||
|
||||
**Option A + Option C combined** is the strongest approach:
|
||||
|
||||
1. **Add platform exclusion markers** to `altk` and `langchain-huggingface` extras for macOS x86_64 (Option A). This prevents installation failures for end users on Intel Macs.
|
||||
|
||||
2. **Optionally remove macOS AMD64 from the Python 3.13 experimental matrix** (Option C) to reduce CI costs and noise, since the platform is EOL from both Apple and PyTorch.
|
||||
|
||||
The combination ensures both end-user experience and CI health. The feature loss on macOS Intel is acceptable because:
|
||||
- Apple stopped producing Intel Macs in June 2022
|
||||
- PyTorch dropped Intel Mac support in March 2024
|
||||
- The HuggingFace/ML ecosystem is rapidly moving to ARM-only on macOS
|
||||
|
||||
---
|
||||
|
||||
## Appendix: Affected Lockfile Entries
|
||||
|
||||
### torch versions resolved by platform
|
||||
|
||||
| Version | Platform | Python | Has Wheels? |
|
||||
|---------|----------|--------|-------------|
|
||||
| `2.2.2` | macOS x86_64 | < 3.13 | Yes (cp310, cp311, cp312) |
|
||||
| `2.2.2+cpu` | macOS x86_64 | >= 3.13 | **No** |
|
||||
| `2.10.0` | macOS arm64 | all | Yes (cp310-cp313) |
|
||||
| `2.10.0+cpu` | Linux/Windows | all | Yes (cp310-cp313) |
|
||||
|
||||
### torchvision versions resolved by platform
|
||||
|
||||
| Version | Platform | Python | Has Wheels? |
|
||||
|---------|----------|--------|-------------|
|
||||
| `0.17.2` | macOS x86_64 | < 3.13 | Yes (cp310, cp311, cp312) |
|
||||
| `0.17.2+cpu` | macOS x86_64 | >= 3.13 | **No** |
|
||||
| `0.25.0` | macOS arm64 | all | Yes |
|
||||
| `0.25.0+cpu` | Linux/Windows | all | Yes |
|
||||
@@ -0,0 +1,79 @@
|
||||
# Anti-Patterns and Scar Tissue
|
||||
|
||||
These are battle scars. Every rule here cites a real recurring failure: a commit, a revert, or an explicit project lesson. Read this file when you're about to "just" change something — most of the don't/do rules below started as a one-line "small fix" that broke production.
|
||||
|
||||
## Don't / Do (with why)
|
||||
|
||||
1. **Don't add or rename a field on a component without regenerating starter projects AND the component index.**
|
||||
*Why:* commits `1a9f4548c`, `5987421bd`, `bbe1dad7c`, `69d29a4c6`, `67af71450`, `4f9beebc0` are all `fix:` commits cleaning up the same "I added a field and shipped" mistake. CI (`9dad1965c`) now syncs the index on label addition — that's a backstop, not a substitute.
|
||||
|
||||
2. **Don't rename a component class, display name, or module path.** If unavoidable, add the new component alongside the old one, set `legacy = True` + `replacement = ["<category>.<NewClassName>"]` on the old class, and update every supported version's `file_names_mapping` in tests.
|
||||
*Why:* class names key saved flows. There is no class-rename map in `setup.py` — the `type_migrations` map there only handles output type strings (`Data` → `JSON`, `DataFrame` → `Table`). See `2a7c56e84` (`IngestionDescriberComponent` → `FileDescriptionGeneratorComponent`) and `f5a0d52197` (starter project rename) for the cleanup churn. See [CONTRACTS.md](./CONTRACTS.md) for the full list.
|
||||
|
||||
3. **Don't mock at a different boundary than production calls.** If production uses `subprocess.Popen`, mock `Popen` — not `run`.
|
||||
*Why:* `a747135d1`, `bc433c375` — mocked tests passed; production failed. **Better: don't mock at all** — project policy is "Avoid mocking in tests." Use real integrations or `MockLanguageModel` for pure-logic LLM paths.
|
||||
|
||||
4. **Don't write a Graph test by poking internals.**
|
||||
*Why:* the canonical pattern is build → connect via `.set()` → call `async_start` → iterate results → validate. Tests that bypass this don't exercise the graph the way users do.
|
||||
|
||||
5. **Don't add a retry, broaden an except, or widen a type to make a symptom go away.**
|
||||
*Why:* "It is not a fix if you didn't have evidence of the change fixing something." See revert chain `9cdbf1b23`, `35aad2f2e`, `e3b90b7351` — fixes that masked rather than solved, then had to be reverted.
|
||||
|
||||
6. **Don't bump a dependency speculatively.** Confirm the conflict reproduces locally and the new version actually resolves it.
|
||||
*Why:* `cf659f0d0`, `ef6a303406`, `38d142a72`, `9029c4b61` — repeated dep-conflict cleanup.
|
||||
|
||||
7. **Don't invent model IDs, MCP tool names, or API surface.** Read the registry, the batch dispatcher, and `models.py` first.
|
||||
*Why:* `9f1402ed9` (`remove fictional gpt-5.3 ids`), `cecc4a6c2` (`expose layout tool as layout_flow to match batch dispatch`).
|
||||
|
||||
8. **Don't use raw `langflow run` / `python` when a `uv` command exists.** Always `uv run`. For lfx tests specifically: `uv sync` inside `src/lfx` (not `src/lfx/src/lfx`) to avoid pulling in langflow.
|
||||
*Why:* lfx must be testable without langflow installed; mixing them masks dependency leaks.
|
||||
|
||||
9. **Don't add a database migration without running `make alembic-upgrade` end to end** and `uv run pytest src/backend/tests/unit/test_database.py` sequentially.
|
||||
*Why:* `d8b9cc38f`, `c30150a2d`, `6585fe661`. `test_database.py` may pass in batch and fail individually — agents have stopped running it.
|
||||
|
||||
10. **Don't claim a race condition is fixed without an integration test that reproduced it.** Agent double-execution and queue leaks keep coming back.
|
||||
*Why:* `8a993ac90`, `42e84d0fd`, `749865b31`, `f30b291f8`.
|
||||
|
||||
11. **Don't add `tool_mode=True` to outputs that confuse agents** (e.g., dataframe surfaces).
|
||||
*Why:* `cbc54108` (`remove tool_mode=True for as_dataframe`), `35aad2f2e` (revert of the original disable). Output tool exposure is a user contract — don't toggle it back and forth.
|
||||
|
||||
12. **Don't widen the scope of a PR.** Closely related follow-ups belong in the existing open PR, not a new stacked PR.
|
||||
|
||||
13. **Don't ship without running the tests locally.** Typing the test command in the chat is not running it.
|
||||
|
||||
14. **Don't make incremental "wip" commits.** One coherent commit per logical change, after everything is done and tested.
|
||||
|
||||
15. **Don't push or open PRs without explicit user confirmation.** Pushing during active CI iteration on an already-open PR is fine; opening a new PR or pushing new branches is not.
|
||||
|
||||
## Things that look like a fix but aren't
|
||||
|
||||
- Adding `try/except Exception: pass` around a flaky call.
|
||||
- Adding a `time.sleep` to dodge a race instead of awaiting a condition.
|
||||
- Bumping a dependency version "just in case."
|
||||
- Marking a failing test `@pytest.mark.skip` without a linked issue.
|
||||
- Switching `# type: ignore` instead of fixing the type.
|
||||
- Reverting symptoms (a UI flag, a tool flag) instead of finding the root.
|
||||
- "Fixing" a test by changing its assertion to match wrong output.
|
||||
|
||||
## Before claiming done
|
||||
|
||||
- [ ] `make format_backend` and `make lint` clean.
|
||||
- [ ] Tests for the changed surface ran and passed locally — not just typed out.
|
||||
- [ ] If a component field/output changed: starter projects regenerated and `component_index.json` rebuilt.
|
||||
- [ ] If a class/module renamed: old class kept with `legacy=True` + `replacement=[...]`; `file_names_mapping` updated for every supported version.
|
||||
- [ ] No incremental "wip" commits. One coherent commit per logical change.
|
||||
- [ ] No "Generated with Claude Code" / `Co-Authored-By: Claude` trailers.
|
||||
- [ ] No `--no-verify` unless explicitly authorized.
|
||||
- [ ] PR not pushed and PR not opened until the user explicitly asks.
|
||||
- [ ] PR description has no test-plan checklist, no Jira links/IDs.
|
||||
|
||||
## Where to look for context — don't invent it
|
||||
|
||||
- **Starter projects:** `src/backend/base/langflow/initial_setup/starter_projects/`. Any field/output change must regenerate these.
|
||||
- **Component index:** `src/lfx/src/lfx/_assets/component_index.json` (rebuilt via the index sync workflow added in `9dad1965c`).
|
||||
- **Output type migrations:** `src/backend/base/langflow/initial_setup/setup.py` `type_migrations` map — handles renames of *output type strings* (e.g., `Data` → `JSON`). It does NOT remap component class names; for class renames use `legacy=True` + `replacement=[...]`.
|
||||
- **Version mapping for component tests:** `src/backend/tests/constants.py::SUPPORTED_VERSIONS` and each test's `file_names_mapping`.
|
||||
- **Release notes / changelog:** `docs/docs/Support/release-notes/` — check before claiming behavior is "new"; many features predate the current task.
|
||||
- **MCP tool catalog:** dispatched via the batch tool — names must match (`cecc4a6c2` is the canonical "I guessed the tool name" failure).
|
||||
- **Models / providers:** real model IDs come from the provider's listing endpoint or our adapter, never from training-data memory (`9f1402ed9`).
|
||||
- **Migrations:** `src/backend/base/langflow/alembic/versions/` — always read the latest two before adding a new one.
|
||||
@@ -0,0 +1,81 @@
|
||||
# Architecture Boundaries
|
||||
|
||||
Langflow is three Python packages and one frontend, layered with one-way dependencies. Most "off-narrative" code is a boundary violation. Read this before adding a new file.
|
||||
|
||||
## Package dependency graph (one-way only)
|
||||
|
||||
```
|
||||
frontend (TS) ──HTTP──▶ langflow (api routers, integrations, distribution)
|
||||
│
|
||||
▼ may import
|
||||
langflow-base (services, graph, db, alembic)
|
||||
│
|
||||
▼ may import
|
||||
lfx (executor core, base primitives, components)
|
||||
│
|
||||
▼ may import
|
||||
langchain-core, pydantic, third-party SDKs
|
||||
```
|
||||
|
||||
### Dependency rules
|
||||
|
||||
- **`lfx` MUST NOT import `langflow.*` or `langflow-base.*`.** If lfx code needs a service (auth, db, flow lookup), define an interface inside `lfx` and inject the implementation from `langflow`. The repo currently has ~13 upward `from langflow.*` imports inside `src/lfx/src/lfx/components/...` (auth, db, helpers) — these are **known violations**. Do not add more; prefer fixing them.
|
||||
- **`langflow-base` MAY import `lfx`.** It MUST NOT import vendor-specific component modules from `langflow.components.<vendor>`.
|
||||
- **`frontend` talks to `langflow` only via HTTP/WebSocket.** No shared filesystem state.
|
||||
|
||||
## "Where does this code go?" decision tree
|
||||
|
||||
Walk top-down. Stop at the first match.
|
||||
|
||||
1. Is it framework-agnostic flow execution, base component classes, or `Component` primitives?
|
||||
→ `src/lfx/src/lfx/` (`base/` for shared primitives, `components/` for built-ins shipped with lfx).
|
||||
2. Is it a FastAPI route, auth, db model, alembic migration, or a lifecycle-managed singleton?
|
||||
→ `src/backend/base/langflow/` (`api/`, `services/X/`, `alembic/versions/`).
|
||||
3. Is it a vendor integration (OpenAI, Pinecone, Notion, …) — a `Component` subclass that wraps a third-party SDK?
|
||||
→ `src/lfx/src/lfx/components/<category>/` and update its `__init__.py` alphabetically. Never rename the class.
|
||||
4. Is it UI, state, or icons?
|
||||
→ `src/frontend/src/`. If it consumes a new API field, also update `src/frontend/src/types/`.
|
||||
5. Is it CLI behavior for `lfx run` / `lfx serve`?
|
||||
→ `src/lfx/src/lfx/cli/`.
|
||||
6. Is it a SQLAlchemy/SQLModel model change?
|
||||
→ `services/database/models/` AND `make alembic-revision message="..."` AND apply with `make alembic-upgrade`.
|
||||
7. Is it a flow JSON schema change?
|
||||
→ STOP. Existing saved flows must keep loading. Add a version mapping; do not mutate the existing shape. See [CONTRACTS.md](./CONTRACTS.md).
|
||||
8. Is it shared by both `lfx` and `langflow-base`?
|
||||
→ `src/lfx/src/lfx/base/`, never `langflow/base/`.
|
||||
|
||||
## Dependency direction — bad/good examples
|
||||
|
||||
- **Bad:** `from langflow.services.deps import session_scope` inside `src/lfx/...`.
|
||||
**Good:** Define `lfx.interfaces.SessionProvider`, accept it as a constructor arg; `langflow` wires the concrete `session_scope` at startup.
|
||||
- **Bad:** `from langflow.components.openai import ...` inside `langflow-base` core (`api/`, `services/`, `graph/`).
|
||||
**Good:** Components are loaded dynamically via the component registry; core code references `Component` only.
|
||||
- **Bad:** A new `MyHelperService` that's just functions.
|
||||
**Good:** Utility functions go in `langflow/helpers/` or `lfx/utils/`. A service inherits from `services/base.Service` and is registered via `services/factory.py`.
|
||||
|
||||
## API change protocol
|
||||
|
||||
- **`api/v1/`** is the live, stable surface (~25 routers). Existing v1 endpoints MUST stay backwards-compatible: only additive fields, never rename or remove.
|
||||
- **`api/v2/`** is the **active redesign surface** (`files`, `mcp`, `registration`, `workflow`) — both are mounted at runtime in `api/router.py`. v2 is **not** "future"; the old cursor rule was wrong.
|
||||
- Add a new endpoint to v2 only if it (a) replaces a v1 endpoint with a breaking shape change, or (b) belongs to one of the four v2 domains. Otherwise extend v1 additively.
|
||||
- A breaking change to a v1 endpoint is forbidden. Add a v2 sibling and leave v1 in place.
|
||||
|
||||
## Cross-cutting change protocol
|
||||
|
||||
A change that touches a request/response shape MUST update three places in the same PR:
|
||||
|
||||
1. The pydantic model in `langflow/api/v{1,2}/schemas.py` (or the route's local schema).
|
||||
2. The TypeScript type in `src/frontend/src/types/` consumed by the affected page/store. There is no OpenAPI generator — the types are hand-maintained, so the frontend silently breaks at runtime if you skip this.
|
||||
3. If the field is persisted: a new alembic revision (`make alembic-revision message=...`) AND a flow-JSON version mapping if the shape lives inside saved flows.
|
||||
|
||||
If you cannot do all three in one PR, do not start.
|
||||
|
||||
## Service vs utility vs component
|
||||
|
||||
- **Service** (`services/<name>/`): lifecycle-managed singleton, inherits `services.base.Service`, registered through `services/factory.py`, accessed via `services/deps.py`. Use when the thing has state, startup/shutdown, or shared connections (db, cache, queue).
|
||||
- **Utility** (`helpers/`, `utils/`, or `lfx/utils/`): pure or near-pure functions. Use when there is no shared state and no lifecycle.
|
||||
- **Component** (`src/lfx/src/lfx/components/<category>/`): user-visible node in the graph, subclass of `Component`, with `display_name`, `inputs`, `outputs`. Use only when the user must wire it on the canvas. Do not add a Component to expose internal plumbing.
|
||||
|
||||
## `lfx/base/` vs `langflow/base/`
|
||||
|
||||
Both exist. Both have `agents/`, `data/`, `models/`, `prompts/`. New shared primitives go in **`src/lfx/src/lfx/base/`**. The `langflow/base/` tree is legacy; do not add to it.
|
||||
@@ -0,0 +1,138 @@
|
||||
# Component Development
|
||||
|
||||
Components are the unit of work in Langflow. They render as nodes on the canvas, persist into flow JSON, and become tools for Agents. Get the contract right and the rest of the system follows; get it wrong and you orphan every saved flow that referenced your component.
|
||||
|
||||
Components live in **`src/lfx/src/lfx/components/<category>/`**. The mirror tree under `src/backend/base/langflow/components/` is legacy stubs — do not add files there.
|
||||
|
||||
## Before you add a component (decision checklist)
|
||||
|
||||
Stop and check, in order. If any answer is "yes," do not create a new component.
|
||||
|
||||
1. **Does one already exist?** Search:
|
||||
```bash
|
||||
rg -l 'display_name = ".*<keyword>.*"' src/lfx/src/lfx/components/
|
||||
```
|
||||
and check the category folder. The Calculator regret (two of them, see `tools/calculator.py` `legacy = True, replacement = ["helpers.CalculatorComponent"]`) is what happens when this step is skipped.
|
||||
2. **Is this a thin wrapper around an existing tool/SDK that an Agent could call directly via `tool_mode=True`?** If yes, add `tool_mode=True` to the relevant input on the existing component instead of building a new one.
|
||||
3. **Does it belong in a vendor bundle** (e.g., `openai/`, `anthropic/`, `datastax/`) rather than in generic `tools/` or `helpers/`? Vendor logic goes in vendor folders so bundles can ship/version independently.
|
||||
4. **Is this an lfx-runtime concern** (graph execution, schema, IO primitives) or a **Langflow-app concern** (auth, DB, tracing)? Components are lfx; never import `langflow.services.*` from a component.
|
||||
5. **Does an existing base class cover this category?** Use it:
|
||||
- LLMs → `LCModelComponent` (see `openai/openai_chat_model.py`)
|
||||
- Tools → `LCToolComponent` (see `tools/calculator.py`)
|
||||
- Vector stores → `LCVectorStoreComponent` + `@check_cached_vector_store` (see `vectorstores/local_db.py`)
|
||||
- Chat IO → `ChatComponent` (see `input_output/chat.py`)
|
||||
|
||||
## Scope rules (one component, one job)
|
||||
|
||||
- One job per component. If the description has " and " in it, split it.
|
||||
- **Inputs budget:** aim for ≤8 visible inputs; push the rest behind `advanced=True`. If you need a mode switch (Ingest/Retrieve etc.), use `TabInput` + `update_build_config` to hide irrelevant fields — see `vectorstores/local_db.py`.
|
||||
- **Outputs:** one primary `Output(method=...)` is the default. Add more only if downstream nodes genuinely need different shapes (Message vs DataFrame vs Data). Don't add an output you don't use.
|
||||
- Mark inputs an Agent should be able to fill with `tool_mode=True` (e.g., `local_db.py`) — this is how a component becomes an agent tool.
|
||||
|
||||
## Breaking-change list (locked once shipped)
|
||||
|
||||
Saved flows reference components by string identifiers. Once a component is merged, the following are **frozen** — change them and existing user flows break silently:
|
||||
|
||||
- The **class name** (already in our rules).
|
||||
- The **`name = "..."` class attribute**. **This is the flow-JSON identifier**, not the class name. See `openai_chat_model.py`, `chat.py`.
|
||||
- Each input **`name=...`**. Renaming `input_value` → `prompt` orphans every edge pointing at it.
|
||||
- Each output **`name=...`** and its declared return type. Downstream nodes match on both.
|
||||
- **Default values** and default behavior of existing inputs (a flow saved with the default re-loads with the new default).
|
||||
|
||||
To remove or rework a component, **do not edit it in place**. Add the replacement under the right category, then on the old one set:
|
||||
|
||||
```python
|
||||
legacy = True
|
||||
replacement = ["<category>.<NewClassName>"]
|
||||
```
|
||||
|
||||
(see `tools/calculator.py`, `flow_controls/sub_flow.py`). The UI shows "Updates Available" and migrates users; the old class stays importable.
|
||||
|
||||
See [CONTRACTS.md](./CONTRACTS.md) for the full user-facing surface this protects.
|
||||
|
||||
## Conventions agents miss
|
||||
|
||||
These appear all over the codebase but rarely in old docs.
|
||||
|
||||
- **`name`** (class attr): flow-JSON identifier. Always set it explicitly; do not rely on `__class__.__name__`. Example: `chat.py` `name = "ChatInput"`.
|
||||
- **`legacy = True` + `replacement = [...]`**: deprecation pair. Always together. Example: `tools/calculator.py`.
|
||||
- **`tool_mode=True`** on an input: exposes the component as an Agent tool with that input as the tool argument. Example: `vectorstores/local_db.py`.
|
||||
- **`real_time_refresh=True` + `update_build_config(self, build_config, value, name)`**: dynamic form. Use for mode switches and dependent-field hide/show. Examples: `openai/openai_chat_model.py`, `vectorstores/local_db.py`.
|
||||
- **`metadata = {"keywords": [...]}`**: extra search terms for the component picker. Example: `data_source/sql_executor.py`.
|
||||
- **`minimized = True`**: render collapsed by default. Example: `input_output/chat.py`.
|
||||
- **`documentation = "https://docs.langflow.org/..."`**: deep-link from the node UI. Example: `input_output/chat.py`.
|
||||
|
||||
## Placement rules
|
||||
|
||||
- **Vendor-specific code** → vendor folder (`openai/`, `anthropic/`, `datastax/`, `cohere/`, …). Not `tools/`, not `models/`.
|
||||
- **Generic, vendor-agnostic helpers** → `helpers/`, `processing/`, `logic/`, `flow_controls/`.
|
||||
- **IO primitives** → `input_output/`.
|
||||
- Add the import to the category `__init__.py` in **alphabetical order**.
|
||||
- **Do not invent a new top-level category.** If you think you need one, that is a signal to discuss with the team first — categories are visible in the UI sidebar and are part of the product surface.
|
||||
|
||||
## Icons
|
||||
|
||||
Every component needs an icon. Use a Lucide icon when one fits; create a custom SVG only for vendor logos.
|
||||
|
||||
### Lucide icon (default)
|
||||
|
||||
```python
|
||||
icon = "calculator" # any Lucide icon name, lowercase
|
||||
```
|
||||
|
||||
See https://lucide.dev/icons for the catalog.
|
||||
|
||||
### Custom vendor icon
|
||||
|
||||
For brand logos you need a frontend SVG component. The Python `icon` string and the frontend mapping key must match **exactly** (case-sensitive).
|
||||
|
||||
1. **Python:** set `icon = "AstraDB"` on the component.
|
||||
2. **Frontend SVG component** at `src/frontend/src/icons/AstraDB/AstraDB.jsx`:
|
||||
```jsx
|
||||
const AstraSVG = (props) => (
|
||||
<svg {...props}>
|
||||
<path fill={props.isDark ? "#ffffff" : "#0A0A0A"} d="..." />
|
||||
</svg>
|
||||
);
|
||||
```
|
||||
3. **Wrapper** at `src/frontend/src/icons/AstraDB/index.tsx`:
|
||||
```tsx
|
||||
import React, { forwardRef } from "react";
|
||||
import AstraSVG from "./AstraDB";
|
||||
|
||||
export const AstraDBIcon = forwardRef<SVGSVGElement, React.PropsWithChildren<{}>>(
|
||||
(props, ref) => <AstraSVG ref={ref} isDark={isDark} {...props} />
|
||||
);
|
||||
```
|
||||
4. **Lazy import** in `src/frontend/src/icons/lazyIconImports.ts`:
|
||||
```ts
|
||||
AstraDB: () =>
|
||||
import("@/icons/AstraDB").then((mod) => ({ default: mod.AstraDBIcon })),
|
||||
```
|
||||
5. Verify in the UI in **both light and dark mode**.
|
||||
|
||||
### Icon checklist
|
||||
|
||||
- [ ] Python `icon = "..."` set.
|
||||
- [ ] If custom: SVG component with `isDark` prop, wrapper with `forwardRef`, entry in `lazyIconImports.ts`.
|
||||
- [ ] Light and dark mode both verified.
|
||||
|
||||
## Component testing
|
||||
|
||||
See [TESTING.md](./TESTING.md) for the full testing contract. Quick reference:
|
||||
|
||||
- Inherit from `ComponentTestBaseWithClient` (needs API) or `ComponentTestBaseWithoutClient` (pure logic).
|
||||
- Provide three fixtures: `component_class`, `default_kwargs`, `file_names_mapping`.
|
||||
- Use `MockLanguageModel` for pure-logic LLM paths; use `@pytest.mark.api_key_required` for real-API tests.
|
||||
- For graph behavior, use the Graph test pattern: build, `.set()`, `async_start`, validate. Do not poke graph internals.
|
||||
|
||||
## Canonical examples
|
||||
|
||||
When in doubt, read these files before starting:
|
||||
|
||||
- LLM with dynamic provider switching: `src/lfx/src/lfx/components/openai/openai_chat_model.py`
|
||||
- Tool with `tool_mode` and legacy/replacement: `src/lfx/src/lfx/components/tools/calculator.py`
|
||||
- Vector store with cache + tab inputs: `src/lfx/src/lfx/components/vectorstores/local_db.py`
|
||||
- Chat input with `minimized`: `src/lfx/src/lfx/components/input_output/chat.py`
|
||||
- Component with searchable metadata: `src/lfx/src/lfx/components/data_source/sql_executor.py`
|
||||
- Legacy + replacement pattern: `src/lfx/src/lfx/components/flow_controls/sub_flow.py`
|
||||
@@ -0,0 +1,57 @@
|
||||
# User-Facing Contracts
|
||||
|
||||
These are the surfaces users depend on. Breaking them silently is the most common way an agent ships work that "doesn't connect to the story." Read this before changing anything below.
|
||||
|
||||
## The contract surface
|
||||
|
||||
| # | Contract | Source of truth | Rule |
|
||||
|---|----------|-----------------|------|
|
||||
| 1 | Component class `name` attribute | `class XComponent: name = "X"` | NEVER rename. Used to match nodes in saved flow JSON. |
|
||||
| 2 | Component class identifier (Python class name) | `class XComponent` | NEVER rename. Combined with `name` for resolution. |
|
||||
| 3 | Component file path + module | `src/lfx/src/lfx/components/<cat>/<file>.py` | Renames are breaking. The supported workflow is to add the new component alongside the old one and set `legacy = True` + `replacement = ["<category>.<NewClassName>"]` on the old class. Update every component's `file_names_mapping` in tests for past `SUPPORTED_VERSIONS` (`src/backend/tests/constants.py`). Regenerate starter projects and rebuild the component index. |
|
||||
| 4 | Input `name=` and `Output(name=...)` on every component | The component file itself | NEVER rename or remove. Saved flows reference these as keys. Adding new optional inputs is safe; removing or renaming breaks every saved flow that used them. |
|
||||
| 5 | Output order and `Output.types` | Component `outputs = [...]` list | Reordering changes default selection in old flows. Tightening a type is a breaking change; widening is safe. |
|
||||
| 6 | Default values for inputs | `default=` / `value=` in input spec | Changing a default silently changes behavior for users who relied on the default. Treat as a breaking change unless the old default was a bug. |
|
||||
| 7 | Flow JSON schema | `src/backend/base/langflow/initial_setup/starter_projects/*.json` (canonical examples) | Top-level `data.nodes[*].data.node` shape and `data.edges` shape are public. New fields must be optional with defaults. |
|
||||
| 8 | Public REST API | `docs/docs/API-Reference/api-flows-run.mdx`, `api-build.mdx`, `api-files.mdx`, `api-projects.mdx`, `api-logs.mdx`, `api-monitor.mdx`, `api-users.mdx`. Endpoints with `include_in_schema=False` are internal. | Documented endpoints are stable. `POST /api/v1/run/{flow_id_or_name}` and `POST /api/v1/webhook/{flow_id_or_name}` are user contracts — payload shape and status codes are frozen. |
|
||||
| 9 | MCP tool exposure | `src/backend/base/langflow/api/v1/mcp.py`, `mcp_projects.py`. Tool-mode toggled by `Output.tool_mode` / outputs named `component_as_tool`. | Removing `tool_mode=True` from an existing component output, or changing its name, breaks every Agent flow using it as a tool. |
|
||||
| 10 | `Message` / `Data` / `DataFrame` schema | `src/lfx/src/lfx/schema/message.py` (`text`, `sender`, `sender_name`, `session_id`, `flow_id`, `timestamp`, `properties`, `content_blocks`, `category`, `files`, `error`, `edit`, `duration`, `session_metadata`) | Inter-component wire format. Add fields with defaults only. Renaming or retyping any listed field breaks every running flow. |
|
||||
| 11 | Environment variables | `LANGFLOW_*` (server) defined in `src/backend/base/langflow/services/settings/base.py` and `feature_flags.py`. `LFX_*` (executor) defined in `src/lfx/src/lfx/services/settings/base.py`, except a few read directly via `os.getenv` (e.g., `LFX_DEV` in `src/lfx/src/lfx/interface/components.py`). | Public deployment contract. Renaming requires deprecation cycle reading both names. |
|
||||
| 12 | Database schema | `src/backend/base/langflow/services/database/models/` + `alembic/versions/`. | NEVER edit a model without `make alembic-revision`. Never edit a past migration. Custom-component Python source is stored in user DBs — refactoring an import path used by `from langflow.X import Y` breaks loading those rows. |
|
||||
| 13 | Starter project JSON | `src/backend/base/langflow/initial_setup/starter_projects/*.json` | Each references real components by `name`/`type`. Renaming a component, removing an input, or changing an output type breaks loading. After ANY component change, re-load the affected starter project. |
|
||||
| 14 | Webhook payload shape | `POST /api/v1/webhook/{flow_id_or_name}` | External systems POST here. Response status (202 Accepted) and the `dict` body shape are frozen. |
|
||||
| 15 | Component index | `src/lfx/src/lfx/_assets/component_index.json` | Generated artifact consumed by the frontend. Any field/output addition requires regeneration; CI enforces this on label add. |
|
||||
|
||||
## Before-you-change matrix
|
||||
|
||||
| If you are about to... | Check / update |
|
||||
|---|---|
|
||||
| Rename a component file | Add the new file alongside; set `legacy = True` + `replacement = [...]` on the old class; update every test's `file_names_mapping`; grep `starter_projects/*.json` for the old type |
|
||||
| Rename an input `name=` | Don't. Add a new input alongside and deprecate the old via `legacy=True` on the component if necessary; grep `starter_projects/*.json` for the old name |
|
||||
| Add or rename an output | Regenerate starter projects; rebuild component index |
|
||||
| Change a default value | Treat as breaking. Grep starter projects + tests for reliance |
|
||||
| Remove `tool_mode=True` from an output | Search agent flows / starter projects using it as a tool; this is a user-visible regression |
|
||||
| Add a `Message` field | Must be `Optional` with default; never reorder existing |
|
||||
| Add a REST endpoint | If user-facing, add to `docs/docs/API-Reference/`; if internal, set `include_in_schema=False` |
|
||||
| Modify a DB model | `make alembic-revision message="..."`; never edit past revisions |
|
||||
| Change a `LANGFLOW_*` / `LFX_*` env var | Read old name as fallback for at least one minor version; document in release notes |
|
||||
| Change a request/response schema | Update pydantic schema + `src/frontend/src/types/` + alembic if persisted, all in one PR |
|
||||
|
||||
## Breaking changes that look harmless
|
||||
|
||||
These all look like cleanup. They are user-visible regressions.
|
||||
|
||||
- **Renaming `input_value` → `text`** on a component: 100% of saved flows referencing that input lose their wiring.
|
||||
- **Reordering `outputs = [a, b]` to `[b, a]`**: old flows with the default selection now route from the wrong output.
|
||||
- **Tightening `Output(types=["Message", "Data"])` → `["Message"]`**: edges that resolved as `Data` go invalid on load.
|
||||
- **Changing a `default="gpt-4o-mini"` to `default="gpt-5"`**: silently re-bills users.
|
||||
- **Moving `langflow/components/foo/bar.py` → `langflow/components/foo2/bar.py`**: custom components stored in user DBs that `from langflow.components.foo.bar import ...` fail on next load. `file_names_mapping` test passes because it only validates listed historical versions, not user code.
|
||||
- **Dropping `tool_mode=True` from a vector store's `as_dataframe`**: every Agent that called it as a tool now sees the tool disappear.
|
||||
- **Adding a required field to `Message`**: every queued message in a running deployment fails to deserialize on next read.
|
||||
- **Renaming a starter project file**: deep links from docs and tutorials 404.
|
||||
|
||||
## Why this matters
|
||||
|
||||
Langflow flows are **persisted user artifacts** running in production. The system that loads them is forgiving by design: it tolerates new fields, falls back on missing ones, applies type migrations on load. That tolerance is paid for by every contract in the table above. Break one and the system can no longer route around it — the flow stops working.
|
||||
|
||||
Treat this file as a checklist. If your change touches any row, the corresponding rule applies; no exceptions without an explicit deprecation plan.
|
||||
@@ -0,0 +1,33 @@
|
||||
# Project Philosophy
|
||||
|
||||
Read this file before scoping any non-trivial work. Most off-narrative work comes from skipping it.
|
||||
|
||||
Langflow is a **visual flow builder first**. Every change should make sense to someone whose only interface is dragging nodes onto a canvas. Use these principles to scope work; if a proposed change fails one of them, stop and reconsider.
|
||||
|
||||
## Tenets
|
||||
|
||||
1. **Flows are user artifacts, not implementation details.** Flow JSON lives in users' databases and runs in production. Backwards compatibility for components is non-negotiable: never rename a class name, `name` attribute, input name, or output name; never remove an input or output; never tighten an output type; never remove methods from base classes like `LCModelComponent`. To change a component, add the new one alongside, set `legacy=True` + `replacement=[...]` on the old, and update `file_names_mapping` in tests so saved flows still load. See [CONTRACTS.md](./CONTRACTS.md) for the full surface.
|
||||
|
||||
2. **Every backend feature must land on the canvas.** If a capability cannot be expressed as a component (or a property of one) that a non-Python user can wire up, it is an SDK feature and belongs in `lfx`, not `langflow-base`. Backend changes that have no visual surface are almost always off-narrative.
|
||||
|
||||
3. **Components are the unit of work, not endpoints or services.** Before adding a route, store, or service, ask: which component does this serve? If the answer is "none yet," the route is premature. New REST endpoints without a component or UI consumer are off-narrative.
|
||||
|
||||
4. **`lfx` is the runtime, `langflow-base` is the platform, `langflow` is the distribution.** Components and the graph engine live in `lfx` (`src/lfx/src/lfx/components/`). API, auth, persistence, and multi-user concerns live in `langflow-base`. The `langflow` package is the integration that ships everything together. New code goes in the lowest layer that can host it. Boundaries are enforced one-way: `langflow` → `langflow-base` → `lfx`. See [ARCHITECTURE.md](./ARCHITECTURE.md).
|
||||
|
||||
5. **Don't add a config flag for something a builder-user would set on a node.** If a setting affects flow behavior, it's a component input. If it affects deployment, it's an env var. Hidden global flags that change component semantics break the visible-data-flow contract.
|
||||
|
||||
6. **Visible data flow beats clever magic.** Implicit context, hidden globals, and side-channel state make flows unreadable on the canvas. Pass data through inputs and outputs. If you find yourself reaching for a global, you're probably building something that doesn't belong in a node.
|
||||
|
||||
7. **Composition over capability.** Prefer adding small, single-purpose components users can wire together over one large component with many modes. The `If-Else` / `Current Date` style (one job, clear name, obvious icon) is the target shape.
|
||||
|
||||
8. **Every component needs `display_name`, `description`, `icon`, and a sensible category.** A component that doesn't render legibly in the sidebar shouldn't ship. The icon is part of the API, not decoration — pick a Lucide icon that matches the verb.
|
||||
|
||||
9. **The Playground is the test harness builder-users see.** A component is "done" when it works end-to-end in a flow on the canvas, not when its unit test passes. Use the Graph test pattern (build, `.set()`, `async_start`, validate) before claiming a feature works.
|
||||
|
||||
10. **Two audiences, one product: visual builders and Python devs.** When their needs conflict, the visual builder wins for `langflow-base` features and the Python dev wins for `lfx` SDK features. Don't compromise the canvas to make the SDK prettier, and don't compromise the SDK to make the canvas easier.
|
||||
|
||||
11. **It is not a fix if you didn't have evidence of the change fixing something.** Adding error handling, retries, type widening, or skipping a flaky test does not constitute a fix. Reproduce the failure, demonstrate the fix removes it, then ship. See [ANTI-PATTERNS.md](./ANTI-PATTERNS.md).
|
||||
|
||||
## How to apply
|
||||
|
||||
When scoping a task, walk through the tenets and name the ones the work serves. If a change fails tenet 1, 2, or 3, stop and surface the conflict before writing code. If you can't tell which tenet a change serves, it's probably off-narrative — ask.
|
||||
@@ -0,0 +1,163 @@
|
||||
# Testing
|
||||
|
||||
Conventions for backend tests. Frontend testing follows the standard Jest/Playwright patterns documented in `src/frontend/`.
|
||||
|
||||
## Project policy: avoid mocking
|
||||
|
||||
Prefer real integrations. The pattern of "mocked test passes, production fails" has cost us multiple release cycles — see [ANTI-PATTERNS.md](./ANTI-PATTERNS.md) for the relevant commit history. Use mocks only when:
|
||||
|
||||
- The dependency is an LLM and the test exercises pure logic → use `MockLanguageModel` from `tests/unit/mock_language_model.py`.
|
||||
- The dependency is genuinely unreliable and orthogonal to what's being tested.
|
||||
|
||||
Otherwise: hit the real thing, mark the test with `@pytest.mark.api_key_required` if it needs credentials, and let CI gate it.
|
||||
|
||||
## Built-in fixtures
|
||||
|
||||
### `client` (FastAPI test client)
|
||||
|
||||
Defined in `src/backend/tests/conftest.py`. Async `httpx.AsyncClient` connected to the full app via `ASGITransport` + `LifespanManager`. Auto-configured with in-memory SQLite and mocked env vars. Skip with `@pytest.mark.noclient`.
|
||||
|
||||
```python
|
||||
async def test_login_endpoint(client):
|
||||
response = await client.post("api/v1/login", data={"username": "foo", "password": "bar"})
|
||||
assert response.status_code == 200
|
||||
```
|
||||
|
||||
For authenticated routes, also use the `logged_in_headers` fixture.
|
||||
|
||||
## Component test base classes
|
||||
|
||||
Located in `src/backend/tests/base.py`.
|
||||
|
||||
| Base class | Creates `client`? | Use for |
|
||||
|---|---|---|
|
||||
| `ComponentTestBase` | No | Component version testing core logic |
|
||||
| `ComponentTestBaseWithClient` | Yes | Components that hit backend services during `run()` |
|
||||
| `ComponentTestBaseWithoutClient` | No | Pure-logic components |
|
||||
|
||||
### Required fixtures
|
||||
|
||||
Every subclass provides three fixtures:
|
||||
|
||||
1. **`component_class`** — the component class under test.
|
||||
2. **`default_kwargs`** — dict of kwargs to instantiate the component (can be empty).
|
||||
3. **`file_names_mapping`** — list of `VersionComponentMapping` entries mapping each historical Langflow version (from `src/backend/tests/constants.py::SUPPORTED_VERSIONS`) to module/file names. Use `DID_NOT_EXIST` for versions before the component was added.
|
||||
|
||||
```python
|
||||
from tests.base import ComponentTestBaseWithClient, VersionComponentMapping, DID_NOT_EXIST
|
||||
from langflow.components.my_namespace import MyComponent
|
||||
|
||||
class TestMyComponent(ComponentTestBaseWithClient):
|
||||
@pytest.fixture
|
||||
def component_class(self):
|
||||
return MyComponent
|
||||
|
||||
@pytest.fixture
|
||||
def default_kwargs(self):
|
||||
return {"foo": "bar"}
|
||||
|
||||
@pytest.fixture
|
||||
def file_names_mapping(self):
|
||||
return [
|
||||
VersionComponentMapping(version="1.1.1", module="my_module", file_name="my_component.py"),
|
||||
VersionComponentMapping(version="1.0.19", module="my_module", file_name=DID_NOT_EXIST),
|
||||
]
|
||||
```
|
||||
|
||||
The base class auto-provides:
|
||||
|
||||
- `test_latest_version` — instantiates and asserts `run()` doesn't return `None`.
|
||||
- `test_all_versions_have_a_file_name_defined` — ensures mapping completeness vs `SUPPORTED_VERSIONS`.
|
||||
- `test_component_versions` (parameterized) — builds the component from source for each supported version and asserts execution.
|
||||
|
||||
If you rename or move a component file, you **must** update `file_names_mapping` for every supported version, or saved flows on those versions will fail to load. See [CONTRACTS.md](./CONTRACTS.md) row 3.
|
||||
|
||||
## Graph testing pattern
|
||||
|
||||
The canonical pattern for tests that exercise the graph engine:
|
||||
|
||||
1. Build the graph with connected components.
|
||||
2. Connect them via `.set()` calls.
|
||||
3. Call `async_start` and iterate over the results.
|
||||
4. Validate the results.
|
||||
|
||||
Don't poke graph internals. If a test needs to reach into private state, the test is wrong or the API is wrong — fix the right one.
|
||||
|
||||
## Async patterns
|
||||
|
||||
```python
|
||||
@pytest.mark.asyncio
|
||||
async def test_async_component():
|
||||
result = await component.async_method()
|
||||
assert result is not None
|
||||
```
|
||||
|
||||
**Awaiting conditions, not sleeping:** never use `time.sleep` or `asyncio.sleep` to mask a race. Wait on a condition (`asyncio.wait_for`, an event, a queue read with timeout). Sleep-based tests are flaky by construction.
|
||||
|
||||
## Pytest markers
|
||||
|
||||
- `@pytest.mark.api_key_required` — needs an external API key; CI skips when absent.
|
||||
- `@pytest.mark.no_blockbuster` — skip blockbuster plugin.
|
||||
- `@pytest.mark.noclient` — skip the `client` fixture.
|
||||
- `@pytest.mark.asyncio` — async test (also `pytest-asyncio` auto-mode in some configs).
|
||||
|
||||
## Database tests
|
||||
|
||||
`test_database.py` may fail in batch and pass individually. If you touch DB models or migrations, run it sequentially as part of your verification:
|
||||
|
||||
```bash
|
||||
uv run pytest src/backend/tests/unit/test_database.py
|
||||
```
|
||||
|
||||
Never edit a past alembic migration. Run `make alembic-upgrade` end-to-end before claiming a migration works.
|
||||
|
||||
## API endpoint tests
|
||||
|
||||
```python
|
||||
async def test_flows_endpoint(client, logged_in_headers):
|
||||
flow_data = {"name": "Test", "data": {"nodes": [], "edges": []}}
|
||||
response = await client.post("api/v1/flows/", json=flow_data, headers=logged_in_headers)
|
||||
assert response.status_code == 201
|
||||
```
|
||||
|
||||
For event-stream endpoints, consume the NDJSON stream and validate event order:
|
||||
|
||||
```python
|
||||
async for line in response.aiter_lines():
|
||||
if not line:
|
||||
continue
|
||||
parsed = json.loads(line)
|
||||
# First event should be vertices_sorted; last should be end.
|
||||
```
|
||||
|
||||
## Flow testing with starter JSON
|
||||
|
||||
Use `tests/unit/build_utils.py` helpers:
|
||||
|
||||
```python
|
||||
from tests.unit.build_utils import create_flow, build_flow, get_build_events
|
||||
|
||||
flow_id = await create_flow(client, json_flow, logged_in_headers)
|
||||
build_response = await build_flow(client, flow_id, logged_in_headers)
|
||||
events_response = await get_build_events(client, job_id, logged_in_headers)
|
||||
```
|
||||
|
||||
## Running tests
|
||||
|
||||
```bash
|
||||
make unit_tests # All backend unit tests, parallel
|
||||
make unit_tests async=false # Sequential
|
||||
uv run pytest path/to/test.py # Single file
|
||||
uv run pytest path/to/test.py::test_name # Single test
|
||||
|
||||
# lfx tests specifically — must be run after `uv sync` inside src/lfx
|
||||
cd src/lfx && uv sync && uv run pytest
|
||||
```
|
||||
|
||||
## Verification checklist before claiming "tests pass"
|
||||
|
||||
- [ ] You actually ran the command, not just composed it.
|
||||
- [ ] You ran `test_database.py` sequentially if you touched DB code.
|
||||
- [ ] You ran the specific test file for any component you changed.
|
||||
- [ ] No skipped tests without a linked issue.
|
||||
- [ ] No mocks added at the wrong boundary.
|
||||
+1021
-95
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,25 @@
|
||||
# API Examples (Local Test Harness)
|
||||
|
||||
Run all API example suites against a local Langflow server:
|
||||
|
||||
```bash
|
||||
make api_examples_local
|
||||
```
|
||||
|
||||
Run one test suite:
|
||||
|
||||
```bash
|
||||
make api_examples_local suites=python
|
||||
make api_examples_local suites=javascript
|
||||
make api_examples_local suites=curl
|
||||
```
|
||||
|
||||
The following examples are not executed in this harness:
|
||||
|
||||
- `api-build/build-flow-and-stream-events-2.py`
|
||||
- `api-build/build-flow-and-stream-events-3.py`
|
||||
- `api-flows-run/stream-llm-token-responses.py`
|
||||
- `api-openai-responses/example-streaming-request.py`
|
||||
- `api-logs/stream-logs.py`
|
||||
- `api-logs/retrieve-logs-with-optional-parameters.py`
|
||||
- `api-users/reset-password.py`
|
||||
@@ -2,6 +2,29 @@
|
||||
title: Build endpoints
|
||||
slug: /api-build
|
||||
---
|
||||
import CodeSnippet from '@site/src/components/CodeSnippet';
|
||||
import exampleApiBuildBuildFlowAndStreamEvents from '!!raw-loader!@site/docs/API-Reference/curl-examples/api-build/build-flow-and-stream-events.sh';
|
||||
import resultApiBuildResultBuildFlowAndStreamEvents from '!!raw-loader!@site/docs/API-Reference/curl-examples/api-build/result-build-flow-and-stream-events.json';
|
||||
import exampleApiBuildBuildFlowAndStreamEvents2 from '!!raw-loader!@site/docs/API-Reference/curl-examples/api-build/build-flow-and-stream-events-2.sh';
|
||||
import resultApiBuildResultBuildFlowAndStreamEvents2 from '!!raw-loader!@site/docs/API-Reference/curl-examples/api-build/result-build-flow-and-stream-events-2.json';
|
||||
import exampleApiBuildBuildFlowAndStreamEvents3 from '!!raw-loader!@site/docs/API-Reference/curl-examples/api-build/build-flow-and-stream-events-3.sh';
|
||||
import exampleApiBuildSetStartAndStopPoints from '!!raw-loader!@site/docs/API-Reference/curl-examples/api-build/set-start-and-stop-points.sh';
|
||||
import exampleApiBuildOverrideFlowParameters from '!!raw-loader!@site/docs/API-Reference/curl-examples/api-build/override-flow-parameters.sh';
|
||||
import resultApiBuildResultOverrideFlowParameters from '!!raw-loader!@site/docs/API-Reference/curl-examples/api-build/result-override-flow-parameters.json';
|
||||
import Tabs from '@theme/Tabs';
|
||||
import TabItem from '@theme/TabItem';
|
||||
import examplePythonApiBuildBuildFlowAndStreamEvents from '!!raw-loader!@site/docs/API-Reference/python-examples/api-build/build-flow-and-stream-events.py';
|
||||
import exampleJavascriptApiBuildBuildFlowAndStreamEvents from '!!raw-loader!@site/docs/API-Reference/javascript-examples/api-build/build-flow-and-stream-events.js';
|
||||
import examplePythonApiBuildBuildFlowAndStreamEvents2 from '!!raw-loader!@site/docs/API-Reference/python-examples/api-build/build-flow-and-stream-events-2.py';
|
||||
import exampleJavascriptApiBuildBuildFlowAndStreamEvents2 from '!!raw-loader!@site/docs/API-Reference/javascript-examples/api-build/build-flow-and-stream-events-2.js';
|
||||
import examplePythonApiBuildBuildFlowAndStreamEvents3 from '!!raw-loader!@site/docs/API-Reference/python-examples/api-build/build-flow-and-stream-events-3.py';
|
||||
import exampleJavascriptApiBuildBuildFlowAndStreamEvents3 from '!!raw-loader!@site/docs/API-Reference/javascript-examples/api-build/build-flow-and-stream-events-3.js';
|
||||
import examplePythonApiBuildSetStartAndStopPoints from '!!raw-loader!@site/docs/API-Reference/python-examples/api-build/set-start-and-stop-points.py';
|
||||
import exampleJavascriptApiBuildSetStartAndStopPoints from '!!raw-loader!@site/docs/API-Reference/javascript-examples/api-build/set-start-and-stop-points.js';
|
||||
import examplePythonApiBuildOverrideFlowParameters from '!!raw-loader!@site/docs/API-Reference/python-examples/api-build/override-flow-parameters.py';
|
||||
import exampleJavascriptApiBuildOverrideFlowParameters from '!!raw-loader!@site/docs/API-Reference/javascript-examples/api-build/override-flow-parameters.js';
|
||||
|
||||
|
||||
|
||||
:::info
|
||||
The `/build` endpoints are used by Langflow's frontend visual editor code.
|
||||
@@ -21,63 +44,78 @@ This endpoint builds and executes a flow, returning a job ID that can be used to
|
||||
|
||||
1. Send a POST request to the `/build/$FLOW_ID/flow` endpoint:
|
||||
|
||||
```shell
|
||||
curl -X POST \
|
||||
"$LANGFLOW_URL/api/v1/build/$FLOW_ID/flow" \
|
||||
-H "accept: application/json" \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "x-api-key: $LANGFLOW_API_KEY" \
|
||||
-d '{
|
||||
"inputs": {
|
||||
"input_value": "Tell me a story"
|
||||
}
|
||||
}'
|
||||
```
|
||||
<Tabs>
|
||||
<TabItem value="Python" label="Python" default>
|
||||
|
||||
<CodeSnippet source={examplePythonApiBuildBuildFlowAndStreamEvents} language="python" />
|
||||
|
||||
</TabItem>
|
||||
<TabItem value="JavaScript" label="JavaScript">
|
||||
|
||||
<CodeSnippet source={exampleJavascriptApiBuildBuildFlowAndStreamEvents} language="javascript" />
|
||||
|
||||
</TabItem>
|
||||
<TabItem value="curl" label="curl">
|
||||
|
||||
<CodeSnippet source={exampleApiBuildBuildFlowAndStreamEvents} language="bash" />
|
||||
|
||||
</TabItem>
|
||||
</Tabs>
|
||||
|
||||
<details>
|
||||
<summary>Result</summary>
|
||||
|
||||
```json
|
||||
{
|
||||
"job_id": "123e4567-e89b-12d3-a456-426614174000"
|
||||
}
|
||||
```
|
||||
<CodeSnippet source={resultApiBuildResultBuildFlowAndStreamEvents} language="json" />
|
||||
|
||||
</details>
|
||||
|
||||
2. After receiving a job ID from the build endpoint, use the `/build/$JOB_ID/events` endpoint to stream the execution results:
|
||||
|
||||
```shell
|
||||
curl -X GET \
|
||||
"$LANGFLOW_URL/api/v1/build/123e4567-e89b-12d3-a456-426614174000/events" \
|
||||
-H "accept: application/json" \
|
||||
-H "x-api-key: $LANGFLOW_API_KEY"
|
||||
```
|
||||
<Tabs>
|
||||
<TabItem value="Python" label="Python" default>
|
||||
|
||||
<CodeSnippet source={examplePythonApiBuildBuildFlowAndStreamEvents2} language="python" />
|
||||
|
||||
</TabItem>
|
||||
<TabItem value="JavaScript" label="JavaScript">
|
||||
|
||||
<CodeSnippet source={exampleJavascriptApiBuildBuildFlowAndStreamEvents2} language="javascript" />
|
||||
|
||||
</TabItem>
|
||||
<TabItem value="curl" label="curl">
|
||||
|
||||
<CodeSnippet source={exampleApiBuildBuildFlowAndStreamEvents2} language="bash" />
|
||||
|
||||
</TabItem>
|
||||
</Tabs>
|
||||
|
||||
<details>
|
||||
<summary>Result</summary>
|
||||
|
||||
```json
|
||||
{"event": "vertices_sorted", "data": {"ids": ["ChatInput-XtBLx"], "to_run": ["Prompt-x74Ze", "ChatOutput-ylMzN", "ChatInput-XtBLx", "OpenAIModel-d1wOZ"]}}
|
||||
|
||||
{"event": "add_message", "data": {"timestamp": "2025-03-03T17:42:23", "sender": "User", "sender_name": "User", "session_id": "d2bbd92b-187e-4c84-b2d4-5df365704201", "text": "Tell me a story", "files": [], "error": false, "edit": false, "properties": {"text_color": "", "background_color": "", "edited": false, "source": {"id": null, "display_name": null, "source": null}, "icon": "", "allow_markdown": false, "positive_feedback": null, "state": "complete", "targets": []}, "category": "message", "content_blocks": [], "id": "28879bd8-6a68-4dd5-b658-74d643a4dd92", "flow_id": "d2bbd92b-187e-4c84-b2d4-5df365704201"}}
|
||||
|
||||
// ... Additional events as the flow executes ...
|
||||
|
||||
{"event": "end", "data": {}}
|
||||
```
|
||||
<CodeSnippet source={resultApiBuildResultBuildFlowAndStreamEvents2} language="json" />
|
||||
|
||||
</details>
|
||||
|
||||
The `/build/$FLOW_ID/events` endpoint has a `stream` query parameter that defaults to `true`.
|
||||
To disable streaming and get all events at once, set `?stream=false`.
|
||||
|
||||
```shell
|
||||
curl -X GET \
|
||||
"$LANGFLOW_URL/api/v1/build/123e4567-e89b-12d3-a456-426614174000/events?stream=false" \
|
||||
-H "accept: application/json" \
|
||||
-H "x-api-key: $LANGFLOW_API_KEY"
|
||||
```
|
||||
<Tabs>
|
||||
<TabItem value="Python" label="Python" default>
|
||||
|
||||
<CodeSnippet source={examplePythonApiBuildBuildFlowAndStreamEvents3} language="python" />
|
||||
|
||||
</TabItem>
|
||||
<TabItem value="JavaScript" label="JavaScript">
|
||||
|
||||
<CodeSnippet source={exampleJavascriptApiBuildBuildFlowAndStreamEvents3} language="javascript" />
|
||||
|
||||
</TabItem>
|
||||
<TabItem value="curl" label="curl">
|
||||
|
||||
<CodeSnippet source={exampleApiBuildBuildFlowAndStreamEvents3} language="bash" />
|
||||
|
||||
</TabItem>
|
||||
</Tabs>
|
||||
|
||||
## Build headers
|
||||
|
||||
@@ -105,44 +143,51 @@ Setting `stop_component_id` for a component triggers the same behavior as clicki
|
||||
|
||||
The following example stops flow execution at an **OpenAI** component:
|
||||
|
||||
```shell
|
||||
curl -X POST \
|
||||
"$LANGFLOW_URL/api/v1/build/$FLOW_ID/flow" \
|
||||
-H "accept: application/json" \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "x-api-key: $LANGFLOW_API_KEY" \
|
||||
-d '{"stop_component_id": "OpenAIModel-Uksag"}'
|
||||
```
|
||||
<Tabs>
|
||||
<TabItem value="Python" label="Python" default>
|
||||
|
||||
<CodeSnippet source={examplePythonApiBuildSetStartAndStopPoints} language="python" />
|
||||
|
||||
</TabItem>
|
||||
<TabItem value="JavaScript" label="JavaScript">
|
||||
|
||||
<CodeSnippet source={exampleJavascriptApiBuildSetStartAndStopPoints} language="javascript" />
|
||||
|
||||
</TabItem>
|
||||
<TabItem value="curl" label="curl">
|
||||
|
||||
<CodeSnippet source={exampleApiBuildSetStartAndStopPoints} language="bash" />
|
||||
|
||||
</TabItem>
|
||||
</Tabs>
|
||||
|
||||
### Override flow parameters
|
||||
|
||||
The `/build` endpoint also accepts inputs for `data` directly, instead of using the values stored in the Langflow database.
|
||||
This is useful for running flows without having to pass custom values through the visual editor.
|
||||
|
||||
```shell
|
||||
curl -X POST \
|
||||
"$LANGFLOW_URL/api/v1/build/$FLOW_ID/flow" \
|
||||
-H "accept: application/json" \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "x-api-key: $LANGFLOW_API_KEY" \
|
||||
-d '{
|
||||
"data": {
|
||||
"nodes": [],
|
||||
"edges": []
|
||||
},
|
||||
"inputs": {
|
||||
"input_value": "Your custom input here",
|
||||
"session": "session_id"
|
||||
}
|
||||
}'
|
||||
```
|
||||
<Tabs>
|
||||
<TabItem value="Python" label="Python" default>
|
||||
|
||||
<CodeSnippet source={examplePythonApiBuildOverrideFlowParameters} language="python" />
|
||||
|
||||
</TabItem>
|
||||
<TabItem value="JavaScript" label="JavaScript">
|
||||
|
||||
<CodeSnippet source={exampleJavascriptApiBuildOverrideFlowParameters} language="javascript" />
|
||||
|
||||
</TabItem>
|
||||
<TabItem value="curl" label="curl">
|
||||
|
||||
<CodeSnippet source={exampleApiBuildOverrideFlowParameters} language="bash" />
|
||||
|
||||
</TabItem>
|
||||
</Tabs>
|
||||
|
||||
<details>
|
||||
<summary>Result</summary>
|
||||
|
||||
```json
|
||||
{ "job_id": "0bcc7f23-40b4-4bfa-9b8a-a44181fd1175" }
|
||||
```
|
||||
<CodeSnippet source={resultApiBuildResultOverrideFlowParameters} language="json" />
|
||||
|
||||
</details>
|
||||
|
||||
@@ -150,4 +195,4 @@ curl -X POST \
|
||||
|
||||
- [Get Vertex builds](/api-monitor#get-vertex-builds)
|
||||
- [Delete Vertex builds](/api-monitor#delete-vertex-builds)
|
||||
- [Session ID](/session-id)
|
||||
- [Session ID](/session-id)
|
||||
|
||||
@@ -2,6 +2,66 @@
|
||||
title: Files endpoints
|
||||
slug: /api-files
|
||||
---
|
||||
import CodeSnippet from '@site/src/components/CodeSnippet';
|
||||
import exampleApiFilesUploadFileV1 from '!!raw-loader!@site/docs/API-Reference/curl-examples/api-files/upload-file-v1.sh';
|
||||
import resultApiFilesResultUploadFileV1 from '!!raw-loader!@site/docs/API-Reference/curl-examples/api-files/result-upload-file-v1.json';
|
||||
import exampleApiFilesUploadImageFilesV1 from '!!raw-loader!@site/docs/API-Reference/curl-examples/api-files/upload-image-files-v1.sh';
|
||||
import exampleApiFilesUploadImageFilesV12 from '!!raw-loader!@site/docs/API-Reference/curl-examples/api-files/upload-image-files-v1-2.sh';
|
||||
import exampleApiFilesListFilesV1 from '!!raw-loader!@site/docs/API-Reference/curl-examples/api-files/list-files-v1.sh';
|
||||
import resultApiFilesResultListFilesV1 from '!!raw-loader!@site/docs/API-Reference/curl-examples/api-files/result-list-files-v1.json';
|
||||
import exampleApiFilesDownloadFileV1 from '!!raw-loader!@site/docs/API-Reference/curl-examples/api-files/download-file-v1.sh';
|
||||
import resultApiFilesResultDownloadFileV1 from '!!raw-loader!@site/docs/API-Reference/curl-examples/api-files/result-download-file-v1.txt';
|
||||
import exampleApiFilesDeleteFileV1 from '!!raw-loader!@site/docs/API-Reference/curl-examples/api-files/delete-file-v1.sh';
|
||||
import resultApiFilesResultDeleteFileV1 from '!!raw-loader!@site/docs/API-Reference/curl-examples/api-files/result-delete-file-v1.json';
|
||||
import exampleApiFilesUploadFileV2 from '!!raw-loader!@site/docs/API-Reference/curl-examples/api-files/upload-file-v2.sh';
|
||||
import resultApiFilesResultUploadFileV2 from '!!raw-loader!@site/docs/API-Reference/curl-examples/api-files/result-upload-file-v2.txt';
|
||||
import exampleApiFilesUploadFileV22 from '!!raw-loader!@site/docs/API-Reference/curl-examples/api-files/upload-file-v2-2.sh';
|
||||
import exampleApiFilesSendFilesToYourFlowsV2 from '!!raw-loader!@site/docs/API-Reference/curl-examples/api-files/send-files-to-your-flows-v2.sh';
|
||||
import exampleApiFilesSendFilesToYourFlowsV22 from '!!raw-loader!@site/docs/API-Reference/curl-examples/api-files/send-files-to-your-flows-v2-2.sh';
|
||||
import exampleApiFilesListFilesV2 from '!!raw-loader!@site/docs/API-Reference/curl-examples/api-files/list-files-v2.sh';
|
||||
import resultApiFilesResultListFilesV2 from '!!raw-loader!@site/docs/API-Reference/curl-examples/api-files/result-list-files-v2.json';
|
||||
import exampleApiFilesDownloadFileV2 from '!!raw-loader!@site/docs/API-Reference/curl-examples/api-files/download-file-v2.sh';
|
||||
import resultApiFilesResultDownloadFileV2 from '!!raw-loader!@site/docs/API-Reference/curl-examples/api-files/result-download-file-v2.txt';
|
||||
import exampleApiFilesEditFileNameV2 from '!!raw-loader!@site/docs/API-Reference/curl-examples/api-files/edit-file-name-v2.sh';
|
||||
import resultApiFilesResultEditFileNameV2 from '!!raw-loader!@site/docs/API-Reference/curl-examples/api-files/result-edit-file-name-v2.json';
|
||||
import exampleApiFilesDeleteFileV2 from '!!raw-loader!@site/docs/API-Reference/curl-examples/api-files/delete-file-v2.sh';
|
||||
import resultApiFilesResultDeleteFileV2 from '!!raw-loader!@site/docs/API-Reference/curl-examples/api-files/result-delete-file-v2.json';
|
||||
import exampleApiFilesDeleteAllFilesV2 from '!!raw-loader!@site/docs/API-Reference/curl-examples/api-files/delete-all-files-v2.sh';
|
||||
import resultApiFilesResultDeleteAllFilesV2 from '!!raw-loader!@site/docs/API-Reference/curl-examples/api-files/result-delete-all-files-v2.json';
|
||||
import Tabs from '@theme/Tabs';
|
||||
import TabItem from '@theme/TabItem';
|
||||
import examplePythonApiFilesUploadFileV1 from '!!raw-loader!@site/docs/API-Reference/python-examples/api-files/upload-file-v1.py';
|
||||
import exampleJavascriptApiFilesUploadFileV1 from '!!raw-loader!@site/docs/API-Reference/javascript-examples/api-files/upload-file-v1.js';
|
||||
import examplePythonApiFilesUploadImageFilesV1 from '!!raw-loader!@site/docs/API-Reference/python-examples/api-files/upload-image-files-v1.py';
|
||||
import exampleJavascriptApiFilesUploadImageFilesV1 from '!!raw-loader!@site/docs/API-Reference/javascript-examples/api-files/upload-image-files-v1.js';
|
||||
import examplePythonApiFilesUploadImageFilesV12 from '!!raw-loader!@site/docs/API-Reference/python-examples/api-files/upload-image-files-v1-2.py';
|
||||
import exampleJavascriptApiFilesUploadImageFilesV12 from '!!raw-loader!@site/docs/API-Reference/javascript-examples/api-files/upload-image-files-v1-2.js';
|
||||
import examplePythonApiFilesListFilesV1 from '!!raw-loader!@site/docs/API-Reference/python-examples/api-files/list-files-v1.py';
|
||||
import exampleJavascriptApiFilesListFilesV1 from '!!raw-loader!@site/docs/API-Reference/javascript-examples/api-files/list-files-v1.js';
|
||||
import examplePythonApiFilesDownloadFileV1 from '!!raw-loader!@site/docs/API-Reference/python-examples/api-files/download-file-v1.py';
|
||||
import exampleJavascriptApiFilesDownloadFileV1 from '!!raw-loader!@site/docs/API-Reference/javascript-examples/api-files/download-file-v1.js';
|
||||
import examplePythonApiFilesDeleteFileV1 from '!!raw-loader!@site/docs/API-Reference/python-examples/api-files/delete-file-v1.py';
|
||||
import exampleJavascriptApiFilesDeleteFileV1 from '!!raw-loader!@site/docs/API-Reference/javascript-examples/api-files/delete-file-v1.js';
|
||||
import examplePythonApiFilesUploadFileV2 from '!!raw-loader!@site/docs/API-Reference/python-examples/api-files/upload-file-v2.py';
|
||||
import exampleJavascriptApiFilesUploadFileV2 from '!!raw-loader!@site/docs/API-Reference/javascript-examples/api-files/upload-file-v2.js';
|
||||
import examplePythonApiFilesUploadFileV22 from '!!raw-loader!@site/docs/API-Reference/python-examples/api-files/upload-file-v2-2.py';
|
||||
import exampleJavascriptApiFilesUploadFileV22 from '!!raw-loader!@site/docs/API-Reference/javascript-examples/api-files/upload-file-v2-2.js';
|
||||
import examplePythonApiFilesSendFilesToYourFlowsV2 from '!!raw-loader!@site/docs/API-Reference/python-examples/api-files/send-files-to-your-flows-v2.py';
|
||||
import exampleJavascriptApiFilesSendFilesToYourFlowsV2 from '!!raw-loader!@site/docs/API-Reference/javascript-examples/api-files/send-files-to-your-flows-v2.js';
|
||||
import examplePythonApiFilesSendFilesToYourFlowsV22 from '!!raw-loader!@site/docs/API-Reference/python-examples/api-files/send-files-to-your-flows-v2-2.py';
|
||||
import exampleJavascriptApiFilesSendFilesToYourFlowsV22 from '!!raw-loader!@site/docs/API-Reference/javascript-examples/api-files/send-files-to-your-flows-v2-2.js';
|
||||
import examplePythonApiFilesListFilesV2 from '!!raw-loader!@site/docs/API-Reference/python-examples/api-files/list-files-v2.py';
|
||||
import exampleJavascriptApiFilesListFilesV2 from '!!raw-loader!@site/docs/API-Reference/javascript-examples/api-files/list-files-v2.js';
|
||||
import examplePythonApiFilesDownloadFileV2 from '!!raw-loader!@site/docs/API-Reference/python-examples/api-files/download-file-v2.py';
|
||||
import exampleJavascriptApiFilesDownloadFileV2 from '!!raw-loader!@site/docs/API-Reference/javascript-examples/api-files/download-file-v2.js';
|
||||
import examplePythonApiFilesEditFileNameV2 from '!!raw-loader!@site/docs/API-Reference/python-examples/api-files/edit-file-name-v2.py';
|
||||
import exampleJavascriptApiFilesEditFileNameV2 from '!!raw-loader!@site/docs/API-Reference/javascript-examples/api-files/edit-file-name-v2.js';
|
||||
import examplePythonApiFilesDeleteFileV2 from '!!raw-loader!@site/docs/API-Reference/python-examples/api-files/delete-file-v2.py';
|
||||
import exampleJavascriptApiFilesDeleteFileV2 from '!!raw-loader!@site/docs/API-Reference/javascript-examples/api-files/delete-file-v2.js';
|
||||
import examplePythonApiFilesDeleteAllFilesV2 from '!!raw-loader!@site/docs/API-Reference/python-examples/api-files/delete-all-files-v2.py';
|
||||
import exampleJavascriptApiFilesDeleteAllFilesV2 from '!!raw-loader!@site/docs/API-Reference/javascript-examples/api-files/delete-all-files-v2.js';
|
||||
|
||||
|
||||
|
||||
Use the `/files` endpoints to move files between your local machine and Langflow.
|
||||
|
||||
@@ -33,14 +93,23 @@ Use the `/files` endpoints to move files between your local machine and Langflow
|
||||
Upload a file to the `v1/files/upload/$FLOW_ID` endpoint:
|
||||
Replace **FILE_NAME** with the uploaded file name.
|
||||
|
||||
```bash
|
||||
curl -X POST \
|
||||
"$LANGFLOW_URL/api/v1/files/upload/$FLOW_ID" \
|
||||
-H "accept: application/json" \
|
||||
-H "Content-Type: multipart/form-data" \
|
||||
-H "x-api-key: $LANGFLOW_API_KEY" \
|
||||
-F "file=@FILE_NAME.txt"
|
||||
```
|
||||
<Tabs>
|
||||
<TabItem value="Python" label="Python" default>
|
||||
|
||||
<CodeSnippet source={examplePythonApiFilesUploadFileV1} language="python" />
|
||||
|
||||
</TabItem>
|
||||
<TabItem value="JavaScript" label="JavaScript">
|
||||
|
||||
<CodeSnippet source={exampleJavascriptApiFilesUploadFileV1} language="javascript" />
|
||||
|
||||
</TabItem>
|
||||
<TabItem value="curl" label="curl">
|
||||
|
||||
<CodeSnippet source={exampleApiFilesUploadFileV1} language="bash" />
|
||||
|
||||
</TabItem>
|
||||
</Tabs>
|
||||
|
||||
Replace `FILE_NAME.txt` with the name and extension of the file you want to upload.
|
||||
Not all file types are supported.
|
||||
@@ -48,12 +117,7 @@ Not all file types are supported.
|
||||
<details>
|
||||
<summary>Result</summary>
|
||||
|
||||
```json
|
||||
{
|
||||
"flowId": "92f9a4c5-cfc8-4656-ae63-1f0881163c28",
|
||||
"file_path": "92f9a4c5-cfc8-4656-ae63-1f0881163c28/2024-12-30_15-19-43_your_file.txt"
|
||||
}
|
||||
```
|
||||
<CodeSnippet source={resultApiFilesResultUploadFileV1} language="json" />
|
||||
|
||||
</details>
|
||||
|
||||
@@ -66,12 +130,23 @@ To change this limit, set the `LANGFLOW_MAX_FILE_SIZE_UPLOAD` [environment varia
|
||||
|
||||
1. Attach the image to a `POST /v1/files/upload/$FLOW_ID` request with `--form` (`-F`) and the file path:
|
||||
|
||||
```bash
|
||||
curl -X POST "$LANGFLOW_URL/api/v1/files/upload/$FLOW_ID" \
|
||||
-H "Content-Type: multipart/form-data" \
|
||||
-H "x-api-key: $LANGFLOW_API_KEY" \
|
||||
-F "file=@PATH/TO/FILE.png"
|
||||
```
|
||||
<Tabs>
|
||||
<TabItem value="Python" label="Python" default>
|
||||
|
||||
<CodeSnippet source={examplePythonApiFilesUploadImageFilesV1} language="python" />
|
||||
|
||||
</TabItem>
|
||||
<TabItem value="JavaScript" label="JavaScript">
|
||||
|
||||
<CodeSnippet source={exampleJavascriptApiFilesUploadImageFilesV1} language="javascript" />
|
||||
|
||||
</TabItem>
|
||||
<TabItem value="curl" label="curl">
|
||||
|
||||
<CodeSnippet source={exampleApiFilesUploadImageFilesV1} language="bash" />
|
||||
|
||||
</TabItem>
|
||||
</Tabs>
|
||||
|
||||
A successful request returns the `file_path` for the image in the Langflow file management system in the format `FLOW_ID/TIMESTAMP_FILENAME.TYPE`.
|
||||
For example:
|
||||
@@ -88,22 +163,23 @@ To change this limit, set the `LANGFLOW_MAX_FILE_SIZE_UPLOAD` [environment varia
|
||||
The following example runs the **Basic Prompting** template flow, passing the image file and the query `describe this image` as input for the **Chat Input** component.
|
||||
In this case, the file path is specified in `tweaks`.
|
||||
|
||||
```bash
|
||||
curl -X POST \
|
||||
"$LANGFLOW_URL/api/v1/run/a430cc57-06bb-4c11-be39-d3d4de68d2c4?stream=false" \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "x-api-key: $LANGFLOW_API_KEY" \
|
||||
-d '{
|
||||
"output_type": "chat",
|
||||
"input_type": "chat",
|
||||
"tweaks": {
|
||||
"ChatInput-b67sL": {
|
||||
"files": "a430cc57-06bb-4c11-be39-d3d4de68d2c4/2024-11-27_14-47-50_image-file.png",
|
||||
"input_value": "describe this image"
|
||||
}
|
||||
}
|
||||
}'
|
||||
```
|
||||
<Tabs>
|
||||
<TabItem value="Python" label="Python" default>
|
||||
|
||||
<CodeSnippet source={examplePythonApiFilesUploadImageFilesV12} language="python" />
|
||||
|
||||
</TabItem>
|
||||
<TabItem value="JavaScript" label="JavaScript">
|
||||
|
||||
<CodeSnippet source={exampleJavascriptApiFilesUploadImageFilesV12} language="javascript" />
|
||||
|
||||
</TabItem>
|
||||
<TabItem value="curl" label="curl">
|
||||
|
||||
<CodeSnippet source={exampleApiFilesUploadImageFilesV12} language="bash" />
|
||||
|
||||
</TabItem>
|
||||
</Tabs>
|
||||
|
||||
:::tip
|
||||
For help with tweaks, use the **Input Schema** in a flow's [**API access** pane](/concepts-publish#api-access).
|
||||
@@ -114,21 +190,28 @@ To change this limit, set the `LANGFLOW_MAX_FILE_SIZE_UPLOAD` [environment varia
|
||||
|
||||
List all files associated with a specific flow.
|
||||
|
||||
```bash
|
||||
curl -X GET \
|
||||
"$LANGFLOW_URL/api/v1/files/list/$FLOW_ID" \
|
||||
-H "accept: application/json" \
|
||||
-H "x-api-key: $LANGFLOW_API_KEY"
|
||||
```
|
||||
<Tabs>
|
||||
<TabItem value="Python" label="Python" default>
|
||||
|
||||
<CodeSnippet source={examplePythonApiFilesListFilesV1} language="python" />
|
||||
|
||||
</TabItem>
|
||||
<TabItem value="JavaScript" label="JavaScript">
|
||||
|
||||
<CodeSnippet source={exampleJavascriptApiFilesListFilesV1} language="javascript" />
|
||||
|
||||
</TabItem>
|
||||
<TabItem value="curl" label="curl">
|
||||
|
||||
<CodeSnippet source={exampleApiFilesListFilesV1} language="bash" />
|
||||
|
||||
</TabItem>
|
||||
</Tabs>
|
||||
|
||||
<details>
|
||||
<summary>Result</summary>
|
||||
|
||||
```json
|
||||
{
|
||||
"files": ["2024-12-30_15-19-43_your_file.txt"]
|
||||
}
|
||||
```
|
||||
<CodeSnippet source={resultApiFilesResultListFilesV1} language="json" />
|
||||
|
||||
</details>
|
||||
|
||||
@@ -136,20 +219,28 @@ curl -X GET \
|
||||
|
||||
Download a specific file from a flow.
|
||||
|
||||
```bash
|
||||
curl -X GET \
|
||||
"$LANGFLOW_URL/api/v1/files/download/$FLOW_ID/2024-12-30_15-19-43_your_file.txt" \
|
||||
-H "accept: application/json" \
|
||||
-H "x-api-key: $LANGFLOW_API_KEY" \
|
||||
--output downloaded_file.txt
|
||||
```
|
||||
<Tabs>
|
||||
<TabItem value="Python" label="Python" default>
|
||||
|
||||
<CodeSnippet source={examplePythonApiFilesDownloadFileV1} language="python" />
|
||||
|
||||
</TabItem>
|
||||
<TabItem value="JavaScript" label="JavaScript">
|
||||
|
||||
<CodeSnippet source={exampleJavascriptApiFilesDownloadFileV1} language="javascript" />
|
||||
|
||||
</TabItem>
|
||||
<TabItem value="curl" label="curl">
|
||||
|
||||
<CodeSnippet source={exampleApiFilesDownloadFileV1} language="bash" />
|
||||
|
||||
</TabItem>
|
||||
</Tabs>
|
||||
|
||||
<details>
|
||||
<summary>Result</summary>
|
||||
|
||||
```text
|
||||
File contents downloaded to downloaded_file.txt
|
||||
```
|
||||
<CodeSnippet source={resultApiFilesResultDownloadFileV1} language="text" />
|
||||
|
||||
</details>
|
||||
|
||||
@@ -157,21 +248,28 @@ File contents downloaded to downloaded_file.txt
|
||||
|
||||
Delete a specific file from a flow.
|
||||
|
||||
```bash
|
||||
curl -X DELETE \
|
||||
"$LANGFLOW_URL/api/v1/files/delete/$FLOW_ID/2024-12-30_15-19-43_your_file.txt" \
|
||||
-H "accept: application/json" \
|
||||
-H "x-api-key: $LANGFLOW_API_KEY"
|
||||
```
|
||||
<Tabs>
|
||||
<TabItem value="Python" label="Python" default>
|
||||
|
||||
<CodeSnippet source={examplePythonApiFilesDeleteFileV1} language="python" />
|
||||
|
||||
</TabItem>
|
||||
<TabItem value="JavaScript" label="JavaScript">
|
||||
|
||||
<CodeSnippet source={exampleJavascriptApiFilesDeleteFileV1} language="javascript" />
|
||||
|
||||
</TabItem>
|
||||
<TabItem value="curl" label="curl">
|
||||
|
||||
<CodeSnippet source={exampleApiFilesDeleteFileV1} language="bash" />
|
||||
|
||||
</TabItem>
|
||||
</Tabs>
|
||||
|
||||
<details>
|
||||
<summary>Result</summary>
|
||||
|
||||
```json
|
||||
{
|
||||
"message": "File 2024-12-30_15-19-43_your_file.txt deleted successfully"
|
||||
}
|
||||
```
|
||||
<CodeSnippet source={resultApiFilesResultDeleteFileV1} language="json" />
|
||||
|
||||
</details>
|
||||
|
||||
@@ -190,33 +288,51 @@ The file is uploaded in the format `USER_ID/FILE_ID.FILE_EXTENSION`, such as `07
|
||||
|
||||
1. To retrieve your current `user_id`, call the `/whoami` endpoint:
|
||||
|
||||
```bash
|
||||
curl -X GET \
|
||||
"$LANGFLOW_URL/api/v1/users/whoami" \
|
||||
-H "accept: application/json" \
|
||||
-H "x-api-key: $LANGFLOW_API_KEY"
|
||||
```
|
||||
<Tabs>
|
||||
<TabItem value="Python" label="Python" default>
|
||||
|
||||
<CodeSnippet source={examplePythonApiFilesUploadFileV2} language="python" />
|
||||
|
||||
</TabItem>
|
||||
<TabItem value="JavaScript" label="JavaScript">
|
||||
|
||||
<CodeSnippet source={exampleJavascriptApiFilesUploadFileV2} language="javascript" />
|
||||
|
||||
</TabItem>
|
||||
<TabItem value="curl" label="curl">
|
||||
|
||||
<CodeSnippet source={exampleApiFilesUploadFileV2} language="bash" />
|
||||
|
||||
</TabItem>
|
||||
</Tabs>
|
||||
|
||||
<details>
|
||||
<summary>Result</summary>
|
||||
|
||||
```
|
||||
{"id":"07e5b864-e367-4f52-b647-a48035ae7e5e","username":"langflow","profile_image":null,"store_api_key":null,"is_active":true,"is_superuser":true,"create_at":"2025-05-08T17:59:07.855965","updated_at":"2025-05-28T19:00:42.556460","last_login_at":"2025-05-28T19:00:42.554338","optins":{"github_starred":false,"dialog_dismissed":true,"discord_clicked":false,"mcp_dialog_dismissed":true}}
|
||||
```
|
||||
<CodeSnippet source={resultApiFilesResultUploadFileV2} language="text" />
|
||||
|
||||
</details>
|
||||
|
||||
2. In the POST request to `v2/files`, replace **@FILE_NAME.EXTENSION** with the uploaded file name and its extension.
|
||||
You must include the ampersand (`@`) in the request to instruct curl to upload the contents of the file, not the string `FILE_NAME.EXTENSION`.
|
||||
|
||||
```bash
|
||||
curl -X POST \
|
||||
"$LANGFLOW_URL/api/v2/files" \
|
||||
-H "accept: application/json" \
|
||||
-H "Content-Type: multipart/form-data" \
|
||||
-H "x-api-key: $LANGFLOW_API_KEY" \
|
||||
-F "file=@FILE_NAME.EXTENSION"
|
||||
```
|
||||
<Tabs>
|
||||
<TabItem value="Python" label="Python" default>
|
||||
|
||||
<CodeSnippet source={examplePythonApiFilesUploadFileV22} language="python" />
|
||||
|
||||
</TabItem>
|
||||
<TabItem value="JavaScript" label="JavaScript">
|
||||
|
||||
<CodeSnippet source={exampleJavascriptApiFilesUploadFileV22} language="javascript" />
|
||||
|
||||
</TabItem>
|
||||
<TabItem value="curl" label="curl">
|
||||
|
||||
<CodeSnippet source={exampleApiFilesUploadFileV22} language="bash" />
|
||||
|
||||
</TabItem>
|
||||
</Tabs>
|
||||
|
||||
The file is uploaded in the format `USER_ID/FILE_ID.FILE_EXTENSION`, and the API returns metadata about the uploaded file:
|
||||
|
||||
@@ -247,14 +363,23 @@ The default file limit is 1024 MB. To configure this value, change the `LANGFLOW
|
||||
Replace **FILE_NAME.EXTENSION** with the name and extension of the file you want to upload.
|
||||
This is the same step described in [Upload file (v2)](#upload-file-v2), but since you need the filename to upload to your flow, it is included here.
|
||||
|
||||
```bash
|
||||
curl -X POST \
|
||||
"$LANGFLOW_URL/api/v2/files" \
|
||||
-H "accept: application/json" \
|
||||
-H "Content-Type: multipart/form-data" \
|
||||
-H "x-api-key: $LANGFLOW_API_KEY" \
|
||||
-F "file=@FILE_NAME.EXTENSION"
|
||||
```
|
||||
<Tabs>
|
||||
<TabItem value="Python" label="Python" default>
|
||||
|
||||
<CodeSnippet source={examplePythonApiFilesSendFilesToYourFlowsV2} language="python" />
|
||||
|
||||
</TabItem>
|
||||
<TabItem value="JavaScript" label="JavaScript">
|
||||
|
||||
<CodeSnippet source={exampleJavascriptApiFilesSendFilesToYourFlowsV2} language="javascript" />
|
||||
|
||||
</TabItem>
|
||||
<TabItem value="curl" label="curl">
|
||||
|
||||
<CodeSnippet source={exampleApiFilesSendFilesToYourFlowsV2} language="bash" />
|
||||
|
||||
</TabItem>
|
||||
</Tabs>
|
||||
|
||||
The file is uploaded in the format `USER_ID/FILE_ID.FILE_EXTENSION`, and the API returns metadata about the uploaded file:
|
||||
|
||||
@@ -273,24 +398,23 @@ This component loads files into flows from your local machine or Langflow file m
|
||||
|
||||
3. Run the flow, passing the `path` to the `Read-File` component in the `tweaks` object:
|
||||
|
||||
```text
|
||||
curl --request POST \
|
||||
--url "$LANGFLOW_URL/api/v1/run/$FLOW_ID" \
|
||||
--header "Content-Type: application/json" \
|
||||
--header "x-api-key: $LANGFLOW_API_KEY" \
|
||||
--data '{
|
||||
"input_value": "what do you see?",
|
||||
"output_type": "chat",
|
||||
"input_type": "text",
|
||||
"tweaks": {
|
||||
"Read-File-1olS3": {
|
||||
"path": [
|
||||
"07e5b864-e367-4f52-b647-a48035ae7e5e/3a290013-fe1e-4d3d-a454-cacae81288f3.pdf"
|
||||
]
|
||||
}
|
||||
}
|
||||
}'
|
||||
```
|
||||
<Tabs>
|
||||
<TabItem value="Python" label="Python" default>
|
||||
|
||||
<CodeSnippet source={examplePythonApiFilesSendFilesToYourFlowsV22} language="python" />
|
||||
|
||||
</TabItem>
|
||||
<TabItem value="JavaScript" label="JavaScript">
|
||||
|
||||
<CodeSnippet source={exampleJavascriptApiFilesSendFilesToYourFlowsV22} language="javascript" />
|
||||
|
||||
</TabItem>
|
||||
<TabItem value="curl" label="curl">
|
||||
|
||||
<CodeSnippet source={exampleApiFilesSendFilesToYourFlowsV22} language="bash" />
|
||||
|
||||
</TabItem>
|
||||
</Tabs>
|
||||
|
||||
To get the `Read-File` component's ID, call the [Read flow](/api-flows#read-flow) endpoint or inspect the component in the visual editor.
|
||||
|
||||
@@ -300,27 +424,28 @@ This component loads files into flows from your local machine or Langflow file m
|
||||
|
||||
List all files associated with your user account.
|
||||
|
||||
```bash
|
||||
curl -X GET \
|
||||
"$LANGFLOW_URL/api/v2/files" \
|
||||
-H "accept: application/json" \
|
||||
-H "x-api-key: $LANGFLOW_API_KEY"
|
||||
```
|
||||
<Tabs>
|
||||
<TabItem value="Python" label="Python" default>
|
||||
|
||||
<CodeSnippet source={examplePythonApiFilesListFilesV2} language="python" />
|
||||
|
||||
</TabItem>
|
||||
<TabItem value="JavaScript" label="JavaScript">
|
||||
|
||||
<CodeSnippet source={exampleJavascriptApiFilesListFilesV2} language="javascript" />
|
||||
|
||||
</TabItem>
|
||||
<TabItem value="curl" label="curl">
|
||||
|
||||
<CodeSnippet source={exampleApiFilesListFilesV2} language="bash" />
|
||||
|
||||
</TabItem>
|
||||
</Tabs>
|
||||
|
||||
<details>
|
||||
<summary>Result</summary>
|
||||
|
||||
```json
|
||||
[
|
||||
{
|
||||
"id": "c7b22c4c-d5e0-4ec9-af97-5d85b7657a34",
|
||||
"name": "your_file",
|
||||
"path": "6f17a73e-97d7-4519-a8d9-8e4c0be411bb/c7b22c4c-d5e0-4ec9-af97-5d85b7657a34.txt",
|
||||
"size": 1234,
|
||||
"provider": null
|
||||
}
|
||||
]
|
||||
```
|
||||
<CodeSnippet source={resultApiFilesResultListFilesV2} language="json" />
|
||||
|
||||
</details>
|
||||
|
||||
@@ -330,20 +455,28 @@ Download a specific file by its ID and file extension.
|
||||
|
||||
You must specify the file type you expect in the `--output` value.
|
||||
|
||||
```bash
|
||||
curl -X GET \
|
||||
"$LANGFLOW_URL/api/v2/files/c7b22c4c-d5e0-4ec9-af97-5d85b7657a34" \
|
||||
-H "accept: application/json" \
|
||||
-H "x-api-key: $LANGFLOW_API_KEY" \
|
||||
--output downloaded_file.txt
|
||||
```
|
||||
<Tabs>
|
||||
<TabItem value="Python" label="Python" default>
|
||||
|
||||
<CodeSnippet source={examplePythonApiFilesDownloadFileV2} language="python" />
|
||||
|
||||
</TabItem>
|
||||
<TabItem value="JavaScript" label="JavaScript">
|
||||
|
||||
<CodeSnippet source={exampleJavascriptApiFilesDownloadFileV2} language="javascript" />
|
||||
|
||||
</TabItem>
|
||||
<TabItem value="curl" label="curl">
|
||||
|
||||
<CodeSnippet source={exampleApiFilesDownloadFileV2} language="bash" />
|
||||
|
||||
</TabItem>
|
||||
</Tabs>
|
||||
|
||||
<details>
|
||||
<summary>Result</summary>
|
||||
|
||||
```text
|
||||
File contents downloaded to downloaded_file.txt
|
||||
```
|
||||
<CodeSnippet source={resultApiFilesResultDownloadFileV2} language="text" />
|
||||
|
||||
</details>
|
||||
|
||||
@@ -351,25 +484,28 @@ File contents downloaded to downloaded_file.txt
|
||||
|
||||
Change a file name.
|
||||
|
||||
```bash
|
||||
curl -X PUT \
|
||||
"$LANGFLOW_URL/api/v2/files/$FILE_ID?name=new_file_name" \
|
||||
-H "accept: application/json" \
|
||||
-H "x-api-key: $LANGFLOW_API_KEY"
|
||||
```
|
||||
<Tabs>
|
||||
<TabItem value="Python" label="Python" default>
|
||||
|
||||
<CodeSnippet source={examplePythonApiFilesEditFileNameV2} language="python" />
|
||||
|
||||
</TabItem>
|
||||
<TabItem value="JavaScript" label="JavaScript">
|
||||
|
||||
<CodeSnippet source={exampleJavascriptApiFilesEditFileNameV2} language="javascript" />
|
||||
|
||||
</TabItem>
|
||||
<TabItem value="curl" label="curl">
|
||||
|
||||
<CodeSnippet source={exampleApiFilesEditFileNameV2} language="bash" />
|
||||
|
||||
</TabItem>
|
||||
</Tabs>
|
||||
|
||||
<details>
|
||||
<summary>Result</summary>
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "76543e40-f388-4cb3-b0ee-a1e870aca3d3",
|
||||
"name": "new_file_name",
|
||||
"path": "6f17a73e-97d7-4519-a8d9-8e4c0be411bb/76543e40-f388-4cb3-b0ee-a1e870aca3d3.png",
|
||||
"size": 2728251,
|
||||
"provider": null
|
||||
}
|
||||
```
|
||||
<CodeSnippet source={resultApiFilesResultEditFileNameV2} language="json" />
|
||||
|
||||
</details>
|
||||
|
||||
@@ -377,21 +513,28 @@ curl -X PUT \
|
||||
|
||||
Delete a specific file by its ID.
|
||||
|
||||
```bash
|
||||
curl -X DELETE \
|
||||
"$LANGFLOW_URL/api/v2/files/$FILE_ID" \
|
||||
-H "accept: application/json" \
|
||||
-H "x-api-key: $LANGFLOW_API_KEY"
|
||||
```
|
||||
<Tabs>
|
||||
<TabItem value="Python" label="Python" default>
|
||||
|
||||
<CodeSnippet source={examplePythonApiFilesDeleteFileV2} language="python" />
|
||||
|
||||
</TabItem>
|
||||
<TabItem value="JavaScript" label="JavaScript">
|
||||
|
||||
<CodeSnippet source={exampleJavascriptApiFilesDeleteFileV2} language="javascript" />
|
||||
|
||||
</TabItem>
|
||||
<TabItem value="curl" label="curl">
|
||||
|
||||
<CodeSnippet source={exampleApiFilesDeleteFileV2} language="bash" />
|
||||
|
||||
</TabItem>
|
||||
</Tabs>
|
||||
|
||||
<details>
|
||||
<summary>Result</summary>
|
||||
|
||||
```json
|
||||
{
|
||||
"message": "File deleted successfully"
|
||||
}
|
||||
```
|
||||
<CodeSnippet source={resultApiFilesResultDeleteFileV2} language="json" />
|
||||
|
||||
</details>
|
||||
|
||||
@@ -399,21 +542,28 @@ curl -X DELETE \
|
||||
|
||||
Delete all files associated with your user account.
|
||||
|
||||
```bash
|
||||
curl -X DELETE \
|
||||
"$LANGFLOW_URL/api/v2/files" \
|
||||
-H "accept: application/json" \
|
||||
-H "x-api-key: $LANGFLOW_API_KEY"
|
||||
```
|
||||
<Tabs>
|
||||
<TabItem value="Python" label="Python" default>
|
||||
|
||||
<CodeSnippet source={examplePythonApiFilesDeleteAllFilesV2} language="python" />
|
||||
|
||||
</TabItem>
|
||||
<TabItem value="JavaScript" label="JavaScript">
|
||||
|
||||
<CodeSnippet source={exampleJavascriptApiFilesDeleteAllFilesV2} language="javascript" />
|
||||
|
||||
</TabItem>
|
||||
<TabItem value="curl" label="curl">
|
||||
|
||||
<CodeSnippet source={exampleApiFilesDeleteAllFilesV2} language="bash" />
|
||||
|
||||
</TabItem>
|
||||
</Tabs>
|
||||
|
||||
<details>
|
||||
<summary>Result</summary>
|
||||
|
||||
```json
|
||||
{
|
||||
"message": "All files deleted successfully"
|
||||
}
|
||||
```
|
||||
<CodeSnippet source={resultApiFilesResultDeleteAllFilesV2} language="json" />
|
||||
|
||||
</details>
|
||||
|
||||
@@ -423,4 +573,4 @@ This endpoint is deprecated. Use the `/files` endpoints instead.
|
||||
|
||||
## See also
|
||||
|
||||
* [Manage files](/concepts-file-management)
|
||||
* [Manage files](/concepts-file-management)
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user