Files
dev-puppeteer/my-deepagent/tests/integration/test_workflow_generator.py
chungyeong 6d371afadd feat(my-deepagent): v0.4 — workflow generator UI + hot-reload + UX polish
브라우저에서 YAML 안 쓰고도 새 워크플로우 템플릿 만들기 + 즉시 등록.
+ /new.html / index.html / new-workflow.html / runs.html / conversation.html
의 nav·copy·empty-state 정비.

A. /new.html UX:
- 제목 "새 Run" → "워크플로우 실행 (고급)"
- 상단 info-box: "자유 대화는 여기가 아닙니다 → 메인 페이지"
- 모든 필드에 한 줄 hint
- Persona 오버라이드 <details> 접힘

B. Nav 재정렬 (5 페이지):
- "대화" nav-primary, 나머지 nav-secondary (작고 dim)

C. 메인 안내 + CSS:
- 메인 / 에 "👋 my-deepagent" info-box 추가
- .info-box / .nav-primary / .nav-secondary / .wf-* 신규 스타일

D. Workflow hot-reload:
- api/deps.py get_workflows 가 매 요청 mtime 튜플 검사 후 변경 시 reload
- lifespan 도 user dir 포함하도록 _load_workflows_combined

E. Workflow generator:
- POST /api/workflows: CreateWorkflowRequest → WorkflowTemplate validate →
  <data_dir>/workflows/<name>@<version>.yaml 저장.  중복 409, validation 422.
- static/new-workflow.html: 기본 정보 / Roles / Phases / YAML preview
- app.js bootstrapWorkflowGenerator: capability chip 토글, role select 동적,
  실시간 YAML preview, XSS 정책 유지

테스트 (test_workflow_generator.py, 7 신규):
- 페이지 200 + 마크업
- POST happy / 422 (empty roles) / 422 (unknown role) / 409 (dup)
- GET hot-reload after POST
- GET hot-reload after external file drop

게이트:
- ruff / format / mypy: PASS (142 source files)
- pytest -q --ignore=tests/integration/test_e2e_workflow.py
  --ignore=tests/integration/test_openrouter_smoke.py: 709 passed (+7 신규)
- 라이브 smoke: / / new.html / new-workflow.html 모두 200, screenshot OK

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 00:38:46 +09:00

203 lines
6.5 KiB
Python

