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:
2026-08-28 11:57:14 +09:00
co-authored by Claude Sonnet 5
51 changed files with 6708 additions and 3852 deletions
+1
View File
@@ -0,0 +1 @@
"""E2E test package for release verification."""
+161
View File
@@ -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"
+5 -5
View File
@@ -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",
]
+139
View File
@@ -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)
+15 -11
View File
@@ -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