Feature/perf ui logic (#13)
CI / test (push) Canceled after 0s

## Summary

Nhánh `feature/perf-ui-logic`: tối ưu hiệu năng/UI, sửa lỗi workspace và điều hướng, và làm cho công tắc **"Block network for agent-run commands"** chặn thật mọi đường ra mạng của app, **trừ nhà cung cấp AI**.

**Chặn mạng (b78d483, 8c497cf, 10b8379)**
- Bộ kiểm tra chung `application/network/network_guard.py`, nối vào cấu hình đang chạy ở Composition Root: đổi công tắc trong Settings là có hiệu lực ngay.
- Lệnh shell của agent và task script chạy trong **Windows AppContainer không có quyền mạng**: kernel chặn socket, ping, DNS, Invoke-WebRequest… Không cần quyền admin. Không cô lập được thì lệnh bị từ chối, không chạy khi mạng còn mở. macOS dùng `sandbox-exec`, Linux dùng `unshare --net`.
- Bật chặn thì: dừng MCP đang chạy, không khởi động server mới, từ chối lời gọi connector; Microsoft 365 (đăng nhập, Graph, đồng bộ cloud, rules, mail), Teams, nút Test REST/Jira/MCP, link đính kèm task, pip tự cài và tài nguyên web trong xem trước HTML đều bị từ chối.
- Vẫn dùng được: chat, tải danh sách model, thử model; tool OneDrive đã đồng bộ trên máy.
- Công tắc **mặc định tắt** khi mở app lần đầu; nhãn giữ nguyên như cũ.
- Xem trước HTML trong tab Folder giờ hiện được ảnh/CSS/JS từ web khi mạng mở (trước đây trang `file://` không tải được).
- Sửa lỗi app văng khi chuyển tab Graph → Folder: profile WebEngine của trang xem trước bị huỷ trước trang (`0xc0000409` trong Qt6Core.dll); giờ dùng một profile chung thuộc QApplication.
- Không cấp quyền AppContainer kế thừa lên thư mục chứa PySide6 (nếu có, Chromium không nạp được `Qt6WebEngineCore.dll` và tab Graph trắng).
- Cột mục lục trong Settings tính độ rộng theo kiểu chữ của mục đang chọn, "Sandbox Security Layer" không còn bị cắt.

**Các commit khác trong nhánh**
- `b7a41b3` mỗi thư mục làm việc chỉ thuộc về một project · `bbdf146` bật nút Sửa project khi đã có project đang mở
- `35f24e0`, `cc8d5c8`, `2e3e719`, `c699beb` canh hàng / khoảng cách thanh điều hướng
- `2759ed9` không refresh workspace khi chuyển tab Cowork · `7607f44` checkpoint hiệu năng và UI
- `8548c1e` chặn tool mạng của agent · `caf3b74` renderer GraphRAG native trên macOS · `c00b83c` khoảng cách metadata hàng project · `a04f8a9` ẩn picker workspace cloud

## Change Type

- [x] Cowork feature
- [x] Bug fix
- [ ] Core AI contribution
- [x] Test / hardening
- [x] Performance
- [ ] Documentation

## Related Work

Cowork Task:

Core Repo: http://34.143.229.138/gitea-admin/fsg-ai-core-assets

Core AI Issue:

Core Task:

Related PR:

## Scope

What is intentionally included?
- Mọi đường ra mạng do app tự mở, trừ nhà cung cấp AI (xem Summary).
- Test: `tests/test_network_guard_lanes.py` (có bài chạy AppContainer thật trên Windows), `tests/ui/test_html_preview_remote_images.py`.

What is intentionally NOT included?
- Chặn cả nhà cung cấp AI / chạy model trên máy (Phương án 2).
- Terminal người dùng tự gõ trong tab Folder, sinh ảnh, cơ chế tự tin chứng chỉ lạ (`tls_trust`).
- Huy hiệu trạng thái "đang chặn" trên thanh trên cùng.

## Validation

- [x] Unit tests
- [x] Integration tests
- [x] Manual verification
- [x] Regression check

Commands / evidence:
- `python -m pytest tests/test_network_guard_lanes.py tests/test_sandbox_block_network.py tests/ui -q` → chỉ còn 1 bài fail, fail cả trên `b7a41b3` (nhãn `ProjectRow` 'Project' chưa dịch, `tests/ui/test_i18n_khong_con_chu_cu.py`).
- `python -m pytest tests -q --ignore=tests/ui` → 4 bài fail, cả 4 cũng fail trên `b7a41b3` (`test_canonical_audit_logger`, 2 bài `test_mcp_audit_security`, `test_monitoring_tab_container`).
- Chạy cả `tests` trong một lượt thì treo ở các test dựng MainWindow trong `tests/ui`; `b7a41b3` cũng treo đúng chỗ đó.
- `check_imports.py` và `check_orphan_modules.py` PASS. `check_loc.py` báo 9 file quá dài, giống hệt trước khi sửa (không file nào do nhánh này làm dài thêm).
- Kiểm tra tay trên Windows 11: trong AppContainer, Python báo `WinError 10013`, ping/nslookup/PowerShell/curl đều không ra được mạng; cmd, git, python chạy bình thường.
- Kiểm tra tay trên Windows 11: xem trước HTML tải được 4/4 tài nguyên web khi mạng mở, 0/4 khi bật chặn; tab Graph hoạt động; tạo/huỷ trang xem trước nhiều lần không còn cảnh báo profile của Qt.

## Security Impact

Permission / credential / network / customer data impact:
- Network: khi bật công tắc, chỉ nhà cung cấp AI còn ra mạng; nội dung chat vẫn gửi tới nhà cung cấp AI.
- Permission: lần đầu chạy lệnh trong sandbox, app **thêm quyền (ACE) cho SID AppContainer** trên thư mục làm việc (ghi), thư mục cài Python gốc (đọc), gốc venv và `Scripts` (đọc). Không xoá quyền nào. Thư mục chứa PySide6 không bao giờ nhận quyền kế thừa; một quyền kế thừa sai trên venv (từ bản dev trước) được tự gỡ.
- Credential: không đổi. Khi chặn, trạng thái đăng nhập M365 được đọc thẳng từ kho token trên máy, không dựng MSAL.

## Compatibility

- [x] No breaking change
- [ ] Breaking change documented

Ghi chú: `block_network` mặc định đổi từ bật sang tắt cho cấu hình mới; máy đã lưu `true` thì giữ nguyên. Khi đang chặn, lệnh dùng công cụ cài trong thư mục người dùng (ngoài Program Files) có thể báo Access denied; thư viện trong venv của app không dùng được trong sandbox.

## Reviewer Notes

- `infrastructure/sandbox/appcontainer_process.py` gọi Win32 bằng ctypes (CreateAppContainerProfile, CreateProcessW với SECURITY_CAPABILITIES) và dùng `icacls` để cấp quyền: nên xem kỹ phần cấp quyền.
- `tests/conftest.py` thêm fixture autouse gỡ `network_guard` sau mỗi test, vì `build_context()` gắn cổng này ở mức process.
- `core/task_executors.py` đang đúng bằng trần LOC nên `_run_script` được tách sang `core/task_script.py`.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: minhanhpkpro <minhanhpkpro@gmail.com>
Co-authored-by: Duy Le Huu <duylh19@fpt.com>
Co-authored-by: thanhnv <thanhnv.ip@gmail.com>
Reviewed-on: #13
This commit was merged in pull request #13.
This commit is contained in:
2026-09-20 12:26:03 +00:00
co-authored by minhanhpkpro duylh19 thanhnv
parent cbae2604db
commit a03a740ea1
57 changed files with 2326 additions and 122 deletions
+14
View File
@@ -73,3 +73,17 @@ def _bind_checkout_as_package() -> None:
_bind_checkout_as_package()
import pytest # noqa: E402 - after the package binding above
@pytest.fixture(autouse=True)
def _reset_network_guard():
"""``build_context()`` binds the process-wide "Block network" gate to that
context's config (default: blocked). Unbind after every test so one test
that built a real context cannot silently block the network for the rest."""
yield
from cowork_local.application.network import network_guard
network_guard.bind(None)
+389
View File
@@ -0,0 +1,389 @@
"""Công tắc "Chặn mạng" phải chặn mọi đường ra mạng của app, TRỪ nhà cung cấp AI.
Bản đồ ``infura/network-map.html`` liệt kê các làn trước đây "không kiểm soát"
hoặc chỉ chặn một phần: M365, Teams và nút Test, connector/MCP đang chạy,
task script và link đính kèm task, tự cài thư viện, xem trước HTML, lệnh shell
chỉ bị proxy giả. Mỗi làn có ít nhất một bài ở đây:
* **bật** — làn từ chối TRƯỚC khi chạm mạng (mọi lời gọi HTTP thật đều nổ);
* **tắt** — đường cũ giữ nguyên, vì chặn một chiều là hỏng tính năng.
"""
from __future__ import annotations
import socket
import sys
import threading
from pathlib import Path
import pytest
from cowork_local.application.network import network_guard
def _no_network(*args, **kwargs):
raise AssertionError("đã chạm mạng dù 'Chặn mạng' đang bật")
@pytest.fixture
def chan_mang(monkeypatch):
"""Bật công tắc qua cổng chung và làm nổ mọi lời gọi HTTP thật."""
import requests
from cowork_local.core import tls_trust
for name in ("request", "get", "post", "put", "patch", "delete"):
monkeypatch.setattr(requests, name, _no_network)
monkeypatch.setattr(tls_trust, "request", _no_network)
monkeypatch.setattr(tls_trust, "request_any_method", _no_network)
network_guard.bind(lambda: True)
yield
network_guard.bind(None)
@pytest.fixture(autouse=True)
def _go_cong_sau_moi_bai():
yield
network_guard.bind(None)
# ---- cổng chung ------------------------------------------------------------
def test_chua_noi_day_thi_khong_chan():
network_guard.bind(None)
assert network_guard.is_blocked() is False
network_guard.ensure_allowed("x")
def test_doc_cong_tac_loi_thi_coi_nhu_dang_chan():
"""Không đọc được công tắc thì đóng cửa, không mở toang."""
def hong():
raise KeyError("agent_security")
network_guard.bind(hong)
with pytest.raises(network_guard.NetworkBlockedError):
network_guard.ensure_allowed("Teams")
def test_cong_doc_cau_hinh_song_khong_chup_lai(tmp_path):
"""Đổi công tắc trong Settings có hiệu lực ngay, không cần mở lại app."""
from cowork_local.presentation.shell.bootstrap import build_context
ctx = build_context(tmp_path / "config.json")
ctx.config.agent_security["block_network"] = True
assert network_guard.is_blocked() is True
ctx.config.agent_security["block_network"] = False
assert network_guard.is_blocked() is False
# ---- Microsoft 365 -----------------------------------------------------------
def test_graph_tu_choi_bang_loi_ma_noi_goi_da_bat(chan_mang):
from cowork_local.core import ms365_graph
with pytest.raises(ms365_graph.Ms365GraphError, match="Sandbox Security Layer"):
ms365_graph.list_onedrive_files("token")
def test_dang_nhap_va_lay_token_tu_choi(chan_mang, monkeypatch):
from cowork_local.core import ms365_auth
monkeypatch.setattr(ms365_auth, "_app", _no_network)
with pytest.raises(ms365_auth.Ms365AuthError, match="blocked"):
ms365_auth.get_access_token("", "")
with pytest.raises(ms365_auth.Ms365AuthError, match="blocked"):
ms365_auth.sign_in_device_code("", "", lambda flow: None)
def test_kiem_tra_da_dang_nhap_khong_dung_msal_khi_chan(chan_mang, monkeypatch):
"""Dựng app MSAL là tải cấu hình OpenID của tenant — phải đọc kho token trực tiếp."""
from cowork_local.core import ms365_auth
monkeypatch.setattr(ms365_auth, "_app", _no_network)
monkeypatch.setattr(ms365_auth, "_cached_account_offline",
lambda: {"username": "a@b.c"})
assert ms365_auth.signed_in_account("", "") == {"username": "a@b.c"}
def test_mail_canh_bao_khong_gui_khi_chan(chan_mang, monkeypatch):
from types import SimpleNamespace
from cowork_local.core import agent_security_alert, ms365_auth
from cowork_local.core.agent_security_types import SecurityVerdict
monkeypatch.setattr(ms365_auth, "_app", _no_network)
config = SimpleNamespace(data={"agent_security": {"admin_email": "admin@x.y"}}, ms365={})
verdict = SecurityVerdict(allowed=False, layer="command", reason="test")
sent, note = agent_security_alert.notify_admin(config, verdict)
assert sent is False
assert "blocked" in note
# ---- Teams, connector REST, Jira, nút Test ---------------------------------
def test_teams_tu_choi(chan_mang):
from cowork_local.core.teams import TeamsNotifier
ok, detail = TeamsNotifier("https://x.webhook.office.com/hook").send("t", "b")
assert ok is False
assert "blocked" in detail
def test_connector_rest_va_nut_test_tu_choi(chan_mang):
from cowork_local.core.ext_connectors import RestApiConnector
rc = RestApiConnector({"id": "erp", "name": "ERP", "base_url": "https://erp.example"})
assert rc.call({"method": "GET", "path": "items"})["ok"] is False
ok, message = rc.test_connection()
assert ok is False and "blocked" in message
def test_nut_test_jira_tu_choi(chan_mang):
from cowork_local.core import jira_tool
cfg = {"base_url": "https://jira.example", "email": "a@b.c", "api_token": "t"}
out = jira_tool.search(cfg, "order by created DESC", 1)
assert out.startswith("Jira search failed") and "blocked" in out
# ---- MCP -------------------------------------------------------------------
def test_mcp_khong_khoi_dong_va_khong_goi_khi_chan(chan_mang):
from cowork_local.core.mcp_client import McpServerConnection, McpServerError
conn = McpServerConnection("srv", "definitely-not-run")
with pytest.raises(McpServerError, match="blocked"):
conn.start(timeout=1)
assert conn.call_tool("srv__search", {})["ok"] is False
def _context(tmp_path, block: bool):
from cowork_local.config import AppConfig
from cowork_local.state import AppContext
ctx = AppContext(AppConfig.load(tmp_path / "config.json"))
ctx.config.agent_security["block_network"] = block
return ctx
def test_chuan_bi_connector_dung_mcp_dang_chay_va_giu_onedrive_cuc_bo(tmp_path, monkeypatch):
from cowork_local.core import ms365_local
ctx = _context(tmp_path, block=True)
stopped = []
monkeypatch.setattr(ctx, "stop_mcp_connections", lambda: stopped.append(True))
local = (["ms365_local__onedrive_list"], object())
monkeypatch.setattr(ms365_local, "build_ms365_local_tools", lambda config: local)
monkeypatch.setattr(ctx._mcp_manager, "ensure", _no_network)
assert ctx.build_mcp_tools() == local
assert stopped == [True]
# ---- task lập lịch -----------------------------------------------------------
def test_link_dinh_kem_task_khong_tai(chan_mang):
from cowork_local.core.tasks import resolve_input_text
text = resolve_input_text({"input": {"mode": "empty", "links": ["https://example.com/a"]}})
assert "not fetched" in text
def test_task_script_chay_trong_tien_trinh_khong_mang(chan_mang, monkeypatch, tmp_path):
from cowork_local.core import deps, task_script
calls = []
def fake_run(command, **kwargs):
calls.append(kwargs)
return 0, "done", False, False, False
monkeypatch.setattr(deps, "run_cancellable", fake_run)
assert task_script.run_script("echo hi", tmp_path, 30) == "done"
assert calls and calls[0]["isolate_network"] is True
def test_task_script_khong_co_lap_duoc_thi_khong_chay(chan_mang, monkeypatch, tmp_path):
from cowork_local.core import deps, task_script
monkeypatch.setattr(deps, "run_cancellable",
lambda command, **kw: (None, "no AppContainer", False, False, False))
with pytest.raises(RuntimeError, match="could not be isolated"):
task_script.run_script("echo hi", tmp_path, 30)
def test_tat_chan_mang_thi_task_script_chay_nhu_cu(tmp_path):
from cowork_local.core import task_script
assert "hi" in task_script.run_script("echo hi", tmp_path, 30)
# ---- tự cài thư viện ---------------------------------------------------------
def test_khong_tu_cai_thu_vien_khi_chan(chan_mang, monkeypatch):
from cowork_local.core import deps
monkeypatch.setattr(deps, "run_cancellable", _no_network)
assert deps.ensure_module("khong_ton_tai_xyz_123") is None
assert "khong_ton_tai_xyz_123" not in deps._FAILED # thử lại khi mở mạng
ok, detail = deps.pip_install("requests")
assert ok is False and "blocked" in detail
def test_pptx_khong_bi_nho_la_thieu_khi_chi_do_chan_mang(chan_mang, monkeypatch):
from cowork_local.application.workspaces import file_preview_helpers as fph
from cowork_local.core import deps
monkeypatch.setattr(fph, "_PPTX_READY", None)
monkeypatch.setattr(deps, "ensure_module", lambda *a, **k: None)
assert fph.pptx_available() is False
assert fph._PPTX_READY is None
# ---- lệnh shell của agent ----------------------------------------------------
@pytest.mark.parametrize("backend", ["direct", "integrity_job_wfp", "appcontainer", "windows_sandbox"])
def test_moi_backend_deu_chay_co_lap_mang(monkeypatch, tmp_path, backend):
"""Các backend cũ chỉ đặt biến proxy — khi chặn mạng, tất cả đi qua lớp cô lập của OS."""
from cowork_local.core import deps
from cowork_local.core.sandbox_manager import SandboxManager
calls = []
def fake_run(command, **kwargs):
calls.append(kwargs)
return 0, "ok", False, False, False
monkeypatch.setattr(deps, "run_cancellable", fake_run)
result = SandboxManager()._execute_with_backend(backend, "echo hi", str(tmp_path), True, 30)
assert calls and calls[0]["isolate_network"] is True
assert result["ok"] is True and result["sandbox"] == "network_isolated"
def test_lenh_khong_co_lap_duoc_thi_bi_tu_choi(monkeypatch, tmp_path):
from cowork_local.core import deps
from cowork_local.core.sandbox_manager import SandboxManager
monkeypatch.setattr(deps, "run_cancellable",
lambda command, **kw: (None, "no isolation", False, False, False))
result = SandboxManager()._execute_with_backend("direct", "echo hi", str(tmp_path), True, 30)
assert result["ok"] is False and result["sandbox"] == "blocked"
def test_he_dieu_hanh_khong_ho_tro_thi_bao_loi_khong_chay(monkeypatch):
from cowork_local.infrastructure.sandbox import network_isolation
monkeypatch.setattr(network_isolation.sys, "platform", "sunos5")
with pytest.raises(network_isolation.NetworkIsolationUnavailable):
network_isolation.spawn_without_network("echo hi", None, None)
def _listening_socket():
server = socket.socket()
server.bind(("127.0.0.1", 0))
server.listen(4)
def serve():
try:
for _ in range(4):
server.accept()
except OSError:
pass # socket closed at the end of the test
threading.Thread(target=serve, daemon=True).start()
return server
@pytest.mark.skipif(sys.platform != "win32", reason="AppContainer chỉ có trên Windows")
def test_appcontainer_that_su_cat_mang_nhung_van_ghi_duoc_thu_muc(tmp_path):
"""Bài thật, không giả lập: cùng một lệnh Python nối tới một cổng đang nghe,
ngoài sandbox thì được, trong sandbox thì bị kernel từ chối."""
from cowork_local.core.deps import run_cancellable
server = _listening_socket()
port = server.getsockname()[1]
probe = (f'"{sys.executable}" -c "import socket;'
"print('PYTHON'+'_RAN');"
f"socket.create_connection(('127.0.0.1',{port}),5);print('CONN'+'ECTED')\"")
try:
rc, out, *_ = run_cancellable(probe, cwd=str(tmp_path), timeout=60, shell=True)
assert rc == 0 and "CONNECTED" in out
rc, out, *_ = run_cancellable(probe + " & echo written> marker.txt",
cwd=str(tmp_path), timeout=60, isolate_network=True)
assert "PYTHON_RAN" in out, out # Python itself starts in the sandbox
assert "CONNECTED" not in out, out
assert (Path(tmp_path) / "marker.txt").read_text().strip() == "written"
finally:
server.close()
# ---- xem trước HTML ------------------------------------------------------------
class _FakeUrl:
def __init__(self, scheme):
self._scheme = scheme
def scheme(self):
return self._scheme
class _FakeRequest:
def __init__(self, scheme):
self._url = _FakeUrl(scheme)
self.blocked = False
def requestUrl(self): # noqa: N802 - Qt name
return self._url
def block(self, flag):
self.blocked = flag
@pytest.mark.parametrize("scheme,expected", [
("https", True), ("http", True), ("wss", True), ("file", False), ("data", False),
])
def test_xem_truoc_html_chan_tai_nguyen_web(chan_mang, scheme, expected):
pytest.importorskip("PySide6.QtWebEngineCore")
from cowork_local.presentation.folder.offline_web_page import RemoteRequestBlocker
req = _FakeRequest(scheme)
RemoteRequestBlocker().interceptRequest(req)
assert req.blocked is expected
def test_xem_truoc_html_tai_binh_thuong_khi_tat():
pytest.importorskip("PySide6.QtWebEngineCore")
from cowork_local.presentation.folder.offline_web_page import RemoteRequestBlocker
req = _FakeRequest("https")
RemoteRequestBlocker().interceptRequest(req)
assert req.blocked is False
@pytest.mark.skipif(sys.platform != "win32", reason="AppContainer chỉ có trên Windows")
def test_khong_cap_quyen_ke_thua_len_thu_muc_chua_qt(tmp_path, monkeypatch):
"""Quyền AppContainer kế thừa xuống Qt6WebEngineCore.dll làm tiến trình render
của WebEngine không nạp được DLL — tab Graph và xem trước HTML trắng trơn."""
from cowork_local.infrastructure.sandbox import appcontainer_process as ac
qt_dir = tmp_path / "venv" / "Lib" / "site-packages" / "PySide6"
qt_dir.mkdir(parents=True)
monkeypatch.setattr(ac, "_qt_package_dir", lambda: str(qt_dir))
monkeypatch.setattr(ac, "_icacls", _no_network)
with pytest.raises(ac.NetworkIsolationUnavailable, match="Qt runtime"):
ac.grant_access(str(tmp_path / "venv"), "S-1-15-2-1", "read")
with pytest.raises(ac.NetworkIsolationUnavailable, match="Qt runtime"):
ac.grant_access(str(tmp_path), "S-1-15-2-1", "write")
def test_quyen_cho_python_khong_bao_gio_phu_len_thu_vien_qt(monkeypatch, tmp_path):
from cowork_local.infrastructure.sandbox import appcontainer_process as ac
venv, base = tmp_path / "venv", tmp_path / "base"
monkeypatch.setattr(ac.sys, "prefix", str(venv))
monkeypatch.setattr(ac.sys, "base_prefix", str(base))
for qt_dir in (venv / "Lib" / "site-packages" / "PySide6",
base / "Lib" / "site-packages" / "PySide6"):
monkeypatch.setattr(ac, "_qt_package_dir", lambda d=qt_dir: str(d))
for folder, mode in ac._interpreter_grants():
assert mode == "read_here" or not ac._covers(folder, str(qt_dir)), (folder, mode)
@@ -0,0 +1,51 @@
"""Xem trước HTML trong tab Folder phải hiện được ảnh lấy từ web khi mạng mở.
Tệp được nạp với base URL ``file://``; Qt mặc định cấm trang cục bộ tải bất kỳ
tài nguyên web nào nếu ``LocalContentCanAccessRemoteUrls`` tắt, nên ảnh
``<img src="https://...">`` không bao giờ hiện, kể cả khi đã mở Internet.
Việc chặn khi bật "Chặn mạng" là của bộ chặn request, không phải của cờ này.
"""
from __future__ import annotations
import pytest
pytest.importorskip("PySide6.QtWebEngineWidgets", reason="cần Qt WebEngine")
from cowork_local.presentation.shared import HAS_WEB_ENGINE # noqa: E402
pytestmark = pytest.mark.skipif(not HAS_WEB_ENGINE, reason="WebEngine không dùng được ở đây")
def test_trang_xem_truoc_cho_phep_tai_anh_tu_web(qapp):
from PySide6.QtWebEngineCore import QWebEngineSettings
from PySide6.QtWebEngineWidgets import QWebEngineView
from cowork_local.presentation.folder import offline_web_page as owp
view = QWebEngineView()
try:
owp.install_offline_page(view)
profile = view.page().profile()
assert profile is owp.preview_profile()
assert profile.settings().testAttribute(
QWebEngineSettings.WebAttribute.LocalContentCanAccessRemoteUrls)
assert isinstance(owp._blocker, owp.RemoteRequestBlocker)
finally:
view.deleteLater()
def test_profile_song_lau_hon_moi_trang(qapp):
"""Profile do view sở hữu bị huỷ TRƯỚC trang: Qt cảnh báo "Release of
profile requested but WebEnginePage still not deleted" rồi app có thể văng
(0xc0000409 trong Qt6Core.dll) khi chuyển tab. Profile phải thuộc về app."""
from PySide6.QtWebEngineWidgets import QWebEngineView
from cowork_local.presentation.folder import offline_web_page as owp
view = QWebEngineView()
try:
owp.install_offline_page(view)
assert view.page().parent() is view
assert owp.preview_profile().parent() is qapp
finally:
view.deleteLater()
+135
View File
@@ -0,0 +1,135 @@
"""Ba hàng cuối thanh rail phải thẳng hàng: Dashboard, Giám sát, Cài đặt.
Dashboard và Giám sát là hàng của ``QTreeWidget`` (``#navrailBottom``), còn Cài
đặt là một ``QPushButton`` tự dựng lấy icon + chữ trong ``top_bar.py``. Hai cách
vẽ khác nhau nên không có gì tự giữ cho chúng thẳng hàng — phải chốt bằng test.
Lần lệch gần nhất: ``srow.setSpacing(_NAV_ROW_GAP + 4)`` (=10) cộng với 10px lề
trái và hộp icon 22px đặt chữ "Cài đặt" ở x=42, trong khi delegate của cây đặt
chữ ở x=35 — thụt phải 7px, thấy rõ bằng mắt trên thanh rail.
Cách đo: render thanh rail ra ảnh rồi tìm cột mực đầu tiên, vì đó đúng là thứ
người dùng nhìn thấy. Trước khi đo, ba hàng được ép về **cùng một icon và cùng
một chữ** — chữ khác nhau thì phần nhô trái của glyph đầu tiên ("D" so với "G"
so với "C") đã lệch nhau vài pixel, và bài test sẽ đo hình dáng chữ chứ không
đo bố cục.
"""
from __future__ import annotations
import os
import pytest
pytest.importorskip("PySide6", reason="cần PySide6 để dựng cửa sổ thật")
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
#: Bỏ qua cột mực nằm sát mép trái: hàng đang được chọn có thêm vạch
#: ``border-left: 2px solid $accent`` (theme/qss.py), không phải icon.
_BO_QUA_MEP_TRAI = 5
@pytest.fixture(scope="module")
def window(qapp, tmp_path_factory):
"""Cửa sổ thật, có nạp stylesheet đúng như ``app.py`` làm.
Không nạp thì ``QTreeWidget::item { padding: 6px 10px }`` không áp, hàng của
cây thụt về 0 còn nút Cài đặt vẫn giữ lề 10px của layout — bài test sẽ đỏ vì
thiếu theme chứ không vì lỗi bố cục.
"""
from cowork_local.presentation.shell.bootstrap import build_config, build_context
from cowork_local.presentation.shell.main_window import MainWindow
from cowork_local.theme import set_active_theme, stylesheet
config_path = tmp_path_factory.mktemp("cfg") / "config.json"
build_config(config_path)
ctx = build_context(config_path)
css_cu = qapp.styleSheet()
set_active_theme(ctx.config.theme)
qapp.setStyleSheet(stylesheet(ctx.config.theme))
win = MainWindow(ctx)
win.resize(1280, 800)
win.show()
for _ in range(3):
qapp.processEvents()
yield win
win.close()
qapp.setStyleSheet(css_cu)
def _cum_muc(img, y0: int, y1: int):
"""Các cụm cột có mực trong dải ``[y0, y1)``, dạng ``[(x_đầu, x_cuối), ...]``.
Màu nền lấy ở cột sát mép phải cùng dòng y, nên hàng đang được tô nền chọn
vẫn so sánh đúng.
"""
w = img.width()
co_muc = [any(img.pixel(x, y) != img.pixel(w - 3, y) for y in range(y0, y1))
for x in range(w)]
cum, dau = [], None
for x, c in enumerate(co_muc):
if c and dau is None:
dau = x
elif not c and dau is not None:
if x - dau >= 2:
cum.append((dau, x - 1))
dau = None
if dau is not None:
cum.append((dau, w - 1))
return [c for c in cum if c[0] >= _BO_QUA_MEP_TRAI]
def _ep_ba_hang_ve_cung_hinh(window):
"""Cho ba hàng cùng icon và cùng chữ, để chỉ còn bố cục là khác biệt."""
from cowork_local.ui.icons import icon as _icon
for i in range(2):
it = window.nav_bottom.topLevelItem(i)
it.setIcon(0, _icon("gear"))
it.setText(0, "M")
window._nav_settings_icon.setPixmap(_icon("gear").pixmap(16, 16))
window._nav_settings_text.setText("M")
def _vi_tri_ba_hang(qapp, window):
"""``{tên hàng: (x_icon, x_chữ)}`` đo từ ảnh render của thanh rail."""
from PySide6.QtCore import QPoint
_ep_ba_hang_ve_cung_hinh(window)
for _ in range(3):
qapp.processEvents()
ref = window._nav_wrap
img = ref.grab().toImage()
ket = {}
for i, ten in ((0, "Dashboard"), (1, "Giám sát")):
it = window.nav_bottom.topLevelItem(i)
r = window.nav_bottom.visualItemRect(it)
y = window.nav_bottom.viewport().mapTo(ref, QPoint(0, r.y())).y()
cum = _cum_muc(img, y + 4, y + r.height() - 4)
assert len(cum) >= 2, f"{ten}: không tìm thấy đủ icon và chữ để đo"
ket[ten] = (cum[0][0], cum[1][0])
btn = window._nav_settings_btn
y = btn.mapTo(ref, QPoint(0, 0)).y()
cum = _cum_muc(img, y + 4, y + btn.height() - 4)
assert len(cum) >= 2, "Cài đặt: không tìm thấy đủ icon và chữ để đo"
ket["Cài đặt"] = (cum[0][0], cum[1][0])
return ket
def test_icon_ba_hang_thang_hang(qapp, window):
"""Icon của ba hàng phải bắt đầu ở cùng một cột."""
vi_tri = _vi_tri_ba_hang(qapp, window)
x = {ten: v[0] for ten, v in vi_tri.items()}
assert len(set(x.values())) == 1, f"icon lệch nhau: {x}"
def test_chu_ba_hang_thang_hang(qapp, window):
"""Chữ của ba hàng phải bắt đầu ở cùng một cột.
Đây là bài đỏ trước khi sửa: Cài đặt ở 42, hai hàng kia ở 35.
"""
vi_tri = _vi_tri_ba_hang(qapp, window)
x = {ten: v[1] for ten, v in vi_tri.items()}
assert len(set(x.values())) == 1, f"chữ lệch nhau: {x}"
+128
View File
@@ -0,0 +1,128 @@
"""Nút "Sửa project" phải sáng ngay khi đã có project đang mở.
Triệu chứng: mở app, chọn một project rồi vào sub-tab Project — biểu mẫu hiện
đủ tên, mô tả, thư mục làm việc, nhưng nút "Sửa project" vẫn mờ, không bấm được.
Nguyên nhân: trạng thái bật/mờ của ba nút chỉ được tính trong
``set_project_editable``, mà đổi project KHÔNG đi qua hàm đó — ``_load_current``
chỉ nạp lại biểu mẫu. ``_sync_project_buttons`` có chạy khi đổi sub-tab nhưng
ngày trước chỉ chỉnh ẩn/hiện, nên nút hiện ra mang theo trạng thái mờ tính từ
lúc dựng cửa sổ, khi chưa project nào được chọn.
Các bài dưới đây chốt cả bốn trạng thái: chưa có project → mờ; có project →
sáng; đi vòng qua sub-tab khác rồi quay lại → vẫn sáng; đang sửa dở → mờ lại
(nếu không thì "đang sửa" và "chưa sửa" trông giống hệt nhau).
Hai quy ước bắt buộc, lấy từ ``test_project_editing.py`` và
``test_project_gate_subtabs.py`` ngay cạnh:
* **Không tạo, không xoá project nào.** ``core/projects.py`` gắn
``PROJECTS_DIR`` vào ``~/.cowork_local`` THẬT, nên tạo project trong test là
ghi vào dữ liệu đang dùng của người chạy test. Trạng thái "đã có project"
được đặt thẳng vào ``_current_id`` — đúng biến mà ba nút đọc.
* **Một cửa sổ cho cả module.** Dựng ``MainWindow`` cho từng bài làm cả bộ
``tests/ui`` chết giữa chừng (Qt đổ stack trace, không phải test nào fail),
nên fixture ở đây là ``scope="module"`` và mỗi bài tự đặt trạng thái đầu vào
của mình.
"""
from __future__ import annotations
import pytest
pytest.importorskip("PySide6", reason="cần PySide6 để dựng cửa sổ thật")
_PID_GIA = "project-test-khong-ghi-dia"
@pytest.fixture(scope="module")
def ws(qapp, tmp_path_factory):
"""Màn Workspace của một MainWindow thật, dùng chung cho cả module."""
from cowork_local.presentation.shell.bootstrap import build_config, build_context
from cowork_local.presentation.shell.main_window import MainWindow
config_path = tmp_path_factory.mktemp("cfg") / "config.json"
build_config(config_path)
window = MainWindow(build_context(config_path))
yield window.workspace
window.close()
def _nap_project(qapp, ws, pid: str) -> None:
"""Đi đúng đường ``_load_current`` đi khi người dùng chọn một project.
Cố ý KHÔNG gọi ``set_project_editable``: chính vì ``_load_current`` không
gọi nó mà lỗi mới tồn tại. Gọi nó ở đây là bài test tự tay bật lại nút rồi
khẳng định nút đang bật — nó sẽ xanh cả trên bản chưa sửa.
``_project_editable`` đặt thẳng về ``False`` vì cửa sổ dùng chung cho cả
module: bài trước có thể đã để form ở chế độ sửa, mà nạp một project mới
thì form luôn ở chế độ chỉ-xem.
"""
ws.tabs.setCurrentIndex(ws._project_tab_idx)
ws._project_editable = False
ws._current_id = pid
ws.project_selected.emit(pid)
qapp.processEvents()
def _chua_co_project(qapp, ws) -> None:
"""Trạng thái chưa chọn project nào, đang ở sub-tab Project."""
_nap_project(qapp, ws, "")
def _mo_mot_project(qapp, ws) -> None:
"""Trạng thái đang mở một project."""
_nap_project(qapp, ws, _PID_GIA)
def test_chua_co_project_thi_nut_sua_mo(qapp, ws):
"""Chưa chọn project thì không có gì để sửa — đây là hành vi phải giữ."""
_chua_co_project(qapp, ws)
assert ws._edit_btn.isEnabled() is False
assert ws._edit_btn.isHidden() is True
def test_da_co_project_thi_nut_sua_sang(qapp, ws):
"""Bài đỏ trước khi sửa: nút hiện ra nhưng vẫn mờ."""
_mo_mot_project(qapp, ws)
assert ws._edit_btn.isHidden() is False, "nút phải hiện khi đã có project"
assert ws._edit_btn.isEnabled() is True, "nút phải bấm được khi đã có project"
def test_quay_lai_tab_project_thi_nut_van_sang(qapp, ws):
"""Đúng thao tác trong ảnh người dùng gửi: rời tab Project rồi quay lại."""
if ws.tabs.count() < 2:
pytest.skip("bản dựng này chỉ có một sub-tab, không đi vòng được")
_mo_mot_project(qapp, ws)
ws.tabs.setCurrentIndex(1 if ws._project_tab_idx == 0 else 0)
qapp.processEvents()
ws.tabs.setCurrentIndex(ws._project_tab_idx)
qapp.processEvents()
assert ws._edit_btn.isHidden() is False
assert ws._edit_btn.isEnabled() is True
def test_dang_sua_thi_nut_sua_mo_lai_va_nut_luu_sang(qapp, ws):
"""Chống sửa quá tay: "Sửa project" chỉ sáng khi CHƯA ở chế độ sửa."""
_mo_mot_project(qapp, ws)
ws.set_project_editable(True)
qapp.processEvents()
assert ws._edit_btn.isEnabled() is False
assert ws._save_btn.isEnabled() is True
assert ws._browse_btn.isEnabled() is True
def test_bo_chon_project_thi_nut_sua_mo_lai(qapp, ws):
"""Xoá project đang mở đưa ``_current_id`` về rỗng — nút phải mờ lại."""
_mo_mot_project(qapp, ws)
ws._current_id = ""
ws.project_selected.emit("")
qapp.processEvents()
assert ws._edit_btn.isEnabled() is False
assert ws._edit_btn.isHidden() is True
+223
View File
@@ -0,0 +1,223 @@
"""Mỗi thư mục làm việc chỉ được thuộc về MỘT project.
Thư mục làm việc vừa là sandbox (agent chỉ được đọc/ghi bên trong nó) vừa là
kho kiến thức chung của project (file ở gốc thư mục được mọi đoạn chat tự đọc).
Hai project trỏ vào cùng một thư mục là đọc lẫn dữ liệu của nhau và ghi đè lên
nhau — đúng điều mà docstring đầu ``core/projects.py`` nói sandbox sinh ra để
ngăn, nhưng trước đây không có gì chặn.
Hai nhóm bài:
* **Luật** — ``folder_conflict`` nhận diện trùng, kể cả khác hoa thường, khác
kiểu dấu phân cách, và LỒNG NHAU (đứng ở thư mục cha thì vẫn với tới được
file của project con).
* **Giao diện** — nhãn cảnh báo dưới ô "Thư mục làm việc" hiện đúng lúc, vì dữ
liệu cũ có thể đã trùng sẵn và luật mới chỉ chặn từ lúc chọn trở đi.
"""
from __future__ import annotations
import os
import pytest
from cowork_local.core import projects as projects_mod
from cowork_local.core.projects import Project, WORKSPACES_DIR, folder_conflict
def _kho(monkeypatch, *ds: Project) -> None:
"""Giả lập kho project, không đụng ``~/.cowork_local`` thật."""
monkeypatch.setattr(projects_mod, "list_projects", lambda directory=None: list(ds))
# ---- luật: nhận diện trùng ----------------------------------------------
def test_trung_y_het_thi_bi_bat(monkeypatch, tmp_path):
_kho(monkeypatch, Project(project_id="a", name="A", output_dir=str(tmp_path)))
khac = folder_conflict(str(tmp_path), ignore_id="b")
assert khac is not None and khac.project_id == "a"
def test_khac_hoa_thuong_va_dau_phan_cach_van_la_trung(monkeypatch, tmp_path):
"""Trên Windows ``D:/Work`` và ``d:/work`` là cùng một thư mục."""
_kho(monkeypatch, Project(project_id="a", name="A", output_dir=str(tmp_path)))
lech = str(tmp_path).replace(os.sep, "/")
if os.name == "nt":
lech = lech.upper()
assert folder_conflict(lech, ignore_id="b") is not None
def test_thu_muc_con_nam_trong_thu_muc_cua_project_khac_la_trung(monkeypatch, tmp_path):
"""Project kia đứng ở thư mục cha thì vẫn đọc/ghi được thư mục con này."""
_kho(monkeypatch, Project(project_id="a", name="A", output_dir=str(tmp_path)))
assert folder_conflict(str(tmp_path / "con"), ignore_id="b") is not None
def test_thu_muc_cha_chua_thu_muc_cua_project_khac_la_trung(monkeypatch, tmp_path):
"""Chiều ngược lại cũng phải bắt: chọn thư mục cha là ôm trọn project kia."""
_kho(monkeypatch, Project(project_id="a", name="A",
output_dir=str(tmp_path / "con")))
assert folder_conflict(str(tmp_path), ignore_id="b") is not None
def test_ten_na_na_nhung_khong_long_nhau_thi_khong_trung(monkeypatch, tmp_path):
"""Bẫy của so sánh tiền tố: ``work2`` KHÔNG nằm trong ``work``."""
_kho(monkeypatch, Project(project_id="a", name="A",
output_dir=str(tmp_path / "work")))
assert folder_conflict(str(tmp_path / "work2"), ignore_id="b") is None
def test_project_chua_dat_thu_muc_rieng_van_dang_chiem_thu_muc_quan_ly(monkeypatch):
"""``output_dir`` rỗng không có nghĩa là "chưa chiếm chỗ nào": project vẫn
đang dùng thư mục quản lý sẵn, và chính nó hay bị chọn nhầm."""
_kho(monkeypatch, Project(project_id="a", name="A", output_dir=""))
assert folder_conflict(str(WORKSPACES_DIR / "a"), ignore_id="b") is not None
def test_giu_nguyen_thu_muc_cua_chinh_no_thi_khong_phai_trung(monkeypatch, tmp_path):
_kho(monkeypatch, Project(project_id="a", name="A", output_dir=str(tmp_path)))
assert folder_conflict(str(tmp_path), ignore_id="a") is None
def test_thu_muc_chua_ai_dung_thi_di_qua(monkeypatch, tmp_path):
_kho(monkeypatch, Project(project_id="a", name="A",
output_dir=str(tmp_path / "cua-a")))
assert folder_conflict(str(tmp_path / "cua-b"), ignore_id="b") is None
def test_duong_dan_rong_khong_bi_coi_la_trung(monkeypatch, tmp_path):
"""Ô trống là "chưa chọn", không phải "trùng" — khác hẳn nhau."""
_kho(monkeypatch, Project(project_id="a", name="A", output_dir=str(tmp_path)))
assert folder_conflict("", ignore_id="b") is None
assert folder_conflict(" ", ignore_id="b") is None
# ---- i18n: ba key mới phải đủ ba ngôn ngữ -------------------------------
@pytest.mark.parametrize("key", [
"workspace.folder_taken_title", "workspace.folder_taken_body",
"workspace.folder_shared_warning",
])
def test_key_moi_co_du_ba_ngon_ngu(key):
from cowork_local import i18n
entry = i18n.STRINGS[key]
for lang in ("en", "ja", "vi"):
assert entry.get(lang), f"{key} thiếu {lang}"
# ---- giao diện: cảnh báo cho dữ liệu đã trùng sẵn -----------------------
pytest.importorskip("PySide6", reason="cần PySide6 để dựng cửa sổ thật")
@pytest.fixture(scope="module")
def ws(qapp, tmp_path_factory):
"""Một cửa sổ cho cả module — dựng nhiều MainWindow làm Qt chết giữa chừng."""
from cowork_local.presentation.shell.bootstrap import build_config, build_context
from cowork_local.presentation.shell.main_window import MainWindow
config_path = tmp_path_factory.mktemp("cfg") / "config.json"
build_config(config_path)
window = MainWindow(build_context(config_path))
yield window.workspace
window.close()
def _mo_project(qapp, ws, monkeypatch, dang_mo: Project, *nhung_cai_khac: Project):
"""Mở ``dang_mo`` trên biểu mẫu, với kho chứa cả các project còn lại."""
_kho(monkeypatch, dang_mo, *nhung_cai_khac)
monkeypatch.setattr(projects_mod, "load_project",
lambda pid, directory=None: dang_mo if pid == dang_mo.project_id else None)
ws._current_id = dang_mo.project_id
ws.project_selected.emit(dang_mo.project_id)
qapp.processEvents()
def test_canh_bao_hien_khi_project_dang_dung_chung_thu_muc(qapp, ws, monkeypatch, tmp_path):
"""Đúng trạng thái trong ảnh người dùng gửi: hai project cùng một thư mục."""
_mo_project(qapp, ws, monkeypatch,
Project(project_id="b", name="test3", output_dir=str(tmp_path)),
Project(project_id="a", name="test2", output_dir=str(tmp_path)))
assert ws._folder_warn_lbl.isHidden() is False
assert "test2" in ws._folder_warn_lbl.text()
def test_khong_canh_bao_khi_thu_muc_rieng(qapp, ws, monkeypatch, tmp_path):
_mo_project(qapp, ws, monkeypatch,
Project(project_id="b", name="test3", output_dir=str(tmp_path / "b")),
Project(project_id="a", name="test2", output_dir=str(tmp_path / "a")))
assert ws._folder_warn_lbl.isHidden() is True
def test_nhan_canh_bao_nam_ngay_duoi_o_thu_muc_lam_viec(ws):
"""Cảnh báo phải ở cạnh thứ nó nói tới, không rơi xuống cuối biểu mẫu."""
from cowork_local.presentation.workspace.project_editing import _row_layout_of
from cowork_local.presentation.workspace.project_folder_rules import _layout_chua
hang = _row_layout_of(ws.folder_lbl)
layout, vi_tri = _layout_chua(ws.folder_lbl.parentWidget().layout(), hang)
assert layout.itemAt(vi_tri + 1).widget() is ws._folder_warn_lbl
# ---- hành vi: chọn thư mục đã thuộc project khác thì KHÔNG được ghi ------
def test_chon_thu_muc_trung_thi_khong_ghi_gi(qapp, ws, monkeypatch, tmp_path):
"""Đây là cổng chặn thật, ở đúng nút "Đổi" mà người dùng bấm."""
from PySide6.QtWidgets import QFileDialog, QMessageBox
from cowork_local.ui import workspace_tab as wt
cua_toi = Project(project_id="b", name="test3", output_dir=str(tmp_path / "b"))
cua_nguoi_khac = Project(project_id="a", name="test2", output_dir=str(tmp_path / "a"))
_mo_project(qapp, ws, monkeypatch, cua_toi, cua_nguoi_khac)
da_ghi = []
monkeypatch.setattr(projects_mod, "save_project",
lambda project, directory=None: da_ghi.append(project))
da_bao = []
monkeypatch.setattr(QMessageBox, "warning",
staticmethod(lambda *a, **k: da_bao.append(a)))
# Người dùng chọn đúng thư mục của project kia.
monkeypatch.setattr(QFileDialog, "getExistingDirectory",
staticmethod(lambda *a, **k: str(tmp_path / "a")))
wt.WorkspaceTab._pick_folder(ws)
assert da_ghi == [], "đã ghi đè output_dir dù thư mục thuộc project khác"
assert cua_toi.output_dir == str(tmp_path / "b"), "thư mục cũ bị đổi mất"
assert da_bao, "chặn im lặng — người dùng không biết vì sao không đổi được"
def test_chon_thu_muc_tu_do_thi_van_doi_duoc(qapp, ws, monkeypatch, tmp_path):
"""Chặn một chiều là hỏng tính năng — thư mục chưa ai dùng phải đổi được."""
from PySide6.QtWidgets import QFileDialog
from cowork_local.ui import workspace_tab as wt
cua_toi = Project(project_id="b", name="test3", output_dir=str(tmp_path / "b"))
cua_nguoi_khac = Project(project_id="a", name="test2", output_dir=str(tmp_path / "a"))
_mo_project(qapp, ws, monkeypatch, cua_toi, cua_nguoi_khac)
da_ghi = []
monkeypatch.setattr(projects_mod, "save_project",
lambda project, directory=None: da_ghi.append(project))
monkeypatch.setattr(QFileDialog, "getExistingDirectory",
staticmethod(lambda *a, **k: str(tmp_path / "hoan-toan-moi")))
wt.WorkspaceTab._pick_folder(ws)
assert len(da_ghi) == 1
assert cua_toi.output_dir == str(tmp_path / "hoan-toan-moi")