Merge branch 'main' into jzh

This commit is contained in:
JzoNg
2026-03-30 09:48:58 +08:00
33 changed files with 4764 additions and 472 deletions

View File

@ -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

View File

@ -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
View 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

View File

@ -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.

View File

@ -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
)

View File

@ -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

View File

@ -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
View File

@ -0,0 +1,6 @@
node_modules/
.auth/
playwright-report/
test-results/
cucumber-report/
.logs/

164
e2e/AGENTS.md Normal file
View 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
View File

@ -0,0 +1,3 @@
# E2E
Canonical documentation for this package lives in [AGENTS.md](./AGENTS.md).

19
e2e/cucumber.config.ts Normal file
View 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

View 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

View 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

View 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

View 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)(?:\?.*)?$/)
})

View 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',
)
})

View 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 })
})

View 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')
},
)

View 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
})

View 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
View 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
View 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

File diff suppressed because it is too large Load Diff

242
e2e/scripts/common.ts Normal file
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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,
},
})

View File

@ -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
View File

@ -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