"""v0.4 — Workflow generator UI + hot-reload tests.
Covers:
1. POST /api/workflows persists a YAML under <data_dir>/workflows/
2. POST rejects malformed body with 422
3. POST rejects duplicate (name, version) with 409
4. GET /api/workflows hot-reloads when a new file appears
5. GET /api/workflows hot-reloads when an existing file is edited
6. /new-workflow.html serves with the page marker
"""
from __future__ import annotations
from collections.abc import AsyncIterator
from pathlib import Path
import pytest
import yaml
from fastapi import FastAPI
from httpx import ASGITransport, AsyncClient
from my_deepagent.api.app import create_app
from my_deepagent.config import load_config
from my_deepagent.persistence.db import Database
@pytest.fixture
async def app_client(tmp_path: Path) -> AsyncIterator[tuple[AsyncClient, FastAPI, Path]]:
db_url = f"sqlite+aiosqlite:///{tmp_path / 'gen.sqlite3'}"
cfg = load_config(
workspace_root=tmp_path,
data_dir=tmp_path / "data",
database_url=db_url,
)
db = Database(db_url)
await db.init_schema()
await db.dispose()
app = create_app(cfg)
transport = ASGITransport(app=app)
async with app.router.lifespan_context(app):
async with AsyncClient(transport=transport, base_url="http://test", timeout=10.0) as client:
yield (client, app, cfg.data_dir)
def _valid_body(name: str = "my-flow", version: int = 1) -> dict[str, object]:
return {
"name": name,
"version": version,
"description": "test workflow generator",
"roles": [{"id": "writer", "required_capabilities": ["code_edit"]}],
"phases": [
{
"key": "p1",
"title": "first phase",
"risk": "medium",
"role": "writer",
"instructions": "do something useful in this phase",
}
],
}
# ---------------------------------------------------------------------------
# Static page
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_new_workflow_page_served(
app_client: tuple[AsyncClient, FastAPI, Path],
) -> None:
client, _app, _dir = app_client
r = await client.get("/new-workflow.html")
assert r.status_code == 200
assert 'data-page="new-workflow"' in r.text
assert "워크플로우 템플릿 만들기" in r.text
# ---------------------------------------------------------------------------
# POST /api/workflows happy path
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_post_workflow_creates_yaml_under_data_dir(
app_client: tuple[AsyncClient, FastAPI, Path],
) -> None:
client, _app, data_dir = app_client
r = await client.post("/api/workflows", json=_valid_body())
assert r.status_code == 201, r.text
body = r.json()
target = Path(body["path"])
assert target.is_file()
assert target.parent == data_dir / "workflows"
assert target.name == "my-flow@1.yaml"
parsed = yaml.safe_load(target.read_text(encoding="utf-8"))
assert parsed["name"] == "my-flow"
assert parsed["version"] == 1
assert parsed["phases"][0]["key"] == "p1"
# ---------------------------------------------------------------------------
# Validation rejection
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_post_workflow_rejects_missing_roles(
app_client: tuple[AsyncClient, FastAPI, Path],
) -> None:
client, _app, _dir = app_client
bad = _valid_body()
bad["roles"] = [] # min_length=1 violation
r = await client.post("/api/workflows", json=bad)
assert r.status_code == 422
@pytest.mark.asyncio
async def test_post_workflow_rejects_phase_referencing_unknown_role(
app_client: tuple[AsyncClient, FastAPI, Path],
) -> None:
client, _app, _dir = app_client
bad = _valid_body()
bad["phases"][0]["role"] = "ghost-role" # type: ignore[index]
r = await client.post("/api/workflows", json=bad)
assert r.status_code == 422
assert "ghost-role" in r.text
# ---------------------------------------------------------------------------
# Duplicate refusal
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_post_workflow_rejects_duplicate_name_version(
app_client: tuple[AsyncClient, FastAPI, Path],
) -> None:
client, _app, _dir = app_client
body = _valid_body("dup-flow", 1)
r1 = await client.post("/api/workflows", json=body)
assert r1.status_code == 201
r2 = await client.post("/api/workflows", json=body)
assert r2.status_code == 409
assert "already exists" in r2.text
# ---------------------------------------------------------------------------
# Hot-reload — new file appears in GET
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_get_workflows_hot_reloads_after_post(
app_client: tuple[AsyncClient, FastAPI, Path],
) -> None:
client, _app, _dir = app_client
before = await client.get("/api/workflows")
before_names = {w["name"] for w in before.json()}
assert "fresh-flow" not in before_names
r = await client.post("/api/workflows", json=_valid_body("fresh-flow", 1))
assert r.status_code == 201
after = await client.get("/api/workflows")
after_names = {w["name"] for w in after.json()}
assert "fresh-flow" in after_names
@pytest.mark.asyncio
async def test_get_workflows_hot_reloads_after_external_file_drop(
app_client: tuple[AsyncClient, FastAPI, Path],
) -> None:
"""Even when the file is dropped directly into the dir (not via POST),
the next GET picks it up via the mtime fingerprint."""
from textwrap import dedent
client, _app, data_dir = app_client
wf_dir = data_dir / "workflows"
wf_dir.mkdir(parents=True, exist_ok=True)
(wf_dir / "external@1.yaml").write_text(
dedent(
"""\
name: external
version: 1
description: dropped by hand
roles:
- id: writer
required_capabilities: [code_edit]
phases:
- key: p1
title: only phase
risk: low
role: writer
instructions: just write something to disk
"""
),
encoding="utf-8",
)
r = await client.get("/api/workflows")
names = {w["name"] for w in r.json()}
assert "external" in names