mirror of
https://github.com/Significant-Gravitas/AutoGPT.git
synced 2026-03-17 03:00:27 -04:00
Compare commits
5 Commits
feat/githu
...
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*]
|
branches: [master, dev, ci-test*]
|
||||||
paths:
|
paths:
|
||||||
- ".github/workflows/platform-backend-ci.yml"
|
- ".github/workflows/platform-backend-ci.yml"
|
||||||
|
- ".github/workflows/scripts/get_package_version_from_lockfile.py"
|
||||||
- "autogpt_platform/backend/**"
|
- "autogpt_platform/backend/**"
|
||||||
- "autogpt_platform/autogpt_libs/**"
|
- "autogpt_platform/autogpt_libs/**"
|
||||||
pull_request:
|
pull_request:
|
||||||
branches: [master, dev, release-*]
|
branches: [master, dev, release-*]
|
||||||
paths:
|
paths:
|
||||||
- ".github/workflows/platform-backend-ci.yml"
|
- ".github/workflows/platform-backend-ci.yml"
|
||||||
|
- ".github/workflows/scripts/get_package_version_from_lockfile.py"
|
||||||
- "autogpt_platform/backend/**"
|
- "autogpt_platform/backend/**"
|
||||||
- "autogpt_platform/autogpt_libs/**"
|
- "autogpt_platform/autogpt_libs/**"
|
||||||
merge_group:
|
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 }}
|
token: ${{ secrets.GITHUB_TOKEN }}
|
||||||
exitOnceUploaded: true
|
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:
|
integration_test:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
needs: setup
|
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:
|
on:
|
||||||
push:
|
push:
|
||||||
branches: [master, dev]
|
branches: [master, dev]
|
||||||
paths:
|
paths:
|
||||||
- ".github/workflows/platform-fullstack-ci.yml"
|
- ".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/**"
|
- "autogpt_platform/**"
|
||||||
pull_request:
|
pull_request:
|
||||||
paths:
|
paths:
|
||||||
- ".github/workflows/platform-fullstack-ci.yml"
|
- ".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/**"
|
- "autogpt_platform/**"
|
||||||
merge_group:
|
merge_group:
|
||||||
|
|
||||||
@@ -24,42 +28,28 @@ defaults:
|
|||||||
jobs:
|
jobs:
|
||||||
setup:
|
setup:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
outputs:
|
|
||||||
cache-key: ${{ steps.cache-key.outputs.key }}
|
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@v6
|
uses: actions/checkout@v6
|
||||||
|
|
||||||
- name: Set up Node.js
|
|
||||||
uses: actions/setup-node@v6
|
|
||||||
with:
|
|
||||||
node-version: "22.18.0"
|
|
||||||
|
|
||||||
- name: Enable corepack
|
- name: Enable corepack
|
||||||
run: corepack enable
|
run: corepack enable
|
||||||
|
|
||||||
- name: Generate cache key
|
- name: Set up Node
|
||||||
id: cache-key
|
uses: actions/setup-node@v6
|
||||||
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
|
|
||||||
with:
|
with:
|
||||||
path: ~/.pnpm-store
|
node-version: "22.18.0"
|
||||||
key: ${{ steps.cache-key.outputs.key }}
|
cache: "pnpm"
|
||||||
restore-keys: |
|
cache-dependency-path: autogpt_platform/frontend/pnpm-lock.yaml
|
||||||
${{ runner.os }}-pnpm-${{ hashFiles('autogpt_platform/frontend/pnpm-lock.yaml') }}
|
|
||||||
${{ runner.os }}-pnpm-
|
|
||||||
|
|
||||||
- name: Install dependencies
|
- name: Install dependencies to populate cache
|
||||||
run: pnpm install --frozen-lockfile
|
run: pnpm install --frozen-lockfile
|
||||||
|
|
||||||
types:
|
check-api-types:
|
||||||
runs-on: big-boi
|
name: check API types
|
||||||
|
runs-on: ubuntu-latest
|
||||||
needs: setup
|
needs: setup
|
||||||
strategy:
|
|
||||||
fail-fast: false
|
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
@@ -67,70 +57,256 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
submodules: recursive
|
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
|
uses: actions/setup-node@v6
|
||||||
with:
|
with:
|
||||||
node-version: "22.18.0"
|
node-version: "22.18.0"
|
||||||
|
cache: "pnpm"
|
||||||
|
cache-dependency-path: autogpt_platform/frontend/pnpm-lock.yaml
|
||||||
|
|
||||||
- name: Enable corepack
|
- name: Set up Frontend - Install dependencies
|
||||||
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
|
|
||||||
run: pnpm install --frozen-lockfile
|
run: pnpm install --frozen-lockfile
|
||||||
|
|
||||||
- name: Setup .env
|
- name: Set up Frontend - Format OpenAPI schema
|
||||||
run: cp .env.default .env
|
id: format-schema
|
||||||
|
run: pnpm prettier --write ./src/app/api/openapi.json
|
||||||
- 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: Check for API schema changes
|
- name: Check for API schema changes
|
||||||
run: |
|
run: |
|
||||||
if ! git diff --exit-code src/app/api/openapi.json; then
|
if ! git diff --exit-code src/app/api/openapi.json; then
|
||||||
echo "❌ API schema changes detected in src/app/api/openapi.json"
|
echo "❌ API schema changes detected in src/app/api/openapi.json"
|
||||||
echo ""
|
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 "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 "The API schema is now out of sync with the Front-end queries."
|
||||||
echo ""
|
echo ""
|
||||||
echo "To fix this:"
|
echo "To fix this:"
|
||||||
echo "1. Pull the backend 'docker compose pull && docker compose up -d --build --force-recreate'"
|
echo "\nIn the backend directory:"
|
||||||
echo "2. Run 'pnpm generate:api' locally"
|
echo "1. Run 'poetry run export-api-schema --output ../frontend/src/app/api/openapi.json'"
|
||||||
echo "3. Run 'pnpm types' locally"
|
echo "\nIn the frontend directory:"
|
||||||
echo "4. Fix any TypeScript errors that may have been introduced"
|
echo "2. Run 'pnpm prettier --write src/app/api/openapi.json'"
|
||||||
echo "5. Commit and push your changes"
|
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 ""
|
echo ""
|
||||||
exit 1
|
exit 1
|
||||||
else
|
else
|
||||||
echo "✅ No API schema changes detected"
|
echo "✅ No API schema changes detected"
|
||||||
fi
|
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
|
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
|
3. Write tests alongside the route file
|
||||||
4. Run `poetry run test` to verify
|
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
|
## Security Implementation
|
||||||
|
|
||||||
### Cache Protection Middleware
|
### Cache Protection Middleware
|
||||||
|
|||||||
@@ -40,7 +40,7 @@ from backend.copilot.response_model import (
|
|||||||
from backend.copilot.service import (
|
from backend.copilot.service import (
|
||||||
_build_system_prompt,
|
_build_system_prompt,
|
||||||
_generate_session_title,
|
_generate_session_title,
|
||||||
client,
|
_get_openai_client,
|
||||||
config,
|
config,
|
||||||
)
|
)
|
||||||
from backend.copilot.tools import execute_tool, get_available_tools
|
from backend.copilot.tools import execute_tool, get_available_tools
|
||||||
@@ -89,7 +89,7 @@ async def _compress_session_messages(
|
|||||||
result = await compress_context(
|
result = await compress_context(
|
||||||
messages=messages_dict,
|
messages=messages_dict,
|
||||||
model=config.model,
|
model=config.model,
|
||||||
client=client,
|
client=_get_openai_client(),
|
||||||
)
|
)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning("[Baseline] Context compression with LLM failed: %s", e)
|
logger.warning("[Baseline] Context compression with LLM failed: %s", e)
|
||||||
@@ -235,7 +235,7 @@ async def stream_chat_completion_baseline(
|
|||||||
)
|
)
|
||||||
if tools:
|
if tools:
|
||||||
create_kwargs["tools"] = 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)
|
# Accumulate streamed response (text + tool calls)
|
||||||
round_text = ""
|
round_text = ""
|
||||||
|
|||||||
@@ -28,10 +28,24 @@ logger = logging.getLogger(__name__)
|
|||||||
|
|
||||||
config = ChatConfig()
|
config = ChatConfig()
|
||||||
settings = Settings()
|
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
|
# Default system prompt used when Langfuse is not configured
|
||||||
# Provides minimal baseline tone and personality - all workflow, tools, and
|
# 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"
|
else "latest"
|
||||||
)
|
)
|
||||||
prompt = await asyncio.to_thread(
|
prompt = await asyncio.to_thread(
|
||||||
langfuse.get_prompt,
|
_get_langfuse().get_prompt,
|
||||||
config.langfuse_prompt_name,
|
config.langfuse_prompt_name,
|
||||||
label=label,
|
label=label,
|
||||||
cache_ttl_seconds=config.langfuse_prompt_cache_ttl,
|
cache_ttl_seconds=config.langfuse_prompt_cache_ttl,
|
||||||
@@ -158,7 +172,7 @@ async def _generate_session_title(
|
|||||||
"environment": settings.config.app_env.value,
|
"environment": settings.config.app_env.value,
|
||||||
}
|
}
|
||||||
|
|
||||||
response = await client.chat.completions.create(
|
response = await _get_openai_client().chat.completions.create(
|
||||||
model=config.title_model,
|
model=config.title_model,
|
||||||
messages=[
|
messages=[
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -183,7 +183,8 @@ class WorkspaceManager:
|
|||||||
f"{Config().max_file_size_mb}MB limit"
|
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)
|
await scan_content_safe(content, filename=filename)
|
||||||
|
|
||||||
# Determine path with session scoping
|
# 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";
|
import { ChangeEvent, FormEvent, useEffect, useState } from "react";
|
||||||
|
|
||||||
interface Args {
|
interface Args {
|
||||||
@@ -16,6 +17,16 @@ export function useChatInput({
|
|||||||
}: Args) {
|
}: Args) {
|
||||||
const [value, setValue] = useState("");
|
const [value, setValue] = useState("");
|
||||||
const [isSending, setIsSending] = useState(false);
|
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(
|
useEffect(
|
||||||
function focusOnMount() {
|
function focusOnMount() {
|
||||||
|
|||||||
@@ -7,6 +7,10 @@ export interface DeleteTarget {
|
|||||||
}
|
}
|
||||||
|
|
||||||
interface CopilotUIState {
|
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;
|
sessionToDelete: DeleteTarget | null;
|
||||||
setSessionToDelete: (target: DeleteTarget | null) => void;
|
setSessionToDelete: (target: DeleteTarget | null) => void;
|
||||||
|
|
||||||
@@ -31,6 +35,9 @@ interface CopilotUIState {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const useCopilotUIStore = create<CopilotUIState>((set) => ({
|
export const useCopilotUIStore = create<CopilotUIState>((set) => ({
|
||||||
|
initialPrompt: null,
|
||||||
|
setInitialPrompt: (prompt) => set({ initialPrompt: prompt }),
|
||||||
|
|
||||||
sessionToDelete: null,
|
sessionToDelete: null,
|
||||||
setSessionToDelete: (target) => set({ sessionToDelete: target }),
|
setSessionToDelete: (target) => set({ sessionToDelete: target }),
|
||||||
|
|
||||||
|
|||||||
@@ -19,6 +19,42 @@ import { useCopilotStream } from "./useCopilotStream";
|
|||||||
const TITLE_POLL_INTERVAL_MS = 2_000;
|
const TITLE_POLL_INTERVAL_MS = 2_000;
|
||||||
const TITLE_POLL_MAX_ATTEMPTS = 5;
|
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 {
|
interface UploadedFile {
|
||||||
file_id: string;
|
file_id: string;
|
||||||
name: string;
|
name: string;
|
||||||
@@ -127,6 +163,28 @@ export function useCopilotPage() {
|
|||||||
}
|
}
|
||||||
}, [sessionId, pendingMessage, sendMessage]);
|
}, [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(
|
async function uploadFiles(
|
||||||
files: File[],
|
files: File[],
|
||||||
sid: string,
|
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