feat(infra): AtomicJsonFile + KeyringAdapter, và đổi tên platform/ vì nó che stdlib
Ngày 21/08 của làn N1 (Nam): R02-T01 và R02-T04.
--- Lỗi phải sửa trước khi làm được gì ---
Kế hoạch đặt tên một tầng là platform/. Tôi dựng đúng theo đó sáng nay, có
kiểm "platform stdlib không bị che" và báo là an toàn. Kiểm đó SAI: tôi chỉ
thử từ thư mục cha. Chạy từ gốc repo — đúng cách 26 script trong tools/ và
scripts/ được gọi — thì platform/ che khuất platform của thư viện chuẩn, và
import keyring chết ngay:
AttributeError: module 'platform' has no attribute 'system'
Nghĩa là R02-T04 không thể làm được chừng nào thư mục đó còn tên cũ. Đổi
platform/ -> adapters/. Đây là lệch khỏi plan.md và ảnh hưởng Team Hoa (họ sở
hữu platform/qt/qt_scheduler_clock.py) — đã ghi vào GammaTeam_decisions.md.
tests/test_no_stdlib_shadow.py chặn lỗi tái diễn, hai lớp: một bài so tên thư
mục gốc repo với sys.stdlib_module_names, một bài chạy tiến trình con với cwd
là gốc repo rồi import keyring thật. Dựng lại platform/ là cả hai đỏ.
--- R02-T01: AtomicJsonFile ---
config.py::save() đang gọi path.write_text(), tức là cắt file về 0 byte rồi
mới ghi. Chết giữa chừng là mất sạch cấu hình. Thay bằng: ghi file tạm cùng
thư mục -> flush + fsync -> os.replace (nguyên tử trên cả Windows và POSIX).
Test tiêm lỗi đúng như cột nghiệm thu của plan.md: cho os.replace ném lỗi
ngay bước cuối rồi khẳng định file cũ còn nguyên. Chỉ test "ghi rồi đọc lại"
thì write_text() cũ cũng qua — mà đó chính là thứ đang thay.
Phần đọc: file hỏng được dời thành .bad-<thời điểm> rồi trả mặc định. Giữ
đúng hành vi "hỏng cấu hình không chặn khởi động" của config.py, thêm phần
cứu được bản hỏng.
--- R02-T04: KeyringAdapter ---
Windows Credential Manager / macOS Keychain / Linux Secret Service. Không bao
giờ ném lỗi: máy không có kho (Linux headless, CI) thì available=False và trả
None, để tầng UI nói "chưa lưu được khoá" thay vì sập app. Test tiêm backend
giả, không đụng keyring thật của máy chạy test.
119 test xanh (102 + 17 mới). CASAN Check 1 sạch.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
2627e691ce
commit
d6dd6a030e
@@ -0,0 +1,12 @@
|
|||||||
|
"""adapters/ — Adapter riêng cho Qt (clock, thread, timer).
|
||||||
|
|
||||||
|
Kế hoạch gốc đặt tên thư mục này là ``platform/``. Không dùng được: chạy
|
||||||
|
bất kỳ script nào từ thư mục gốc repo (``python tools/...``,
|
||||||
|
``python scripts/...``) thì ``platform/`` **che khuất module ``platform``
|
||||||
|
của thư viện chuẩn**, và ``import keyring`` chết ngay với
|
||||||
|
``AttributeError: module 'platform' has no attribute 'system'``.
|
||||||
|
Repo có 26 script chạy đúng kiểu đó.
|
||||||
|
|
||||||
|
Đổi tên là cách duy nhất chắc chắn — không thể bắt mọi người nhớ "đừng bao
|
||||||
|
giờ chạy python từ thư mục gốc".
|
||||||
|
"""
|
||||||
@@ -0,0 +1,102 @@
|
|||||||
|
"""Ghi JSON kiểu không-hỏng-file — R02-T01.
|
||||||
|
|
||||||
|
Vấn đề đang có: ``config.py::save()`` gọi thẳng ``path.write_text(...)``. Hàm
|
||||||
|
đó mở file, cắt cụt về 0 byte, rồi mới ghi nội dung mới. Mất điện, tắt máy, hay
|
||||||
|
process bị kill đúng khoảng giữa thì file cấu hình còn lại **rỗng hoặc ghi dở**
|
||||||
|
— và người dùng mất toàn bộ cấu hình.
|
||||||
|
|
||||||
|
Cách làm ở đây theo đúng thứ tự bắt buộc:
|
||||||
|
|
||||||
|
1. Ghi vào file tạm cùng thư mục (phải cùng ổ đĩa thì bước 3 mới nguyên tử)
|
||||||
|
2. ``flush()`` + ``os.fsync()`` — ép dữ liệu xuống đĩa thật, không nằm trong
|
||||||
|
bộ đệm của hệ điều hành
|
||||||
|
3. ``os.replace()`` — nguyên tử trên cả Windows lẫn POSIX
|
||||||
|
|
||||||
|
Bất kỳ lúc nào chết giữa chừng, file đích vẫn là **bản cũ nguyên vẹn**. Không
|
||||||
|
bao giờ có trạng thái ghi dở.
|
||||||
|
|
||||||
|
Phần đọc có chính sách phục hồi: file hỏng thì giữ lại thành ``.bad`` để còn
|
||||||
|
cứu tay, rồi trả về giá trị mặc định — hỏng cấu hình không được chặn khởi động,
|
||||||
|
đúng như ``config.py`` hiện tại đang làm.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import tempfile
|
||||||
|
from datetime import datetime
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
|
||||||
|
class AtomicJsonFile:
|
||||||
|
"""Một file JSON, đọc ghi an toàn.
|
||||||
|
|
||||||
|
>>> f = AtomicJsonFile(Path("cau_hinh.json"))
|
||||||
|
>>> f.write({"theme": "dark"})
|
||||||
|
>>> f.read(default={})
|
||||||
|
{'theme': 'dark'}
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, path: Path, *, indent: int = 2):
|
||||||
|
self.path = Path(path)
|
||||||
|
self.indent = indent
|
||||||
|
|
||||||
|
# ---- đọc ------------------------------------------------------------
|
||||||
|
def read(self, default: Any = None) -> Any:
|
||||||
|
"""Nội dung file, hoặc ``default`` nếu chưa có / hỏng.
|
||||||
|
|
||||||
|
Không ném lỗi. File hỏng được đổi tên thành ``<tên>.bad-<thời điểm>``
|
||||||
|
rồi mới trả mặc định — hỏng thì cứu được, chứ đừng ghi đè im lặng.
|
||||||
|
"""
|
||||||
|
if not self.path.exists():
|
||||||
|
return default
|
||||||
|
try:
|
||||||
|
return json.loads(self.path.read_text(encoding="utf-8"))
|
||||||
|
except (json.JSONDecodeError, UnicodeDecodeError):
|
||||||
|
self._quarantine()
|
||||||
|
return default
|
||||||
|
except OSError:
|
||||||
|
# Không đọc được (khoá file, mất quyền) — KHÔNG cách ly, vì file
|
||||||
|
# có thể vẫn tốt nguyên.
|
||||||
|
return default
|
||||||
|
|
||||||
|
def _quarantine(self) -> Path | None:
|
||||||
|
stamp = datetime.now().strftime("%Y%m%d-%H%M%S")
|
||||||
|
target = self.path.with_suffix(self.path.suffix + f".bad-{stamp}")
|
||||||
|
try:
|
||||||
|
os.replace(self.path, target)
|
||||||
|
return target
|
||||||
|
except OSError:
|
||||||
|
return None
|
||||||
|
|
||||||
|
# ---- ghi ------------------------------------------------------------
|
||||||
|
def write(self, data: Any) -> None:
|
||||||
|
"""Ghi ``data``. Hoặc thành công trọn vẹn, hoặc file cũ còn nguyên."""
|
||||||
|
self.path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
text = json.dumps(data, indent=self.indent, ensure_ascii=False)
|
||||||
|
|
||||||
|
# File tạm phải nằm CÙNG thư mục: os.replace chỉ nguyên tử trong cùng
|
||||||
|
# một hệ thống tệp. Để ở %TEMP% là có thể rơi sang ổ khác và biến
|
||||||
|
# thành copy + delete — mất luôn tính nguyên tử.
|
||||||
|
fd, tmp_name = tempfile.mkstemp(
|
||||||
|
dir=str(self.path.parent), prefix=f".{self.path.name}.", suffix=".tmp")
|
||||||
|
tmp = Path(tmp_name)
|
||||||
|
try:
|
||||||
|
with os.fdopen(fd, "w", encoding="utf-8") as f:
|
||||||
|
f.write(text)
|
||||||
|
f.flush()
|
||||||
|
os.fsync(f.fileno()) # xuống đĩa thật, không chỉ vào bộ đệm
|
||||||
|
os.replace(tmp, self.path) # nguyên tử
|
||||||
|
except BaseException:
|
||||||
|
# Kể cả KeyboardInterrupt/SystemExit cũng phải dọn file tạm, đừng
|
||||||
|
# để rác .tmp nằm lại cạnh file cấu hình.
|
||||||
|
tmp.unlink(missing_ok=True)
|
||||||
|
raise
|
||||||
|
|
||||||
|
# ---- tiện ích -------------------------------------------------------
|
||||||
|
def exists(self) -> bool:
|
||||||
|
return self.path.exists()
|
||||||
|
|
||||||
|
def __repr__(self) -> str:
|
||||||
|
return f"AtomicJsonFile({self.path})"
|
||||||
@@ -1 +0,0 @@
|
|||||||
"""platform/ — Adapter riêng cho Qt (clock, thread, timer)."""
|
|
||||||
@@ -0,0 +1,105 @@
|
|||||||
|
"""AtomicJsonFile — R02-T01. Test tiêm lỗi, đúng như cột nghiệm thu của plan.md.
|
||||||
|
|
||||||
|
Cách kiểm: cắt ngang giữa lúc ghi rồi khẳng định file cũ **còn nguyên**. Nếu
|
||||||
|
chỉ test "ghi rồi đọc lại thấy đúng" thì `path.write_text()` cũ cũng qua — mà
|
||||||
|
đó chính là thứ ta đang thay.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from cowork_local.infrastructure.persistence.json.atomic_json_file import AtomicJsonFile
|
||||||
|
|
||||||
|
|
||||||
|
def test_ghi_roi_doc_lai(tmp_path):
|
||||||
|
f = AtomicJsonFile(tmp_path / "cau_hinh.json")
|
||||||
|
f.write({"theme": "dark", "ngôn ngữ": "vi"})
|
||||||
|
assert f.read() == {"theme": "dark", "ngôn ngữ": "vi"}
|
||||||
|
|
||||||
|
|
||||||
|
def test_chua_co_file_thi_tra_mac_dinh(tmp_path):
|
||||||
|
f = AtomicJsonFile(tmp_path / "chua-ton-tai.json")
|
||||||
|
assert f.read(default={"theme": "dark"}) == {"theme": "dark"}
|
||||||
|
assert f.exists() is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_chet_giua_luc_ghi_thi_file_cu_con_nguyen(tmp_path, monkeypatch):
|
||||||
|
"""Lõi của R02-T01.
|
||||||
|
|
||||||
|
Giả lập mất điện đúng lúc: cho ``os.replace`` ném lỗi. Đây là bước cuối
|
||||||
|
cùng, tức là dữ liệu mới đã nằm trong file tạm rồi — nếu cài đặt sai theo
|
||||||
|
kiểu ghi đè thẳng, file đích lúc này đã hỏng.
|
||||||
|
"""
|
||||||
|
path = tmp_path / "cau_hinh.json"
|
||||||
|
f = AtomicJsonFile(path)
|
||||||
|
f.write({"phiên bản": 1, "quan trọng": "đừng mất"})
|
||||||
|
|
||||||
|
def no_dien(*args, **kwargs):
|
||||||
|
raise OSError("mô phỏng mất điện")
|
||||||
|
|
||||||
|
monkeypatch.setattr(os, "replace", no_dien)
|
||||||
|
with pytest.raises(OSError):
|
||||||
|
f.write({"phiên bản": 2})
|
||||||
|
|
||||||
|
# bản cũ phải còn y nguyên
|
||||||
|
assert f.read() == {"phiên bản": 1, "quan trọng": "đừng mất"}
|
||||||
|
|
||||||
|
|
||||||
|
def test_khong_de_lai_rac_tmp_khi_ghi_hong(tmp_path, monkeypatch):
|
||||||
|
path = tmp_path / "cau_hinh.json"
|
||||||
|
f = AtomicJsonFile(path)
|
||||||
|
f.write({"a": 1})
|
||||||
|
|
||||||
|
monkeypatch.setattr(os, "replace", lambda *a, **k: (_ for _ in ()).throw(OSError("x")))
|
||||||
|
with pytest.raises(OSError):
|
||||||
|
f.write({"a": 2})
|
||||||
|
|
||||||
|
con_lai = [p.name for p in tmp_path.iterdir()]
|
||||||
|
assert con_lai == ["cau_hinh.json"], f"còn rác: {con_lai}"
|
||||||
|
|
||||||
|
|
||||||
|
def test_file_hong_thi_cach_ly_va_tra_mac_dinh(tmp_path):
|
||||||
|
"""Hỏng cấu hình không được chặn khởi động — giữ đúng hành vi config.py
|
||||||
|
hiện tại, nhưng thêm phần giữ lại bản hỏng để còn cứu."""
|
||||||
|
path = tmp_path / "cau_hinh.json"
|
||||||
|
path.write_text("{ đây không phải json", encoding="utf-8")
|
||||||
|
f = AtomicJsonFile(path)
|
||||||
|
|
||||||
|
assert f.read(default={"theme": "dark"}) == {"theme": "dark"}
|
||||||
|
assert not path.exists(), "file hỏng phải được dời đi"
|
||||||
|
bad = list(tmp_path.glob("*.bad-*"))
|
||||||
|
assert len(bad) == 1, "phải giữ lại bản hỏng để cứu tay"
|
||||||
|
assert "đây không phải json" in bad[0].read_text(encoding="utf-8")
|
||||||
|
|
||||||
|
|
||||||
|
def test_ghi_de_nhieu_lan_van_dung(tmp_path):
|
||||||
|
f = AtomicJsonFile(tmp_path / "dem.json")
|
||||||
|
for i in range(20):
|
||||||
|
f.write({"lần": i})
|
||||||
|
assert f.read() == {"lần": 19}
|
||||||
|
assert list(tmp_path.glob("*.tmp")) == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_giu_nguyen_tieng_viet_khong_escape(tmp_path):
|
||||||
|
"""config.py hiện dùng ensure_ascii=False — giữ nguyên để file đọc được
|
||||||
|
bằng mắt và git diff không thành một đống \\uXXXX."""
|
||||||
|
path = tmp_path / "vi.json"
|
||||||
|
AtomicJsonFile(path).write({"tên": "Nguyễn Văn Đức"})
|
||||||
|
raw = path.read_text(encoding="utf-8")
|
||||||
|
assert "Nguyễn Văn Đức" in raw
|
||||||
|
assert "\\u" not in raw
|
||||||
|
|
||||||
|
|
||||||
|
def test_tao_thu_muc_cha_neu_chua_co(tmp_path):
|
||||||
|
f = AtomicJsonFile(tmp_path / "sâu" / "hơn" / "nữa" / "c.json")
|
||||||
|
f.write({"ok": True})
|
||||||
|
assert f.read() == {"ok": True}
|
||||||
|
|
||||||
|
|
||||||
|
def test_json_ghi_ra_doc_duoc_bang_thu_vien_chuan(tmp_path):
|
||||||
|
path = tmp_path / "c.json"
|
||||||
|
AtomicJsonFile(path).write({"n": [1, 2, {"m": None}]})
|
||||||
|
assert json.loads(path.read_text(encoding="utf-8")) == {"n": [1, 2, {"m": None}]}
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
"""KeyringAdapter — R02-T04.
|
||||||
|
|
||||||
|
Không đụng vào keyring thật của máy chạy test: tiêm một backend giả. Test mà
|
||||||
|
ghi vào Credential Manager thật thì để lại rác trên máy người khác, và trên CI
|
||||||
|
thì không có kho nào để ghi.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from cowork_local.infrastructure.secrets.keyring_adapter import KeyringAdapter
|
||||||
|
from cowork_local.infrastructure.secrets.secret_store import SecretStore, provider_key
|
||||||
|
|
||||||
|
|
||||||
|
class _KeyringGia:
|
||||||
|
"""Đủ giống thư viện keyring để adapter dùng được."""
|
||||||
|
|
||||||
|
def __init__(self, hong: bool = False):
|
||||||
|
self.kho: dict[tuple[str, str], str] = {}
|
||||||
|
self.hong = hong
|
||||||
|
|
||||||
|
def get_password(self, service, key):
|
||||||
|
if self.hong:
|
||||||
|
raise RuntimeError("kho bí mật không phản hồi")
|
||||||
|
return self.kho.get((service, key))
|
||||||
|
|
||||||
|
def set_password(self, service, key, value):
|
||||||
|
if self.hong:
|
||||||
|
raise RuntimeError("kho bí mật không phản hồi")
|
||||||
|
self.kho[(service, key)] = value
|
||||||
|
|
||||||
|
def delete_password(self, service, key):
|
||||||
|
if self.hong:
|
||||||
|
raise RuntimeError("kho bí mật không phản hồi")
|
||||||
|
del self.kho[(service, key)]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def store():
|
||||||
|
a = KeyringAdapter(service="test-cowork")
|
||||||
|
a._backend = _KeyringGia()
|
||||||
|
a._available = True
|
||||||
|
return a
|
||||||
|
|
||||||
|
|
||||||
|
def test_khop_hop_dong_secret_store(store):
|
||||||
|
assert isinstance(store, SecretStore)
|
||||||
|
|
||||||
|
|
||||||
|
def test_luu_doc_xoa(store):
|
||||||
|
k = provider_key("openai")
|
||||||
|
assert store.get(k) is None
|
||||||
|
assert store.has(k) is False
|
||||||
|
|
||||||
|
store.set(k, "sk-that-la-bi-mat")
|
||||||
|
assert store.get(k) == "sk-that-la-bi-mat"
|
||||||
|
assert store.has(k) is True
|
||||||
|
|
||||||
|
store.delete(k)
|
||||||
|
assert store.get(k) is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_moi_provider_mot_khoa_rieng(store):
|
||||||
|
store.set(provider_key("openai"), "khoa-openai")
|
||||||
|
store.set(provider_key("anthropic"), "khoa-anthropic")
|
||||||
|
assert store.get(provider_key("openai")) == "khoa-openai"
|
||||||
|
assert store.get(provider_key("anthropic")) == "khoa-anthropic"
|
||||||
|
|
||||||
|
|
||||||
|
def test_may_khong_co_kho_thi_im_lang_chu_khong_sap():
|
||||||
|
"""Linux headless và CI không có Secret Service. App vẫn phải chạy."""
|
||||||
|
a = KeyringAdapter(service="test-cowork")
|
||||||
|
a._backend = None
|
||||||
|
a._available = False
|
||||||
|
|
||||||
|
assert a.available is False
|
||||||
|
assert a.get("bat-ky") is None
|
||||||
|
a.set("bat-ky", "gia-tri") # không ném lỗi
|
||||||
|
a.delete("bat-ky") # không ném lỗi
|
||||||
|
assert a.has("bat-ky") is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_kho_loi_giua_chung_thi_khong_lam_sap_app(store):
|
||||||
|
"""Keyring có thể hỏng lúc đang chạy — mất DBus, người dùng khoá máy."""
|
||||||
|
store._backend.hong = True
|
||||||
|
|
||||||
|
assert store.get("x") is None # nuốt lỗi, trả None
|
||||||
|
store.set("x", "y") # nuốt lỗi
|
||||||
|
store.delete("x") # nuốt lỗi
|
||||||
|
|
||||||
|
|
||||||
|
def test_xoa_khoa_khong_ton_tai_thi_bo_qua(store):
|
||||||
|
store.delete(provider_key("chua-bao-gio-luu")) # không ném lỗi
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
"""Không thư mục nào ở gốc repo được trùng tên module thư viện chuẩn.
|
||||||
|
|
||||||
|
Bài này sinh ra từ một lỗi thật: kế hoạch refactor đặt tên một tầng là
|
||||||
|
``platform/``, và ngay khi tạo thư mục đó thì mọi script chạy từ gốc repo —
|
||||||
|
``python tools/check_*.py``, ``python scripts/audit_security.py``, 26 file tất
|
||||||
|
cả — đều nạp nhầm ``platform/`` thay cho ``platform`` của Python. ``keyring``
|
||||||
|
chết ngay với ``AttributeError: module 'platform' has no attribute 'system'``.
|
||||||
|
|
||||||
|
Kiểm bằng tên chứ không phải bằng cách thử import: import chỉ hỏng khi có ai
|
||||||
|
đó thật sự dùng module bị che, nên nó im lặng cho tới lúc muộn.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
REPO = Path(__file__).resolve().parent.parent
|
||||||
|
|
||||||
|
#: Không tính: đây là thư mục dữ liệu/tài liệu, không phải package Python.
|
||||||
|
NOT_PACKAGES = {".git", ".gitea", ".vibeflow-preview", "docs", "assets",
|
||||||
|
"__pycache__", ".pytest_cache", "cowork-local-gitea",
|
||||||
|
".cowork_history", ".cowork_local"}
|
||||||
|
|
||||||
|
|
||||||
|
def _top_level_packages() -> list[str]:
|
||||||
|
return [d.name for d in REPO.iterdir()
|
||||||
|
if d.is_dir() and d.name not in NOT_PACKAGES
|
||||||
|
and (d / "__init__.py").exists()]
|
||||||
|
|
||||||
|
|
||||||
|
def test_khong_package_nao_che_khuat_thu_vien_chuan():
|
||||||
|
stdlib = set(sys.stdlib_module_names)
|
||||||
|
clashes = [name for name in _top_level_packages() if name in stdlib]
|
||||||
|
assert not clashes, (
|
||||||
|
"Thư mục ở gốc repo trùng tên module thư viện chuẩn: "
|
||||||
|
+ ", ".join(sorted(clashes))
|
||||||
|
+ ". Chạy script từ gốc repo sẽ nạp nhầm thư mục này. Đổi tên thư mục."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_import_duoc_stdlib_khi_chay_tu_goc_repo():
|
||||||
|
"""Bài trên bắt bằng tên; bài này bắt bằng hành vi thật.
|
||||||
|
|
||||||
|
Chạy tiến trình con với thư mục làm việc là gốc repo — đúng cách 26 script
|
||||||
|
trong ``tools/`` và ``scripts/`` được gọi.
|
||||||
|
"""
|
||||||
|
import subprocess
|
||||||
|
|
||||||
|
snippet = (
|
||||||
|
"import platform, json, types, io\n"
|
||||||
|
"assert 'site-packages' not in platform.__file__\n"
|
||||||
|
"assert platform.system(), 'platform.system() phải trả về tên hệ điều hành'\n"
|
||||||
|
"import keyring\n"
|
||||||
|
"print('OK')\n"
|
||||||
|
)
|
||||||
|
out = subprocess.run([sys.executable, "-c", snippet], cwd=REPO,
|
||||||
|
capture_output=True, text=True, timeout=60)
|
||||||
|
assert out.returncode == 0, out.stderr
|
||||||
|
assert "OK" in out.stdout
|
||||||
Reference in New Issue
Block a user