Merge pull request #195 from openqj/feat-shared-home-mcp-sync
feat: sync shared-home MCP scripts on control-plane startup
This commit is contained in:
3 files changed
+401
No files matched your search
@@ -107,6 +107,7 @@ from cloud.wait_ops import poll_task_statuses as _wait_poll # noqa: F401
|
|||||||
# /wait 本身走首变轮询(_wait_poll_first),见 _task_wait。
|
# /wait 本身走首变轮询(_wait_poll_first),见 _task_wait。
|
||||||
from cloud.wait_ops import report_handoff as _handoff_report
|
from cloud.wait_ops import report_handoff as _handoff_report
|
||||||
from cloud.wait_ops import worktree_status as _wait_worktree_status
|
from cloud.wait_ops import worktree_status as _wait_worktree_status
|
||||||
|
from cloud.shared_home_mcp import sync_shared_home_mcp as _sync_shared_home_mcp
|
||||||
from cloud.automations import ensure_automations_tables as _auto_ensure
|
from cloud.automations import ensure_automations_tables as _auto_ensure
|
||||||
from cloud.automations import list_automations as _auto_list
|
from cloud.automations import list_automations as _auto_list
|
||||||
from cloud.todos import create_todo as _todo_create
|
from cloud.todos import create_todo as _todo_create
|
||||||
@@ -425,6 +426,30 @@ def _local_worker_codex_home():
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_shared_home_mcp(server=None):
|
||||||
|
"""共享 home 解析:供服务端启动时的 MCP 脚本同步用。
|
||||||
|
|
||||||
|
优先级(生产路径绝不硬编码在这里,全部来自已有解析):
|
||||||
|
1. _local_worker_codex_home():读 host-config-host.json 的
|
||||||
|
codex_home,生产即共享 home;
|
||||||
|
2. 兜底:tenants_root 的父目录 + codex-home(tenants_root
|
||||||
|
本身来自 server 属性 / CODEX_TENANTS_ROOT / 默认值)。
|
||||||
|
任一解析不出时返回 None,调用方跳过同步、不因此报错。
|
||||||
|
注意:本函数只解析路径,不做文件同步;文件清单与同步
|
||||||
|
语义只在 cloud/shared_home_mcp.py 里。
|
||||||
|
"""
|
||||||
|
local = _local_worker_codex_home()
|
||||||
|
if local:
|
||||||
|
return local
|
||||||
|
try:
|
||||||
|
root = _tenants_root_for(server)
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
if not root:
|
||||||
|
return None
|
||||||
|
return os.path.join(os.path.dirname(root), "codex-home")
|
||||||
|
|
||||||
|
|
||||||
def _tenant_work_dir(server, project_id):
|
def _tenant_work_dir(server, project_id):
|
||||||
"""项目对应的租户工作目录:tenants_root/<安全项目名>/work,
|
"""项目对应的租户工作目录:tenants_root/<安全项目名>/work,
|
||||||
skills/list 的 cwds 过滤用它(不传 cwd 时 app-server 会
|
skills/list 的 cwds 过滤用它(不传 cwd 时 app-server 会
|
||||||
@@ -10309,6 +10334,30 @@ def main(argv=None):
|
|||||||
args = parser.parse_args(argv)
|
args = parser.parse_args(argv)
|
||||||
server = create_server(args.db, args.host, args.port,
|
server = create_server(args.db, args.host, args.port,
|
||||||
backend=args.db_backend)
|
backend=args.db_backend)
|
||||||
|
# 共享 home MCP 脚本同步:只在 main() 启动时做,不在
|
||||||
|
# create_server() 里做——create_server 是单测入口,单测若
|
||||||
|
# 在此同步会按 host-config-host.json 写向生产共享路径。
|
||||||
|
# systemd 两个控制面都跑 python3 -m cloud.server,走的
|
||||||
|
# 都是 main(),每次部署重启都会同步一次;同步失败只记
|
||||||
|
# warning,绝不阻塞启动(sync 本身也不抛异常,外层再
|
||||||
|
# 兜一层防解析等意外)。Docker 任务不受影响:容器内
|
||||||
|
# 该共享路径是租户 home 的挂载,宿主补齐不进容器。
|
||||||
|
try:
|
||||||
|
_shared_home = _resolve_shared_home_mcp(server)
|
||||||
|
if _shared_home:
|
||||||
|
_sync_result = _sync_shared_home_mcp(_shared_home)
|
||||||
|
if _sync_result.get("copied") or _sync_result.get("refreshed"):
|
||||||
|
_server_log.info(
|
||||||
|
"shared home MCP synced: copied=%s refreshed=%s",
|
||||||
|
_sync_result.get("copied"),
|
||||||
|
_sync_result.get("refreshed"))
|
||||||
|
if _sync_result.get("error"):
|
||||||
|
_server_log.warning(
|
||||||
|
"shared home MCP sync failed (non-fatal): %s",
|
||||||
|
_sync_result.get("error"))
|
||||||
|
except Exception as exc:
|
||||||
|
_server_log.warning(
|
||||||
|
"shared home MCP sync failed (non-fatal): %s", exc)
|
||||||
start_rate_limit_sweeper(server.rate_limiter, args.db)
|
start_rate_limit_sweeper(server.rate_limiter, args.db)
|
||||||
print(
|
print(
|
||||||
"cloud-control-plane listening on %s:%d db=%s"
|
"cloud-control-plane listening on %s:%d db=%s"
|
||||||
|
|||||||
@@ -0,0 +1,139 @@
|
|||||||
|
"""共享 codex-home 的 MCP 脚本自动补齐。
|
||||||
|
|
||||||
|
背景(2026-10-30 实测):租户 config 的 ``mcp_servers.*`` 指向
|
||||||
|
共享路径 ``<codex-home>/playwright_mcp.py`` 等,但宿主共享 home
|
||||||
|
里原先一个 MCP 脚本都没有——宿主 app-server 按该路径启动 MCP
|
||||||
|
时文件不存在、握手断掉,面板 ``mcp-servers-list`` 三服务器的
|
||||||
|
``toolsError`` 全是 ``handshake failed``。Docker 任务不受影响,
|
||||||
|
是因为租户 home 被挂载到容器内那个共享路径,容器内能解析。
|
||||||
|
|
||||||
|
本模块是这 7 个文件清单的唯一定义处:服务端启动同步、
|
||||||
|
测试、以及将来任何其它调用方都只从这里取清单,绝不在
|
||||||
|
server/worker/deploy 三处各写一份 7 文件列表(清单漂移就是
|
||||||
|
下一次“面板又跑旧版”的根源)。
|
||||||
|
|
||||||
|
语义与 playwright 播种一致,而不是 websearch/imagegen 的租户
|
||||||
|
播种语义:共享 home 里的脚本是系统文件,不是租户数据,租户
|
||||||
|
不会去改它,所以
|
||||||
|
- 缺失则拷入;
|
||||||
|
- 内容不同则刷新(原子替换)并记入 ``refreshed``;
|
||||||
|
- 内容相同则不动,绝不重写 mtime。
|
||||||
|
其它文件(config.toml、技能、租户数据)一律不动。
|
||||||
|
|
||||||
|
失败语义:本函数绝不抛异常阻塞调用方。源缺失、目标目录建
|
||||||
|
不出、单个文件不可写时只记入返回值(调用方记 warning),其
|
||||||
|
余文件照常同步。调用方(服务端启动)因此永远不会因为一次
|
||||||
|
补齐失败而起不来。
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import tempfile
|
||||||
|
|
||||||
|
# 唯一清单:MCP 脚本与其同目录兄弟模块。只拷 *_mcp.py 不够,
|
||||||
|
# 因为 MCP 脚本 fallback 时从同目录 import 兄弟模块
|
||||||
|
# (playwright_mcp -> playwright_browser/playwright_remote,
|
||||||
|
# websearch_mcp -> websearch,imagegen_mcp -> imagegen)。
|
||||||
|
SHARED_HOME_MCP_FILES = (
|
||||||
|
"playwright_mcp.py",
|
||||||
|
"playwright_browser.py",
|
||||||
|
"playwright_remote.py",
|
||||||
|
"websearch_mcp.py",
|
||||||
|
"websearch.py",
|
||||||
|
"imagegen_mcp.py",
|
||||||
|
"imagegen.py",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _cloud_dir():
|
||||||
|
"""部署代码的 cloud/ 目录:同步源,绝不是租户目录。"""
|
||||||
|
return os.path.dirname(os.path.abspath(__file__))
|
||||||
|
|
||||||
|
|
||||||
|
def _write_atomic(dst, data):
|
||||||
|
"""同目录临时文件 + os.replace 原子替换,避免并发启动的
|
||||||
|
另一控制面实例读到半写文件。"""
|
||||||
|
directory = os.path.dirname(dst) or "."
|
||||||
|
fd, tmp = tempfile.mkstemp(prefix=".mcp-sync-", dir=directory)
|
||||||
|
try:
|
||||||
|
with os.fdopen(fd, "wb") as fh:
|
||||||
|
fh.write(data)
|
||||||
|
os.replace(tmp, dst)
|
||||||
|
except BaseException:
|
||||||
|
try:
|
||||||
|
os.unlink(tmp)
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
raise
|
||||||
|
|
||||||
|
|
||||||
|
def sync_shared_home_mcp(shared_home, source_dir=None):
|
||||||
|
"""把部署版 MCP 脚本同步到共享 home。
|
||||||
|
|
||||||
|
参数:
|
||||||
|
- shared_home:目标目录(测试时传临时目录;生产由服务端
|
||||||
|
的解析函数给出,见 cloud/server.py 的
|
||||||
|
``_resolve_shared_home_mcp``)。
|
||||||
|
- source_dir:源目录,缺省为本模块所在的 cloud/ 目录。
|
||||||
|
|
||||||
|
返回 dict,键固定为 copied / refreshed / unchanged /
|
||||||
|
missing / error,调用方只看它、不捕获异常:
|
||||||
|
- copied:目标缺失、本次拷入的文件;
|
||||||
|
- refreshed:目标存在但内容不同、本次刷新的文件;
|
||||||
|
- unchanged:内容相同、本次未动的文件;
|
||||||
|
- missing:源目录里不存在的文件(跳过,不阻塞其它文件);
|
||||||
|
- error:None 或把缺失/写失败拼成的一行文本,调用方记
|
||||||
|
warning 即可,绝不因此阻塞启动/部署/派发。
|
||||||
|
"""
|
||||||
|
copied = []
|
||||||
|
refreshed = []
|
||||||
|
unchanged = []
|
||||||
|
missing = []
|
||||||
|
errors = []
|
||||||
|
if not shared_home:
|
||||||
|
return {"copied": copied, "refreshed": refreshed,
|
||||||
|
"unchanged": unchanged, "missing": missing,
|
||||||
|
"error": "no shared home resolved"}
|
||||||
|
source = os.path.abspath(source_dir) if source_dir else _cloud_dir()
|
||||||
|
try:
|
||||||
|
os.makedirs(shared_home, exist_ok=True)
|
||||||
|
except Exception as exc: # 目录建不出:只记错,不阻塞
|
||||||
|
return {"copied": copied, "refreshed": refreshed,
|
||||||
|
"unchanged": unchanged, "missing": missing,
|
||||||
|
"error": "cannot create shared home %s: %s"
|
||||||
|
% (shared_home, exc)}
|
||||||
|
for name in SHARED_HOME_MCP_FILES:
|
||||||
|
src = os.path.join(source, name)
|
||||||
|
dst = os.path.join(shared_home, name)
|
||||||
|
try:
|
||||||
|
with open(src, "rb") as fh:
|
||||||
|
data = fh.read()
|
||||||
|
except OSError:
|
||||||
|
missing.append(name)
|
||||||
|
continue
|
||||||
|
except Exception as exc:
|
||||||
|
errors.append("%s: cannot read source: %s" % (name, exc))
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
if os.path.isfile(dst):
|
||||||
|
with open(dst, "rb") as fh:
|
||||||
|
if fh.read() == data:
|
||||||
|
unchanged.append(name)
|
||||||
|
continue
|
||||||
|
_write_atomic(dst, data)
|
||||||
|
refreshed.append(name)
|
||||||
|
elif os.path.exists(dst):
|
||||||
|
# 目标是目录等非文件:不删、不覆盖,只记错。
|
||||||
|
errors.append("%s: destination is not a file" % name)
|
||||||
|
else:
|
||||||
|
_write_atomic(dst, data)
|
||||||
|
copied.append(name)
|
||||||
|
except Exception as exc:
|
||||||
|
errors.append("%s: %s" % (name, exc))
|
||||||
|
continue
|
||||||
|
parts = []
|
||||||
|
if missing:
|
||||||
|
parts.append("missing source files: %s" % ",".join(missing))
|
||||||
|
parts.extend(errors)
|
||||||
|
return {"copied": copied, "refreshed": refreshed,
|
||||||
|
"unchanged": unchanged, "missing": missing,
|
||||||
|
"error": "; ".join(parts) if parts else None}
|
||||||
@@ -0,0 +1,213 @@
|
|||||||
|
"""Tests for shared-home MCP script sync (cloud/shared_home_mcp.py).
|
||||||
|
|
||||||
|
No production path is ever touched here: every sync target is a
|
||||||
|
temp dir, and the server-resolution tests monkeypatch the resolver
|
||||||
|
inputs instead of reading the repo's host-config-host.json (which
|
||||||
|
points at the production shared home).
|
||||||
|
|
||||||
|
Coverage:
|
||||||
|
- the 7-file list is the single source of truth (exact set);
|
||||||
|
- missing files are copied;
|
||||||
|
- differing files are refreshed, same files are untouched
|
||||||
|
(mtime unchanged);
|
||||||
|
- missing source files do not crash and are reported;
|
||||||
|
- an uncreatable target and a per-file unwritable destination
|
||||||
|
(a directory in place of a file, root-proof) do not crash and
|
||||||
|
do not block the other files;
|
||||||
|
- server resolution: local worker home wins, tenants-root
|
||||||
|
parent is the fallback;
|
||||||
|
- server main() actually calls the sync at startup and survives
|
||||||
|
a sync error (fake server, no real port, no real DB).
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import tempfile
|
||||||
|
import unittest
|
||||||
|
from pathlib import Path
|
||||||
|
from unittest import mock
|
||||||
|
|
||||||
|
sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
|
||||||
|
|
||||||
|
from shared_home_mcp import SHARED_HOME_MCP_FILES # noqa: E402
|
||||||
|
from shared_home_mcp import sync_shared_home_mcp # noqa: E402
|
||||||
|
|
||||||
|
try:
|
||||||
|
from cloud.server import _resolve_shared_home_mcp
|
||||||
|
from cloud.server import main as _server_main
|
||||||
|
_SERVER_OK = True
|
||||||
|
except ImportError:
|
||||||
|
sys.path.insert(0, str(Path(__file__).resolve().parents[2]))
|
||||||
|
try:
|
||||||
|
from cloud.server import _resolve_shared_home_mcp
|
||||||
|
from cloud.server import main as _server_main
|
||||||
|
_SERVER_OK = True
|
||||||
|
except ImportError:
|
||||||
|
_resolve_shared_home_mcp = None
|
||||||
|
_server_main = None
|
||||||
|
_SERVER_OK = False
|
||||||
|
|
||||||
|
|
||||||
|
def _write_source(source_dir, contents):
|
||||||
|
for name, data in contents.items():
|
||||||
|
with open(os.path.join(source_dir, name), "wb") as fh:
|
||||||
|
fh.write(data)
|
||||||
|
|
||||||
|
|
||||||
|
class SharedHomeMcpSyncTest(unittest.TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self._tmp = tempfile.TemporaryDirectory()
|
||||||
|
self.source = os.path.join(self._tmp.name, "cloud")
|
||||||
|
self.target = os.path.join(self._tmp.name, "shared-home")
|
||||||
|
os.makedirs(self.source)
|
||||||
|
self.contents = {name: ("# %s v2\n" % name).encode()
|
||||||
|
for name in SHARED_HOME_MCP_FILES}
|
||||||
|
|
||||||
|
def tearDown(self):
|
||||||
|
self._tmp.cleanup()
|
||||||
|
|
||||||
|
def test_file_list_is_exactly_the_seven(self):
|
||||||
|
self.assertEqual(len(SHARED_HOME_MCP_FILES), 7)
|
||||||
|
self.assertEqual(len(set(SHARED_HOME_MCP_FILES)), 7)
|
||||||
|
self.assertEqual(set(SHARED_HOME_MCP_FILES), {
|
||||||
|
"playwright_mcp.py", "playwright_browser.py",
|
||||||
|
"playwright_remote.py",
|
||||||
|
"websearch_mcp.py", "websearch.py",
|
||||||
|
"imagegen_mcp.py", "imagegen.py",
|
||||||
|
})
|
||||||
|
|
||||||
|
def test_missing_files_are_copied(self):
|
||||||
|
_write_source(self.source, self.contents)
|
||||||
|
result = sync_shared_home_mcp(self.target, self.source)
|
||||||
|
self.assertEqual(sorted(result["copied"]),
|
||||||
|
sorted(SHARED_HOME_MCP_FILES))
|
||||||
|
self.assertEqual(result["refreshed"], [])
|
||||||
|
self.assertEqual(result["unchanged"], [])
|
||||||
|
self.assertEqual(result["missing"], [])
|
||||||
|
self.assertIsNone(result["error"])
|
||||||
|
for name, data in self.contents.items():
|
||||||
|
with open(os.path.join(self.target, name), "rb") as fh:
|
||||||
|
self.assertEqual(fh.read(), data)
|
||||||
|
|
||||||
|
def test_differing_files_are_refreshed(self):
|
||||||
|
_write_source(self.source, self.contents)
|
||||||
|
os.makedirs(self.target)
|
||||||
|
for name in SHARED_HOME_MCP_FILES:
|
||||||
|
with open(os.path.join(self.target, name), "wb") as fh:
|
||||||
|
fh.write(b"# old\n")
|
||||||
|
result = sync_shared_home_mcp(self.target, self.source)
|
||||||
|
self.assertEqual(sorted(result["refreshed"]),
|
||||||
|
sorted(SHARED_HOME_MCP_FILES))
|
||||||
|
self.assertEqual(result["copied"], [])
|
||||||
|
self.assertIsNone(result["error"])
|
||||||
|
|
||||||
|
def test_same_files_are_untouched(self):
|
||||||
|
_write_source(self.source, self.contents)
|
||||||
|
os.makedirs(self.target)
|
||||||
|
mtimes = {}
|
||||||
|
for name, data in self.contents.items():
|
||||||
|
path = os.path.join(self.target, name)
|
||||||
|
with open(path, "wb") as fh:
|
||||||
|
fh.write(data)
|
||||||
|
os.utime(path, (1000000000, 1000000000))
|
||||||
|
mtimes[name] = os.stat(path).st_mtime
|
||||||
|
result = sync_shared_home_mcp(self.target, self.source)
|
||||||
|
self.assertEqual(sorted(result["unchanged"]),
|
||||||
|
sorted(SHARED_HOME_MCP_FILES))
|
||||||
|
self.assertEqual(result["copied"], [])
|
||||||
|
self.assertEqual(result["refreshed"], [])
|
||||||
|
for name, mtime in mtimes.items():
|
||||||
|
self.assertEqual(os.stat(os.path.join(self.target, name)).st_mtime,
|
||||||
|
mtime)
|
||||||
|
|
||||||
|
def test_missing_source_does_not_crash(self):
|
||||||
|
# Source dir exists but is empty: every file is missing.
|
||||||
|
result = sync_shared_home_mcp(self.target, self.source)
|
||||||
|
self.assertEqual(sorted(result["missing"]),
|
||||||
|
sorted(SHARED_HOME_MCP_FILES))
|
||||||
|
self.assertEqual(result["copied"], [])
|
||||||
|
self.assertIsNotNone(result["error"])
|
||||||
|
self.assertIn("missing source", result["error"])
|
||||||
|
|
||||||
|
def test_uncreatable_target_does_not_crash(self):
|
||||||
|
_write_source(self.source, self.contents)
|
||||||
|
# Target path is an existing file, not a directory:
|
||||||
|
# makedirs fails even as root, and must not raise.
|
||||||
|
blocker = os.path.join(self._tmp.name, "blocker")
|
||||||
|
with open(blocker, "wb") as fh:
|
||||||
|
fh.write(b"x")
|
||||||
|
result = sync_shared_home_mcp(blocker, self.source)
|
||||||
|
self.assertEqual(result["copied"], [])
|
||||||
|
self.assertIsNotNone(result["error"])
|
||||||
|
|
||||||
|
def test_one_unwritable_file_does_not_block_others(self):
|
||||||
|
_write_source(self.source, self.contents)
|
||||||
|
os.makedirs(self.target)
|
||||||
|
bad = SHARED_HOME_MCP_FILES[0]
|
||||||
|
os.makedirs(os.path.join(self.target, bad))
|
||||||
|
result = sync_shared_home_mcp(self.target, self.source)
|
||||||
|
self.assertEqual(len(result["copied"]),
|
||||||
|
len(SHARED_HOME_MCP_FILES) - 1)
|
||||||
|
self.assertIn(bad, result["error"])
|
||||||
|
for name in SHARED_HOME_MCP_FILES[1:]:
|
||||||
|
self.assertTrue(os.path.isfile(os.path.join(self.target, name)))
|
||||||
|
|
||||||
|
def test_no_shared_home_does_not_crash(self):
|
||||||
|
result = sync_shared_home_mcp(None, self.source)
|
||||||
|
self.assertIsNotNone(result["error"])
|
||||||
|
self.assertEqual(result["copied"], [])
|
||||||
|
|
||||||
|
|
||||||
|
@unittest.skipUnless(_SERVER_OK, "cloud.server import unavailable")
|
||||||
|
class SharedHomeMcpServerTest(unittest.TestCase):
|
||||||
|
def test_local_worker_home_wins(self):
|
||||||
|
import cloud.server as server_mod
|
||||||
|
with mock.patch.object(server_mod, "_local_worker_codex_home",
|
||||||
|
return_value="/tmp/fake-shared"):
|
||||||
|
self.assertEqual(_resolve_shared_home_mcp(object()),
|
||||||
|
"/tmp/fake-shared")
|
||||||
|
|
||||||
|
def test_tenants_root_parent_is_fallback(self):
|
||||||
|
import cloud.server as server_mod
|
||||||
|
|
||||||
|
class _FakeServer:
|
||||||
|
tenants_root = "/tmp/fake-root/tenants"
|
||||||
|
|
||||||
|
with mock.patch.object(server_mod, "_local_worker_codex_home",
|
||||||
|
return_value=None):
|
||||||
|
self.assertEqual(_resolve_shared_home_mcp(_FakeServer()),
|
||||||
|
"/tmp/fake-root/codex-home")
|
||||||
|
|
||||||
|
def test_main_calls_sync_and_survives_sync_error(self):
|
||||||
|
import cloud.server as server_mod
|
||||||
|
|
||||||
|
class _FakeServer:
|
||||||
|
rate_limiter = None
|
||||||
|
server_address = ("127.0.0.1", 0)
|
||||||
|
|
||||||
|
def serve_forever(self):
|
||||||
|
raise KeyboardInterrupt
|
||||||
|
|
||||||
|
calls = {}
|
||||||
|
fake = _FakeServer()
|
||||||
|
|
||||||
|
def _fake_sync(target):
|
||||||
|
calls["target"] = target
|
||||||
|
return {"copied": [], "refreshed": [], "unchanged": [],
|
||||||
|
"missing": [], "error": "boom (test)"}
|
||||||
|
|
||||||
|
with mock.patch.object(server_mod, "create_server",
|
||||||
|
return_value=fake), \
|
||||||
|
mock.patch.object(server_mod, "_resolve_shared_home_mcp",
|
||||||
|
return_value="/tmp/fake-shared"), \
|
||||||
|
mock.patch.object(server_mod, "_sync_shared_home_mcp",
|
||||||
|
side_effect=_fake_sync), \
|
||||||
|
mock.patch.object(server_mod, "start_rate_limit_sweeper"):
|
||||||
|
# No exception: sync error is warning-only, and the
|
||||||
|
# KeyboardInterrupt from serve_forever ends main().
|
||||||
|
_server_main(["--db", "/tmp/fake.db", "--port", "0"])
|
||||||
|
self.assertEqual(calls.get("target"), "/tmp/fake-shared")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
Reference in new issue
Block a user