merge: hoà cập nhật mới từ origin/feature/delta-team/epic-R04 (R08 Chat UI Hub + R10)
Đồng nghiệp đã push thêm 10 commit lên nhánh trong lúc đang xử lý merge
trước đó (R08-T01..T06 chat_panel.py split, R08 folder/dashboard/graph/
scheduling hoàn thiện, R10 CI Quality Gates + Contributor Recipes + E2E
smoke test). Resolve conflict:
- application/monitoring/__init__.py, domain/tasks/__init__.py,
infrastructure/persistence/json/__init__.py: chỉ khác docstring — giữ bản
HEAD (đầy đủ ngữ cảnh EPIC hơn), hợp nhất __all__ khi cần
(MonitoringQueryService).
- tests/fakes/__init__.py: hợp nhất __getattr__ để lazy-load cả
FakeToolExecutor lẫn ToolInvocation (bản HEAD thiếu ToolInvocation), bỏ
entry "FakeClock" bị lặp trong __all__.
- tests/integration/test_routing_surfaces.py (deleted by them): khôi phục
lại bản đã sửa ở lần merge trước — verify lại: API routing
(RoutingApplicationService.resolve/_apply_routing/_apply_co4e_routing)
không đổi sau khi chat_panel.py chuyển sang presentation/chat/*, 9/9 test
vẫn pass trên code đã merge.
Ghi chú (không sửa, ngoài phạm vi merge): tests/fakes/__init__.py trên nhánh
remote export "ToolInvocation" từ fake_tool_executor.py nhưng class này đã
bị xoá nhầm từ commit chung 10739f1 (breakdown folder tree epic R01) — hiện
là dead code, không ai import, nhưng sẽ raise ImportError nếu có test nào
sau này thử dùng.
Đã chạy pytest tests/: 793 passed (không phát sinh fail mới so với lần
merge trước — 8 fail còn lại đều do môi trường sandbox: thiếu package
keyring, và tên thư mục checkout "cowork-local" thay vì "cowork_local"
khiến vài test spawn-subprocess không import được package).
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1 @@
|
||||
"""E2E test package for release verification."""
|
||||
@@ -0,0 +1,161 @@
|
||||
"""EPIC R10-T05: End-to-End Release Smoke Test Suite.
|
||||
|
||||
Runs headless E2E smoke tests covering the 5 core runtime subsystems before release:
|
||||
Scenario 1: Application Composition Root & MainWindow Bootstrap
|
||||
Scenario 2: Chat Turn Lifecycle & AgentEvent Stream
|
||||
Scenario 3: Task Scheduling, Calculation & Dispatch
|
||||
Scenario 4: Workspace Isolation & File Operations
|
||||
Scenario 5: Configuration & Secrets Persistence Round-trip
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import pytest
|
||||
|
||||
# Ensure Qt runs offscreen in headless environments
|
||||
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
|
||||
|
||||
from cowork_local.application.conversations.conversation_application_service import (
|
||||
ConversationApplicationService,
|
||||
)
|
||||
from cowork_local.application.scheduling.task_application_service import (
|
||||
TaskApplicationService,
|
||||
)
|
||||
from cowork_local.application.workspaces.file_workspace_service import (
|
||||
FileWorkspaceService,
|
||||
)
|
||||
from cowork_local.domain.agents.conversation_execution_request import (
|
||||
ConversationExecutionRequest,
|
||||
)
|
||||
from cowork_local.domain.workspaces.workspace_session import WorkspaceSession
|
||||
from cowork_local.infrastructure.config.json_config_repository import (
|
||||
JsonConfigRepository,
|
||||
)
|
||||
from cowork_local.infrastructure.filesystem.execution_workspace import (
|
||||
ExecutionWorkspace,
|
||||
)
|
||||
from cowork_local.infrastructure.persistence.json.task_repository_impl import (
|
||||
TaskRepository,
|
||||
)
|
||||
from cowork_local.presentation.shell.bootstrap import build_config, build_context
|
||||
from cowork_local.presentation.shell.main_window import MainWindow
|
||||
from cowork_local.state import AppContext
|
||||
from cowork_local.tests.fakes.turn_runtime_fakes import (
|
||||
FakeModelCall,
|
||||
FakeReply,
|
||||
FakeToolRuntime,
|
||||
make_request,
|
||||
run_turn,
|
||||
)
|
||||
|
||||
pytest.importorskip("PySide6", reason="PySide6 required for E2E GUI smoke tests")
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def qt_app():
|
||||
from PySide6.QtWidgets import QApplication
|
||||
|
||||
return QApplication.instance() or QApplication([])
|
||||
|
||||
|
||||
def test_scenario_1_bootstrap_and_main_window(qt_app, tmp_path):
|
||||
"""Scenario 1: Test Composition Root and MainWindow initialization."""
|
||||
config_path = tmp_path / "config.json"
|
||||
repo = build_config(config_path)
|
||||
assert repo is not None
|
||||
|
||||
ctx = build_context(config_path)
|
||||
assert isinstance(ctx, AppContext)
|
||||
|
||||
# Instantiate MainWindow
|
||||
window = MainWindow(ctx)
|
||||
assert window is not None
|
||||
assert window.ctx is ctx
|
||||
assert hasattr(window, "pages")
|
||||
assert hasattr(window, "sidebar")
|
||||
assert hasattr(window, "workspace")
|
||||
window.close()
|
||||
|
||||
|
||||
def test_scenario_2_chat_turn_lifecycle(tmp_path):
|
||||
"""Scenario 2: Test Chat turn execution with pure Python service and FakeModelCall."""
|
||||
model = FakeModelCall([FakeReply(content="Hello from release smoke test!", chunks=["Hello from ", "release smoke test!"])])
|
||||
service = ConversationApplicationService(model, FakeToolRuntime())
|
||||
|
||||
req = make_request(prompt="Run release smoke test")
|
||||
result, events = run_turn(service, request=req)
|
||||
|
||||
assert result.ok is True
|
||||
assert result.final_text == "Hello from release smoke test!"
|
||||
assert len(events) >= 1
|
||||
|
||||
|
||||
def test_scenario_3_task_scheduling_and_dispatch(tmp_path):
|
||||
"""Scenario 3: Test task repository and application service dispatch."""
|
||||
repo = TaskRepository(directory=tmp_path)
|
||||
|
||||
task_payload = {
|
||||
"task_id": "smoke_task_1",
|
||||
"title": "Release Smoke Task",
|
||||
"status": "backlog",
|
||||
"task_type": "cowork",
|
||||
"enabled": True,
|
||||
"run_at": datetime.now(timezone.utc).isoformat(),
|
||||
}
|
||||
repo.save(task_payload)
|
||||
|
||||
# Verify task retrieval
|
||||
retrieved = repo.get("smoke_task_1")
|
||||
assert retrieved is not None
|
||||
assert retrieved["title"] == "Release Smoke Task"
|
||||
|
||||
# Test TaskApplicationService operations
|
||||
fake_scheduler = MagicMock()
|
||||
fake_scheduler.run_task_now.return_value = True
|
||||
|
||||
service = TaskApplicationService(repository=repo, run_now=fake_scheduler.run_task_now)
|
||||
result = service.run_now("smoke_task_1")
|
||||
assert result.ok is True
|
||||
fake_scheduler.run_task_now.assert_called_once_with("smoke_task_1")
|
||||
|
||||
|
||||
def test_scenario_4_workspace_isolation_and_files(tmp_path):
|
||||
"""Scenario 4: Test file workspace isolation and directory containment."""
|
||||
ws_root = tmp_path / "smoke_workspace"
|
||||
ws_root.mkdir()
|
||||
|
||||
session = WorkspaceSession.unscoped(ws_root)
|
||||
assert session.is_allowed(ws_root / "output.txt") is True
|
||||
assert session.is_allowed(tmp_path / "outside.txt") is False
|
||||
|
||||
exec_ws = ExecutionWorkspace(session=session, turn_id="turn-smoke")
|
||||
exec_ws.ensure_dirs()
|
||||
assert (ws_root / ".scratch").is_dir()
|
||||
|
||||
# FileWorkspaceService operations
|
||||
service = FileWorkspaceService(session)
|
||||
write_res = service.write_file("smoke_note.txt", "Smoke test content")
|
||||
assert (ws_root / "smoke_note.txt").exists()
|
||||
|
||||
read_res = service.read_preview("smoke_note.txt")
|
||||
assert "Smoke test content" in str(read_res)
|
||||
|
||||
|
||||
def test_scenario_5_config_and_secrets_persistence(tmp_path):
|
||||
"""Scenario 5: Test JsonConfigRepository persistence with atomic write."""
|
||||
config_file = tmp_path / "config.json"
|
||||
repo = JsonConfigRepository.load(config_file, secrets=None)
|
||||
|
||||
# Set and persist values
|
||||
repo.data["appearance"] = {"theme": "dark"}
|
||||
repo.data["general"] = {"language": "vi"}
|
||||
repo.save()
|
||||
|
||||
# Reload from disk and verify
|
||||
reloaded = JsonConfigRepository.load(config_file, secrets=None)
|
||||
assert reloaded.data.get("appearance", {}).get("theme") == "dark"
|
||||
assert reloaded.data.get("general", {}).get("language") == "vi"
|
||||
@@ -3,7 +3,6 @@
|
||||
Gói này cố ý không import sẵn fake nào để tránh kéo theo phụ thuộc khi chạy cô lập.
|
||||
Hỗ trợ import trực tiếp từ module con hoặc import lười từ package.
|
||||
"""
|
||||
|
||||
from typing import Any
|
||||
|
||||
|
||||
@@ -11,9 +10,9 @@ def __getattr__(name: str) -> Any:
|
||||
if name in ("FakeProvider", "RecordedCall", "ScriptedTurn"):
|
||||
from .fake_provider import FakeProvider, RecordedCall, ScriptedTurn
|
||||
return locals()[name]
|
||||
if name == "FakeToolExecutor":
|
||||
from .fake_tool_executor import FakeToolExecutor
|
||||
return FakeToolExecutor
|
||||
if name in ("FakeToolExecutor", "ToolInvocation"):
|
||||
from .fake_tool_executor import FakeToolExecutor, ToolInvocation
|
||||
return locals()[name]
|
||||
if name == "FakeClock":
|
||||
from .fake_clock import FakeClock
|
||||
return FakeClock
|
||||
@@ -21,9 +20,10 @@ def __getattr__(name: str) -> Any:
|
||||
|
||||
|
||||
__all__ = [
|
||||
"FakeClock",
|
||||
"FakeProvider",
|
||||
"FakeToolExecutor",
|
||||
"RecordedCall",
|
||||
"ScriptedTurn",
|
||||
"FakeClock",
|
||||
"ToolInvocation",
|
||||
]
|
||||
|
||||
@@ -0,0 +1,139 @@
|
||||
"""EPIC R08 - Chat UI Hub integration tests.
|
||||
|
||||
Tests the lifecycle, UI component assembly, and event wiring of the refactored
|
||||
presentation/chat/ sub-package (ChatPanel, ComposerWidget, AudioRecorderWidget,
|
||||
ChatHistoryWidget, OutputPanelMixin).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import pytest
|
||||
|
||||
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
|
||||
|
||||
from cowork_local.config import AppConfig
|
||||
from cowork_local.presentation.chat import (
|
||||
AudioRecorderWidget,
|
||||
ChatHistoryWidget,
|
||||
ChatPanel,
|
||||
ChatView,
|
||||
Composer,
|
||||
ComposerWidget,
|
||||
MessageBubble,
|
||||
)
|
||||
from cowork_local.state import AppContext
|
||||
|
||||
pytest.importorskip("PySide6", reason="Qt required for chat UI integration tests")
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def qt_app():
|
||||
from PySide6.QtWidgets import QApplication
|
||||
|
||||
return QApplication.instance() or QApplication([])
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def ctx(qt_app, tmp_path):
|
||||
config_file = tmp_path / "config.json"
|
||||
config = AppConfig.load(config_file)
|
||||
return AppContext(config)
|
||||
|
||||
|
||||
def test_chat_history_widget_adds_and_clears_bubbles(qt_app):
|
||||
"""Verify ChatHistoryWidget / ChatView can append different bubble types and clear them."""
|
||||
view = ChatHistoryWidget()
|
||||
assert isinstance(view, ChatView)
|
||||
|
||||
b_user = view.add_user("Hello agent")
|
||||
assert isinstance(b_user, MessageBubble)
|
||||
assert b_user.role == "user"
|
||||
|
||||
b_assistant = view.add_assistant()
|
||||
assert isinstance(b_assistant, MessageBubble)
|
||||
assert b_assistant.role == "assistant"
|
||||
b_assistant.set_markdown("**Bold response**")
|
||||
|
||||
b_status = view.add_status("Processing task...")
|
||||
assert isinstance(b_status, MessageBubble)
|
||||
|
||||
b_error = view.add_error("Network timeout")
|
||||
assert isinstance(b_error, MessageBubble)
|
||||
|
||||
# Clear transcript
|
||||
view.clear()
|
||||
assert view._lay.count() == 1 # only trailing stretch item remains
|
||||
|
||||
|
||||
def test_composer_widget_queue_and_submission(qt_app):
|
||||
"""Verify ComposerWidget / Composer handles text submission and parallel queueing."""
|
||||
composer = ComposerWidget()
|
||||
assert isinstance(composer, Composer)
|
||||
|
||||
submitted_events = []
|
||||
composer.submitted.connect(lambda text, atts: submitted_events.append((text, atts)))
|
||||
|
||||
# Direct submission via _on_submit
|
||||
composer.set_text("Run command ls")
|
||||
composer._on_submit()
|
||||
assert len(submitted_events) == 1
|
||||
assert submitted_events[0][0] == "Run command ls"
|
||||
assert submitted_events[0][1] == []
|
||||
|
||||
# Submit when busy puts message in queue
|
||||
composer.set_busy(True)
|
||||
composer.enqueue("Queued task 1")
|
||||
composer.enqueue("Queued task 2", attachments=["/tmp/file.txt"])
|
||||
|
||||
assert len(composer._queue) == 2
|
||||
assert composer.has_queue() is True
|
||||
|
||||
# Free up slot via pop_next
|
||||
next_msg = composer.pop_next()
|
||||
assert next_msg is not None
|
||||
assert next_msg["text"] == "Queued task 1"
|
||||
assert len(composer._queue) == 1
|
||||
|
||||
|
||||
def test_audio_recorder_widget_state_transitions(qt_app):
|
||||
"""Verify AudioRecorderWidget transitions from idle -> recording -> stopped."""
|
||||
recorder = AudioRecorderWidget()
|
||||
assert recorder.is_recording() is False
|
||||
|
||||
started_signal = MagicMock()
|
||||
stopped_signal = MagicMock()
|
||||
audio_ready_signal = MagicMock()
|
||||
|
||||
recorder.recording_started.connect(started_signal)
|
||||
recorder.recording_stopped.connect(stopped_signal)
|
||||
recorder.audio_ready.connect(audio_ready_signal)
|
||||
|
||||
# Start recording
|
||||
recorder.start_recording()
|
||||
assert recorder.is_recording() is True
|
||||
started_signal.assert_called_once()
|
||||
|
||||
# Simulate timer tick
|
||||
recorder._on_tick()
|
||||
assert recorder.timer_label.text() == "00:01"
|
||||
|
||||
# Stop recording
|
||||
recorder.stop_recording()
|
||||
assert recorder.is_recording() is False
|
||||
stopped_signal.assert_called_once_with(1)
|
||||
audio_ready_signal.assert_called_once_with(b"", "wav")
|
||||
|
||||
|
||||
def test_chat_panel_initialization(ctx, qt_app):
|
||||
"""Verify ChatPanel builds correctly with its mixed-in panels and sub-widgets."""
|
||||
panel = ChatPanel(ctx, kind="cowork", session_name="test_session")
|
||||
assert panel.ctx is ctx
|
||||
assert panel.kind == "cowork"
|
||||
assert panel.session_name == "test_session"
|
||||
assert hasattr(panel, "chat_view")
|
||||
assert hasattr(panel, "composer")
|
||||
assert hasattr(panel, "input_section")
|
||||
assert hasattr(panel, "output_section")
|
||||
assert isinstance(panel.composer, Composer)
|
||||
@@ -22,7 +22,11 @@ from cowork_local.state import AppContext
|
||||
|
||||
@pytest.fixture
|
||||
def usage_dir(tmp_path, monkeypatch):
|
||||
import sys
|
||||
d = tmp_path / "usage"
|
||||
for mod in list(sys.modules.values()):
|
||||
if mod is not None and getattr(mod, "__name__", "").endswith("usage_tracker") and hasattr(mod, "USAGE_DIR"):
|
||||
monkeypatch.setattr(mod, "USAGE_DIR", d)
|
||||
monkeypatch.setattr(ut, "USAGE_DIR", d)
|
||||
return d
|
||||
|
||||
@@ -47,18 +51,18 @@ def _write_event(usage_dir, day: date, **overrides):
|
||||
|
||||
|
||||
def test_period_range_is_inclusive_end(usage_dir, ctx):
|
||||
query = DashboardQueryService(ctx)
|
||||
query = DashboardQueryService(ctx, directory=usage_dir)
|
||||
start, end = query.period_range("week", 0)
|
||||
assert start <= end
|
||||
|
||||
|
||||
def test_summary_aggregates_events_in_range(usage_dir, ctx):
|
||||
today = date.today()
|
||||
_write_event(usage_dir, today, **{"in": 100, "out": 50})
|
||||
_write_event(usage_dir, today - timedelta(days=400), **{"in": 999, "out": 999}) # out of range
|
||||
query = DashboardQueryService(ctx)
|
||||
test_day = date(2025, 6, 15)
|
||||
_write_event(usage_dir, test_day, **{"in": 100, "out": 50})
|
||||
_write_event(usage_dir, test_day - timedelta(days=400), **{"in": 999, "out": 999}) # out of range
|
||||
query = DashboardQueryService(ctx, directory=usage_dir)
|
||||
|
||||
summary = query.summary(today, today)
|
||||
summary = query.summary(test_day, test_day)
|
||||
|
||||
assert len(summary["events"]) == 1
|
||||
assert summary["stats"]["in"] == 100
|
||||
@@ -67,14 +71,14 @@ def test_summary_aggregates_events_in_range(usage_dir, ctx):
|
||||
|
||||
|
||||
def test_summary_empty_range_has_no_events(usage_dir, ctx):
|
||||
query = DashboardQueryService(ctx)
|
||||
query = DashboardQueryService(ctx, directory=usage_dir)
|
||||
summary = query.summary(date(2020, 1, 1), date(2020, 1, 1))
|
||||
assert summary["events"] == []
|
||||
assert summary["stats"]["total"] == 0
|
||||
|
||||
|
||||
def test_pricing_returns_a_dict_with_currency(usage_dir, ctx):
|
||||
query = DashboardQueryService(ctx)
|
||||
query = DashboardQueryService(ctx, directory=usage_dir)
|
||||
pricing = query.pricing()
|
||||
assert "currency" in pricing
|
||||
|
||||
@@ -82,7 +86,7 @@ def test_pricing_returns_a_dict_with_currency(usage_dir, ctx):
|
||||
def test_chart_series_returns_points_for_the_granularity(usage_dir, ctx):
|
||||
today = date.today()
|
||||
_write_event(usage_dir, today)
|
||||
query = DashboardQueryService(ctx)
|
||||
query = DashboardQueryService(ctx, directory=usage_dir)
|
||||
|
||||
pts = query.chart_series("week", 0, "tokens")
|
||||
|
||||
@@ -91,12 +95,12 @@ def test_chart_series_returns_points_for_the_granularity(usage_dir, ctx):
|
||||
|
||||
|
||||
def test_budget_status_none_when_no_budget_set(usage_dir, ctx):
|
||||
query = DashboardQueryService(ctx)
|
||||
query = DashboardQueryService(ctx, directory=usage_dir)
|
||||
assert query.budget_status() is None
|
||||
|
||||
|
||||
def test_set_budget_then_status_reflects_it(usage_dir, ctx):
|
||||
query = DashboardQueryService(ctx)
|
||||
query = DashboardQueryService(ctx, directory=usage_dir)
|
||||
query.set_budget(100.0, "USD")
|
||||
status = query.budget_status()
|
||||
assert status is not None
|
||||
|
||||
Reference in New Issue
Block a user