mirror of
https://github.com/langgenius/dify.git
synced 2026-04-24 21:05:48 +08:00
Merge branch 'main' into jzh
This commit is contained in:
136
.github/workflows/api-tests.yml
vendored
136
.github/workflows/api-tests.yml
vendored
@ -14,11 +14,11 @@ concurrency:
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
test:
|
||||
name: API Tests
|
||||
api-unit:
|
||||
name: API Unit Tests
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }}
|
||||
COVERAGE_FILE: coverage-unit
|
||||
defaults:
|
||||
run:
|
||||
shell: bash
|
||||
@ -50,6 +50,52 @@ jobs:
|
||||
- name: Run dify config tests
|
||||
run: uv run --project api dev/pytest/pytest_config_tests.py
|
||||
|
||||
- name: Run Unit Tests
|
||||
run: uv run --project api bash dev/pytest/pytest_unit_tests.sh
|
||||
|
||||
- name: Upload unit coverage data
|
||||
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
|
||||
with:
|
||||
name: api-coverage-unit
|
||||
path: coverage-unit
|
||||
retention-days: 1
|
||||
|
||||
api-integration:
|
||||
name: API Integration Tests
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
COVERAGE_FILE: coverage-integration
|
||||
STORAGE_TYPE: opendal
|
||||
OPENDAL_SCHEME: fs
|
||||
OPENDAL_FS_ROOT: /tmp/dify-storage
|
||||
defaults:
|
||||
run:
|
||||
shell: bash
|
||||
strategy:
|
||||
matrix:
|
||||
python-version:
|
||||
- "3.12"
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
fetch-depth: 0
|
||||
persist-credentials: false
|
||||
|
||||
- name: Setup UV and Python
|
||||
uses: astral-sh/setup-uv@37802adc94f370d6bfd71619e3f0bf239e1f3b78 # v7.6.0
|
||||
with:
|
||||
enable-cache: true
|
||||
python-version: ${{ matrix.python-version }}
|
||||
cache-dependency-glob: api/uv.lock
|
||||
|
||||
- name: Check UV lockfile
|
||||
run: uv lock --project api --check
|
||||
|
||||
- name: Install dependencies
|
||||
run: uv sync --project api --dev
|
||||
|
||||
- name: Set up dotenvs
|
||||
run: |
|
||||
cp docker/.env.example docker/.env
|
||||
@ -73,22 +119,90 @@ jobs:
|
||||
run: |
|
||||
cp api/tests/integration_tests/.env.example api/tests/integration_tests/.env
|
||||
|
||||
- name: Run API Tests
|
||||
env:
|
||||
STORAGE_TYPE: opendal
|
||||
OPENDAL_SCHEME: fs
|
||||
OPENDAL_FS_ROOT: /tmp/dify-storage
|
||||
- name: Run Integration Tests
|
||||
run: |
|
||||
uv run --project api pytest \
|
||||
-n auto \
|
||||
--timeout "${PYTEST_TIMEOUT:-180}" \
|
||||
api/tests/integration_tests/workflow \
|
||||
api/tests/integration_tests/tools \
|
||||
api/tests/test_containers_integration_tests \
|
||||
api/tests/unit_tests
|
||||
api/tests/test_containers_integration_tests
|
||||
|
||||
- name: Upload integration coverage data
|
||||
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
|
||||
with:
|
||||
name: api-coverage-integration
|
||||
path: coverage-integration
|
||||
retention-days: 1
|
||||
|
||||
api-coverage:
|
||||
name: API Coverage
|
||||
runs-on: ubuntu-latest
|
||||
needs:
|
||||
- api-unit
|
||||
- api-integration
|
||||
env:
|
||||
CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }}
|
||||
COVERAGE_FILE: .coverage
|
||||
defaults:
|
||||
run:
|
||||
shell: bash
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
fetch-depth: 0
|
||||
persist-credentials: false
|
||||
|
||||
- name: Setup UV and Python
|
||||
uses: astral-sh/setup-uv@37802adc94f370d6bfd71619e3f0bf239e1f3b78 # v7.6.0
|
||||
with:
|
||||
enable-cache: true
|
||||
python-version: "3.12"
|
||||
cache-dependency-glob: api/uv.lock
|
||||
|
||||
- name: Install dependencies
|
||||
run: uv sync --project api --dev
|
||||
|
||||
- name: Download coverage data
|
||||
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||
with:
|
||||
path: coverage-data
|
||||
pattern: api-coverage-*
|
||||
merge-multiple: true
|
||||
|
||||
- name: Combine coverage
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
echo "### API Coverage" >> "$GITHUB_STEP_SUMMARY"
|
||||
echo "" >> "$GITHUB_STEP_SUMMARY"
|
||||
echo "Merged backend coverage report generated for Codecov project status." >> "$GITHUB_STEP_SUMMARY"
|
||||
echo "" >> "$GITHUB_STEP_SUMMARY"
|
||||
|
||||
unit_coverage="$(find coverage-data -type f -name coverage-unit -print -quit)"
|
||||
integration_coverage="$(find coverage-data -type f -name coverage-integration -print -quit)"
|
||||
: "${unit_coverage:?coverage-unit artifact not found}"
|
||||
: "${integration_coverage:?coverage-integration artifact not found}"
|
||||
|
||||
report_file="$(mktemp)"
|
||||
uv run --project api coverage combine "$unit_coverage" "$integration_coverage"
|
||||
uv run --project api coverage report --show-missing | tee "$report_file"
|
||||
echo "Summary: \`$(tail -n 1 "$report_file")\`" >> "$GITHUB_STEP_SUMMARY"
|
||||
{
|
||||
echo ""
|
||||
echo "<details><summary>Coverage report</summary>"
|
||||
echo ""
|
||||
echo '```'
|
||||
cat "$report_file"
|
||||
echo '```'
|
||||
echo "</details>"
|
||||
} >> "$GITHUB_STEP_SUMMARY"
|
||||
uv run --project api coverage xml -o coverage.xml
|
||||
|
||||
- name: Report coverage
|
||||
if: ${{ env.CODECOV_TOKEN != '' && matrix.python-version == '3.12' }}
|
||||
if: ${{ env.CODECOV_TOKEN != '' }}
|
||||
uses: codecov/codecov-action@1af58845a975a7985b0beb0cbe6fbbb71a41dbad # v5.5.3
|
||||
with:
|
||||
files: ./coverage.xml
|
||||
|
||||
106
.github/workflows/main-ci.yml
vendored
106
.github/workflows/main-ci.yml
vendored
@ -42,6 +42,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
api-changed: ${{ steps.changes.outputs.api }}
|
||||
e2e-changed: ${{ steps.changes.outputs.e2e }}
|
||||
web-changed: ${{ steps.changes.outputs.web }}
|
||||
vdb-changed: ${{ steps.changes.outputs.vdb }}
|
||||
migration-changed: ${{ steps.changes.outputs.migration }}
|
||||
@ -53,21 +54,63 @@ jobs:
|
||||
filters: |
|
||||
api:
|
||||
- 'api/**'
|
||||
- 'docker/**'
|
||||
- '.github/workflows/api-tests.yml'
|
||||
- '.github/workflows/expose_service_ports.sh'
|
||||
- 'docker/.env.example'
|
||||
- 'docker/middleware.env.example'
|
||||
- 'docker/docker-compose.middleware.yaml'
|
||||
- 'docker/docker-compose-template.yaml'
|
||||
- 'docker/generate_docker_compose'
|
||||
- 'docker/ssrf_proxy/**'
|
||||
- 'docker/volumes/sandbox/conf/**'
|
||||
web:
|
||||
- 'web/**'
|
||||
- '.github/workflows/web-tests.yml'
|
||||
- '.github/actions/setup-web/**'
|
||||
e2e:
|
||||
- 'api/**'
|
||||
- 'api/pyproject.toml'
|
||||
- 'api/uv.lock'
|
||||
- 'e2e/**'
|
||||
- 'web/**'
|
||||
- 'docker/docker-compose.middleware.yaml'
|
||||
- 'docker/middleware.env.example'
|
||||
- '.github/workflows/web-e2e.yml'
|
||||
- '.github/actions/setup-web/**'
|
||||
vdb:
|
||||
- 'api/core/rag/datasource/**'
|
||||
- 'docker/**'
|
||||
- 'api/tests/integration_tests/vdb/**'
|
||||
- '.github/workflows/vdb-tests.yml'
|
||||
- '.github/workflows/expose_service_ports.sh'
|
||||
- 'docker/.env.example'
|
||||
- 'docker/middleware.env.example'
|
||||
- 'docker/docker-compose.yaml'
|
||||
- 'docker/docker-compose-template.yaml'
|
||||
- 'docker/generate_docker_compose'
|
||||
- 'docker/certbot/**'
|
||||
- 'docker/couchbase-server/**'
|
||||
- 'docker/elasticsearch/**'
|
||||
- 'docker/iris/**'
|
||||
- 'docker/nginx/**'
|
||||
- 'docker/pgvector/**'
|
||||
- 'docker/ssrf_proxy/**'
|
||||
- 'docker/startupscripts/**'
|
||||
- 'docker/tidb/**'
|
||||
- 'docker/volumes/**'
|
||||
- 'api/uv.lock'
|
||||
- 'api/pyproject.toml'
|
||||
migration:
|
||||
- 'api/migrations/**'
|
||||
- 'api/.env.example'
|
||||
- '.github/workflows/db-migration-test.yml'
|
||||
- '.github/workflows/expose_service_ports.sh'
|
||||
- 'docker/.env.example'
|
||||
- 'docker/middleware.env.example'
|
||||
- 'docker/docker-compose.middleware.yaml'
|
||||
- 'docker/docker-compose-template.yaml'
|
||||
- 'docker/generate_docker_compose'
|
||||
- 'docker/ssrf_proxy/**'
|
||||
- 'docker/volumes/sandbox/conf/**'
|
||||
|
||||
# Run tests in parallel while always emitting stable required checks.
|
||||
api-tests-run:
|
||||
@ -190,6 +233,65 @@ jobs:
|
||||
echo "Web tests were not required, but the skip job finished with result: $SKIP_RESULT" >&2
|
||||
exit 1
|
||||
|
||||
web-e2e-run:
|
||||
name: Run Web Full-Stack E2E
|
||||
needs:
|
||||
- pre_job
|
||||
- check-changes
|
||||
if: needs.pre_job.outputs.should_skip != 'true' && needs.check-changes.outputs.e2e-changed == 'true'
|
||||
uses: ./.github/workflows/web-e2e.yml
|
||||
|
||||
web-e2e-skip:
|
||||
name: Skip Web Full-Stack E2E
|
||||
needs:
|
||||
- pre_job
|
||||
- check-changes
|
||||
if: needs.pre_job.outputs.should_skip != 'true' && needs.check-changes.outputs.e2e-changed != 'true'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Report skipped web full-stack e2e
|
||||
run: echo "No E2E-related changes detected; skipping web full-stack E2E."
|
||||
|
||||
web-e2e:
|
||||
name: Web Full-Stack E2E
|
||||
if: ${{ always() }}
|
||||
needs:
|
||||
- pre_job
|
||||
- check-changes
|
||||
- web-e2e-run
|
||||
- web-e2e-skip
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Finalize Web Full-Stack E2E status
|
||||
env:
|
||||
SHOULD_SKIP_WORKFLOW: ${{ needs.pre_job.outputs.should_skip }}
|
||||
TESTS_CHANGED: ${{ needs.check-changes.outputs.e2e-changed }}
|
||||
RUN_RESULT: ${{ needs.web-e2e-run.result }}
|
||||
SKIP_RESULT: ${{ needs.web-e2e-skip.result }}
|
||||
run: |
|
||||
if [[ "$SHOULD_SKIP_WORKFLOW" == 'true' ]]; then
|
||||
echo "Web full-stack E2E was skipped because this workflow run duplicated a successful or newer run."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
if [[ "$TESTS_CHANGED" == 'true' ]]; then
|
||||
if [[ "$RUN_RESULT" == 'success' ]]; then
|
||||
echo "Web full-stack E2E ran successfully."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
echo "Web full-stack E2E was required but finished with result: $RUN_RESULT" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [[ "$SKIP_RESULT" == 'success' ]]; then
|
||||
echo "Web full-stack E2E was skipped because no E2E-related files changed."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
echo "Web full-stack E2E was not required, but the skip job finished with result: $SKIP_RESULT" >&2
|
||||
exit 1
|
||||
|
||||
style-check:
|
||||
name: Style Check
|
||||
needs: pre_job
|
||||
|
||||
72
.github/workflows/web-e2e.yml
vendored
Normal file
72
.github/workflows/web-e2e.yml
vendored
Normal file
@ -0,0 +1,72 @@
|
||||
name: Web Full-Stack E2E
|
||||
|
||||
on:
|
||||
workflow_call:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: web-e2e-${{ github.head_ref || github.run_id }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
test:
|
||||
name: Web Full-Stack E2E
|
||||
runs-on: ubuntu-latest
|
||||
defaults:
|
||||
run:
|
||||
shell: bash
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Setup web dependencies
|
||||
uses: ./.github/actions/setup-web
|
||||
|
||||
- name: Install E2E package dependencies
|
||||
working-directory: ./e2e
|
||||
run: vp install --frozen-lockfile
|
||||
|
||||
- name: Setup UV and Python
|
||||
uses: astral-sh/setup-uv@37802adc94f370d6bfd71619e3f0bf239e1f3b78 # v7.6.0
|
||||
with:
|
||||
enable-cache: true
|
||||
python-version: "3.12"
|
||||
cache-dependency-glob: api/uv.lock
|
||||
|
||||
- name: Install API dependencies
|
||||
run: uv sync --project api --dev
|
||||
|
||||
- name: Install Playwright browser
|
||||
working-directory: ./e2e
|
||||
run: vp run e2e:install
|
||||
|
||||
- name: Run isolated source-api and built-web Cucumber E2E tests
|
||||
working-directory: ./e2e
|
||||
env:
|
||||
E2E_ADMIN_EMAIL: e2e-admin@example.com
|
||||
E2E_ADMIN_NAME: E2E Admin
|
||||
E2E_ADMIN_PASSWORD: E2eAdmin12345
|
||||
E2E_FORCE_WEB_BUILD: "1"
|
||||
E2E_INIT_PASSWORD: E2eInit12345
|
||||
run: vp run e2e:full
|
||||
|
||||
- name: Upload Cucumber report
|
||||
if: ${{ !cancelled() }}
|
||||
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
|
||||
with:
|
||||
name: cucumber-report
|
||||
path: e2e/cucumber-report
|
||||
retention-days: 7
|
||||
|
||||
- name: Upload E2E logs
|
||||
if: ${{ !cancelled() }}
|
||||
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
|
||||
with:
|
||||
name: e2e-logs
|
||||
path: e2e/.logs
|
||||
retention-days: 7
|
||||
@ -555,6 +555,124 @@ class TestWorkflowService:
|
||||
assert len(result_workflows) == 2
|
||||
assert all(wf.marked_name for wf in result_workflows)
|
||||
|
||||
def test_get_all_published_workflow_no_workflow_id(self, db_session_with_containers: Session):
|
||||
"""Test that an app with no workflow_id returns empty results."""
|
||||
# Arrange
|
||||
fake = Faker()
|
||||
app = self._create_test_app(db_session_with_containers, fake)
|
||||
app.workflow_id = None
|
||||
db_session_with_containers.commit()
|
||||
|
||||
workflow_service = WorkflowService()
|
||||
|
||||
# Act
|
||||
result_workflows, has_more = workflow_service.get_all_published_workflow(
|
||||
session=db_session_with_containers, app_model=app, page=1, limit=10, user_id=None
|
||||
)
|
||||
|
||||
# Assert
|
||||
assert result_workflows == []
|
||||
assert has_more is False
|
||||
|
||||
def test_get_all_published_workflow_basic(self, db_session_with_containers: Session):
|
||||
"""Test basic retrieval of published workflows."""
|
||||
# Arrange
|
||||
fake = Faker()
|
||||
account = self._create_test_account(db_session_with_containers, fake)
|
||||
app = self._create_test_app(db_session_with_containers, fake)
|
||||
|
||||
workflow1 = self._create_test_workflow(db_session_with_containers, app, account, fake)
|
||||
workflow1.version = "2024.01.01.001"
|
||||
workflow2 = self._create_test_workflow(db_session_with_containers, app, account, fake)
|
||||
workflow2.version = "2024.01.02.001"
|
||||
|
||||
app.workflow_id = workflow1.id
|
||||
db_session_with_containers.commit()
|
||||
|
||||
workflow_service = WorkflowService()
|
||||
|
||||
# Act
|
||||
result_workflows, has_more = workflow_service.get_all_published_workflow(
|
||||
session=db_session_with_containers, app_model=app, page=1, limit=10, user_id=None
|
||||
)
|
||||
|
||||
# Assert
|
||||
assert len(result_workflows) == 2
|
||||
assert has_more is False
|
||||
|
||||
def test_get_all_published_workflow_combined_filters(self, db_session_with_containers: Session):
|
||||
"""Test combined user_id and named_only filters."""
|
||||
# Arrange
|
||||
fake = Faker()
|
||||
account1 = self._create_test_account(db_session_with_containers, fake)
|
||||
account2 = self._create_test_account(db_session_with_containers, fake)
|
||||
app = self._create_test_app(db_session_with_containers, fake)
|
||||
|
||||
# account1 named
|
||||
wf1 = self._create_test_workflow(db_session_with_containers, app, account1, fake)
|
||||
wf1.version = "2024.01.01.001"
|
||||
wf1.marked_name = "Named by user1"
|
||||
wf1.created_by = account1.id
|
||||
|
||||
# account1 unnamed
|
||||
wf2 = self._create_test_workflow(db_session_with_containers, app, account1, fake)
|
||||
wf2.version = "2024.01.02.001"
|
||||
wf2.marked_name = ""
|
||||
wf2.created_by = account1.id
|
||||
|
||||
# account2 named
|
||||
wf3 = self._create_test_workflow(db_session_with_containers, app, account2, fake)
|
||||
wf3.version = "2024.01.03.001"
|
||||
wf3.marked_name = "Named by user2"
|
||||
wf3.created_by = account2.id
|
||||
|
||||
app.workflow_id = wf1.id
|
||||
db_session_with_containers.commit()
|
||||
|
||||
workflow_service = WorkflowService()
|
||||
|
||||
# Act - Filter by account1 + named_only
|
||||
result_workflows, has_more = workflow_service.get_all_published_workflow(
|
||||
session=db_session_with_containers,
|
||||
app_model=app,
|
||||
page=1,
|
||||
limit=10,
|
||||
user_id=account1.id,
|
||||
named_only=True,
|
||||
)
|
||||
|
||||
# Assert - Only wf1 matches (account1 + named)
|
||||
assert len(result_workflows) == 1
|
||||
assert result_workflows[0].marked_name == "Named by user1"
|
||||
assert result_workflows[0].created_by == account1.id
|
||||
|
||||
def test_get_all_published_workflow_empty_result(self, db_session_with_containers: Session):
|
||||
"""Test that querying with no matching workflows returns empty."""
|
||||
# Arrange
|
||||
fake = Faker()
|
||||
account = self._create_test_account(db_session_with_containers, fake)
|
||||
app = self._create_test_app(db_session_with_containers, fake)
|
||||
|
||||
# Create a draft workflow (no version set = draft)
|
||||
workflow = self._create_test_workflow(db_session_with_containers, app, account, fake)
|
||||
app.workflow_id = workflow.id
|
||||
db_session_with_containers.commit()
|
||||
|
||||
workflow_service = WorkflowService()
|
||||
|
||||
# Act - Filter by a user that has no workflows
|
||||
result_workflows, has_more = workflow_service.get_all_published_workflow(
|
||||
session=db_session_with_containers,
|
||||
app_model=app,
|
||||
page=1,
|
||||
limit=10,
|
||||
user_id="00000000-0000-0000-0000-000000000000",
|
||||
)
|
||||
|
||||
# Assert
|
||||
assert result_workflows == []
|
||||
assert has_more is False
|
||||
|
||||
def test_sync_draft_workflow_create_new(self, db_session_with_containers: Session):
|
||||
"""
|
||||
Test creating a new draft workflow through sync operation.
|
||||
|
||||
@ -1,415 +0,0 @@
|
||||
from contextlib import nullcontext
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import pytest
|
||||
from graphon.entities.graph_config import NodeConfigDictAdapter
|
||||
from graphon.enums import BuiltinNodeTypes
|
||||
from graphon.nodes.human_input.entities import FormInput, HumanInputNodeData, UserAction
|
||||
from graphon.nodes.human_input.enums import FormInputType
|
||||
|
||||
from models.model import App
|
||||
from models.workflow import Workflow
|
||||
from services import workflow_service as workflow_service_module
|
||||
from services.workflow_service import WorkflowService
|
||||
|
||||
|
||||
class TestWorkflowService:
|
||||
@pytest.fixture
|
||||
def workflow_service(self):
|
||||
mock_session_maker = MagicMock()
|
||||
return WorkflowService(mock_session_maker)
|
||||
|
||||
@pytest.fixture
|
||||
def mock_app(self):
|
||||
app = MagicMock(spec=App)
|
||||
app.id = "app-id-1"
|
||||
app.workflow_id = "workflow-id-1"
|
||||
app.tenant_id = "tenant-id-1"
|
||||
return app
|
||||
|
||||
@pytest.fixture
|
||||
def mock_workflows(self):
|
||||
workflows = []
|
||||
for i in range(5):
|
||||
workflow = MagicMock(spec=Workflow)
|
||||
workflow.id = f"workflow-id-{i}"
|
||||
workflow.app_id = "app-id-1"
|
||||
workflow.created_at = f"2023-01-0{5 - i}" # Descending date order
|
||||
workflow.created_by = "user-id-1" if i % 2 == 0 else "user-id-2"
|
||||
workflow.marked_name = f"Workflow {i}" if i % 2 == 0 else ""
|
||||
workflows.append(workflow)
|
||||
return workflows
|
||||
|
||||
@pytest.fixture
|
||||
def dummy_session_cls(self):
|
||||
class DummySession:
|
||||
def __init__(self, *args, **kwargs):
|
||||
self.commit = MagicMock()
|
||||
|
||||
def __enter__(self):
|
||||
return self
|
||||
|
||||
def __exit__(self, exc_type, exc, tb):
|
||||
return False
|
||||
|
||||
def begin(self):
|
||||
return nullcontext()
|
||||
|
||||
return DummySession
|
||||
|
||||
def test_get_all_published_workflow_no_workflow_id(self, workflow_service, mock_app):
|
||||
mock_app.workflow_id = None
|
||||
mock_session = MagicMock()
|
||||
|
||||
workflows, has_more = workflow_service.get_all_published_workflow(
|
||||
session=mock_session, app_model=mock_app, page=1, limit=10, user_id=None
|
||||
)
|
||||
|
||||
assert workflows == []
|
||||
assert has_more is False
|
||||
mock_session.scalars.assert_not_called()
|
||||
|
||||
def test_get_all_published_workflow_basic(self, workflow_service, mock_app, mock_workflows):
|
||||
mock_session = MagicMock()
|
||||
mock_scalar_result = MagicMock()
|
||||
mock_scalar_result.all.return_value = mock_workflows[:3]
|
||||
mock_session.scalars.return_value = mock_scalar_result
|
||||
|
||||
workflows, has_more = workflow_service.get_all_published_workflow(
|
||||
session=mock_session, app_model=mock_app, page=1, limit=3, user_id=None
|
||||
)
|
||||
|
||||
assert workflows == mock_workflows[:3]
|
||||
assert has_more is False
|
||||
mock_session.scalars.assert_called_once()
|
||||
|
||||
def test_get_all_published_workflow_pagination(self, workflow_service, mock_app, mock_workflows):
|
||||
mock_session = MagicMock()
|
||||
mock_scalar_result = MagicMock()
|
||||
# Return 4 items when limit is 3, which should indicate has_more=True
|
||||
mock_scalar_result.all.return_value = mock_workflows[:4]
|
||||
mock_session.scalars.return_value = mock_scalar_result
|
||||
|
||||
workflows, has_more = workflow_service.get_all_published_workflow(
|
||||
session=mock_session, app_model=mock_app, page=1, limit=3, user_id=None
|
||||
)
|
||||
|
||||
# Should return only the first 3 items
|
||||
assert len(workflows) == 3
|
||||
assert workflows == mock_workflows[:3]
|
||||
assert has_more is True
|
||||
|
||||
# Test page 2
|
||||
mock_scalar_result.all.return_value = mock_workflows[3:]
|
||||
mock_session.scalars.return_value = mock_scalar_result
|
||||
|
||||
workflows, has_more = workflow_service.get_all_published_workflow(
|
||||
session=mock_session, app_model=mock_app, page=2, limit=3, user_id=None
|
||||
)
|
||||
|
||||
assert len(workflows) == 2
|
||||
assert has_more is False
|
||||
|
||||
def test_get_all_published_workflow_user_filter(self, workflow_service, mock_app, mock_workflows):
|
||||
mock_session = MagicMock()
|
||||
mock_scalar_result = MagicMock()
|
||||
# Filter workflows for user-id-1
|
||||
filtered_workflows = [w for w in mock_workflows if w.created_by == "user-id-1"]
|
||||
mock_scalar_result.all.return_value = filtered_workflows
|
||||
mock_session.scalars.return_value = mock_scalar_result
|
||||
|
||||
workflows, has_more = workflow_service.get_all_published_workflow(
|
||||
session=mock_session, app_model=mock_app, page=1, limit=10, user_id="user-id-1"
|
||||
)
|
||||
|
||||
assert workflows == filtered_workflows
|
||||
assert has_more is False
|
||||
mock_session.scalars.assert_called_once()
|
||||
|
||||
# Verify that the select contains a user filter clause
|
||||
args = mock_session.scalars.call_args[0][0]
|
||||
assert "created_by" in str(args)
|
||||
|
||||
def test_get_all_published_workflow_named_only(self, workflow_service, mock_app, mock_workflows):
|
||||
mock_session = MagicMock()
|
||||
mock_scalar_result = MagicMock()
|
||||
# Filter workflows that have a marked_name
|
||||
named_workflows = [w for w in mock_workflows if w.marked_name]
|
||||
mock_scalar_result.all.return_value = named_workflows
|
||||
mock_session.scalars.return_value = mock_scalar_result
|
||||
|
||||
workflows, has_more = workflow_service.get_all_published_workflow(
|
||||
session=mock_session, app_model=mock_app, page=1, limit=10, user_id=None, named_only=True
|
||||
)
|
||||
|
||||
assert workflows == named_workflows
|
||||
assert has_more is False
|
||||
mock_session.scalars.assert_called_once()
|
||||
|
||||
# Verify that the select contains a named_only filter clause
|
||||
args = mock_session.scalars.call_args[0][0]
|
||||
assert "marked_name !=" in str(args)
|
||||
|
||||
def test_get_all_published_workflow_combined_filters(self, workflow_service, mock_app, mock_workflows):
|
||||
mock_session = MagicMock()
|
||||
mock_scalar_result = MagicMock()
|
||||
# Combined filter: user-id-1 and has marked_name
|
||||
filtered_workflows = [w for w in mock_workflows if w.created_by == "user-id-1" and w.marked_name]
|
||||
mock_scalar_result.all.return_value = filtered_workflows
|
||||
mock_session.scalars.return_value = mock_scalar_result
|
||||
|
||||
workflows, has_more = workflow_service.get_all_published_workflow(
|
||||
session=mock_session, app_model=mock_app, page=1, limit=10, user_id="user-id-1", named_only=True
|
||||
)
|
||||
|
||||
assert workflows == filtered_workflows
|
||||
assert has_more is False
|
||||
mock_session.scalars.assert_called_once()
|
||||
|
||||
# Verify that both filters are applied
|
||||
args = mock_session.scalars.call_args[0][0]
|
||||
assert "created_by" in str(args)
|
||||
assert "marked_name !=" in str(args)
|
||||
|
||||
def test_get_all_published_workflow_empty_result(self, workflow_service, mock_app):
|
||||
mock_session = MagicMock()
|
||||
mock_scalar_result = MagicMock()
|
||||
mock_scalar_result.all.return_value = []
|
||||
mock_session.scalars.return_value = mock_scalar_result
|
||||
|
||||
workflows, has_more = workflow_service.get_all_published_workflow(
|
||||
session=mock_session, app_model=mock_app, page=1, limit=10, user_id=None
|
||||
)
|
||||
|
||||
assert workflows == []
|
||||
assert has_more is False
|
||||
mock_session.scalars.assert_called_once()
|
||||
|
||||
def test_submit_human_input_form_preview_uses_rendered_content(
|
||||
self,
|
||||
workflow_service: WorkflowService,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
dummy_session_cls,
|
||||
) -> None:
|
||||
service = workflow_service
|
||||
node_data = HumanInputNodeData(
|
||||
title="Human Input",
|
||||
form_content="<p>{{#$output.name#}}</p>",
|
||||
inputs=[FormInput(type=FormInputType.TEXT_INPUT, output_variable_name="name")],
|
||||
user_actions=[UserAction(id="approve", title="Approve")],
|
||||
)
|
||||
node = MagicMock()
|
||||
node.node_data = node_data
|
||||
node.render_form_content_before_submission.return_value = "<p>preview</p>"
|
||||
node.render_form_content_with_outputs.return_value = "<p>rendered</p>"
|
||||
|
||||
service._build_human_input_variable_pool = MagicMock(return_value=MagicMock()) # type: ignore[method-assign]
|
||||
service._build_human_input_node = MagicMock(return_value=node) # type: ignore[method-assign]
|
||||
|
||||
workflow = MagicMock()
|
||||
node_config = NodeConfigDictAdapter.validate_python(
|
||||
{"id": "node-1", "data": {"type": BuiltinNodeTypes.HUMAN_INPUT}}
|
||||
)
|
||||
workflow.get_node_config_by_id.return_value = node_config
|
||||
workflow.get_enclosing_node_type_and_id.return_value = None
|
||||
service.get_draft_workflow = MagicMock(return_value=workflow) # type: ignore[method-assign]
|
||||
|
||||
saved_outputs: dict[str, object] = {}
|
||||
|
||||
class DummySaver:
|
||||
def __init__(self, *args, **kwargs):
|
||||
pass
|
||||
|
||||
def save(self, outputs, process_data):
|
||||
saved_outputs.update(outputs)
|
||||
|
||||
monkeypatch.setattr(workflow_service_module, "Session", dummy_session_cls)
|
||||
monkeypatch.setattr(workflow_service_module, "DraftVariableSaver", DummySaver)
|
||||
monkeypatch.setattr(workflow_service_module, "db", SimpleNamespace(engine=MagicMock()))
|
||||
|
||||
app_model = SimpleNamespace(id="app-1", tenant_id="tenant-1")
|
||||
account = SimpleNamespace(id="account-1")
|
||||
|
||||
result = service.submit_human_input_form_preview(
|
||||
app_model=app_model,
|
||||
account=account,
|
||||
node_id="node-1",
|
||||
form_inputs={"name": "Ada", "extra": "ignored"},
|
||||
inputs={"#node-0.result#": "LLM output"},
|
||||
action="approve",
|
||||
)
|
||||
|
||||
service._build_human_input_variable_pool.assert_called_once_with(
|
||||
app_model=app_model,
|
||||
workflow=workflow,
|
||||
node_config=node_config,
|
||||
manual_inputs={"#node-0.result#": "LLM output"},
|
||||
user_id="account-1",
|
||||
)
|
||||
|
||||
node.render_form_content_with_outputs.assert_called_once()
|
||||
called_args = node.render_form_content_with_outputs.call_args.args
|
||||
assert called_args[0] == "<p>preview</p>"
|
||||
assert called_args[2] == node_data.outputs_field_names()
|
||||
rendered_outputs = called_args[1]
|
||||
assert rendered_outputs["name"] == "Ada"
|
||||
assert rendered_outputs["extra"] == "ignored"
|
||||
assert "extra" in saved_outputs
|
||||
assert "extra" in result
|
||||
assert saved_outputs["name"] == "Ada"
|
||||
assert result["name"] == "Ada"
|
||||
assert result["__action_id"] == "approve"
|
||||
assert "__rendered_content" in result
|
||||
|
||||
def test_submit_human_input_form_preview_missing_inputs_message(self, workflow_service: WorkflowService) -> None:
|
||||
service = workflow_service
|
||||
node_data = HumanInputNodeData(
|
||||
title="Human Input",
|
||||
form_content="<p>{{#$output.name#}}</p>",
|
||||
inputs=[FormInput(type=FormInputType.TEXT_INPUT, output_variable_name="name")],
|
||||
user_actions=[UserAction(id="approve", title="Approve")],
|
||||
)
|
||||
node = MagicMock()
|
||||
node.node_data = node_data
|
||||
node._render_form_content_before_submission.return_value = "<p>preview</p>"
|
||||
node._render_form_content_with_outputs.return_value = "<p>rendered</p>"
|
||||
|
||||
service._build_human_input_variable_pool = MagicMock(return_value=MagicMock()) # type: ignore[method-assign]
|
||||
service._build_human_input_node = MagicMock(return_value=node) # type: ignore[method-assign]
|
||||
|
||||
workflow = MagicMock()
|
||||
workflow.get_node_config_by_id.return_value = NodeConfigDictAdapter.validate_python(
|
||||
{"id": "node-1", "data": {"type": BuiltinNodeTypes.HUMAN_INPUT}}
|
||||
)
|
||||
service.get_draft_workflow = MagicMock(return_value=workflow) # type: ignore[method-assign]
|
||||
|
||||
app_model = SimpleNamespace(id="app-1", tenant_id="tenant-1")
|
||||
account = SimpleNamespace(id="account-1")
|
||||
with pytest.raises(ValueError) as exc_info:
|
||||
service.submit_human_input_form_preview(
|
||||
app_model=app_model,
|
||||
account=account,
|
||||
node_id="node-1",
|
||||
form_inputs={},
|
||||
inputs={},
|
||||
action="approve",
|
||||
)
|
||||
|
||||
assert "Missing required inputs" in str(exc_info.value)
|
||||
|
||||
def test_run_draft_workflow_node_successful_behavior(
|
||||
self, workflow_service, mock_app, monkeypatch, dummy_session_cls
|
||||
):
|
||||
"""Behavior: When a basic workflow node runs, it correctly sets up context,
|
||||
executes the node, and saves outputs."""
|
||||
service = workflow_service
|
||||
account = SimpleNamespace(id="account-1")
|
||||
mock_workflow = MagicMock()
|
||||
mock_workflow.id = "wf-1"
|
||||
mock_workflow.tenant_id = "tenant-1"
|
||||
mock_workflow.environment_variables = []
|
||||
mock_workflow.conversation_variables = []
|
||||
|
||||
# Mock node config
|
||||
mock_workflow.get_node_config_by_id.return_value = NodeConfigDictAdapter.validate_python(
|
||||
{"id": "node-1", "data": {"type": BuiltinNodeTypes.LLM}}
|
||||
)
|
||||
mock_workflow.get_enclosing_node_type_and_id.return_value = None
|
||||
|
||||
# Mock class methods
|
||||
monkeypatch.setattr(workflow_service_module, "WorkflowDraftVariableService", MagicMock())
|
||||
monkeypatch.setattr(workflow_service_module, "DraftVarLoader", MagicMock())
|
||||
|
||||
# Mock workflow entry execution
|
||||
mock_node_exec = MagicMock()
|
||||
mock_node_exec.id = "exec-1"
|
||||
mock_node_exec.process_data = {}
|
||||
mock_run = MagicMock()
|
||||
monkeypatch.setattr(workflow_service_module.WorkflowEntry, "single_step_run", mock_run)
|
||||
|
||||
# Mock execution handling
|
||||
service._handle_single_step_result = MagicMock(return_value=mock_node_exec)
|
||||
|
||||
# Mock repository
|
||||
mock_repo = MagicMock()
|
||||
mock_repo.get_execution_by_id.return_value = mock_node_exec
|
||||
mock_repo_factory = MagicMock(return_value=mock_repo)
|
||||
monkeypatch.setattr(
|
||||
workflow_service_module.DifyCoreRepositoryFactory,
|
||||
"create_workflow_node_execution_repository",
|
||||
mock_repo_factory,
|
||||
)
|
||||
service._node_execution_service_repo = mock_repo
|
||||
|
||||
# Set up node execution service repo mock to return our exec node
|
||||
mock_node_exec.load_full_outputs.return_value = {"output_var": "result_value"}
|
||||
mock_node_exec.node_id = "node-1"
|
||||
mock_node_exec.node_type = "llm"
|
||||
|
||||
# Mock draft variable saver
|
||||
mock_saver = MagicMock()
|
||||
monkeypatch.setattr(workflow_service_module, "DraftVariableSaver", MagicMock(return_value=mock_saver))
|
||||
|
||||
# Mock DB
|
||||
monkeypatch.setattr(workflow_service_module, "db", SimpleNamespace(engine=MagicMock()))
|
||||
|
||||
monkeypatch.setattr(workflow_service_module, "Session", dummy_session_cls)
|
||||
|
||||
# Act
|
||||
result = service.run_draft_workflow_node(
|
||||
app_model=mock_app,
|
||||
draft_workflow=mock_workflow,
|
||||
node_id="node-1",
|
||||
user_inputs={"input_val": "test"},
|
||||
account=account,
|
||||
)
|
||||
|
||||
# Assert
|
||||
assert result == mock_node_exec
|
||||
service._handle_single_step_result.assert_called_once()
|
||||
mock_repo.save.assert_called_once_with(mock_node_exec)
|
||||
mock_saver.save.assert_called_once_with(process_data={}, outputs={"output_var": "result_value"})
|
||||
|
||||
def test_run_draft_workflow_node_failure_behavior(self, workflow_service, mock_app, monkeypatch, dummy_session_cls):
|
||||
"""Behavior: If retrieving the saved execution fails, an appropriate error bubble matches expectations."""
|
||||
service = workflow_service
|
||||
account = SimpleNamespace(id="account-1")
|
||||
mock_workflow = MagicMock()
|
||||
mock_workflow.tenant_id = "tenant-1"
|
||||
mock_workflow.environment_variables = []
|
||||
mock_workflow.conversation_variables = []
|
||||
mock_workflow.get_node_config_by_id.return_value = NodeConfigDictAdapter.validate_python(
|
||||
{"id": "node-1", "data": {"type": BuiltinNodeTypes.LLM}}
|
||||
)
|
||||
mock_workflow.get_enclosing_node_type_and_id.return_value = None
|
||||
|
||||
monkeypatch.setattr(workflow_service_module, "WorkflowDraftVariableService", MagicMock())
|
||||
monkeypatch.setattr(workflow_service_module, "DraftVarLoader", MagicMock())
|
||||
monkeypatch.setattr(workflow_service_module.WorkflowEntry, "single_step_run", MagicMock())
|
||||
|
||||
mock_node_exec = MagicMock()
|
||||
mock_node_exec.id = "exec-invalid"
|
||||
service._handle_single_step_result = MagicMock(return_value=mock_node_exec)
|
||||
|
||||
mock_repo = MagicMock()
|
||||
mock_repo_factory = MagicMock(return_value=mock_repo)
|
||||
monkeypatch.setattr(
|
||||
workflow_service_module.DifyCoreRepositoryFactory,
|
||||
"create_workflow_node_execution_repository",
|
||||
mock_repo_factory,
|
||||
)
|
||||
service._node_execution_service_repo = mock_repo
|
||||
|
||||
# Simulate failure to retrieve the saved execution
|
||||
mock_repo.get_execution_by_id.return_value = None
|
||||
|
||||
monkeypatch.setattr(workflow_service_module, "db", SimpleNamespace(engine=MagicMock()))
|
||||
|
||||
monkeypatch.setattr(workflow_service_module, "Session", dummy_session_cls)
|
||||
|
||||
# Act & Assert
|
||||
with pytest.raises(ValueError, match="WorkflowNodeExecution with id exec-invalid not found after saving"):
|
||||
service.run_draft_workflow_node(
|
||||
app_model=mock_app, draft_workflow=mock_workflow, node_id="node-1", user_inputs={}, account=account
|
||||
)
|
||||
@ -13,4 +13,4 @@ PYTEST_XDIST_ARGS="${PYTEST_XDIST_ARGS:--n auto}"
|
||||
pytest --timeout "${PYTEST_TIMEOUT}" ${PYTEST_XDIST_ARGS} api/tests/unit_tests --ignore=api/tests/unit_tests/controllers
|
||||
|
||||
# Run controller tests sequentially to avoid import race conditions
|
||||
pytest --timeout "${PYTEST_TIMEOUT}" api/tests/unit_tests/controllers
|
||||
pytest --timeout "${PYTEST_TIMEOUT}" --cov-append api/tests/unit_tests/controllers
|
||||
|
||||
@ -127,6 +127,8 @@ services:
|
||||
restart: always
|
||||
env_file:
|
||||
- ./middleware.env
|
||||
extra_hosts:
|
||||
- "host.docker.internal:host-gateway"
|
||||
environment:
|
||||
# Use the shared environment variables.
|
||||
LOG_OUTPUT_FORMAT: ${LOG_OUTPUT_FORMAT:-text}
|
||||
|
||||
6
e2e/.gitignore
vendored
Normal file
6
e2e/.gitignore
vendored
Normal file
@ -0,0 +1,6 @@
|
||||
node_modules/
|
||||
.auth/
|
||||
playwright-report/
|
||||
test-results/
|
||||
cucumber-report/
|
||||
.logs/
|
||||
164
e2e/AGENTS.md
Normal file
164
e2e/AGENTS.md
Normal file
@ -0,0 +1,164 @@
|
||||
# E2E
|
||||
|
||||
This package contains the repository-level end-to-end tests for Dify.
|
||||
|
||||
This file is the canonical package guide for `e2e/`. Keep detailed workflow, architecture, debugging, and reporting documentation here. Keep `README.md` as a minimal pointer to this file so the two documents do not drift.
|
||||
|
||||
The suite uses Cucumber for scenario definitions and Playwright as the browser execution layer.
|
||||
|
||||
It tests:
|
||||
|
||||
- backend API started from source
|
||||
- frontend served from the production artifact
|
||||
- middleware services started from Docker
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Node.js `^22.22.1`
|
||||
- `pnpm`
|
||||
- `uv`
|
||||
- Docker
|
||||
|
||||
Install Playwright browsers once:
|
||||
|
||||
```bash
|
||||
cd e2e
|
||||
pnpm install
|
||||
pnpm e2e:install
|
||||
pnpm check
|
||||
```
|
||||
|
||||
Use `pnpm check` as the default local verification step after editing E2E TypeScript, Cucumber support code, or feature glue. It runs formatting, linting, and type checks for this package.
|
||||
|
||||
Common commands:
|
||||
|
||||
```bash
|
||||
# authenticated-only regression (default excludes @fresh)
|
||||
# expects backend API, frontend artifact, and middleware stack to already be running
|
||||
pnpm e2e
|
||||
|
||||
# full reset + fresh install + authenticated scenarios
|
||||
# starts required middleware/dependencies for you
|
||||
pnpm e2e:full
|
||||
|
||||
# run a tagged subset
|
||||
pnpm e2e -- --tags @smoke
|
||||
|
||||
# headed browser
|
||||
pnpm e2e:headed -- --tags @smoke
|
||||
|
||||
# slow down browser actions for local debugging
|
||||
E2E_SLOW_MO=500 pnpm e2e:headed -- --tags @smoke
|
||||
```
|
||||
|
||||
Frontend artifact behavior:
|
||||
|
||||
- if `web/.next/BUILD_ID` exists, E2E reuses the existing build by default
|
||||
- if you set `E2E_FORCE_WEB_BUILD=1`, E2E rebuilds the frontend before starting it
|
||||
|
||||
## Lifecycle
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
A["Start E2E run"] --> B["run-cucumber.ts orchestrates setup/API/frontend"]
|
||||
B --> C["support/web-server.ts starts or reuses frontend directly"]
|
||||
C --> D["Cucumber loads config, steps, and support modules"]
|
||||
D --> E["BeforeAll bootstraps shared auth state via /install"]
|
||||
E --> F{"Which command is running?"}
|
||||
F -->|`pnpm e2e`| G["Run config default tags: not @fresh and not @skip"]
|
||||
F -->|`pnpm e2e:full*`| H["Override tags to not @skip"]
|
||||
G --> I["Per-scenario BrowserContext from shared browser"]
|
||||
H --> I
|
||||
I --> J["Failure artifacts written to cucumber-report/artifacts"]
|
||||
```
|
||||
|
||||
Ownership is split like this:
|
||||
|
||||
- `scripts/setup.ts` is the single environment entrypoint for reset, middleware, backend, and frontend startup
|
||||
- `run-cucumber.ts` orchestrates the E2E run and Cucumber invocation
|
||||
- `support/web-server.ts` manages frontend reuse, startup, readiness, and shutdown
|
||||
- `features/support/hooks.ts` manages auth bootstrap, scenario lifecycle, and diagnostics
|
||||
- `features/support/world.ts` owns per-scenario typed context
|
||||
- `features/step-definitions/` holds domain-oriented glue so the official VS Code Cucumber plugin works with default conventions when `e2e/` is opened as the workspace root
|
||||
|
||||
Package layout:
|
||||
|
||||
- `features/`: Gherkin scenarios grouped by capability
|
||||
- `features/step-definitions/`: domain-oriented step definitions
|
||||
- `features/support/hooks.ts`: suite lifecycle, auth-state bootstrap, diagnostics
|
||||
- `features/support/world.ts`: shared scenario context
|
||||
- `support/web-server.ts`: typed frontend startup/reuse logic
|
||||
- `scripts/setup.ts`: reset and service lifecycle commands
|
||||
- `scripts/run-cucumber.ts`: Cucumber orchestration entrypoint
|
||||
|
||||
Behavior depends on instance state:
|
||||
|
||||
- uninitialized instance: completes install and stores authenticated state
|
||||
- initialized instance: signs in and reuses authenticated state
|
||||
|
||||
Because of that, the `@fresh` install scenario only runs in the `pnpm e2e:full*` flows. The default `pnpm e2e*` flows exclude `@fresh` via Cucumber config tags so they can be re-run against an already initialized instance.
|
||||
|
||||
Reset all persisted E2E state:
|
||||
|
||||
```bash
|
||||
pnpm e2e:reset
|
||||
```
|
||||
|
||||
This removes:
|
||||
|
||||
- `docker/volumes/db/data`
|
||||
- `docker/volumes/redis/data`
|
||||
- `docker/volumes/weaviate`
|
||||
- `docker/volumes/plugin_daemon`
|
||||
- `e2e/.auth`
|
||||
- `e2e/.logs`
|
||||
- `e2e/cucumber-report`
|
||||
|
||||
Start the full middleware stack:
|
||||
|
||||
```bash
|
||||
pnpm e2e:middleware:up
|
||||
```
|
||||
|
||||
Stop the full middleware stack:
|
||||
|
||||
```bash
|
||||
pnpm e2e:middleware:down
|
||||
```
|
||||
|
||||
The middleware stack includes:
|
||||
|
||||
- PostgreSQL
|
||||
- Redis
|
||||
- Weaviate
|
||||
- Sandbox
|
||||
- SSRF proxy
|
||||
- Plugin daemon
|
||||
|
||||
Fresh install verification:
|
||||
|
||||
```bash
|
||||
pnpm e2e:full
|
||||
```
|
||||
|
||||
Run the Cucumber suite against an already running middleware stack:
|
||||
|
||||
```bash
|
||||
pnpm e2e:middleware:up
|
||||
pnpm e2e
|
||||
pnpm e2e:middleware:down
|
||||
```
|
||||
|
||||
Artifacts and diagnostics:
|
||||
|
||||
- `cucumber-report/report.html`: HTML report
|
||||
- `cucumber-report/report.json`: JSON report
|
||||
- `cucumber-report/artifacts/`: failure screenshots and HTML captures
|
||||
- `.logs/cucumber-api.log`: backend startup log
|
||||
- `.logs/cucumber-web.log`: frontend startup log
|
||||
|
||||
Open the HTML report locally with:
|
||||
|
||||
```bash
|
||||
open cucumber-report/report.html
|
||||
```
|
||||
3
e2e/README.md
Normal file
3
e2e/README.md
Normal file
@ -0,0 +1,3 @@
|
||||
# E2E
|
||||
|
||||
Canonical documentation for this package lives in [AGENTS.md](./AGENTS.md).
|
||||
19
e2e/cucumber.config.ts
Normal file
19
e2e/cucumber.config.ts
Normal file
@ -0,0 +1,19 @@
|
||||
import type { IConfiguration } from '@cucumber/cucumber'
|
||||
|
||||
const config = {
|
||||
format: [
|
||||
'progress-bar',
|
||||
'summary',
|
||||
'html:./cucumber-report/report.html',
|
||||
'json:./cucumber-report/report.json',
|
||||
],
|
||||
import: ['features/**/*.ts'],
|
||||
parallel: 1,
|
||||
paths: ['features/**/*.feature'],
|
||||
tags: process.env.E2E_CUCUMBER_TAGS || 'not @fresh and not @skip',
|
||||
timeout: 60_000,
|
||||
} satisfies Partial<IConfiguration> & {
|
||||
timeout: number
|
||||
}
|
||||
|
||||
export default config
|
||||
10
e2e/features/apps/create-app.feature
Normal file
10
e2e/features/apps/create-app.feature
Normal file
@ -0,0 +1,10 @@
|
||||
@apps @authenticated
|
||||
Feature: Create app
|
||||
Scenario: Create a new blank app and redirect to the editor
|
||||
Given I am signed in as the default E2E admin
|
||||
When I open the apps console
|
||||
And I start creating a blank app
|
||||
And I enter a unique E2E app name
|
||||
And I confirm app creation
|
||||
Then I should land on the app editor
|
||||
And I should see the "Orchestrate" text
|
||||
8
e2e/features/smoke/authenticated-entry.feature
Normal file
8
e2e/features/smoke/authenticated-entry.feature
Normal file
@ -0,0 +1,8 @@
|
||||
@smoke @authenticated
|
||||
Feature: Authenticated app console
|
||||
Scenario: Open the apps console with the shared authenticated state
|
||||
Given I am signed in as the default E2E admin
|
||||
When I open the apps console
|
||||
Then I should stay on the apps console
|
||||
And I should see the "Create from Blank" button
|
||||
And I should not see the "Sign in" button
|
||||
7
e2e/features/smoke/install.feature
Normal file
7
e2e/features/smoke/install.feature
Normal file
@ -0,0 +1,7 @@
|
||||
@smoke @fresh
|
||||
Feature: Fresh installation bootstrap
|
||||
Scenario: Complete the initial installation bootstrap on a fresh instance
|
||||
Given the last authentication bootstrap came from a fresh install
|
||||
When I open the apps console
|
||||
Then I should stay on the apps console
|
||||
And I should see the "Create from Blank" button
|
||||
29
e2e/features/step-definitions/apps/create-app.steps.ts
Normal file
29
e2e/features/step-definitions/apps/create-app.steps.ts
Normal file
@ -0,0 +1,29 @@
|
||||
import { Then, When } from '@cucumber/cucumber'
|
||||
import { expect } from '@playwright/test'
|
||||
import type { DifyWorld } from '../../support/world'
|
||||
|
||||
When('I start creating a blank app', async function (this: DifyWorld) {
|
||||
const page = this.getPage()
|
||||
|
||||
await expect(page.getByRole('button', { name: 'Create from Blank' })).toBeVisible()
|
||||
await page.getByRole('button', { name: 'Create from Blank' }).click()
|
||||
})
|
||||
|
||||
When('I enter a unique E2E app name', async function (this: DifyWorld) {
|
||||
const appName = `E2E App ${Date.now()}`
|
||||
|
||||
await this.getPage().getByPlaceholder('Give your app a name').fill(appName)
|
||||
})
|
||||
|
||||
When('I confirm app creation', async function (this: DifyWorld) {
|
||||
const createButton = this.getPage()
|
||||
.getByRole('button', { name: /^Create(?:\s|$)/ })
|
||||
.last()
|
||||
|
||||
await expect(createButton).toBeEnabled()
|
||||
await createButton.click()
|
||||
})
|
||||
|
||||
Then('I should land on the app editor', async function (this: DifyWorld) {
|
||||
await expect(this.getPage()).toHaveURL(/\/app\/[^/]+\/(workflow|configuration)(?:\?.*)?$/)
|
||||
})
|
||||
11
e2e/features/step-definitions/common/auth.steps.ts
Normal file
11
e2e/features/step-definitions/common/auth.steps.ts
Normal file
@ -0,0 +1,11 @@
|
||||
import { Given } from '@cucumber/cucumber'
|
||||
import type { DifyWorld } from '../../support/world'
|
||||
|
||||
Given('I am signed in as the default E2E admin', async function (this: DifyWorld) {
|
||||
const session = await this.getAuthSession()
|
||||
|
||||
this.attach(
|
||||
`Authenticated as ${session.adminEmail} using ${session.mode} flow at ${session.baseURL}.`,
|
||||
'text/plain',
|
||||
)
|
||||
})
|
||||
23
e2e/features/step-definitions/common/navigation.steps.ts
Normal file
23
e2e/features/step-definitions/common/navigation.steps.ts
Normal file
@ -0,0 +1,23 @@
|
||||
import { Then, When } from '@cucumber/cucumber'
|
||||
import { expect } from '@playwright/test'
|
||||
import type { DifyWorld } from '../../support/world'
|
||||
|
||||
When('I open the apps console', async function (this: DifyWorld) {
|
||||
await this.getPage().goto('/apps')
|
||||
})
|
||||
|
||||
Then('I should stay on the apps console', async function (this: DifyWorld) {
|
||||
await expect(this.getPage()).toHaveURL(/\/apps(?:\?.*)?$/)
|
||||
})
|
||||
|
||||
Then('I should see the {string} button', async function (this: DifyWorld, label: string) {
|
||||
await expect(this.getPage().getByRole('button', { name: label })).toBeVisible()
|
||||
})
|
||||
|
||||
Then('I should not see the {string} button', async function (this: DifyWorld, label: string) {
|
||||
await expect(this.getPage().getByRole('button', { name: label })).not.toBeVisible()
|
||||
})
|
||||
|
||||
Then('I should see the {string} text', async function (this: DifyWorld, text: string) {
|
||||
await expect(this.getPage().getByText(text)).toBeVisible({ timeout: 30_000 })
|
||||
})
|
||||
12
e2e/features/step-definitions/smoke/install.steps.ts
Normal file
12
e2e/features/step-definitions/smoke/install.steps.ts
Normal file
@ -0,0 +1,12 @@
|
||||
import { Given } from '@cucumber/cucumber'
|
||||
import { expect } from '@playwright/test'
|
||||
import type { DifyWorld } from '../../support/world'
|
||||
|
||||
Given(
|
||||
'the last authentication bootstrap came from a fresh install',
|
||||
async function (this: DifyWorld) {
|
||||
const session = await this.getAuthSession()
|
||||
|
||||
expect(session.mode).toBe('install')
|
||||
},
|
||||
)
|
||||
90
e2e/features/support/hooks.ts
Normal file
90
e2e/features/support/hooks.ts
Normal file
@ -0,0 +1,90 @@
|
||||
import { After, AfterAll, Before, BeforeAll, Status, setDefaultTimeout } from '@cucumber/cucumber'
|
||||
import { chromium, type Browser } from '@playwright/test'
|
||||
import { mkdir, writeFile } from 'node:fs/promises'
|
||||
import path from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { ensureAuthenticatedState } from '../../fixtures/auth'
|
||||
import { baseURL, cucumberHeadless, cucumberSlowMo } from '../../test-env'
|
||||
import type { DifyWorld } from './world'
|
||||
|
||||
const e2eRoot = fileURLToPath(new URL('../..', import.meta.url))
|
||||
const artifactsDir = path.join(e2eRoot, 'cucumber-report', 'artifacts')
|
||||
|
||||
let browser: Browser | undefined
|
||||
|
||||
setDefaultTimeout(60_000)
|
||||
|
||||
const sanitizeForPath = (value: string) =>
|
||||
value.replaceAll(/[^a-zA-Z0-9_-]+/g, '-').replaceAll(/^-+|-+$/g, '')
|
||||
|
||||
const writeArtifact = async (
|
||||
scenarioName: string,
|
||||
extension: 'html' | 'png',
|
||||
contents: Buffer | string,
|
||||
) => {
|
||||
const artifactPath = path.join(
|
||||
artifactsDir,
|
||||
`${Date.now()}-${sanitizeForPath(scenarioName || 'scenario')}.${extension}`,
|
||||
)
|
||||
await writeFile(artifactPath, contents)
|
||||
|
||||
return artifactPath
|
||||
}
|
||||
|
||||
BeforeAll(async () => {
|
||||
await mkdir(artifactsDir, { recursive: true })
|
||||
|
||||
browser = await chromium.launch({
|
||||
headless: cucumberHeadless,
|
||||
slowMo: cucumberSlowMo,
|
||||
})
|
||||
|
||||
console.log(`[e2e] session cache bootstrap against ${baseURL}`)
|
||||
await ensureAuthenticatedState(browser, baseURL)
|
||||
})
|
||||
|
||||
Before(async function (this: DifyWorld, { pickle }) {
|
||||
if (!browser) throw new Error('Shared Playwright browser is not available.')
|
||||
|
||||
await this.startAuthenticatedSession(browser)
|
||||
this.scenarioStartedAt = Date.now()
|
||||
|
||||
const tags = pickle.tags.map((tag) => tag.name).join(' ')
|
||||
console.log(`[e2e] start ${pickle.name}${tags ? ` ${tags}` : ''}`)
|
||||
})
|
||||
|
||||
After(async function (this: DifyWorld, { pickle, result }) {
|
||||
const elapsedMs = this.scenarioStartedAt ? Date.now() - this.scenarioStartedAt : undefined
|
||||
|
||||
if (result?.status !== Status.PASSED && this.page) {
|
||||
const screenshot = await this.page.screenshot({
|
||||
fullPage: true,
|
||||
})
|
||||
const screenshotPath = await writeArtifact(pickle.name, 'png', screenshot)
|
||||
this.attach(screenshot, 'image/png')
|
||||
|
||||
const html = await this.page.content()
|
||||
const htmlPath = await writeArtifact(pickle.name, 'html', html)
|
||||
this.attach(html, 'text/html')
|
||||
|
||||
if (this.consoleErrors.length > 0)
|
||||
this.attach(`Console Errors:\n${this.consoleErrors.join('\n')}`, 'text/plain')
|
||||
|
||||
if (this.pageErrors.length > 0)
|
||||
this.attach(`Page Errors:\n${this.pageErrors.join('\n')}`, 'text/plain')
|
||||
|
||||
this.attach(`Artifacts:\n${[screenshotPath, htmlPath].join('\n')}`, 'text/plain')
|
||||
}
|
||||
|
||||
const status = result?.status || 'UNKNOWN'
|
||||
console.log(
|
||||
`[e2e] end ${pickle.name} status=${status}${elapsedMs ? ` durationMs=${elapsedMs}` : ''}`,
|
||||
)
|
||||
|
||||
await this.closeSession()
|
||||
})
|
||||
|
||||
AfterAll(async () => {
|
||||
await browser?.close()
|
||||
browser = undefined
|
||||
})
|
||||
68
e2e/features/support/world.ts
Normal file
68
e2e/features/support/world.ts
Normal file
@ -0,0 +1,68 @@
|
||||
import { type IWorldOptions, World, setWorldConstructor } from '@cucumber/cucumber'
|
||||
import type { Browser, BrowserContext, ConsoleMessage, Page } from '@playwright/test'
|
||||
import {
|
||||
authStatePath,
|
||||
readAuthSessionMetadata,
|
||||
type AuthSessionMetadata,
|
||||
} from '../../fixtures/auth'
|
||||
import { baseURL, defaultLocale } from '../../test-env'
|
||||
|
||||
export class DifyWorld extends World {
|
||||
context: BrowserContext | undefined
|
||||
page: Page | undefined
|
||||
consoleErrors: string[] = []
|
||||
pageErrors: string[] = []
|
||||
scenarioStartedAt: number | undefined
|
||||
session: AuthSessionMetadata | undefined
|
||||
|
||||
constructor(options: IWorldOptions) {
|
||||
super(options)
|
||||
this.resetScenarioState()
|
||||
}
|
||||
|
||||
resetScenarioState() {
|
||||
this.consoleErrors = []
|
||||
this.pageErrors = []
|
||||
}
|
||||
|
||||
async startAuthenticatedSession(browser: Browser) {
|
||||
this.resetScenarioState()
|
||||
this.context = await browser.newContext({
|
||||
baseURL,
|
||||
locale: defaultLocale,
|
||||
storageState: authStatePath,
|
||||
})
|
||||
this.context.setDefaultTimeout(30_000)
|
||||
this.page = await this.context.newPage()
|
||||
this.page.setDefaultTimeout(30_000)
|
||||
|
||||
this.page.on('console', (message: ConsoleMessage) => {
|
||||
if (message.type() === 'error') this.consoleErrors.push(message.text())
|
||||
})
|
||||
this.page.on('pageerror', (error) => {
|
||||
this.pageErrors.push(error.message)
|
||||
})
|
||||
}
|
||||
|
||||
getPage() {
|
||||
if (!this.page) throw new Error('Playwright page has not been initialized for this scenario.')
|
||||
|
||||
return this.page
|
||||
}
|
||||
|
||||
async getAuthSession() {
|
||||
this.session ??= await readAuthSessionMetadata()
|
||||
return this.session
|
||||
}
|
||||
|
||||
async closeSession() {
|
||||
await this.context?.close()
|
||||
this.context = undefined
|
||||
this.page = undefined
|
||||
this.session = undefined
|
||||
this.scenarioStartedAt = undefined
|
||||
this.resetScenarioState()
|
||||
}
|
||||
}
|
||||
|
||||
setWorldConstructor(DifyWorld)
|
||||
148
e2e/fixtures/auth.ts
Normal file
148
e2e/fixtures/auth.ts
Normal file
@ -0,0 +1,148 @@
|
||||
import type { Browser, Page } from '@playwright/test'
|
||||
import { expect } from '@playwright/test'
|
||||
import { mkdir, readFile, writeFile } from 'node:fs/promises'
|
||||
import path from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { defaultBaseURL, defaultLocale } from '../test-env'
|
||||
|
||||
export type AuthSessionMetadata = {
|
||||
adminEmail: string
|
||||
baseURL: string
|
||||
mode: 'install' | 'login'
|
||||
usedInitPassword: boolean
|
||||
}
|
||||
|
||||
const WAIT_TIMEOUT_MS = 120_000
|
||||
const e2eRoot = fileURLToPath(new URL('..', import.meta.url))
|
||||
|
||||
export const authDir = path.join(e2eRoot, '.auth')
|
||||
export const authStatePath = path.join(authDir, 'admin.json')
|
||||
export const authMetadataPath = path.join(authDir, 'session.json')
|
||||
|
||||
export const adminCredentials = {
|
||||
email: process.env.E2E_ADMIN_EMAIL || 'e2e-admin@example.com',
|
||||
name: process.env.E2E_ADMIN_NAME || 'E2E Admin',
|
||||
password: process.env.E2E_ADMIN_PASSWORD || 'E2eAdmin12345',
|
||||
}
|
||||
|
||||
const initPassword = process.env.E2E_INIT_PASSWORD || 'E2eInit12345'
|
||||
|
||||
export const resolveBaseURL = (configuredBaseURL?: string) =>
|
||||
configuredBaseURL || process.env.E2E_BASE_URL || defaultBaseURL
|
||||
|
||||
export const readAuthSessionMetadata = async () => {
|
||||
const content = await readFile(authMetadataPath, 'utf8')
|
||||
return JSON.parse(content) as AuthSessionMetadata
|
||||
}
|
||||
|
||||
const escapeRegex = (value: string) => value.replaceAll(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
||||
|
||||
const appURL = (baseURL: string, pathname: string) => new URL(pathname, baseURL).toString()
|
||||
|
||||
const waitForPageState = async (page: Page) => {
|
||||
const installHeading = page.getByRole('heading', { name: 'Setting up an admin account' })
|
||||
const signInButton = page.getByRole('button', { name: 'Sign in' })
|
||||
const initPasswordField = page.getByLabel('Admin initialization password')
|
||||
|
||||
const deadline = Date.now() + WAIT_TIMEOUT_MS
|
||||
|
||||
while (Date.now() < deadline) {
|
||||
if (await installHeading.isVisible().catch(() => false)) return 'install' as const
|
||||
if (await signInButton.isVisible().catch(() => false)) return 'login' as const
|
||||
if (await initPasswordField.isVisible().catch(() => false)) return 'init' as const
|
||||
|
||||
await page.waitForTimeout(1_000)
|
||||
}
|
||||
|
||||
throw new Error(`Unable to determine auth page state for ${page.url()}`)
|
||||
}
|
||||
|
||||
const completeInitPasswordIfNeeded = async (page: Page) => {
|
||||
const initPasswordField = page.getByLabel('Admin initialization password')
|
||||
if (!(await initPasswordField.isVisible({ timeout: 3_000 }).catch(() => false))) return false
|
||||
|
||||
await initPasswordField.fill(initPassword)
|
||||
await page.getByRole('button', { name: 'Validate' }).click()
|
||||
await expect(page.getByRole('heading', { name: 'Setting up an admin account' })).toBeVisible({
|
||||
timeout: WAIT_TIMEOUT_MS,
|
||||
})
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
const completeInstall = async (page: Page, baseURL: string) => {
|
||||
await expect(page.getByRole('heading', { name: 'Setting up an admin account' })).toBeVisible({
|
||||
timeout: WAIT_TIMEOUT_MS,
|
||||
})
|
||||
|
||||
await page.getByLabel('Email address').fill(adminCredentials.email)
|
||||
await page.getByLabel('Username').fill(adminCredentials.name)
|
||||
await page.getByLabel('Password').fill(adminCredentials.password)
|
||||
await page.getByRole('button', { name: 'Set up' }).click()
|
||||
|
||||
await expect(page).toHaveURL(new RegExp(`^${escapeRegex(baseURL)}/apps(?:\\?.*)?$`), {
|
||||
timeout: WAIT_TIMEOUT_MS,
|
||||
})
|
||||
}
|
||||
|
||||
const completeLogin = async (page: Page, baseURL: string) => {
|
||||
await expect(page.getByRole('button', { name: 'Sign in' })).toBeVisible({
|
||||
timeout: WAIT_TIMEOUT_MS,
|
||||
})
|
||||
|
||||
await page.getByLabel('Email address').fill(adminCredentials.email)
|
||||
await page.getByLabel('Password').fill(adminCredentials.password)
|
||||
await page.getByRole('button', { name: 'Sign in' }).click()
|
||||
|
||||
await expect(page).toHaveURL(new RegExp(`^${escapeRegex(baseURL)}/apps(?:\\?.*)?$`), {
|
||||
timeout: WAIT_TIMEOUT_MS,
|
||||
})
|
||||
}
|
||||
|
||||
export const ensureAuthenticatedState = async (browser: Browser, configuredBaseURL?: string) => {
|
||||
const baseURL = resolveBaseURL(configuredBaseURL)
|
||||
|
||||
await mkdir(authDir, { recursive: true })
|
||||
|
||||
const context = await browser.newContext({
|
||||
baseURL,
|
||||
locale: defaultLocale,
|
||||
})
|
||||
const page = await context.newPage()
|
||||
|
||||
try {
|
||||
await page.goto(appURL(baseURL, '/install'), { waitUntil: 'networkidle' })
|
||||
|
||||
let usedInitPassword = await completeInitPasswordIfNeeded(page)
|
||||
let pageState = await waitForPageState(page)
|
||||
|
||||
while (pageState === 'init') {
|
||||
const completedInitPassword = await completeInitPasswordIfNeeded(page)
|
||||
if (!completedInitPassword)
|
||||
throw new Error(`Unable to validate initialization password for ${page.url()}`)
|
||||
|
||||
usedInitPassword = true
|
||||
pageState = await waitForPageState(page)
|
||||
}
|
||||
|
||||
if (pageState === 'install') await completeInstall(page, baseURL)
|
||||
else await completeLogin(page, baseURL)
|
||||
|
||||
await expect(page.getByRole('button', { name: 'Create from Blank' })).toBeVisible({
|
||||
timeout: WAIT_TIMEOUT_MS,
|
||||
})
|
||||
|
||||
await context.storageState({ path: authStatePath })
|
||||
|
||||
const metadata: AuthSessionMetadata = {
|
||||
adminEmail: adminCredentials.email,
|
||||
baseURL,
|
||||
mode: pageState,
|
||||
usedInitPassword,
|
||||
}
|
||||
|
||||
await writeFile(authMetadataPath, `${JSON.stringify(metadata, null, 2)}\n`, 'utf8')
|
||||
} finally {
|
||||
await context.close()
|
||||
}
|
||||
}
|
||||
34
e2e/package.json
Normal file
34
e2e/package.json
Normal file
@ -0,0 +1,34 @@
|
||||
{
|
||||
"name": "dify-e2e",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"check": "vp check --fix",
|
||||
"e2e": "tsx ./scripts/run-cucumber.ts",
|
||||
"e2e:full": "tsx ./scripts/run-cucumber.ts --full",
|
||||
"e2e:full:headed": "tsx ./scripts/run-cucumber.ts --full --headed",
|
||||
"e2e:headed": "tsx ./scripts/run-cucumber.ts --headed",
|
||||
"e2e:install": "playwright install --with-deps chromium",
|
||||
"e2e:middleware:down": "tsx ./scripts/setup.ts middleware-down",
|
||||
"e2e:middleware:up": "tsx ./scripts/setup.ts middleware-up",
|
||||
"e2e:reset": "tsx ./scripts/setup.ts reset"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@cucumber/cucumber": "12.7.0",
|
||||
"@playwright/test": "1.51.1",
|
||||
"@types/node": "25.5.0",
|
||||
"tsx": "4.21.0",
|
||||
"typescript": "5.9.3",
|
||||
"vite-plus": "latest"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^22.22.1"
|
||||
},
|
||||
"packageManager": "pnpm@10.32.1",
|
||||
"pnpm": {
|
||||
"overrides": {
|
||||
"vite": "npm:@voidzero-dev/vite-plus-core@latest",
|
||||
"vitest": "npm:@voidzero-dev/vite-plus-test@latest"
|
||||
}
|
||||
}
|
||||
}
|
||||
2632
e2e/pnpm-lock.yaml
generated
Normal file
2632
e2e/pnpm-lock.yaml
generated
Normal file
File diff suppressed because it is too large
Load Diff
242
e2e/scripts/common.ts
Normal file
242
e2e/scripts/common.ts
Normal file
@ -0,0 +1,242 @@
|
||||
import { spawn, type ChildProcess } from 'node:child_process'
|
||||
import { access, copyFile, readFile, writeFile } from 'node:fs/promises'
|
||||
import net from 'node:net'
|
||||
import path from 'node:path'
|
||||
import { fileURLToPath, pathToFileURL } from 'node:url'
|
||||
import { sleep } from '../support/process'
|
||||
|
||||
type RunCommandOptions = {
|
||||
command: string
|
||||
args: string[]
|
||||
cwd: string
|
||||
env?: NodeJS.ProcessEnv
|
||||
stdio?: 'inherit' | 'pipe'
|
||||
}
|
||||
|
||||
type RunCommandResult = {
|
||||
exitCode: number
|
||||
stdout: string
|
||||
stderr: string
|
||||
}
|
||||
|
||||
type ForegroundProcessOptions = {
|
||||
command: string
|
||||
args: string[]
|
||||
cwd: string
|
||||
env?: NodeJS.ProcessEnv
|
||||
}
|
||||
|
||||
export const rootDir = fileURLToPath(new URL('../..', import.meta.url))
|
||||
export const e2eDir = path.join(rootDir, 'e2e')
|
||||
export const apiDir = path.join(rootDir, 'api')
|
||||
export const dockerDir = path.join(rootDir, 'docker')
|
||||
export const webDir = path.join(rootDir, 'web')
|
||||
|
||||
export const middlewareComposeFile = path.join(dockerDir, 'docker-compose.middleware.yaml')
|
||||
export const middlewareEnvFile = path.join(dockerDir, 'middleware.env')
|
||||
export const middlewareEnvExampleFile = path.join(dockerDir, 'middleware.env.example')
|
||||
export const webEnvLocalFile = path.join(webDir, '.env.local')
|
||||
export const webEnvExampleFile = path.join(webDir, '.env.example')
|
||||
export const apiEnvExampleFile = path.join(apiDir, 'tests', 'integration_tests', '.env.example')
|
||||
|
||||
const formatCommand = (command: string, args: string[]) => [command, ...args].join(' ')
|
||||
|
||||
export const isMainModule = (metaUrl: string) => {
|
||||
const entrypoint = process.argv[1]
|
||||
if (!entrypoint) return false
|
||||
|
||||
return pathToFileURL(entrypoint).href === metaUrl
|
||||
}
|
||||
|
||||
export const runCommand = async ({
|
||||
command,
|
||||
args,
|
||||
cwd,
|
||||
env,
|
||||
stdio = 'inherit',
|
||||
}: RunCommandOptions): Promise<RunCommandResult> => {
|
||||
const childProcess = spawn(command, args, {
|
||||
cwd,
|
||||
env: {
|
||||
...process.env,
|
||||
...env,
|
||||
},
|
||||
stdio: stdio === 'inherit' ? 'inherit' : 'pipe',
|
||||
})
|
||||
|
||||
let stdout = ''
|
||||
let stderr = ''
|
||||
|
||||
if (stdio === 'pipe') {
|
||||
childProcess.stdout?.on('data', (chunk: Buffer | string) => {
|
||||
stdout += chunk.toString()
|
||||
})
|
||||
childProcess.stderr?.on('data', (chunk: Buffer | string) => {
|
||||
stderr += chunk.toString()
|
||||
})
|
||||
}
|
||||
|
||||
return await new Promise<RunCommandResult>((resolve, reject) => {
|
||||
childProcess.once('error', reject)
|
||||
childProcess.once('exit', (code) => {
|
||||
resolve({
|
||||
exitCode: code ?? 1,
|
||||
stdout,
|
||||
stderr,
|
||||
})
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
export const runCommandOrThrow = async (options: RunCommandOptions) => {
|
||||
const result = await runCommand(options)
|
||||
|
||||
if (result.exitCode !== 0) {
|
||||
throw new Error(
|
||||
`Command failed (${result.exitCode}): ${formatCommand(options.command, options.args)}`,
|
||||
)
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
const forwardSignalsToChild = (childProcess: ChildProcess) => {
|
||||
const handleSignal = (signal: NodeJS.Signals) => {
|
||||
if (childProcess.exitCode === null) childProcess.kill(signal)
|
||||
}
|
||||
|
||||
const onSigint = () => handleSignal('SIGINT')
|
||||
const onSigterm = () => handleSignal('SIGTERM')
|
||||
|
||||
process.on('SIGINT', onSigint)
|
||||
process.on('SIGTERM', onSigterm)
|
||||
|
||||
return () => {
|
||||
process.off('SIGINT', onSigint)
|
||||
process.off('SIGTERM', onSigterm)
|
||||
}
|
||||
}
|
||||
|
||||
export const runForegroundProcess = async ({
|
||||
command,
|
||||
args,
|
||||
cwd,
|
||||
env,
|
||||
}: ForegroundProcessOptions) => {
|
||||
const childProcess = spawn(command, args, {
|
||||
cwd,
|
||||
env: {
|
||||
...process.env,
|
||||
...env,
|
||||
},
|
||||
stdio: 'inherit',
|
||||
})
|
||||
|
||||
const cleanupSignals = forwardSignalsToChild(childProcess)
|
||||
const exitCode = await new Promise<number>((resolve, reject) => {
|
||||
childProcess.once('error', reject)
|
||||
childProcess.once('exit', (code) => {
|
||||
resolve(code ?? 1)
|
||||
})
|
||||
})
|
||||
|
||||
cleanupSignals()
|
||||
process.exit(exitCode)
|
||||
}
|
||||
|
||||
export const ensureFileExists = async (filePath: string, exampleFilePath: string) => {
|
||||
try {
|
||||
await access(filePath)
|
||||
} catch {
|
||||
await copyFile(exampleFilePath, filePath)
|
||||
}
|
||||
}
|
||||
|
||||
export const ensureLineInFile = async (filePath: string, line: string) => {
|
||||
const fileContent = await readFile(filePath, 'utf8')
|
||||
const lines = fileContent.split(/\r?\n/)
|
||||
const assignmentPrefix = line.includes('=') ? `${line.slice(0, line.indexOf('='))}=` : null
|
||||
|
||||
if (lines.includes(line)) return
|
||||
|
||||
if (assignmentPrefix && lines.some((existingLine) => existingLine.startsWith(assignmentPrefix)))
|
||||
return
|
||||
|
||||
const normalizedContent = fileContent.endsWith('\n') ? fileContent : `${fileContent}\n`
|
||||
await writeFile(filePath, `${normalizedContent}${line}\n`, 'utf8')
|
||||
}
|
||||
|
||||
export const ensureWebEnvLocal = async () => {
|
||||
await ensureFileExists(webEnvLocalFile, webEnvExampleFile)
|
||||
|
||||
const fileContent = await readFile(webEnvLocalFile, 'utf8')
|
||||
const nextContent = fileContent.replaceAll('http://localhost:5001', 'http://127.0.0.1:5001')
|
||||
|
||||
if (nextContent !== fileContent) await writeFile(webEnvLocalFile, nextContent, 'utf8')
|
||||
}
|
||||
|
||||
export const readSimpleDotenv = async (filePath: string) => {
|
||||
const fileContent = await readFile(filePath, 'utf8')
|
||||
const entries = fileContent
|
||||
.split(/\r?\n/)
|
||||
.map((line) => line.trim())
|
||||
.filter((line) => line && !line.startsWith('#'))
|
||||
.map<[string, string]>((line) => {
|
||||
const separatorIndex = line.indexOf('=')
|
||||
const key = separatorIndex === -1 ? line : line.slice(0, separatorIndex).trim()
|
||||
const rawValue = separatorIndex === -1 ? '' : line.slice(separatorIndex + 1).trim()
|
||||
|
||||
if (
|
||||
(rawValue.startsWith('"') && rawValue.endsWith('"')) ||
|
||||
(rawValue.startsWith("'") && rawValue.endsWith("'"))
|
||||
) {
|
||||
return [key, rawValue.slice(1, -1)]
|
||||
}
|
||||
|
||||
return [key, rawValue]
|
||||
})
|
||||
|
||||
return Object.fromEntries(entries)
|
||||
}
|
||||
|
||||
export const waitForCondition = async ({
|
||||
check,
|
||||
description,
|
||||
intervalMs,
|
||||
timeoutMs,
|
||||
}: {
|
||||
check: () => Promise<boolean> | boolean
|
||||
description: string
|
||||
intervalMs: number
|
||||
timeoutMs: number
|
||||
}) => {
|
||||
const deadline = Date.now() + timeoutMs
|
||||
|
||||
while (Date.now() < deadline) {
|
||||
if (await check()) return
|
||||
|
||||
await sleep(intervalMs)
|
||||
}
|
||||
|
||||
throw new Error(`Timed out waiting for ${description} after ${timeoutMs}ms.`)
|
||||
}
|
||||
|
||||
export const isTcpPortReachable = async (host: string, port: number, timeoutMs = 1_000) => {
|
||||
return await new Promise<boolean>((resolve) => {
|
||||
const socket = net.createConnection({
|
||||
host,
|
||||
port,
|
||||
})
|
||||
|
||||
const finish = (result: boolean) => {
|
||||
socket.removeAllListeners()
|
||||
socket.destroy()
|
||||
resolve(result)
|
||||
}
|
||||
|
||||
socket.setTimeout(timeoutMs)
|
||||
socket.once('connect', () => finish(true))
|
||||
socket.once('timeout', () => finish(false))
|
||||
socket.once('error', () => finish(false))
|
||||
})
|
||||
}
|
||||
154
e2e/scripts/run-cucumber.ts
Normal file
154
e2e/scripts/run-cucumber.ts
Normal file
@ -0,0 +1,154 @@
|
||||
import { mkdir, rm } from 'node:fs/promises'
|
||||
import path from 'node:path'
|
||||
import { startWebServer, stopWebServer } from '../support/web-server'
|
||||
import { waitForUrl, startLoggedProcess, stopManagedProcess } from '../support/process'
|
||||
import { apiURL, baseURL, reuseExistingWebServer } from '../test-env'
|
||||
import { e2eDir, isMainModule, runCommand } from './common'
|
||||
import { resetState, startMiddleware, stopMiddleware } from './setup'
|
||||
|
||||
type RunOptions = {
|
||||
forwardArgs: string[]
|
||||
full: boolean
|
||||
headed: boolean
|
||||
}
|
||||
|
||||
const parseArgs = (argv: string[]): RunOptions => {
|
||||
let full = false
|
||||
let headed = false
|
||||
const forwardArgs: string[] = []
|
||||
|
||||
for (let index = 0; index < argv.length; index += 1) {
|
||||
const arg = argv[index]
|
||||
|
||||
if (arg === '--') {
|
||||
forwardArgs.push(...argv.slice(index + 1))
|
||||
break
|
||||
}
|
||||
|
||||
if (arg === '--full') {
|
||||
full = true
|
||||
continue
|
||||
}
|
||||
|
||||
if (arg === '--headed') {
|
||||
headed = true
|
||||
continue
|
||||
}
|
||||
|
||||
forwardArgs.push(arg)
|
||||
}
|
||||
|
||||
return {
|
||||
forwardArgs,
|
||||
full,
|
||||
headed,
|
||||
}
|
||||
}
|
||||
|
||||
const hasCustomTags = (forwardArgs: string[]) =>
|
||||
forwardArgs.some((arg) => arg === '--tags' || arg.startsWith('--tags='))
|
||||
|
||||
const main = async () => {
|
||||
const { forwardArgs, full, headed } = parseArgs(process.argv.slice(2))
|
||||
const startMiddlewareForRun = full
|
||||
const resetStateForRun = full
|
||||
|
||||
if (resetStateForRun) await resetState()
|
||||
|
||||
if (startMiddlewareForRun) await startMiddleware()
|
||||
|
||||
const cucumberReportDir = path.join(e2eDir, 'cucumber-report')
|
||||
const logDir = path.join(e2eDir, '.logs')
|
||||
|
||||
await rm(cucumberReportDir, { force: true, recursive: true })
|
||||
await mkdir(logDir, { recursive: true })
|
||||
|
||||
const apiProcess = await startLoggedProcess({
|
||||
command: 'npx',
|
||||
args: ['tsx', './scripts/setup.ts', 'api'],
|
||||
cwd: e2eDir,
|
||||
label: 'api server',
|
||||
logFilePath: path.join(logDir, 'cucumber-api.log'),
|
||||
})
|
||||
|
||||
let cleanupPromise: Promise<void> | undefined
|
||||
const cleanup = async () => {
|
||||
if (!cleanupPromise) {
|
||||
cleanupPromise = (async () => {
|
||||
await stopWebServer()
|
||||
await stopManagedProcess(apiProcess)
|
||||
|
||||
if (startMiddlewareForRun) {
|
||||
try {
|
||||
await stopMiddleware()
|
||||
} catch {
|
||||
// Cleanup should continue even if middleware shutdown fails.
|
||||
}
|
||||
}
|
||||
})()
|
||||
}
|
||||
|
||||
await cleanupPromise
|
||||
}
|
||||
|
||||
const onTerminate = () => {
|
||||
void cleanup().finally(() => {
|
||||
process.exit(1)
|
||||
})
|
||||
}
|
||||
|
||||
process.once('SIGINT', onTerminate)
|
||||
process.once('SIGTERM', onTerminate)
|
||||
|
||||
try {
|
||||
try {
|
||||
await waitForUrl(`${apiURL}/health`, 180_000, 1_000)
|
||||
} catch {
|
||||
throw new Error(`API did not become ready at ${apiURL}/health.`)
|
||||
}
|
||||
|
||||
await startWebServer({
|
||||
baseURL,
|
||||
command: 'npx',
|
||||
args: ['tsx', './scripts/setup.ts', 'web'],
|
||||
cwd: e2eDir,
|
||||
logFilePath: path.join(logDir, 'cucumber-web.log'),
|
||||
reuseExistingServer: reuseExistingWebServer,
|
||||
timeoutMs: 300_000,
|
||||
})
|
||||
|
||||
const cucumberEnv: NodeJS.ProcessEnv = {
|
||||
...process.env,
|
||||
CUCUMBER_HEADLESS: headed ? '0' : '1',
|
||||
}
|
||||
|
||||
if (startMiddlewareForRun && !hasCustomTags(forwardArgs))
|
||||
cucumberEnv.E2E_CUCUMBER_TAGS = 'not @skip'
|
||||
|
||||
const result = await runCommand({
|
||||
command: 'npx',
|
||||
args: [
|
||||
'tsx',
|
||||
'./node_modules/@cucumber/cucumber/bin/cucumber.js',
|
||||
'--config',
|
||||
'./cucumber.config.ts',
|
||||
...forwardArgs,
|
||||
],
|
||||
cwd: e2eDir,
|
||||
env: cucumberEnv,
|
||||
})
|
||||
|
||||
process.exitCode = result.exitCode
|
||||
} finally {
|
||||
process.off('SIGINT', onTerminate)
|
||||
process.off('SIGTERM', onTerminate)
|
||||
await cleanup()
|
||||
}
|
||||
}
|
||||
|
||||
if (isMainModule(import.meta.url)) {
|
||||
void main().catch((error) => {
|
||||
console.error(error instanceof Error ? error.message : String(error))
|
||||
process.exit(1)
|
||||
})
|
||||
}
|
||||
306
e2e/scripts/setup.ts
Normal file
306
e2e/scripts/setup.ts
Normal file
@ -0,0 +1,306 @@
|
||||
import { access, mkdir, rm } from 'node:fs/promises'
|
||||
import path from 'node:path'
|
||||
import { waitForUrl } from '../support/process'
|
||||
import {
|
||||
apiDir,
|
||||
apiEnvExampleFile,
|
||||
dockerDir,
|
||||
e2eDir,
|
||||
ensureFileExists,
|
||||
ensureLineInFile,
|
||||
ensureWebEnvLocal,
|
||||
isMainModule,
|
||||
isTcpPortReachable,
|
||||
middlewareComposeFile,
|
||||
middlewareEnvExampleFile,
|
||||
middlewareEnvFile,
|
||||
readSimpleDotenv,
|
||||
runCommand,
|
||||
runCommandOrThrow,
|
||||
runForegroundProcess,
|
||||
waitForCondition,
|
||||
webDir,
|
||||
} from './common'
|
||||
|
||||
const buildIdPath = path.join(webDir, '.next', 'BUILD_ID')
|
||||
|
||||
const middlewareDataPaths = [
|
||||
path.join(dockerDir, 'volumes', 'db', 'data'),
|
||||
path.join(dockerDir, 'volumes', 'plugin_daemon'),
|
||||
path.join(dockerDir, 'volumes', 'redis', 'data'),
|
||||
path.join(dockerDir, 'volumes', 'weaviate'),
|
||||
]
|
||||
|
||||
const e2eStatePaths = [
|
||||
path.join(e2eDir, '.auth'),
|
||||
path.join(e2eDir, 'cucumber-report'),
|
||||
path.join(e2eDir, '.logs'),
|
||||
path.join(e2eDir, 'playwright-report'),
|
||||
path.join(e2eDir, 'test-results'),
|
||||
]
|
||||
|
||||
const composeArgs = [
|
||||
'compose',
|
||||
'-f',
|
||||
middlewareComposeFile,
|
||||
'--profile',
|
||||
'postgresql',
|
||||
'--profile',
|
||||
'weaviate',
|
||||
]
|
||||
|
||||
const getApiEnvironment = async () => {
|
||||
const envFromExample = await readSimpleDotenv(apiEnvExampleFile)
|
||||
|
||||
return {
|
||||
...envFromExample,
|
||||
FLASK_APP: 'app.py',
|
||||
}
|
||||
}
|
||||
|
||||
const getServiceContainerId = async (service: string) => {
|
||||
const result = await runCommandOrThrow({
|
||||
command: 'docker',
|
||||
args: ['compose', '-f', middlewareComposeFile, 'ps', '-q', service],
|
||||
cwd: dockerDir,
|
||||
stdio: 'pipe',
|
||||
})
|
||||
|
||||
return result.stdout.trim()
|
||||
}
|
||||
|
||||
const getContainerHealth = async (containerId: string) => {
|
||||
const result = await runCommand({
|
||||
command: 'docker',
|
||||
args: ['inspect', '-f', '{{.State.Health.Status}}', containerId],
|
||||
cwd: dockerDir,
|
||||
stdio: 'pipe',
|
||||
})
|
||||
|
||||
if (result.exitCode !== 0) return ''
|
||||
|
||||
return result.stdout.trim()
|
||||
}
|
||||
|
||||
const printComposeLogs = async (services: string[]) => {
|
||||
await runCommand({
|
||||
command: 'docker',
|
||||
args: ['compose', '-f', middlewareComposeFile, 'logs', ...services],
|
||||
cwd: dockerDir,
|
||||
})
|
||||
}
|
||||
|
||||
const waitForDependency = async ({
|
||||
description,
|
||||
services,
|
||||
wait,
|
||||
}: {
|
||||
description: string
|
||||
services: string[]
|
||||
wait: () => Promise<void>
|
||||
}) => {
|
||||
console.log(`Waiting for ${description}...`)
|
||||
|
||||
try {
|
||||
await wait()
|
||||
} catch (error) {
|
||||
await printComposeLogs(services)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
export const ensureWebBuild = async () => {
|
||||
await ensureWebEnvLocal()
|
||||
|
||||
if (process.env.E2E_FORCE_WEB_BUILD === '1') {
|
||||
await runCommandOrThrow({
|
||||
command: 'pnpm',
|
||||
args: ['run', 'build'],
|
||||
cwd: webDir,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
await access(buildIdPath)
|
||||
console.log('Reusing existing web build artifact.')
|
||||
} catch {
|
||||
await runCommandOrThrow({
|
||||
command: 'pnpm',
|
||||
args: ['run', 'build'],
|
||||
cwd: webDir,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
export const startWeb = async () => {
|
||||
await ensureWebBuild()
|
||||
|
||||
await runForegroundProcess({
|
||||
command: 'pnpm',
|
||||
args: ['run', 'start'],
|
||||
cwd: webDir,
|
||||
env: {
|
||||
HOSTNAME: '127.0.0.1',
|
||||
PORT: '3000',
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export const startApi = async () => {
|
||||
const env = await getApiEnvironment()
|
||||
|
||||
await runCommandOrThrow({
|
||||
command: 'uv',
|
||||
args: ['run', '--project', '.', 'flask', 'upgrade-db'],
|
||||
cwd: apiDir,
|
||||
env,
|
||||
})
|
||||
|
||||
await runForegroundProcess({
|
||||
command: 'uv',
|
||||
args: ['run', '--project', '.', 'flask', 'run', '--host', '127.0.0.1', '--port', '5001'],
|
||||
cwd: apiDir,
|
||||
env,
|
||||
})
|
||||
}
|
||||
|
||||
export const stopMiddleware = async () => {
|
||||
await runCommandOrThrow({
|
||||
command: 'docker',
|
||||
args: [...composeArgs, 'down', '--remove-orphans'],
|
||||
cwd: dockerDir,
|
||||
})
|
||||
}
|
||||
|
||||
export const resetState = async () => {
|
||||
console.log('Stopping middleware services...')
|
||||
try {
|
||||
await stopMiddleware()
|
||||
} catch {
|
||||
// Reset should continue even if middleware is already stopped.
|
||||
}
|
||||
|
||||
console.log('Removing persisted middleware data...')
|
||||
await Promise.all(
|
||||
middlewareDataPaths.map(async (targetPath) => {
|
||||
await rm(targetPath, { force: true, recursive: true })
|
||||
await mkdir(targetPath, { recursive: true })
|
||||
}),
|
||||
)
|
||||
|
||||
console.log('Removing E2E local state...')
|
||||
await Promise.all(
|
||||
e2eStatePaths.map((targetPath) => rm(targetPath, { force: true, recursive: true })),
|
||||
)
|
||||
|
||||
console.log('E2E state reset complete.')
|
||||
}
|
||||
|
||||
export const startMiddleware = async () => {
|
||||
await ensureFileExists(middlewareEnvFile, middlewareEnvExampleFile)
|
||||
await ensureLineInFile(middlewareEnvFile, 'COMPOSE_PROFILES=postgresql,weaviate')
|
||||
|
||||
console.log('Starting middleware services...')
|
||||
await runCommandOrThrow({
|
||||
command: 'docker',
|
||||
args: [
|
||||
...composeArgs,
|
||||
'up',
|
||||
'-d',
|
||||
'db_postgres',
|
||||
'redis',
|
||||
'weaviate',
|
||||
'sandbox',
|
||||
'ssrf_proxy',
|
||||
'plugin_daemon',
|
||||
],
|
||||
cwd: dockerDir,
|
||||
})
|
||||
|
||||
const [postgresContainerId, redisContainerId] = await Promise.all([
|
||||
getServiceContainerId('db_postgres'),
|
||||
getServiceContainerId('redis'),
|
||||
])
|
||||
|
||||
await waitForDependency({
|
||||
description: 'PostgreSQL and Redis health checks',
|
||||
services: ['db_postgres', 'redis'],
|
||||
wait: () =>
|
||||
waitForCondition({
|
||||
check: async () => {
|
||||
const [postgresStatus, redisStatus] = await Promise.all([
|
||||
getContainerHealth(postgresContainerId),
|
||||
getContainerHealth(redisContainerId),
|
||||
])
|
||||
|
||||
return postgresStatus === 'healthy' && redisStatus === 'healthy'
|
||||
},
|
||||
description: 'PostgreSQL and Redis health checks',
|
||||
intervalMs: 2_000,
|
||||
timeoutMs: 240_000,
|
||||
}),
|
||||
})
|
||||
|
||||
await waitForDependency({
|
||||
description: 'Weaviate readiness',
|
||||
services: ['weaviate'],
|
||||
wait: () => waitForUrl('http://127.0.0.1:8080/v1/.well-known/ready', 120_000, 2_000),
|
||||
})
|
||||
|
||||
await waitForDependency({
|
||||
description: 'sandbox health',
|
||||
services: ['sandbox', 'ssrf_proxy'],
|
||||
wait: () => waitForUrl('http://127.0.0.1:8194/health', 120_000, 2_000),
|
||||
})
|
||||
|
||||
await waitForDependency({
|
||||
description: 'plugin daemon port',
|
||||
services: ['plugin_daemon'],
|
||||
wait: () =>
|
||||
waitForCondition({
|
||||
check: async () => isTcpPortReachable('127.0.0.1', 5002),
|
||||
description: 'plugin daemon port',
|
||||
intervalMs: 2_000,
|
||||
timeoutMs: 120_000,
|
||||
}),
|
||||
})
|
||||
|
||||
console.log('Full middleware stack is ready.')
|
||||
}
|
||||
|
||||
const printUsage = () => {
|
||||
console.log('Usage: tsx ./scripts/setup.ts <reset|middleware-up|middleware-down|api|web>')
|
||||
}
|
||||
|
||||
const main = async () => {
|
||||
const command = process.argv[2]
|
||||
|
||||
switch (command) {
|
||||
case 'api':
|
||||
await startApi()
|
||||
return
|
||||
case 'middleware-down':
|
||||
await stopMiddleware()
|
||||
return
|
||||
case 'middleware-up':
|
||||
await startMiddleware()
|
||||
return
|
||||
case 'reset':
|
||||
await resetState()
|
||||
return
|
||||
case 'web':
|
||||
await startWeb()
|
||||
return
|
||||
default:
|
||||
printUsage()
|
||||
process.exitCode = 1
|
||||
}
|
||||
}
|
||||
|
||||
if (isMainModule(import.meta.url)) {
|
||||
void main().catch((error) => {
|
||||
console.error(error instanceof Error ? error.message : String(error))
|
||||
process.exit(1)
|
||||
})
|
||||
}
|
||||
178
e2e/support/process.ts
Normal file
178
e2e/support/process.ts
Normal file
@ -0,0 +1,178 @@
|
||||
import type { ChildProcess } from 'node:child_process'
|
||||
import { spawn } from 'node:child_process'
|
||||
import { createWriteStream, type WriteStream } from 'node:fs'
|
||||
import { mkdir } from 'node:fs/promises'
|
||||
import net from 'node:net'
|
||||
import { dirname } from 'node:path'
|
||||
|
||||
type ManagedProcessOptions = {
|
||||
command: string
|
||||
args?: string[]
|
||||
cwd: string
|
||||
env?: NodeJS.ProcessEnv
|
||||
label: string
|
||||
logFilePath: string
|
||||
}
|
||||
|
||||
export type ManagedProcess = {
|
||||
childProcess: ChildProcess
|
||||
label: string
|
||||
logFilePath: string
|
||||
logStream: WriteStream
|
||||
}
|
||||
|
||||
export const sleep = (ms: number) =>
|
||||
new Promise<void>((resolve) => {
|
||||
setTimeout(resolve, ms)
|
||||
})
|
||||
|
||||
export const isPortReachable = async (host: string, port: number, timeoutMs = 1_000) => {
|
||||
return await new Promise<boolean>((resolve) => {
|
||||
const socket = net.createConnection({
|
||||
host,
|
||||
port,
|
||||
})
|
||||
|
||||
const finish = (result: boolean) => {
|
||||
socket.removeAllListeners()
|
||||
socket.destroy()
|
||||
resolve(result)
|
||||
}
|
||||
|
||||
socket.setTimeout(timeoutMs)
|
||||
socket.once('connect', () => finish(true))
|
||||
socket.once('timeout', () => finish(false))
|
||||
socket.once('error', () => finish(false))
|
||||
})
|
||||
}
|
||||
|
||||
export const waitForUrl = async (
|
||||
url: string,
|
||||
timeoutMs: number,
|
||||
intervalMs = 1_000,
|
||||
requestTimeoutMs = Math.max(intervalMs, 1_000),
|
||||
) => {
|
||||
const deadline = Date.now() + timeoutMs
|
||||
|
||||
while (Date.now() < deadline) {
|
||||
try {
|
||||
const controller = new AbortController()
|
||||
const timeout = setTimeout(() => controller.abort(), requestTimeoutMs)
|
||||
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
signal: controller.signal,
|
||||
})
|
||||
if (response.ok) return
|
||||
} finally {
|
||||
clearTimeout(timeout)
|
||||
}
|
||||
} catch {
|
||||
// Keep polling until timeout.
|
||||
}
|
||||
|
||||
await sleep(intervalMs)
|
||||
}
|
||||
|
||||
throw new Error(`Timed out waiting for ${url} after ${timeoutMs}ms.`)
|
||||
}
|
||||
|
||||
export const startLoggedProcess = async ({
|
||||
command,
|
||||
args = [],
|
||||
cwd,
|
||||
env,
|
||||
label,
|
||||
logFilePath,
|
||||
}: ManagedProcessOptions): Promise<ManagedProcess> => {
|
||||
await mkdir(dirname(logFilePath), { recursive: true })
|
||||
|
||||
const logStream = createWriteStream(logFilePath, { flags: 'a' })
|
||||
const childProcess = spawn(command, args, {
|
||||
cwd,
|
||||
env: {
|
||||
...process.env,
|
||||
...env,
|
||||
},
|
||||
detached: process.platform !== 'win32',
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
})
|
||||
|
||||
const formattedCommand = [command, ...args].join(' ')
|
||||
logStream.write(`[${new Date().toISOString()}] Starting ${label}: ${formattedCommand}\n`)
|
||||
childProcess.stdout?.pipe(logStream, { end: false })
|
||||
childProcess.stderr?.pipe(logStream, { end: false })
|
||||
|
||||
return {
|
||||
childProcess,
|
||||
label,
|
||||
logFilePath,
|
||||
logStream,
|
||||
}
|
||||
}
|
||||
|
||||
const waitForProcessExit = (childProcess: ChildProcess, timeoutMs: number) =>
|
||||
new Promise<void>((resolve) => {
|
||||
if (childProcess.exitCode !== null) {
|
||||
resolve()
|
||||
return
|
||||
}
|
||||
|
||||
const timeout = setTimeout(() => {
|
||||
cleanup()
|
||||
resolve()
|
||||
}, timeoutMs)
|
||||
|
||||
const onExit = () => {
|
||||
cleanup()
|
||||
resolve()
|
||||
}
|
||||
|
||||
const cleanup = () => {
|
||||
clearTimeout(timeout)
|
||||
childProcess.off('exit', onExit)
|
||||
}
|
||||
|
||||
childProcess.once('exit', onExit)
|
||||
})
|
||||
|
||||
const signalManagedProcess = (childProcess: ChildProcess, signal: NodeJS.Signals) => {
|
||||
const { pid } = childProcess
|
||||
if (!pid) return
|
||||
|
||||
try {
|
||||
if (process.platform !== 'win32') {
|
||||
process.kill(-pid, signal)
|
||||
return
|
||||
}
|
||||
|
||||
childProcess.kill(signal)
|
||||
} catch {
|
||||
// Best-effort shutdown. Cleanup continues even when the process is already gone.
|
||||
}
|
||||
}
|
||||
|
||||
export const stopManagedProcess = async (managedProcess?: ManagedProcess) => {
|
||||
if (!managedProcess) return
|
||||
|
||||
const { childProcess, logStream } = managedProcess
|
||||
|
||||
if (childProcess.exitCode === null) {
|
||||
signalManagedProcess(childProcess, 'SIGTERM')
|
||||
await waitForProcessExit(childProcess, 5_000)
|
||||
}
|
||||
|
||||
if (childProcess.exitCode === null) {
|
||||
signalManagedProcess(childProcess, 'SIGKILL')
|
||||
await waitForProcessExit(childProcess, 5_000)
|
||||
}
|
||||
|
||||
childProcess.stdout?.unpipe(logStream)
|
||||
childProcess.stderr?.unpipe(logStream)
|
||||
childProcess.stdout?.destroy()
|
||||
childProcess.stderr?.destroy()
|
||||
|
||||
await new Promise<void>((resolve) => {
|
||||
logStream.end(() => resolve())
|
||||
})
|
||||
}
|
||||
83
e2e/support/web-server.ts
Normal file
83
e2e/support/web-server.ts
Normal file
@ -0,0 +1,83 @@
|
||||
import type { ManagedProcess } from './process'
|
||||
import { isPortReachable, startLoggedProcess, stopManagedProcess, waitForUrl } from './process'
|
||||
|
||||
type WebServerStartOptions = {
|
||||
baseURL: string
|
||||
command: string
|
||||
args?: string[]
|
||||
cwd: string
|
||||
logFilePath: string
|
||||
reuseExistingServer: boolean
|
||||
timeoutMs: number
|
||||
}
|
||||
|
||||
let activeProcess: ManagedProcess | undefined
|
||||
|
||||
const getUrlHostAndPort = (url: string) => {
|
||||
const parsedUrl = new URL(url)
|
||||
const isHttps = parsedUrl.protocol === 'https:'
|
||||
|
||||
return {
|
||||
host: parsedUrl.hostname,
|
||||
port: parsedUrl.port ? Number(parsedUrl.port) : isHttps ? 443 : 80,
|
||||
}
|
||||
}
|
||||
|
||||
export const startWebServer = async ({
|
||||
baseURL,
|
||||
command,
|
||||
args = [],
|
||||
cwd,
|
||||
logFilePath,
|
||||
reuseExistingServer,
|
||||
timeoutMs,
|
||||
}: WebServerStartOptions) => {
|
||||
const { host, port } = getUrlHostAndPort(baseURL)
|
||||
|
||||
if (reuseExistingServer && (await isPortReachable(host, port))) return
|
||||
|
||||
activeProcess = await startLoggedProcess({
|
||||
command,
|
||||
args,
|
||||
cwd,
|
||||
label: 'web server',
|
||||
logFilePath,
|
||||
})
|
||||
|
||||
let startupError: Error | undefined
|
||||
activeProcess.childProcess.once('error', (error) => {
|
||||
startupError = error
|
||||
})
|
||||
activeProcess.childProcess.once('exit', (code, signal) => {
|
||||
if (startupError) return
|
||||
|
||||
startupError = new Error(
|
||||
`Web server exited before readiness (code: ${code ?? 'unknown'}, signal: ${signal ?? 'none'}).`,
|
||||
)
|
||||
})
|
||||
|
||||
const deadline = Date.now() + timeoutMs
|
||||
while (Date.now() < deadline) {
|
||||
if (startupError) {
|
||||
await stopManagedProcess(activeProcess)
|
||||
activeProcess = undefined
|
||||
throw startupError
|
||||
}
|
||||
|
||||
try {
|
||||
await waitForUrl(baseURL, 1_000, 250, 1_000)
|
||||
return
|
||||
} catch {
|
||||
// Continue polling until timeout or child exit.
|
||||
}
|
||||
}
|
||||
|
||||
await stopManagedProcess(activeProcess)
|
||||
activeProcess = undefined
|
||||
throw new Error(`Timed out waiting for web server readiness at ${baseURL} after ${timeoutMs}ms.`)
|
||||
}
|
||||
|
||||
export const stopWebServer = async () => {
|
||||
await stopManagedProcess(activeProcess)
|
||||
activeProcess = undefined
|
||||
}
|
||||
12
e2e/test-env.ts
Normal file
12
e2e/test-env.ts
Normal file
@ -0,0 +1,12 @@
|
||||
export const defaultBaseURL = 'http://127.0.0.1:3000'
|
||||
export const defaultApiURL = 'http://127.0.0.1:5001'
|
||||
export const defaultLocale = 'en-US'
|
||||
|
||||
export const baseURL = process.env.E2E_BASE_URL || defaultBaseURL
|
||||
export const apiURL = process.env.E2E_API_URL || defaultApiURL
|
||||
|
||||
export const cucumberHeadless = process.env.CUCUMBER_HEADLESS !== '0'
|
||||
export const cucumberSlowMo = Number(process.env.E2E_SLOW_MO || 0)
|
||||
export const reuseExistingWebServer = process.env.E2E_REUSE_WEB_SERVER
|
||||
? process.env.E2E_REUSE_WEB_SERVER !== '0'
|
||||
: !process.env.CI
|
||||
25
e2e/tsconfig.json
Normal file
25
e2e/tsconfig.json
Normal file
@ -0,0 +1,25 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2023",
|
||||
"lib": ["ES2023", "DOM"],
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "Bundler",
|
||||
"allowJs": false,
|
||||
"resolveJsonModule": true,
|
||||
"noEmit": true,
|
||||
"strict": true,
|
||||
"skipLibCheck": true,
|
||||
"types": ["node", "@playwright/test", "@cucumber/cucumber"],
|
||||
"isolatedModules": true,
|
||||
"verbatimModuleSyntax": true
|
||||
},
|
||||
"include": ["./**/*.ts"],
|
||||
"exclude": [
|
||||
"./node_modules",
|
||||
"./playwright-report",
|
||||
"./test-results",
|
||||
"./.auth",
|
||||
"./cucumber-report",
|
||||
"./.logs"
|
||||
]
|
||||
}
|
||||
15
e2e/vite.config.ts
Normal file
15
e2e/vite.config.ts
Normal file
@ -0,0 +1,15 @@
|
||||
import { defineConfig } from 'vite-plus'
|
||||
|
||||
export default defineConfig({
|
||||
lint: {
|
||||
options: {
|
||||
typeAware: true,
|
||||
typeCheck: true,
|
||||
denyWarnings: true,
|
||||
},
|
||||
},
|
||||
fmt: {
|
||||
singleQuote: true,
|
||||
semi: false,
|
||||
},
|
||||
})
|
||||
@ -220,7 +220,7 @@
|
||||
"eslint-plugin-react-refresh": "0.5.2",
|
||||
"eslint-plugin-sonarjs": "4.0.2",
|
||||
"eslint-plugin-storybook": "10.3.1",
|
||||
"happy-dom": "20.8.8",
|
||||
"happy-dom": "20.8.9",
|
||||
"hono": "4.12.8",
|
||||
"husky": "9.1.7",
|
||||
"iconify-import-svg": "0.1.2",
|
||||
|
||||
104
web/pnpm-lock.yaml
generated
104
web/pnpm-lock.yaml
generated
@ -371,7 +371,7 @@ importers:
|
||||
devDependencies:
|
||||
'@antfu/eslint-config':
|
||||
specifier: 7.7.3
|
||||
version: 7.7.3(@eslint-react/eslint-plugin@3.0.0(eslint@10.1.0(jiti@1.21.7))(typescript@5.9.3))(@next/eslint-plugin-next@16.2.1)(@typescript-eslint/rule-tester@8.57.1(eslint@10.1.0(jiti@1.21.7))(typescript@5.9.3))(@typescript-eslint/typescript-estree@8.57.1(typescript@5.9.3))(@typescript-eslint/utils@8.57.1(eslint@10.1.0(jiti@1.21.7))(typescript@5.9.3))(@voidzero-dev/vite-plus-test@0.1.13(@types/node@25.5.0)(@voidzero-dev/vite-plus-core@0.1.13(@types/node@25.5.0)(esbuild@0.27.2)(jiti@1.21.7)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.3))(esbuild@0.27.2)(happy-dom@20.8.8)(jiti@1.21.7)(jsdom@29.0.1(canvas@3.2.2))(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.3))(@vue/compiler-sfc@3.5.30)(eslint-plugin-react-hooks@7.0.1(eslint@10.1.0(jiti@1.21.7)))(eslint-plugin-react-refresh@0.5.2(eslint@10.1.0(jiti@1.21.7)))(eslint@10.1.0(jiti@1.21.7))(oxlint@1.56.0(oxlint-tsgolint@0.17.1))(typescript@5.9.3)
|
||||
version: 7.7.3(@eslint-react/eslint-plugin@3.0.0(eslint@10.1.0(jiti@1.21.7))(typescript@5.9.3))(@next/eslint-plugin-next@16.2.1)(@typescript-eslint/rule-tester@8.57.1(eslint@10.1.0(jiti@1.21.7))(typescript@5.9.3))(@typescript-eslint/typescript-estree@8.57.1(typescript@5.9.3))(@typescript-eslint/utils@8.57.1(eslint@10.1.0(jiti@1.21.7))(typescript@5.9.3))(@voidzero-dev/vite-plus-test@0.1.13(@types/node@25.5.0)(@voidzero-dev/vite-plus-core@0.1.13(@types/node@25.5.0)(esbuild@0.27.2)(jiti@1.21.7)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.3))(esbuild@0.27.2)(happy-dom@20.8.9)(jiti@1.21.7)(jsdom@29.0.1(canvas@3.2.2))(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.3))(@vue/compiler-sfc@3.5.30)(eslint-plugin-react-hooks@7.0.1(eslint@10.1.0(jiti@1.21.7)))(eslint-plugin-react-refresh@0.5.2(eslint@10.1.0(jiti@1.21.7)))(eslint@10.1.0(jiti@1.21.7))(oxlint@1.56.0(oxlint-tsgolint@0.17.1))(typescript@5.9.3)
|
||||
'@chromatic-com/storybook':
|
||||
specifier: 5.0.2
|
||||
version: 5.0.2(storybook@10.3.1(@testing-library/dom@10.4.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))
|
||||
@ -506,7 +506,7 @@ importers:
|
||||
version: 0.5.21(@voidzero-dev/vite-plus-core@0.1.13(@types/node@25.5.0)(esbuild@0.27.2)(jiti@1.21.7)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.3))(react-dom@19.2.4(react@19.2.4))(react-server-dom-webpack@19.2.4(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(webpack@5.105.4(esbuild@0.27.2)(uglify-js@3.19.3)))(react@19.2.4)
|
||||
'@vitest/coverage-v8':
|
||||
specifier: 4.1.0
|
||||
version: 4.1.0(@voidzero-dev/vite-plus-test@0.1.13(@types/node@25.5.0)(@voidzero-dev/vite-plus-core@0.1.13(@types/node@25.5.0)(esbuild@0.27.2)(jiti@1.21.7)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.3))(esbuild@0.27.2)(happy-dom@20.8.8)(jiti@1.21.7)(jsdom@29.0.1(canvas@3.2.2))(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.3))
|
||||
version: 4.1.0(@voidzero-dev/vite-plus-test@0.1.13(@types/node@25.5.0)(@voidzero-dev/vite-plus-core@0.1.13(@types/node@25.5.0)(esbuild@0.27.2)(jiti@1.21.7)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.3))(esbuild@0.27.2)(happy-dom@20.8.9)(jiti@1.21.7)(jsdom@29.0.1(canvas@3.2.2))(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.3))
|
||||
agentation:
|
||||
specifier: 2.3.3
|
||||
version: 2.3.3(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
||||
@ -547,8 +547,8 @@ importers:
|
||||
specifier: 10.3.1
|
||||
version: 10.3.1(eslint@10.1.0(jiti@1.21.7))(storybook@10.3.1(@testing-library/dom@10.4.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(typescript@5.9.3)
|
||||
happy-dom:
|
||||
specifier: 20.8.8
|
||||
version: 20.8.8
|
||||
specifier: 20.8.9
|
||||
version: 20.8.9
|
||||
hono:
|
||||
specifier: 4.12.8
|
||||
version: 4.12.8
|
||||
@ -605,13 +605,13 @@ importers:
|
||||
version: 11.3.3(@voidzero-dev/vite-plus-core@0.1.13(@types/node@25.5.0)(esbuild@0.27.2)(jiti@1.21.7)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.3))
|
||||
vite-plus:
|
||||
specifier: 0.1.13
|
||||
version: 0.1.13(@types/node@25.5.0)(@voidzero-dev/vite-plus-core@0.1.13(@types/node@25.5.0)(esbuild@0.27.2)(jiti@1.21.7)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.3))(esbuild@0.27.2)(happy-dom@20.8.8)(jiti@1.21.7)(jsdom@29.0.1(canvas@3.2.2))(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.3)
|
||||
version: 0.1.13(@types/node@25.5.0)(@voidzero-dev/vite-plus-core@0.1.13(@types/node@25.5.0)(esbuild@0.27.2)(jiti@1.21.7)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.3))(esbuild@0.27.2)(happy-dom@20.8.9)(jiti@1.21.7)(jsdom@29.0.1(canvas@3.2.2))(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.3)
|
||||
vitest:
|
||||
specifier: npm:@voidzero-dev/vite-plus-test@0.1.13
|
||||
version: '@voidzero-dev/vite-plus-test@0.1.13(@types/node@25.5.0)(@voidzero-dev/vite-plus-core@0.1.13(@types/node@25.5.0)(esbuild@0.27.2)(jiti@1.21.7)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.3))(esbuild@0.27.2)(happy-dom@20.8.8)(jiti@1.21.7)(jsdom@29.0.1(canvas@3.2.2))(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.3)'
|
||||
version: '@voidzero-dev/vite-plus-test@0.1.13(@types/node@25.5.0)(@voidzero-dev/vite-plus-core@0.1.13(@types/node@25.5.0)(esbuild@0.27.2)(jiti@1.21.7)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.3))(esbuild@0.27.2)(happy-dom@20.8.9)(jiti@1.21.7)(jsdom@29.0.1(canvas@3.2.2))(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.3)'
|
||||
vitest-canvas-mock:
|
||||
specifier: 1.1.3
|
||||
version: 1.1.3(@voidzero-dev/vite-plus-test@0.1.13(@types/node@25.5.0)(@voidzero-dev/vite-plus-core@0.1.13(@types/node@25.5.0)(esbuild@0.27.2)(jiti@1.21.7)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.3))(esbuild@0.27.2)(happy-dom@20.8.8)(jiti@1.21.7)(jsdom@29.0.1(canvas@3.2.2))(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.3))
|
||||
version: 1.1.3(@voidzero-dev/vite-plus-test@0.1.13(@types/node@25.5.0)(@voidzero-dev/vite-plus-core@0.1.13(@types/node@25.5.0)(esbuild@0.27.2)(jiti@1.21.7)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.3))(esbuild@0.27.2)(happy-dom@20.8.9)(jiti@1.21.7)(jsdom@29.0.1(canvas@3.2.2))(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.3))
|
||||
|
||||
packages:
|
||||
|
||||
@ -768,12 +768,12 @@ packages:
|
||||
'@antfu/utils@8.1.1':
|
||||
resolution: {integrity: sha512-Mex9nXf9vR6AhcXmMrlz/HVgYYZpVGJ6YlPgwl7UnaFpnshXs6EK/oa5Gpf3CzENMjkvEx2tQtntGnb7UtSTOQ==}
|
||||
|
||||
'@asamuzakjp/css-color@5.0.1':
|
||||
resolution: {integrity: sha512-2SZFvqMyvboVV1d15lMf7XiI3m7SDqXUuKaTymJYLN6dSGadqp+fVojqJlVoMlbZnlTmu3S0TLwLTJpvBMO1Aw==}
|
||||
'@asamuzakjp/css-color@5.1.1':
|
||||
resolution: {integrity: sha512-iGWN8E45Ws0XWx3D44Q1t6vX2LqhCKcwfmwBYCDsFrYFS6m4q/Ks61L2veETaLv+ckDC6+dTETJoaAAb7VjLiw==}
|
||||
engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0}
|
||||
|
||||
'@asamuzakjp/dom-selector@7.0.3':
|
||||
resolution: {integrity: sha512-Q6mU0Z6bfj6YvnX2k9n0JxiIwrCFN59x/nWmYQnAqP000ruX/yV+5bp/GRcF5T8ncvfwJQ7fgfP74DlpKExILA==}
|
||||
'@asamuzakjp/dom-selector@7.0.4':
|
||||
resolution: {integrity: sha512-jXR6x4AcT3eIrS2fSNAwJpwirOkGcd+E7F7CP3zjdTqz9B/2huHOL8YJZBgekKwLML+u7qB/6P1LXQuMScsx0w==}
|
||||
engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0}
|
||||
|
||||
'@asamuzakjp/nwsapi@2.3.9':
|
||||
@ -957,8 +957,8 @@ packages:
|
||||
peerDependencies:
|
||||
'@csstools/css-tokenizer': ^4.0.0
|
||||
|
||||
'@csstools/css-syntax-patches-for-csstree@1.1.1':
|
||||
resolution: {integrity: sha512-BvqN0AMWNAnLk9G8jnUT77D+mUbY/H2b3uDTvg2isJkHaOufUE2R3AOwxWo7VBQKT1lOdwdvorddo2B/lk64+w==}
|
||||
'@csstools/css-syntax-patches-for-csstree@1.1.2':
|
||||
resolution: {integrity: sha512-5GkLzz4prTIpoyeUiIu3iV6CSG3Plo7xRVOFPKI7FVEJ3mZ0A8SwK0XU3Gl7xAkiQ+mDyam+NNp875/C5y+jSA==}
|
||||
peerDependencies:
|
||||
css-tree: ^3.2.1
|
||||
peerDependenciesMeta:
|
||||
@ -5356,8 +5356,8 @@ packages:
|
||||
hachure-fill@0.5.2:
|
||||
resolution: {integrity: sha512-3GKBOn+m2LX9iq+JC1064cSFprJY4jL1jCXTcpnfER5HYE2l/4EfWSGzkPa/ZDBmYI0ZOEj5VHV/eKnPGkHuOg==}
|
||||
|
||||
happy-dom@20.8.8:
|
||||
resolution: {integrity: sha512-5/F8wxkNxYtsN0bXfMwIyNLZ9WYsoOYPbmoluqVJqv8KBUbcyKZawJ7uYK4WTX8IHBLYv+VXIwfeNDPy1oKMwQ==}
|
||||
happy-dom@20.8.9:
|
||||
resolution: {integrity: sha512-Tz23LR9T9jOGVZm2x1EPdXqwA37G/owYMxRwU0E4miurAtFsPMQ1d2Jc2okUaSjZqAFz2oEn3FLXC5a0a+siyA==}
|
||||
engines: {node: '>=20.0.0'}
|
||||
|
||||
has-flag@4.0.0:
|
||||
@ -7313,6 +7313,10 @@ packages:
|
||||
resolution: {integrity: sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg==}
|
||||
engines: {node: '>=6'}
|
||||
|
||||
tapable@2.3.2:
|
||||
resolution: {integrity: sha512-1MOpMXuhGzGL5TTCZFItxCc0AARf1EZFQkGqMm7ERKj8+Hgr5oLvJOVFcC+lRmR8hCe2S3jC4T5D7Vg/d7/fhA==}
|
||||
engines: {node: '>=6'}
|
||||
|
||||
tar-fs@2.1.4:
|
||||
resolution: {integrity: sha512-mDAjwmZdh7LTT6pNleZ05Yt65HC3E+NiQzl672vQG38jIrehtJk/J3mNwIg+vShQPcLF/LV7CMnDW6vjj6sfYQ==}
|
||||
|
||||
@ -7532,8 +7536,8 @@ packages:
|
||||
resolution: {integrity: sha512-jxytwMHhsbdpBXxLAcuu0fzlQeXCNnWdDyRHpvWsUl8vd98UwYdl9YTyn8/HcpcJPC3pwUveefsa3zTxyD/ERg==}
|
||||
engines: {node: '>=20.18.1'}
|
||||
|
||||
undici@7.24.5:
|
||||
resolution: {integrity: sha512-3IWdCpjgxp15CbJnsi/Y9TCDE7HWVN19j1hmzVhoAkY/+CJx449tVxT5wZc1Gwg8J+P0LWvzlBzxYRnHJ+1i7Q==}
|
||||
undici@7.24.6:
|
||||
resolution: {integrity: sha512-Xi4agocCbRzt0yYMZGMA6ApD7gvtUFaxm4ZmeacWI4cZxaF6C+8I8QfofC20NAePiB/IcvZmzkJ7XPa471AEtA==}
|
||||
engines: {node: '>=20.18.1'}
|
||||
|
||||
unicode-trie@2.0.0:
|
||||
@ -7680,7 +7684,7 @@ packages:
|
||||
resolution: {integrity: sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q==}
|
||||
|
||||
vinext@https://pkg.pr.new/vinext@b6a2cac:
|
||||
resolution: {integrity: sha512-/Jm507qqC1dCOhCaorb9H8/I5JEqkcsiUJw0Wgprg7Znym4eyLUvcWcRLVyM9z22Tm0+O1PugcSDA8oNvbqPuQ==, tarball: https://pkg.pr.new/vinext@b6a2cac}
|
||||
resolution: {tarball: https://pkg.pr.new/vinext@b6a2cac}
|
||||
version: 0.0.5
|
||||
engines: {node: '>=22'}
|
||||
hasBin: true
|
||||
@ -7882,6 +7886,18 @@ packages:
|
||||
utf-8-validate:
|
||||
optional: true
|
||||
|
||||
ws@8.20.0:
|
||||
resolution: {integrity: sha512-sAt8BhgNbzCtgGbt2OxmpuryO63ZoDk/sqaB/znQm94T4fCEsy/yV+7CdC1kJhOU9lboAEU7R3kquuycDoibVA==}
|
||||
engines: {node: '>=10.0.0'}
|
||||
peerDependencies:
|
||||
bufferutil: ^4.0.1
|
||||
utf-8-validate: '>=5.0.2'
|
||||
peerDependenciesMeta:
|
||||
bufferutil:
|
||||
optional: true
|
||||
utf-8-validate:
|
||||
optional: true
|
||||
|
||||
wsl-utils@0.1.0:
|
||||
resolution: {integrity: sha512-h3Fbisa2nKGPxCpm89Hk33lBLsnaGBvctQopaBSOW/uIs6FTe1ATyAnKFJrzVs9vpGdsTe73WF3V4lIsk4Gacw==}
|
||||
engines: {node: '>=18'}
|
||||
@ -8141,7 +8157,7 @@ snapshots:
|
||||
idb: 8.0.0
|
||||
tslib: 2.8.1
|
||||
|
||||
'@antfu/eslint-config@7.7.3(@eslint-react/eslint-plugin@3.0.0(eslint@10.1.0(jiti@1.21.7))(typescript@5.9.3))(@next/eslint-plugin-next@16.2.1)(@typescript-eslint/rule-tester@8.57.1(eslint@10.1.0(jiti@1.21.7))(typescript@5.9.3))(@typescript-eslint/typescript-estree@8.57.1(typescript@5.9.3))(@typescript-eslint/utils@8.57.1(eslint@10.1.0(jiti@1.21.7))(typescript@5.9.3))(@voidzero-dev/vite-plus-test@0.1.13(@types/node@25.5.0)(@voidzero-dev/vite-plus-core@0.1.13(@types/node@25.5.0)(esbuild@0.27.2)(jiti@1.21.7)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.3))(esbuild@0.27.2)(happy-dom@20.8.8)(jiti@1.21.7)(jsdom@29.0.1(canvas@3.2.2))(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.3))(@vue/compiler-sfc@3.5.30)(eslint-plugin-react-hooks@7.0.1(eslint@10.1.0(jiti@1.21.7)))(eslint-plugin-react-refresh@0.5.2(eslint@10.1.0(jiti@1.21.7)))(eslint@10.1.0(jiti@1.21.7))(oxlint@1.56.0(oxlint-tsgolint@0.17.1))(typescript@5.9.3)':
|
||||
'@antfu/eslint-config@7.7.3(@eslint-react/eslint-plugin@3.0.0(eslint@10.1.0(jiti@1.21.7))(typescript@5.9.3))(@next/eslint-plugin-next@16.2.1)(@typescript-eslint/rule-tester@8.57.1(eslint@10.1.0(jiti@1.21.7))(typescript@5.9.3))(@typescript-eslint/typescript-estree@8.57.1(typescript@5.9.3))(@typescript-eslint/utils@8.57.1(eslint@10.1.0(jiti@1.21.7))(typescript@5.9.3))(@voidzero-dev/vite-plus-test@0.1.13(@types/node@25.5.0)(@voidzero-dev/vite-plus-core@0.1.13(@types/node@25.5.0)(esbuild@0.27.2)(jiti@1.21.7)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.3))(esbuild@0.27.2)(happy-dom@20.8.9)(jiti@1.21.7)(jsdom@29.0.1(canvas@3.2.2))(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.3))(@vue/compiler-sfc@3.5.30)(eslint-plugin-react-hooks@7.0.1(eslint@10.1.0(jiti@1.21.7)))(eslint-plugin-react-refresh@0.5.2(eslint@10.1.0(jiti@1.21.7)))(eslint@10.1.0(jiti@1.21.7))(oxlint@1.56.0(oxlint-tsgolint@0.17.1))(typescript@5.9.3)':
|
||||
dependencies:
|
||||
'@antfu/install-pkg': 1.1.0
|
||||
'@clack/prompts': 1.1.0
|
||||
@ -8151,7 +8167,7 @@ snapshots:
|
||||
'@stylistic/eslint-plugin': 5.10.0(eslint@10.1.0(jiti@1.21.7))
|
||||
'@typescript-eslint/eslint-plugin': 8.57.1(@typescript-eslint/parser@8.57.1(eslint@10.1.0(jiti@1.21.7))(typescript@5.9.3))(eslint@10.1.0(jiti@1.21.7))(typescript@5.9.3)
|
||||
'@typescript-eslint/parser': 8.57.1(eslint@10.1.0(jiti@1.21.7))(typescript@5.9.3)
|
||||
'@vitest/eslint-plugin': 1.6.12(@voidzero-dev/vite-plus-test@0.1.13(@types/node@25.5.0)(@voidzero-dev/vite-plus-core@0.1.13(@types/node@25.5.0)(esbuild@0.27.2)(jiti@1.21.7)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.3))(esbuild@0.27.2)(happy-dom@20.8.8)(jiti@1.21.7)(jsdom@29.0.1(canvas@3.2.2))(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.3))(eslint@10.1.0(jiti@1.21.7))(typescript@5.9.3)
|
||||
'@vitest/eslint-plugin': 1.6.12(@voidzero-dev/vite-plus-test@0.1.13(@types/node@25.5.0)(@voidzero-dev/vite-plus-core@0.1.13(@types/node@25.5.0)(esbuild@0.27.2)(jiti@1.21.7)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.3))(esbuild@0.27.2)(happy-dom@20.8.9)(jiti@1.21.7)(jsdom@29.0.1(canvas@3.2.2))(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.3))(eslint@10.1.0(jiti@1.21.7))(typescript@5.9.3)
|
||||
ansis: 4.2.0
|
||||
cac: 7.0.0
|
||||
eslint: 10.1.0(jiti@1.21.7)
|
||||
@ -8211,7 +8227,7 @@ snapshots:
|
||||
|
||||
'@antfu/utils@8.1.1': {}
|
||||
|
||||
'@asamuzakjp/css-color@5.0.1':
|
||||
'@asamuzakjp/css-color@5.1.1':
|
||||
dependencies:
|
||||
'@csstools/css-calc': 3.1.1(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0)
|
||||
'@csstools/css-color-parser': 4.0.2(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0)
|
||||
@ -8220,7 +8236,7 @@ snapshots:
|
||||
lru-cache: 11.2.7
|
||||
optional: true
|
||||
|
||||
'@asamuzakjp/dom-selector@7.0.3':
|
||||
'@asamuzakjp/dom-selector@7.0.4':
|
||||
dependencies:
|
||||
'@asamuzakjp/nwsapi': 2.3.9
|
||||
bidi-js: 1.0.3
|
||||
@ -8480,7 +8496,7 @@ snapshots:
|
||||
'@csstools/css-tokenizer': 4.0.0
|
||||
optional: true
|
||||
|
||||
'@csstools/css-syntax-patches-for-csstree@1.1.1(css-tree@3.2.1)':
|
||||
'@csstools/css-syntax-patches-for-csstree@1.1.2(css-tree@3.2.1)':
|
||||
optionalDependencies:
|
||||
css-tree: 3.2.1
|
||||
optional: true
|
||||
@ -10368,7 +10384,7 @@ snapshots:
|
||||
|
||||
'@tanstack/devtools-event-bus@0.4.1':
|
||||
dependencies:
|
||||
ws: 8.19.0
|
||||
ws: 8.20.0
|
||||
transitivePeerDependencies:
|
||||
- bufferutil
|
||||
- utf-8-validate
|
||||
@ -11037,7 +11053,7 @@ snapshots:
|
||||
optionalDependencies:
|
||||
react-server-dom-webpack: 19.2.4(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(webpack@5.105.4(esbuild@0.27.2)(uglify-js@3.19.3))
|
||||
|
||||
'@vitest/coverage-v8@4.1.0(@voidzero-dev/vite-plus-test@0.1.13(@types/node@25.5.0)(@voidzero-dev/vite-plus-core@0.1.13(@types/node@25.5.0)(esbuild@0.27.2)(jiti@1.21.7)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.3))(esbuild@0.27.2)(happy-dom@20.8.8)(jiti@1.21.7)(jsdom@29.0.1(canvas@3.2.2))(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.3))':
|
||||
'@vitest/coverage-v8@4.1.0(@voidzero-dev/vite-plus-test@0.1.13(@types/node@25.5.0)(@voidzero-dev/vite-plus-core@0.1.13(@types/node@25.5.0)(esbuild@0.27.2)(jiti@1.21.7)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.3))(esbuild@0.27.2)(happy-dom@20.8.9)(jiti@1.21.7)(jsdom@29.0.1(canvas@3.2.2))(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.3))':
|
||||
dependencies:
|
||||
'@bcoe/v8-coverage': 1.0.2
|
||||
'@vitest/utils': 4.1.0
|
||||
@ -11049,16 +11065,16 @@ snapshots:
|
||||
obug: 2.1.1
|
||||
std-env: 4.0.0
|
||||
tinyrainbow: 3.1.0
|
||||
vitest: '@voidzero-dev/vite-plus-test@0.1.13(@types/node@25.5.0)(@voidzero-dev/vite-plus-core@0.1.13(@types/node@25.5.0)(esbuild@0.27.2)(jiti@1.21.7)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.3))(esbuild@0.27.2)(happy-dom@20.8.8)(jiti@1.21.7)(jsdom@29.0.1(canvas@3.2.2))(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.3)'
|
||||
vitest: '@voidzero-dev/vite-plus-test@0.1.13(@types/node@25.5.0)(@voidzero-dev/vite-plus-core@0.1.13(@types/node@25.5.0)(esbuild@0.27.2)(jiti@1.21.7)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.3))(esbuild@0.27.2)(happy-dom@20.8.9)(jiti@1.21.7)(jsdom@29.0.1(canvas@3.2.2))(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.3)'
|
||||
|
||||
'@vitest/eslint-plugin@1.6.12(@voidzero-dev/vite-plus-test@0.1.13(@types/node@25.5.0)(@voidzero-dev/vite-plus-core@0.1.13(@types/node@25.5.0)(esbuild@0.27.2)(jiti@1.21.7)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.3))(esbuild@0.27.2)(happy-dom@20.8.8)(jiti@1.21.7)(jsdom@29.0.1(canvas@3.2.2))(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.3))(eslint@10.1.0(jiti@1.21.7))(typescript@5.9.3)':
|
||||
'@vitest/eslint-plugin@1.6.12(@voidzero-dev/vite-plus-test@0.1.13(@types/node@25.5.0)(@voidzero-dev/vite-plus-core@0.1.13(@types/node@25.5.0)(esbuild@0.27.2)(jiti@1.21.7)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.3))(esbuild@0.27.2)(happy-dom@20.8.9)(jiti@1.21.7)(jsdom@29.0.1(canvas@3.2.2))(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.3))(eslint@10.1.0(jiti@1.21.7))(typescript@5.9.3)':
|
||||
dependencies:
|
||||
'@typescript-eslint/scope-manager': 8.57.1
|
||||
'@typescript-eslint/utils': 8.57.1(eslint@10.1.0(jiti@1.21.7))(typescript@5.9.3)
|
||||
eslint: 10.1.0(jiti@1.21.7)
|
||||
optionalDependencies:
|
||||
typescript: 5.9.3
|
||||
vitest: '@voidzero-dev/vite-plus-test@0.1.13(@types/node@25.5.0)(@voidzero-dev/vite-plus-core@0.1.13(@types/node@25.5.0)(esbuild@0.27.2)(jiti@1.21.7)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.3))(esbuild@0.27.2)(happy-dom@20.8.8)(jiti@1.21.7)(jsdom@29.0.1(canvas@3.2.2))(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.3)'
|
||||
vitest: '@voidzero-dev/vite-plus-test@0.1.13(@types/node@25.5.0)(@voidzero-dev/vite-plus-core@0.1.13(@types/node@25.5.0)(esbuild@0.27.2)(jiti@1.21.7)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.3))(esbuild@0.27.2)(happy-dom@20.8.9)(jiti@1.21.7)(jsdom@29.0.1(canvas@3.2.2))(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.3)'
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
@ -11123,7 +11139,7 @@ snapshots:
|
||||
'@voidzero-dev/vite-plus-linux-x64-gnu@0.1.13':
|
||||
optional: true
|
||||
|
||||
'@voidzero-dev/vite-plus-test@0.1.13(@types/node@25.5.0)(@voidzero-dev/vite-plus-core@0.1.13(@types/node@25.5.0)(esbuild@0.27.2)(jiti@1.21.7)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.3))(esbuild@0.27.2)(happy-dom@20.8.8)(jiti@1.21.7)(jsdom@29.0.1(canvas@3.2.2))(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.3)':
|
||||
'@voidzero-dev/vite-plus-test@0.1.13(@types/node@25.5.0)(@voidzero-dev/vite-plus-core@0.1.13(@types/node@25.5.0)(esbuild@0.27.2)(jiti@1.21.7)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.3))(esbuild@0.27.2)(happy-dom@20.8.9)(jiti@1.21.7)(jsdom@29.0.1(canvas@3.2.2))(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.3)':
|
||||
dependencies:
|
||||
'@standard-schema/spec': 1.1.0
|
||||
'@types/chai': 5.2.3
|
||||
@ -11141,7 +11157,7 @@ snapshots:
|
||||
ws: 8.19.0
|
||||
optionalDependencies:
|
||||
'@types/node': 25.5.0
|
||||
happy-dom: 20.8.8
|
||||
happy-dom: 20.8.9
|
||||
jsdom: 29.0.1(canvas@3.2.2)
|
||||
transitivePeerDependencies:
|
||||
- '@arethetypeswrong/core'
|
||||
@ -12915,14 +12931,14 @@ snapshots:
|
||||
|
||||
hachure-fill@0.5.2: {}
|
||||
|
||||
happy-dom@20.8.8:
|
||||
happy-dom@20.8.9:
|
||||
dependencies:
|
||||
'@types/node': 25.5.0
|
||||
'@types/whatwg-mimetype': 3.0.2
|
||||
'@types/ws': 8.18.1
|
||||
entities: 7.0.1
|
||||
whatwg-mimetype: 3.0.0
|
||||
ws: 8.19.0
|
||||
ws: 8.20.0
|
||||
transitivePeerDependencies:
|
||||
- bufferutil
|
||||
- utf-8-validate
|
||||
@ -13295,10 +13311,10 @@ snapshots:
|
||||
|
||||
jsdom@29.0.1(canvas@3.2.2):
|
||||
dependencies:
|
||||
'@asamuzakjp/css-color': 5.0.1
|
||||
'@asamuzakjp/dom-selector': 7.0.3
|
||||
'@asamuzakjp/css-color': 5.1.1
|
||||
'@asamuzakjp/dom-selector': 7.0.4
|
||||
'@bramus/specificity': 2.4.2
|
||||
'@csstools/css-syntax-patches-for-csstree': 1.1.1(css-tree@3.2.1)
|
||||
'@csstools/css-syntax-patches-for-csstree': 1.1.2(css-tree@3.2.1)
|
||||
'@exodus/bytes': 1.15.0
|
||||
css-tree: 3.2.1
|
||||
data-urls: 7.0.0
|
||||
@ -13310,7 +13326,7 @@ snapshots:
|
||||
saxes: 6.0.0
|
||||
symbol-tree: 3.2.4
|
||||
tough-cookie: 6.0.1
|
||||
undici: 7.24.5
|
||||
undici: 7.24.6
|
||||
w3c-xmlserializer: 5.0.0
|
||||
webidl-conversions: 8.0.1
|
||||
whatwg-mimetype: 5.0.0
|
||||
@ -15474,6 +15490,8 @@ snapshots:
|
||||
|
||||
tapable@2.3.0: {}
|
||||
|
||||
tapable@2.3.2: {}
|
||||
|
||||
tar-fs@2.1.4:
|
||||
dependencies:
|
||||
chownr: 1.1.4
|
||||
@ -15688,7 +15706,7 @@ snapshots:
|
||||
|
||||
undici@7.24.0: {}
|
||||
|
||||
undici@7.24.5:
|
||||
undici@7.24.6:
|
||||
optional: true
|
||||
|
||||
unicode-trie@2.0.0:
|
||||
@ -15913,11 +15931,11 @@ snapshots:
|
||||
- supports-color
|
||||
- typescript
|
||||
|
||||
vite-plus@0.1.13(@types/node@25.5.0)(@voidzero-dev/vite-plus-core@0.1.13(@types/node@25.5.0)(esbuild@0.27.2)(jiti@1.21.7)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.3))(esbuild@0.27.2)(happy-dom@20.8.8)(jiti@1.21.7)(jsdom@29.0.1(canvas@3.2.2))(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.3):
|
||||
vite-plus@0.1.13(@types/node@25.5.0)(@voidzero-dev/vite-plus-core@0.1.13(@types/node@25.5.0)(esbuild@0.27.2)(jiti@1.21.7)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.3))(esbuild@0.27.2)(happy-dom@20.8.9)(jiti@1.21.7)(jsdom@29.0.1(canvas@3.2.2))(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.3):
|
||||
dependencies:
|
||||
'@oxc-project/types': 0.120.0
|
||||
'@voidzero-dev/vite-plus-core': 0.1.13(@types/node@25.5.0)(esbuild@0.27.2)(jiti@1.21.7)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.3)
|
||||
'@voidzero-dev/vite-plus-test': 0.1.13(@types/node@25.5.0)(@voidzero-dev/vite-plus-core@0.1.13(@types/node@25.5.0)(esbuild@0.27.2)(jiti@1.21.7)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.3))(esbuild@0.27.2)(happy-dom@20.8.8)(jiti@1.21.7)(jsdom@29.0.1(canvas@3.2.2))(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.3)
|
||||
'@voidzero-dev/vite-plus-test': 0.1.13(@types/node@25.5.0)(@voidzero-dev/vite-plus-core@0.1.13(@types/node@25.5.0)(esbuild@0.27.2)(jiti@1.21.7)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.3))(esbuild@0.27.2)(happy-dom@20.8.9)(jiti@1.21.7)(jsdom@29.0.1(canvas@3.2.2))(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.3)
|
||||
cac: 7.0.0
|
||||
cross-spawn: 7.0.6
|
||||
oxfmt: 0.41.0
|
||||
@ -15984,11 +16002,11 @@ snapshots:
|
||||
optionalDependencies:
|
||||
vite: '@voidzero-dev/vite-plus-core@0.1.13(@types/node@25.5.0)(esbuild@0.27.2)(jiti@1.21.7)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.3)'
|
||||
|
||||
vitest-canvas-mock@1.1.3(@voidzero-dev/vite-plus-test@0.1.13(@types/node@25.5.0)(@voidzero-dev/vite-plus-core@0.1.13(@types/node@25.5.0)(esbuild@0.27.2)(jiti@1.21.7)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.3))(esbuild@0.27.2)(happy-dom@20.8.8)(jiti@1.21.7)(jsdom@29.0.1(canvas@3.2.2))(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.3)):
|
||||
vitest-canvas-mock@1.1.3(@voidzero-dev/vite-plus-test@0.1.13(@types/node@25.5.0)(@voidzero-dev/vite-plus-core@0.1.13(@types/node@25.5.0)(esbuild@0.27.2)(jiti@1.21.7)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.3))(esbuild@0.27.2)(happy-dom@20.8.9)(jiti@1.21.7)(jsdom@29.0.1(canvas@3.2.2))(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.3)):
|
||||
dependencies:
|
||||
cssfontparser: 1.2.1
|
||||
moo-color: 1.0.3
|
||||
vitest: '@voidzero-dev/vite-plus-test@0.1.13(@types/node@25.5.0)(@voidzero-dev/vite-plus-core@0.1.13(@types/node@25.5.0)(esbuild@0.27.2)(jiti@1.21.7)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.3))(esbuild@0.27.2)(happy-dom@20.8.8)(jiti@1.21.7)(jsdom@29.0.1(canvas@3.2.2))(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.3)'
|
||||
vitest: '@voidzero-dev/vite-plus-test@0.1.13(@types/node@25.5.0)(@voidzero-dev/vite-plus-core@0.1.13(@types/node@25.5.0)(esbuild@0.27.2)(jiti@1.21.7)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.3))(esbuild@0.27.2)(happy-dom@20.8.9)(jiti@1.21.7)(jsdom@29.0.1(canvas@3.2.2))(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.3)'
|
||||
|
||||
void-elements@3.1.0: {}
|
||||
|
||||
@ -16067,7 +16085,7 @@ snapshots:
|
||||
mime-types: 2.1.35
|
||||
neo-async: 2.6.2
|
||||
schema-utils: 4.3.3
|
||||
tapable: 2.3.0
|
||||
tapable: 2.3.2
|
||||
terser-webpack-plugin: 5.4.0(esbuild@0.27.2)(uglify-js@3.19.3)(webpack@5.105.4(esbuild@0.27.2)(uglify-js@3.19.3))
|
||||
watchpack: 2.5.1
|
||||
webpack-sources: 3.3.4
|
||||
@ -16112,6 +16130,8 @@ snapshots:
|
||||
|
||||
ws@8.19.0: {}
|
||||
|
||||
ws@8.20.0: {}
|
||||
|
||||
wsl-utils@0.1.0:
|
||||
dependencies:
|
||||
is-wsl: 3.1.1
|
||||
|
||||
Reference in New Issue
Block a user