mirror of
https://github.com/Significant-Gravitas/AutoGPT.git
synced 2026-03-17 03:00:27 -04:00
Compare commits
23 Commits
dev
...
feat/githu
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
88eaab2baa | ||
|
|
4b0a445635 | ||
|
|
36312d2c6e | ||
|
|
d6d3b8d710 | ||
|
|
17d8d0bf05 | ||
|
|
5a2ab65f41 | ||
|
|
81a318de3e | ||
|
|
62c8e8634b | ||
|
|
b91c959cd9 | ||
|
|
5b95a2a1ef | ||
|
|
9c2a601167 | ||
|
|
b98e37bf23 | ||
|
|
fec8924361 | ||
|
|
712aee7302 | ||
|
|
bef292033e | ||
|
|
ec6974e3b8 | ||
|
|
2ef5e2fe77 | ||
|
|
0a8c7221ce | ||
|
|
840d1de636 | ||
|
|
ac55ab619b | ||
|
|
a8014d1e92 | ||
|
|
7de13c7713 | ||
|
|
9358b525a0 |
2
.github/workflows/platform-backend-ci.yml
vendored
2
.github/workflows/platform-backend-ci.yml
vendored
@@ -5,14 +5,12 @@ 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,6 +120,175 @@ 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
|
||||||
|
|||||||
314
.github/workflows/platform-fullstack-ci.yml
vendored
314
.github/workflows/platform-fullstack-ci.yml
vendored
@@ -1,18 +1,14 @@
|
|||||||
name: AutoGPT Platform - Full-stack CI
|
name: AutoGPT Platform - Frontend 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:
|
||||||
|
|
||||||
@@ -28,28 +24,42 @@ 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: Enable corepack
|
- name: Set up Node.js
|
||||||
run: corepack enable
|
|
||||||
|
|
||||||
- name: 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: Install dependencies to populate cache
|
- name: Enable corepack
|
||||||
|
run: corepack enable
|
||||||
|
|
||||||
|
- name: Generate cache key
|
||||||
|
id: cache-key
|
||||||
|
run: echo "key=${{ runner.os }}-pnpm-${{ hashFiles('autogpt_platform/frontend/pnpm-lock.yaml', 'autogpt_platform/frontend/package.json') }}" >> $GITHUB_OUTPUT
|
||||||
|
|
||||||
|
- name: Cache dependencies
|
||||||
|
uses: actions/cache@v5
|
||||||
|
with:
|
||||||
|
path: ~/.pnpm-store
|
||||||
|
key: ${{ steps.cache-key.outputs.key }}
|
||||||
|
restore-keys: |
|
||||||
|
${{ runner.os }}-pnpm-${{ hashFiles('autogpt_platform/frontend/pnpm-lock.yaml') }}
|
||||||
|
${{ runner.os }}-pnpm-
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
run: pnpm install --frozen-lockfile
|
run: pnpm install --frozen-lockfile
|
||||||
|
|
||||||
check-api-types:
|
types:
|
||||||
name: check API types
|
runs-on: big-boi
|
||||||
runs-on: ubuntu-latest
|
|
||||||
needs: setup
|
needs: setup
|
||||||
|
strategy:
|
||||||
|
fail-fast: false
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
@@ -57,256 +67,70 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
submodules: recursive
|
submodules: recursive
|
||||||
|
|
||||||
# ------------------------ Backend setup ------------------------
|
- name: Set up Node.js
|
||||||
|
|
||||||
- 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: Set up Frontend - Install dependencies
|
- name: Enable corepack
|
||||||
|
run: corepack enable
|
||||||
|
|
||||||
|
- name: Copy default supabase .env
|
||||||
|
run: |
|
||||||
|
cp ../.env.default ../.env
|
||||||
|
|
||||||
|
- name: Copy backend .env
|
||||||
|
run: |
|
||||||
|
cp ../backend/.env.default ../backend/.env
|
||||||
|
|
||||||
|
- name: Run docker compose
|
||||||
|
run: |
|
||||||
|
docker compose -f ../docker-compose.yml --profile local up -d deps_backend
|
||||||
|
|
||||||
|
- name: Restore dependencies cache
|
||||||
|
uses: actions/cache@v5
|
||||||
|
with:
|
||||||
|
path: ~/.pnpm-store
|
||||||
|
key: ${{ needs.setup.outputs.cache-key }}
|
||||||
|
restore-keys: |
|
||||||
|
${{ runner.os }}-pnpm-
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
run: pnpm install --frozen-lockfile
|
run: pnpm install --frozen-lockfile
|
||||||
|
|
||||||
- name: Set up Frontend - Format OpenAPI schema
|
- name: Setup .env
|
||||||
id: format-schema
|
run: cp .env.default .env
|
||||||
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 exporting the API schema."
|
echo "The openapi.json file has been modified after running 'pnpm generate:api-all'."
|
||||||
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 "\nIn the backend directory:"
|
echo "1. Pull the backend 'docker compose pull && docker compose up -d --build --force-recreate'"
|
||||||
echo "1. Run 'poetry run export-api-schema --output ../frontend/src/app/api/openapi.json'"
|
echo "2. Run 'pnpm generate:api' locally"
|
||||||
echo "\nIn the frontend directory:"
|
echo "3. Run 'pnpm types' locally"
|
||||||
echo "2. Run 'pnpm prettier --write src/app/api/openapi.json'"
|
echo "4. Fix any TypeScript errors that may have been introduced"
|
||||||
echo "3. Run 'pnpm generate:api'"
|
echo "5. Commit and push your changes"
|
||||||
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: Set up Frontend - Generate API client
|
- name: Run Typescript checks
|
||||||
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,16 +178,6 @@ 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,
|
||||||
_get_openai_client,
|
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=_get_openai_client(),
|
client=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 _get_openai_client().chat.completions.create(**create_kwargs) # type: ignore[arg-type] # dynamic kwargs
|
response = await 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 = ""
|
||||||
|
|||||||
162
autogpt_platform/backend/backend/copilot/integration_creds.py
Normal file
162
autogpt_platform/backend/backend/copilot/integration_creds.py
Normal file
@@ -0,0 +1,162 @@
|
|||||||
|
"""Integration credential lookup with per-process TTL cache.
|
||||||
|
|
||||||
|
Provides token retrieval for connected integrations so that copilot tools
|
||||||
|
(e.g. bash_exec) can inject auth tokens into the execution environment without
|
||||||
|
hitting the database on every command.
|
||||||
|
|
||||||
|
Cache semantics (handled automatically by TTLCache):
|
||||||
|
- Token found → cached for _TOKEN_CACHE_TTL (5 min). Avoids repeated DB hits
|
||||||
|
for users who have credentials and are running many bash commands.
|
||||||
|
- No credentials found → cached for _NULL_CACHE_TTL (60 s). Avoids a DB hit
|
||||||
|
on every E2B command for users who haven't connected an account yet, while
|
||||||
|
still picking up a newly-connected account within one minute.
|
||||||
|
|
||||||
|
Both caches are bounded to _CACHE_MAX_SIZE entries; cachetools evicts the
|
||||||
|
least-recently-used entry when the limit is reached.
|
||||||
|
|
||||||
|
Multi-worker note: both caches are in-process only. Each worker/replica
|
||||||
|
maintains its own independent cache, so a credential fetch may be duplicated
|
||||||
|
across processes. This is acceptable for the current goal (reduce DB hits per
|
||||||
|
session per-process), but if cache efficiency across replicas becomes important
|
||||||
|
a shared cache (e.g. Redis) should be used instead.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from typing import cast
|
||||||
|
|
||||||
|
from cachetools import TTLCache
|
||||||
|
|
||||||
|
from backend.data.model import APIKeyCredentials, OAuth2Credentials
|
||||||
|
from backend.integrations.creds_manager import (
|
||||||
|
IntegrationCredentialsManager,
|
||||||
|
register_creds_changed_hook,
|
||||||
|
)
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# Maps provider slug → env var names to inject when the provider is connected.
|
||||||
|
# Add new providers here when adding integration support.
|
||||||
|
# NOTE: keep in sync with connect_integration._PROVIDER_INFO — both registries
|
||||||
|
# must be updated when adding a new provider.
|
||||||
|
PROVIDER_ENV_VARS: dict[str, list[str]] = {
|
||||||
|
"github": ["GH_TOKEN", "GITHUB_TOKEN"],
|
||||||
|
}
|
||||||
|
|
||||||
|
_TOKEN_CACHE_TTL = 300.0 # seconds — for found tokens
|
||||||
|
_NULL_CACHE_TTL = 60.0 # seconds — for "not connected" results
|
||||||
|
_CACHE_MAX_SIZE = 10_000
|
||||||
|
|
||||||
|
# (user_id, provider) → token string. TTLCache handles expiry + eviction.
|
||||||
|
# Thread-safety note: TTLCache is NOT thread-safe, but that is acceptable here
|
||||||
|
# because all callers (get_provider_token, invalidate_user_provider_cache) run
|
||||||
|
# exclusively on the asyncio event loop. There are no await points between a
|
||||||
|
# cache read and its corresponding write within any function, so no concurrent
|
||||||
|
# coroutine can interleave. If ThreadPoolExecutor workers are ever added to
|
||||||
|
# this path, a threading.RLock should be wrapped around these caches.
|
||||||
|
_token_cache: TTLCache[tuple[str, str], str] = TTLCache(
|
||||||
|
maxsize=_CACHE_MAX_SIZE, ttl=_TOKEN_CACHE_TTL
|
||||||
|
)
|
||||||
|
# Separate cache for "no credentials" results with a shorter TTL.
|
||||||
|
_null_cache: TTLCache[tuple[str, str], bool] = TTLCache(
|
||||||
|
maxsize=_CACHE_MAX_SIZE, ttl=_NULL_CACHE_TTL
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def invalidate_user_provider_cache(user_id: str, provider: str) -> None:
|
||||||
|
"""Remove the cached entry for *user_id*/*provider* from both caches.
|
||||||
|
|
||||||
|
Call this after storing new credentials so that the next
|
||||||
|
``get_provider_token()`` call performs a fresh DB lookup instead of
|
||||||
|
serving a stale TTL-cached result.
|
||||||
|
"""
|
||||||
|
key = (user_id, provider)
|
||||||
|
_token_cache.pop(key, None)
|
||||||
|
_null_cache.pop(key, None)
|
||||||
|
|
||||||
|
|
||||||
|
# Register this module's cache-bust function with the credentials manager so
|
||||||
|
# that any create/update/delete operation immediately evicts stale cache
|
||||||
|
# entries. This avoids a lazy import inside creds_manager and eliminates the
|
||||||
|
# circular-import risk.
|
||||||
|
register_creds_changed_hook(invalidate_user_provider_cache)
|
||||||
|
|
||||||
|
# Module-level singleton to avoid re-instantiating IntegrationCredentialsManager
|
||||||
|
# on every cache-miss call to get_provider_token().
|
||||||
|
_manager = IntegrationCredentialsManager()
|
||||||
|
|
||||||
|
|
||||||
|
async def get_provider_token(user_id: str, provider: str) -> str | None:
|
||||||
|
"""Return the user's access token for *provider*, or ``None`` if not connected.
|
||||||
|
|
||||||
|
OAuth2 tokens are preferred (refreshed if needed); API keys are the fallback.
|
||||||
|
Found tokens are cached for _TOKEN_CACHE_TTL (5 min). "Not connected" results
|
||||||
|
are cached for _NULL_CACHE_TTL (60 s) to avoid a DB hit on every bash_exec
|
||||||
|
command for users who haven't connected yet, while still picking up a
|
||||||
|
newly-connected account within one minute.
|
||||||
|
"""
|
||||||
|
cache_key = (user_id, provider)
|
||||||
|
|
||||||
|
if cache_key in _null_cache:
|
||||||
|
return None
|
||||||
|
if cached := _token_cache.get(cache_key):
|
||||||
|
return cached
|
||||||
|
|
||||||
|
manager = _manager
|
||||||
|
try:
|
||||||
|
creds_list = await manager.store.get_creds_by_provider(user_id, provider)
|
||||||
|
except Exception:
|
||||||
|
logger.debug("Failed to fetch %s credentials for user %s", provider, user_id)
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Pass 1: prefer OAuth2 (carry scope info, refreshable via token endpoint).
|
||||||
|
# Sort so broader-scoped tokens come first: a token with "repo" scope covers
|
||||||
|
# full git access, while a public-data-only token lacks push/pull permission.
|
||||||
|
# lock=False — background injection; not worth a distributed lock acquisition.
|
||||||
|
oauth2_creds = sorted(
|
||||||
|
[c for c in creds_list if c.type == "oauth2"],
|
||||||
|
key=lambda c: 0 if "repo" in (cast(OAuth2Credentials, c).scopes or []) else 1,
|
||||||
|
)
|
||||||
|
for creds in oauth2_creds:
|
||||||
|
if creds.type == "oauth2":
|
||||||
|
try:
|
||||||
|
fresh = await manager.refresh_if_needed(
|
||||||
|
user_id, cast(OAuth2Credentials, creds), lock=False
|
||||||
|
)
|
||||||
|
token = fresh.access_token.get_secret_value()
|
||||||
|
except Exception:
|
||||||
|
logger.warning(
|
||||||
|
"Failed to refresh %s OAuth token for user %s; "
|
||||||
|
"falling back to potentially stale token",
|
||||||
|
provider,
|
||||||
|
user_id,
|
||||||
|
)
|
||||||
|
token = cast(OAuth2Credentials, creds).access_token.get_secret_value()
|
||||||
|
_token_cache[cache_key] = token
|
||||||
|
return token
|
||||||
|
|
||||||
|
# Pass 2: fall back to API key (no expiry, no refresh needed).
|
||||||
|
for creds in creds_list:
|
||||||
|
if creds.type == "api_key":
|
||||||
|
token = cast(APIKeyCredentials, creds).api_key.get_secret_value()
|
||||||
|
_token_cache[cache_key] = token
|
||||||
|
return token
|
||||||
|
|
||||||
|
# No credentials found — cache to avoid repeated DB hits.
|
||||||
|
_null_cache[cache_key] = True
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
async def get_integration_env_vars(user_id: str) -> dict[str, str]:
|
||||||
|
"""Return env vars for all providers the user has connected.
|
||||||
|
|
||||||
|
Iterates :data:`PROVIDER_ENV_VARS`, fetches each token, and builds a flat
|
||||||
|
``{env_var: token}`` dict ready to pass to a subprocess or E2B sandbox.
|
||||||
|
Only providers with a stored credential contribute entries.
|
||||||
|
"""
|
||||||
|
env: dict[str, str] = {}
|
||||||
|
for provider, var_names in PROVIDER_ENV_VARS.items():
|
||||||
|
token = await get_provider_token(user_id, provider)
|
||||||
|
if token:
|
||||||
|
for var in var_names:
|
||||||
|
env[var] = token
|
||||||
|
return env
|
||||||
@@ -0,0 +1,193 @@
|
|||||||
|
"""Tests for integration_creds — TTL cache and token lookup paths."""
|
||||||
|
|
||||||
|
from unittest.mock import AsyncMock, MagicMock, patch
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from pydantic import SecretStr
|
||||||
|
|
||||||
|
from backend.copilot.integration_creds import (
|
||||||
|
_NULL_CACHE_TTL,
|
||||||
|
_TOKEN_CACHE_TTL,
|
||||||
|
PROVIDER_ENV_VARS,
|
||||||
|
_null_cache,
|
||||||
|
_token_cache,
|
||||||
|
get_integration_env_vars,
|
||||||
|
get_provider_token,
|
||||||
|
invalidate_user_provider_cache,
|
||||||
|
)
|
||||||
|
from backend.data.model import APIKeyCredentials, OAuth2Credentials
|
||||||
|
|
||||||
|
_USER = "user-integration-creds-test"
|
||||||
|
_PROVIDER = "github"
|
||||||
|
|
||||||
|
|
||||||
|
def _make_api_key_creds(key: str = "test-api-key") -> APIKeyCredentials:
|
||||||
|
return APIKeyCredentials(
|
||||||
|
id="creds-api-key",
|
||||||
|
provider=_PROVIDER,
|
||||||
|
api_key=SecretStr(key),
|
||||||
|
title="Test API Key",
|
||||||
|
expires_at=None,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _make_oauth2_creds(token: str = "test-oauth-token") -> OAuth2Credentials:
|
||||||
|
return OAuth2Credentials(
|
||||||
|
id="creds-oauth2",
|
||||||
|
provider=_PROVIDER,
|
||||||
|
title="Test OAuth",
|
||||||
|
access_token=SecretStr(token),
|
||||||
|
refresh_token=SecretStr("test-refresh"),
|
||||||
|
access_token_expires_at=None,
|
||||||
|
refresh_token_expires_at=None,
|
||||||
|
scopes=[],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def clear_caches():
|
||||||
|
"""Ensure clean caches before and after every test."""
|
||||||
|
_token_cache.clear()
|
||||||
|
_null_cache.clear()
|
||||||
|
yield
|
||||||
|
_token_cache.clear()
|
||||||
|
_null_cache.clear()
|
||||||
|
|
||||||
|
|
||||||
|
class TestInvalidateUserProviderCache:
|
||||||
|
def test_removes_token_entry(self):
|
||||||
|
key = (_USER, _PROVIDER)
|
||||||
|
_token_cache[key] = "tok"
|
||||||
|
invalidate_user_provider_cache(_USER, _PROVIDER)
|
||||||
|
assert key not in _token_cache
|
||||||
|
|
||||||
|
def test_removes_null_entry(self):
|
||||||
|
key = (_USER, _PROVIDER)
|
||||||
|
_null_cache[key] = True
|
||||||
|
invalidate_user_provider_cache(_USER, _PROVIDER)
|
||||||
|
assert key not in _null_cache
|
||||||
|
|
||||||
|
def test_noop_when_key_not_cached(self):
|
||||||
|
# Should not raise even when there is no cache entry.
|
||||||
|
invalidate_user_provider_cache("no-such-user", _PROVIDER)
|
||||||
|
|
||||||
|
def test_only_removes_targeted_key(self):
|
||||||
|
other_key = ("other-user", _PROVIDER)
|
||||||
|
_token_cache[other_key] = "other-tok"
|
||||||
|
invalidate_user_provider_cache(_USER, _PROVIDER)
|
||||||
|
assert other_key in _token_cache
|
||||||
|
|
||||||
|
|
||||||
|
class TestGetProviderToken:
|
||||||
|
@pytest.mark.asyncio(loop_scope="session")
|
||||||
|
async def test_returns_cached_token_without_db_hit(self):
|
||||||
|
_token_cache[(_USER, _PROVIDER)] = "cached-tok"
|
||||||
|
|
||||||
|
mock_manager = MagicMock()
|
||||||
|
with patch("backend.copilot.integration_creds._manager", mock_manager):
|
||||||
|
result = await get_provider_token(_USER, _PROVIDER)
|
||||||
|
|
||||||
|
assert result == "cached-tok"
|
||||||
|
mock_manager.store.get_creds_by_provider.assert_not_called()
|
||||||
|
|
||||||
|
@pytest.mark.asyncio(loop_scope="session")
|
||||||
|
async def test_returns_none_for_null_cached_provider(self):
|
||||||
|
_null_cache[(_USER, _PROVIDER)] = True
|
||||||
|
|
||||||
|
mock_manager = MagicMock()
|
||||||
|
with patch("backend.copilot.integration_creds._manager", mock_manager):
|
||||||
|
result = await get_provider_token(_USER, _PROVIDER)
|
||||||
|
|
||||||
|
assert result is None
|
||||||
|
mock_manager.store.get_creds_by_provider.assert_not_called()
|
||||||
|
|
||||||
|
@pytest.mark.asyncio(loop_scope="session")
|
||||||
|
async def test_api_key_creds_returned_and_cached(self):
|
||||||
|
api_creds = _make_api_key_creds("my-api-key")
|
||||||
|
mock_manager = MagicMock()
|
||||||
|
mock_manager.store.get_creds_by_provider = AsyncMock(return_value=[api_creds])
|
||||||
|
|
||||||
|
with patch("backend.copilot.integration_creds._manager", mock_manager):
|
||||||
|
result = await get_provider_token(_USER, _PROVIDER)
|
||||||
|
|
||||||
|
assert result == "my-api-key"
|
||||||
|
assert _token_cache.get((_USER, _PROVIDER)) == "my-api-key"
|
||||||
|
|
||||||
|
@pytest.mark.asyncio(loop_scope="session")
|
||||||
|
async def test_oauth2_preferred_over_api_key(self):
|
||||||
|
oauth_creds = _make_oauth2_creds("oauth-tok")
|
||||||
|
api_creds = _make_api_key_creds("api-tok")
|
||||||
|
mock_manager = MagicMock()
|
||||||
|
mock_manager.store.get_creds_by_provider = AsyncMock(
|
||||||
|
return_value=[api_creds, oauth_creds]
|
||||||
|
)
|
||||||
|
mock_manager.refresh_if_needed = AsyncMock(return_value=oauth_creds)
|
||||||
|
|
||||||
|
with patch("backend.copilot.integration_creds._manager", mock_manager):
|
||||||
|
result = await get_provider_token(_USER, _PROVIDER)
|
||||||
|
|
||||||
|
assert result == "oauth-tok"
|
||||||
|
|
||||||
|
@pytest.mark.asyncio(loop_scope="session")
|
||||||
|
async def test_oauth2_refresh_failure_falls_back_to_stale_token(self):
|
||||||
|
oauth_creds = _make_oauth2_creds("stale-oauth-tok")
|
||||||
|
mock_manager = MagicMock()
|
||||||
|
mock_manager.store.get_creds_by_provider = AsyncMock(return_value=[oauth_creds])
|
||||||
|
mock_manager.refresh_if_needed = AsyncMock(side_effect=RuntimeError("network"))
|
||||||
|
|
||||||
|
with patch("backend.copilot.integration_creds._manager", mock_manager):
|
||||||
|
result = await get_provider_token(_USER, _PROVIDER)
|
||||||
|
|
||||||
|
assert result == "stale-oauth-tok"
|
||||||
|
|
||||||
|
@pytest.mark.asyncio(loop_scope="session")
|
||||||
|
async def test_no_credentials_caches_null_entry(self):
|
||||||
|
mock_manager = MagicMock()
|
||||||
|
mock_manager.store.get_creds_by_provider = AsyncMock(return_value=[])
|
||||||
|
|
||||||
|
with patch("backend.copilot.integration_creds._manager", mock_manager):
|
||||||
|
result = await get_provider_token(_USER, _PROVIDER)
|
||||||
|
|
||||||
|
assert result is None
|
||||||
|
assert _null_cache.get((_USER, _PROVIDER)) is True
|
||||||
|
|
||||||
|
@pytest.mark.asyncio(loop_scope="session")
|
||||||
|
async def test_db_exception_returns_none_without_caching(self):
|
||||||
|
mock_manager = MagicMock()
|
||||||
|
mock_manager.store.get_creds_by_provider = AsyncMock(
|
||||||
|
side_effect=RuntimeError("db down")
|
||||||
|
)
|
||||||
|
|
||||||
|
with patch("backend.copilot.integration_creds._manager", mock_manager):
|
||||||
|
result = await get_provider_token(_USER, _PROVIDER)
|
||||||
|
|
||||||
|
assert result is None
|
||||||
|
# DB errors are not cached — next call will retry
|
||||||
|
assert (_USER, _PROVIDER) not in _token_cache
|
||||||
|
assert (_USER, _PROVIDER) not in _null_cache
|
||||||
|
|
||||||
|
@pytest.mark.asyncio(loop_scope="session")
|
||||||
|
async def test_null_cache_has_shorter_ttl_than_token_cache(self):
|
||||||
|
"""Verify the TTL constants are set correctly for each cache."""
|
||||||
|
assert _null_cache.ttl == _NULL_CACHE_TTL
|
||||||
|
assert _token_cache.ttl == _TOKEN_CACHE_TTL
|
||||||
|
assert _NULL_CACHE_TTL < _TOKEN_CACHE_TTL
|
||||||
|
|
||||||
|
|
||||||
|
class TestGetIntegrationEnvVars:
|
||||||
|
@pytest.mark.asyncio(loop_scope="session")
|
||||||
|
async def test_injects_all_env_vars_for_provider(self):
|
||||||
|
_token_cache[(_USER, "github")] = "gh-tok"
|
||||||
|
|
||||||
|
result = await get_integration_env_vars(_USER)
|
||||||
|
|
||||||
|
for var in PROVIDER_ENV_VARS["github"]:
|
||||||
|
assert result[var] == "gh-tok"
|
||||||
|
|
||||||
|
@pytest.mark.asyncio(loop_scope="session")
|
||||||
|
async def test_empty_dict_when_no_credentials(self):
|
||||||
|
_null_cache[(_USER, "github")] = True
|
||||||
|
|
||||||
|
result = await get_integration_env_vars(_USER)
|
||||||
|
|
||||||
|
assert result == {}
|
||||||
@@ -95,6 +95,25 @@ Example — committing an image file to GitHub:
|
|||||||
All tasks must run in the foreground.
|
All tasks must run in the foreground.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
# E2B-only notes — E2B has full internet access so gh CLI works there.
|
||||||
|
# Not shown in local (bubblewrap) mode: --unshare-net blocks all network.
|
||||||
|
_E2B_TOOL_NOTES = """
|
||||||
|
### GitHub CLI (`gh`) and git
|
||||||
|
- If the user has connected their GitHub account, both `gh` and `git` are
|
||||||
|
pre-authenticated — use them directly without any manual login step.
|
||||||
|
`git` HTTPS operations (clone, push, pull) work automatically.
|
||||||
|
- If the token changes mid-session (e.g. user reconnects with a new token),
|
||||||
|
run `gh auth setup-git` to re-register the credential helper.
|
||||||
|
- If `gh` or `git` fails with an authentication error (e.g. "authentication
|
||||||
|
required", "could not read Username", or exit code 128), call
|
||||||
|
`connect_integration(provider="github")` to surface the GitHub credentials
|
||||||
|
setup card so the user can connect their account. Once connected, retry
|
||||||
|
the operation.
|
||||||
|
- For operations that need broader access (e.g. private org repos, GitHub
|
||||||
|
Actions), pass the required scopes: e.g.
|
||||||
|
`connect_integration(provider="github", scopes=["repo", "read:org"])`.
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
# Environment-specific supplement templates
|
# Environment-specific supplement templates
|
||||||
def _build_storage_supplement(
|
def _build_storage_supplement(
|
||||||
@@ -105,6 +124,7 @@ def _build_storage_supplement(
|
|||||||
storage_system_1_persistence: list[str],
|
storage_system_1_persistence: list[str],
|
||||||
file_move_name_1_to_2: str,
|
file_move_name_1_to_2: str,
|
||||||
file_move_name_2_to_1: str,
|
file_move_name_2_to_1: str,
|
||||||
|
extra_notes: str = "",
|
||||||
) -> str:
|
) -> str:
|
||||||
"""Build storage/filesystem supplement for a specific environment.
|
"""Build storage/filesystem supplement for a specific environment.
|
||||||
|
|
||||||
@@ -119,6 +139,7 @@ def _build_storage_supplement(
|
|||||||
storage_system_1_persistence: List of persistence behavior descriptions
|
storage_system_1_persistence: List of persistence behavior descriptions
|
||||||
file_move_name_1_to_2: Direction label for primary→persistent
|
file_move_name_1_to_2: Direction label for primary→persistent
|
||||||
file_move_name_2_to_1: Direction label for persistent→primary
|
file_move_name_2_to_1: Direction label for persistent→primary
|
||||||
|
extra_notes: Environment-specific notes appended after shared notes
|
||||||
"""
|
"""
|
||||||
# Format lists as bullet points with proper indentation
|
# Format lists as bullet points with proper indentation
|
||||||
characteristics = "\n".join(f" - {c}" for c in storage_system_1_characteristics)
|
characteristics = "\n".join(f" - {c}" for c in storage_system_1_characteristics)
|
||||||
@@ -152,12 +173,16 @@ def _build_storage_supplement(
|
|||||||
|
|
||||||
### File persistence
|
### File persistence
|
||||||
Important files (code, configs, outputs) should be saved to workspace to ensure they persist.
|
Important files (code, configs, outputs) should be saved to workspace to ensure they persist.
|
||||||
{_SHARED_TOOL_NOTES}"""
|
{_SHARED_TOOL_NOTES}{extra_notes}"""
|
||||||
|
|
||||||
|
|
||||||
# Pre-built supplements for common environments
|
# Pre-built supplements for common environments
|
||||||
def _get_local_storage_supplement(cwd: str) -> str:
|
def _get_local_storage_supplement(cwd: str) -> str:
|
||||||
"""Local ephemeral storage (files lost between turns)."""
|
"""Local ephemeral storage (files lost between turns).
|
||||||
|
|
||||||
|
Network is isolated (bubblewrap --unshare-net), so internet-dependent CLIs
|
||||||
|
like gh will not work — no integration env-var notes are included.
|
||||||
|
"""
|
||||||
return _build_storage_supplement(
|
return _build_storage_supplement(
|
||||||
working_dir=cwd,
|
working_dir=cwd,
|
||||||
sandbox_type="in a network-isolated sandbox",
|
sandbox_type="in a network-isolated sandbox",
|
||||||
@@ -175,7 +200,11 @@ def _get_local_storage_supplement(cwd: str) -> str:
|
|||||||
|
|
||||||
|
|
||||||
def _get_cloud_sandbox_supplement() -> str:
|
def _get_cloud_sandbox_supplement() -> str:
|
||||||
"""Cloud persistent sandbox (files survive across turns in session)."""
|
"""Cloud persistent sandbox (files survive across turns in session).
|
||||||
|
|
||||||
|
E2B has full internet access, so integration tokens (GH_TOKEN etc.) are
|
||||||
|
injected per command in bash_exec — include the CLI guidance notes.
|
||||||
|
"""
|
||||||
return _build_storage_supplement(
|
return _build_storage_supplement(
|
||||||
working_dir="/home/user",
|
working_dir="/home/user",
|
||||||
sandbox_type="in a cloud sandbox with full internet access",
|
sandbox_type="in a cloud sandbox with full internet access",
|
||||||
@@ -190,6 +219,7 @@ def _get_cloud_sandbox_supplement() -> str:
|
|||||||
],
|
],
|
||||||
file_move_name_1_to_2="Sandbox → Persistent",
|
file_move_name_1_to_2="Sandbox → Persistent",
|
||||||
file_move_name_2_to_1="Persistent → Sandbox",
|
file_move_name_2_to_1="Persistent → Sandbox",
|
||||||
|
extra_notes=_E2B_TOOL_NOTES,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -769,7 +769,7 @@ async def stream_chat_completion_sdk(
|
|||||||
)
|
)
|
||||||
return None
|
return None
|
||||||
try:
|
try:
|
||||||
return await get_or_create_sandbox(
|
sandbox = await get_or_create_sandbox(
|
||||||
session_id,
|
session_id,
|
||||||
api_key=e2b_api_key,
|
api_key=e2b_api_key,
|
||||||
template=config.e2b_sandbox_template,
|
template=config.e2b_sandbox_template,
|
||||||
@@ -785,6 +785,8 @@ async def stream_chat_completion_sdk(
|
|||||||
)
|
)
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
return sandbox
|
||||||
|
|
||||||
async def _fetch_transcript():
|
async def _fetch_transcript():
|
||||||
"""Download transcript for --resume if applicable."""
|
"""Download transcript for --resume if applicable."""
|
||||||
if not (
|
if not (
|
||||||
|
|||||||
@@ -28,24 +28,10 @@ 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
|
|
||||||
|
|
||||||
|
|
||||||
def _get_openai_client() -> LangfuseAsyncOpenAI:
|
langfuse = get_client()
|
||||||
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
|
||||||
@@ -98,7 +84,7 @@ async def _get_system_prompt_template(context: str) -> str:
|
|||||||
else "latest"
|
else "latest"
|
||||||
)
|
)
|
||||||
prompt = await asyncio.to_thread(
|
prompt = await asyncio.to_thread(
|
||||||
_get_langfuse().get_prompt,
|
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,
|
||||||
@@ -172,7 +158,7 @@ async def _generate_session_title(
|
|||||||
"environment": settings.config.app_env.value,
|
"environment": settings.config.app_env.value,
|
||||||
}
|
}
|
||||||
|
|
||||||
response = await _get_openai_client().chat.completions.create(
|
response = await client.chat.completions.create(
|
||||||
model=config.title_model,
|
model=config.title_model,
|
||||||
messages=[
|
messages=[
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ from .agent_browser import BrowserActTool, BrowserNavigateTool, BrowserScreensho
|
|||||||
from .agent_output import AgentOutputTool
|
from .agent_output import AgentOutputTool
|
||||||
from .base import BaseTool
|
from .base import BaseTool
|
||||||
from .bash_exec import BashExecTool
|
from .bash_exec import BashExecTool
|
||||||
|
from .connect_integration import ConnectIntegrationTool
|
||||||
from .continue_run_block import ContinueRunBlockTool
|
from .continue_run_block import ContinueRunBlockTool
|
||||||
from .create_agent import CreateAgentTool
|
from .create_agent import CreateAgentTool
|
||||||
from .customize_agent import CustomizeAgentTool
|
from .customize_agent import CustomizeAgentTool
|
||||||
@@ -84,6 +85,7 @@ TOOL_REGISTRY: dict[str, BaseTool] = {
|
|||||||
"browser_screenshot": BrowserScreenshotTool(),
|
"browser_screenshot": BrowserScreenshotTool(),
|
||||||
# Sandboxed code execution (bubblewrap)
|
# Sandboxed code execution (bubblewrap)
|
||||||
"bash_exec": BashExecTool(),
|
"bash_exec": BashExecTool(),
|
||||||
|
"connect_integration": ConnectIntegrationTool(),
|
||||||
# Persistent workspace tools (cloud storage, survives across sessions)
|
# Persistent workspace tools (cloud storage, survives across sessions)
|
||||||
# Feature request tools
|
# Feature request tools
|
||||||
"search_feature_requests": SearchFeatureRequestsTool(),
|
"search_feature_requests": SearchFeatureRequestsTool(),
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ from e2b import AsyncSandbox
|
|||||||
from e2b.exceptions import TimeoutException
|
from e2b.exceptions import TimeoutException
|
||||||
|
|
||||||
from backend.copilot.context import E2B_WORKDIR, get_current_sandbox
|
from backend.copilot.context import E2B_WORKDIR, get_current_sandbox
|
||||||
|
from backend.copilot.integration_creds import get_integration_env_vars
|
||||||
from backend.copilot.model import ChatSession
|
from backend.copilot.model import ChatSession
|
||||||
|
|
||||||
from .base import BaseTool
|
from .base import BaseTool
|
||||||
@@ -96,7 +97,9 @@ class BashExecTool(BaseTool):
|
|||||||
|
|
||||||
sandbox = get_current_sandbox()
|
sandbox = get_current_sandbox()
|
||||||
if sandbox is not None:
|
if sandbox is not None:
|
||||||
return await self._execute_on_e2b(sandbox, command, timeout, session_id)
|
return await self._execute_on_e2b(
|
||||||
|
sandbox, command, timeout, session_id, user_id
|
||||||
|
)
|
||||||
|
|
||||||
# Bubblewrap fallback: local isolated execution.
|
# Bubblewrap fallback: local isolated execution.
|
||||||
if not has_full_sandbox():
|
if not has_full_sandbox():
|
||||||
@@ -133,14 +136,27 @@ class BashExecTool(BaseTool):
|
|||||||
command: str,
|
command: str,
|
||||||
timeout: int,
|
timeout: int,
|
||||||
session_id: str | None,
|
session_id: str | None,
|
||||||
|
user_id: str | None = None,
|
||||||
) -> ToolResponseBase:
|
) -> ToolResponseBase:
|
||||||
"""Execute *command* on the E2B sandbox via commands.run()."""
|
"""Execute *command* on the E2B sandbox via commands.run().
|
||||||
|
|
||||||
|
Integration tokens (e.g. GH_TOKEN) are injected into the sandbox env
|
||||||
|
for any user with connected accounts. E2B has full internet access, so
|
||||||
|
CLI tools like ``gh`` work without manual authentication.
|
||||||
|
"""
|
||||||
|
envs: dict[str, str] = {
|
||||||
|
"PATH": "/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin",
|
||||||
|
}
|
||||||
|
if user_id is not None:
|
||||||
|
integration_env = await get_integration_env_vars(user_id)
|
||||||
|
envs.update(integration_env)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
result = await sandbox.commands.run(
|
result = await sandbox.commands.run(
|
||||||
f"bash -c {shlex.quote(command)}",
|
f"bash -c {shlex.quote(command)}",
|
||||||
cwd=E2B_WORKDIR,
|
cwd=E2B_WORKDIR,
|
||||||
timeout=timeout,
|
timeout=timeout,
|
||||||
envs={"PATH": "/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin"},
|
envs=envs,
|
||||||
)
|
)
|
||||||
return BashExecResponse(
|
return BashExecResponse(
|
||||||
message=f"Command executed on E2B (exit {result.exit_code})",
|
message=f"Command executed on E2B (exit {result.exit_code})",
|
||||||
|
|||||||
@@ -0,0 +1,78 @@
|
|||||||
|
"""Tests for BashExecTool — E2B path with token injection."""
|
||||||
|
|
||||||
|
from unittest.mock import AsyncMock, MagicMock, patch
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from ._test_data import make_session
|
||||||
|
from .bash_exec import BashExecTool
|
||||||
|
from .models import BashExecResponse
|
||||||
|
|
||||||
|
_USER = "user-bash-exec-test"
|
||||||
|
|
||||||
|
|
||||||
|
def _make_tool() -> BashExecTool:
|
||||||
|
return BashExecTool()
|
||||||
|
|
||||||
|
|
||||||
|
def _make_sandbox(exit_code: int = 0, stdout: str = "", stderr: str = "") -> MagicMock:
|
||||||
|
result = MagicMock()
|
||||||
|
result.exit_code = exit_code
|
||||||
|
result.stdout = stdout
|
||||||
|
result.stderr = stderr
|
||||||
|
|
||||||
|
sandbox = MagicMock()
|
||||||
|
sandbox.commands.run = AsyncMock(return_value=result)
|
||||||
|
return sandbox
|
||||||
|
|
||||||
|
|
||||||
|
class TestBashExecE2BTokenInjection:
|
||||||
|
@pytest.mark.asyncio(loop_scope="session")
|
||||||
|
async def test_token_injected_when_user_id_set(self):
|
||||||
|
"""When user_id is provided, integration env vars are merged into sandbox envs."""
|
||||||
|
tool = _make_tool()
|
||||||
|
session = make_session(user_id=_USER)
|
||||||
|
sandbox = _make_sandbox(stdout="ok")
|
||||||
|
env_vars = {"GH_TOKEN": "gh-secret", "GITHUB_TOKEN": "gh-secret"}
|
||||||
|
|
||||||
|
with patch(
|
||||||
|
"backend.copilot.tools.bash_exec.get_integration_env_vars",
|
||||||
|
new=AsyncMock(return_value=env_vars),
|
||||||
|
) as mock_get_env:
|
||||||
|
result = await tool._execute_on_e2b(
|
||||||
|
sandbox=sandbox,
|
||||||
|
command="echo hi",
|
||||||
|
timeout=10,
|
||||||
|
session_id=session.session_id,
|
||||||
|
user_id=_USER,
|
||||||
|
)
|
||||||
|
|
||||||
|
mock_get_env.assert_awaited_once_with(_USER)
|
||||||
|
call_kwargs = sandbox.commands.run.call_args[1]
|
||||||
|
assert call_kwargs["envs"]["GH_TOKEN"] == "gh-secret"
|
||||||
|
assert call_kwargs["envs"]["GITHUB_TOKEN"] == "gh-secret"
|
||||||
|
assert isinstance(result, BashExecResponse)
|
||||||
|
|
||||||
|
@pytest.mark.asyncio(loop_scope="session")
|
||||||
|
async def test_no_token_injection_when_user_id_is_none(self):
|
||||||
|
"""When user_id is None, get_integration_env_vars must NOT be called."""
|
||||||
|
tool = _make_tool()
|
||||||
|
session = make_session(user_id=_USER)
|
||||||
|
sandbox = _make_sandbox(stdout="ok")
|
||||||
|
|
||||||
|
with patch(
|
||||||
|
"backend.copilot.tools.bash_exec.get_integration_env_vars",
|
||||||
|
new=AsyncMock(return_value={"GH_TOKEN": "should-not-appear"}),
|
||||||
|
) as mock_get_env:
|
||||||
|
result = await tool._execute_on_e2b(
|
||||||
|
sandbox=sandbox,
|
||||||
|
command="echo hi",
|
||||||
|
timeout=10,
|
||||||
|
session_id=session.session_id,
|
||||||
|
user_id=None,
|
||||||
|
)
|
||||||
|
|
||||||
|
mock_get_env.assert_not_called()
|
||||||
|
call_kwargs = sandbox.commands.run.call_args[1]
|
||||||
|
assert "GH_TOKEN" not in call_kwargs["envs"]
|
||||||
|
assert isinstance(result, BashExecResponse)
|
||||||
@@ -0,0 +1,215 @@
|
|||||||
|
"""Tool for prompting the user to connect a required integration.
|
||||||
|
|
||||||
|
When the copilot encounters an authentication failure (e.g. `gh` CLI returns
|
||||||
|
"authentication required"), it calls this tool to surface the credentials
|
||||||
|
setup card in the chat — the same UI that appears when a GitHub block runs
|
||||||
|
without configured credentials.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import functools
|
||||||
|
from typing import Any, TypedDict
|
||||||
|
|
||||||
|
from backend.copilot.model import ChatSession
|
||||||
|
from backend.copilot.tools.models import (
|
||||||
|
ErrorResponse,
|
||||||
|
ResponseType,
|
||||||
|
SetupInfo,
|
||||||
|
SetupRequirementsResponse,
|
||||||
|
ToolResponseBase,
|
||||||
|
UserReadiness,
|
||||||
|
)
|
||||||
|
|
||||||
|
from .base import BaseTool
|
||||||
|
|
||||||
|
|
||||||
|
class _ProviderInfo(TypedDict):
|
||||||
|
name: str
|
||||||
|
types: list[str]
|
||||||
|
# Default OAuth scopes requested when the agent doesn't specify any.
|
||||||
|
scopes: list[str]
|
||||||
|
|
||||||
|
|
||||||
|
class _CredentialEntry(TypedDict):
|
||||||
|
"""Shape of each entry inside SetupRequirementsResponse.user_readiness.missing_credentials."""
|
||||||
|
|
||||||
|
id: str
|
||||||
|
title: str
|
||||||
|
provider: str
|
||||||
|
provider_name: str
|
||||||
|
type: str
|
||||||
|
types: list[str]
|
||||||
|
scopes: list[str]
|
||||||
|
|
||||||
|
|
||||||
|
@functools.lru_cache(maxsize=1)
|
||||||
|
def _is_github_oauth_configured() -> bool:
|
||||||
|
"""Return True if GitHub OAuth env vars are set.
|
||||||
|
|
||||||
|
Evaluated lazily (not at import time) to avoid triggering Secrets() during
|
||||||
|
module import, which can fail in environments where secrets are not loaded.
|
||||||
|
"""
|
||||||
|
from backend.blocks.github._auth import GITHUB_OAUTH_IS_CONFIGURED
|
||||||
|
|
||||||
|
return GITHUB_OAUTH_IS_CONFIGURED
|
||||||
|
|
||||||
|
|
||||||
|
# Registry of known providers: name + supported credential types for the UI.
|
||||||
|
# When adding a new provider, also add its env var names to
|
||||||
|
# backend.copilot.integration_creds.PROVIDER_ENV_VARS.
|
||||||
|
def _get_provider_info() -> dict[str, _ProviderInfo]:
|
||||||
|
"""Build the provider registry, evaluating OAuth config lazily."""
|
||||||
|
return {
|
||||||
|
"github": {
|
||||||
|
"name": "GitHub",
|
||||||
|
"types": (
|
||||||
|
["api_key", "oauth2"] if _is_github_oauth_configured() else ["api_key"]
|
||||||
|
),
|
||||||
|
# Default: repo scope covers clone/push/pull for public and private repos.
|
||||||
|
# Agent can request additional scopes (e.g. "read:org") via the scopes param.
|
||||||
|
"scopes": ["repo"],
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class ConnectIntegrationTool(BaseTool):
|
||||||
|
"""Surface the credentials setup UI when an integration is not connected."""
|
||||||
|
|
||||||
|
@property
|
||||||
|
def name(self) -> str:
|
||||||
|
return "connect_integration"
|
||||||
|
|
||||||
|
@property
|
||||||
|
def description(self) -> str:
|
||||||
|
return (
|
||||||
|
"Prompt the user to connect a required integration (e.g. GitHub). "
|
||||||
|
"Call this when an external CLI or API call fails because the user "
|
||||||
|
"has not connected the relevant account. "
|
||||||
|
"The tool surfaces a credentials setup card in the chat so the user "
|
||||||
|
"can authenticate without leaving the page. "
|
||||||
|
"After the user connects the account, retry the operation. "
|
||||||
|
"In E2B/cloud sandbox mode the token (GH_TOKEN/GITHUB_TOKEN) is "
|
||||||
|
"automatically injected per-command in bash_exec — no manual export needed. "
|
||||||
|
"In local bubblewrap mode network is isolated so GitHub CLI commands "
|
||||||
|
"will still fail after connecting; inform the user of this limitation."
|
||||||
|
)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def parameters(self) -> dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"provider": {
|
||||||
|
"type": "string",
|
||||||
|
"description": (
|
||||||
|
"Integration provider slug, e.g. 'github'. "
|
||||||
|
"Must be one of the supported providers."
|
||||||
|
),
|
||||||
|
"enum": list(_get_provider_info().keys()),
|
||||||
|
},
|
||||||
|
"reason": {
|
||||||
|
"type": "string",
|
||||||
|
"description": (
|
||||||
|
"Brief explanation of why the integration is needed, "
|
||||||
|
"shown to the user in the setup card."
|
||||||
|
),
|
||||||
|
"maxLength": 500,
|
||||||
|
},
|
||||||
|
"scopes": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {"type": "string"},
|
||||||
|
"description": (
|
||||||
|
"OAuth scopes to request. Omit to use the provider default. "
|
||||||
|
"Add extra scopes when you need more access — e.g. for GitHub: "
|
||||||
|
"'repo' (clone/push/pull), 'read:org' (org membership), "
|
||||||
|
"'workflow' (GitHub Actions). "
|
||||||
|
"Requesting only the scopes you actually need is best practice."
|
||||||
|
),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"required": ["provider"],
|
||||||
|
}
|
||||||
|
|
||||||
|
@property
|
||||||
|
def requires_auth(self) -> bool:
|
||||||
|
# Require auth so only authenticated users can trigger the setup card.
|
||||||
|
# The card itself is user-agnostic (no per-user data needed), so
|
||||||
|
# user_id is intentionally unused in _execute.
|
||||||
|
return True
|
||||||
|
|
||||||
|
async def _execute(
|
||||||
|
self,
|
||||||
|
user_id: str | None,
|
||||||
|
session: ChatSession,
|
||||||
|
**kwargs: Any,
|
||||||
|
) -> ToolResponseBase:
|
||||||
|
del user_id # setup card is user-agnostic; auth is enforced via requires_auth
|
||||||
|
session_id = session.session_id if session else None
|
||||||
|
provider: str = (kwargs.get("provider") or "").strip().lower()
|
||||||
|
reason: str = (kwargs.get("reason") or "").strip()[
|
||||||
|
:500
|
||||||
|
] # cap LLM-controlled text
|
||||||
|
extra_scopes: list[str] = [
|
||||||
|
str(s).strip() for s in (kwargs.get("scopes") or []) if str(s).strip()
|
||||||
|
]
|
||||||
|
|
||||||
|
provider_info = _get_provider_info()
|
||||||
|
info = provider_info.get(provider)
|
||||||
|
if not info:
|
||||||
|
supported = ", ".join(f"'{p}'" for p in provider_info)
|
||||||
|
return ErrorResponse(
|
||||||
|
message=(
|
||||||
|
f"Unknown provider '{provider}'. "
|
||||||
|
f"Supported providers: {supported}."
|
||||||
|
),
|
||||||
|
error="unknown_provider",
|
||||||
|
session_id=session_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
provider_name: str = info["name"]
|
||||||
|
supported_types: list[str] = info["types"]
|
||||||
|
# Merge agent-requested scopes with provider defaults (deduplicated, order preserved).
|
||||||
|
default_scopes: list[str] = info["scopes"]
|
||||||
|
seen: set[str] = set()
|
||||||
|
scopes: list[str] = []
|
||||||
|
for s in default_scopes + extra_scopes:
|
||||||
|
if s not in seen:
|
||||||
|
seen.add(s)
|
||||||
|
scopes.append(s)
|
||||||
|
field_key = f"{provider}_credentials"
|
||||||
|
|
||||||
|
message_parts = [
|
||||||
|
f"To continue, please connect your {provider_name} account.",
|
||||||
|
]
|
||||||
|
if reason:
|
||||||
|
message_parts.append(reason)
|
||||||
|
|
||||||
|
credential_entry: _CredentialEntry = {
|
||||||
|
"id": field_key,
|
||||||
|
"title": f"{provider_name} Credentials",
|
||||||
|
"provider": provider,
|
||||||
|
"provider_name": provider_name,
|
||||||
|
"type": supported_types[0],
|
||||||
|
"types": supported_types,
|
||||||
|
"scopes": scopes,
|
||||||
|
}
|
||||||
|
missing_credentials: dict[str, _CredentialEntry] = {field_key: credential_entry}
|
||||||
|
|
||||||
|
return SetupRequirementsResponse(
|
||||||
|
type=ResponseType.SETUP_REQUIREMENTS,
|
||||||
|
message=" ".join(message_parts),
|
||||||
|
session_id=session_id,
|
||||||
|
setup_info=SetupInfo(
|
||||||
|
agent_id=f"connect_{provider}",
|
||||||
|
agent_name=provider_name,
|
||||||
|
user_readiness=UserReadiness(
|
||||||
|
has_all_credentials=False,
|
||||||
|
missing_credentials=missing_credentials,
|
||||||
|
ready_to_run=False,
|
||||||
|
),
|
||||||
|
requirements={
|
||||||
|
"credentials": [missing_credentials[field_key]],
|
||||||
|
"inputs": [],
|
||||||
|
"execution_modes": [],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
)
|
||||||
@@ -0,0 +1,135 @@
|
|||||||
|
"""Tests for ConnectIntegrationTool."""
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from ._test_data import make_session
|
||||||
|
from .connect_integration import ConnectIntegrationTool
|
||||||
|
from .models import ErrorResponse, SetupRequirementsResponse
|
||||||
|
|
||||||
|
_TEST_USER_ID = "test-user-connect-integration"
|
||||||
|
|
||||||
|
|
||||||
|
class TestConnectIntegrationTool:
|
||||||
|
def _make_tool(self) -> ConnectIntegrationTool:
|
||||||
|
return ConnectIntegrationTool()
|
||||||
|
|
||||||
|
@pytest.mark.asyncio(loop_scope="session")
|
||||||
|
async def test_unknown_provider_returns_error(self):
|
||||||
|
tool = self._make_tool()
|
||||||
|
session = make_session(user_id=_TEST_USER_ID)
|
||||||
|
result = await tool._execute(
|
||||||
|
user_id=_TEST_USER_ID, session=session, provider="nonexistent"
|
||||||
|
)
|
||||||
|
assert isinstance(result, ErrorResponse)
|
||||||
|
assert result.error == "unknown_provider"
|
||||||
|
assert "nonexistent" in result.message
|
||||||
|
assert "github" in result.message
|
||||||
|
|
||||||
|
@pytest.mark.asyncio(loop_scope="session")
|
||||||
|
async def test_empty_provider_returns_error(self):
|
||||||
|
tool = self._make_tool()
|
||||||
|
session = make_session(user_id=_TEST_USER_ID)
|
||||||
|
result = await tool._execute(
|
||||||
|
user_id=_TEST_USER_ID, session=session, provider=""
|
||||||
|
)
|
||||||
|
assert isinstance(result, ErrorResponse)
|
||||||
|
assert result.error == "unknown_provider"
|
||||||
|
|
||||||
|
@pytest.mark.asyncio(loop_scope="session")
|
||||||
|
async def test_github_provider_returns_setup_response(self):
|
||||||
|
tool = self._make_tool()
|
||||||
|
session = make_session(user_id=_TEST_USER_ID)
|
||||||
|
result = await tool._execute(
|
||||||
|
user_id=_TEST_USER_ID, session=session, provider="github"
|
||||||
|
)
|
||||||
|
assert isinstance(result, SetupRequirementsResponse)
|
||||||
|
assert result.setup_info.agent_name == "GitHub"
|
||||||
|
assert result.setup_info.agent_id == "connect_github"
|
||||||
|
|
||||||
|
@pytest.mark.asyncio(loop_scope="session")
|
||||||
|
async def test_github_has_missing_credentials_in_readiness(self):
|
||||||
|
tool = self._make_tool()
|
||||||
|
session = make_session(user_id=_TEST_USER_ID)
|
||||||
|
result = await tool._execute(
|
||||||
|
user_id=_TEST_USER_ID, session=session, provider="github"
|
||||||
|
)
|
||||||
|
assert isinstance(result, SetupRequirementsResponse)
|
||||||
|
readiness = result.setup_info.user_readiness
|
||||||
|
assert readiness.has_all_credentials is False
|
||||||
|
assert readiness.ready_to_run is False
|
||||||
|
assert "github_credentials" in readiness.missing_credentials
|
||||||
|
|
||||||
|
@pytest.mark.asyncio(loop_scope="session")
|
||||||
|
async def test_github_requirements_include_credential_entry(self):
|
||||||
|
tool = self._make_tool()
|
||||||
|
session = make_session(user_id=_TEST_USER_ID)
|
||||||
|
result = await tool._execute(
|
||||||
|
user_id=_TEST_USER_ID, session=session, provider="github"
|
||||||
|
)
|
||||||
|
assert isinstance(result, SetupRequirementsResponse)
|
||||||
|
creds = result.setup_info.requirements["credentials"]
|
||||||
|
assert len(creds) == 1
|
||||||
|
assert creds[0]["provider"] == "github"
|
||||||
|
assert creds[0]["id"] == "github_credentials"
|
||||||
|
|
||||||
|
@pytest.mark.asyncio(loop_scope="session")
|
||||||
|
async def test_reason_appears_in_message(self):
|
||||||
|
tool = self._make_tool()
|
||||||
|
session = make_session(user_id=_TEST_USER_ID)
|
||||||
|
reason = "Needed to create a pull request."
|
||||||
|
result = await tool._execute(
|
||||||
|
user_id=_TEST_USER_ID, session=session, provider="github", reason=reason
|
||||||
|
)
|
||||||
|
assert isinstance(result, SetupRequirementsResponse)
|
||||||
|
assert reason in result.message
|
||||||
|
|
||||||
|
@pytest.mark.asyncio(loop_scope="session")
|
||||||
|
async def test_session_id_propagated(self):
|
||||||
|
tool = self._make_tool()
|
||||||
|
session = make_session(user_id=_TEST_USER_ID)
|
||||||
|
result = await tool._execute(
|
||||||
|
user_id=_TEST_USER_ID, session=session, provider="github"
|
||||||
|
)
|
||||||
|
assert isinstance(result, SetupRequirementsResponse)
|
||||||
|
assert result.session_id == session.session_id
|
||||||
|
|
||||||
|
@pytest.mark.asyncio(loop_scope="session")
|
||||||
|
async def test_provider_case_insensitive(self):
|
||||||
|
"""Provider slug is normalised to lowercase before lookup."""
|
||||||
|
tool = self._make_tool()
|
||||||
|
session = make_session(user_id=_TEST_USER_ID)
|
||||||
|
result = await tool._execute(
|
||||||
|
user_id=_TEST_USER_ID, session=session, provider="GitHub"
|
||||||
|
)
|
||||||
|
assert isinstance(result, SetupRequirementsResponse)
|
||||||
|
|
||||||
|
def test_tool_name(self):
|
||||||
|
assert ConnectIntegrationTool().name == "connect_integration"
|
||||||
|
|
||||||
|
def test_requires_auth(self):
|
||||||
|
assert ConnectIntegrationTool().requires_auth is True
|
||||||
|
|
||||||
|
@pytest.mark.asyncio(loop_scope="session")
|
||||||
|
async def test_unauthenticated_user_gets_need_login_response(self):
|
||||||
|
"""execute() with user_id=None must return NeedLoginResponse, not the setup card.
|
||||||
|
|
||||||
|
This verifies that the requires_auth guard in BaseTool.execute() fires
|
||||||
|
before _execute() is called, so unauthenticated callers cannot probe
|
||||||
|
which integrations are configured.
|
||||||
|
"""
|
||||||
|
import json
|
||||||
|
|
||||||
|
tool = self._make_tool()
|
||||||
|
# Session still needs a user_id string; the None is passed to execute()
|
||||||
|
# to simulate an unauthenticated call.
|
||||||
|
session = make_session(user_id=_TEST_USER_ID)
|
||||||
|
result = await tool.execute(
|
||||||
|
user_id=None,
|
||||||
|
session=session,
|
||||||
|
tool_call_id="test-call-id",
|
||||||
|
provider="github",
|
||||||
|
)
|
||||||
|
raw = result.output
|
||||||
|
output = json.loads(raw) if isinstance(raw, str) else raw
|
||||||
|
assert output.get("type") == "need_login"
|
||||||
|
assert result.success is False
|
||||||
@@ -25,6 +25,35 @@ logger = logging.getLogger(__name__)
|
|||||||
settings = Settings()
|
settings = Settings()
|
||||||
|
|
||||||
|
|
||||||
|
_on_creds_changed: Callable[[str, str], None] | None = None
|
||||||
|
|
||||||
|
|
||||||
|
def register_creds_changed_hook(hook: Callable[[str, str], None]) -> None:
|
||||||
|
"""Register a callback invoked after any credential is created/updated/deleted.
|
||||||
|
|
||||||
|
The callback receives ``(user_id, provider)`` and should be idempotent.
|
||||||
|
Only one hook can be registered at a time; calling this again replaces the
|
||||||
|
previous hook. Intended to be called once at application startup by the
|
||||||
|
copilot module to bust its token cache without creating an import cycle.
|
||||||
|
"""
|
||||||
|
global _on_creds_changed
|
||||||
|
_on_creds_changed = hook
|
||||||
|
|
||||||
|
|
||||||
|
def _bust_copilot_cache(user_id: str, provider: str) -> None:
|
||||||
|
"""Invoke the registered hook (if any) to bust downstream token caches."""
|
||||||
|
if _on_creds_changed is not None:
|
||||||
|
try:
|
||||||
|
_on_creds_changed(user_id, provider)
|
||||||
|
except Exception:
|
||||||
|
logger.warning(
|
||||||
|
"Credential-change hook failed for user=%s provider=%s",
|
||||||
|
user_id,
|
||||||
|
provider,
|
||||||
|
exc_info=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class IntegrationCredentialsManager:
|
class IntegrationCredentialsManager:
|
||||||
"""
|
"""
|
||||||
Handles the lifecycle of integration credentials.
|
Handles the lifecycle of integration credentials.
|
||||||
@@ -69,7 +98,11 @@ class IntegrationCredentialsManager:
|
|||||||
return self._locks
|
return self._locks
|
||||||
|
|
||||||
async def create(self, user_id: str, credentials: Credentials) -> None:
|
async def create(self, user_id: str, credentials: Credentials) -> None:
|
||||||
return await self.store.add_creds(user_id, credentials)
|
result = await self.store.add_creds(user_id, credentials)
|
||||||
|
# Bust the copilot token cache so that the next bash_exec picks up the
|
||||||
|
# new credential immediately instead of waiting for _NULL_CACHE_TTL.
|
||||||
|
_bust_copilot_cache(user_id, credentials.provider)
|
||||||
|
return result
|
||||||
|
|
||||||
async def exists(self, user_id: str, credentials_id: str) -> bool:
|
async def exists(self, user_id: str, credentials_id: str) -> bool:
|
||||||
return (await self.store.get_creds_by_id(user_id, credentials_id)) is not None
|
return (await self.store.get_creds_by_id(user_id, credentials_id)) is not None
|
||||||
@@ -156,6 +189,8 @@ class IntegrationCredentialsManager:
|
|||||||
|
|
||||||
fresh_credentials = await oauth_handler.refresh_tokens(credentials)
|
fresh_credentials = await oauth_handler.refresh_tokens(credentials)
|
||||||
await self.store.update_creds(user_id, fresh_credentials)
|
await self.store.update_creds(user_id, fresh_credentials)
|
||||||
|
# Bust copilot cache so the refreshed token is picked up immediately.
|
||||||
|
_bust_copilot_cache(user_id, fresh_credentials.provider)
|
||||||
if _lock and (await _lock.locked()) and (await _lock.owned()):
|
if _lock and (await _lock.locked()) and (await _lock.owned()):
|
||||||
try:
|
try:
|
||||||
await _lock.release()
|
await _lock.release()
|
||||||
@@ -168,10 +203,17 @@ class IntegrationCredentialsManager:
|
|||||||
async def update(self, user_id: str, updated: Credentials) -> None:
|
async def update(self, user_id: str, updated: Credentials) -> None:
|
||||||
async with self._locked(user_id, updated.id):
|
async with self._locked(user_id, updated.id):
|
||||||
await self.store.update_creds(user_id, updated)
|
await self.store.update_creds(user_id, updated)
|
||||||
|
# Bust the copilot token cache so the updated credential is picked up immediately.
|
||||||
|
_bust_copilot_cache(user_id, updated.provider)
|
||||||
|
|
||||||
async def delete(self, user_id: str, credentials_id: str) -> None:
|
async def delete(self, user_id: str, credentials_id: str) -> None:
|
||||||
async with self._locked(user_id, credentials_id):
|
async with self._locked(user_id, credentials_id):
|
||||||
|
# Read inside the lock to avoid TOCTOU — another coroutine could
|
||||||
|
# delete the same credential between the read and the delete.
|
||||||
|
creds = await self.store.get_creds_by_id(user_id, credentials_id)
|
||||||
await self.store.delete_creds_by_id(user_id, credentials_id)
|
await self.store.delete_creds_by_id(user_id, credentials_id)
|
||||||
|
if creds:
|
||||||
|
_bust_copilot_cache(user_id, creds.provider)
|
||||||
|
|
||||||
# -- Locking utilities -- #
|
# -- Locking utilities -- #
|
||||||
|
|
||||||
|
|||||||
@@ -183,8 +183,7 @@ class WorkspaceManager:
|
|||||||
f"{Config().max_file_size_mb}MB limit"
|
f"{Config().max_file_size_mb}MB limit"
|
||||||
)
|
)
|
||||||
|
|
||||||
# Scan here — callers must NOT duplicate this scan.
|
# Virus scan content before persisting (defense in depth)
|
||||||
# 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
|
||||||
|
|||||||
@@ -1,440 +0,0 @@
|
|||||||
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");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,342 +0,0 @@
|
|||||||
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("");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,270 +0,0 @@
|
|||||||
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();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,147 +0,0 @@
|
|||||||
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();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,257 +0,0 @@
|
|||||||
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");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,751 +0,0 @@
|
|||||||
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);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,347 +0,0 @@
|
|||||||
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([]);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,407 +0,0 @@
|
|||||||
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);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,791 +0,0 @@
|
|||||||
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([]);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,567 +0,0 @@
|
|||||||
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);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,134 +0,0 @@
|
|||||||
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,4 +1,3 @@
|
|||||||
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 {
|
||||||
@@ -17,16 +16,6 @@ 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() {
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { ErrorCard } from "@/components/molecules/ErrorCard/ErrorCard";
|
|||||||
import { ExclamationMarkIcon } from "@phosphor-icons/react";
|
import { ExclamationMarkIcon } from "@phosphor-icons/react";
|
||||||
import { ToolUIPart, UIDataTypes, UIMessage, UITools } from "ai";
|
import { ToolUIPart, UIDataTypes, UIMessage, UITools } from "ai";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
|
import { ConnectIntegrationTool } from "../../../tools/ConnectIntegrationTool/ConnectIntegrationTool";
|
||||||
import { CreateAgentTool } from "../../../tools/CreateAgent/CreateAgent";
|
import { CreateAgentTool } from "../../../tools/CreateAgent/CreateAgent";
|
||||||
import { EditAgentTool } from "../../../tools/EditAgent/EditAgent";
|
import { EditAgentTool } from "../../../tools/EditAgent/EditAgent";
|
||||||
import {
|
import {
|
||||||
@@ -129,6 +130,8 @@ export function MessagePartRenderer({ part, messageID, partIndex }: Props) {
|
|||||||
case "tool-search_docs":
|
case "tool-search_docs":
|
||||||
case "tool-get_doc_page":
|
case "tool-get_doc_page":
|
||||||
return <SearchDocsTool key={key} part={part as ToolUIPart} />;
|
return <SearchDocsTool key={key} part={part as ToolUIPart} />;
|
||||||
|
case "tool-connect_integration":
|
||||||
|
return <ConnectIntegrationTool key={key} part={part as ToolUIPart} />;
|
||||||
case "tool-run_block":
|
case "tool-run_block":
|
||||||
case "tool-continue_run_block":
|
case "tool-continue_run_block":
|
||||||
return <RunBlockTool key={key} part={part as ToolUIPart} />;
|
return <RunBlockTool key={key} part={part as ToolUIPart} />;
|
||||||
|
|||||||
@@ -7,10 +7,6 @@ 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;
|
||||||
|
|
||||||
@@ -35,9 +31,6 @@ 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 }),
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,104 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import type { SetupRequirementsResponse } from "@/app/api/__generated__/models/setupRequirementsResponse";
|
||||||
|
import type { ToolUIPart } from "ai";
|
||||||
|
import { useState } from "react";
|
||||||
|
import { MorphingTextAnimation } from "../../components/MorphingTextAnimation/MorphingTextAnimation";
|
||||||
|
import { ContentMessage } from "../../components/ToolAccordion/AccordionContent";
|
||||||
|
import { SetupRequirementsCard } from "../RunBlock/components/SetupRequirementsCard/SetupRequirementsCard";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
part: ToolUIPart;
|
||||||
|
};
|
||||||
|
|
||||||
|
function parseJson(raw: unknown): unknown {
|
||||||
|
if (typeof raw === "string") {
|
||||||
|
try {
|
||||||
|
return JSON.parse(raw);
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return raw;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseOutput(raw: unknown): SetupRequirementsResponse | null {
|
||||||
|
const parsed = parseJson(raw);
|
||||||
|
if (parsed && typeof parsed === "object" && "setup_info" in parsed) {
|
||||||
|
return parsed as SetupRequirementsResponse;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseError(raw: unknown): string | null {
|
||||||
|
const parsed = parseJson(raw);
|
||||||
|
if (parsed && typeof parsed === "object" && "message" in parsed) {
|
||||||
|
return String((parsed as { message: unknown }).message);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ConnectIntegrationTool({ part }: Props) {
|
||||||
|
// Persist dismissed state here so SetupRequirementsCard remounts don't re-enable Proceed.
|
||||||
|
const [isDismissed, setIsDismissed] = useState(false);
|
||||||
|
|
||||||
|
const isStreaming =
|
||||||
|
part.state === "input-streaming" || part.state === "input-available";
|
||||||
|
const isError = part.state === "output-error";
|
||||||
|
|
||||||
|
const output =
|
||||||
|
part.state === "output-available"
|
||||||
|
? parseOutput((part as { output?: unknown }).output)
|
||||||
|
: null;
|
||||||
|
|
||||||
|
const errorMessage = isError
|
||||||
|
? (parseError((part as { output?: unknown }).output) ??
|
||||||
|
"Failed to connect integration")
|
||||||
|
: null;
|
||||||
|
|
||||||
|
const rawProvider =
|
||||||
|
(part as { input?: { provider?: string } }).input?.provider ?? "";
|
||||||
|
const providerName =
|
||||||
|
output?.setup_info?.agent_name ??
|
||||||
|
// Sanitize LLM-controlled provider slug: trim and cap at 64 chars to
|
||||||
|
// prevent runaway text in the DOM.
|
||||||
|
(rawProvider ? rawProvider.trim().slice(0, 64) : "integration");
|
||||||
|
|
||||||
|
const label = isStreaming
|
||||||
|
? `Connecting ${providerName}…`
|
||||||
|
: isError
|
||||||
|
? `Failed to connect ${providerName}`
|
||||||
|
: output
|
||||||
|
? `Connect ${output.setup_info?.agent_name ?? providerName}`
|
||||||
|
: `Connect ${providerName}`;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="py-2">
|
||||||
|
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||||
|
<MorphingTextAnimation
|
||||||
|
text={label}
|
||||||
|
className={isError ? "text-red-500" : undefined}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isError && errorMessage && (
|
||||||
|
<p className="mt-1 text-sm text-red-500">{errorMessage}</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{output && (
|
||||||
|
<div className="mt-2">
|
||||||
|
{isDismissed ? (
|
||||||
|
<ContentMessage>Connected. Continuing…</ContentMessage>
|
||||||
|
) : (
|
||||||
|
<SetupRequirementsCard
|
||||||
|
output={output}
|
||||||
|
credentialsLabel={`${output.setup_info?.agent_name ?? providerName} credentials`}
|
||||||
|
retryInstruction="I've connected my account. Please continue."
|
||||||
|
onComplete={() => setIsDismissed(true)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -23,12 +23,16 @@ interface Props {
|
|||||||
/** Override the label shown above the credentials section.
|
/** Override the label shown above the credentials section.
|
||||||
* Defaults to "Credentials". */
|
* Defaults to "Credentials". */
|
||||||
credentialsLabel?: string;
|
credentialsLabel?: string;
|
||||||
|
/** Called after Proceed is clicked so the parent can persist the dismissed state
|
||||||
|
* across remounts (avoids re-enabling the Proceed button on remount). */
|
||||||
|
onComplete?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function SetupRequirementsCard({
|
export function SetupRequirementsCard({
|
||||||
output,
|
output,
|
||||||
retryInstruction,
|
retryInstruction,
|
||||||
credentialsLabel,
|
credentialsLabel,
|
||||||
|
onComplete,
|
||||||
}: Props) {
|
}: Props) {
|
||||||
const { onSend } = useCopilotChatActions();
|
const { onSend } = useCopilotChatActions();
|
||||||
|
|
||||||
@@ -68,13 +72,17 @@ export function SetupRequirementsCard({
|
|||||||
return v !== undefined && v !== null && v !== "";
|
return v !== undefined && v !== null && v !== "";
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (hasSent) {
|
||||||
|
return <ContentMessage>Connected. Continuing…</ContentMessage>;
|
||||||
|
}
|
||||||
|
|
||||||
const canRun =
|
const canRun =
|
||||||
!hasSent &&
|
|
||||||
(!needsCredentials || isAllCredentialsComplete) &&
|
(!needsCredentials || isAllCredentialsComplete) &&
|
||||||
(!needsInputs || isAllInputsComplete);
|
(!needsInputs || isAllInputsComplete);
|
||||||
|
|
||||||
function handleRun() {
|
function handleRun() {
|
||||||
setHasSent(true);
|
setHasSent(true);
|
||||||
|
onComplete?.();
|
||||||
|
|
||||||
const parts: string[] = [];
|
const parts: string[] = [];
|
||||||
if (needsCredentials) {
|
if (needsCredentials) {
|
||||||
|
|||||||
@@ -19,42 +19,6 @@ 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;
|
||||||
@@ -163,28 +127,6 @@ 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,
|
||||||
|
|||||||
@@ -125,9 +125,9 @@ export function useCredentialsInput({
|
|||||||
if (hasAttemptedAutoSelect.current) return;
|
if (hasAttemptedAutoSelect.current) return;
|
||||||
hasAttemptedAutoSelect.current = true;
|
hasAttemptedAutoSelect.current = true;
|
||||||
|
|
||||||
// Auto-select if exactly one credential matches.
|
// Auto-select only when there is exactly one saved credential.
|
||||||
// For optional fields with multiple options, let the user choose.
|
// With multiple options the user must choose — regardless of optional/required.
|
||||||
if (isOptional && savedCreds.length > 1) return;
|
if (savedCreds.length > 1) return;
|
||||||
|
|
||||||
const cred = savedCreds[0];
|
const cred = savedCreds[0];
|
||||||
onSelectCredential({
|
onSelectCredential({
|
||||||
|
|||||||
@@ -1,343 +0,0 @@
|
|||||||
# 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