mirror of
https://github.com/Significant-Gravitas/AutoGPT.git
synced 2026-03-17 03:00:27 -04:00
Compare commits
5 Commits
fix/block-
...
dev
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8892bcd230 | ||
|
|
48ff8300a4 | ||
|
|
c268fc6464 | ||
|
|
aff3fb44af | ||
|
|
0b594a219c |
2
.github/workflows/platform-backend-ci.yml
vendored
2
.github/workflows/platform-backend-ci.yml
vendored
@@ -5,12 +5,14 @@ on:
|
||||
branches: [master, dev, ci-test*]
|
||||
paths:
|
||||
- ".github/workflows/platform-backend-ci.yml"
|
||||
- ".github/workflows/scripts/get_package_version_from_lockfile.py"
|
||||
- "autogpt_platform/backend/**"
|
||||
- "autogpt_platform/autogpt_libs/**"
|
||||
pull_request:
|
||||
branches: [master, dev, release-*]
|
||||
paths:
|
||||
- ".github/workflows/platform-backend-ci.yml"
|
||||
- ".github/workflows/scripts/get_package_version_from_lockfile.py"
|
||||
- "autogpt_platform/backend/**"
|
||||
- "autogpt_platform/autogpt_libs/**"
|
||||
merge_group:
|
||||
|
||||
169
.github/workflows/platform-frontend-ci.yml
vendored
169
.github/workflows/platform-frontend-ci.yml
vendored
@@ -120,175 +120,6 @@ jobs:
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
exitOnceUploaded: true
|
||||
|
||||
e2e_test:
|
||||
name: end-to-end tests
|
||||
runs-on: big-boi
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
submodules: recursive
|
||||
|
||||
- name: Set up Platform - Copy default supabase .env
|
||||
run: |
|
||||
cp ../.env.default ../.env
|
||||
|
||||
- name: Set up Platform - Copy backend .env and set OpenAI API key
|
||||
run: |
|
||||
cp ../backend/.env.default ../backend/.env
|
||||
echo "OPENAI_INTERNAL_API_KEY=${{ secrets.OPENAI_API_KEY }}" >> ../backend/.env
|
||||
env:
|
||||
# Used by E2E test data script to generate embeddings for approved store agents
|
||||
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
|
||||
|
||||
- name: Set up Platform - Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
with:
|
||||
driver: docker-container
|
||||
driver-opts: network=host
|
||||
|
||||
- name: Set up Platform - Expose GHA cache to docker buildx CLI
|
||||
uses: crazy-max/ghaction-github-runtime@v4
|
||||
|
||||
- name: Set up Platform - Build Docker images (with cache)
|
||||
working-directory: autogpt_platform
|
||||
run: |
|
||||
pip install pyyaml
|
||||
|
||||
# Resolve extends and generate a flat compose file that bake can understand
|
||||
docker compose -f docker-compose.yml config > docker-compose.resolved.yml
|
||||
|
||||
# Add cache configuration to the resolved compose file
|
||||
python ../.github/workflows/scripts/docker-ci-fix-compose-build-cache.py \
|
||||
--source docker-compose.resolved.yml \
|
||||
--cache-from "type=gha" \
|
||||
--cache-to "type=gha,mode=max" \
|
||||
--backend-hash "${{ hashFiles('autogpt_platform/backend/Dockerfile', 'autogpt_platform/backend/poetry.lock', 'autogpt_platform/backend/backend') }}" \
|
||||
--frontend-hash "${{ hashFiles('autogpt_platform/frontend/Dockerfile', 'autogpt_platform/frontend/pnpm-lock.yaml', 'autogpt_platform/frontend/src') }}" \
|
||||
--git-ref "${{ github.ref }}"
|
||||
|
||||
# Build with bake using the resolved compose file (now includes cache config)
|
||||
docker buildx bake --allow=fs.read=.. -f docker-compose.resolved.yml --load
|
||||
env:
|
||||
NEXT_PUBLIC_PW_TEST: true
|
||||
|
||||
- name: Set up tests - Cache E2E test data
|
||||
id: e2e-data-cache
|
||||
uses: actions/cache@v5
|
||||
with:
|
||||
path: /tmp/e2e_test_data.sql
|
||||
key: e2e-test-data-${{ hashFiles('autogpt_platform/backend/test/e2e_test_data.py', 'autogpt_platform/backend/migrations/**', '.github/workflows/platform-frontend-ci.yml') }}
|
||||
|
||||
- name: Set up Platform - Start Supabase DB + Auth
|
||||
run: |
|
||||
docker compose -f ../docker-compose.resolved.yml up -d db auth --no-build
|
||||
echo "Waiting for database to be ready..."
|
||||
timeout 60 sh -c 'until docker compose -f ../docker-compose.resolved.yml exec -T db pg_isready -U postgres 2>/dev/null; do sleep 2; done'
|
||||
echo "Waiting for auth service to be ready..."
|
||||
timeout 60 sh -c 'until docker compose -f ../docker-compose.resolved.yml exec -T db psql -U postgres -d postgres -c "SELECT 1 FROM auth.users LIMIT 1" 2>/dev/null; do sleep 2; done' || echo "Auth schema check timeout, continuing..."
|
||||
|
||||
- name: Set up Platform - Run migrations
|
||||
run: |
|
||||
echo "Running migrations..."
|
||||
docker compose -f ../docker-compose.resolved.yml run --rm migrate
|
||||
echo "✅ Migrations completed"
|
||||
env:
|
||||
NEXT_PUBLIC_PW_TEST: true
|
||||
|
||||
- name: Set up tests - Load cached E2E test data
|
||||
if: steps.e2e-data-cache.outputs.cache-hit == 'true'
|
||||
run: |
|
||||
echo "✅ Found cached E2E test data, restoring..."
|
||||
{
|
||||
echo "SET session_replication_role = 'replica';"
|
||||
cat /tmp/e2e_test_data.sql
|
||||
echo "SET session_replication_role = 'origin';"
|
||||
} | docker compose -f ../docker-compose.resolved.yml exec -T db psql -U postgres -d postgres -b
|
||||
# Refresh materialized views after restore
|
||||
docker compose -f ../docker-compose.resolved.yml exec -T db \
|
||||
psql -U postgres -d postgres -b -c "SET search_path TO platform; SELECT refresh_store_materialized_views();" || true
|
||||
|
||||
echo "✅ E2E test data restored from cache"
|
||||
|
||||
- name: Set up Platform - Start (all other services)
|
||||
run: |
|
||||
docker compose -f ../docker-compose.resolved.yml up -d --no-build
|
||||
echo "Waiting for rest_server to be ready..."
|
||||
timeout 60 sh -c 'until curl -f http://localhost:8006/health 2>/dev/null; do sleep 2; done' || echo "Rest server health check timeout, continuing..."
|
||||
env:
|
||||
NEXT_PUBLIC_PW_TEST: true
|
||||
|
||||
- name: Set up tests - Create E2E test data
|
||||
if: steps.e2e-data-cache.outputs.cache-hit != 'true'
|
||||
run: |
|
||||
echo "Creating E2E test data..."
|
||||
docker cp ../backend/test/e2e_test_data.py $(docker compose -f ../docker-compose.resolved.yml ps -q rest_server):/tmp/e2e_test_data.py
|
||||
docker compose -f ../docker-compose.resolved.yml exec -T rest_server sh -c "cd /app/autogpt_platform && python /tmp/e2e_test_data.py" || {
|
||||
echo "❌ E2E test data creation failed!"
|
||||
docker compose -f ../docker-compose.resolved.yml logs --tail=50 rest_server
|
||||
exit 1
|
||||
}
|
||||
|
||||
# Dump auth.users + platform schema for cache (two separate dumps)
|
||||
echo "Dumping database for cache..."
|
||||
{
|
||||
docker compose -f ../docker-compose.resolved.yml exec -T db \
|
||||
pg_dump -U postgres --data-only --column-inserts \
|
||||
--table='auth.users' postgres
|
||||
docker compose -f ../docker-compose.resolved.yml exec -T db \
|
||||
pg_dump -U postgres --data-only --column-inserts \
|
||||
--schema=platform \
|
||||
--exclude-table='platform._prisma_migrations' \
|
||||
--exclude-table='platform.apscheduler_jobs' \
|
||||
--exclude-table='platform.apscheduler_jobs_batched_notifications' \
|
||||
postgres
|
||||
} > /tmp/e2e_test_data.sql
|
||||
|
||||
echo "✅ Database dump created for caching ($(wc -l < /tmp/e2e_test_data.sql) lines)"
|
||||
|
||||
- name: Set up tests - Enable corepack
|
||||
run: corepack enable
|
||||
|
||||
- name: Set up tests - Set up Node
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: "22.18.0"
|
||||
cache: "pnpm"
|
||||
cache-dependency-path: autogpt_platform/frontend/pnpm-lock.yaml
|
||||
|
||||
- name: Set up tests - Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Set up tests - Install browser 'chromium'
|
||||
run: pnpm playwright install --with-deps chromium
|
||||
|
||||
- name: Run Playwright tests
|
||||
run: pnpm test:no-build
|
||||
continue-on-error: false
|
||||
|
||||
- name: Upload Playwright report
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: playwright-report
|
||||
path: playwright-report
|
||||
if-no-files-found: ignore
|
||||
retention-days: 3
|
||||
|
||||
- name: Upload Playwright test results
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: playwright-test-results
|
||||
path: test-results
|
||||
if-no-files-found: ignore
|
||||
retention-days: 3
|
||||
|
||||
- name: Print Final Docker Compose logs
|
||||
if: always()
|
||||
run: docker compose -f ../docker-compose.resolved.yml logs
|
||||
|
||||
integration_test:
|
||||
runs-on: ubuntu-latest
|
||||
needs: setup
|
||||
|
||||
312
.github/workflows/platform-fullstack-ci.yml
vendored
312
.github/workflows/platform-fullstack-ci.yml
vendored
@@ -1,14 +1,18 @@
|
||||
name: AutoGPT Platform - Frontend CI
|
||||
name: AutoGPT Platform - Full-stack CI
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [master, dev]
|
||||
paths:
|
||||
- ".github/workflows/platform-fullstack-ci.yml"
|
||||
- ".github/workflows/scripts/docker-ci-fix-compose-build-cache.py"
|
||||
- ".github/workflows/scripts/get_package_version_from_lockfile.py"
|
||||
- "autogpt_platform/**"
|
||||
pull_request:
|
||||
paths:
|
||||
- ".github/workflows/platform-fullstack-ci.yml"
|
||||
- ".github/workflows/scripts/docker-ci-fix-compose-build-cache.py"
|
||||
- ".github/workflows/scripts/get_package_version_from_lockfile.py"
|
||||
- "autogpt_platform/**"
|
||||
merge_group:
|
||||
|
||||
@@ -24,42 +28,28 @@ defaults:
|
||||
jobs:
|
||||
setup:
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
cache-key: ${{ steps.cache-key.outputs.key }}
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Set up Node.js
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: "22.18.0"
|
||||
|
||||
- name: Enable corepack
|
||||
run: corepack enable
|
||||
|
||||
- name: Generate cache key
|
||||
id: cache-key
|
||||
run: echo "key=${{ runner.os }}-pnpm-${{ hashFiles('autogpt_platform/frontend/pnpm-lock.yaml', 'autogpt_platform/frontend/package.json') }}" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Cache dependencies
|
||||
uses: actions/cache@v5
|
||||
- name: Set up Node
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
path: ~/.pnpm-store
|
||||
key: ${{ steps.cache-key.outputs.key }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-pnpm-${{ hashFiles('autogpt_platform/frontend/pnpm-lock.yaml') }}
|
||||
${{ runner.os }}-pnpm-
|
||||
node-version: "22.18.0"
|
||||
cache: "pnpm"
|
||||
cache-dependency-path: autogpt_platform/frontend/pnpm-lock.yaml
|
||||
|
||||
- name: Install dependencies
|
||||
- name: Install dependencies to populate cache
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
types:
|
||||
runs-on: big-boi
|
||||
check-api-types:
|
||||
name: check API types
|
||||
runs-on: ubuntu-latest
|
||||
needs: setup
|
||||
strategy:
|
||||
fail-fast: false
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
@@ -67,70 +57,256 @@ jobs:
|
||||
with:
|
||||
submodules: recursive
|
||||
|
||||
- name: Set up Node.js
|
||||
# ------------------------ Backend setup ------------------------
|
||||
|
||||
- name: Set up Backend - Set up Python
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: "3.12"
|
||||
|
||||
- name: Set up Backend - Install Poetry
|
||||
working-directory: autogpt_platform/backend
|
||||
run: |
|
||||
POETRY_VERSION=$(python ../../.github/workflows/scripts/get_package_version_from_lockfile.py poetry)
|
||||
echo "Installing Poetry version ${POETRY_VERSION}"
|
||||
curl -sSL https://install.python-poetry.org | POETRY_VERSION=$POETRY_VERSION python3 -
|
||||
|
||||
- name: Set up Backend - Set up dependency cache
|
||||
uses: actions/cache@v5
|
||||
with:
|
||||
path: ~/.cache/pypoetry
|
||||
key: poetry-${{ runner.os }}-${{ hashFiles('autogpt_platform/backend/poetry.lock') }}
|
||||
|
||||
- name: Set up Backend - Install dependencies
|
||||
working-directory: autogpt_platform/backend
|
||||
run: poetry install
|
||||
|
||||
- name: Set up Backend - Generate Prisma client
|
||||
working-directory: autogpt_platform/backend
|
||||
run: poetry run prisma generate && poetry run gen-prisma-stub
|
||||
|
||||
- name: Set up Frontend - Export OpenAPI schema from Backend
|
||||
working-directory: autogpt_platform/backend
|
||||
run: poetry run export-api-schema --output ../frontend/src/app/api/openapi.json
|
||||
|
||||
# ------------------------ Frontend setup ------------------------
|
||||
|
||||
- name: Set up Frontend - Enable corepack
|
||||
run: corepack enable
|
||||
|
||||
- name: Set up Frontend - Set up Node
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: "22.18.0"
|
||||
cache: "pnpm"
|
||||
cache-dependency-path: autogpt_platform/frontend/pnpm-lock.yaml
|
||||
|
||||
- name: Enable corepack
|
||||
run: corepack enable
|
||||
|
||||
- name: Copy default supabase .env
|
||||
run: |
|
||||
cp ../.env.default ../.env
|
||||
|
||||
- name: Copy backend .env
|
||||
run: |
|
||||
cp ../backend/.env.default ../backend/.env
|
||||
|
||||
- name: Run docker compose
|
||||
run: |
|
||||
docker compose -f ../docker-compose.yml --profile local up -d deps_backend
|
||||
|
||||
- name: Restore dependencies cache
|
||||
uses: actions/cache@v5
|
||||
with:
|
||||
path: ~/.pnpm-store
|
||||
key: ${{ needs.setup.outputs.cache-key }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-pnpm-
|
||||
|
||||
- name: Install dependencies
|
||||
- name: Set up Frontend - Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Setup .env
|
||||
run: cp .env.default .env
|
||||
|
||||
- name: Wait for services to be ready
|
||||
run: |
|
||||
echo "Waiting for rest_server to be ready..."
|
||||
timeout 60 sh -c 'until curl -f http://localhost:8006/health 2>/dev/null; do sleep 2; done' || echo "Rest server health check timeout, continuing..."
|
||||
echo "Waiting for database to be ready..."
|
||||
timeout 60 sh -c 'until docker compose -f ../docker-compose.yml exec -T db pg_isready -U postgres 2>/dev/null; do sleep 2; done' || echo "Database ready check timeout, continuing..."
|
||||
|
||||
- name: Generate API queries
|
||||
run: pnpm generate:api:force
|
||||
- name: Set up Frontend - Format OpenAPI schema
|
||||
id: format-schema
|
||||
run: pnpm prettier --write ./src/app/api/openapi.json
|
||||
|
||||
- name: Check for API schema changes
|
||||
run: |
|
||||
if ! git diff --exit-code src/app/api/openapi.json; then
|
||||
echo "❌ API schema changes detected in src/app/api/openapi.json"
|
||||
echo ""
|
||||
echo "The openapi.json file has been modified after running 'pnpm generate:api-all'."
|
||||
echo "The openapi.json file has been modified after exporting the API schema."
|
||||
echo "This usually means changes have been made in the BE endpoints without updating the Frontend."
|
||||
echo "The API schema is now out of sync with the Front-end queries."
|
||||
echo ""
|
||||
echo "To fix this:"
|
||||
echo "1. Pull the backend 'docker compose pull && docker compose up -d --build --force-recreate'"
|
||||
echo "2. Run 'pnpm generate:api' locally"
|
||||
echo "3. Run 'pnpm types' locally"
|
||||
echo "4. Fix any TypeScript errors that may have been introduced"
|
||||
echo "5. Commit and push your changes"
|
||||
echo "\nIn the backend directory:"
|
||||
echo "1. Run 'poetry run export-api-schema --output ../frontend/src/app/api/openapi.json'"
|
||||
echo "\nIn the frontend directory:"
|
||||
echo "2. Run 'pnpm prettier --write src/app/api/openapi.json'"
|
||||
echo "3. Run 'pnpm generate:api'"
|
||||
echo "4. Run 'pnpm types'"
|
||||
echo "5. Fix any TypeScript errors that may have been introduced"
|
||||
echo "6. Commit and push your changes"
|
||||
echo ""
|
||||
exit 1
|
||||
else
|
||||
echo "✅ No API schema changes detected"
|
||||
fi
|
||||
|
||||
- name: Run Typescript checks
|
||||
- name: Set up Frontend - Generate API client
|
||||
id: generate-api-client
|
||||
run: pnpm orval --config ./orval.config.ts
|
||||
# Continue with type generation & check even if there are schema changes
|
||||
if: success() || (steps.format-schema.outcome == 'success')
|
||||
|
||||
- name: Check for TypeScript errors
|
||||
run: pnpm types
|
||||
if: success() || (steps.generate-api-client.outcome == 'success')
|
||||
|
||||
e2e_test:
|
||||
name: end-to-end tests
|
||||
runs-on: big-boi
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
submodules: recursive
|
||||
|
||||
- name: Set up Platform - Copy default supabase .env
|
||||
run: |
|
||||
cp ../.env.default ../.env
|
||||
|
||||
- name: Set up Platform - Copy backend .env and set OpenAI API key
|
||||
run: |
|
||||
cp ../backend/.env.default ../backend/.env
|
||||
echo "OPENAI_INTERNAL_API_KEY=${{ secrets.OPENAI_API_KEY }}" >> ../backend/.env
|
||||
env:
|
||||
# Used by E2E test data script to generate embeddings for approved store agents
|
||||
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
|
||||
|
||||
- name: Set up Platform - Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
with:
|
||||
driver: docker-container
|
||||
driver-opts: network=host
|
||||
|
||||
- name: Set up Platform - Expose GHA cache to docker buildx CLI
|
||||
uses: crazy-max/ghaction-github-runtime@v4
|
||||
|
||||
- name: Set up Platform - Build Docker images (with cache)
|
||||
working-directory: autogpt_platform
|
||||
run: |
|
||||
pip install pyyaml
|
||||
|
||||
# Resolve extends and generate a flat compose file that bake can understand
|
||||
docker compose -f docker-compose.yml config > docker-compose.resolved.yml
|
||||
|
||||
# Add cache configuration to the resolved compose file
|
||||
python ../.github/workflows/scripts/docker-ci-fix-compose-build-cache.py \
|
||||
--source docker-compose.resolved.yml \
|
||||
--cache-from "type=gha" \
|
||||
--cache-to "type=gha,mode=max" \
|
||||
--backend-hash "${{ hashFiles('autogpt_platform/backend/Dockerfile', 'autogpt_platform/backend/poetry.lock', 'autogpt_platform/backend/backend/**') }}" \
|
||||
--frontend-hash "${{ hashFiles('autogpt_platform/frontend/Dockerfile', 'autogpt_platform/frontend/pnpm-lock.yaml', 'autogpt_platform/frontend/src/**') }}" \
|
||||
--git-ref "${{ github.ref }}"
|
||||
|
||||
# Build with bake using the resolved compose file (now includes cache config)
|
||||
docker buildx bake --allow=fs.read=.. -f docker-compose.resolved.yml --load
|
||||
env:
|
||||
NEXT_PUBLIC_PW_TEST: true
|
||||
|
||||
- name: Set up tests - Cache E2E test data
|
||||
id: e2e-data-cache
|
||||
uses: actions/cache@v5
|
||||
with:
|
||||
path: /tmp/e2e_test_data.sql
|
||||
key: e2e-test-data-${{ hashFiles('autogpt_platform/backend/test/e2e_test_data.py', 'autogpt_platform/backend/migrations/**', '.github/workflows/platform-fullstack-ci.yml') }}
|
||||
|
||||
- name: Set up Platform - Start Supabase DB + Auth
|
||||
run: |
|
||||
docker compose -f ../docker-compose.resolved.yml up -d db auth --no-build
|
||||
echo "Waiting for database to be ready..."
|
||||
timeout 60 sh -c 'until docker compose -f ../docker-compose.resolved.yml exec -T db pg_isready -U postgres 2>/dev/null; do sleep 2; done'
|
||||
echo "Waiting for auth service to be ready..."
|
||||
timeout 60 sh -c 'until docker compose -f ../docker-compose.resolved.yml exec -T db psql -U postgres -d postgres -c "SELECT 1 FROM auth.users LIMIT 1" 2>/dev/null; do sleep 2; done' || echo "Auth schema check timeout, continuing..."
|
||||
|
||||
- name: Set up Platform - Run migrations
|
||||
run: |
|
||||
echo "Running migrations..."
|
||||
docker compose -f ../docker-compose.resolved.yml run --rm migrate
|
||||
echo "✅ Migrations completed"
|
||||
env:
|
||||
NEXT_PUBLIC_PW_TEST: true
|
||||
|
||||
- name: Set up tests - Load cached E2E test data
|
||||
if: steps.e2e-data-cache.outputs.cache-hit == 'true'
|
||||
run: |
|
||||
echo "✅ Found cached E2E test data, restoring..."
|
||||
{
|
||||
echo "SET session_replication_role = 'replica';"
|
||||
cat /tmp/e2e_test_data.sql
|
||||
echo "SET session_replication_role = 'origin';"
|
||||
} | docker compose -f ../docker-compose.resolved.yml exec -T db psql -U postgres -d postgres -b
|
||||
# Refresh materialized views after restore
|
||||
docker compose -f ../docker-compose.resolved.yml exec -T db \
|
||||
psql -U postgres -d postgres -b -c "SET search_path TO platform; SELECT refresh_store_materialized_views();" || true
|
||||
|
||||
echo "✅ E2E test data restored from cache"
|
||||
|
||||
- name: Set up Platform - Start (all other services)
|
||||
run: |
|
||||
docker compose -f ../docker-compose.resolved.yml up -d --no-build
|
||||
echo "Waiting for rest_server to be ready..."
|
||||
timeout 60 sh -c 'until curl -f http://localhost:8006/health 2>/dev/null; do sleep 2; done' || echo "Rest server health check timeout, continuing..."
|
||||
env:
|
||||
NEXT_PUBLIC_PW_TEST: true
|
||||
|
||||
- name: Set up tests - Create E2E test data
|
||||
if: steps.e2e-data-cache.outputs.cache-hit != 'true'
|
||||
run: |
|
||||
echo "Creating E2E test data..."
|
||||
docker cp ../backend/test/e2e_test_data.py $(docker compose -f ../docker-compose.resolved.yml ps -q rest_server):/tmp/e2e_test_data.py
|
||||
docker compose -f ../docker-compose.resolved.yml exec -T rest_server sh -c "cd /app/autogpt_platform && python /tmp/e2e_test_data.py" || {
|
||||
echo "❌ E2E test data creation failed!"
|
||||
docker compose -f ../docker-compose.resolved.yml logs --tail=50 rest_server
|
||||
exit 1
|
||||
}
|
||||
|
||||
# Dump auth.users + platform schema for cache (two separate dumps)
|
||||
echo "Dumping database for cache..."
|
||||
{
|
||||
docker compose -f ../docker-compose.resolved.yml exec -T db \
|
||||
pg_dump -U postgres --data-only --column-inserts \
|
||||
--table='auth.users' postgres
|
||||
docker compose -f ../docker-compose.resolved.yml exec -T db \
|
||||
pg_dump -U postgres --data-only --column-inserts \
|
||||
--schema=platform \
|
||||
--exclude-table='platform._prisma_migrations' \
|
||||
--exclude-table='platform.apscheduler_jobs' \
|
||||
--exclude-table='platform.apscheduler_jobs_batched_notifications' \
|
||||
postgres
|
||||
} > /tmp/e2e_test_data.sql
|
||||
|
||||
echo "✅ Database dump created for caching ($(wc -l < /tmp/e2e_test_data.sql) lines)"
|
||||
|
||||
- name: Set up tests - Enable corepack
|
||||
run: corepack enable
|
||||
|
||||
- name: Set up tests - Set up Node
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: "22.18.0"
|
||||
cache: "pnpm"
|
||||
cache-dependency-path: autogpt_platform/frontend/pnpm-lock.yaml
|
||||
|
||||
- name: Set up tests - Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Set up tests - Install browser 'chromium'
|
||||
run: pnpm playwright install --with-deps chromium
|
||||
|
||||
- name: Run Playwright tests
|
||||
run: pnpm test:no-build
|
||||
continue-on-error: false
|
||||
|
||||
- name: Upload Playwright report
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: playwright-report
|
||||
path: playwright-report
|
||||
if-no-files-found: ignore
|
||||
retention-days: 3
|
||||
|
||||
- name: Upload Playwright test results
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: playwright-test-results
|
||||
path: test-results
|
||||
if-no-files-found: ignore
|
||||
retention-days: 3
|
||||
|
||||
- name: Print Final Docker Compose logs
|
||||
if: always()
|
||||
run: docker compose -f ../docker-compose.resolved.yml logs
|
||||
|
||||
@@ -178,6 +178,16 @@ yield "image_url", result_url
|
||||
3. Write tests alongside the route file
|
||||
4. Run `poetry run test` to verify
|
||||
|
||||
## Workspace & Media Files
|
||||
|
||||
**Read [Workspace & Media Architecture](../../docs/platform/workspace-media-architecture.md) when:**
|
||||
- Working on CoPilot file upload/download features
|
||||
- Building blocks that handle `MediaFileType` inputs/outputs
|
||||
- Modifying `WorkspaceManager` or `store_media_file()`
|
||||
- Debugging file persistence or virus scanning issues
|
||||
|
||||
Covers: `WorkspaceManager` (persistent storage with session scoping), `store_media_file()` (media normalization pipeline), and responsibility boundaries for virus scanning and persistence.
|
||||
|
||||
## Security Implementation
|
||||
|
||||
### Cache Protection Middleware
|
||||
|
||||
@@ -5,26 +5,16 @@ Pluggable system for different content sources (store agents, blocks, docs).
|
||||
Each handler knows how to fetch and process its content type for embedding.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import functools
|
||||
import itertools
|
||||
import logging
|
||||
from abc import ABC, abstractmethod
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING, Any, get_args, get_origin
|
||||
from typing import Any, get_args, get_origin
|
||||
|
||||
from prisma.enums import ContentType
|
||||
|
||||
from backend.blocks import get_blocks
|
||||
from backend.blocks.llm import LlmModel
|
||||
from backend.data.db import query_raw_with_schema
|
||||
from backend.util.text import split_camelcase
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from backend.blocks._base import AnyBlockSchema
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -164,28 +154,6 @@ class StoreAgentHandler(ContentHandler):
|
||||
}
|
||||
|
||||
|
||||
@functools.lru_cache(maxsize=1)
|
||||
def _get_enabled_blocks() -> dict[str, AnyBlockSchema]:
|
||||
"""Return ``{block_id: block_instance}`` for all enabled, instantiable blocks.
|
||||
|
||||
Disabled blocks and blocks that fail to instantiate are silently skipped
|
||||
(with a warning log), so callers never need their own try/except loop.
|
||||
|
||||
Results are cached for the process lifetime via ``lru_cache`` because
|
||||
blocks are registered at import time and never change while running.
|
||||
"""
|
||||
enabled: dict[str, AnyBlockSchema] = {}
|
||||
for block_id, block_cls in get_blocks().items():
|
||||
try:
|
||||
instance = block_cls()
|
||||
except Exception as e:
|
||||
logger.warning(f"Skipping block {block_id}: init failed: {e}")
|
||||
continue
|
||||
if not instance.disabled:
|
||||
enabled[block_id] = instance
|
||||
return enabled
|
||||
|
||||
|
||||
class BlockHandler(ContentHandler):
|
||||
"""Handler for block definitions (Python classes)."""
|
||||
|
||||
@@ -195,14 +163,16 @@ class BlockHandler(ContentHandler):
|
||||
|
||||
async def get_missing_items(self, batch_size: int) -> list[ContentItem]:
|
||||
"""Fetch blocks without embeddings."""
|
||||
# to_thread keeps the first (heavy) call off the event loop. On
|
||||
# subsequent calls the lru_cache makes this a dict lookup, so the
|
||||
# thread-pool overhead is negligible compared to the DB queries below.
|
||||
enabled = await asyncio.to_thread(_get_enabled_blocks)
|
||||
if not enabled:
|
||||
from backend.blocks import get_blocks
|
||||
|
||||
# Get all available blocks
|
||||
all_blocks = get_blocks()
|
||||
|
||||
# Check which ones have embeddings
|
||||
if not all_blocks:
|
||||
return []
|
||||
|
||||
block_ids = list(enabled.keys())
|
||||
block_ids = list(all_blocks.keys())
|
||||
|
||||
# Query for existing embeddings
|
||||
placeholders = ",".join([f"${i+1}" for i in range(len(block_ids))])
|
||||
@@ -217,42 +187,52 @@ class BlockHandler(ContentHandler):
|
||||
)
|
||||
|
||||
existing_ids = {row["contentId"] for row in existing_result}
|
||||
missing_blocks = [
|
||||
(block_id, block_cls)
|
||||
for block_id, block_cls in all_blocks.items()
|
||||
if block_id not in existing_ids
|
||||
]
|
||||
|
||||
# Convert to ContentItem — disabled filtering already done by
|
||||
# _get_enabled_blocks so batch_size won't be exhausted by disabled blocks.
|
||||
missing = ((bid, b) for bid, b in enabled.items() if bid not in existing_ids)
|
||||
# Convert to ContentItem
|
||||
items = []
|
||||
for block_id, block in itertools.islice(missing, batch_size):
|
||||
for block_id, block_cls in missing_blocks[:batch_size]:
|
||||
try:
|
||||
block_instance = block_cls()
|
||||
|
||||
if block_instance.disabled:
|
||||
continue
|
||||
|
||||
# Build searchable text from block metadata
|
||||
if not block.name:
|
||||
logger.warning(
|
||||
f"Block {block_id} has no name — using block_id as fallback"
|
||||
)
|
||||
display_name = split_camelcase(block.name) if block.name else ""
|
||||
parts = []
|
||||
if display_name:
|
||||
parts.append(display_name)
|
||||
if block.description:
|
||||
parts.append(block.description)
|
||||
if block.categories:
|
||||
parts.append(" ".join(str(cat.value) for cat in block.categories))
|
||||
if block_instance.name:
|
||||
parts.append(block_instance.name)
|
||||
if block_instance.description:
|
||||
parts.append(block_instance.description)
|
||||
if block_instance.categories:
|
||||
parts.append(
|
||||
" ".join(str(cat.value) for cat in block_instance.categories)
|
||||
)
|
||||
|
||||
# Add input schema field descriptions
|
||||
block_input_fields = block_instance.input_schema.model_fields
|
||||
parts += [
|
||||
f"{field_name}: {field_info.description}"
|
||||
for field_name, field_info in block.input_schema.model_fields.items()
|
||||
for field_name, field_info in block_input_fields.items()
|
||||
if field_info.description
|
||||
]
|
||||
|
||||
searchable_text = " ".join(parts)
|
||||
|
||||
categories_list = (
|
||||
[cat.value for cat in block.categories] if block.categories else []
|
||||
[cat.value for cat in block_instance.categories]
|
||||
if block_instance.categories
|
||||
else []
|
||||
)
|
||||
|
||||
# Extract provider names from credentials fields
|
||||
credentials_info = block.input_schema.get_credentials_fields_info()
|
||||
credentials_info = (
|
||||
block_instance.input_schema.get_credentials_fields_info()
|
||||
)
|
||||
is_integration = len(credentials_info) > 0
|
||||
provider_names = [
|
||||
provider.value.lower()
|
||||
@@ -263,7 +243,7 @@ class BlockHandler(ContentHandler):
|
||||
# Check if block has LlmModel field in input schema
|
||||
has_llm_model_field = any(
|
||||
_contains_type(field.annotation, LlmModel)
|
||||
for field in block.input_schema.model_fields.values()
|
||||
for field in block_instance.input_schema.model_fields.values()
|
||||
)
|
||||
|
||||
items.append(
|
||||
@@ -272,13 +252,13 @@ class BlockHandler(ContentHandler):
|
||||
content_type=ContentType.BLOCK,
|
||||
searchable_text=searchable_text,
|
||||
metadata={
|
||||
"name": display_name or block.name or block_id,
|
||||
"name": block_instance.name,
|
||||
"categories": categories_list,
|
||||
"providers": provider_names,
|
||||
"has_llm_model_field": has_llm_model_field,
|
||||
"is_integration": is_integration,
|
||||
},
|
||||
user_id=None,
|
||||
user_id=None, # Blocks are public
|
||||
)
|
||||
)
|
||||
except Exception as e:
|
||||
@@ -289,13 +269,22 @@ class BlockHandler(ContentHandler):
|
||||
|
||||
async def get_stats(self) -> dict[str, int]:
|
||||
"""Get statistics about block embedding coverage."""
|
||||
enabled = await asyncio.to_thread(_get_enabled_blocks)
|
||||
total_blocks = len(enabled)
|
||||
from backend.blocks import get_blocks
|
||||
|
||||
all_blocks = get_blocks()
|
||||
|
||||
# Filter out disabled blocks - they're not indexed
|
||||
enabled_block_ids = [
|
||||
block_id
|
||||
for block_id, block_cls in all_blocks.items()
|
||||
if not block_cls().disabled
|
||||
]
|
||||
total_blocks = len(enabled_block_ids)
|
||||
|
||||
if total_blocks == 0:
|
||||
return {"total": 0, "with_embeddings": 0, "without_embeddings": 0}
|
||||
|
||||
block_ids = list(enabled.keys())
|
||||
block_ids = enabled_block_ids
|
||||
placeholders = ",".join([f"${i+1}" for i in range(len(block_ids))])
|
||||
|
||||
embedded_result = await query_raw_with_schema(
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
"""
|
||||
Tests for content handlers (blocks, store agents, documentation).
|
||||
E2E tests for content handlers (blocks, store agents, documentation).
|
||||
|
||||
Tests the full flow: discovering content → generating embeddings → storing.
|
||||
"""
|
||||
|
||||
from pathlib import Path
|
||||
@@ -13,103 +15,15 @@ from backend.api.features.store.content_handlers import (
|
||||
BlockHandler,
|
||||
DocumentationHandler,
|
||||
StoreAgentHandler,
|
||||
_get_enabled_blocks,
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _clear_block_cache():
|
||||
"""Clear the lru_cache on _get_enabled_blocks before each test."""
|
||||
_get_enabled_blocks.cache_clear()
|
||||
yield
|
||||
_get_enabled_blocks.cache_clear()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helper to build a mock block class that returns a pre-configured instance
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _make_block_class(
|
||||
*,
|
||||
name: str = "Block",
|
||||
description: str = "",
|
||||
disabled: bool = False,
|
||||
categories: list[MagicMock] | None = None,
|
||||
fields: dict[str, str] | None = None,
|
||||
raise_on_init: Exception | None = None,
|
||||
) -> MagicMock:
|
||||
cls = MagicMock()
|
||||
if raise_on_init is not None:
|
||||
cls.side_effect = raise_on_init
|
||||
return cls
|
||||
inst = MagicMock()
|
||||
inst.name = name
|
||||
inst.disabled = disabled
|
||||
inst.description = description
|
||||
inst.categories = categories or []
|
||||
field_mocks = {
|
||||
fname: MagicMock(description=fdesc) for fname, fdesc in (fields or {}).items()
|
||||
}
|
||||
inst.input_schema.model_fields = field_mocks
|
||||
inst.input_schema.get_credentials_fields_info.return_value = {}
|
||||
cls.return_value = inst
|
||||
return cls
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _get_enabled_blocks
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_get_enabled_blocks_filters_disabled():
|
||||
"""Disabled blocks are excluded."""
|
||||
blocks = {
|
||||
"enabled": _make_block_class(name="E", disabled=False),
|
||||
"disabled": _make_block_class(name="D", disabled=True),
|
||||
}
|
||||
with patch(
|
||||
"backend.api.features.store.content_handlers.get_blocks", return_value=blocks
|
||||
):
|
||||
result = _get_enabled_blocks()
|
||||
assert list(result.keys()) == ["enabled"]
|
||||
|
||||
|
||||
def test_get_enabled_blocks_skips_broken():
|
||||
"""Blocks that raise on init are skipped without crashing."""
|
||||
blocks = {
|
||||
"good": _make_block_class(name="Good"),
|
||||
"bad": _make_block_class(raise_on_init=RuntimeError("boom")),
|
||||
}
|
||||
with patch(
|
||||
"backend.api.features.store.content_handlers.get_blocks", return_value=blocks
|
||||
):
|
||||
result = _get_enabled_blocks()
|
||||
assert list(result.keys()) == ["good"]
|
||||
|
||||
|
||||
def test_get_enabled_blocks_cached():
|
||||
"""_get_enabled_blocks() calls get_blocks() only once across multiple calls."""
|
||||
blocks = {"b1": _make_block_class(name="B1")}
|
||||
with patch(
|
||||
"backend.api.features.store.content_handlers.get_blocks", return_value=blocks
|
||||
) as mock_get_blocks:
|
||||
result1 = _get_enabled_blocks()
|
||||
result2 = _get_enabled_blocks()
|
||||
assert result1 is result2
|
||||
mock_get_blocks.assert_called_once()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# StoreAgentHandler
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.asyncio(loop_scope="session")
|
||||
async def test_store_agent_handler_get_missing_items(mocker):
|
||||
"""Test StoreAgentHandler fetches approved agents without embeddings."""
|
||||
handler = StoreAgentHandler()
|
||||
|
||||
# Mock database query
|
||||
mock_missing = [
|
||||
{
|
||||
"id": "agent-1",
|
||||
@@ -140,7 +54,9 @@ async def test_store_agent_handler_get_stats(mocker):
|
||||
"""Test StoreAgentHandler returns correct stats."""
|
||||
handler = StoreAgentHandler()
|
||||
|
||||
# Mock approved count query
|
||||
mock_approved = [{"count": 50}]
|
||||
# Mock embedded count query
|
||||
mock_embedded = [{"count": 30}]
|
||||
|
||||
with patch(
|
||||
@@ -154,130 +70,74 @@ async def test_store_agent_handler_get_stats(mocker):
|
||||
assert stats["without_embeddings"] == 20
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# BlockHandler
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.asyncio(loop_scope="session")
|
||||
async def test_block_handler_get_missing_items():
|
||||
async def test_block_handler_get_missing_items(mocker):
|
||||
"""Test BlockHandler discovers blocks without embeddings."""
|
||||
handler = BlockHandler()
|
||||
|
||||
blocks = {
|
||||
"block-uuid-1": _make_block_class(
|
||||
name="CalculatorBlock",
|
||||
description="Performs calculations",
|
||||
categories=[MagicMock(value="MATH")],
|
||||
fields={"expression": "Math expression to evaluate"},
|
||||
),
|
||||
}
|
||||
# Mock get_blocks to return test blocks
|
||||
mock_block_class = MagicMock()
|
||||
mock_block_instance = MagicMock()
|
||||
mock_block_instance.name = "Calculator Block"
|
||||
mock_block_instance.description = "Performs calculations"
|
||||
mock_block_instance.categories = [MagicMock(value="MATH")]
|
||||
mock_block_instance.disabled = False
|
||||
mock_field = MagicMock()
|
||||
mock_field.description = "Math expression to evaluate"
|
||||
mock_block_instance.input_schema.model_fields = {"expression": mock_field}
|
||||
mock_block_instance.input_schema.get_credentials_fields_info.return_value = {}
|
||||
mock_block_class.return_value = mock_block_instance
|
||||
|
||||
mock_blocks = {"block-uuid-1": mock_block_class}
|
||||
|
||||
# Mock existing embeddings query (no embeddings exist)
|
||||
mock_existing = []
|
||||
|
||||
with patch(
|
||||
"backend.api.features.store.content_handlers.get_blocks", return_value=blocks
|
||||
"backend.blocks.get_blocks",
|
||||
return_value=mock_blocks,
|
||||
):
|
||||
with patch(
|
||||
"backend.api.features.store.content_handlers.query_raw_with_schema",
|
||||
return_value=[],
|
||||
return_value=mock_existing,
|
||||
):
|
||||
items = await handler.get_missing_items(batch_size=10)
|
||||
|
||||
assert len(items) == 1
|
||||
assert items[0].content_id == "block-uuid-1"
|
||||
assert items[0].content_type == ContentType.BLOCK
|
||||
# CamelCase should be split in searchable text and metadata name
|
||||
assert "Calculator Block" in items[0].searchable_text
|
||||
assert "Performs calculations" in items[0].searchable_text
|
||||
assert "MATH" in items[0].searchable_text
|
||||
assert "expression: Math expression" in items[0].searchable_text
|
||||
assert items[0].metadata["name"] == "Calculator Block"
|
||||
assert items[0].user_id is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio(loop_scope="session")
|
||||
async def test_block_handler_get_missing_items_splits_camelcase():
|
||||
"""CamelCase block names are split for better search indexing."""
|
||||
handler = BlockHandler()
|
||||
|
||||
blocks = {
|
||||
"ai-block": _make_block_class(name="AITextGeneratorBlock"),
|
||||
}
|
||||
|
||||
with patch(
|
||||
"backend.api.features.store.content_handlers.get_blocks", return_value=blocks
|
||||
):
|
||||
with patch(
|
||||
"backend.api.features.store.content_handlers.query_raw_with_schema",
|
||||
return_value=[],
|
||||
):
|
||||
items = await handler.get_missing_items(batch_size=10)
|
||||
|
||||
assert len(items) == 1
|
||||
assert "AI Text Generator Block" in items[0].searchable_text
|
||||
|
||||
|
||||
@pytest.mark.asyncio(loop_scope="session")
|
||||
async def test_block_handler_get_missing_items_batch_size_zero():
|
||||
"""batch_size=0 returns an empty list; the DB is still queried to find missing IDs."""
|
||||
handler = BlockHandler()
|
||||
|
||||
blocks = {"b1": _make_block_class(name="B1")}
|
||||
|
||||
with patch(
|
||||
"backend.api.features.store.content_handlers.get_blocks", return_value=blocks
|
||||
):
|
||||
with patch(
|
||||
"backend.api.features.store.content_handlers.query_raw_with_schema",
|
||||
return_value=[],
|
||||
) as mock_query:
|
||||
items = await handler.get_missing_items(batch_size=0)
|
||||
assert items == []
|
||||
# DB query is still issued to learn which blocks lack embeddings;
|
||||
# the empty result comes from itertools.islice limiting to 0 items.
|
||||
mock_query.assert_called_once()
|
||||
|
||||
|
||||
@pytest.mark.asyncio(loop_scope="session")
|
||||
async def test_block_handler_disabled_dont_exhaust_batch():
|
||||
"""Disabled blocks don't consume batch budget, so enabled blocks get indexed."""
|
||||
handler = BlockHandler()
|
||||
|
||||
# 5 disabled + 3 enabled, batch_size=2
|
||||
blocks = {
|
||||
**{
|
||||
f"dis-{i}": _make_block_class(name=f"D{i}", disabled=True) for i in range(5)
|
||||
},
|
||||
**{f"en-{i}": _make_block_class(name=f"E{i}") for i in range(3)},
|
||||
}
|
||||
|
||||
with patch(
|
||||
"backend.api.features.store.content_handlers.get_blocks", return_value=blocks
|
||||
):
|
||||
with patch(
|
||||
"backend.api.features.store.content_handlers.query_raw_with_schema",
|
||||
return_value=[],
|
||||
):
|
||||
items = await handler.get_missing_items(batch_size=2)
|
||||
|
||||
assert len(items) == 2
|
||||
assert all(item.content_id.startswith("en-") for item in items)
|
||||
|
||||
|
||||
@pytest.mark.asyncio(loop_scope="session")
|
||||
async def test_block_handler_get_stats():
|
||||
async def test_block_handler_get_stats(mocker):
|
||||
"""Test BlockHandler returns correct stats."""
|
||||
handler = BlockHandler()
|
||||
|
||||
blocks = {
|
||||
"block-1": _make_block_class(name="B1"),
|
||||
"block-2": _make_block_class(name="B2"),
|
||||
"block-3": _make_block_class(name="B3"),
|
||||
# Mock get_blocks - each block class returns an instance with disabled=False
|
||||
def make_mock_block_class():
|
||||
mock_class = MagicMock()
|
||||
mock_instance = MagicMock()
|
||||
mock_instance.disabled = False
|
||||
mock_class.return_value = mock_instance
|
||||
return mock_class
|
||||
|
||||
mock_blocks = {
|
||||
"block-1": make_mock_block_class(),
|
||||
"block-2": make_mock_block_class(),
|
||||
"block-3": make_mock_block_class(),
|
||||
}
|
||||
|
||||
# Mock embedded count query (2 blocks have embeddings)
|
||||
mock_embedded = [{"count": 2}]
|
||||
|
||||
with patch(
|
||||
"backend.api.features.store.content_handlers.get_blocks", return_value=blocks
|
||||
"backend.blocks.get_blocks",
|
||||
return_value=mock_blocks,
|
||||
):
|
||||
with patch(
|
||||
"backend.api.features.store.content_handlers.query_raw_with_schema",
|
||||
@@ -290,123 +150,21 @@ async def test_block_handler_get_stats():
|
||||
assert stats["without_embeddings"] == 1
|
||||
|
||||
|
||||
@pytest.mark.asyncio(loop_scope="session")
|
||||
async def test_block_handler_get_stats_skips_broken():
|
||||
"""get_stats skips broken blocks instead of crashing."""
|
||||
handler = BlockHandler()
|
||||
|
||||
blocks = {
|
||||
"good": _make_block_class(name="Good"),
|
||||
"bad": _make_block_class(raise_on_init=RuntimeError("boom")),
|
||||
}
|
||||
|
||||
mock_embedded = [{"count": 1}]
|
||||
|
||||
with patch(
|
||||
"backend.api.features.store.content_handlers.get_blocks", return_value=blocks
|
||||
):
|
||||
with patch(
|
||||
"backend.api.features.store.content_handlers.query_raw_with_schema",
|
||||
return_value=mock_embedded,
|
||||
):
|
||||
stats = await handler.get_stats()
|
||||
|
||||
assert stats["total"] == 1 # only the good block
|
||||
assert stats["with_embeddings"] == 1
|
||||
|
||||
|
||||
@pytest.mark.asyncio(loop_scope="session")
|
||||
async def test_block_handler_handles_none_name():
|
||||
"""When block.name is None the fallback display name logic is used."""
|
||||
handler = BlockHandler()
|
||||
|
||||
blocks = {
|
||||
"none-name-block": _make_block_class(
|
||||
name="placeholder", # will be overridden to None below
|
||||
description="A block with no name",
|
||||
),
|
||||
}
|
||||
# Override the name to None after construction so _make_block_class
|
||||
# doesn't interfere with the mock wiring.
|
||||
blocks["none-name-block"].return_value.name = None
|
||||
|
||||
with patch(
|
||||
"backend.api.features.store.content_handlers.get_blocks", return_value=blocks
|
||||
):
|
||||
with patch(
|
||||
"backend.api.features.store.content_handlers.query_raw_with_schema",
|
||||
return_value=[],
|
||||
):
|
||||
items = await handler.get_missing_items(batch_size=10)
|
||||
|
||||
assert len(items) == 1
|
||||
# display_name should be "" because block.name is None
|
||||
# searchable_text should still contain the description
|
||||
assert "A block with no name" in items[0].searchable_text
|
||||
# metadata["name"] falls back to block_id when both display_name
|
||||
# and block.name are falsy, ensuring it is always a non-empty string.
|
||||
assert items[0].metadata["name"] == "none-name-block"
|
||||
|
||||
|
||||
@pytest.mark.asyncio(loop_scope="session")
|
||||
async def test_block_handler_handles_empty_attributes():
|
||||
"""Test BlockHandler handles blocks with empty/falsy attribute values."""
|
||||
handler = BlockHandler()
|
||||
|
||||
blocks = {"block-minimal": _make_block_class(name="Minimal Block")}
|
||||
|
||||
with patch(
|
||||
"backend.api.features.store.content_handlers.get_blocks", return_value=blocks
|
||||
):
|
||||
with patch(
|
||||
"backend.api.features.store.content_handlers.query_raw_with_schema",
|
||||
return_value=[],
|
||||
):
|
||||
items = await handler.get_missing_items(batch_size=10)
|
||||
|
||||
assert len(items) == 1
|
||||
assert items[0].searchable_text == "Minimal Block"
|
||||
|
||||
|
||||
@pytest.mark.asyncio(loop_scope="session")
|
||||
async def test_block_handler_skips_failed_blocks():
|
||||
"""Test BlockHandler skips blocks that fail to instantiate."""
|
||||
handler = BlockHandler()
|
||||
|
||||
blocks = {
|
||||
"good-block": _make_block_class(name="Good Block", description="Works fine"),
|
||||
"bad-block": _make_block_class(raise_on_init=Exception("Instantiation failed")),
|
||||
}
|
||||
|
||||
with patch(
|
||||
"backend.api.features.store.content_handlers.get_blocks", return_value=blocks
|
||||
):
|
||||
with patch(
|
||||
"backend.api.features.store.content_handlers.query_raw_with_schema",
|
||||
return_value=[],
|
||||
):
|
||||
items = await handler.get_missing_items(batch_size=10)
|
||||
|
||||
assert len(items) == 1
|
||||
assert items[0].content_id == "good-block"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# DocumentationHandler
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.asyncio(loop_scope="session")
|
||||
async def test_documentation_handler_get_missing_items(tmp_path, mocker):
|
||||
"""Test DocumentationHandler discovers docs without embeddings."""
|
||||
handler = DocumentationHandler()
|
||||
|
||||
# Create temporary docs directory with test files
|
||||
docs_root = tmp_path / "docs"
|
||||
docs_root.mkdir()
|
||||
|
||||
(docs_root / "guide.md").write_text("# Getting Started\n\nThis is a guide.")
|
||||
(docs_root / "api.mdx").write_text("# API Reference\n\nAPI documentation.")
|
||||
|
||||
# Mock _get_docs_root to return temp dir
|
||||
with patch.object(handler, "_get_docs_root", return_value=docs_root):
|
||||
# Mock existing embeddings query (no embeddings exist)
|
||||
with patch(
|
||||
"backend.api.features.store.content_handlers.query_raw_with_schema",
|
||||
return_value=[],
|
||||
@@ -415,6 +173,7 @@ async def test_documentation_handler_get_missing_items(tmp_path, mocker):
|
||||
|
||||
assert len(items) == 2
|
||||
|
||||
# Check guide.md (content_id format: doc_path::section_index)
|
||||
guide_item = next(
|
||||
(item for item in items if item.content_id == "guide.md::0"), None
|
||||
)
|
||||
@@ -425,6 +184,7 @@ async def test_documentation_handler_get_missing_items(tmp_path, mocker):
|
||||
assert guide_item.metadata["doc_title"] == "Getting Started"
|
||||
assert guide_item.user_id is None
|
||||
|
||||
# Check api.mdx (content_id format: doc_path::section_index)
|
||||
api_item = next(
|
||||
(item for item in items if item.content_id == "api.mdx::0"), None
|
||||
)
|
||||
@@ -437,12 +197,14 @@ async def test_documentation_handler_get_stats(tmp_path, mocker):
|
||||
"""Test DocumentationHandler returns correct stats."""
|
||||
handler = DocumentationHandler()
|
||||
|
||||
# Create temporary docs directory
|
||||
docs_root = tmp_path / "docs"
|
||||
docs_root.mkdir()
|
||||
(docs_root / "doc1.md").write_text("# Doc 1")
|
||||
(docs_root / "doc2.md").write_text("# Doc 2")
|
||||
(docs_root / "doc3.mdx").write_text("# Doc 3")
|
||||
|
||||
# Mock embedded count query (1 doc has embedding)
|
||||
mock_embedded = [{"count": 1}]
|
||||
|
||||
with patch.object(handler, "_get_docs_root", return_value=docs_root):
|
||||
@@ -462,11 +224,13 @@ async def test_documentation_handler_title_extraction(tmp_path):
|
||||
"""Test DocumentationHandler extracts title from markdown heading."""
|
||||
handler = DocumentationHandler()
|
||||
|
||||
# Test with heading
|
||||
doc_with_heading = tmp_path / "with_heading.md"
|
||||
doc_with_heading.write_text("# My Title\n\nContent here")
|
||||
title = handler._extract_doc_title(doc_with_heading)
|
||||
assert title == "My Title"
|
||||
|
||||
# Test without heading
|
||||
doc_without_heading = tmp_path / "no-heading.md"
|
||||
doc_without_heading.write_text("Just content, no heading")
|
||||
title = handler._extract_doc_title(doc_without_heading)
|
||||
@@ -478,6 +242,7 @@ async def test_documentation_handler_markdown_chunking(tmp_path):
|
||||
"""Test DocumentationHandler chunks markdown by headings."""
|
||||
handler = DocumentationHandler()
|
||||
|
||||
# Test document with multiple sections
|
||||
doc_with_sections = tmp_path / "sections.md"
|
||||
doc_with_sections.write_text(
|
||||
"# Document Title\n\n"
|
||||
@@ -489,6 +254,7 @@ async def test_documentation_handler_markdown_chunking(tmp_path):
|
||||
)
|
||||
sections = handler._chunk_markdown_by_headings(doc_with_sections)
|
||||
|
||||
# Should have 3 sections: intro (with doc title), section one, section two
|
||||
assert len(sections) == 3
|
||||
assert sections[0].title == "Document Title"
|
||||
assert sections[0].index == 0
|
||||
@@ -502,6 +268,7 @@ async def test_documentation_handler_markdown_chunking(tmp_path):
|
||||
assert sections[2].index == 2
|
||||
assert "Content for section two" in sections[2].content
|
||||
|
||||
# Test document without headings
|
||||
doc_no_sections = tmp_path / "no-sections.md"
|
||||
doc_no_sections.write_text("Just plain content without any headings.")
|
||||
sections = handler._chunk_markdown_by_headings(doc_no_sections)
|
||||
@@ -515,39 +282,21 @@ async def test_documentation_handler_section_content_ids():
|
||||
"""Test DocumentationHandler creates and parses section content IDs."""
|
||||
handler = DocumentationHandler()
|
||||
|
||||
# Test making content ID
|
||||
content_id = handler._make_section_content_id("docs/guide.md", 2)
|
||||
assert content_id == "docs/guide.md::2"
|
||||
|
||||
# Test parsing content ID
|
||||
doc_path, section_index = handler._parse_section_content_id("docs/guide.md::2")
|
||||
assert doc_path == "docs/guide.md"
|
||||
assert section_index == 2
|
||||
|
||||
# Test parsing legacy format (no section index)
|
||||
doc_path, section_index = handler._parse_section_content_id("docs/old-format.md")
|
||||
assert doc_path == "docs/old-format.md"
|
||||
assert section_index == 0
|
||||
|
||||
|
||||
@pytest.mark.asyncio(loop_scope="session")
|
||||
async def test_documentation_handler_missing_docs_directory():
|
||||
"""Test DocumentationHandler handles missing docs directory gracefully."""
|
||||
handler = DocumentationHandler()
|
||||
|
||||
fake_path = Path("/nonexistent/docs")
|
||||
with patch.object(handler, "_get_docs_root", return_value=fake_path):
|
||||
items = await handler.get_missing_items(batch_size=10)
|
||||
assert items == []
|
||||
|
||||
stats = await handler.get_stats()
|
||||
assert stats["total"] == 0
|
||||
assert stats["with_embeddings"] == 0
|
||||
assert stats["without_embeddings"] == 0
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Registry
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.asyncio(loop_scope="session")
|
||||
async def test_content_handlers_registry():
|
||||
"""Test all content types are registered."""
|
||||
@@ -558,3 +307,88 @@ async def test_content_handlers_registry():
|
||||
assert isinstance(CONTENT_HANDLERS[ContentType.STORE_AGENT], StoreAgentHandler)
|
||||
assert isinstance(CONTENT_HANDLERS[ContentType.BLOCK], BlockHandler)
|
||||
assert isinstance(CONTENT_HANDLERS[ContentType.DOCUMENTATION], DocumentationHandler)
|
||||
|
||||
|
||||
@pytest.mark.asyncio(loop_scope="session")
|
||||
async def test_block_handler_handles_empty_attributes():
|
||||
"""Test BlockHandler handles blocks with empty/falsy attribute values."""
|
||||
handler = BlockHandler()
|
||||
|
||||
# Mock block with empty values (all attributes exist but are falsy)
|
||||
mock_block_class = MagicMock()
|
||||
mock_block_instance = MagicMock()
|
||||
mock_block_instance.name = "Minimal Block"
|
||||
mock_block_instance.disabled = False
|
||||
mock_block_instance.description = ""
|
||||
mock_block_instance.categories = set()
|
||||
mock_block_instance.input_schema.model_fields = {}
|
||||
mock_block_instance.input_schema.get_credentials_fields_info.return_value = {}
|
||||
mock_block_class.return_value = mock_block_instance
|
||||
|
||||
mock_blocks = {"block-minimal": mock_block_class}
|
||||
|
||||
with patch(
|
||||
"backend.blocks.get_blocks",
|
||||
return_value=mock_blocks,
|
||||
):
|
||||
with patch(
|
||||
"backend.api.features.store.content_handlers.query_raw_with_schema",
|
||||
return_value=[],
|
||||
):
|
||||
items = await handler.get_missing_items(batch_size=10)
|
||||
|
||||
assert len(items) == 1
|
||||
assert items[0].searchable_text == "Minimal Block"
|
||||
|
||||
|
||||
@pytest.mark.asyncio(loop_scope="session")
|
||||
async def test_block_handler_skips_failed_blocks():
|
||||
"""Test BlockHandler skips blocks that fail to instantiate."""
|
||||
handler = BlockHandler()
|
||||
|
||||
# Mock one good block and one bad block
|
||||
good_block = MagicMock()
|
||||
good_instance = MagicMock()
|
||||
good_instance.name = "Good Block"
|
||||
good_instance.description = "Works fine"
|
||||
good_instance.categories = []
|
||||
good_instance.disabled = False
|
||||
good_instance.input_schema.model_fields = {}
|
||||
good_instance.input_schema.get_credentials_fields_info.return_value = {}
|
||||
good_block.return_value = good_instance
|
||||
|
||||
bad_block = MagicMock()
|
||||
bad_block.side_effect = Exception("Instantiation failed")
|
||||
|
||||
mock_blocks = {"good-block": good_block, "bad-block": bad_block}
|
||||
|
||||
with patch(
|
||||
"backend.blocks.get_blocks",
|
||||
return_value=mock_blocks,
|
||||
):
|
||||
with patch(
|
||||
"backend.api.features.store.content_handlers.query_raw_with_schema",
|
||||
return_value=[],
|
||||
):
|
||||
items = await handler.get_missing_items(batch_size=10)
|
||||
|
||||
# Should only get the good block
|
||||
assert len(items) == 1
|
||||
assert items[0].content_id == "good-block"
|
||||
|
||||
|
||||
@pytest.mark.asyncio(loop_scope="session")
|
||||
async def test_documentation_handler_missing_docs_directory():
|
||||
"""Test DocumentationHandler handles missing docs directory gracefully."""
|
||||
handler = DocumentationHandler()
|
||||
|
||||
# Mock _get_docs_root to return non-existent path
|
||||
fake_path = Path("/nonexistent/docs")
|
||||
with patch.object(handler, "_get_docs_root", return_value=fake_path):
|
||||
items = await handler.get_missing_items(batch_size=10)
|
||||
assert items == []
|
||||
|
||||
stats = await handler.get_stats()
|
||||
assert stats["total"] == 0
|
||||
assert stats["with_embeddings"] == 0
|
||||
assert stats["without_embeddings"] == 0
|
||||
|
||||
@@ -15,7 +15,6 @@ from prisma.enums import ContentType
|
||||
from tiktoken import encoding_for_model
|
||||
|
||||
from backend.api.features.store.content_handlers import CONTENT_HANDLERS
|
||||
from backend.blocks import get_blocks
|
||||
from backend.data.db import execute_raw_with_schema, query_raw_with_schema
|
||||
from backend.util.clients import get_openai_client
|
||||
from backend.util.json import dumps
|
||||
@@ -663,6 +662,8 @@ async def cleanup_orphaned_embeddings() -> dict[str, Any]:
|
||||
)
|
||||
current_ids = {row["id"] for row in valid_agents}
|
||||
elif content_type == ContentType.BLOCK:
|
||||
from backend.blocks import get_blocks
|
||||
|
||||
current_ids = set(get_blocks().keys())
|
||||
elif content_type == ContentType.DOCUMENTATION:
|
||||
# Use DocumentationHandler to get section-based content IDs
|
||||
|
||||
@@ -31,10 +31,12 @@ logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def tokenize(text: str) -> list[str]:
|
||||
"""Tokenize text for BM25."""
|
||||
"""Simple tokenizer for BM25 - lowercase and split on non-alphanumeric."""
|
||||
if not text:
|
||||
return []
|
||||
return re.findall(r"\b\w+\b", text.lower())
|
||||
# Lowercase and split on non-alphanumeric characters
|
||||
tokens = re.findall(r"\b\w+\b", text.lower())
|
||||
return tokens
|
||||
|
||||
|
||||
def bm25_rerank(
|
||||
|
||||
@@ -14,27 +14,9 @@ from backend.api.features.store.hybrid_search import (
|
||||
HybridSearchWeights,
|
||||
UnifiedSearchWeights,
|
||||
hybrid_search,
|
||||
tokenize,
|
||||
unified_hybrid_search,
|
||||
)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# tokenize (BM25)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"input_text, expected",
|
||||
[
|
||||
("AITextGeneratorBlock", ["aitextgeneratorblock"]),
|
||||
("hello world", ["hello", "world"]),
|
||||
("", []),
|
||||
("HTTPRequest", ["httprequest"]),
|
||||
],
|
||||
)
|
||||
def test_tokenize(input_text: str, expected: list[str]):
|
||||
assert tokenize(input_text) == expected
|
||||
|
||||
|
||||
@pytest.mark.asyncio(loop_scope="session")
|
||||
@pytest.mark.integration
|
||||
|
||||
@@ -1,5 +0,0 @@
|
||||
"""Backward-compatibility shim — ``split_camelcase`` now lives in backend.util.text."""
|
||||
|
||||
from backend.util.text import split_camelcase # noqa: F401
|
||||
|
||||
__all__ = ["split_camelcase"]
|
||||
@@ -1,49 +0,0 @@
|
||||
"""Tests for split_camelcase (now in backend.util.text)."""
|
||||
|
||||
import pytest
|
||||
|
||||
from backend.util.text import split_camelcase
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# split_camelcase
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"input_text, expected",
|
||||
[
|
||||
("AITextGeneratorBlock", "AI Text Generator Block"),
|
||||
("HTTPRequestBlock", "HTTP Request Block"),
|
||||
("simpleWord", "simple Word"),
|
||||
("already spaced", "already spaced"),
|
||||
("XMLParser", "XML Parser"),
|
||||
("getHTTPResponse", "get HTTP Response"),
|
||||
("Block", "Block"),
|
||||
("", ""),
|
||||
("OAuth2Block", "OAuth2 Block"),
|
||||
("IOError", "IO Error"),
|
||||
("getHTTPSResponse", "get HTTPS Response"),
|
||||
# Known limitation: single-letter uppercase prefixes are NOT split.
|
||||
# "ABlock" stays "ABlock" because the algorithm requires the left
|
||||
# part of an uppercase run to retain at least 2 uppercase chars.
|
||||
("ABlock", "ABlock"),
|
||||
# Digit-to-uppercase transitions
|
||||
("Base64Encoder", "Base64 Encoder"),
|
||||
("UTF8Decoder", "UTF8 Decoder"),
|
||||
# Pure digits — no camelCase boundaries to split
|
||||
("123", "123"),
|
||||
# Known limitation: single-letter uppercase segments after digits
|
||||
# are not split from the following word. "3D" is only 1 uppercase
|
||||
# char so the uppercase-run rule cannot fire, producing "3 DRenderer"
|
||||
# rather than the ideal "3D Renderer".
|
||||
("3DRenderer", "3 DRenderer"),
|
||||
# Exception list — compound terms that should stay together
|
||||
("YouTubeBlock", "YouTube Block"),
|
||||
("OpenAIBlock", "OpenAI Block"),
|
||||
("AutoGPTAgent", "AutoGPT Agent"),
|
||||
("GitHubIntegration", "GitHub Integration"),
|
||||
("LinkedInBlock", "LinkedIn Block"),
|
||||
],
|
||||
)
|
||||
def test_split_camelcase(input_text: str, expected: str):
|
||||
assert split_camelcase(input_text) == expected
|
||||
@@ -40,7 +40,7 @@ from backend.copilot.response_model import (
|
||||
from backend.copilot.service import (
|
||||
_build_system_prompt,
|
||||
_generate_session_title,
|
||||
client,
|
||||
_get_openai_client,
|
||||
config,
|
||||
)
|
||||
from backend.copilot.tools import execute_tool, get_available_tools
|
||||
@@ -89,7 +89,7 @@ async def _compress_session_messages(
|
||||
result = await compress_context(
|
||||
messages=messages_dict,
|
||||
model=config.model,
|
||||
client=client,
|
||||
client=_get_openai_client(),
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning("[Baseline] Context compression with LLM failed: %s", e)
|
||||
@@ -235,7 +235,7 @@ async def stream_chat_completion_baseline(
|
||||
)
|
||||
if tools:
|
||||
create_kwargs["tools"] = tools
|
||||
response = await client.chat.completions.create(**create_kwargs) # type: ignore[arg-type] # dynamic kwargs
|
||||
response = await _get_openai_client().chat.completions.create(**create_kwargs) # type: ignore[arg-type] # dynamic kwargs
|
||||
|
||||
# Accumulate streamed response (text + tool calls)
|
||||
round_text = ""
|
||||
|
||||
@@ -28,10 +28,24 @@ logger = logging.getLogger(__name__)
|
||||
|
||||
config = ChatConfig()
|
||||
settings = Settings()
|
||||
client = LangfuseAsyncOpenAI(api_key=config.api_key, base_url=config.base_url)
|
||||
|
||||
_client: LangfuseAsyncOpenAI | None = None
|
||||
_langfuse = None
|
||||
|
||||
|
||||
langfuse = get_client()
|
||||
def _get_openai_client() -> LangfuseAsyncOpenAI:
|
||||
global _client
|
||||
if _client is None:
|
||||
_client = LangfuseAsyncOpenAI(api_key=config.api_key, base_url=config.base_url)
|
||||
return _client
|
||||
|
||||
|
||||
def _get_langfuse():
|
||||
global _langfuse
|
||||
if _langfuse is None:
|
||||
_langfuse = get_client()
|
||||
return _langfuse
|
||||
|
||||
|
||||
# Default system prompt used when Langfuse is not configured
|
||||
# Provides minimal baseline tone and personality - all workflow, tools, and
|
||||
@@ -84,7 +98,7 @@ async def _get_system_prompt_template(context: str) -> str:
|
||||
else "latest"
|
||||
)
|
||||
prompt = await asyncio.to_thread(
|
||||
langfuse.get_prompt,
|
||||
_get_langfuse().get_prompt,
|
||||
config.langfuse_prompt_name,
|
||||
label=label,
|
||||
cache_ttl_seconds=config.langfuse_prompt_cache_ttl,
|
||||
@@ -158,7 +172,7 @@ async def _generate_session_title(
|
||||
"environment": settings.config.app_env.value,
|
||||
}
|
||||
|
||||
response = await client.chat.completions.create(
|
||||
response = await _get_openai_client().chat.completions.create(
|
||||
model=config.title_model,
|
||||
messages=[
|
||||
{
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import logging
|
||||
import re
|
||||
|
||||
import bleach
|
||||
from bleach.css_sanitizer import CSSSanitizer
|
||||
@@ -155,76 +154,3 @@ class TextFormatter:
|
||||
)
|
||||
|
||||
return rendered_subject_template, rendered_base_template
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# CamelCase splitting
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
# Map of split forms back to their canonical compound terms.
|
||||
# Mirrors the frontend exception list in frontend/src/lib/utils.ts.
|
||||
_CAMELCASE_EXCEPTIONS: dict[str, str] = {
|
||||
"Auto GPT": "AutoGPT",
|
||||
"Open AI": "OpenAI",
|
||||
"You Tube": "YouTube",
|
||||
"Git Hub": "GitHub",
|
||||
"Linked In": "LinkedIn",
|
||||
}
|
||||
|
||||
_CAMELCASE_EXCEPTION_RE = re.compile(
|
||||
"|".join(re.escape(k) for k in _CAMELCASE_EXCEPTIONS),
|
||||
)
|
||||
|
||||
|
||||
def split_camelcase(text: str) -> str:
|
||||
"""Split CamelCase into separate words.
|
||||
|
||||
Uses a single-pass character-by-character algorithm to avoid any
|
||||
regex backtracking concerns (guaranteed O(n) time).
|
||||
|
||||
After splitting, known compound terms are restored via an exception
|
||||
list (e.g. ``"YouTube"`` stays ``"YouTube"`` instead of becoming
|
||||
``"You Tube"``). The list mirrors the frontend mapping in
|
||||
``frontend/src/lib/utils.ts``.
|
||||
|
||||
Examples::
|
||||
|
||||
>>> split_camelcase("AITextGeneratorBlock")
|
||||
'AI Text Generator Block'
|
||||
>>> split_camelcase("OAuth2Block")
|
||||
'OAuth2 Block'
|
||||
>>> split_camelcase("YouTubeBlock")
|
||||
'YouTube Block'
|
||||
"""
|
||||
if len(text) <= 1:
|
||||
return text
|
||||
|
||||
parts: list[str] = []
|
||||
prev = 0
|
||||
for i in range(1, len(text)):
|
||||
# Insert split between lowercase/digit and uppercase: "camelCase" -> "camel|Case"
|
||||
if (text[i - 1].islower() or text[i - 1].isdigit()) and text[i].isupper():
|
||||
parts.append(text[prev:i])
|
||||
prev = i
|
||||
# Insert split between uppercase run (2+ chars) and uppercase+lowercase:
|
||||
# "AIText" -> "AI|Text". Requires at least 3 consecutive uppercase chars
|
||||
# before the lowercase so that the left part keeps 2+ uppercase chars
|
||||
# (mirrors the original regex r"([A-Z]{2,})([A-Z][a-z])").
|
||||
elif (
|
||||
i >= 2
|
||||
and text[i - 2].isupper()
|
||||
and text[i - 1].isupper()
|
||||
and text[i].islower()
|
||||
and (i - 1 - prev) >= 2 # left part must retain at least 2 upper chars
|
||||
):
|
||||
parts.append(text[prev : i - 1])
|
||||
prev = i - 1
|
||||
|
||||
parts.append(text[prev:])
|
||||
result = " ".join(parts)
|
||||
|
||||
# Restore known compound terms that should not be split.
|
||||
result = _CAMELCASE_EXCEPTION_RE.sub(
|
||||
lambda m: _CAMELCASE_EXCEPTIONS[m.group()], result
|
||||
)
|
||||
return result
|
||||
|
||||
@@ -183,7 +183,8 @@ class WorkspaceManager:
|
||||
f"{Config().max_file_size_mb}MB limit"
|
||||
)
|
||||
|
||||
# Virus scan content before persisting (defense in depth)
|
||||
# Scan here — callers must NOT duplicate this scan.
|
||||
# WorkspaceManager owns virus scanning for all persisted files.
|
||||
await scan_content_safe(content, filename=filename)
|
||||
|
||||
# Determine path with session scoping
|
||||
|
||||
@@ -0,0 +1,440 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { screen, cleanup } from "@testing-library/react";
|
||||
import { render } from "@/tests/integrations/test-utils";
|
||||
import React from "react";
|
||||
import { BlockUIType } from "../components/types";
|
||||
import type {
|
||||
CustomNodeData,
|
||||
CustomNode as CustomNodeType,
|
||||
} from "../components/FlowEditor/nodes/CustomNode/CustomNode";
|
||||
import type { NodeProps } from "@xyflow/react";
|
||||
import type { NodeExecutionResult } from "@/app/api/__generated__/models/nodeExecutionResult";
|
||||
|
||||
// ---- Mock sub-components ----
|
||||
|
||||
vi.mock(
|
||||
"@/app/(platform)/build/components/FlowEditor/nodes/CustomNode/components/NodeContainer",
|
||||
() => ({
|
||||
NodeContainer: ({
|
||||
children,
|
||||
hasErrors,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
hasErrors: boolean;
|
||||
}) => (
|
||||
<div data-testid="node-container" data-has-errors={String(!!hasErrors)}>
|
||||
{children}
|
||||
</div>
|
||||
),
|
||||
}),
|
||||
);
|
||||
|
||||
vi.mock(
|
||||
"@/app/(platform)/build/components/FlowEditor/nodes/CustomNode/components/NodeHeader",
|
||||
() => ({
|
||||
NodeHeader: ({ data }: { data: CustomNodeData }) => (
|
||||
<div data-testid="node-header">{data.title}</div>
|
||||
),
|
||||
}),
|
||||
);
|
||||
|
||||
vi.mock(
|
||||
"@/app/(platform)/build/components/FlowEditor/nodes/CustomNode/components/StickyNoteBlock",
|
||||
() => ({
|
||||
StickyNoteBlock: ({ data }: { data: CustomNodeData }) => (
|
||||
<div data-testid="sticky-note-block">{data.title}</div>
|
||||
),
|
||||
}),
|
||||
);
|
||||
|
||||
vi.mock(
|
||||
"@/app/(platform)/build/components/FlowEditor/nodes/CustomNode/components/NodeAdvancedToggle",
|
||||
() => ({
|
||||
NodeAdvancedToggle: () => <div data-testid="node-advanced-toggle" />,
|
||||
}),
|
||||
);
|
||||
|
||||
vi.mock(
|
||||
"@/app/(platform)/build/components/FlowEditor/nodes/CustomNode/components/NodeOutput/NodeOutput",
|
||||
() => ({
|
||||
NodeDataRenderer: () => <div data-testid="node-data-renderer" />,
|
||||
}),
|
||||
);
|
||||
|
||||
vi.mock(
|
||||
"@/app/(platform)/build/components/FlowEditor/nodes/CustomNode/components/NodeExecutionBadge",
|
||||
() => ({
|
||||
NodeExecutionBadge: () => <div data-testid="node-execution-badge" />,
|
||||
}),
|
||||
);
|
||||
|
||||
vi.mock(
|
||||
"@/app/(platform)/build/components/FlowEditor/nodes/CustomNode/components/NodeRightClickMenu",
|
||||
() => ({
|
||||
NodeRightClickMenu: ({ children }: { children: React.ReactNode }) => (
|
||||
<div data-testid="node-right-click-menu">{children}</div>
|
||||
),
|
||||
}),
|
||||
);
|
||||
|
||||
vi.mock(
|
||||
"@/app/(platform)/build/components/FlowEditor/nodes/CustomNode/components/WebhookDisclaimer",
|
||||
() => ({
|
||||
WebhookDisclaimer: () => <div data-testid="webhook-disclaimer" />,
|
||||
}),
|
||||
);
|
||||
|
||||
vi.mock(
|
||||
"@/app/(platform)/build/components/FlowEditor/nodes/CustomNode/components/SubAgentUpdate/SubAgentUpdateFeature",
|
||||
() => ({
|
||||
SubAgentUpdateFeature: () => <div data-testid="sub-agent-update" />,
|
||||
}),
|
||||
);
|
||||
|
||||
vi.mock(
|
||||
"@/app/(platform)/build/components/FlowEditor/nodes/CustomNode/components/AyrshareConnectButton",
|
||||
() => ({
|
||||
AyrshareConnectButton: () => <div data-testid="ayrshare-connect-button" />,
|
||||
}),
|
||||
);
|
||||
|
||||
vi.mock(
|
||||
"@/app/(platform)/build/components/FlowEditor/nodes/FormCreator",
|
||||
() => ({
|
||||
FormCreator: () => <div data-testid="form-creator" />,
|
||||
}),
|
||||
);
|
||||
|
||||
vi.mock(
|
||||
"@/app/(platform)/build/components/FlowEditor/nodes/OutputHandler",
|
||||
() => ({
|
||||
OutputHandler: () => <div data-testid="output-handler" />,
|
||||
}),
|
||||
);
|
||||
|
||||
vi.mock(
|
||||
"@/components/renderers/InputRenderer/utils/input-schema-pre-processor",
|
||||
() => ({
|
||||
preprocessInputSchema: (schema: unknown) => schema,
|
||||
}),
|
||||
);
|
||||
|
||||
vi.mock(
|
||||
"@/app/(platform)/build/components/FlowEditor/nodes/CustomNode/useCustomNode",
|
||||
() => ({
|
||||
useCustomNode: ({ data }: { data: CustomNodeData }) => ({
|
||||
inputSchema: data.inputSchema,
|
||||
outputSchema: data.outputSchema,
|
||||
isMCPWithTool: false,
|
||||
}),
|
||||
}),
|
||||
);
|
||||
|
||||
vi.mock("@xyflow/react", async () => {
|
||||
const actual = await vi.importActual("@xyflow/react");
|
||||
return {
|
||||
...actual,
|
||||
useReactFlow: () => ({
|
||||
getNodes: () => [],
|
||||
getEdges: () => [],
|
||||
setNodes: vi.fn(),
|
||||
setEdges: vi.fn(),
|
||||
getNode: vi.fn(),
|
||||
}),
|
||||
useNodeId: () => "test-node-id",
|
||||
useUpdateNodeInternals: () => vi.fn(),
|
||||
Handle: ({ children }: { children: React.ReactNode }) => (
|
||||
<div>{children}</div>
|
||||
),
|
||||
Position: { Left: "left", Right: "right", Top: "top", Bottom: "bottom" },
|
||||
};
|
||||
});
|
||||
|
||||
import { CustomNode } from "../components/FlowEditor/nodes/CustomNode/CustomNode";
|
||||
|
||||
// ---- Helpers ----
|
||||
|
||||
function buildNodeData(
|
||||
overrides: Partial<CustomNodeData> = {},
|
||||
): CustomNodeData {
|
||||
return {
|
||||
hardcodedValues: {},
|
||||
title: "Test Block",
|
||||
description: "A test block",
|
||||
inputSchema: { type: "object", properties: {} },
|
||||
outputSchema: { type: "object", properties: {} },
|
||||
uiType: BlockUIType.STANDARD,
|
||||
block_id: "block-123",
|
||||
costs: [],
|
||||
categories: [],
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function buildNodeProps(
|
||||
dataOverrides: Partial<CustomNodeData> = {},
|
||||
propsOverrides: Partial<NodeProps<CustomNodeType>> = {},
|
||||
): NodeProps<CustomNodeType> {
|
||||
return {
|
||||
id: "node-1",
|
||||
data: buildNodeData(dataOverrides),
|
||||
selected: false,
|
||||
type: "custom",
|
||||
isConnectable: true,
|
||||
positionAbsoluteX: 0,
|
||||
positionAbsoluteY: 0,
|
||||
zIndex: 0,
|
||||
dragging: false,
|
||||
dragHandle: undefined,
|
||||
draggable: true,
|
||||
selectable: true,
|
||||
deletable: true,
|
||||
parentId: undefined,
|
||||
width: undefined,
|
||||
height: undefined,
|
||||
sourcePosition: undefined,
|
||||
targetPosition: undefined,
|
||||
...propsOverrides,
|
||||
};
|
||||
}
|
||||
|
||||
function renderCustomNode(
|
||||
dataOverrides: Partial<CustomNodeData> = {},
|
||||
propsOverrides: Partial<NodeProps<CustomNodeType>> = {},
|
||||
) {
|
||||
const props = buildNodeProps(dataOverrides, propsOverrides);
|
||||
return render(<CustomNode {...props} />);
|
||||
}
|
||||
|
||||
function createExecutionResult(
|
||||
overrides: Partial<NodeExecutionResult> = {},
|
||||
): NodeExecutionResult {
|
||||
return {
|
||||
node_exec_id: overrides.node_exec_id ?? "exec-1",
|
||||
node_id: overrides.node_id ?? "node-1",
|
||||
graph_exec_id: overrides.graph_exec_id ?? "graph-exec-1",
|
||||
graph_id: overrides.graph_id ?? "graph-1",
|
||||
graph_version: overrides.graph_version ?? 1,
|
||||
user_id: overrides.user_id ?? "test-user",
|
||||
block_id: overrides.block_id ?? "block-1",
|
||||
status: overrides.status ?? "COMPLETED",
|
||||
input_data: overrides.input_data ?? {},
|
||||
output_data: overrides.output_data ?? {},
|
||||
add_time: overrides.add_time ?? new Date("2024-01-01T00:00:00Z"),
|
||||
queue_time: overrides.queue_time ?? new Date("2024-01-01T00:00:00Z"),
|
||||
start_time: overrides.start_time ?? new Date("2024-01-01T00:00:01Z"),
|
||||
end_time: overrides.end_time ?? new Date("2024-01-01T00:00:02Z"),
|
||||
};
|
||||
}
|
||||
|
||||
// ---- Tests ----
|
||||
|
||||
beforeEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
describe("CustomNode", () => {
|
||||
describe("STANDARD type rendering", () => {
|
||||
it("renders NodeHeader with the block title", () => {
|
||||
renderCustomNode({ title: "My Standard Block" });
|
||||
|
||||
const header = screen.getByTestId("node-header");
|
||||
expect(header).toBeDefined();
|
||||
expect(header.textContent).toContain("My Standard Block");
|
||||
});
|
||||
|
||||
it("renders NodeContainer, FormCreator, OutputHandler, and NodeExecutionBadge", () => {
|
||||
renderCustomNode();
|
||||
|
||||
expect(screen.getByTestId("node-container")).toBeDefined();
|
||||
expect(screen.getByTestId("form-creator")).toBeDefined();
|
||||
expect(screen.getByTestId("output-handler")).toBeDefined();
|
||||
expect(screen.getByTestId("node-execution-badge")).toBeDefined();
|
||||
expect(screen.getByTestId("node-data-renderer")).toBeDefined();
|
||||
expect(screen.getByTestId("node-advanced-toggle")).toBeDefined();
|
||||
});
|
||||
|
||||
it("wraps content in NodeRightClickMenu", () => {
|
||||
renderCustomNode();
|
||||
|
||||
expect(screen.getByTestId("node-right-click-menu")).toBeDefined();
|
||||
});
|
||||
|
||||
it("does not render StickyNoteBlock for STANDARD type", () => {
|
||||
renderCustomNode();
|
||||
|
||||
expect(screen.queryByTestId("sticky-note-block")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("NOTE type rendering", () => {
|
||||
it("renders StickyNoteBlock instead of main UI", () => {
|
||||
renderCustomNode({ uiType: BlockUIType.NOTE, title: "My Note" });
|
||||
|
||||
const note = screen.getByTestId("sticky-note-block");
|
||||
expect(note).toBeDefined();
|
||||
expect(note.textContent).toContain("My Note");
|
||||
});
|
||||
|
||||
it("does not render NodeContainer or other standard components", () => {
|
||||
renderCustomNode({ uiType: BlockUIType.NOTE });
|
||||
|
||||
expect(screen.queryByTestId("node-container")).toBeNull();
|
||||
expect(screen.queryByTestId("node-header")).toBeNull();
|
||||
expect(screen.queryByTestId("form-creator")).toBeNull();
|
||||
expect(screen.queryByTestId("output-handler")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("WEBHOOK type rendering", () => {
|
||||
it("renders WebhookDisclaimer for WEBHOOK type", () => {
|
||||
renderCustomNode({ uiType: BlockUIType.WEBHOOK });
|
||||
|
||||
expect(screen.getByTestId("webhook-disclaimer")).toBeDefined();
|
||||
});
|
||||
|
||||
it("renders WebhookDisclaimer for WEBHOOK_MANUAL type", () => {
|
||||
renderCustomNode({ uiType: BlockUIType.WEBHOOK_MANUAL });
|
||||
|
||||
expect(screen.getByTestId("webhook-disclaimer")).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("AGENT type rendering", () => {
|
||||
it("renders SubAgentUpdateFeature for AGENT type", () => {
|
||||
renderCustomNode({ uiType: BlockUIType.AGENT });
|
||||
|
||||
expect(screen.getByTestId("sub-agent-update")).toBeDefined();
|
||||
});
|
||||
|
||||
it("does not render SubAgentUpdateFeature for non-AGENT types", () => {
|
||||
renderCustomNode({ uiType: BlockUIType.STANDARD });
|
||||
|
||||
expect(screen.queryByTestId("sub-agent-update")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("OUTPUT type rendering", () => {
|
||||
it("does not render OutputHandler for OUTPUT type", () => {
|
||||
renderCustomNode({ uiType: BlockUIType.OUTPUT });
|
||||
|
||||
expect(screen.queryByTestId("output-handler")).toBeNull();
|
||||
});
|
||||
|
||||
it("still renders FormCreator and other components for OUTPUT type", () => {
|
||||
renderCustomNode({ uiType: BlockUIType.OUTPUT });
|
||||
|
||||
expect(screen.getByTestId("form-creator")).toBeDefined();
|
||||
expect(screen.getByTestId("node-header")).toBeDefined();
|
||||
expect(screen.getByTestId("node-execution-badge")).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("AYRSHARE type rendering", () => {
|
||||
it("renders AyrshareConnectButton for AYRSHARE type", () => {
|
||||
renderCustomNode({ uiType: BlockUIType.AYRSHARE });
|
||||
|
||||
expect(screen.getByTestId("ayrshare-connect-button")).toBeDefined();
|
||||
});
|
||||
|
||||
it("does not render AyrshareConnectButton for non-AYRSHARE types", () => {
|
||||
renderCustomNode({ uiType: BlockUIType.STANDARD });
|
||||
|
||||
expect(screen.queryByTestId("ayrshare-connect-button")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("error states", () => {
|
||||
it("sets hasErrors on NodeContainer when data.errors has non-empty values", () => {
|
||||
renderCustomNode({
|
||||
errors: { field1: "This field is required" },
|
||||
});
|
||||
|
||||
const container = screen.getByTestId("node-container");
|
||||
expect(container.getAttribute("data-has-errors")).toBe("true");
|
||||
});
|
||||
|
||||
it("does not set hasErrors when data.errors is empty", () => {
|
||||
renderCustomNode({ errors: {} });
|
||||
|
||||
const container = screen.getByTestId("node-container");
|
||||
expect(container.getAttribute("data-has-errors")).toBe("false");
|
||||
});
|
||||
|
||||
it("does not set hasErrors when data.errors values are all empty strings", () => {
|
||||
renderCustomNode({ errors: { field1: "" } });
|
||||
|
||||
const container = screen.getByTestId("node-container");
|
||||
expect(container.getAttribute("data-has-errors")).toBe("false");
|
||||
});
|
||||
|
||||
it("sets hasErrors when last execution result has error in output_data", () => {
|
||||
renderCustomNode({
|
||||
nodeExecutionResults: [
|
||||
createExecutionResult({
|
||||
output_data: { error: ["Something went wrong"] },
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
const container = screen.getByTestId("node-container");
|
||||
expect(container.getAttribute("data-has-errors")).toBe("true");
|
||||
});
|
||||
|
||||
it("does not set hasErrors when execution results have no error", () => {
|
||||
renderCustomNode({
|
||||
nodeExecutionResults: [
|
||||
createExecutionResult({
|
||||
output_data: { result: ["success"] },
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
const container = screen.getByTestId("node-container");
|
||||
expect(container.getAttribute("data-has-errors")).toBe("false");
|
||||
});
|
||||
});
|
||||
|
||||
describe("NodeExecutionBadge", () => {
|
||||
it("always renders NodeExecutionBadge for non-NOTE types", () => {
|
||||
renderCustomNode({ uiType: BlockUIType.STANDARD });
|
||||
expect(screen.getByTestId("node-execution-badge")).toBeDefined();
|
||||
});
|
||||
|
||||
it("renders NodeExecutionBadge for AGENT type", () => {
|
||||
renderCustomNode({ uiType: BlockUIType.AGENT });
|
||||
expect(screen.getByTestId("node-execution-badge")).toBeDefined();
|
||||
});
|
||||
|
||||
it("renders NodeExecutionBadge for OUTPUT type", () => {
|
||||
renderCustomNode({ uiType: BlockUIType.OUTPUT });
|
||||
expect(screen.getByTestId("node-execution-badge")).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("edge cases", () => {
|
||||
it("renders without nodeExecutionResults", () => {
|
||||
renderCustomNode({ nodeExecutionResults: undefined });
|
||||
|
||||
const container = screen.getByTestId("node-container");
|
||||
expect(container).toBeDefined();
|
||||
expect(container.getAttribute("data-has-errors")).toBe("false");
|
||||
});
|
||||
|
||||
it("renders without errors property", () => {
|
||||
renderCustomNode({ errors: undefined });
|
||||
|
||||
const container = screen.getByTestId("node-container");
|
||||
expect(container).toBeDefined();
|
||||
expect(container.getAttribute("data-has-errors")).toBe("false");
|
||||
});
|
||||
|
||||
it("renders with empty execution results array", () => {
|
||||
renderCustomNode({ nodeExecutionResults: [] });
|
||||
|
||||
const container = screen.getByTestId("node-container");
|
||||
expect(container).toBeDefined();
|
||||
expect(container.getAttribute("data-has-errors")).toBe("false");
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,342 @@
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
|
||||
import {
|
||||
render,
|
||||
screen,
|
||||
fireEvent,
|
||||
waitFor,
|
||||
cleanup,
|
||||
} from "@/tests/integrations/test-utils";
|
||||
import { useBlockMenuStore } from "../stores/blockMenuStore";
|
||||
import { useControlPanelStore } from "../stores/controlPanelStore";
|
||||
import { DefaultStateType } from "../components/NewControlPanel/NewBlockMenu/types";
|
||||
import { SearchEntryFilterAnyOfItem } from "@/app/api/__generated__/models/searchEntryFilterAnyOfItem";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Mocks for heavy child components
|
||||
// ---------------------------------------------------------------------------
|
||||
vi.mock(
|
||||
"../components/NewControlPanel/NewBlockMenu/BlockMenuDefault/BlockMenuDefault",
|
||||
() => ({
|
||||
BlockMenuDefault: () => (
|
||||
<div data-testid="block-menu-default">Default Content</div>
|
||||
),
|
||||
}),
|
||||
);
|
||||
|
||||
vi.mock(
|
||||
"../components/NewControlPanel/NewBlockMenu/BlockMenuSearch/BlockMenuSearch",
|
||||
() => ({
|
||||
BlockMenuSearch: () => (
|
||||
<div data-testid="block-menu-search">Search Results</div>
|
||||
),
|
||||
}),
|
||||
);
|
||||
|
||||
// Mock query client used by the search bar hook
|
||||
vi.mock("@/lib/react-query/queryClient", () => ({
|
||||
getQueryClient: () => ({
|
||||
invalidateQueries: vi.fn(),
|
||||
}),
|
||||
}));
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Reset stores before each test
|
||||
// ---------------------------------------------------------------------------
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
useBlockMenuStore.getState().reset();
|
||||
useBlockMenuStore.setState({
|
||||
filters: [],
|
||||
creators: [],
|
||||
creators_list: [],
|
||||
categoryCounts: {
|
||||
blocks: 0,
|
||||
integrations: 0,
|
||||
marketplace_agents: 0,
|
||||
my_agents: 0,
|
||||
},
|
||||
});
|
||||
useControlPanelStore.getState().reset();
|
||||
});
|
||||
|
||||
// ===========================================================================
|
||||
// Section 1: blockMenuStore unit tests
|
||||
// ===========================================================================
|
||||
describe("blockMenuStore", () => {
|
||||
describe("searchQuery", () => {
|
||||
it("defaults to an empty string", () => {
|
||||
expect(useBlockMenuStore.getState().searchQuery).toBe("");
|
||||
});
|
||||
|
||||
it("sets the search query", () => {
|
||||
useBlockMenuStore.getState().setSearchQuery("timer");
|
||||
expect(useBlockMenuStore.getState().searchQuery).toBe("timer");
|
||||
});
|
||||
});
|
||||
|
||||
describe("defaultState", () => {
|
||||
it("defaults to SUGGESTION", () => {
|
||||
expect(useBlockMenuStore.getState().defaultState).toBe(
|
||||
DefaultStateType.SUGGESTION,
|
||||
);
|
||||
});
|
||||
|
||||
it("sets the default state", () => {
|
||||
useBlockMenuStore.getState().setDefaultState(DefaultStateType.ALL_BLOCKS);
|
||||
expect(useBlockMenuStore.getState().defaultState).toBe(
|
||||
DefaultStateType.ALL_BLOCKS,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("filters", () => {
|
||||
it("defaults to an empty array", () => {
|
||||
expect(useBlockMenuStore.getState().filters).toEqual([]);
|
||||
});
|
||||
|
||||
it("adds a filter", () => {
|
||||
useBlockMenuStore.getState().addFilter(SearchEntryFilterAnyOfItem.blocks);
|
||||
expect(useBlockMenuStore.getState().filters).toEqual([
|
||||
SearchEntryFilterAnyOfItem.blocks,
|
||||
]);
|
||||
});
|
||||
|
||||
it("removes a filter", () => {
|
||||
useBlockMenuStore
|
||||
.getState()
|
||||
.setFilters([
|
||||
SearchEntryFilterAnyOfItem.blocks,
|
||||
SearchEntryFilterAnyOfItem.integrations,
|
||||
]);
|
||||
useBlockMenuStore
|
||||
.getState()
|
||||
.removeFilter(SearchEntryFilterAnyOfItem.blocks);
|
||||
expect(useBlockMenuStore.getState().filters).toEqual([
|
||||
SearchEntryFilterAnyOfItem.integrations,
|
||||
]);
|
||||
});
|
||||
|
||||
it("replaces all filters with setFilters", () => {
|
||||
useBlockMenuStore.getState().addFilter(SearchEntryFilterAnyOfItem.blocks);
|
||||
useBlockMenuStore
|
||||
.getState()
|
||||
.setFilters([SearchEntryFilterAnyOfItem.marketplace_agents]);
|
||||
expect(useBlockMenuStore.getState().filters).toEqual([
|
||||
SearchEntryFilterAnyOfItem.marketplace_agents,
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("creators", () => {
|
||||
it("adds a creator", () => {
|
||||
useBlockMenuStore.getState().addCreator("alice");
|
||||
expect(useBlockMenuStore.getState().creators).toEqual(["alice"]);
|
||||
});
|
||||
|
||||
it("removes a creator", () => {
|
||||
useBlockMenuStore.getState().setCreators(["alice", "bob"]);
|
||||
useBlockMenuStore.getState().removeCreator("alice");
|
||||
expect(useBlockMenuStore.getState().creators).toEqual(["bob"]);
|
||||
});
|
||||
|
||||
it("replaces all creators with setCreators", () => {
|
||||
useBlockMenuStore.getState().addCreator("alice");
|
||||
useBlockMenuStore.getState().setCreators(["charlie"]);
|
||||
expect(useBlockMenuStore.getState().creators).toEqual(["charlie"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("categoryCounts", () => {
|
||||
it("sets category counts", () => {
|
||||
const counts = {
|
||||
blocks: 10,
|
||||
integrations: 5,
|
||||
marketplace_agents: 3,
|
||||
my_agents: 2,
|
||||
};
|
||||
useBlockMenuStore.getState().setCategoryCounts(counts);
|
||||
expect(useBlockMenuStore.getState().categoryCounts).toEqual(counts);
|
||||
});
|
||||
});
|
||||
|
||||
describe("searchId", () => {
|
||||
it("defaults to undefined", () => {
|
||||
expect(useBlockMenuStore.getState().searchId).toBeUndefined();
|
||||
});
|
||||
|
||||
it("sets and clears searchId", () => {
|
||||
useBlockMenuStore.getState().setSearchId("search-123");
|
||||
expect(useBlockMenuStore.getState().searchId).toBe("search-123");
|
||||
|
||||
useBlockMenuStore.getState().setSearchId(undefined);
|
||||
expect(useBlockMenuStore.getState().searchId).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("integration", () => {
|
||||
it("defaults to undefined", () => {
|
||||
expect(useBlockMenuStore.getState().integration).toBeUndefined();
|
||||
});
|
||||
|
||||
it("sets the integration", () => {
|
||||
useBlockMenuStore.getState().setIntegration("slack");
|
||||
expect(useBlockMenuStore.getState().integration).toBe("slack");
|
||||
});
|
||||
});
|
||||
|
||||
describe("reset", () => {
|
||||
it("resets searchQuery, searchId, defaultState, and integration", () => {
|
||||
useBlockMenuStore.getState().setSearchQuery("hello");
|
||||
useBlockMenuStore.getState().setSearchId("id-1");
|
||||
useBlockMenuStore.getState().setDefaultState(DefaultStateType.ALL_BLOCKS);
|
||||
useBlockMenuStore.getState().setIntegration("github");
|
||||
|
||||
useBlockMenuStore.getState().reset();
|
||||
|
||||
const state = useBlockMenuStore.getState();
|
||||
expect(state.searchQuery).toBe("");
|
||||
expect(state.searchId).toBeUndefined();
|
||||
expect(state.defaultState).toBe(DefaultStateType.SUGGESTION);
|
||||
expect(state.integration).toBeUndefined();
|
||||
});
|
||||
|
||||
it("does not reset filters or creators (by design)", () => {
|
||||
useBlockMenuStore
|
||||
.getState()
|
||||
.setFilters([SearchEntryFilterAnyOfItem.blocks]);
|
||||
useBlockMenuStore.getState().setCreators(["alice"]);
|
||||
|
||||
useBlockMenuStore.getState().reset();
|
||||
|
||||
expect(useBlockMenuStore.getState().filters).toEqual([
|
||||
SearchEntryFilterAnyOfItem.blocks,
|
||||
]);
|
||||
expect(useBlockMenuStore.getState().creators).toEqual(["alice"]);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ===========================================================================
|
||||
// Section 2: controlPanelStore unit tests
|
||||
// ===========================================================================
|
||||
describe("controlPanelStore", () => {
|
||||
it("defaults blockMenuOpen to false", () => {
|
||||
expect(useControlPanelStore.getState().blockMenuOpen).toBe(false);
|
||||
});
|
||||
|
||||
it("sets blockMenuOpen", () => {
|
||||
useControlPanelStore.getState().setBlockMenuOpen(true);
|
||||
expect(useControlPanelStore.getState().blockMenuOpen).toBe(true);
|
||||
});
|
||||
|
||||
it("sets forceOpenBlockMenu", () => {
|
||||
useControlPanelStore.getState().setForceOpenBlockMenu(true);
|
||||
expect(useControlPanelStore.getState().forceOpenBlockMenu).toBe(true);
|
||||
});
|
||||
|
||||
it("resets all control panel state", () => {
|
||||
useControlPanelStore.getState().setBlockMenuOpen(true);
|
||||
useControlPanelStore.getState().setForceOpenBlockMenu(true);
|
||||
useControlPanelStore.getState().setSaveControlOpen(true);
|
||||
useControlPanelStore.getState().setForceOpenSave(true);
|
||||
|
||||
useControlPanelStore.getState().reset();
|
||||
|
||||
const state = useControlPanelStore.getState();
|
||||
expect(state.blockMenuOpen).toBe(false);
|
||||
expect(state.forceOpenBlockMenu).toBe(false);
|
||||
expect(state.saveControlOpen).toBe(false);
|
||||
expect(state.forceOpenSave).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
// ===========================================================================
|
||||
// Section 3: BlockMenuContent integration tests
|
||||
// ===========================================================================
|
||||
// We import BlockMenuContent directly to avoid dealing with the Popover wrapper.
|
||||
import { BlockMenuContent } from "../components/NewControlPanel/NewBlockMenu/BlockMenuContent/BlockMenuContent";
|
||||
|
||||
describe("BlockMenuContent", () => {
|
||||
it("shows BlockMenuDefault when there is no search query", () => {
|
||||
useBlockMenuStore.getState().setSearchQuery("");
|
||||
|
||||
render(<BlockMenuContent />);
|
||||
|
||||
expect(screen.getByTestId("block-menu-default")).toBeDefined();
|
||||
expect(screen.queryByTestId("block-menu-search")).toBeNull();
|
||||
});
|
||||
|
||||
it("shows BlockMenuSearch when a search query is present", () => {
|
||||
useBlockMenuStore.getState().setSearchQuery("timer");
|
||||
|
||||
render(<BlockMenuContent />);
|
||||
|
||||
expect(screen.getByTestId("block-menu-search")).toBeDefined();
|
||||
expect(screen.queryByTestId("block-menu-default")).toBeNull();
|
||||
});
|
||||
|
||||
it("renders the search bar", () => {
|
||||
render(<BlockMenuContent />);
|
||||
|
||||
expect(
|
||||
screen.getByPlaceholderText(
|
||||
"Blocks, Agents, Integrations or Keywords...",
|
||||
),
|
||||
).toBeDefined();
|
||||
});
|
||||
|
||||
it("switches from default to search view when store query changes", () => {
|
||||
const { rerender } = render(<BlockMenuContent />);
|
||||
expect(screen.getByTestId("block-menu-default")).toBeDefined();
|
||||
|
||||
// Simulate typing by setting the store directly
|
||||
useBlockMenuStore.getState().setSearchQuery("webhook");
|
||||
rerender(<BlockMenuContent />);
|
||||
|
||||
expect(screen.getByTestId("block-menu-search")).toBeDefined();
|
||||
expect(screen.queryByTestId("block-menu-default")).toBeNull();
|
||||
});
|
||||
|
||||
it("switches back to default view when search query is cleared", () => {
|
||||
useBlockMenuStore.getState().setSearchQuery("something");
|
||||
const { rerender } = render(<BlockMenuContent />);
|
||||
expect(screen.getByTestId("block-menu-search")).toBeDefined();
|
||||
|
||||
useBlockMenuStore.getState().setSearchQuery("");
|
||||
rerender(<BlockMenuContent />);
|
||||
|
||||
expect(screen.getByTestId("block-menu-default")).toBeDefined();
|
||||
expect(screen.queryByTestId("block-menu-search")).toBeNull();
|
||||
});
|
||||
|
||||
it("typing in the search bar updates the local input value", async () => {
|
||||
render(<BlockMenuContent />);
|
||||
|
||||
const input = screen.getByPlaceholderText(
|
||||
"Blocks, Agents, Integrations or Keywords...",
|
||||
);
|
||||
fireEvent.change(input, { target: { value: "slack" } });
|
||||
|
||||
expect((input as HTMLInputElement).value).toBe("slack");
|
||||
});
|
||||
|
||||
it("shows clear button when input has text and clears on click", async () => {
|
||||
render(<BlockMenuContent />);
|
||||
|
||||
const input = screen.getByPlaceholderText(
|
||||
"Blocks, Agents, Integrations or Keywords...",
|
||||
);
|
||||
fireEvent.change(input, { target: { value: "test" } });
|
||||
|
||||
// The clear button should appear
|
||||
const clearButton = screen.getByRole("button");
|
||||
fireEvent.click(clearButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect((input as HTMLInputElement).value).toBe("");
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,270 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import {
|
||||
render,
|
||||
screen,
|
||||
fireEvent,
|
||||
waitFor,
|
||||
cleanup,
|
||||
} from "@/tests/integrations/test-utils";
|
||||
import { UseFormReturn, useForm } from "react-hook-form";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import * as z from "zod";
|
||||
import { renderHook } from "@testing-library/react";
|
||||
import { useControlPanelStore } from "../stores/controlPanelStore";
|
||||
import { TooltipProvider } from "@/components/atoms/Tooltip/BaseTooltip";
|
||||
import { NewSaveControl } from "../components/NewControlPanel/NewSaveControl/NewSaveControl";
|
||||
import { useNewSaveControl } from "../components/NewControlPanel/NewSaveControl/useNewSaveControl";
|
||||
|
||||
const formSchema = z.object({
|
||||
name: z.string().min(1, "Name is required").max(100),
|
||||
description: z.string().max(500),
|
||||
});
|
||||
|
||||
type SaveableGraphFormValues = z.infer<typeof formSchema>;
|
||||
|
||||
const mockHandleSave = vi.fn();
|
||||
|
||||
vi.mock(
|
||||
"../components/NewControlPanel/NewSaveControl/useNewSaveControl",
|
||||
() => ({
|
||||
useNewSaveControl: vi.fn(),
|
||||
}),
|
||||
);
|
||||
|
||||
const mockUseNewSaveControl = vi.mocked(useNewSaveControl);
|
||||
|
||||
function createMockForm(
|
||||
defaults: SaveableGraphFormValues = { name: "", description: "" },
|
||||
): UseFormReturn<SaveableGraphFormValues> {
|
||||
const { result } = renderHook(() =>
|
||||
useForm<SaveableGraphFormValues>({
|
||||
resolver: zodResolver(formSchema),
|
||||
defaultValues: defaults,
|
||||
}),
|
||||
);
|
||||
return result.current;
|
||||
}
|
||||
|
||||
function setupMock(overrides: {
|
||||
isSaving?: boolean;
|
||||
graphVersion?: number;
|
||||
name?: string;
|
||||
description?: string;
|
||||
}) {
|
||||
const form = createMockForm({
|
||||
name: overrides.name ?? "",
|
||||
description: overrides.description ?? "",
|
||||
});
|
||||
|
||||
mockUseNewSaveControl.mockReturnValue({
|
||||
form,
|
||||
isSaving: overrides.isSaving ?? false,
|
||||
graphVersion: overrides.graphVersion,
|
||||
handleSave: mockHandleSave,
|
||||
});
|
||||
|
||||
return form;
|
||||
}
|
||||
|
||||
function resetStore() {
|
||||
useControlPanelStore.setState({
|
||||
blockMenuOpen: false,
|
||||
saveControlOpen: false,
|
||||
forceOpenBlockMenu: false,
|
||||
forceOpenSave: false,
|
||||
});
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
cleanup();
|
||||
resetStore();
|
||||
mockHandleSave.mockReset();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
describe("NewSaveControl", () => {
|
||||
it("renders save button trigger", () => {
|
||||
setupMock({});
|
||||
render(
|
||||
<TooltipProvider>
|
||||
<NewSaveControl />
|
||||
</TooltipProvider>,
|
||||
);
|
||||
|
||||
expect(screen.getByTestId("save-control-save-button")).toBeDefined();
|
||||
});
|
||||
|
||||
it("renders name and description inputs when popover is open", () => {
|
||||
useControlPanelStore.setState({ saveControlOpen: true });
|
||||
setupMock({});
|
||||
render(
|
||||
<TooltipProvider>
|
||||
<NewSaveControl />
|
||||
</TooltipProvider>,
|
||||
);
|
||||
|
||||
expect(screen.getByTestId("save-control-name-input")).toBeDefined();
|
||||
expect(screen.getByTestId("save-control-description-input")).toBeDefined();
|
||||
});
|
||||
|
||||
it("does not render popover content when closed", () => {
|
||||
useControlPanelStore.setState({ saveControlOpen: false });
|
||||
setupMock({});
|
||||
render(
|
||||
<TooltipProvider>
|
||||
<NewSaveControl />
|
||||
</TooltipProvider>,
|
||||
);
|
||||
|
||||
expect(screen.queryByTestId("save-control-name-input")).toBeNull();
|
||||
expect(screen.queryByTestId("save-control-description-input")).toBeNull();
|
||||
});
|
||||
|
||||
it("shows version output when graphVersion is set", () => {
|
||||
useControlPanelStore.setState({ saveControlOpen: true });
|
||||
setupMock({ graphVersion: 3 });
|
||||
render(
|
||||
<TooltipProvider>
|
||||
<NewSaveControl />
|
||||
</TooltipProvider>,
|
||||
);
|
||||
|
||||
const versionInput = screen.getByTestId("save-control-version-output");
|
||||
expect(versionInput).toBeDefined();
|
||||
expect((versionInput as HTMLInputElement).disabled).toBe(true);
|
||||
});
|
||||
|
||||
it("hides version output when graphVersion is undefined", () => {
|
||||
useControlPanelStore.setState({ saveControlOpen: true });
|
||||
setupMock({ graphVersion: undefined });
|
||||
render(
|
||||
<TooltipProvider>
|
||||
<NewSaveControl />
|
||||
</TooltipProvider>,
|
||||
);
|
||||
|
||||
expect(screen.queryByTestId("save-control-version-output")).toBeNull();
|
||||
});
|
||||
|
||||
it("enables save button when isSaving is false", () => {
|
||||
useControlPanelStore.setState({ saveControlOpen: true });
|
||||
setupMock({ isSaving: false });
|
||||
render(
|
||||
<TooltipProvider>
|
||||
<NewSaveControl />
|
||||
</TooltipProvider>,
|
||||
);
|
||||
|
||||
const saveButton = screen.getByTestId("save-control-save-agent-button");
|
||||
expect((saveButton as HTMLButtonElement).disabled).toBe(false);
|
||||
});
|
||||
|
||||
it("disables save button when isSaving is true", () => {
|
||||
useControlPanelStore.setState({ saveControlOpen: true });
|
||||
setupMock({ isSaving: true });
|
||||
render(
|
||||
<TooltipProvider>
|
||||
<NewSaveControl />
|
||||
</TooltipProvider>,
|
||||
);
|
||||
|
||||
const saveButton = screen.getByRole("button", { name: /save agent/i });
|
||||
expect((saveButton as HTMLButtonElement).disabled).toBe(true);
|
||||
});
|
||||
|
||||
it("calls handleSave on form submission with valid data", async () => {
|
||||
useControlPanelStore.setState({ saveControlOpen: true });
|
||||
const form = setupMock({ name: "My Agent", description: "A description" });
|
||||
|
||||
form.setValue("name", "My Agent");
|
||||
form.setValue("description", "A description");
|
||||
|
||||
render(
|
||||
<TooltipProvider>
|
||||
<NewSaveControl />
|
||||
</TooltipProvider>,
|
||||
);
|
||||
|
||||
const saveButton = screen.getByTestId("save-control-save-agent-button");
|
||||
fireEvent.click(saveButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockHandleSave).toHaveBeenCalledWith(
|
||||
{ name: "My Agent", description: "A description" },
|
||||
expect.anything(),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it("does not call handleSave when name is empty (validation fails)", async () => {
|
||||
useControlPanelStore.setState({ saveControlOpen: true });
|
||||
setupMock({ name: "", description: "" });
|
||||
render(
|
||||
<TooltipProvider>
|
||||
<NewSaveControl />
|
||||
</TooltipProvider>,
|
||||
);
|
||||
|
||||
const saveButton = screen.getByTestId("save-control-save-agent-button");
|
||||
fireEvent.click(saveButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockHandleSave).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
it("popover stays open when forceOpenSave is true", () => {
|
||||
useControlPanelStore.setState({
|
||||
saveControlOpen: false,
|
||||
forceOpenSave: true,
|
||||
});
|
||||
setupMock({});
|
||||
render(
|
||||
<TooltipProvider>
|
||||
<NewSaveControl />
|
||||
</TooltipProvider>,
|
||||
);
|
||||
|
||||
expect(screen.getByTestId("save-control-name-input")).toBeDefined();
|
||||
});
|
||||
|
||||
it("allows typing in name and description inputs", () => {
|
||||
useControlPanelStore.setState({ saveControlOpen: true });
|
||||
setupMock({});
|
||||
render(
|
||||
<TooltipProvider>
|
||||
<NewSaveControl />
|
||||
</TooltipProvider>,
|
||||
);
|
||||
|
||||
const nameInput = screen.getByTestId(
|
||||
"save-control-name-input",
|
||||
) as HTMLInputElement;
|
||||
const descriptionInput = screen.getByTestId(
|
||||
"save-control-description-input",
|
||||
) as HTMLInputElement;
|
||||
|
||||
fireEvent.change(nameInput, { target: { value: "Test Agent" } });
|
||||
fireEvent.change(descriptionInput, {
|
||||
target: { value: "Test Description" },
|
||||
});
|
||||
|
||||
expect(nameInput.value).toBe("Test Agent");
|
||||
expect(descriptionInput.value).toBe("Test Description");
|
||||
});
|
||||
|
||||
it("displays save button text", () => {
|
||||
useControlPanelStore.setState({ saveControlOpen: true });
|
||||
setupMock({});
|
||||
render(
|
||||
<TooltipProvider>
|
||||
<NewSaveControl />
|
||||
</TooltipProvider>,
|
||||
);
|
||||
|
||||
expect(screen.getByText("Save Agent")).toBeDefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,147 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { screen, fireEvent, cleanup } from "@testing-library/react";
|
||||
import { render } from "@/tests/integrations/test-utils";
|
||||
import React from "react";
|
||||
import { useGraphStore } from "../stores/graphStore";
|
||||
|
||||
vi.mock(
|
||||
"@/app/(platform)/build/components/BuilderActions/components/RunGraph/useRunGraph",
|
||||
() => ({
|
||||
useRunGraph: vi.fn(),
|
||||
}),
|
||||
);
|
||||
|
||||
vi.mock(
|
||||
"@/app/(platform)/build/components/BuilderActions/components/RunInputDialog/RunInputDialog",
|
||||
() => ({
|
||||
RunInputDialog: ({ isOpen }: { isOpen: boolean }) =>
|
||||
isOpen ? <div data-testid="run-input-dialog">Dialog</div> : null,
|
||||
}),
|
||||
);
|
||||
|
||||
// Must import after mocks
|
||||
import { useRunGraph } from "../components/BuilderActions/components/RunGraph/useRunGraph";
|
||||
import { RunGraph } from "../components/BuilderActions/components/RunGraph/RunGraph";
|
||||
|
||||
const mockUseRunGraph = vi.mocked(useRunGraph);
|
||||
|
||||
function createMockReturnValue(
|
||||
overrides: Partial<ReturnType<typeof useRunGraph>> = {},
|
||||
) {
|
||||
return {
|
||||
handleRunGraph: vi.fn(),
|
||||
handleStopGraph: vi.fn(),
|
||||
openRunInputDialog: false,
|
||||
setOpenRunInputDialog: vi.fn(),
|
||||
isExecutingGraph: false,
|
||||
isTerminatingGraph: false,
|
||||
isSaving: false,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
// RunGraph uses Tooltip which requires TooltipProvider
|
||||
import { TooltipProvider } from "@/components/atoms/Tooltip/BaseTooltip";
|
||||
|
||||
function renderRunGraph(flowID: string | null = "test-flow-id") {
|
||||
return render(
|
||||
<TooltipProvider>
|
||||
<RunGraph flowID={flowID} />
|
||||
</TooltipProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
describe("RunGraph", () => {
|
||||
beforeEach(() => {
|
||||
cleanup();
|
||||
mockUseRunGraph.mockReturnValue(createMockReturnValue());
|
||||
useGraphStore.setState({ isGraphRunning: false });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
it("renders an enabled button when flowID is provided", () => {
|
||||
renderRunGraph("test-flow-id");
|
||||
const button = screen.getByRole("button");
|
||||
expect((button as HTMLButtonElement).disabled).toBe(false);
|
||||
});
|
||||
|
||||
it("renders a disabled button when flowID is null", () => {
|
||||
renderRunGraph(null);
|
||||
const button = screen.getByRole("button");
|
||||
expect((button as HTMLButtonElement).disabled).toBe(true);
|
||||
});
|
||||
|
||||
it("disables the button when isExecutingGraph is true", () => {
|
||||
mockUseRunGraph.mockReturnValue(
|
||||
createMockReturnValue({ isExecutingGraph: true }),
|
||||
);
|
||||
renderRunGraph();
|
||||
expect((screen.getByRole("button") as HTMLButtonElement).disabled).toBe(
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
it("disables the button when isTerminatingGraph is true", () => {
|
||||
mockUseRunGraph.mockReturnValue(
|
||||
createMockReturnValue({ isTerminatingGraph: true }),
|
||||
);
|
||||
renderRunGraph();
|
||||
expect((screen.getByRole("button") as HTMLButtonElement).disabled).toBe(
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
it("disables the button when isSaving is true", () => {
|
||||
mockUseRunGraph.mockReturnValue(createMockReturnValue({ isSaving: true }));
|
||||
renderRunGraph();
|
||||
expect((screen.getByRole("button") as HTMLButtonElement).disabled).toBe(
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
it("uses data-id run-graph-button when not running", () => {
|
||||
renderRunGraph();
|
||||
const button = screen.getByRole("button");
|
||||
expect(button.getAttribute("data-id")).toBe("run-graph-button");
|
||||
});
|
||||
|
||||
it("uses data-id stop-graph-button when running", () => {
|
||||
useGraphStore.setState({ isGraphRunning: true });
|
||||
renderRunGraph();
|
||||
const button = screen.getByRole("button");
|
||||
expect(button.getAttribute("data-id")).toBe("stop-graph-button");
|
||||
});
|
||||
|
||||
it("calls handleRunGraph when clicked and graph is not running", () => {
|
||||
const handleRunGraph = vi.fn();
|
||||
mockUseRunGraph.mockReturnValue(createMockReturnValue({ handleRunGraph }));
|
||||
renderRunGraph();
|
||||
fireEvent.click(screen.getByRole("button"));
|
||||
expect(handleRunGraph).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("calls handleStopGraph when clicked and graph is running", () => {
|
||||
const handleStopGraph = vi.fn();
|
||||
mockUseRunGraph.mockReturnValue(createMockReturnValue({ handleStopGraph }));
|
||||
useGraphStore.setState({ isGraphRunning: true });
|
||||
renderRunGraph();
|
||||
fireEvent.click(screen.getByRole("button"));
|
||||
expect(handleStopGraph).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("renders RunInputDialog hidden by default", () => {
|
||||
renderRunGraph();
|
||||
expect(screen.queryByTestId("run-input-dialog")).toBeNull();
|
||||
});
|
||||
|
||||
it("renders RunInputDialog when openRunInputDialog is true", () => {
|
||||
mockUseRunGraph.mockReturnValue(
|
||||
createMockReturnValue({ openRunInputDialog: true }),
|
||||
);
|
||||
renderRunGraph();
|
||||
expect(screen.getByTestId("run-input-dialog")).toBeDefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,257 @@
|
||||
import { describe, it, expect, beforeEach, vi } from "vitest";
|
||||
import { CustomNode } from "../components/FlowEditor/nodes/CustomNode/CustomNode";
|
||||
import { BlockUIType } from "../components/types";
|
||||
|
||||
vi.mock("@/services/storage/local-storage", () => {
|
||||
const store: Record<string, string> = {};
|
||||
return {
|
||||
Key: { COPIED_FLOW_DATA: "COPIED_FLOW_DATA" },
|
||||
storage: {
|
||||
get: (key: string) => store[key] ?? null,
|
||||
set: (key: string, value: string) => {
|
||||
store[key] = value;
|
||||
},
|
||||
clean: (key: string) => {
|
||||
delete store[key];
|
||||
},
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
import { useCopyPasteStore } from "../stores/copyPasteStore";
|
||||
import { useNodeStore } from "../stores/nodeStore";
|
||||
import { useEdgeStore } from "../stores/edgeStore";
|
||||
import { useHistoryStore } from "../stores/historyStore";
|
||||
import { storage, Key } from "@/services/storage/local-storage";
|
||||
|
||||
function createTestNode(
|
||||
id: string,
|
||||
overrides: Partial<CustomNode> = {},
|
||||
): CustomNode {
|
||||
return {
|
||||
id,
|
||||
type: "custom",
|
||||
position: overrides.position ?? { x: 100, y: 200 },
|
||||
selected: overrides.selected,
|
||||
data: {
|
||||
hardcodedValues: {},
|
||||
title: `Node ${id}`,
|
||||
description: "test node",
|
||||
inputSchema: {},
|
||||
outputSchema: {},
|
||||
uiType: BlockUIType.STANDARD,
|
||||
block_id: `block-${id}`,
|
||||
costs: [],
|
||||
categories: [],
|
||||
...overrides.data,
|
||||
},
|
||||
} as CustomNode;
|
||||
}
|
||||
|
||||
describe("useCopyPasteStore", () => {
|
||||
beforeEach(() => {
|
||||
useNodeStore.setState({ nodes: [], nodeCounter: 0 });
|
||||
useEdgeStore.setState({ edges: [] });
|
||||
useHistoryStore.getState().clear();
|
||||
storage.clean(Key.COPIED_FLOW_DATA);
|
||||
});
|
||||
|
||||
describe("copySelectedNodes", () => {
|
||||
it("copies a single selected node to localStorage", () => {
|
||||
const node = createTestNode("1", { selected: true });
|
||||
useNodeStore.setState({ nodes: [node] });
|
||||
|
||||
useCopyPasteStore.getState().copySelectedNodes();
|
||||
|
||||
const stored = storage.get(Key.COPIED_FLOW_DATA);
|
||||
expect(stored).not.toBeNull();
|
||||
|
||||
const parsed = JSON.parse(stored!);
|
||||
expect(parsed.nodes).toHaveLength(1);
|
||||
expect(parsed.nodes[0].id).toBe("1");
|
||||
expect(parsed.edges).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("copies only edges between selected nodes", () => {
|
||||
const nodeA = createTestNode("a", { selected: true });
|
||||
const nodeB = createTestNode("b", { selected: true });
|
||||
const nodeC = createTestNode("c", { selected: false });
|
||||
useNodeStore.setState({ nodes: [nodeA, nodeB, nodeC] });
|
||||
|
||||
useEdgeStore.setState({
|
||||
edges: [
|
||||
{
|
||||
id: "e-ab",
|
||||
source: "a",
|
||||
target: "b",
|
||||
sourceHandle: "out",
|
||||
targetHandle: "in",
|
||||
},
|
||||
{
|
||||
id: "e-bc",
|
||||
source: "b",
|
||||
target: "c",
|
||||
sourceHandle: "out",
|
||||
targetHandle: "in",
|
||||
},
|
||||
{
|
||||
id: "e-ac",
|
||||
source: "a",
|
||||
target: "c",
|
||||
sourceHandle: "out",
|
||||
targetHandle: "in",
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
useCopyPasteStore.getState().copySelectedNodes();
|
||||
|
||||
const parsed = JSON.parse(storage.get(Key.COPIED_FLOW_DATA)!);
|
||||
expect(parsed.nodes).toHaveLength(2);
|
||||
expect(parsed.edges).toHaveLength(1);
|
||||
expect(parsed.edges[0].id).toBe("e-ab");
|
||||
});
|
||||
|
||||
it("stores empty data when no nodes are selected", () => {
|
||||
const node = createTestNode("1", { selected: false });
|
||||
useNodeStore.setState({ nodes: [node] });
|
||||
|
||||
useCopyPasteStore.getState().copySelectedNodes();
|
||||
|
||||
const parsed = JSON.parse(storage.get(Key.COPIED_FLOW_DATA)!);
|
||||
expect(parsed.nodes).toHaveLength(0);
|
||||
expect(parsed.edges).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("pasteNodes", () => {
|
||||
it("creates new nodes with new IDs via incrementNodeCounter", () => {
|
||||
const node = createTestNode("orig", {
|
||||
selected: true,
|
||||
position: { x: 100, y: 200 },
|
||||
});
|
||||
useNodeStore.setState({ nodes: [node], nodeCounter: 5 });
|
||||
|
||||
useCopyPasteStore.getState().copySelectedNodes();
|
||||
useCopyPasteStore.getState().pasteNodes();
|
||||
|
||||
const { nodes } = useNodeStore.getState();
|
||||
expect(nodes).toHaveLength(2);
|
||||
|
||||
const pastedNode = nodes.find((n) => n.id !== "orig");
|
||||
expect(pastedNode).toBeDefined();
|
||||
expect(pastedNode!.id).not.toBe("orig");
|
||||
});
|
||||
|
||||
it("offsets pasted node positions by +50 x/y", () => {
|
||||
const node = createTestNode("orig", {
|
||||
selected: true,
|
||||
position: { x: 100, y: 200 },
|
||||
});
|
||||
useNodeStore.setState({ nodes: [node], nodeCounter: 5 });
|
||||
|
||||
useCopyPasteStore.getState().copySelectedNodes();
|
||||
useCopyPasteStore.getState().pasteNodes();
|
||||
|
||||
const { nodes } = useNodeStore.getState();
|
||||
const pastedNode = nodes.find((n) => n.id !== "orig");
|
||||
expect(pastedNode).toBeDefined();
|
||||
expect(pastedNode!.position).toEqual({ x: 150, y: 250 });
|
||||
});
|
||||
|
||||
it("preserves internal connections with remapped IDs", () => {
|
||||
const nodeA = createTestNode("a", {
|
||||
selected: true,
|
||||
position: { x: 0, y: 0 },
|
||||
});
|
||||
const nodeB = createTestNode("b", {
|
||||
selected: true,
|
||||
position: { x: 200, y: 0 },
|
||||
});
|
||||
useNodeStore.setState({ nodes: [nodeA, nodeB], nodeCounter: 0 });
|
||||
useEdgeStore.setState({
|
||||
edges: [
|
||||
{
|
||||
id: "e-ab",
|
||||
source: "a",
|
||||
target: "b",
|
||||
sourceHandle: "output",
|
||||
targetHandle: "input",
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
useCopyPasteStore.getState().copySelectedNodes();
|
||||
useCopyPasteStore.getState().pasteNodes();
|
||||
|
||||
const { edges } = useEdgeStore.getState();
|
||||
const newEdges = edges.filter((e) => e.id !== "e-ab");
|
||||
expect(newEdges).toHaveLength(1);
|
||||
|
||||
const newEdge = newEdges[0];
|
||||
expect(newEdge.source).not.toBe("a");
|
||||
expect(newEdge.target).not.toBe("b");
|
||||
|
||||
const { nodes } = useNodeStore.getState();
|
||||
const pastedNodeIDs = nodes
|
||||
.filter((n) => n.id !== "a" && n.id !== "b")
|
||||
.map((n) => n.id);
|
||||
|
||||
expect(pastedNodeIDs).toContain(newEdge.source);
|
||||
expect(pastedNodeIDs).toContain(newEdge.target);
|
||||
});
|
||||
|
||||
it("deselects existing nodes and selects pasted ones", () => {
|
||||
const existingNode = createTestNode("existing", {
|
||||
selected: true,
|
||||
position: { x: 0, y: 0 },
|
||||
});
|
||||
const nodeToCopy = createTestNode("copy-me", {
|
||||
selected: true,
|
||||
position: { x: 100, y: 100 },
|
||||
});
|
||||
useNodeStore.setState({
|
||||
nodes: [existingNode, nodeToCopy],
|
||||
nodeCounter: 0,
|
||||
});
|
||||
|
||||
useCopyPasteStore.getState().copySelectedNodes();
|
||||
|
||||
// Deselect nodeToCopy, keep existingNode selected to verify deselection on paste
|
||||
useNodeStore.setState({
|
||||
nodes: [
|
||||
{ ...existingNode, selected: true },
|
||||
{ ...nodeToCopy, selected: false },
|
||||
],
|
||||
});
|
||||
|
||||
useCopyPasteStore.getState().pasteNodes();
|
||||
|
||||
const { nodes } = useNodeStore.getState();
|
||||
const originalNodes = nodes.filter(
|
||||
(n) => n.id === "existing" || n.id === "copy-me",
|
||||
);
|
||||
const pastedNodes = nodes.filter(
|
||||
(n) => n.id !== "existing" && n.id !== "copy-me",
|
||||
);
|
||||
|
||||
originalNodes.forEach((n) => {
|
||||
expect(n.selected).toBe(false);
|
||||
});
|
||||
pastedNodes.forEach((n) => {
|
||||
expect(n.selected).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
it("does nothing when clipboard is empty", () => {
|
||||
const node = createTestNode("1", { position: { x: 0, y: 0 } });
|
||||
useNodeStore.setState({ nodes: [node], nodeCounter: 0 });
|
||||
|
||||
useCopyPasteStore.getState().pasteNodes();
|
||||
|
||||
const { nodes } = useNodeStore.getState();
|
||||
expect(nodes).toHaveLength(1);
|
||||
expect(nodes[0].id).toBe("1");
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,751 @@
|
||||
import { describe, it, expect, beforeEach, vi } from "vitest";
|
||||
import { MarkerType } from "@xyflow/react";
|
||||
import { useEdgeStore } from "../stores/edgeStore";
|
||||
import { useNodeStore } from "../stores/nodeStore";
|
||||
import { useHistoryStore } from "../stores/historyStore";
|
||||
import type { CustomEdge } from "../components/FlowEditor/edges/CustomEdge";
|
||||
import type { NodeExecutionResult } from "@/app/api/__generated__/models/nodeExecutionResult";
|
||||
import type { Link } from "@/app/api/__generated__/models/link";
|
||||
|
||||
function makeEdge(overrides: Partial<CustomEdge> & { id: string }): CustomEdge {
|
||||
return {
|
||||
type: "custom",
|
||||
source: "node-a",
|
||||
target: "node-b",
|
||||
sourceHandle: "output",
|
||||
targetHandle: "input",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function makeExecutionResult(
|
||||
overrides: Partial<NodeExecutionResult>,
|
||||
): NodeExecutionResult {
|
||||
return {
|
||||
user_id: "user-1",
|
||||
graph_id: "graph-1",
|
||||
graph_version: 1,
|
||||
graph_exec_id: "gexec-1",
|
||||
node_exec_id: "nexec-1",
|
||||
node_id: "node-1",
|
||||
block_id: "block-1",
|
||||
status: "INCOMPLETE",
|
||||
input_data: {},
|
||||
output_data: {},
|
||||
add_time: new Date(),
|
||||
queue_time: null,
|
||||
start_time: null,
|
||||
end_time: null,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
useEdgeStore.setState({ edges: [] });
|
||||
useNodeStore.setState({ nodes: [] });
|
||||
useHistoryStore.setState({ past: [], future: [] });
|
||||
});
|
||||
|
||||
describe("edgeStore", () => {
|
||||
describe("setEdges", () => {
|
||||
it("replaces all edges", () => {
|
||||
const edges = [
|
||||
makeEdge({ id: "e1" }),
|
||||
makeEdge({ id: "e2", source: "node-c" }),
|
||||
];
|
||||
|
||||
useEdgeStore.getState().setEdges(edges);
|
||||
|
||||
expect(useEdgeStore.getState().edges).toHaveLength(2);
|
||||
expect(useEdgeStore.getState().edges[0].id).toBe("e1");
|
||||
expect(useEdgeStore.getState().edges[1].id).toBe("e2");
|
||||
});
|
||||
});
|
||||
|
||||
describe("addEdge", () => {
|
||||
it("adds an edge and auto-generates an ID", () => {
|
||||
const result = useEdgeStore.getState().addEdge({
|
||||
source: "n1",
|
||||
target: "n2",
|
||||
sourceHandle: "out",
|
||||
targetHandle: "in",
|
||||
});
|
||||
|
||||
expect(result.id).toBe("n1:out->n2:in");
|
||||
expect(useEdgeStore.getState().edges).toHaveLength(1);
|
||||
expect(useEdgeStore.getState().edges[0].id).toBe("n1:out->n2:in");
|
||||
});
|
||||
|
||||
it("uses provided ID when given", () => {
|
||||
const result = useEdgeStore.getState().addEdge({
|
||||
id: "custom-id",
|
||||
source: "n1",
|
||||
target: "n2",
|
||||
sourceHandle: "out",
|
||||
targetHandle: "in",
|
||||
});
|
||||
|
||||
expect(result.id).toBe("custom-id");
|
||||
});
|
||||
|
||||
it("sets type to custom and adds arrow marker", () => {
|
||||
const result = useEdgeStore.getState().addEdge({
|
||||
source: "n1",
|
||||
target: "n2",
|
||||
sourceHandle: "out",
|
||||
targetHandle: "in",
|
||||
});
|
||||
|
||||
expect(result.type).toBe("custom");
|
||||
expect(result.markerEnd).toEqual({
|
||||
type: MarkerType.ArrowClosed,
|
||||
strokeWidth: 2,
|
||||
color: "#555",
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects duplicate edges without adding", () => {
|
||||
useEdgeStore.getState().addEdge({
|
||||
source: "n1",
|
||||
target: "n2",
|
||||
sourceHandle: "out",
|
||||
targetHandle: "in",
|
||||
});
|
||||
|
||||
const pushSpy = vi.spyOn(useHistoryStore.getState(), "pushState");
|
||||
|
||||
const duplicate = useEdgeStore.getState().addEdge({
|
||||
source: "n1",
|
||||
target: "n2",
|
||||
sourceHandle: "out",
|
||||
targetHandle: "in",
|
||||
});
|
||||
|
||||
expect(useEdgeStore.getState().edges).toHaveLength(1);
|
||||
expect(duplicate.id).toBe("n1:out->n2:in");
|
||||
expect(pushSpy).not.toHaveBeenCalled();
|
||||
|
||||
pushSpy.mockRestore();
|
||||
});
|
||||
|
||||
it("pushes previous state to history store", () => {
|
||||
const pushSpy = vi.spyOn(useHistoryStore.getState(), "pushState");
|
||||
|
||||
useEdgeStore.getState().addEdge({
|
||||
source: "n1",
|
||||
target: "n2",
|
||||
sourceHandle: "out",
|
||||
targetHandle: "in",
|
||||
});
|
||||
|
||||
expect(pushSpy).toHaveBeenCalledWith({
|
||||
nodes: [],
|
||||
edges: [],
|
||||
});
|
||||
|
||||
pushSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
describe("removeEdge", () => {
|
||||
it("removes an edge by ID", () => {
|
||||
useEdgeStore.setState({
|
||||
edges: [makeEdge({ id: "e1" }), makeEdge({ id: "e2" })],
|
||||
});
|
||||
|
||||
useEdgeStore.getState().removeEdge("e1");
|
||||
|
||||
expect(useEdgeStore.getState().edges).toHaveLength(1);
|
||||
expect(useEdgeStore.getState().edges[0].id).toBe("e2");
|
||||
});
|
||||
|
||||
it("does nothing when removing a non-existent edge", () => {
|
||||
useEdgeStore.setState({ edges: [makeEdge({ id: "e1" })] });
|
||||
|
||||
useEdgeStore.getState().removeEdge("nonexistent");
|
||||
|
||||
expect(useEdgeStore.getState().edges).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("pushes previous state to history store", () => {
|
||||
const existingEdges = [makeEdge({ id: "e1" })];
|
||||
useEdgeStore.setState({ edges: existingEdges });
|
||||
|
||||
const pushSpy = vi.spyOn(useHistoryStore.getState(), "pushState");
|
||||
|
||||
useEdgeStore.getState().removeEdge("e1");
|
||||
|
||||
expect(pushSpy).toHaveBeenCalledWith({
|
||||
nodes: [],
|
||||
edges: existingEdges,
|
||||
});
|
||||
|
||||
pushSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
describe("upsertMany", () => {
|
||||
it("inserts new edges", () => {
|
||||
useEdgeStore.setState({ edges: [makeEdge({ id: "e1" })] });
|
||||
|
||||
useEdgeStore.getState().upsertMany([makeEdge({ id: "e2" })]);
|
||||
|
||||
expect(useEdgeStore.getState().edges).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("updates existing edges by ID", () => {
|
||||
useEdgeStore.setState({
|
||||
edges: [makeEdge({ id: "e1", source: "old-source" })],
|
||||
});
|
||||
|
||||
useEdgeStore
|
||||
.getState()
|
||||
.upsertMany([makeEdge({ id: "e1", source: "new-source" })]);
|
||||
|
||||
expect(useEdgeStore.getState().edges).toHaveLength(1);
|
||||
expect(useEdgeStore.getState().edges[0].source).toBe("new-source");
|
||||
});
|
||||
|
||||
it("handles mixed inserts and updates", () => {
|
||||
useEdgeStore.setState({
|
||||
edges: [makeEdge({ id: "e1", source: "old" })],
|
||||
});
|
||||
|
||||
useEdgeStore
|
||||
.getState()
|
||||
.upsertMany([
|
||||
makeEdge({ id: "e1", source: "updated" }),
|
||||
makeEdge({ id: "e2", source: "new" }),
|
||||
]);
|
||||
|
||||
const edges = useEdgeStore.getState().edges;
|
||||
expect(edges).toHaveLength(2);
|
||||
expect(edges.find((e) => e.id === "e1")?.source).toBe("updated");
|
||||
expect(edges.find((e) => e.id === "e2")?.source).toBe("new");
|
||||
});
|
||||
});
|
||||
|
||||
describe("removeEdgesByHandlePrefix", () => {
|
||||
it("removes edges targeting a node with matching handle prefix", () => {
|
||||
useEdgeStore.setState({
|
||||
edges: [
|
||||
makeEdge({ id: "e1", target: "node-b", targetHandle: "input_foo" }),
|
||||
makeEdge({ id: "e2", target: "node-b", targetHandle: "input_bar" }),
|
||||
makeEdge({
|
||||
id: "e3",
|
||||
target: "node-b",
|
||||
targetHandle: "other_handle",
|
||||
}),
|
||||
makeEdge({ id: "e4", target: "node-c", targetHandle: "input_foo" }),
|
||||
],
|
||||
});
|
||||
|
||||
useEdgeStore.getState().removeEdgesByHandlePrefix("node-b", "input_");
|
||||
|
||||
const edges = useEdgeStore.getState().edges;
|
||||
expect(edges).toHaveLength(2);
|
||||
expect(edges.map((e) => e.id).sort()).toEqual(["e3", "e4"]);
|
||||
});
|
||||
|
||||
it("does not remove edges where target does not match nodeId", () => {
|
||||
useEdgeStore.setState({
|
||||
edges: [
|
||||
makeEdge({
|
||||
id: "e1",
|
||||
source: "node-b",
|
||||
target: "node-c",
|
||||
targetHandle: "input_x",
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
useEdgeStore.getState().removeEdgesByHandlePrefix("node-b", "input_");
|
||||
|
||||
expect(useEdgeStore.getState().edges).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getNodeEdges", () => {
|
||||
it("returns edges where node is source", () => {
|
||||
useEdgeStore.setState({
|
||||
edges: [
|
||||
makeEdge({ id: "e1", source: "node-a", target: "node-b" }),
|
||||
makeEdge({ id: "e2", source: "node-c", target: "node-d" }),
|
||||
],
|
||||
});
|
||||
|
||||
const result = useEdgeStore.getState().getNodeEdges("node-a");
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].id).toBe("e1");
|
||||
});
|
||||
|
||||
it("returns edges where node is target", () => {
|
||||
useEdgeStore.setState({
|
||||
edges: [
|
||||
makeEdge({ id: "e1", source: "node-a", target: "node-b" }),
|
||||
makeEdge({ id: "e2", source: "node-c", target: "node-d" }),
|
||||
],
|
||||
});
|
||||
|
||||
const result = useEdgeStore.getState().getNodeEdges("node-b");
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].id).toBe("e1");
|
||||
});
|
||||
|
||||
it("returns edges for both source and target", () => {
|
||||
useEdgeStore.setState({
|
||||
edges: [
|
||||
makeEdge({ id: "e1", source: "node-a", target: "node-b" }),
|
||||
makeEdge({ id: "e2", source: "node-b", target: "node-c" }),
|
||||
makeEdge({ id: "e3", source: "node-d", target: "node-e" }),
|
||||
],
|
||||
});
|
||||
|
||||
const result = useEdgeStore.getState().getNodeEdges("node-b");
|
||||
expect(result).toHaveLength(2);
|
||||
expect(result.map((e) => e.id).sort()).toEqual(["e1", "e2"]);
|
||||
});
|
||||
|
||||
it("returns empty array for unconnected node", () => {
|
||||
useEdgeStore.setState({
|
||||
edges: [makeEdge({ id: "e1", source: "node-a", target: "node-b" })],
|
||||
});
|
||||
|
||||
expect(useEdgeStore.getState().getNodeEdges("node-z")).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("isInputConnected", () => {
|
||||
it("returns true when target handle is connected", () => {
|
||||
useEdgeStore.setState({
|
||||
edges: [
|
||||
makeEdge({
|
||||
id: "e1",
|
||||
target: "node-b",
|
||||
targetHandle: "input",
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
expect(useEdgeStore.getState().isInputConnected("node-b", "input")).toBe(
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
it("returns false when target handle is not connected", () => {
|
||||
useEdgeStore.setState({
|
||||
edges: [
|
||||
makeEdge({
|
||||
id: "e1",
|
||||
target: "node-b",
|
||||
targetHandle: "input",
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
expect(useEdgeStore.getState().isInputConnected("node-b", "other")).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
it("returns false when node is source not target", () => {
|
||||
useEdgeStore.setState({
|
||||
edges: [
|
||||
makeEdge({
|
||||
id: "e1",
|
||||
source: "node-b",
|
||||
target: "node-c",
|
||||
sourceHandle: "output",
|
||||
targetHandle: "input",
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
expect(useEdgeStore.getState().isInputConnected("node-b", "output")).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("isOutputConnected", () => {
|
||||
it("returns true when source handle is connected", () => {
|
||||
useEdgeStore.setState({
|
||||
edges: [
|
||||
makeEdge({
|
||||
id: "e1",
|
||||
source: "node-a",
|
||||
sourceHandle: "output",
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
expect(
|
||||
useEdgeStore.getState().isOutputConnected("node-a", "output"),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("returns false when source handle is not connected", () => {
|
||||
useEdgeStore.setState({
|
||||
edges: [
|
||||
makeEdge({
|
||||
id: "e1",
|
||||
source: "node-a",
|
||||
sourceHandle: "output",
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
expect(useEdgeStore.getState().isOutputConnected("node-a", "other")).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getBackendLinks", () => {
|
||||
it("converts edges to Link format", () => {
|
||||
useEdgeStore.setState({
|
||||
edges: [
|
||||
makeEdge({
|
||||
id: "e1",
|
||||
source: "n1",
|
||||
target: "n2",
|
||||
sourceHandle: "out",
|
||||
targetHandle: "in",
|
||||
data: { isStatic: true },
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
const links = useEdgeStore.getState().getBackendLinks();
|
||||
|
||||
expect(links).toHaveLength(1);
|
||||
expect(links[0]).toEqual({
|
||||
id: "e1",
|
||||
source_id: "n1",
|
||||
sink_id: "n2",
|
||||
source_name: "out",
|
||||
sink_name: "in",
|
||||
is_static: true,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("addLinks", () => {
|
||||
it("converts Links to edges and adds them", () => {
|
||||
const links: Link[] = [
|
||||
{
|
||||
id: "link-1",
|
||||
source_id: "n1",
|
||||
sink_id: "n2",
|
||||
source_name: "out",
|
||||
sink_name: "in",
|
||||
is_static: false,
|
||||
},
|
||||
];
|
||||
|
||||
useEdgeStore.getState().addLinks(links);
|
||||
|
||||
const edges = useEdgeStore.getState().edges;
|
||||
expect(edges).toHaveLength(1);
|
||||
expect(edges[0].source).toBe("n1");
|
||||
expect(edges[0].target).toBe("n2");
|
||||
expect(edges[0].sourceHandle).toBe("out");
|
||||
expect(edges[0].targetHandle).toBe("in");
|
||||
expect(edges[0].data?.isStatic).toBe(false);
|
||||
});
|
||||
|
||||
it("adds multiple links", () => {
|
||||
const links: Link[] = [
|
||||
{
|
||||
id: "link-1",
|
||||
source_id: "n1",
|
||||
sink_id: "n2",
|
||||
source_name: "out",
|
||||
sink_name: "in",
|
||||
},
|
||||
{
|
||||
id: "link-2",
|
||||
source_id: "n3",
|
||||
sink_id: "n4",
|
||||
source_name: "result",
|
||||
sink_name: "value",
|
||||
},
|
||||
];
|
||||
|
||||
useEdgeStore.getState().addLinks(links);
|
||||
|
||||
expect(useEdgeStore.getState().edges).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getAllHandleIdsOfANode", () => {
|
||||
it("returns targetHandle values for edges targeting the node", () => {
|
||||
useEdgeStore.setState({
|
||||
edges: [
|
||||
makeEdge({ id: "e1", target: "node-b", targetHandle: "input_a" }),
|
||||
makeEdge({ id: "e2", target: "node-b", targetHandle: "input_b" }),
|
||||
makeEdge({ id: "e3", target: "node-c", targetHandle: "input_c" }),
|
||||
],
|
||||
});
|
||||
|
||||
const handles = useEdgeStore.getState().getAllHandleIdsOfANode("node-b");
|
||||
expect(handles).toEqual(["input_a", "input_b"]);
|
||||
});
|
||||
|
||||
it("returns empty array when no edges target the node", () => {
|
||||
useEdgeStore.setState({
|
||||
edges: [makeEdge({ id: "e1", source: "node-b", target: "node-c" })],
|
||||
});
|
||||
|
||||
expect(useEdgeStore.getState().getAllHandleIdsOfANode("node-b")).toEqual(
|
||||
[],
|
||||
);
|
||||
});
|
||||
|
||||
it("returns empty string for edges with no targetHandle", () => {
|
||||
useEdgeStore.setState({
|
||||
edges: [
|
||||
makeEdge({
|
||||
id: "e1",
|
||||
target: "node-b",
|
||||
targetHandle: undefined,
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
expect(useEdgeStore.getState().getAllHandleIdsOfANode("node-b")).toEqual([
|
||||
"",
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("updateEdgeBeads", () => {
|
||||
it("updates bead counts for edges targeting the node", () => {
|
||||
useEdgeStore.setState({
|
||||
edges: [
|
||||
makeEdge({
|
||||
id: "e1",
|
||||
target: "node-b",
|
||||
targetHandle: "input",
|
||||
data: { beadUp: 0, beadDown: 0, beadData: new Map() },
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
useEdgeStore.getState().updateEdgeBeads(
|
||||
"node-b",
|
||||
makeExecutionResult({
|
||||
node_exec_id: "exec-1",
|
||||
status: "COMPLETED",
|
||||
input_data: { input: "some-value" },
|
||||
}),
|
||||
);
|
||||
|
||||
const edge = useEdgeStore.getState().edges[0];
|
||||
expect(edge.data?.beadUp).toBe(1);
|
||||
expect(edge.data?.beadDown).toBe(1);
|
||||
});
|
||||
|
||||
it("counts INCOMPLETE status in beadUp but not beadDown", () => {
|
||||
useEdgeStore.setState({
|
||||
edges: [
|
||||
makeEdge({
|
||||
id: "e1",
|
||||
target: "node-b",
|
||||
targetHandle: "input",
|
||||
data: { beadUp: 0, beadDown: 0, beadData: new Map() },
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
useEdgeStore.getState().updateEdgeBeads(
|
||||
"node-b",
|
||||
makeExecutionResult({
|
||||
node_exec_id: "exec-1",
|
||||
status: "INCOMPLETE",
|
||||
input_data: { input: "data" },
|
||||
}),
|
||||
);
|
||||
|
||||
const edge = useEdgeStore.getState().edges[0];
|
||||
expect(edge.data?.beadUp).toBe(1);
|
||||
expect(edge.data?.beadDown).toBe(0);
|
||||
});
|
||||
|
||||
it("does not modify edges not targeting the node", () => {
|
||||
useEdgeStore.setState({
|
||||
edges: [
|
||||
makeEdge({
|
||||
id: "e1",
|
||||
target: "node-c",
|
||||
targetHandle: "input",
|
||||
data: { beadUp: 0, beadDown: 0, beadData: new Map() },
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
useEdgeStore.getState().updateEdgeBeads(
|
||||
"node-b",
|
||||
makeExecutionResult({
|
||||
node_exec_id: "exec-1",
|
||||
status: "COMPLETED",
|
||||
input_data: { input: "data" },
|
||||
}),
|
||||
);
|
||||
|
||||
const edge = useEdgeStore.getState().edges[0];
|
||||
expect(edge.data?.beadUp).toBe(0);
|
||||
expect(edge.data?.beadDown).toBe(0);
|
||||
});
|
||||
|
||||
it("does not update edge when input_data has no matching handle", () => {
|
||||
useEdgeStore.setState({
|
||||
edges: [
|
||||
makeEdge({
|
||||
id: "e1",
|
||||
target: "node-b",
|
||||
targetHandle: "input",
|
||||
data: { beadUp: 0, beadDown: 0, beadData: new Map() },
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
useEdgeStore.getState().updateEdgeBeads(
|
||||
"node-b",
|
||||
makeExecutionResult({
|
||||
node_exec_id: "exec-1",
|
||||
status: "COMPLETED",
|
||||
input_data: { other_handle: "data" },
|
||||
}),
|
||||
);
|
||||
|
||||
const edge = useEdgeStore.getState().edges[0];
|
||||
expect(edge.data?.beadUp).toBe(0);
|
||||
expect(edge.data?.beadDown).toBe(0);
|
||||
});
|
||||
|
||||
it("accumulates beads across multiple executions", () => {
|
||||
useEdgeStore.setState({
|
||||
edges: [
|
||||
makeEdge({
|
||||
id: "e1",
|
||||
target: "node-b",
|
||||
targetHandle: "input",
|
||||
data: { beadUp: 0, beadDown: 0, beadData: new Map() },
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
useEdgeStore.getState().updateEdgeBeads(
|
||||
"node-b",
|
||||
makeExecutionResult({
|
||||
node_exec_id: "exec-1",
|
||||
status: "COMPLETED",
|
||||
input_data: { input: "data1" },
|
||||
}),
|
||||
);
|
||||
|
||||
useEdgeStore.getState().updateEdgeBeads(
|
||||
"node-b",
|
||||
makeExecutionResult({
|
||||
node_exec_id: "exec-2",
|
||||
status: "INCOMPLETE",
|
||||
input_data: { input: "data2" },
|
||||
}),
|
||||
);
|
||||
|
||||
const edge = useEdgeStore.getState().edges[0];
|
||||
expect(edge.data?.beadUp).toBe(2);
|
||||
expect(edge.data?.beadDown).toBe(1);
|
||||
});
|
||||
|
||||
it("handles static edges by setting beadUp to beadDown + 1", () => {
|
||||
useEdgeStore.setState({
|
||||
edges: [
|
||||
makeEdge({
|
||||
id: "e1",
|
||||
target: "node-b",
|
||||
targetHandle: "input",
|
||||
data: {
|
||||
isStatic: true,
|
||||
beadUp: 0,
|
||||
beadDown: 0,
|
||||
beadData: new Map(),
|
||||
},
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
useEdgeStore.getState().updateEdgeBeads(
|
||||
"node-b",
|
||||
makeExecutionResult({
|
||||
node_exec_id: "exec-1",
|
||||
status: "COMPLETED",
|
||||
input_data: { input: "data" },
|
||||
}),
|
||||
);
|
||||
|
||||
const edge = useEdgeStore.getState().edges[0];
|
||||
expect(edge.data?.beadUp).toBe(2);
|
||||
expect(edge.data?.beadDown).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("resetEdgeBeads", () => {
|
||||
it("resets all bead data on all edges", () => {
|
||||
useEdgeStore.setState({
|
||||
edges: [
|
||||
makeEdge({
|
||||
id: "e1",
|
||||
data: {
|
||||
beadUp: 5,
|
||||
beadDown: 3,
|
||||
beadData: new Map([["exec-1", "COMPLETED"]]),
|
||||
},
|
||||
}),
|
||||
makeEdge({
|
||||
id: "e2",
|
||||
data: {
|
||||
beadUp: 2,
|
||||
beadDown: 1,
|
||||
beadData: new Map([["exec-2", "INCOMPLETE"]]),
|
||||
},
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
useEdgeStore.getState().resetEdgeBeads();
|
||||
|
||||
const edges = useEdgeStore.getState().edges;
|
||||
for (const edge of edges) {
|
||||
expect(edge.data?.beadUp).toBe(0);
|
||||
expect(edge.data?.beadDown).toBe(0);
|
||||
expect(edge.data?.beadData?.size).toBe(0);
|
||||
}
|
||||
});
|
||||
|
||||
it("preserves other edge data when resetting beads", () => {
|
||||
useEdgeStore.setState({
|
||||
edges: [
|
||||
makeEdge({
|
||||
id: "e1",
|
||||
data: {
|
||||
isStatic: true,
|
||||
edgeColorClass: "text-red-500",
|
||||
beadUp: 3,
|
||||
beadDown: 2,
|
||||
beadData: new Map(),
|
||||
},
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
useEdgeStore.getState().resetEdgeBeads();
|
||||
|
||||
const edge = useEdgeStore.getState().edges[0];
|
||||
expect(edge.data?.isStatic).toBe(true);
|
||||
expect(edge.data?.edgeColorClass).toBe("text-red-500");
|
||||
expect(edge.data?.beadUp).toBe(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,347 @@
|
||||
import { describe, it, expect, beforeEach } from "vitest";
|
||||
import { useGraphStore } from "../stores/graphStore";
|
||||
import { AgentExecutionStatus } from "@/app/api/__generated__/models/agentExecutionStatus";
|
||||
import { GraphMeta } from "@/app/api/__generated__/models/graphMeta";
|
||||
|
||||
function createTestGraphMeta(
|
||||
overrides: Partial<GraphMeta> & { id: string; name: string },
|
||||
): GraphMeta {
|
||||
return {
|
||||
version: 1,
|
||||
description: "",
|
||||
is_active: true,
|
||||
user_id: "test-user",
|
||||
created_at: new Date("2024-01-01T00:00:00Z"),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function resetStore() {
|
||||
useGraphStore.setState({
|
||||
graphExecutionStatus: undefined,
|
||||
isGraphRunning: false,
|
||||
inputSchema: null,
|
||||
credentialsInputSchema: null,
|
||||
outputSchema: null,
|
||||
availableSubGraphs: [],
|
||||
});
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
resetStore();
|
||||
});
|
||||
|
||||
describe("graphStore", () => {
|
||||
describe("execution status transitions", () => {
|
||||
it("handles QUEUED -> RUNNING -> COMPLETED transition", () => {
|
||||
const { setGraphExecutionStatus } = useGraphStore.getState();
|
||||
|
||||
setGraphExecutionStatus(AgentExecutionStatus.QUEUED);
|
||||
expect(useGraphStore.getState().graphExecutionStatus).toBe(
|
||||
AgentExecutionStatus.QUEUED,
|
||||
);
|
||||
expect(useGraphStore.getState().isGraphRunning).toBe(true);
|
||||
|
||||
setGraphExecutionStatus(AgentExecutionStatus.RUNNING);
|
||||
expect(useGraphStore.getState().graphExecutionStatus).toBe(
|
||||
AgentExecutionStatus.RUNNING,
|
||||
);
|
||||
expect(useGraphStore.getState().isGraphRunning).toBe(true);
|
||||
|
||||
setGraphExecutionStatus(AgentExecutionStatus.COMPLETED);
|
||||
expect(useGraphStore.getState().graphExecutionStatus).toBe(
|
||||
AgentExecutionStatus.COMPLETED,
|
||||
);
|
||||
expect(useGraphStore.getState().isGraphRunning).toBe(false);
|
||||
});
|
||||
|
||||
it("handles QUEUED -> RUNNING -> FAILED transition", () => {
|
||||
const { setGraphExecutionStatus } = useGraphStore.getState();
|
||||
|
||||
setGraphExecutionStatus(AgentExecutionStatus.QUEUED);
|
||||
expect(useGraphStore.getState().isGraphRunning).toBe(true);
|
||||
|
||||
setGraphExecutionStatus(AgentExecutionStatus.RUNNING);
|
||||
expect(useGraphStore.getState().isGraphRunning).toBe(true);
|
||||
|
||||
setGraphExecutionStatus(AgentExecutionStatus.FAILED);
|
||||
expect(useGraphStore.getState().graphExecutionStatus).toBe(
|
||||
AgentExecutionStatus.FAILED,
|
||||
);
|
||||
expect(useGraphStore.getState().isGraphRunning).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("setGraphExecutionStatus auto-sets isGraphRunning", () => {
|
||||
it("sets isGraphRunning to true for RUNNING", () => {
|
||||
useGraphStore
|
||||
.getState()
|
||||
.setGraphExecutionStatus(AgentExecutionStatus.RUNNING);
|
||||
expect(useGraphStore.getState().isGraphRunning).toBe(true);
|
||||
});
|
||||
|
||||
it("sets isGraphRunning to true for QUEUED", () => {
|
||||
useGraphStore
|
||||
.getState()
|
||||
.setGraphExecutionStatus(AgentExecutionStatus.QUEUED);
|
||||
expect(useGraphStore.getState().isGraphRunning).toBe(true);
|
||||
});
|
||||
|
||||
it("sets isGraphRunning to false for COMPLETED", () => {
|
||||
useGraphStore
|
||||
.getState()
|
||||
.setGraphExecutionStatus(AgentExecutionStatus.RUNNING);
|
||||
expect(useGraphStore.getState().isGraphRunning).toBe(true);
|
||||
|
||||
useGraphStore
|
||||
.getState()
|
||||
.setGraphExecutionStatus(AgentExecutionStatus.COMPLETED);
|
||||
expect(useGraphStore.getState().isGraphRunning).toBe(false);
|
||||
});
|
||||
|
||||
it("sets isGraphRunning to false for FAILED", () => {
|
||||
useGraphStore
|
||||
.getState()
|
||||
.setGraphExecutionStatus(AgentExecutionStatus.RUNNING);
|
||||
useGraphStore
|
||||
.getState()
|
||||
.setGraphExecutionStatus(AgentExecutionStatus.FAILED);
|
||||
expect(useGraphStore.getState().isGraphRunning).toBe(false);
|
||||
});
|
||||
|
||||
it("sets isGraphRunning to false for TERMINATED", () => {
|
||||
useGraphStore
|
||||
.getState()
|
||||
.setGraphExecutionStatus(AgentExecutionStatus.RUNNING);
|
||||
useGraphStore
|
||||
.getState()
|
||||
.setGraphExecutionStatus(AgentExecutionStatus.TERMINATED);
|
||||
expect(useGraphStore.getState().isGraphRunning).toBe(false);
|
||||
});
|
||||
|
||||
it("sets isGraphRunning to false for INCOMPLETE", () => {
|
||||
useGraphStore
|
||||
.getState()
|
||||
.setGraphExecutionStatus(AgentExecutionStatus.RUNNING);
|
||||
useGraphStore
|
||||
.getState()
|
||||
.setGraphExecutionStatus(AgentExecutionStatus.INCOMPLETE);
|
||||
expect(useGraphStore.getState().isGraphRunning).toBe(false);
|
||||
});
|
||||
|
||||
it("sets isGraphRunning to false for undefined", () => {
|
||||
useGraphStore
|
||||
.getState()
|
||||
.setGraphExecutionStatus(AgentExecutionStatus.RUNNING);
|
||||
expect(useGraphStore.getState().isGraphRunning).toBe(true);
|
||||
|
||||
useGraphStore.getState().setGraphExecutionStatus(undefined);
|
||||
expect(useGraphStore.getState().graphExecutionStatus).toBeUndefined();
|
||||
expect(useGraphStore.getState().isGraphRunning).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("setIsGraphRunning", () => {
|
||||
it("sets isGraphRunning independently of status", () => {
|
||||
useGraphStore.getState().setIsGraphRunning(true);
|
||||
expect(useGraphStore.getState().isGraphRunning).toBe(true);
|
||||
|
||||
useGraphStore.getState().setIsGraphRunning(false);
|
||||
expect(useGraphStore.getState().isGraphRunning).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("schema management", () => {
|
||||
it("sets all three schemas via setGraphSchemas", () => {
|
||||
const input = { properties: { prompt: { type: "string" } } };
|
||||
const credentials = { properties: { apiKey: { type: "string" } } };
|
||||
const output = { properties: { result: { type: "string" } } };
|
||||
|
||||
useGraphStore.getState().setGraphSchemas(input, credentials, output);
|
||||
|
||||
const state = useGraphStore.getState();
|
||||
expect(state.inputSchema).toEqual(input);
|
||||
expect(state.credentialsInputSchema).toEqual(credentials);
|
||||
expect(state.outputSchema).toEqual(output);
|
||||
});
|
||||
|
||||
it("sets schemas to null", () => {
|
||||
const input = { properties: { prompt: { type: "string" } } };
|
||||
useGraphStore.getState().setGraphSchemas(input, null, null);
|
||||
|
||||
const state = useGraphStore.getState();
|
||||
expect(state.inputSchema).toEqual(input);
|
||||
expect(state.credentialsInputSchema).toBeNull();
|
||||
expect(state.outputSchema).toBeNull();
|
||||
});
|
||||
|
||||
it("overwrites previous schemas", () => {
|
||||
const first = { properties: { a: { type: "string" } } };
|
||||
const second = { properties: { b: { type: "number" } } };
|
||||
|
||||
useGraphStore.getState().setGraphSchemas(first, first, first);
|
||||
useGraphStore.getState().setGraphSchemas(second, null, second);
|
||||
|
||||
const state = useGraphStore.getState();
|
||||
expect(state.inputSchema).toEqual(second);
|
||||
expect(state.credentialsInputSchema).toBeNull();
|
||||
expect(state.outputSchema).toEqual(second);
|
||||
});
|
||||
});
|
||||
|
||||
describe("hasInputs", () => {
|
||||
it("returns false when inputSchema is null", () => {
|
||||
expect(useGraphStore.getState().hasInputs()).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false when inputSchema has no properties", () => {
|
||||
useGraphStore.getState().setGraphSchemas({}, null, null);
|
||||
expect(useGraphStore.getState().hasInputs()).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false when inputSchema has empty properties", () => {
|
||||
useGraphStore.getState().setGraphSchemas({ properties: {} }, null, null);
|
||||
expect(useGraphStore.getState().hasInputs()).toBe(false);
|
||||
});
|
||||
|
||||
it("returns true when inputSchema has properties", () => {
|
||||
useGraphStore
|
||||
.getState()
|
||||
.setGraphSchemas(
|
||||
{ properties: { prompt: { type: "string" } } },
|
||||
null,
|
||||
null,
|
||||
);
|
||||
expect(useGraphStore.getState().hasInputs()).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("hasCredentials", () => {
|
||||
it("returns false when credentialsInputSchema is null", () => {
|
||||
expect(useGraphStore.getState().hasCredentials()).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false when credentialsInputSchema has empty properties", () => {
|
||||
useGraphStore.getState().setGraphSchemas(null, { properties: {} }, null);
|
||||
expect(useGraphStore.getState().hasCredentials()).toBe(false);
|
||||
});
|
||||
|
||||
it("returns true when credentialsInputSchema has properties", () => {
|
||||
useGraphStore
|
||||
.getState()
|
||||
.setGraphSchemas(
|
||||
null,
|
||||
{ properties: { apiKey: { type: "string" } } },
|
||||
null,
|
||||
);
|
||||
expect(useGraphStore.getState().hasCredentials()).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("hasOutputs", () => {
|
||||
it("returns false when outputSchema is null", () => {
|
||||
expect(useGraphStore.getState().hasOutputs()).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false when outputSchema has empty properties", () => {
|
||||
useGraphStore.getState().setGraphSchemas(null, null, { properties: {} });
|
||||
expect(useGraphStore.getState().hasOutputs()).toBe(false);
|
||||
});
|
||||
|
||||
it("returns true when outputSchema has properties", () => {
|
||||
useGraphStore.getState().setGraphSchemas(null, null, {
|
||||
properties: { result: { type: "string" } },
|
||||
});
|
||||
expect(useGraphStore.getState().hasOutputs()).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("reset", () => {
|
||||
it("clears execution status and schemas but preserves outputSchema and availableSubGraphs", () => {
|
||||
const subGraphs: GraphMeta[] = [
|
||||
createTestGraphMeta({
|
||||
id: "sub-1",
|
||||
name: "Sub Graph",
|
||||
description: "A sub graph",
|
||||
}),
|
||||
];
|
||||
|
||||
useGraphStore
|
||||
.getState()
|
||||
.setGraphExecutionStatus(AgentExecutionStatus.RUNNING);
|
||||
useGraphStore
|
||||
.getState()
|
||||
.setGraphSchemas(
|
||||
{ properties: { a: {} } },
|
||||
{ properties: { b: {} } },
|
||||
{ properties: { c: {} } },
|
||||
);
|
||||
useGraphStore.getState().setAvailableSubGraphs(subGraphs);
|
||||
|
||||
useGraphStore.getState().reset();
|
||||
|
||||
const state = useGraphStore.getState();
|
||||
expect(state.graphExecutionStatus).toBeUndefined();
|
||||
expect(state.isGraphRunning).toBe(false);
|
||||
expect(state.inputSchema).toBeNull();
|
||||
expect(state.credentialsInputSchema).toBeNull();
|
||||
// reset does not clear outputSchema or availableSubGraphs
|
||||
expect(state.outputSchema).toEqual({ properties: { c: {} } });
|
||||
expect(state.availableSubGraphs).toEqual(subGraphs);
|
||||
});
|
||||
|
||||
it("is idempotent on fresh state", () => {
|
||||
useGraphStore.getState().reset();
|
||||
|
||||
const state = useGraphStore.getState();
|
||||
expect(state.graphExecutionStatus).toBeUndefined();
|
||||
expect(state.isGraphRunning).toBe(false);
|
||||
expect(state.inputSchema).toBeNull();
|
||||
expect(state.credentialsInputSchema).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("setAvailableSubGraphs", () => {
|
||||
it("sets sub-graphs list", () => {
|
||||
const graphs: GraphMeta[] = [
|
||||
createTestGraphMeta({
|
||||
id: "graph-1",
|
||||
name: "Graph One",
|
||||
description: "First graph",
|
||||
}),
|
||||
createTestGraphMeta({
|
||||
id: "graph-2",
|
||||
version: 2,
|
||||
name: "Graph Two",
|
||||
description: "Second graph",
|
||||
}),
|
||||
];
|
||||
|
||||
useGraphStore.getState().setAvailableSubGraphs(graphs);
|
||||
expect(useGraphStore.getState().availableSubGraphs).toEqual(graphs);
|
||||
});
|
||||
|
||||
it("replaces previous sub-graphs", () => {
|
||||
const first: GraphMeta[] = [createTestGraphMeta({ id: "a", name: "A" })];
|
||||
const second: GraphMeta[] = [
|
||||
createTestGraphMeta({ id: "b", name: "B" }),
|
||||
createTestGraphMeta({ id: "c", name: "C" }),
|
||||
];
|
||||
|
||||
useGraphStore.getState().setAvailableSubGraphs(first);
|
||||
expect(useGraphStore.getState().availableSubGraphs).toHaveLength(1);
|
||||
|
||||
useGraphStore.getState().setAvailableSubGraphs(second);
|
||||
expect(useGraphStore.getState().availableSubGraphs).toHaveLength(2);
|
||||
expect(useGraphStore.getState().availableSubGraphs).toEqual(second);
|
||||
});
|
||||
|
||||
it("can set empty sub-graphs list", () => {
|
||||
useGraphStore
|
||||
.getState()
|
||||
.setAvailableSubGraphs([createTestGraphMeta({ id: "x", name: "X" })]);
|
||||
useGraphStore.getState().setAvailableSubGraphs([]);
|
||||
expect(useGraphStore.getState().availableSubGraphs).toEqual([]);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,407 @@
|
||||
import { describe, it, expect, beforeEach } from "vitest";
|
||||
import { useHistoryStore } from "../stores/historyStore";
|
||||
import { useNodeStore } from "../stores/nodeStore";
|
||||
import { useEdgeStore } from "../stores/edgeStore";
|
||||
import { CustomNode } from "../components/FlowEditor/nodes/CustomNode/CustomNode";
|
||||
import { CustomEdge } from "../components/FlowEditor/edges/CustomEdge";
|
||||
|
||||
function createTestNode(
|
||||
id: string,
|
||||
overrides: Partial<CustomNode> = {},
|
||||
): CustomNode {
|
||||
return {
|
||||
id,
|
||||
type: "custom" as const,
|
||||
position: { x: 0, y: 0 },
|
||||
data: {
|
||||
hardcodedValues: {},
|
||||
title: `Node ${id}`,
|
||||
description: "",
|
||||
inputSchema: {},
|
||||
outputSchema: {},
|
||||
uiType: "STANDARD" as never,
|
||||
block_id: `block-${id}`,
|
||||
costs: [],
|
||||
categories: [],
|
||||
},
|
||||
...overrides,
|
||||
} as CustomNode;
|
||||
}
|
||||
|
||||
function createTestEdge(
|
||||
id: string,
|
||||
source: string,
|
||||
target: string,
|
||||
): CustomEdge {
|
||||
return {
|
||||
id,
|
||||
source,
|
||||
target,
|
||||
type: "custom" as const,
|
||||
} as CustomEdge;
|
||||
}
|
||||
|
||||
async function flushMicrotasks() {
|
||||
await new Promise<void>((resolve) => queueMicrotask(resolve));
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
useHistoryStore.getState().clear();
|
||||
useNodeStore.setState({ nodes: [] });
|
||||
useEdgeStore.setState({ edges: [] });
|
||||
});
|
||||
|
||||
describe("historyStore", () => {
|
||||
describe("undo/redo single action", () => {
|
||||
it("undoes a single pushed state", async () => {
|
||||
const node = createTestNode("1");
|
||||
|
||||
// Initialize history with node present as baseline
|
||||
useNodeStore.setState({ nodes: [node] });
|
||||
useHistoryStore.getState().initializeHistory();
|
||||
|
||||
// Simulate a change: clear nodes
|
||||
useNodeStore.setState({ nodes: [] });
|
||||
|
||||
// Undo should restore to [node]
|
||||
useHistoryStore.getState().undo();
|
||||
expect(useNodeStore.getState().nodes).toEqual([node]);
|
||||
expect(useHistoryStore.getState().future).toHaveLength(1);
|
||||
expect(useHistoryStore.getState().future[0].nodes).toEqual([]);
|
||||
});
|
||||
|
||||
it("redoes after undo", async () => {
|
||||
const node = createTestNode("1");
|
||||
|
||||
useNodeStore.setState({ nodes: [node] });
|
||||
useHistoryStore.getState().initializeHistory();
|
||||
|
||||
// Change: clear nodes
|
||||
useNodeStore.setState({ nodes: [] });
|
||||
|
||||
// Undo → back to [node]
|
||||
useHistoryStore.getState().undo();
|
||||
expect(useNodeStore.getState().nodes).toEqual([node]);
|
||||
|
||||
// Redo → back to []
|
||||
useHistoryStore.getState().redo();
|
||||
expect(useNodeStore.getState().nodes).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("undo/redo multiple actions", () => {
|
||||
it("undoes through multiple states in order", async () => {
|
||||
const node1 = createTestNode("1");
|
||||
const node2 = createTestNode("2");
|
||||
const node3 = createTestNode("3");
|
||||
|
||||
// Initialize with [node1] as baseline
|
||||
useNodeStore.setState({ nodes: [node1] });
|
||||
useHistoryStore.getState().initializeHistory();
|
||||
|
||||
// Second change: add node2, push pre-change state
|
||||
useNodeStore.setState({ nodes: [node1, node2] });
|
||||
useHistoryStore.getState().pushState({ nodes: [node1], edges: [] });
|
||||
await flushMicrotasks();
|
||||
|
||||
// Third change: add node3, push pre-change state
|
||||
useNodeStore.setState({ nodes: [node1, node2, node3] });
|
||||
useHistoryStore
|
||||
.getState()
|
||||
.pushState({ nodes: [node1, node2], edges: [] });
|
||||
await flushMicrotasks();
|
||||
|
||||
// Undo 1: back to [node1, node2]
|
||||
useHistoryStore.getState().undo();
|
||||
expect(useNodeStore.getState().nodes).toEqual([node1, node2]);
|
||||
|
||||
// Undo 2: back to [node1]
|
||||
useHistoryStore.getState().undo();
|
||||
expect(useNodeStore.getState().nodes).toEqual([node1]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("undo past empty history", () => {
|
||||
it("does nothing when there is no history to undo", () => {
|
||||
useHistoryStore.getState().undo();
|
||||
|
||||
expect(useNodeStore.getState().nodes).toEqual([]);
|
||||
expect(useEdgeStore.getState().edges).toEqual([]);
|
||||
expect(useHistoryStore.getState().past).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("does nothing when current state equals last past entry", () => {
|
||||
expect(useHistoryStore.getState().canUndo()).toBe(false);
|
||||
|
||||
useHistoryStore.getState().undo();
|
||||
|
||||
expect(useHistoryStore.getState().past).toHaveLength(1);
|
||||
expect(useHistoryStore.getState().future).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("state consistency: undo after node add restores previous, redo restores added", () => {
|
||||
it("undo removes added node, redo restores it", async () => {
|
||||
const node = createTestNode("added");
|
||||
|
||||
useNodeStore.setState({ nodes: [node] });
|
||||
useHistoryStore.getState().pushState({ nodes: [], edges: [] });
|
||||
await flushMicrotasks();
|
||||
|
||||
useHistoryStore.getState().undo();
|
||||
expect(useNodeStore.getState().nodes).toEqual([]);
|
||||
|
||||
useHistoryStore.getState().redo();
|
||||
expect(useNodeStore.getState().nodes).toEqual([node]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("history limits", () => {
|
||||
it("does not grow past MAX_HISTORY (50)", async () => {
|
||||
for (let i = 0; i < 60; i++) {
|
||||
const node = createTestNode(`node-${i}`);
|
||||
useNodeStore.setState({ nodes: [node] });
|
||||
useHistoryStore.getState().pushState({
|
||||
nodes: [createTestNode(`node-${i - 1}`)],
|
||||
edges: [],
|
||||
});
|
||||
await flushMicrotasks();
|
||||
}
|
||||
|
||||
expect(useHistoryStore.getState().past.length).toBeLessThanOrEqual(50);
|
||||
});
|
||||
});
|
||||
|
||||
describe("edge cases", () => {
|
||||
it("redo does nothing when future is empty", () => {
|
||||
const nodesBefore = useNodeStore.getState().nodes;
|
||||
const edgesBefore = useEdgeStore.getState().edges;
|
||||
|
||||
useHistoryStore.getState().redo();
|
||||
|
||||
expect(useNodeStore.getState().nodes).toEqual(nodesBefore);
|
||||
expect(useEdgeStore.getState().edges).toEqual(edgesBefore);
|
||||
});
|
||||
|
||||
it("interleaved undo/redo sequence", async () => {
|
||||
const node1 = createTestNode("1");
|
||||
const node2 = createTestNode("2");
|
||||
const node3 = createTestNode("3");
|
||||
|
||||
useNodeStore.setState({ nodes: [node1] });
|
||||
useHistoryStore.getState().pushState({ nodes: [], edges: [] });
|
||||
await flushMicrotasks();
|
||||
|
||||
useNodeStore.setState({ nodes: [node1, node2] });
|
||||
useHistoryStore.getState().pushState({ nodes: [node1], edges: [] });
|
||||
await flushMicrotasks();
|
||||
|
||||
useNodeStore.setState({ nodes: [node1, node2, node3] });
|
||||
useHistoryStore.getState().pushState({
|
||||
nodes: [node1, node2],
|
||||
edges: [],
|
||||
});
|
||||
await flushMicrotasks();
|
||||
|
||||
useHistoryStore.getState().undo();
|
||||
expect(useNodeStore.getState().nodes).toEqual([node1, node2]);
|
||||
|
||||
useHistoryStore.getState().undo();
|
||||
expect(useNodeStore.getState().nodes).toEqual([node1]);
|
||||
|
||||
useHistoryStore.getState().redo();
|
||||
expect(useNodeStore.getState().nodes).toEqual([node1, node2]);
|
||||
|
||||
useHistoryStore.getState().undo();
|
||||
expect(useNodeStore.getState().nodes).toEqual([node1]);
|
||||
|
||||
useHistoryStore.getState().redo();
|
||||
useHistoryStore.getState().redo();
|
||||
expect(useNodeStore.getState().nodes).toEqual([node1, node2, node3]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("canUndo / canRedo", () => {
|
||||
it("canUndo is false on fresh store", () => {
|
||||
expect(useHistoryStore.getState().canUndo()).toBe(false);
|
||||
});
|
||||
|
||||
it("canUndo is true when current state differs from last past entry", async () => {
|
||||
const node = createTestNode("1");
|
||||
useNodeStore.setState({ nodes: [node] });
|
||||
useHistoryStore.getState().pushState({ nodes: [], edges: [] });
|
||||
await flushMicrotasks();
|
||||
|
||||
expect(useHistoryStore.getState().canUndo()).toBe(true);
|
||||
});
|
||||
|
||||
it("canRedo is false on fresh store", () => {
|
||||
expect(useHistoryStore.getState().canRedo()).toBe(false);
|
||||
});
|
||||
|
||||
it("canRedo is true after undo", async () => {
|
||||
const node = createTestNode("1");
|
||||
useNodeStore.setState({ nodes: [node] });
|
||||
useHistoryStore.getState().pushState({ nodes: [], edges: [] });
|
||||
await flushMicrotasks();
|
||||
|
||||
useHistoryStore.getState().undo();
|
||||
|
||||
expect(useHistoryStore.getState().canRedo()).toBe(true);
|
||||
});
|
||||
|
||||
it("canRedo becomes false after redo exhausts future", async () => {
|
||||
const node = createTestNode("1");
|
||||
useNodeStore.setState({ nodes: [node] });
|
||||
useHistoryStore.getState().pushState({ nodes: [], edges: [] });
|
||||
await flushMicrotasks();
|
||||
|
||||
useHistoryStore.getState().undo();
|
||||
useHistoryStore.getState().redo();
|
||||
|
||||
expect(useHistoryStore.getState().canRedo()).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("pushState deduplication", () => {
|
||||
it("does not push a state identical to the last past entry", async () => {
|
||||
useHistoryStore.getState().pushState({ nodes: [], edges: [] });
|
||||
await flushMicrotasks();
|
||||
|
||||
expect(useHistoryStore.getState().past).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("does not push if state matches current node/edge store state", async () => {
|
||||
const node = createTestNode("1");
|
||||
useNodeStore.setState({ nodes: [node] });
|
||||
useEdgeStore.setState({ edges: [] });
|
||||
|
||||
useHistoryStore.getState().pushState({ nodes: [node], edges: [] });
|
||||
await flushMicrotasks();
|
||||
|
||||
expect(useHistoryStore.getState().past).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("initializeHistory", () => {
|
||||
it("resets history with current node/edge store state", async () => {
|
||||
const node = createTestNode("1");
|
||||
const edge = createTestEdge("e1", "1", "2");
|
||||
|
||||
useNodeStore.setState({ nodes: [node] });
|
||||
useEdgeStore.setState({ edges: [edge] });
|
||||
|
||||
useNodeStore.setState({ nodes: [node, createTestNode("2")] });
|
||||
useHistoryStore.getState().pushState({ nodes: [node], edges: [edge] });
|
||||
await flushMicrotasks();
|
||||
|
||||
useHistoryStore.getState().initializeHistory();
|
||||
|
||||
const { past, future } = useHistoryStore.getState();
|
||||
expect(past).toHaveLength(1);
|
||||
expect(past[0].nodes).toEqual(useNodeStore.getState().nodes);
|
||||
expect(past[0].edges).toEqual(useEdgeStore.getState().edges);
|
||||
expect(future).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("clear", () => {
|
||||
it("resets to empty initial state", async () => {
|
||||
const node = createTestNode("1");
|
||||
useNodeStore.setState({ nodes: [node] });
|
||||
useHistoryStore.getState().pushState({ nodes: [], edges: [] });
|
||||
await flushMicrotasks();
|
||||
|
||||
useHistoryStore.getState().clear();
|
||||
|
||||
const { past, future } = useHistoryStore.getState();
|
||||
expect(past).toEqual([{ nodes: [], edges: [] }]);
|
||||
expect(future).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("microtask batching", () => {
|
||||
it("only commits the first state when multiple pushState calls happen in the same tick", async () => {
|
||||
const node1 = createTestNode("1");
|
||||
const node2 = createTestNode("2");
|
||||
const node3 = createTestNode("3");
|
||||
|
||||
useNodeStore.setState({ nodes: [node1, node2, node3] });
|
||||
|
||||
useHistoryStore.getState().pushState({ nodes: [node1], edges: [] });
|
||||
useHistoryStore.getState().pushState({ nodes: [node2], edges: [] });
|
||||
useHistoryStore
|
||||
.getState()
|
||||
.pushState({ nodes: [node1, node2], edges: [] });
|
||||
await flushMicrotasks();
|
||||
|
||||
const { past } = useHistoryStore.getState();
|
||||
expect(past).toHaveLength(2);
|
||||
expect(past[1].nodes).toEqual([node1]);
|
||||
});
|
||||
|
||||
it("commits separately when pushState calls are in different ticks", async () => {
|
||||
const node1 = createTestNode("1");
|
||||
const node2 = createTestNode("2");
|
||||
|
||||
useNodeStore.setState({ nodes: [node1, node2] });
|
||||
|
||||
useHistoryStore.getState().pushState({ nodes: [node1], edges: [] });
|
||||
await flushMicrotasks();
|
||||
|
||||
useHistoryStore.getState().pushState({ nodes: [node2], edges: [] });
|
||||
await flushMicrotasks();
|
||||
|
||||
const { past } = useHistoryStore.getState();
|
||||
expect(past).toHaveLength(3);
|
||||
expect(past[1].nodes).toEqual([node1]);
|
||||
expect(past[2].nodes).toEqual([node2]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("edges in undo/redo", () => {
|
||||
it("restores edges on undo and redo", async () => {
|
||||
const edge = createTestEdge("e1", "1", "2");
|
||||
useEdgeStore.setState({ edges: [edge] });
|
||||
|
||||
useHistoryStore.getState().pushState({ nodes: [], edges: [] });
|
||||
await flushMicrotasks();
|
||||
|
||||
useHistoryStore.getState().undo();
|
||||
expect(useEdgeStore.getState().edges).toEqual([]);
|
||||
|
||||
useHistoryStore.getState().redo();
|
||||
expect(useEdgeStore.getState().edges).toEqual([edge]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("pushState clears future", () => {
|
||||
it("clears future when a new state is pushed after undo", async () => {
|
||||
const node1 = createTestNode("1");
|
||||
const node2 = createTestNode("2");
|
||||
const node3 = createTestNode("3");
|
||||
|
||||
// Initialize empty
|
||||
useHistoryStore.getState().initializeHistory();
|
||||
|
||||
// First change: set [node1]
|
||||
useNodeStore.setState({ nodes: [node1] });
|
||||
|
||||
// Second change: set [node1, node2], push pre-change [node1]
|
||||
useNodeStore.setState({ nodes: [node1, node2] });
|
||||
useHistoryStore.getState().pushState({ nodes: [node1], edges: [] });
|
||||
await flushMicrotasks();
|
||||
|
||||
// Undo: back to [node1]
|
||||
useHistoryStore.getState().undo();
|
||||
expect(useHistoryStore.getState().future).toHaveLength(1);
|
||||
|
||||
// New diverging change: add node3 instead of node2
|
||||
useNodeStore.setState({ nodes: [node1, node3] });
|
||||
useHistoryStore.getState().pushState({ nodes: [node1], edges: [] });
|
||||
await flushMicrotasks();
|
||||
|
||||
expect(useHistoryStore.getState().future).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,791 @@
|
||||
import { describe, it, expect, beforeEach, vi } from "vitest";
|
||||
import { useNodeStore } from "../stores/nodeStore";
|
||||
import { useHistoryStore } from "../stores/historyStore";
|
||||
import { useEdgeStore } from "../stores/edgeStore";
|
||||
import { BlockUIType } from "../components/types";
|
||||
import type { CustomNode } from "../components/FlowEditor/nodes/CustomNode/CustomNode";
|
||||
import type { CustomNodeData } from "../components/FlowEditor/nodes/CustomNode/CustomNode";
|
||||
import type { NodeExecutionResult } from "@/app/api/__generated__/models/nodeExecutionResult";
|
||||
|
||||
function createTestNode(overrides: {
|
||||
id: string;
|
||||
position?: { x: number; y: number };
|
||||
data?: Partial<CustomNodeData>;
|
||||
}): CustomNode {
|
||||
const defaults: CustomNodeData = {
|
||||
hardcodedValues: {},
|
||||
title: "Test Block",
|
||||
description: "A test block",
|
||||
inputSchema: {},
|
||||
outputSchema: {},
|
||||
uiType: BlockUIType.STANDARD,
|
||||
block_id: "test-block-id",
|
||||
costs: [],
|
||||
categories: [],
|
||||
};
|
||||
|
||||
return {
|
||||
id: overrides.id,
|
||||
type: "custom",
|
||||
position: overrides.position ?? { x: 0, y: 0 },
|
||||
data: { ...defaults, ...overrides.data },
|
||||
};
|
||||
}
|
||||
|
||||
function createExecutionResult(
|
||||
overrides: Partial<NodeExecutionResult> = {},
|
||||
): NodeExecutionResult {
|
||||
return {
|
||||
node_exec_id: overrides.node_exec_id ?? "exec-1",
|
||||
node_id: overrides.node_id ?? "1",
|
||||
graph_exec_id: overrides.graph_exec_id ?? "graph-exec-1",
|
||||
graph_id: overrides.graph_id ?? "graph-1",
|
||||
graph_version: overrides.graph_version ?? 1,
|
||||
user_id: overrides.user_id ?? "test-user",
|
||||
block_id: overrides.block_id ?? "block-1",
|
||||
status: overrides.status ?? "COMPLETED",
|
||||
input_data: overrides.input_data ?? { input_key: "input_value" },
|
||||
output_data: overrides.output_data ?? { output_key: ["output_value"] },
|
||||
add_time: overrides.add_time ?? new Date("2024-01-01T00:00:00Z"),
|
||||
queue_time: overrides.queue_time ?? new Date("2024-01-01T00:00:00Z"),
|
||||
start_time: overrides.start_time ?? new Date("2024-01-01T00:00:01Z"),
|
||||
end_time: overrides.end_time ?? new Date("2024-01-01T00:00:02Z"),
|
||||
};
|
||||
}
|
||||
|
||||
function resetStores() {
|
||||
useNodeStore.setState({
|
||||
nodes: [],
|
||||
nodeCounter: 0,
|
||||
nodeAdvancedStates: {},
|
||||
latestNodeInputData: {},
|
||||
latestNodeOutputData: {},
|
||||
accumulatedNodeInputData: {},
|
||||
accumulatedNodeOutputData: {},
|
||||
nodesInResolutionMode: new Set(),
|
||||
brokenEdgeIDs: new Map(),
|
||||
nodeResolutionData: new Map(),
|
||||
});
|
||||
useEdgeStore.setState({ edges: [] });
|
||||
useHistoryStore.setState({ past: [], future: [] });
|
||||
}
|
||||
|
||||
describe("nodeStore", () => {
|
||||
beforeEach(() => {
|
||||
resetStores();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe("node lifecycle", () => {
|
||||
it("starts with empty nodes", () => {
|
||||
const { nodes } = useNodeStore.getState();
|
||||
expect(nodes).toEqual([]);
|
||||
});
|
||||
|
||||
it("adds a single node with addNode", () => {
|
||||
const node = createTestNode({ id: "1" });
|
||||
useNodeStore.getState().addNode(node);
|
||||
|
||||
const { nodes } = useNodeStore.getState();
|
||||
expect(nodes).toHaveLength(1);
|
||||
expect(nodes[0].id).toBe("1");
|
||||
});
|
||||
|
||||
it("sets nodes with setNodes, replacing existing ones", () => {
|
||||
const node1 = createTestNode({ id: "1" });
|
||||
const node2 = createTestNode({ id: "2" });
|
||||
useNodeStore.getState().addNode(node1);
|
||||
|
||||
useNodeStore.getState().setNodes([node2]);
|
||||
|
||||
const { nodes } = useNodeStore.getState();
|
||||
expect(nodes).toHaveLength(1);
|
||||
expect(nodes[0].id).toBe("2");
|
||||
});
|
||||
|
||||
it("removes nodes via onNodesChange", () => {
|
||||
const node = createTestNode({ id: "1" });
|
||||
useNodeStore.getState().setNodes([node]);
|
||||
|
||||
useNodeStore.getState().onNodesChange([{ type: "remove", id: "1" }]);
|
||||
|
||||
expect(useNodeStore.getState().nodes).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("updates node data with updateNodeData", () => {
|
||||
const node = createTestNode({ id: "1" });
|
||||
useNodeStore.getState().addNode(node);
|
||||
|
||||
useNodeStore.getState().updateNodeData("1", { title: "Updated Title" });
|
||||
|
||||
const updated = useNodeStore.getState().nodes[0];
|
||||
expect(updated.data.title).toBe("Updated Title");
|
||||
expect(updated.data.block_id).toBe("test-block-id");
|
||||
});
|
||||
|
||||
it("updateNodeData does not affect other nodes", () => {
|
||||
const node1 = createTestNode({ id: "1" });
|
||||
const node2 = createTestNode({
|
||||
id: "2",
|
||||
data: { title: "Node 2" },
|
||||
});
|
||||
useNodeStore.getState().setNodes([node1, node2]);
|
||||
|
||||
useNodeStore.getState().updateNodeData("1", { title: "Changed" });
|
||||
|
||||
expect(useNodeStore.getState().nodes[1].data.title).toBe("Node 2");
|
||||
});
|
||||
});
|
||||
|
||||
describe("bulk operations", () => {
|
||||
it("adds multiple nodes with addNodes", () => {
|
||||
const nodes = [
|
||||
createTestNode({ id: "1" }),
|
||||
createTestNode({ id: "2" }),
|
||||
createTestNode({ id: "3" }),
|
||||
];
|
||||
useNodeStore.getState().addNodes(nodes);
|
||||
|
||||
expect(useNodeStore.getState().nodes).toHaveLength(3);
|
||||
});
|
||||
|
||||
it("removes multiple nodes via onNodesChange", () => {
|
||||
const nodes = [
|
||||
createTestNode({ id: "1" }),
|
||||
createTestNode({ id: "2" }),
|
||||
createTestNode({ id: "3" }),
|
||||
];
|
||||
useNodeStore.getState().setNodes(nodes);
|
||||
|
||||
useNodeStore.getState().onNodesChange([
|
||||
{ type: "remove", id: "1" },
|
||||
{ type: "remove", id: "3" },
|
||||
]);
|
||||
|
||||
const remaining = useNodeStore.getState().nodes;
|
||||
expect(remaining).toHaveLength(1);
|
||||
expect(remaining[0].id).toBe("2");
|
||||
});
|
||||
});
|
||||
|
||||
describe("nodeCounter", () => {
|
||||
it("starts at zero", () => {
|
||||
expect(useNodeStore.getState().nodeCounter).toBe(0);
|
||||
});
|
||||
|
||||
it("increments the counter", () => {
|
||||
useNodeStore.getState().incrementNodeCounter();
|
||||
expect(useNodeStore.getState().nodeCounter).toBe(1);
|
||||
|
||||
useNodeStore.getState().incrementNodeCounter();
|
||||
expect(useNodeStore.getState().nodeCounter).toBe(2);
|
||||
});
|
||||
|
||||
it("sets the counter to a specific value", () => {
|
||||
useNodeStore.getState().setNodeCounter(42);
|
||||
expect(useNodeStore.getState().nodeCounter).toBe(42);
|
||||
});
|
||||
});
|
||||
|
||||
describe("advanced states", () => {
|
||||
it("defaults to false for unknown node IDs", () => {
|
||||
expect(useNodeStore.getState().getShowAdvanced("unknown")).toBe(false);
|
||||
});
|
||||
|
||||
it("toggles advanced state", () => {
|
||||
useNodeStore.getState().toggleAdvanced("node-1");
|
||||
expect(useNodeStore.getState().getShowAdvanced("node-1")).toBe(true);
|
||||
|
||||
useNodeStore.getState().toggleAdvanced("node-1");
|
||||
expect(useNodeStore.getState().getShowAdvanced("node-1")).toBe(false);
|
||||
});
|
||||
|
||||
it("sets advanced state explicitly", () => {
|
||||
useNodeStore.getState().setShowAdvanced("node-1", true);
|
||||
expect(useNodeStore.getState().getShowAdvanced("node-1")).toBe(true);
|
||||
|
||||
useNodeStore.getState().setShowAdvanced("node-1", false);
|
||||
expect(useNodeStore.getState().getShowAdvanced("node-1")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("convertCustomNodeToBackendNode", () => {
|
||||
it("converts a node with minimal data", () => {
|
||||
const node = createTestNode({
|
||||
id: "42",
|
||||
position: { x: 100, y: 200 },
|
||||
});
|
||||
|
||||
const backend = useNodeStore
|
||||
.getState()
|
||||
.convertCustomNodeToBackendNode(node);
|
||||
|
||||
expect(backend.id).toBe("42");
|
||||
expect(backend.block_id).toBe("test-block-id");
|
||||
expect(backend.input_default).toEqual({});
|
||||
expect(backend.metadata).toEqual({ position: { x: 100, y: 200 } });
|
||||
});
|
||||
|
||||
it("includes customized_name when present in metadata", () => {
|
||||
const node = createTestNode({
|
||||
id: "1",
|
||||
data: {
|
||||
metadata: { customized_name: "My Custom Name" },
|
||||
},
|
||||
});
|
||||
|
||||
const backend = useNodeStore
|
||||
.getState()
|
||||
.convertCustomNodeToBackendNode(node);
|
||||
|
||||
expect(backend.metadata).toHaveProperty(
|
||||
"customized_name",
|
||||
"My Custom Name",
|
||||
);
|
||||
});
|
||||
|
||||
it("includes credentials_optional when present in metadata", () => {
|
||||
const node = createTestNode({
|
||||
id: "1",
|
||||
data: {
|
||||
metadata: { credentials_optional: true },
|
||||
},
|
||||
});
|
||||
|
||||
const backend = useNodeStore
|
||||
.getState()
|
||||
.convertCustomNodeToBackendNode(node);
|
||||
|
||||
expect(backend.metadata).toHaveProperty("credentials_optional", true);
|
||||
});
|
||||
|
||||
it("prunes empty values from hardcodedValues", () => {
|
||||
const node = createTestNode({
|
||||
id: "1",
|
||||
data: {
|
||||
hardcodedValues: { filled: "value", empty: "" },
|
||||
},
|
||||
});
|
||||
|
||||
const backend = useNodeStore
|
||||
.getState()
|
||||
.convertCustomNodeToBackendNode(node);
|
||||
|
||||
expect(backend.input_default).toEqual({ filled: "value" });
|
||||
expect(backend.input_default).not.toHaveProperty("empty");
|
||||
});
|
||||
});
|
||||
|
||||
describe("getBackendNodes", () => {
|
||||
it("converts all nodes to backend format", () => {
|
||||
useNodeStore
|
||||
.getState()
|
||||
.setNodes([
|
||||
createTestNode({ id: "1", position: { x: 0, y: 0 } }),
|
||||
createTestNode({ id: "2", position: { x: 100, y: 100 } }),
|
||||
]);
|
||||
|
||||
const backendNodes = useNodeStore.getState().getBackendNodes();
|
||||
|
||||
expect(backendNodes).toHaveLength(2);
|
||||
expect(backendNodes[0].id).toBe("1");
|
||||
expect(backendNodes[1].id).toBe("2");
|
||||
});
|
||||
});
|
||||
|
||||
describe("node status", () => {
|
||||
it("returns undefined for a node with no status", () => {
|
||||
useNodeStore.getState().addNode(createTestNode({ id: "1" }));
|
||||
expect(useNodeStore.getState().getNodeStatus("1")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("updates node status", () => {
|
||||
useNodeStore.getState().addNode(createTestNode({ id: "1" }));
|
||||
|
||||
useNodeStore.getState().updateNodeStatus("1", "RUNNING");
|
||||
expect(useNodeStore.getState().getNodeStatus("1")).toBe("RUNNING");
|
||||
|
||||
useNodeStore.getState().updateNodeStatus("1", "COMPLETED");
|
||||
expect(useNodeStore.getState().getNodeStatus("1")).toBe("COMPLETED");
|
||||
});
|
||||
|
||||
it("cleans all node statuses", () => {
|
||||
useNodeStore
|
||||
.getState()
|
||||
.setNodes([createTestNode({ id: "1" }), createTestNode({ id: "2" })]);
|
||||
useNodeStore.getState().updateNodeStatus("1", "RUNNING");
|
||||
useNodeStore.getState().updateNodeStatus("2", "COMPLETED");
|
||||
|
||||
useNodeStore.getState().cleanNodesStatuses();
|
||||
|
||||
expect(useNodeStore.getState().getNodeStatus("1")).toBeUndefined();
|
||||
expect(useNodeStore.getState().getNodeStatus("2")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("updating status for non-existent node does not crash", () => {
|
||||
useNodeStore.getState().updateNodeStatus("nonexistent", "RUNNING");
|
||||
expect(
|
||||
useNodeStore.getState().getNodeStatus("nonexistent"),
|
||||
).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("execution result tracking", () => {
|
||||
it("returns empty array for node with no results", () => {
|
||||
useNodeStore.getState().addNode(createTestNode({ id: "1" }));
|
||||
expect(useNodeStore.getState().getNodeExecutionResults("1")).toEqual([]);
|
||||
});
|
||||
|
||||
it("tracks a single execution result", () => {
|
||||
useNodeStore.getState().addNode(createTestNode({ id: "1" }));
|
||||
const result = createExecutionResult({ node_id: "1" });
|
||||
|
||||
useNodeStore.getState().updateNodeExecutionResult("1", result);
|
||||
|
||||
const results = useNodeStore.getState().getNodeExecutionResults("1");
|
||||
expect(results).toHaveLength(1);
|
||||
expect(results[0].node_exec_id).toBe("exec-1");
|
||||
});
|
||||
|
||||
it("accumulates multiple execution results", () => {
|
||||
useNodeStore.getState().addNode(createTestNode({ id: "1" }));
|
||||
|
||||
useNodeStore.getState().updateNodeExecutionResult(
|
||||
"1",
|
||||
createExecutionResult({
|
||||
node_exec_id: "exec-1",
|
||||
input_data: { key: "val1" },
|
||||
output_data: { key: ["out1"] },
|
||||
}),
|
||||
);
|
||||
useNodeStore.getState().updateNodeExecutionResult(
|
||||
"1",
|
||||
createExecutionResult({
|
||||
node_exec_id: "exec-2",
|
||||
input_data: { key: "val2" },
|
||||
output_data: { key: ["out2"] },
|
||||
}),
|
||||
);
|
||||
|
||||
expect(useNodeStore.getState().getNodeExecutionResults("1")).toHaveLength(
|
||||
2,
|
||||
);
|
||||
});
|
||||
|
||||
it("updates latest input/output data", () => {
|
||||
useNodeStore.getState().addNode(createTestNode({ id: "1" }));
|
||||
|
||||
useNodeStore.getState().updateNodeExecutionResult(
|
||||
"1",
|
||||
createExecutionResult({
|
||||
node_exec_id: "exec-1",
|
||||
input_data: { key: "first" },
|
||||
output_data: { key: ["first_out"] },
|
||||
}),
|
||||
);
|
||||
useNodeStore.getState().updateNodeExecutionResult(
|
||||
"1",
|
||||
createExecutionResult({
|
||||
node_exec_id: "exec-2",
|
||||
input_data: { key: "second" },
|
||||
output_data: { key: ["second_out"] },
|
||||
}),
|
||||
);
|
||||
|
||||
expect(useNodeStore.getState().getLatestNodeInputData("1")).toEqual({
|
||||
key: "second",
|
||||
});
|
||||
expect(useNodeStore.getState().getLatestNodeOutputData("1")).toEqual({
|
||||
key: ["second_out"],
|
||||
});
|
||||
});
|
||||
|
||||
it("accumulates input/output data across results", () => {
|
||||
useNodeStore.getState().addNode(createTestNode({ id: "1" }));
|
||||
|
||||
useNodeStore.getState().updateNodeExecutionResult(
|
||||
"1",
|
||||
createExecutionResult({
|
||||
node_exec_id: "exec-1",
|
||||
input_data: { key: "val1" },
|
||||
output_data: { key: ["out1"] },
|
||||
}),
|
||||
);
|
||||
useNodeStore.getState().updateNodeExecutionResult(
|
||||
"1",
|
||||
createExecutionResult({
|
||||
node_exec_id: "exec-2",
|
||||
input_data: { key: "val2" },
|
||||
output_data: { key: ["out2"] },
|
||||
}),
|
||||
);
|
||||
|
||||
const accInput = useNodeStore.getState().getAccumulatedNodeInputData("1");
|
||||
expect(accInput.key).toEqual(["val1", "val2"]);
|
||||
|
||||
const accOutput = useNodeStore
|
||||
.getState()
|
||||
.getAccumulatedNodeOutputData("1");
|
||||
expect(accOutput.key).toEqual(["out1", "out2"]);
|
||||
});
|
||||
|
||||
it("deduplicates execution results by node_exec_id", () => {
|
||||
useNodeStore.getState().addNode(createTestNode({ id: "1" }));
|
||||
|
||||
useNodeStore.getState().updateNodeExecutionResult(
|
||||
"1",
|
||||
createExecutionResult({
|
||||
node_exec_id: "exec-1",
|
||||
input_data: { key: "original" },
|
||||
output_data: { key: ["original_out"] },
|
||||
}),
|
||||
);
|
||||
useNodeStore.getState().updateNodeExecutionResult(
|
||||
"1",
|
||||
createExecutionResult({
|
||||
node_exec_id: "exec-1",
|
||||
input_data: { key: "updated" },
|
||||
output_data: { key: ["updated_out"] },
|
||||
}),
|
||||
);
|
||||
|
||||
const results = useNodeStore.getState().getNodeExecutionResults("1");
|
||||
expect(results).toHaveLength(1);
|
||||
expect(results[0].input_data).toEqual({ key: "updated" });
|
||||
});
|
||||
|
||||
it("returns the latest execution result", () => {
|
||||
useNodeStore.getState().addNode(createTestNode({ id: "1" }));
|
||||
|
||||
useNodeStore
|
||||
.getState()
|
||||
.updateNodeExecutionResult(
|
||||
"1",
|
||||
createExecutionResult({ node_exec_id: "exec-1" }),
|
||||
);
|
||||
useNodeStore
|
||||
.getState()
|
||||
.updateNodeExecutionResult(
|
||||
"1",
|
||||
createExecutionResult({ node_exec_id: "exec-2" }),
|
||||
);
|
||||
|
||||
const latest = useNodeStore.getState().getLatestNodeExecutionResult("1");
|
||||
expect(latest?.node_exec_id).toBe("exec-2");
|
||||
});
|
||||
|
||||
it("returns undefined for latest result on unknown node", () => {
|
||||
expect(
|
||||
useNodeStore.getState().getLatestNodeExecutionResult("unknown"),
|
||||
).toBeUndefined();
|
||||
});
|
||||
|
||||
it("clears all execution results", () => {
|
||||
useNodeStore
|
||||
.getState()
|
||||
.setNodes([createTestNode({ id: "1" }), createTestNode({ id: "2" })]);
|
||||
useNodeStore
|
||||
.getState()
|
||||
.updateNodeExecutionResult(
|
||||
"1",
|
||||
createExecutionResult({ node_exec_id: "exec-1" }),
|
||||
);
|
||||
useNodeStore
|
||||
.getState()
|
||||
.updateNodeExecutionResult(
|
||||
"2",
|
||||
createExecutionResult({ node_exec_id: "exec-2" }),
|
||||
);
|
||||
|
||||
useNodeStore.getState().clearAllNodeExecutionResults();
|
||||
|
||||
expect(useNodeStore.getState().getNodeExecutionResults("1")).toEqual([]);
|
||||
expect(useNodeStore.getState().getNodeExecutionResults("2")).toEqual([]);
|
||||
expect(
|
||||
useNodeStore.getState().getLatestNodeInputData("1"),
|
||||
).toBeUndefined();
|
||||
expect(
|
||||
useNodeStore.getState().getLatestNodeOutputData("1"),
|
||||
).toBeUndefined();
|
||||
expect(useNodeStore.getState().getAccumulatedNodeInputData("1")).toEqual(
|
||||
{},
|
||||
);
|
||||
expect(useNodeStore.getState().getAccumulatedNodeOutputData("1")).toEqual(
|
||||
{},
|
||||
);
|
||||
});
|
||||
|
||||
it("returns empty object for accumulated data on unknown node", () => {
|
||||
expect(
|
||||
useNodeStore.getState().getAccumulatedNodeInputData("unknown"),
|
||||
).toEqual({});
|
||||
expect(
|
||||
useNodeStore.getState().getAccumulatedNodeOutputData("unknown"),
|
||||
).toEqual({});
|
||||
});
|
||||
});
|
||||
|
||||
describe("getNodeBlockUIType", () => {
|
||||
it("returns the node UI type", () => {
|
||||
useNodeStore.getState().addNode(
|
||||
createTestNode({
|
||||
id: "1",
|
||||
data: {
|
||||
uiType: BlockUIType.INPUT,
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
expect(useNodeStore.getState().getNodeBlockUIType("1")).toBe(
|
||||
BlockUIType.INPUT,
|
||||
);
|
||||
});
|
||||
|
||||
it("defaults to STANDARD for unknown node IDs", () => {
|
||||
expect(useNodeStore.getState().getNodeBlockUIType("unknown")).toBe(
|
||||
BlockUIType.STANDARD,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("hasWebhookNodes", () => {
|
||||
it("returns false when there are no webhook nodes", () => {
|
||||
useNodeStore.getState().addNode(createTestNode({ id: "1" }));
|
||||
expect(useNodeStore.getState().hasWebhookNodes()).toBe(false);
|
||||
});
|
||||
|
||||
it("returns true when a WEBHOOK node exists", () => {
|
||||
useNodeStore.getState().addNode(
|
||||
createTestNode({
|
||||
id: "1",
|
||||
data: {
|
||||
uiType: BlockUIType.WEBHOOK,
|
||||
},
|
||||
}),
|
||||
);
|
||||
expect(useNodeStore.getState().hasWebhookNodes()).toBe(true);
|
||||
});
|
||||
|
||||
it("returns true when a WEBHOOK_MANUAL node exists", () => {
|
||||
useNodeStore.getState().addNode(
|
||||
createTestNode({
|
||||
id: "1",
|
||||
data: {
|
||||
uiType: BlockUIType.WEBHOOK_MANUAL,
|
||||
},
|
||||
}),
|
||||
);
|
||||
expect(useNodeStore.getState().hasWebhookNodes()).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("node errors", () => {
|
||||
it("returns undefined for a node with no errors", () => {
|
||||
useNodeStore.getState().addNode(createTestNode({ id: "1" }));
|
||||
expect(useNodeStore.getState().getNodeErrors("1")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("sets and retrieves node errors", () => {
|
||||
useNodeStore.getState().addNode(createTestNode({ id: "1" }));
|
||||
|
||||
const errors = { field1: "required", field2: "invalid" };
|
||||
useNodeStore.getState().updateNodeErrors("1", errors);
|
||||
|
||||
expect(useNodeStore.getState().getNodeErrors("1")).toEqual(errors);
|
||||
});
|
||||
|
||||
it("clears errors for a specific node", () => {
|
||||
useNodeStore
|
||||
.getState()
|
||||
.setNodes([createTestNode({ id: "1" }), createTestNode({ id: "2" })]);
|
||||
useNodeStore.getState().updateNodeErrors("1", { f: "err" });
|
||||
useNodeStore.getState().updateNodeErrors("2", { g: "err2" });
|
||||
|
||||
useNodeStore.getState().clearNodeErrors("1");
|
||||
|
||||
expect(useNodeStore.getState().getNodeErrors("1")).toBeUndefined();
|
||||
expect(useNodeStore.getState().getNodeErrors("2")).toEqual({ g: "err2" });
|
||||
});
|
||||
|
||||
it("clears all node errors", () => {
|
||||
useNodeStore
|
||||
.getState()
|
||||
.setNodes([createTestNode({ id: "1" }), createTestNode({ id: "2" })]);
|
||||
useNodeStore.getState().updateNodeErrors("1", { a: "err1" });
|
||||
useNodeStore.getState().updateNodeErrors("2", { b: "err2" });
|
||||
|
||||
useNodeStore.getState().clearAllNodeErrors();
|
||||
|
||||
expect(useNodeStore.getState().getNodeErrors("1")).toBeUndefined();
|
||||
expect(useNodeStore.getState().getNodeErrors("2")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("sets errors by backend ID matching node id", () => {
|
||||
useNodeStore.getState().addNode(createTestNode({ id: "backend-1" }));
|
||||
|
||||
useNodeStore
|
||||
.getState()
|
||||
.setNodeErrorsForBackendId("backend-1", { x: "error" });
|
||||
|
||||
expect(useNodeStore.getState().getNodeErrors("backend-1")).toEqual({
|
||||
x: "error",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("getHardCodedValues", () => {
|
||||
it("returns hardcoded values for a node", () => {
|
||||
useNodeStore.getState().addNode(
|
||||
createTestNode({
|
||||
id: "1",
|
||||
data: {
|
||||
hardcodedValues: { key: "value" },
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
expect(useNodeStore.getState().getHardCodedValues("1")).toEqual({
|
||||
key: "value",
|
||||
});
|
||||
});
|
||||
|
||||
it("returns empty object for unknown node", () => {
|
||||
expect(useNodeStore.getState().getHardCodedValues("unknown")).toEqual({});
|
||||
});
|
||||
});
|
||||
|
||||
describe("credentials optional", () => {
|
||||
it("sets credentials_optional in node metadata", () => {
|
||||
useNodeStore.getState().addNode(createTestNode({ id: "1" }));
|
||||
|
||||
useNodeStore.getState().setCredentialsOptional("1", true);
|
||||
|
||||
const node = useNodeStore.getState().nodes[0];
|
||||
expect(node.data.metadata?.credentials_optional).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolution mode", () => {
|
||||
it("defaults to not in resolution mode", () => {
|
||||
expect(useNodeStore.getState().isNodeInResolutionMode("1")).toBe(false);
|
||||
});
|
||||
|
||||
it("enters and exits resolution mode", () => {
|
||||
useNodeStore.getState().setNodeResolutionMode("1", true);
|
||||
expect(useNodeStore.getState().isNodeInResolutionMode("1")).toBe(true);
|
||||
|
||||
useNodeStore.getState().setNodeResolutionMode("1", false);
|
||||
expect(useNodeStore.getState().isNodeInResolutionMode("1")).toBe(false);
|
||||
});
|
||||
|
||||
it("tracks broken edge IDs", () => {
|
||||
useNodeStore.getState().setBrokenEdgeIDs("node-1", ["edge-1", "edge-2"]);
|
||||
|
||||
expect(useNodeStore.getState().isEdgeBroken("edge-1")).toBe(true);
|
||||
expect(useNodeStore.getState().isEdgeBroken("edge-2")).toBe(true);
|
||||
expect(useNodeStore.getState().isEdgeBroken("edge-3")).toBe(false);
|
||||
});
|
||||
|
||||
it("removes individual broken edge IDs", () => {
|
||||
useNodeStore.getState().setBrokenEdgeIDs("node-1", ["edge-1", "edge-2"]);
|
||||
useNodeStore.getState().removeBrokenEdgeID("node-1", "edge-1");
|
||||
|
||||
expect(useNodeStore.getState().isEdgeBroken("edge-1")).toBe(false);
|
||||
expect(useNodeStore.getState().isEdgeBroken("edge-2")).toBe(true);
|
||||
});
|
||||
|
||||
it("clears all resolution state", () => {
|
||||
useNodeStore.getState().setNodeResolutionMode("1", true);
|
||||
useNodeStore.getState().setBrokenEdgeIDs("1", ["edge-1"]);
|
||||
|
||||
useNodeStore.getState().clearResolutionState();
|
||||
|
||||
expect(useNodeStore.getState().isNodeInResolutionMode("1")).toBe(false);
|
||||
expect(useNodeStore.getState().isEdgeBroken("edge-1")).toBe(false);
|
||||
});
|
||||
|
||||
it("cleans up broken edges when exiting resolution mode", () => {
|
||||
useNodeStore.getState().setNodeResolutionMode("1", true);
|
||||
useNodeStore.getState().setBrokenEdgeIDs("1", ["edge-1"]);
|
||||
|
||||
useNodeStore.getState().setNodeResolutionMode("1", false);
|
||||
|
||||
expect(useNodeStore.getState().isEdgeBroken("edge-1")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("edge cases", () => {
|
||||
it("handles updating data on a non-existent node gracefully", () => {
|
||||
useNodeStore
|
||||
.getState()
|
||||
.updateNodeData("nonexistent", { title: "New Title" });
|
||||
|
||||
expect(useNodeStore.getState().nodes).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("handles removing a non-existent node gracefully", () => {
|
||||
useNodeStore.getState().addNode(createTestNode({ id: "1" }));
|
||||
|
||||
useNodeStore
|
||||
.getState()
|
||||
.onNodesChange([{ type: "remove", id: "nonexistent" }]);
|
||||
|
||||
expect(useNodeStore.getState().nodes).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("handles duplicate node IDs in addNodes", () => {
|
||||
useNodeStore.getState().addNodes([
|
||||
createTestNode({
|
||||
id: "1",
|
||||
data: { title: "First" },
|
||||
}),
|
||||
createTestNode({
|
||||
id: "1",
|
||||
data: { title: "Second" },
|
||||
}),
|
||||
]);
|
||||
|
||||
const { nodes } = useNodeStore.getState();
|
||||
expect(nodes).toHaveLength(2);
|
||||
expect(nodes[0].data.title).toBe("First");
|
||||
expect(nodes[1].data.title).toBe("Second");
|
||||
});
|
||||
|
||||
it("updating node status mid-execution preserves other data", () => {
|
||||
useNodeStore.getState().addNode(
|
||||
createTestNode({
|
||||
id: "1",
|
||||
data: {
|
||||
title: "My Node",
|
||||
hardcodedValues: { key: "val" },
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
useNodeStore.getState().updateNodeStatus("1", "RUNNING");
|
||||
|
||||
const node = useNodeStore.getState().nodes[0];
|
||||
expect(node.data.status).toBe("RUNNING");
|
||||
expect(node.data.title).toBe("My Node");
|
||||
expect(node.data.hardcodedValues).toEqual({ key: "val" });
|
||||
});
|
||||
|
||||
it("execution result for non-existent node does not add it", () => {
|
||||
useNodeStore
|
||||
.getState()
|
||||
.updateNodeExecutionResult(
|
||||
"nonexistent",
|
||||
createExecutionResult({ node_exec_id: "exec-1" }),
|
||||
);
|
||||
|
||||
expect(useNodeStore.getState().nodes).toHaveLength(0);
|
||||
expect(
|
||||
useNodeStore.getState().getNodeExecutionResults("nonexistent"),
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
it("getBackendNodes returns empty array when no nodes exist", () => {
|
||||
expect(useNodeStore.getState().getBackendNodes()).toEqual([]);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,567 @@
|
||||
import { describe, it, expect, beforeEach, vi } from "vitest";
|
||||
import { renderHook, act } from "@testing-library/react";
|
||||
import { CustomNode } from "../components/FlowEditor/nodes/CustomNode/CustomNode";
|
||||
import { BlockUIType } from "../components/types";
|
||||
|
||||
// ---- Mocks ----
|
||||
|
||||
const mockGetViewport = vi.fn(() => ({ x: 0, y: 0, zoom: 1 }));
|
||||
|
||||
vi.mock("@xyflow/react", async () => {
|
||||
const actual = await vi.importActual("@xyflow/react");
|
||||
return {
|
||||
...actual,
|
||||
useReactFlow: vi.fn(() => ({
|
||||
getViewport: mockGetViewport,
|
||||
})),
|
||||
};
|
||||
});
|
||||
|
||||
const mockToast = vi.fn();
|
||||
|
||||
vi.mock("@/components/molecules/Toast/use-toast", () => ({
|
||||
useToast: vi.fn(() => ({ toast: mockToast })),
|
||||
}));
|
||||
|
||||
let uuidCounter = 0;
|
||||
vi.mock("uuid", () => ({
|
||||
v4: vi.fn(() => `new-uuid-${++uuidCounter}`),
|
||||
}));
|
||||
|
||||
// Mock navigator.clipboard
|
||||
const mockWriteText = vi.fn(() => Promise.resolve());
|
||||
const mockReadText = vi.fn(() => Promise.resolve(""));
|
||||
|
||||
Object.defineProperty(navigator, "clipboard", {
|
||||
value: {
|
||||
writeText: mockWriteText,
|
||||
readText: mockReadText,
|
||||
},
|
||||
writable: true,
|
||||
configurable: true,
|
||||
});
|
||||
|
||||
// Mock window.innerWidth / innerHeight for viewport centering calculations
|
||||
Object.defineProperty(window, "innerWidth", { value: 1000, writable: true });
|
||||
Object.defineProperty(window, "innerHeight", { value: 800, writable: true });
|
||||
|
||||
import { useCopyPaste } from "../components/FlowEditor/Flow/useCopyPaste";
|
||||
import { useNodeStore } from "../stores/nodeStore";
|
||||
import { useEdgeStore } from "../stores/edgeStore";
|
||||
import { useHistoryStore } from "../stores/historyStore";
|
||||
import { CustomEdge } from "../components/FlowEditor/edges/CustomEdge";
|
||||
|
||||
const CLIPBOARD_PREFIX = "autogpt-flow-data:";
|
||||
|
||||
function createTestNode(
|
||||
id: string,
|
||||
overrides: Partial<CustomNode> = {},
|
||||
): CustomNode {
|
||||
return {
|
||||
id,
|
||||
type: "custom",
|
||||
position: overrides.position ?? { x: 100, y: 200 },
|
||||
selected: overrides.selected,
|
||||
data: {
|
||||
hardcodedValues: {},
|
||||
title: `Node ${id}`,
|
||||
description: "test node",
|
||||
inputSchema: {},
|
||||
outputSchema: {},
|
||||
uiType: BlockUIType.STANDARD,
|
||||
block_id: `block-${id}`,
|
||||
costs: [],
|
||||
categories: [],
|
||||
...overrides.data,
|
||||
},
|
||||
} as CustomNode;
|
||||
}
|
||||
|
||||
function createTestEdge(
|
||||
id: string,
|
||||
source: string,
|
||||
target: string,
|
||||
sourceHandle = "out",
|
||||
targetHandle = "in",
|
||||
): CustomEdge {
|
||||
return {
|
||||
id,
|
||||
source,
|
||||
target,
|
||||
sourceHandle,
|
||||
targetHandle,
|
||||
} as CustomEdge;
|
||||
}
|
||||
|
||||
function makeCopyEvent(): KeyboardEvent {
|
||||
return new KeyboardEvent("keydown", {
|
||||
key: "c",
|
||||
ctrlKey: true,
|
||||
bubbles: true,
|
||||
});
|
||||
}
|
||||
|
||||
function makePasteEvent(): KeyboardEvent {
|
||||
return new KeyboardEvent("keydown", {
|
||||
key: "v",
|
||||
ctrlKey: true,
|
||||
bubbles: true,
|
||||
});
|
||||
}
|
||||
|
||||
function clipboardPayload(nodes: CustomNode[], edges: CustomEdge[]): string {
|
||||
return `${CLIPBOARD_PREFIX}${JSON.stringify({ nodes, edges })}`;
|
||||
}
|
||||
|
||||
describe("useCopyPaste", () => {
|
||||
beforeEach(() => {
|
||||
useNodeStore.setState({ nodes: [], nodeCounter: 0 });
|
||||
useEdgeStore.setState({ edges: [] });
|
||||
useHistoryStore.getState().clear();
|
||||
mockWriteText.mockClear();
|
||||
mockReadText.mockClear();
|
||||
mockToast.mockClear();
|
||||
mockGetViewport.mockReturnValue({ x: 0, y: 0, zoom: 1 });
|
||||
uuidCounter = 0;
|
||||
|
||||
// Ensure no input element is focused
|
||||
if (document.activeElement && document.activeElement !== document.body) {
|
||||
(document.activeElement as HTMLElement).blur();
|
||||
}
|
||||
});
|
||||
|
||||
describe("copy (Ctrl+C)", () => {
|
||||
it("copies a single selected node to clipboard with prefix", async () => {
|
||||
const node = createTestNode("1", { selected: true });
|
||||
useNodeStore.setState({ nodes: [node] });
|
||||
|
||||
const { result } = renderHook(() => useCopyPaste());
|
||||
|
||||
act(() => {
|
||||
result.current(makeCopyEvent());
|
||||
});
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(mockWriteText).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
const written = (mockWriteText.mock.calls as string[][])[0][0];
|
||||
expect(written.startsWith(CLIPBOARD_PREFIX)).toBe(true);
|
||||
|
||||
const parsed = JSON.parse(written.slice(CLIPBOARD_PREFIX.length));
|
||||
expect(parsed.nodes).toHaveLength(1);
|
||||
expect(parsed.nodes[0].id).toBe("1");
|
||||
expect(parsed.edges).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("shows a success toast after copying", async () => {
|
||||
const node = createTestNode("1", { selected: true });
|
||||
useNodeStore.setState({ nodes: [node] });
|
||||
|
||||
const { result } = renderHook(() => useCopyPaste());
|
||||
|
||||
act(() => {
|
||||
result.current(makeCopyEvent());
|
||||
});
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(mockToast).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
title: "Copied successfully",
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it("copies multiple connected nodes and preserves internal edges", async () => {
|
||||
const nodeA = createTestNode("a", { selected: true });
|
||||
const nodeB = createTestNode("b", { selected: true });
|
||||
const nodeC = createTestNode("c", { selected: false });
|
||||
useNodeStore.setState({ nodes: [nodeA, nodeB, nodeC] });
|
||||
|
||||
useEdgeStore.setState({
|
||||
edges: [
|
||||
createTestEdge("e-ab", "a", "b"),
|
||||
createTestEdge("e-bc", "b", "c"),
|
||||
],
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useCopyPaste());
|
||||
|
||||
act(() => {
|
||||
result.current(makeCopyEvent());
|
||||
});
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(mockWriteText).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
const parsed = JSON.parse(
|
||||
(mockWriteText.mock.calls as string[][])[0][0].slice(
|
||||
CLIPBOARD_PREFIX.length,
|
||||
),
|
||||
);
|
||||
expect(parsed.nodes).toHaveLength(2);
|
||||
expect(parsed.edges).toHaveLength(1);
|
||||
expect(parsed.edges[0].id).toBe("e-ab");
|
||||
});
|
||||
|
||||
it("drops external edges where one endpoint is not selected", async () => {
|
||||
const nodeA = createTestNode("a", { selected: true });
|
||||
const nodeB = createTestNode("b", { selected: false });
|
||||
useNodeStore.setState({ nodes: [nodeA, nodeB] });
|
||||
|
||||
useEdgeStore.setState({
|
||||
edges: [createTestEdge("e-ab", "a", "b")],
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useCopyPaste());
|
||||
|
||||
act(() => {
|
||||
result.current(makeCopyEvent());
|
||||
});
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(mockWriteText).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
const parsed = JSON.parse(
|
||||
(mockWriteText.mock.calls as string[][])[0][0].slice(
|
||||
CLIPBOARD_PREFIX.length,
|
||||
),
|
||||
);
|
||||
expect(parsed.nodes).toHaveLength(1);
|
||||
expect(parsed.edges).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("copies nothing when no nodes are selected", async () => {
|
||||
const node = createTestNode("1", { selected: false });
|
||||
useNodeStore.setState({ nodes: [node] });
|
||||
|
||||
const { result } = renderHook(() => useCopyPaste());
|
||||
|
||||
act(() => {
|
||||
result.current(makeCopyEvent());
|
||||
});
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(mockWriteText).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
const parsed = JSON.parse(
|
||||
(mockWriteText.mock.calls as string[][])[0][0].slice(
|
||||
CLIPBOARD_PREFIX.length,
|
||||
),
|
||||
);
|
||||
expect(parsed.nodes).toHaveLength(0);
|
||||
expect(parsed.edges).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("paste (Ctrl+V)", () => {
|
||||
it("creates new nodes with new UUIDs", async () => {
|
||||
const node = createTestNode("orig", {
|
||||
selected: true,
|
||||
position: { x: 100, y: 200 },
|
||||
});
|
||||
|
||||
mockReadText.mockResolvedValue(clipboardPayload([node], []));
|
||||
|
||||
useNodeStore.setState({ nodes: [], nodeCounter: 0 });
|
||||
|
||||
const { result } = renderHook(() => useCopyPaste());
|
||||
|
||||
act(() => {
|
||||
result.current(makePasteEvent());
|
||||
});
|
||||
|
||||
await vi.waitFor(() => {
|
||||
const { nodes } = useNodeStore.getState();
|
||||
expect(nodes).toHaveLength(1);
|
||||
});
|
||||
|
||||
const { nodes } = useNodeStore.getState();
|
||||
expect(nodes[0].id).toBe("new-uuid-1");
|
||||
expect(nodes[0].id).not.toBe("orig");
|
||||
});
|
||||
|
||||
it("centers pasted nodes in the current viewport", async () => {
|
||||
// Viewport at origin, zoom 1 => center = (500, 400)
|
||||
mockGetViewport.mockReturnValue({ x: 0, y: 0, zoom: 1 });
|
||||
|
||||
const node = createTestNode("orig", {
|
||||
selected: true,
|
||||
position: { x: 100, y: 100 },
|
||||
});
|
||||
|
||||
mockReadText.mockResolvedValue(clipboardPayload([node], []));
|
||||
|
||||
useNodeStore.setState({ nodes: [], nodeCounter: 0 });
|
||||
|
||||
const { result } = renderHook(() => useCopyPaste());
|
||||
|
||||
act(() => {
|
||||
result.current(makePasteEvent());
|
||||
});
|
||||
|
||||
await vi.waitFor(() => {
|
||||
const { nodes } = useNodeStore.getState();
|
||||
expect(nodes).toHaveLength(1);
|
||||
});
|
||||
|
||||
const { nodes } = useNodeStore.getState();
|
||||
// Single node: center of bounds = (100, 100)
|
||||
// Viewport center = (500, 400)
|
||||
// Offset = (400, 300)
|
||||
// New position = (100 + 400, 100 + 300) = (500, 400)
|
||||
expect(nodes[0].position).toEqual({ x: 500, y: 400 });
|
||||
});
|
||||
|
||||
it("deselects existing nodes and selects pasted nodes", async () => {
|
||||
const existingNode = createTestNode("existing", {
|
||||
selected: true,
|
||||
position: { x: 0, y: 0 },
|
||||
});
|
||||
|
||||
useNodeStore.setState({ nodes: [existingNode], nodeCounter: 0 });
|
||||
|
||||
const nodeToPaste = createTestNode("paste-me", {
|
||||
selected: false,
|
||||
position: { x: 100, y: 100 },
|
||||
});
|
||||
|
||||
mockReadText.mockResolvedValue(clipboardPayload([nodeToPaste], []));
|
||||
|
||||
const { result } = renderHook(() => useCopyPaste());
|
||||
|
||||
act(() => {
|
||||
result.current(makePasteEvent());
|
||||
});
|
||||
|
||||
await vi.waitFor(() => {
|
||||
const { nodes } = useNodeStore.getState();
|
||||
expect(nodes).toHaveLength(2);
|
||||
});
|
||||
|
||||
const { nodes } = useNodeStore.getState();
|
||||
const originalNode = nodes.find((n) => n.id === "existing");
|
||||
const pastedNode = nodes.find((n) => n.id !== "existing");
|
||||
|
||||
expect(originalNode!.selected).toBe(false);
|
||||
expect(pastedNode!.selected).toBe(true);
|
||||
});
|
||||
|
||||
it("remaps edge source/target IDs to newly created node IDs", async () => {
|
||||
const nodeA = createTestNode("a", {
|
||||
selected: true,
|
||||
position: { x: 0, y: 0 },
|
||||
});
|
||||
const nodeB = createTestNode("b", {
|
||||
selected: true,
|
||||
position: { x: 200, y: 0 },
|
||||
});
|
||||
const edge = createTestEdge("e-ab", "a", "b", "output", "input");
|
||||
|
||||
mockReadText.mockResolvedValue(clipboardPayload([nodeA, nodeB], [edge]));
|
||||
|
||||
useNodeStore.setState({ nodes: [], nodeCounter: 0 });
|
||||
useEdgeStore.setState({ edges: [] });
|
||||
|
||||
const { result } = renderHook(() => useCopyPaste());
|
||||
|
||||
act(() => {
|
||||
result.current(makePasteEvent());
|
||||
});
|
||||
|
||||
await vi.waitFor(() => {
|
||||
const { nodes } = useNodeStore.getState();
|
||||
expect(nodes).toHaveLength(2);
|
||||
});
|
||||
|
||||
// Wait for edges to be added too
|
||||
await vi.waitFor(() => {
|
||||
const { edges } = useEdgeStore.getState();
|
||||
expect(edges).toHaveLength(1);
|
||||
});
|
||||
|
||||
const { edges } = useEdgeStore.getState();
|
||||
const newEdge = edges[0];
|
||||
|
||||
// Edge source/target should be remapped to new UUIDs, not "a"/"b"
|
||||
expect(newEdge.source).not.toBe("a");
|
||||
expect(newEdge.target).not.toBe("b");
|
||||
expect(newEdge.source).toBe("new-uuid-1");
|
||||
expect(newEdge.target).toBe("new-uuid-2");
|
||||
expect(newEdge.sourceHandle).toBe("output");
|
||||
expect(newEdge.targetHandle).toBe("input");
|
||||
});
|
||||
|
||||
it("does nothing when clipboard does not have the expected prefix", async () => {
|
||||
mockReadText.mockResolvedValue("some random text");
|
||||
|
||||
const existingNode = createTestNode("1", { position: { x: 0, y: 0 } });
|
||||
useNodeStore.setState({ nodes: [existingNode], nodeCounter: 0 });
|
||||
|
||||
const { result } = renderHook(() => useCopyPaste());
|
||||
|
||||
act(() => {
|
||||
result.current(makePasteEvent());
|
||||
});
|
||||
|
||||
// Give async operations time to settle
|
||||
await vi.waitFor(() => {
|
||||
expect(mockReadText).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// Ensure no state changes happen after clipboard read
|
||||
await vi.waitFor(() => {
|
||||
const { nodes } = useNodeStore.getState();
|
||||
expect(nodes).toHaveLength(1);
|
||||
expect(nodes[0].id).toBe("1");
|
||||
});
|
||||
});
|
||||
|
||||
it("does nothing when clipboard is empty", async () => {
|
||||
mockReadText.mockResolvedValue("");
|
||||
|
||||
const existingNode = createTestNode("1", { position: { x: 0, y: 0 } });
|
||||
useNodeStore.setState({ nodes: [existingNode], nodeCounter: 0 });
|
||||
|
||||
const { result } = renderHook(() => useCopyPaste());
|
||||
|
||||
act(() => {
|
||||
result.current(makePasteEvent());
|
||||
});
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(mockReadText).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// Ensure no state changes happen after clipboard read
|
||||
await vi.waitFor(() => {
|
||||
const { nodes } = useNodeStore.getState();
|
||||
expect(nodes).toHaveLength(1);
|
||||
expect(nodes[0].id).toBe("1");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("input field focus guard", () => {
|
||||
it("ignores Ctrl+C when an input element is focused", async () => {
|
||||
const node = createTestNode("1", { selected: true });
|
||||
useNodeStore.setState({ nodes: [node] });
|
||||
|
||||
const input = document.createElement("input");
|
||||
document.body.appendChild(input);
|
||||
input.focus();
|
||||
|
||||
const { result } = renderHook(() => useCopyPaste());
|
||||
|
||||
act(() => {
|
||||
result.current(makeCopyEvent());
|
||||
});
|
||||
|
||||
// Clipboard write should NOT be called
|
||||
expect(mockWriteText).not.toHaveBeenCalled();
|
||||
|
||||
document.body.removeChild(input);
|
||||
});
|
||||
|
||||
it("ignores Ctrl+V when a textarea element is focused", async () => {
|
||||
mockReadText.mockResolvedValue(
|
||||
clipboardPayload(
|
||||
[createTestNode("a", { position: { x: 0, y: 0 } })],
|
||||
[],
|
||||
),
|
||||
);
|
||||
|
||||
useNodeStore.setState({ nodes: [], nodeCounter: 0 });
|
||||
|
||||
const textarea = document.createElement("textarea");
|
||||
document.body.appendChild(textarea);
|
||||
textarea.focus();
|
||||
|
||||
const { result } = renderHook(() => useCopyPaste());
|
||||
|
||||
act(() => {
|
||||
result.current(makePasteEvent());
|
||||
});
|
||||
|
||||
expect(mockReadText).not.toHaveBeenCalled();
|
||||
|
||||
const { nodes } = useNodeStore.getState();
|
||||
expect(nodes).toHaveLength(0);
|
||||
|
||||
document.body.removeChild(textarea);
|
||||
});
|
||||
|
||||
it("ignores keypresses when a contenteditable element is focused", async () => {
|
||||
const node = createTestNode("1", { selected: true });
|
||||
useNodeStore.setState({ nodes: [node] });
|
||||
|
||||
const div = document.createElement("div");
|
||||
div.setAttribute("contenteditable", "true");
|
||||
document.body.appendChild(div);
|
||||
div.focus();
|
||||
|
||||
const { result } = renderHook(() => useCopyPaste());
|
||||
|
||||
act(() => {
|
||||
result.current(makeCopyEvent());
|
||||
});
|
||||
|
||||
expect(mockWriteText).not.toHaveBeenCalled();
|
||||
|
||||
document.body.removeChild(div);
|
||||
});
|
||||
});
|
||||
|
||||
describe("meta key support (macOS)", () => {
|
||||
it("handles Cmd+C (metaKey) the same as Ctrl+C", async () => {
|
||||
const node = createTestNode("1", { selected: true });
|
||||
useNodeStore.setState({ nodes: [node] });
|
||||
|
||||
const { result } = renderHook(() => useCopyPaste());
|
||||
|
||||
const metaCopyEvent = new KeyboardEvent("keydown", {
|
||||
key: "c",
|
||||
metaKey: true,
|
||||
bubbles: true,
|
||||
});
|
||||
|
||||
act(() => {
|
||||
result.current(metaCopyEvent);
|
||||
});
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(mockWriteText).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
it("handles Cmd+V (metaKey) the same as Ctrl+V", async () => {
|
||||
const node = createTestNode("orig", {
|
||||
selected: true,
|
||||
position: { x: 0, y: 0 },
|
||||
});
|
||||
mockReadText.mockResolvedValue(clipboardPayload([node], []));
|
||||
useNodeStore.setState({ nodes: [], nodeCounter: 0 });
|
||||
|
||||
const { result } = renderHook(() => useCopyPaste());
|
||||
|
||||
const metaPasteEvent = new KeyboardEvent("keydown", {
|
||||
key: "v",
|
||||
metaKey: true,
|
||||
bubbles: true,
|
||||
});
|
||||
|
||||
act(() => {
|
||||
result.current(metaPasteEvent);
|
||||
});
|
||||
|
||||
await vi.waitFor(() => {
|
||||
const { nodes } = useNodeStore.getState();
|
||||
expect(nodes).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,134 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { renderHook, act } from "@testing-library/react";
|
||||
|
||||
const mockScreenToFlowPosition = vi.fn((pos: { x: number; y: number }) => pos);
|
||||
const mockFitView = vi.fn();
|
||||
|
||||
vi.mock("@xyflow/react", async () => {
|
||||
const actual = await vi.importActual("@xyflow/react");
|
||||
return {
|
||||
...actual,
|
||||
useReactFlow: () => ({
|
||||
screenToFlowPosition: mockScreenToFlowPosition,
|
||||
fitView: mockFitView,
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
const mockSetQueryStates = vi.fn();
|
||||
let mockQueryStateValues: {
|
||||
flowID: string | null;
|
||||
flowVersion: number | null;
|
||||
flowExecutionID: string | null;
|
||||
} = {
|
||||
flowID: null,
|
||||
flowVersion: null,
|
||||
flowExecutionID: null,
|
||||
};
|
||||
|
||||
vi.mock("nuqs", () => ({
|
||||
parseAsString: {},
|
||||
parseAsInteger: {},
|
||||
useQueryStates: vi.fn(() => [mockQueryStateValues, mockSetQueryStates]),
|
||||
}));
|
||||
|
||||
let mockGraphLoading = false;
|
||||
let mockBlocksLoading = false;
|
||||
|
||||
vi.mock("@/app/api/__generated__/endpoints/graphs/graphs", () => ({
|
||||
useGetV1GetSpecificGraph: vi.fn(() => ({
|
||||
data: undefined,
|
||||
isLoading: mockGraphLoading,
|
||||
})),
|
||||
useGetV1GetExecutionDetails: vi.fn(() => ({
|
||||
data: undefined,
|
||||
})),
|
||||
useGetV1ListUserGraphs: vi.fn(() => ({
|
||||
data: undefined,
|
||||
})),
|
||||
}));
|
||||
|
||||
vi.mock("@/app/api/__generated__/endpoints/default/default", () => ({
|
||||
useGetV2GetSpecificBlocks: vi.fn(() => ({
|
||||
data: undefined,
|
||||
isLoading: mockBlocksLoading,
|
||||
})),
|
||||
}));
|
||||
|
||||
vi.mock("@/app/api/helpers", () => ({
|
||||
okData: (res: { data: unknown }) => res?.data,
|
||||
}));
|
||||
|
||||
vi.mock("../components/helper", () => ({
|
||||
convertNodesPlusBlockInfoIntoCustomNodes: vi.fn(),
|
||||
}));
|
||||
|
||||
describe("useFlow", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.useFakeTimers({ shouldAdvanceTime: true });
|
||||
mockGraphLoading = false;
|
||||
mockBlocksLoading = false;
|
||||
mockQueryStateValues = {
|
||||
flowID: null,
|
||||
flowVersion: null,
|
||||
flowExecutionID: null,
|
||||
};
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
describe("loading states", () => {
|
||||
it("returns isFlowContentLoading true when graph is loading", async () => {
|
||||
mockGraphLoading = true;
|
||||
mockQueryStateValues = {
|
||||
flowID: "test-flow",
|
||||
flowVersion: 1,
|
||||
flowExecutionID: null,
|
||||
};
|
||||
|
||||
const { useFlow } = await import("../components/FlowEditor/Flow/useFlow");
|
||||
const { result } = renderHook(() => useFlow());
|
||||
|
||||
expect(result.current.isFlowContentLoading).toBe(true);
|
||||
});
|
||||
|
||||
it("returns isFlowContentLoading true when blocks are loading", async () => {
|
||||
mockBlocksLoading = true;
|
||||
mockQueryStateValues = {
|
||||
flowID: "test-flow",
|
||||
flowVersion: 1,
|
||||
flowExecutionID: null,
|
||||
};
|
||||
|
||||
const { useFlow } = await import("../components/FlowEditor/Flow/useFlow");
|
||||
const { result } = renderHook(() => useFlow());
|
||||
|
||||
expect(result.current.isFlowContentLoading).toBe(true);
|
||||
});
|
||||
|
||||
it("returns isFlowContentLoading false when neither is loading", async () => {
|
||||
const { useFlow } = await import("../components/FlowEditor/Flow/useFlow");
|
||||
const { result } = renderHook(() => useFlow());
|
||||
|
||||
expect(result.current.isFlowContentLoading).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("initial load completion", () => {
|
||||
it("marks initial load complete for new flows without flowID", async () => {
|
||||
const { useFlow } = await import("../components/FlowEditor/Flow/useFlow");
|
||||
const { result } = renderHook(() => useFlow());
|
||||
|
||||
expect(result.current.isInitialLoadComplete).toBe(false);
|
||||
|
||||
await act(async () => {
|
||||
vi.advanceTimersByTime(300);
|
||||
});
|
||||
|
||||
expect(result.current.isInitialLoadComplete).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,3 +1,4 @@
|
||||
import { useCopilotUIStore } from "@/app/(platform)/copilot/store";
|
||||
import { ChangeEvent, FormEvent, useEffect, useState } from "react";
|
||||
|
||||
interface Args {
|
||||
@@ -16,6 +17,16 @@ export function useChatInput({
|
||||
}: Args) {
|
||||
const [value, setValue] = useState("");
|
||||
const [isSending, setIsSending] = useState(false);
|
||||
const { initialPrompt, setInitialPrompt } = useCopilotUIStore();
|
||||
|
||||
useEffect(
|
||||
function consumeInitialPrompt() {
|
||||
if (!initialPrompt) return;
|
||||
setValue((prev) => (prev.length === 0 ? initialPrompt : prev));
|
||||
setInitialPrompt(null);
|
||||
},
|
||||
[initialPrompt, setInitialPrompt],
|
||||
);
|
||||
|
||||
useEffect(
|
||||
function focusOnMount() {
|
||||
|
||||
@@ -7,6 +7,10 @@ export interface DeleteTarget {
|
||||
}
|
||||
|
||||
interface CopilotUIState {
|
||||
/** Prompt extracted from URL hash (e.g. /copilot#prompt=...) for input prefill. */
|
||||
initialPrompt: string | null;
|
||||
setInitialPrompt: (prompt: string | null) => void;
|
||||
|
||||
sessionToDelete: DeleteTarget | null;
|
||||
setSessionToDelete: (target: DeleteTarget | null) => void;
|
||||
|
||||
@@ -31,6 +35,9 @@ interface CopilotUIState {
|
||||
}
|
||||
|
||||
export const useCopilotUIStore = create<CopilotUIState>((set) => ({
|
||||
initialPrompt: null,
|
||||
setInitialPrompt: (prompt) => set({ initialPrompt: prompt }),
|
||||
|
||||
sessionToDelete: null,
|
||||
setSessionToDelete: (target) => set({ sessionToDelete: target }),
|
||||
|
||||
|
||||
@@ -19,6 +19,42 @@ import { useCopilotStream } from "./useCopilotStream";
|
||||
const TITLE_POLL_INTERVAL_MS = 2_000;
|
||||
const TITLE_POLL_MAX_ATTEMPTS = 5;
|
||||
|
||||
/**
|
||||
* Extract a prompt from the URL hash fragment.
|
||||
* Supports: /copilot#prompt=URL-encoded-text
|
||||
* Optionally auto-submits if ?autosubmit=true is in the query string.
|
||||
* Returns null if no prompt is present.
|
||||
*/
|
||||
function extractPromptFromUrl(): {
|
||||
prompt: string;
|
||||
autosubmit: boolean;
|
||||
} | null {
|
||||
if (typeof window === "undefined") return null;
|
||||
|
||||
const hash = window.location.hash;
|
||||
if (!hash) return null;
|
||||
|
||||
const hashParams = new URLSearchParams(hash.slice(1));
|
||||
const prompt = hashParams.get("prompt");
|
||||
|
||||
if (!prompt || !prompt.trim()) return null;
|
||||
|
||||
const searchParams = new URLSearchParams(window.location.search);
|
||||
const autosubmit = searchParams.get("autosubmit") === "true";
|
||||
|
||||
// Clean up hash + autosubmit param only (preserve other query params)
|
||||
const cleanURL = new URL(window.location.href);
|
||||
cleanURL.hash = "";
|
||||
cleanURL.searchParams.delete("autosubmit");
|
||||
window.history.replaceState(
|
||||
null,
|
||||
"",
|
||||
`${cleanURL.pathname}${cleanURL.search}`,
|
||||
);
|
||||
|
||||
return { prompt: prompt.trim(), autosubmit };
|
||||
}
|
||||
|
||||
interface UploadedFile {
|
||||
file_id: string;
|
||||
name: string;
|
||||
@@ -127,6 +163,28 @@ export function useCopilotPage() {
|
||||
}
|
||||
}, [sessionId, pendingMessage, sendMessage]);
|
||||
|
||||
// --- Extract prompt from URL hash on mount (e.g. /copilot#prompt=Hello) ---
|
||||
const { setInitialPrompt } = useCopilotUIStore();
|
||||
const hasProcessedUrlPrompt = useRef(false);
|
||||
useEffect(() => {
|
||||
if (hasProcessedUrlPrompt.current) return;
|
||||
|
||||
const urlPrompt = extractPromptFromUrl();
|
||||
if (!urlPrompt) return;
|
||||
|
||||
hasProcessedUrlPrompt.current = true;
|
||||
|
||||
if (urlPrompt.autosubmit) {
|
||||
setPendingMessage(urlPrompt.prompt);
|
||||
void createSession().catch(() => {
|
||||
setPendingMessage(null);
|
||||
setInitialPrompt(urlPrompt.prompt);
|
||||
});
|
||||
} else {
|
||||
setInitialPrompt(urlPrompt.prompt);
|
||||
}
|
||||
}, [createSession, setInitialPrompt]);
|
||||
|
||||
async function uploadFiles(
|
||||
files: File[],
|
||||
sid: string,
|
||||
|
||||
343
docs/platform/workspace-media-architecture.md
Normal file
343
docs/platform/workspace-media-architecture.md
Normal file
@@ -0,0 +1,343 @@
|
||||
# Workspace & Media File Architecture
|
||||
|
||||
This document describes the architecture for handling user files in AutoGPT Platform, covering persistent user storage (Workspace) and ephemeral media processing pipelines.
|
||||
|
||||
## Overview
|
||||
|
||||
The platform has two distinct file-handling layers:
|
||||
|
||||
| Layer | Purpose | Persistence | Scope |
|
||||
|-------|---------|-------------|-------|
|
||||
| **Workspace** | Long-term user file storage | Persistent (DB + GCS/local) | Per-user, session-scoped access |
|
||||
| **Media Pipeline** | Ephemeral file processing for blocks | Temporary (local disk) | Per-execution |
|
||||
|
||||
## Database Models
|
||||
|
||||
### UserWorkspace
|
||||
|
||||
Represents a user's file storage space. Created on-demand (one per user).
|
||||
|
||||
```prisma
|
||||
model UserWorkspace {
|
||||
id String @id @default(uuid())
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
userId String @unique
|
||||
Files UserWorkspaceFile[]
|
||||
}
|
||||
```
|
||||
|
||||
**Key points:**
|
||||
- One workspace per user (enforced by `@unique` on `userId`)
|
||||
- Created lazily via `get_or_create_workspace()`
|
||||
- Uses upsert to handle race conditions
|
||||
|
||||
### UserWorkspaceFile
|
||||
|
||||
Represents a file stored in a user's workspace.
|
||||
|
||||
```prisma
|
||||
model UserWorkspaceFile {
|
||||
id String @id @default(uuid())
|
||||
workspaceId String
|
||||
name String // User-visible filename
|
||||
path String // Virtual path (e.g., "/sessions/abc123/image.png")
|
||||
storagePath String // Actual storage path (gcs://... or local://...)
|
||||
mimeType String
|
||||
sizeBytes BigInt
|
||||
checksum String? // SHA256 for integrity
|
||||
isDeleted Boolean @default(false)
|
||||
deletedAt DateTime?
|
||||
metadata Json @default("{}")
|
||||
|
||||
@@unique([workspaceId, path]) // Enforce unique paths within workspace
|
||||
}
|
||||
```
|
||||
|
||||
**Key points:**
|
||||
- `path` is a virtual path for organizing files (not actual filesystem path)
|
||||
- `storagePath` contains the actual GCS or local storage location
|
||||
- Soft-delete pattern: `isDeleted` flag with `deletedAt` timestamp
|
||||
- Path is modified on delete to free up the virtual path for reuse
|
||||
|
||||
---
|
||||
|
||||
## WorkspaceManager
|
||||
|
||||
**Location:** `backend/util/workspace.py`
|
||||
|
||||
High-level API for workspace file operations. Combines storage backend operations with database record management.
|
||||
|
||||
### Initialization
|
||||
|
||||
```python
|
||||
from backend.util.workspace import WorkspaceManager
|
||||
|
||||
# Basic usage
|
||||
manager = WorkspaceManager(user_id="user-123", workspace_id="ws-456")
|
||||
|
||||
# With session scoping (CoPilot sessions)
|
||||
manager = WorkspaceManager(
|
||||
user_id="user-123",
|
||||
workspace_id="ws-456",
|
||||
session_id="session-789"
|
||||
)
|
||||
```
|
||||
|
||||
### Session Scoping
|
||||
|
||||
When `session_id` is provided, files are isolated to `/sessions/{session_id}/`:
|
||||
|
||||
```python
|
||||
# With session_id="abc123":
|
||||
manager.write_file(content, "image.png")
|
||||
# → stored at /sessions/abc123/image.png
|
||||
|
||||
# Cross-session access is explicit:
|
||||
manager.read_file("/sessions/other-session/file.txt") # Works
|
||||
```
|
||||
|
||||
**Why session scoping?**
|
||||
- CoPilot conversations need file isolation
|
||||
- Prevents file collisions between concurrent sessions
|
||||
- Allows session cleanup without affecting other sessions
|
||||
|
||||
### Core Methods
|
||||
|
||||
| Method | Description |
|
||||
|--------|-------------|
|
||||
| `write_file(content, filename, path?, mime_type?, overwrite?)` | Write file to workspace |
|
||||
| `read_file(path)` | Read file by virtual path |
|
||||
| `read_file_by_id(file_id)` | Read file by ID |
|
||||
| `list_files(path?, limit?, offset?, include_all_sessions?)` | List files |
|
||||
| `delete_file(file_id)` | Soft-delete a file |
|
||||
| `get_download_url(file_id, expires_in?)` | Get signed download URL |
|
||||
| `get_file_info(file_id)` | Get file metadata |
|
||||
| `get_file_info_by_path(path)` | Get file metadata by path |
|
||||
| `get_file_count(path?, include_all_sessions?)` | Count files |
|
||||
|
||||
### Storage Backends
|
||||
|
||||
WorkspaceManager delegates to `WorkspaceStorageBackend`:
|
||||
|
||||
| Backend | When Used | Storage Path Format |
|
||||
|---------|-----------|---------------------|
|
||||
| `GCSWorkspaceStorage` | `media_gcs_bucket_name` is configured | `gcs://bucket/workspaces/{ws_id}/{file_id}/{filename}` |
|
||||
| `LocalWorkspaceStorage` | No GCS bucket configured | `local://{ws_id}/{file_id}/{filename}` |
|
||||
|
||||
---
|
||||
|
||||
## store_media_file()
|
||||
|
||||
**Location:** `backend/util/file.py`
|
||||
|
||||
The media normalization pipeline. Handles various input types and normalizes them for processing or output.
|
||||
|
||||
### Purpose
|
||||
|
||||
Blocks receive files in many formats (URLs, data URIs, workspace references, local paths). `store_media_file()` normalizes these to a consistent format based on what the block needs.
|
||||
|
||||
### Input Types Handled
|
||||
|
||||
| Input Format | Example | How It's Processed |
|
||||
|--------------|---------|-------------------|
|
||||
| Data URI | `data:image/png;base64,iVBOR...` | Decoded, virus scanned, written locally |
|
||||
| HTTP(S) URL | `https://example.com/image.png` | Downloaded, virus scanned, written locally |
|
||||
| Workspace URI | `workspace://abc123` or `workspace:///path/to/file` | Read from workspace, virus scanned, written locally |
|
||||
| Cloud path | `gcs://bucket/path` | Downloaded, virus scanned, written locally |
|
||||
| Local path | `image.png` | Verified to exist in exec_file directory |
|
||||
|
||||
### Return Formats
|
||||
|
||||
The `return_format` parameter determines what you get back:
|
||||
|
||||
```python
|
||||
from backend.util.file import store_media_file
|
||||
|
||||
# For local processing (ffmpeg, MoviePy, PIL)
|
||||
local_path = await store_media_file(
|
||||
file=input_file,
|
||||
execution_context=ctx,
|
||||
return_format="for_local_processing"
|
||||
)
|
||||
# Returns: "image.png" (relative path in exec_file dir)
|
||||
|
||||
# For external APIs (Replicate, OpenAI, etc.)
|
||||
data_uri = await store_media_file(
|
||||
file=input_file,
|
||||
execution_context=ctx,
|
||||
return_format="for_external_api"
|
||||
)
|
||||
# Returns: "data:image/png;base64,iVBOR..."
|
||||
|
||||
# For block output (adapts to execution context)
|
||||
output = await store_media_file(
|
||||
file=input_file,
|
||||
execution_context=ctx,
|
||||
return_format="for_block_output"
|
||||
)
|
||||
# In CoPilot: Returns "workspace://file-id#image/png"
|
||||
# In graphs: Returns "data:image/png;base64,..."
|
||||
```
|
||||
|
||||
### Execution Context
|
||||
|
||||
`store_media_file()` requires an `ExecutionContext` with:
|
||||
- `graph_exec_id` - Required for temp file location
|
||||
- `user_id` - Required for workspace access
|
||||
- `workspace_id` - Optional; enables workspace features
|
||||
- `session_id` - Optional; for session scoping in CoPilot
|
||||
|
||||
---
|
||||
|
||||
## Responsibility Boundaries
|
||||
|
||||
### Virus Scanning
|
||||
|
||||
| Component | Scans? | Notes |
|
||||
|-----------|--------|-------|
|
||||
| `store_media_file()` | ✅ Yes | Scans **all** content before writing to local disk |
|
||||
| `WorkspaceManager.write_file()` | ✅ Yes | Scans content before persisting |
|
||||
|
||||
**Scanning happens at:**
|
||||
1. `store_media_file()` — scans everything it downloads/decodes
|
||||
2. `WorkspaceManager.write_file()` — scans before persistence
|
||||
|
||||
Tools like `WriteWorkspaceFileTool` don't need to scan because `WorkspaceManager.write_file()` handles it.
|
||||
|
||||
### Persistence
|
||||
|
||||
| Component | Persists To | Lifecycle |
|
||||
|-----------|-------------|-----------|
|
||||
| `store_media_file()` | Temp dir (`/tmp/exec_file/{exec_id}/`) | Cleaned after execution |
|
||||
| `WorkspaceManager` | GCS or local storage + DB | Persistent until deleted |
|
||||
|
||||
**Automatic cleanup:** `clean_exec_files(graph_exec_id)` removes temp files after execution completes.
|
||||
|
||||
---
|
||||
|
||||
## Decision Tree: WorkspaceManager vs store_media_file
|
||||
|
||||
```text
|
||||
┌─────────────────────────────────────────────────────┐
|
||||
│ What do you need to do with the file? │
|
||||
└─────────────────────────────────────────────────────┘
|
||||
│
|
||||
┌─────────────┴─────────────┐
|
||||
▼ ▼
|
||||
Process in a block Store for user access
|
||||
(ffmpeg, PIL, etc.) (CoPilot files, uploads)
|
||||
│ │
|
||||
▼ ▼
|
||||
store_media_file() WorkspaceManager
|
||||
with appropriate
|
||||
return_format
|
||||
│
|
||||
│
|
||||
┌──────┴──────┐
|
||||
▼ ▼
|
||||
"for_local_ "for_block_
|
||||
processing" output"
|
||||
│ │
|
||||
▼ ▼
|
||||
Get local Auto-saves to
|
||||
path for workspace in
|
||||
tools CoPilot context
|
||||
|
||||
Store for user access
|
||||
│
|
||||
├── write_file() ─── Upload + persist (scans internally)
|
||||
├── read_file() / get_download_url() ─── Retrieve
|
||||
└── list_files() / delete_file() ─── Manage
|
||||
```
|
||||
|
||||
### Quick Reference
|
||||
|
||||
| Scenario | Use |
|
||||
|----------|-----|
|
||||
| Block needs to process a file with ffmpeg | `store_media_file(..., return_format="for_local_processing")` |
|
||||
| Block needs to send file to external API | `store_media_file(..., return_format="for_external_api")` |
|
||||
| Block returning a generated file | `store_media_file(..., return_format="for_block_output")` |
|
||||
| API endpoint handling file upload | `WorkspaceManager.write_file()` (handles virus scanning internally) |
|
||||
| API endpoint serving file download | `WorkspaceManager.get_download_url()` |
|
||||
| Listing user's files | `WorkspaceManager.list_files()` |
|
||||
|
||||
---
|
||||
|
||||
## Key Files Reference
|
||||
|
||||
| File | Purpose |
|
||||
|------|---------|
|
||||
| `backend/data/workspace.py` | Database CRUD operations for UserWorkspace and UserWorkspaceFile |
|
||||
| `backend/util/workspace.py` | `WorkspaceManager` class - high-level workspace API |
|
||||
| `backend/util/workspace_storage.py` | Storage backends (GCS, local) and `WorkspaceStorageBackend` interface |
|
||||
| `backend/util/file.py` | `store_media_file()` and media processing utilities |
|
||||
| `backend/util/virus_scanner.py` | `VirusScannerService` and `scan_content_safe()` |
|
||||
| `schema.prisma` | Database model definitions |
|
||||
|
||||
---
|
||||
|
||||
## Common Patterns
|
||||
|
||||
### Block Processing a User's File
|
||||
|
||||
```python
|
||||
async def run(self, input_data, *, execution_context, **kwargs):
|
||||
# Normalize input to local path
|
||||
local_path = await store_media_file(
|
||||
file=input_data.video,
|
||||
execution_context=execution_context,
|
||||
return_format="for_local_processing",
|
||||
)
|
||||
|
||||
# Process with local tools
|
||||
output_path = process_video(local_path)
|
||||
|
||||
# Return (auto-saves to workspace in CoPilot)
|
||||
result = await store_media_file(
|
||||
file=output_path,
|
||||
execution_context=execution_context,
|
||||
return_format="for_block_output",
|
||||
)
|
||||
yield "output", result
|
||||
```
|
||||
|
||||
### API Upload Endpoint
|
||||
|
||||
```python
|
||||
from backend.util.virus_scanner import VirusDetectedError, VirusScanError
|
||||
|
||||
async def upload_file(file: UploadFile, user_id: str, workspace_id: str):
|
||||
content = await file.read()
|
||||
|
||||
# write_file handles virus scanning internally
|
||||
manager = WorkspaceManager(user_id, workspace_id)
|
||||
try:
|
||||
workspace_file = await manager.write_file(
|
||||
content=content,
|
||||
filename=file.filename,
|
||||
)
|
||||
except VirusDetectedError:
|
||||
raise HTTPException(status_code=400, detail="File rejected: virus detected")
|
||||
except VirusScanError:
|
||||
raise HTTPException(status_code=503, detail="Virus scanning unavailable")
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
|
||||
return {"file_id": workspace_file.id}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Configuration
|
||||
|
||||
| Setting | Purpose | Default |
|
||||
|---------|---------|---------|
|
||||
| `media_gcs_bucket_name` | GCS bucket for workspace storage | None (uses local) |
|
||||
| `workspace_storage_dir` | Local storage directory | `{app_data}/workspaces` |
|
||||
| `max_file_size_mb` | Maximum file size in MB | 100 |
|
||||
| `clamav_service_enabled` | Enable virus scanning | true |
|
||||
| `clamav_service_host` | ClamAV daemon host | localhost |
|
||||
| `clamav_service_port` | ClamAV daemon port | 3310 |
|
||||
| `clamav_max_concurrency` | Max concurrent scans to ClamAV daemon | 5 |
|
||||
| `clamav_mark_failed_scans_as_clean` | If true, scan failures pass content through instead of rejecting (⚠️ security risk if ClamAV is unreachable) | false |
|
||||
Reference in New Issue
Block a user