源码快照 @ 2026-09-01 (source-only, 剔除非源码素材)
Creator App Tests / PR Spam Gate (push) Skipped
Frontend Tests / PR Spam Gate (push) Skipped
NPM Format (website & console) / PR Spam Gate (push) Skipped
Pre-commit Checks / PR Spam Gate (push) Skipped
QwenPaw Creator Plugin Release / check-version (push) Canceled after 0s
QwenPaw Creator Plugin Release / upload-oss (push) Canceled after 0s
Tests / PR Spam Gate (push) Skipped
CodeQL / CodeQL (python) (push) Canceled after 0s
CodeQL / CodeQL (typescript) (push) Canceled after 0s
Creator App Tests / Creator Backend Tests (push) Canceled after 0s
Creator App Tests / Creator UI Build & Tests (push) Canceled after 0s
E2E Smoke Tests / Playwright UI Smoke (push) Canceled after 0s
Frontend Tests / Detect console changes (push) Canceled after 0s
NPM Format (website & console) / website format check (push) Canceled after 0s
NPM Format (website & console) / console format check (push) Canceled after 0s
Pre-commit Checks / Pre-commit (push) Canceled after 0s
QwenPaw Data Plugin Release / check-version (push) Canceled after 0s
Tests / Detect code changes (push) Canceled after 0s
Frontend Tests / Vitest Unit Tests (push) Canceled after 0s
Frontend Tests / Frontend Summary (push) Canceled after 0s
QwenPaw Data Plugin Release / upload-oss (push) Canceled after 0s
Tests / Maintainer Approval (push) Canceled after 0s
Tests / Unit Tests - py3.11 - macos-latest (push) Canceled after 0s
Tests / Unit Tests - py3.11 - ubuntu-latest (push) Canceled after 0s
Tests / Unit Tests - py3.13 - ubuntu-latest (push) Canceled after 0s
Tests / Unit Tests - py3.11 - windows-latest (push) Canceled after 0s
Tests / Contract Tests - py3.11 - macos-latest (push) Canceled after 0s
Tests / Contract Tests - py3.11 - ubuntu-latest (push) Canceled after 0s
Tests / Contract Tests - py3.13 - ubuntu-latest (push) Canceled after 0s
Tests / Contract Tests - py3.11 - windows-latest (push) Canceled after 0s
Tests / Integrated Tests - py3.11 - macos-latest - p0 (push) Canceled after 0s
Tests / Integrated Tests - py3.11 - macos-latest - p1 (push) Canceled after 0s
Tests / Integrated Tests - py3.11 - macos-latest - p2 (push) Canceled after 0s
Tests / Integrated Tests - py3.11 - ubuntu-latest - fallback (push) Canceled after 0s
Tests / Integrated Tests - py3.11 - ubuntu-latest - p0 (push) Canceled after 0s
Tests / Integrated Tests - py3.11 - ubuntu-latest - p1 (push) Canceled after 0s
Tests / Integrated Tests - py3.11 - ubuntu-latest - p2 (push) Canceled after 0s
Tests / Integrated Tests - py3.13 - ubuntu-latest - p0 (push) Canceled after 0s
Tests / Integrated Tests - py3.13 - ubuntu-latest - p1 (push) Canceled after 0s
Tests / Integrated Tests - py3.13 - ubuntu-latest - p2 (push) Canceled after 0s
Tests / Integrated Tests - py3.11 - windows-latest - p0 (push) Canceled after 0s
Tests / Integrated Tests - py3.11 - windows-latest - p1 (push) Canceled after 0s
Tests / Integrated Tests - py3.11 - windows-latest - p2 (push) Canceled after 0s
Tests / Coverage Report (push) Canceled after 0s
Tests / Test Summary (push) Canceled after 0s
Creator App Tests / PR Spam Gate (push) Skipped
Frontend Tests / PR Spam Gate (push) Skipped
NPM Format (website & console) / PR Spam Gate (push) Skipped
Pre-commit Checks / PR Spam Gate (push) Skipped
QwenPaw Creator Plugin Release / check-version (push) Canceled after 0s
QwenPaw Creator Plugin Release / upload-oss (push) Canceled after 0s
Tests / PR Spam Gate (push) Skipped
CodeQL / CodeQL (python) (push) Canceled after 0s
CodeQL / CodeQL (typescript) (push) Canceled after 0s
Creator App Tests / Creator Backend Tests (push) Canceled after 0s
Creator App Tests / Creator UI Build & Tests (push) Canceled after 0s
E2E Smoke Tests / Playwright UI Smoke (push) Canceled after 0s
Frontend Tests / Detect console changes (push) Canceled after 0s
NPM Format (website & console) / website format check (push) Canceled after 0s
NPM Format (website & console) / console format check (push) Canceled after 0s
Pre-commit Checks / Pre-commit (push) Canceled after 0s
QwenPaw Data Plugin Release / check-version (push) Canceled after 0s
Tests / Detect code changes (push) Canceled after 0s
Frontend Tests / Vitest Unit Tests (push) Canceled after 0s
Frontend Tests / Frontend Summary (push) Canceled after 0s
QwenPaw Data Plugin Release / upload-oss (push) Canceled after 0s
Tests / Maintainer Approval (push) Canceled after 0s
Tests / Unit Tests - py3.11 - macos-latest (push) Canceled after 0s
Tests / Unit Tests - py3.11 - ubuntu-latest (push) Canceled after 0s
Tests / Unit Tests - py3.13 - ubuntu-latest (push) Canceled after 0s
Tests / Unit Tests - py3.11 - windows-latest (push) Canceled after 0s
Tests / Contract Tests - py3.11 - macos-latest (push) Canceled after 0s
Tests / Contract Tests - py3.11 - ubuntu-latest (push) Canceled after 0s
Tests / Contract Tests - py3.13 - ubuntu-latest (push) Canceled after 0s
Tests / Contract Tests - py3.11 - windows-latest (push) Canceled after 0s
Tests / Integrated Tests - py3.11 - macos-latest - p0 (push) Canceled after 0s
Tests / Integrated Tests - py3.11 - macos-latest - p1 (push) Canceled after 0s
Tests / Integrated Tests - py3.11 - macos-latest - p2 (push) Canceled after 0s
Tests / Integrated Tests - py3.11 - ubuntu-latest - fallback (push) Canceled after 0s
Tests / Integrated Tests - py3.11 - ubuntu-latest - p0 (push) Canceled after 0s
Tests / Integrated Tests - py3.11 - ubuntu-latest - p1 (push) Canceled after 0s
Tests / Integrated Tests - py3.11 - ubuntu-latest - p2 (push) Canceled after 0s
Tests / Integrated Tests - py3.13 - ubuntu-latest - p0 (push) Canceled after 0s
Tests / Integrated Tests - py3.13 - ubuntu-latest - p1 (push) Canceled after 0s
Tests / Integrated Tests - py3.13 - ubuntu-latest - p2 (push) Canceled after 0s
Tests / Integrated Tests - py3.11 - windows-latest - p0 (push) Canceled after 0s
Tests / Integrated Tests - py3.11 - windows-latest - p1 (push) Canceled after 0s
Tests / Integrated Tests - py3.11 - windows-latest - p2 (push) Canceled after 0s
Tests / Coverage Report (push) Canceled after 0s
Tests / Test Summary (push) Canceled after 0s
This commit is contained in:
@@ -0,0 +1,951 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
from click.testing import CliRunner
|
||||
|
||||
from qwenpaw.__version__ import __version__
|
||||
from qwenpaw.cli.main import cli
|
||||
from qwenpaw.cli.update_cmd import (
|
||||
InstallInfo,
|
||||
RunningServiceInfo,
|
||||
_build_upgrade_command,
|
||||
_detect_running_service,
|
||||
_detect_installation,
|
||||
_is_newer_version,
|
||||
_probe_service,
|
||||
_detect_source_type,
|
||||
_run_update_worker_detached,
|
||||
_run_update_worker_foreground,
|
||||
_select_latest_version,
|
||||
run_update_worker,
|
||||
)
|
||||
|
||||
|
||||
def _install_info(
|
||||
*,
|
||||
source_type: str = "pypi",
|
||||
installer: str = "pip",
|
||||
) -> InstallInfo:
|
||||
return InstallInfo(
|
||||
package_dir="/tmp/site-packages/qwenpaw",
|
||||
python_executable="/tmp/venv/bin/python",
|
||||
environment_root="/tmp/venv",
|
||||
environment_kind="virtualenv",
|
||||
installer=installer,
|
||||
source_type=source_type,
|
||||
source_url=None,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("latest", "current", "expected"),
|
||||
[
|
||||
("1.2.4", "1.2.3", True),
|
||||
("1.2.3", "1.2.3", False),
|
||||
("1.2.2", "1.2.3", False),
|
||||
("1.2.3", "1.2.3rc1", True),
|
||||
("1.2.3rc1", "1.2.3", False),
|
||||
("main", "main", False),
|
||||
("main", "feature", None),
|
||||
("main", "1.2.3", None),
|
||||
],
|
||||
)
|
||||
def test_is_newer_version(
|
||||
latest: str,
|
||||
current: str,
|
||||
expected: bool | None,
|
||||
) -> None:
|
||||
assert _is_newer_version(latest, current) is expected
|
||||
|
||||
|
||||
def test_select_latest_version_prefers_stable_by_default() -> None:
|
||||
data = {
|
||||
"info": {"version": "2.0.0b1"},
|
||||
"releases": {
|
||||
"1.9.0": [{"url": "https://example.com/qwenpaw-1.9.0.tar.gz"}],
|
||||
"2.0.0b1": [{"url": "https://example.com/qwenpaw-2.0.0b1.tar.gz"}],
|
||||
},
|
||||
}
|
||||
|
||||
assert _select_latest_version(data, include_prerelease=False) == "1.9.0"
|
||||
assert _select_latest_version(data, include_prerelease=True) == "2.0.0b1"
|
||||
|
||||
|
||||
def test_build_upgrade_command_adds_prerelease_flag_for_uv_only_when_requested(
|
||||
monkeypatch,
|
||||
) -> None:
|
||||
monkeypatch.setattr(
|
||||
"qwenpaw.cli.update_cmd.shutil.which",
|
||||
lambda name: "/usr/local/bin/uv" if name == "uv" else None,
|
||||
)
|
||||
info = _install_info(installer="uv")
|
||||
|
||||
stable_command, label = _build_upgrade_command(
|
||||
info,
|
||||
"1.9.0",
|
||||
include_prerelease=False,
|
||||
)
|
||||
prerelease_command, _ = _build_upgrade_command(
|
||||
info,
|
||||
"2.0.0b1",
|
||||
include_prerelease=True,
|
||||
)
|
||||
|
||||
assert label == "uv pip"
|
||||
assert "--prerelease=allow" not in stable_command
|
||||
assert prerelease_command[-1] == "--prerelease=allow"
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("direct_url", "expected"),
|
||||
[
|
||||
(None, ("pypi", None)),
|
||||
(
|
||||
{
|
||||
"url": "file:///Users/test/QwenPaw",
|
||||
"dir_info": {"editable": True},
|
||||
},
|
||||
("editable", "file:///Users/test/QwenPaw"),
|
||||
),
|
||||
(
|
||||
{
|
||||
"url": "https://github.com/agentscope-ai/QwenPaw.git",
|
||||
"vcs_info": {"vcs": "git", "commit_id": "abc123"},
|
||||
},
|
||||
("vcs", "https://github.com/agentscope-ai/QwenPaw.git"),
|
||||
),
|
||||
(
|
||||
{"url": "file:///tmp/qwenpaw.whl"},
|
||||
("local", "file:///tmp/qwenpaw.whl"),
|
||||
),
|
||||
(
|
||||
{"url": "https://example.com/qwenpaw.whl"},
|
||||
("direct-url", "https://example.com/qwenpaw.whl"),
|
||||
),
|
||||
],
|
||||
)
|
||||
def test_detect_source_type(
|
||||
direct_url: dict[str, object] | None,
|
||||
expected: tuple[str, str | None],
|
||||
) -> None:
|
||||
assert _detect_source_type(direct_url) == expected
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
(
|
||||
"installer_text",
|
||||
"direct_url_text",
|
||||
"expected_installer",
|
||||
"expected_source_type",
|
||||
"expected_source_url",
|
||||
),
|
||||
[
|
||||
(None, None, "pip", "pypi", None),
|
||||
(
|
||||
"uv\n",
|
||||
json.dumps(
|
||||
{
|
||||
"url": "file:///Users/test/QwenPaw",
|
||||
"dir_info": {"editable": True},
|
||||
},
|
||||
),
|
||||
"uv",
|
||||
"editable",
|
||||
"file:///Users/test/QwenPaw",
|
||||
),
|
||||
],
|
||||
)
|
||||
def test_detect_installation(
|
||||
monkeypatch,
|
||||
installer_text: str | None,
|
||||
direct_url_text: str | None,
|
||||
expected_installer: str,
|
||||
expected_source_type: str,
|
||||
expected_source_url: str | None,
|
||||
) -> None:
|
||||
from qwenpaw.cli import update_cmd as update_cmd_module
|
||||
|
||||
class _FakeDistribution:
|
||||
def read_text(self, name: str) -> str | None:
|
||||
mapping = {
|
||||
"INSTALLER": installer_text,
|
||||
"direct_url.json": direct_url_text,
|
||||
}
|
||||
return mapping.get(name)
|
||||
|
||||
expected_python_executable = "/tmp/test-venv/bin/python"
|
||||
expected_environment_root = str(Path("/tmp/test-venv").resolve())
|
||||
expected_package_dir = str(
|
||||
Path(update_cmd_module.__file__).resolve().parent.parent,
|
||||
)
|
||||
|
||||
monkeypatch.setattr(
|
||||
update_cmd_module.metadata,
|
||||
"distribution",
|
||||
lambda name: _FakeDistribution(),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
update_cmd_module.sys,
|
||||
"executable",
|
||||
"/tmp/test-venv/bin/python",
|
||||
)
|
||||
monkeypatch.setattr(update_cmd_module.sys, "prefix", "/tmp/test-venv")
|
||||
monkeypatch.setattr(update_cmd_module.sys, "base_prefix", "/usr/local")
|
||||
|
||||
result = _detect_installation()
|
||||
|
||||
assert result.installer == expected_installer
|
||||
assert result.source_type == expected_source_type
|
||||
assert result.source_url == expected_source_url
|
||||
assert result.python_executable == expected_python_executable
|
||||
assert result.environment_root == expected_environment_root
|
||||
assert result.environment_kind == "virtualenv"
|
||||
assert result.package_dir == expected_package_dir
|
||||
|
||||
|
||||
def test_update_reports_up_to_date(monkeypatch) -> None:
|
||||
from qwenpaw.cli import update_cmd as update_cmd_module
|
||||
|
||||
install_info = _install_info()
|
||||
|
||||
def _detect_installation() -> InstallInfo:
|
||||
return install_info
|
||||
|
||||
def _fetch_latest_version(**_: object) -> str:
|
||||
return __version__
|
||||
|
||||
monkeypatch.setattr(
|
||||
update_cmd_module,
|
||||
"_detect_installation",
|
||||
_detect_installation,
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
update_cmd_module,
|
||||
"_fetch_latest_version",
|
||||
_fetch_latest_version,
|
||||
)
|
||||
|
||||
result = CliRunner().invoke(cli, ["update", "--yes"])
|
||||
|
||||
assert result.exit_code == 0
|
||||
assert "QwenPaw is already up to date." in result.output
|
||||
|
||||
|
||||
def test_probe_service_ignores_proxy_env(monkeypatch) -> None:
|
||||
captured: dict[str, object] = {}
|
||||
|
||||
class _Response:
|
||||
def raise_for_status(self) -> None:
|
||||
return None
|
||||
|
||||
def json(self) -> dict[str, str]:
|
||||
return {"version": "1.2.3"}
|
||||
|
||||
def _fake_get(url: str, **kwargs):
|
||||
captured["url"] = url
|
||||
captured.update(kwargs)
|
||||
return _Response()
|
||||
|
||||
monkeypatch.setattr("qwenpaw.cli.update_cmd.httpx.get", _fake_get)
|
||||
|
||||
result = _probe_service("http://127.0.0.1:8088")
|
||||
|
||||
assert result.is_running is True
|
||||
assert result.base_url == "http://127.0.0.1:8088"
|
||||
assert result.version == "1.2.3"
|
||||
assert captured["trust_env"] is False
|
||||
|
||||
|
||||
def test_probe_service_returns_not_running_on_http_error(monkeypatch) -> None:
|
||||
def _fake_get(_url: str, **_kwargs):
|
||||
raise httpx.HTTPError("bad gateway")
|
||||
|
||||
monkeypatch.setattr("qwenpaw.cli.update_cmd.httpx.get", _fake_get)
|
||||
|
||||
result = _probe_service("http://127.0.0.1:8088")
|
||||
|
||||
assert result.is_running is False
|
||||
|
||||
|
||||
def test_detect_running_service_handles_wildcard_host(monkeypatch) -> None:
|
||||
from qwenpaw.cli import update_cmd as update_cmd_module
|
||||
|
||||
monkeypatch.setattr(update_cmd_module, "read_last_api", lambda: None)
|
||||
monkeypatch.setattr(
|
||||
update_cmd_module,
|
||||
"_probe_service",
|
||||
lambda base_url: RunningServiceInfo(
|
||||
is_running=base_url == "http://127.0.0.1:9090",
|
||||
base_url=base_url if base_url == "http://127.0.0.1:9090" else None,
|
||||
),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
update_cmd_module,
|
||||
"_detect_running_service_from_processes",
|
||||
lambda _hosts: RunningServiceInfo(is_running=False),
|
||||
)
|
||||
|
||||
result = _detect_running_service("0.0.0.0", 9090)
|
||||
|
||||
assert result.is_running is True
|
||||
assert result.base_url == "http://127.0.0.1:9090"
|
||||
|
||||
|
||||
def test_detect_running_service_falls_back_to_process_ports(
|
||||
monkeypatch,
|
||||
) -> None:
|
||||
from qwenpaw.cli import update_cmd as update_cmd_module
|
||||
|
||||
monkeypatch.setattr(update_cmd_module, "read_last_api", lambda: None)
|
||||
monkeypatch.setattr(
|
||||
update_cmd_module,
|
||||
"_probe_service",
|
||||
lambda _base_url: RunningServiceInfo(is_running=False),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
update_cmd_module,
|
||||
"_detect_running_service_from_processes",
|
||||
lambda hosts: RunningServiceInfo(
|
||||
is_running=True,
|
||||
base_url=f"http://{hosts[0]}:8088",
|
||||
),
|
||||
)
|
||||
|
||||
result = _detect_running_service(None, None)
|
||||
|
||||
assert result.is_running is True
|
||||
assert result.base_url == "http://127.0.0.1:8088"
|
||||
|
||||
|
||||
def test_update_blocks_running_service(monkeypatch) -> None:
|
||||
from qwenpaw.cli import update_cmd as update_cmd_module
|
||||
|
||||
install_info = _install_info()
|
||||
|
||||
def _detect_installation() -> InstallInfo:
|
||||
return install_info
|
||||
|
||||
def _fetch_latest_version(**_: object) -> str:
|
||||
return "9.9.9"
|
||||
|
||||
monkeypatch.setattr(
|
||||
update_cmd_module,
|
||||
"_detect_installation",
|
||||
_detect_installation,
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
update_cmd_module,
|
||||
"_fetch_latest_version",
|
||||
_fetch_latest_version,
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
update_cmd_module,
|
||||
"_detect_running_service",
|
||||
lambda host, port: RunningServiceInfo(
|
||||
is_running=True,
|
||||
base_url="http://127.0.0.1:8088",
|
||||
version=__version__,
|
||||
),
|
||||
)
|
||||
|
||||
result = CliRunner().invoke(cli, ["update", "--yes"])
|
||||
|
||||
assert result.exit_code != 0
|
||||
assert "Please stop it before running `qwenpaw update`" in result.output
|
||||
assert (
|
||||
"without `--yes` to confirm a forced `qwenpaw shutdown`"
|
||||
in result.output
|
||||
)
|
||||
|
||||
|
||||
def test_update_can_cancel_forced_shutdown(monkeypatch) -> None:
|
||||
from qwenpaw.cli import update_cmd as update_cmd_module
|
||||
|
||||
install_info = _install_info()
|
||||
|
||||
monkeypatch.setattr(
|
||||
update_cmd_module,
|
||||
"_detect_installation",
|
||||
lambda: install_info,
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
update_cmd_module,
|
||||
"_fetch_latest_version",
|
||||
lambda **_: "9.9.9",
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
update_cmd_module,
|
||||
"_detect_running_service",
|
||||
lambda host, port: RunningServiceInfo(
|
||||
is_running=True,
|
||||
base_url="http://127.0.0.1:8088",
|
||||
version=__version__,
|
||||
),
|
||||
)
|
||||
|
||||
result = CliRunner().invoke(cli, ["update"], input="n\n")
|
||||
|
||||
assert result.exit_code == 0
|
||||
assert (
|
||||
"forcibly terminate the current QwenPaw backend/frontend "
|
||||
"processes" in result.output
|
||||
)
|
||||
assert (
|
||||
"Run `qwenpaw shutdown` now and continue with the update?"
|
||||
in result.output
|
||||
)
|
||||
assert "Cancelled." in result.output
|
||||
|
||||
|
||||
def test_update_can_force_shutdown_running_service(
|
||||
monkeypatch,
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
from qwenpaw.cli import update_cmd as update_cmd_module
|
||||
|
||||
install_info = _install_info()
|
||||
spawned: dict[str, object] = {}
|
||||
service_checks = iter(
|
||||
[
|
||||
RunningServiceInfo(
|
||||
is_running=True,
|
||||
base_url="http://127.0.0.1:8088",
|
||||
version=__version__,
|
||||
),
|
||||
RunningServiceInfo(is_running=False),
|
||||
],
|
||||
)
|
||||
|
||||
monkeypatch.setattr(update_cmd_module, "WORKING_DIR", tmp_path)
|
||||
monkeypatch.setattr(update_cmd_module.sys, "platform", "darwin")
|
||||
monkeypatch.setattr(
|
||||
update_cmd_module,
|
||||
"_detect_installation",
|
||||
lambda: install_info,
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
update_cmd_module,
|
||||
"_fetch_latest_version",
|
||||
lambda **_: "9.9.9",
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
update_cmd_module,
|
||||
"_detect_running_service",
|
||||
lambda host, port: next(service_checks),
|
||||
)
|
||||
|
||||
def _fake_shutdown(
|
||||
command,
|
||||
stdout,
|
||||
stderr,
|
||||
text,
|
||||
encoding,
|
||||
errors,
|
||||
check,
|
||||
):
|
||||
del stdout, stderr, text, encoding, errors, check
|
||||
assert command == [
|
||||
"/tmp/venv/bin/python",
|
||||
"-m",
|
||||
"qwenpaw",
|
||||
"--port",
|
||||
"8088",
|
||||
"shutdown",
|
||||
]
|
||||
|
||||
class _Result:
|
||||
returncode = 0
|
||||
stdout = "Stopped QwenPaw processes: 1234\n"
|
||||
|
||||
return _Result()
|
||||
|
||||
def _fake_run_worker(plan_path: Path) -> int:
|
||||
spawned["path"] = plan_path
|
||||
spawned["plan"] = json.loads(plan_path.read_text(encoding="utf-8"))
|
||||
return 0
|
||||
|
||||
monkeypatch.setattr(update_cmd_module.subprocess, "run", _fake_shutdown)
|
||||
monkeypatch.setattr(
|
||||
update_cmd_module,
|
||||
"_run_update_worker_foreground",
|
||||
_fake_run_worker,
|
||||
)
|
||||
|
||||
result = CliRunner().invoke(cli, ["update"], input="y\ny\n")
|
||||
|
||||
assert result.exit_code == 0
|
||||
assert "Running `qwenpaw shutdown` before updating..." in result.output
|
||||
assert "Stopped QwenPaw processes: 1234" in result.output
|
||||
assert "Starting QwenPaw update..." in result.output
|
||||
assert isinstance(spawned["path"], Path)
|
||||
|
||||
|
||||
def test_update_can_cancel_non_pypi_override(monkeypatch) -> None:
|
||||
from qwenpaw.cli import update_cmd as update_cmd_module
|
||||
|
||||
install_info = _install_info(source_type="editable")
|
||||
|
||||
def _detect_installation() -> InstallInfo:
|
||||
return install_info
|
||||
|
||||
def _fetch_latest_version(**_: object) -> str:
|
||||
return "9.9.9"
|
||||
|
||||
monkeypatch.setattr(
|
||||
update_cmd_module,
|
||||
"_detect_installation",
|
||||
_detect_installation,
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
update_cmd_module,
|
||||
"_fetch_latest_version",
|
||||
_fetch_latest_version,
|
||||
)
|
||||
|
||||
result = CliRunner().invoke(cli, ["update"], input="n\n")
|
||||
|
||||
assert result.exit_code == 0
|
||||
assert "Detected a non-PyPI installation source: editable" in result.output
|
||||
assert "Continue and replace the current installation" in result.output
|
||||
assert "Cancelled." in result.output
|
||||
|
||||
|
||||
def test_update_can_override_non_pypi_install_with_yes(
|
||||
monkeypatch,
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
from qwenpaw.cli import update_cmd as update_cmd_module
|
||||
|
||||
spawned: dict[str, object] = {}
|
||||
install_info = _install_info(source_type="editable")
|
||||
|
||||
def _detect_installation() -> InstallInfo:
|
||||
return install_info
|
||||
|
||||
def _fetch_latest_version(**_: object) -> str:
|
||||
return "9.9.9"
|
||||
|
||||
def _detect_running_service(
|
||||
host: str | None,
|
||||
port: int | None,
|
||||
) -> RunningServiceInfo:
|
||||
del host, port
|
||||
return RunningServiceInfo(is_running=False)
|
||||
|
||||
monkeypatch.setattr(update_cmd_module, "WORKING_DIR", tmp_path)
|
||||
monkeypatch.setattr(update_cmd_module.sys, "platform", "darwin")
|
||||
monkeypatch.setattr(
|
||||
update_cmd_module,
|
||||
"_detect_installation",
|
||||
_detect_installation,
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
update_cmd_module,
|
||||
"_fetch_latest_version",
|
||||
_fetch_latest_version,
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
update_cmd_module,
|
||||
"_detect_running_service",
|
||||
_detect_running_service,
|
||||
)
|
||||
|
||||
def _fake_run_worker(plan_path: Path) -> int:
|
||||
spawned["path"] = plan_path
|
||||
spawned["plan"] = json.loads(plan_path.read_text(encoding="utf-8"))
|
||||
return 0
|
||||
|
||||
monkeypatch.setattr(
|
||||
update_cmd_module,
|
||||
"_run_update_worker_foreground",
|
||||
_fake_run_worker,
|
||||
)
|
||||
|
||||
result = CliRunner().invoke(cli, ["update", "--yes"])
|
||||
|
||||
assert result.exit_code == 0
|
||||
assert "Proceeding because `--yes` was provided." in result.output
|
||||
assert "Starting QwenPaw update..." in result.output
|
||||
assert isinstance(spawned["path"], Path)
|
||||
|
||||
|
||||
def test_update_spawns_worker(monkeypatch, tmp_path: Path) -> None:
|
||||
from qwenpaw.cli import update_cmd as update_cmd_module
|
||||
|
||||
spawned: dict[str, object] = {}
|
||||
install_info = _install_info()
|
||||
|
||||
def _detect_installation() -> InstallInfo:
|
||||
return install_info
|
||||
|
||||
def _fetch_latest_version(**_: object) -> str:
|
||||
return "9.9.9"
|
||||
|
||||
def _detect_running_service(
|
||||
host: str | None,
|
||||
port: int | None,
|
||||
) -> RunningServiceInfo:
|
||||
del host, port
|
||||
return RunningServiceInfo(is_running=False)
|
||||
|
||||
monkeypatch.setattr(update_cmd_module, "WORKING_DIR", tmp_path)
|
||||
monkeypatch.setattr(update_cmd_module.sys, "platform", "darwin")
|
||||
monkeypatch.setattr(
|
||||
update_cmd_module,
|
||||
"_detect_installation",
|
||||
_detect_installation,
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
update_cmd_module,
|
||||
"_fetch_latest_version",
|
||||
_fetch_latest_version,
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
update_cmd_module,
|
||||
"_detect_running_service",
|
||||
_detect_running_service,
|
||||
)
|
||||
|
||||
def _fake_run_worker(plan_path: Path) -> int:
|
||||
spawned["path"] = plan_path
|
||||
spawned["plan"] = json.loads(plan_path.read_text(encoding="utf-8"))
|
||||
return 0
|
||||
|
||||
monkeypatch.setattr(
|
||||
update_cmd_module,
|
||||
"_run_update_worker_foreground",
|
||||
_fake_run_worker,
|
||||
)
|
||||
|
||||
result = CliRunner().invoke(cli, ["update", "--yes"])
|
||||
|
||||
assert result.exit_code == 0
|
||||
assert "Starting QwenPaw update..." in result.output
|
||||
assert isinstance(spawned["path"], Path)
|
||||
plan = spawned["plan"]
|
||||
assert plan["latest_version"] == "9.9.9" # type: ignore [index]
|
||||
assert plan["installer_label"] == "pip" # type: ignore [index]
|
||||
assert plan["command"][:5] == [ # type: ignore [index]
|
||||
"/tmp/venv/bin/python",
|
||||
"-m",
|
||||
"pip",
|
||||
"install",
|
||||
"--upgrade",
|
||||
]
|
||||
|
||||
|
||||
def test_update_prompts_when_version_is_not_comparable(
|
||||
monkeypatch,
|
||||
) -> None:
|
||||
from qwenpaw.cli import update_cmd as update_cmd_module
|
||||
|
||||
install_info = _install_info()
|
||||
|
||||
def _detect_installation() -> InstallInfo:
|
||||
return install_info
|
||||
|
||||
def _fetch_latest_version(**_: object) -> str:
|
||||
return "main"
|
||||
|
||||
monkeypatch.setattr(
|
||||
update_cmd_module,
|
||||
"_detect_installation",
|
||||
_detect_installation,
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
update_cmd_module,
|
||||
"_fetch_latest_version",
|
||||
_fetch_latest_version,
|
||||
)
|
||||
|
||||
result = CliRunner().invoke(cli, ["update"], input="n\n")
|
||||
|
||||
assert result.exit_code == 0
|
||||
assert "Unable to compare the current version" in result.output
|
||||
assert "Cancelled." in result.output
|
||||
|
||||
|
||||
def test_update_can_continue_when_version_is_not_comparable(
|
||||
monkeypatch,
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
from qwenpaw.cli import update_cmd as update_cmd_module
|
||||
|
||||
spawned: dict[str, object] = {}
|
||||
install_info = _install_info()
|
||||
|
||||
def _detect_installation() -> InstallInfo:
|
||||
return install_info
|
||||
|
||||
def _fetch_latest_version(**_: object) -> str:
|
||||
return "main"
|
||||
|
||||
def _detect_running_service(
|
||||
host: str | None,
|
||||
port: int | None,
|
||||
) -> RunningServiceInfo:
|
||||
del host, port
|
||||
return RunningServiceInfo(is_running=False)
|
||||
|
||||
monkeypatch.setattr(update_cmd_module, "WORKING_DIR", tmp_path)
|
||||
monkeypatch.setattr(update_cmd_module.sys, "platform", "darwin")
|
||||
monkeypatch.setattr(
|
||||
update_cmd_module,
|
||||
"_detect_installation",
|
||||
_detect_installation,
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
update_cmd_module,
|
||||
"_fetch_latest_version",
|
||||
_fetch_latest_version,
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
update_cmd_module,
|
||||
"_detect_running_service",
|
||||
_detect_running_service,
|
||||
)
|
||||
|
||||
def _fake_run_worker(plan_path: Path) -> int:
|
||||
spawned["path"] = plan_path
|
||||
spawned["plan"] = json.loads(plan_path.read_text(encoding="utf-8"))
|
||||
return 0
|
||||
|
||||
monkeypatch.setattr(
|
||||
update_cmd_module,
|
||||
"_run_update_worker_foreground",
|
||||
_fake_run_worker,
|
||||
)
|
||||
|
||||
result = CliRunner().invoke(cli, ["update"], input="y\ny\n")
|
||||
|
||||
assert result.exit_code == 0
|
||||
assert isinstance(spawned["path"], Path)
|
||||
assert "Starting QwenPaw update..." in result.output
|
||||
|
||||
|
||||
def test_update_returns_worker_exit_code(monkeypatch, tmp_path: Path) -> None:
|
||||
from qwenpaw.cli import update_cmd as update_cmd_module
|
||||
|
||||
install_info = _install_info()
|
||||
|
||||
monkeypatch.setattr(update_cmd_module, "WORKING_DIR", tmp_path)
|
||||
monkeypatch.setattr(update_cmd_module.sys, "platform", "darwin")
|
||||
monkeypatch.setattr(
|
||||
update_cmd_module,
|
||||
"_detect_installation",
|
||||
lambda: install_info,
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
update_cmd_module,
|
||||
"_fetch_latest_version",
|
||||
lambda **_: "9.9.9",
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
update_cmd_module,
|
||||
"_detect_running_service",
|
||||
lambda host, port: RunningServiceInfo(is_running=False),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
update_cmd_module,
|
||||
"_run_update_worker_foreground",
|
||||
lambda plan_path: 2,
|
||||
)
|
||||
|
||||
result = CliRunner().invoke(cli, ["update", "--yes"])
|
||||
|
||||
assert result.exit_code == 2
|
||||
assert "Starting QwenPaw update..." in result.output
|
||||
|
||||
|
||||
def test_update_detaches_worker_on_windows(
|
||||
monkeypatch,
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
from qwenpaw.cli import update_cmd as update_cmd_module
|
||||
|
||||
install_info = _install_info()
|
||||
spawned: dict[str, object] = {}
|
||||
|
||||
monkeypatch.setattr(update_cmd_module, "WORKING_DIR", tmp_path)
|
||||
monkeypatch.setattr(update_cmd_module.sys, "platform", "win32")
|
||||
monkeypatch.setattr(
|
||||
update_cmd_module,
|
||||
"_detect_installation",
|
||||
lambda: install_info,
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
update_cmd_module,
|
||||
"_fetch_latest_version",
|
||||
lambda **_: "9.9.9",
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
update_cmd_module,
|
||||
"_detect_running_service",
|
||||
lambda host, port: RunningServiceInfo(is_running=False),
|
||||
)
|
||||
|
||||
def _fake_run_detached(plan_path: Path) -> None:
|
||||
spawned["path"] = plan_path
|
||||
spawned["plan"] = json.loads(plan_path.read_text(encoding="utf-8"))
|
||||
|
||||
monkeypatch.setattr(
|
||||
update_cmd_module,
|
||||
"_run_update_worker_detached",
|
||||
_fake_run_detached,
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
update_cmd_module,
|
||||
"_run_update_worker_foreground",
|
||||
lambda plan_path: pytest.fail("foreground worker should not run"),
|
||||
)
|
||||
|
||||
result = CliRunner().invoke(cli, ["update", "--yes"])
|
||||
|
||||
assert result.exit_code == 0
|
||||
assert "Starting QwenPaw update..." in result.output
|
||||
assert "continue after this command exits" in result.output
|
||||
assert isinstance(spawned["path"], Path)
|
||||
assert spawned["plan"]["launcher_pid"] is not None # type: ignore[index]
|
||||
|
||||
|
||||
def test_update_worker_waits_for_launcher_exit(
|
||||
monkeypatch,
|
||||
tmp_path: Path,
|
||||
capsys,
|
||||
) -> None:
|
||||
plan_path = tmp_path / "update-plan-wait.json"
|
||||
_write_plan(
|
||||
plan_path,
|
||||
command=[
|
||||
sys.executable,
|
||||
"-u",
|
||||
"-c",
|
||||
"print('installer: done', flush=True)",
|
||||
],
|
||||
)
|
||||
plan = json.loads(plan_path.read_text(encoding="utf-8"))
|
||||
plan["launcher_pid"] = 4321
|
||||
plan_path.write_text(json.dumps(plan), encoding="utf-8")
|
||||
|
||||
waited: list[tuple[int | None, float]] = []
|
||||
monkeypatch.setattr(
|
||||
"qwenpaw.cli.update_cmd._wait_for_process_exit",
|
||||
lambda pid, timeout=15.0: waited.append((pid, timeout)),
|
||||
)
|
||||
|
||||
return_code = run_update_worker(plan_path)
|
||||
captured = capsys.readouterr()
|
||||
|
||||
assert return_code == 0
|
||||
assert waited == [(4321, 15.0)]
|
||||
assert "installer: done" in captured.out
|
||||
|
||||
|
||||
def test_run_update_worker_detached_spawns_without_capture(
|
||||
monkeypatch,
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
captured: dict[str, object] = {}
|
||||
|
||||
def _fake_spawn(plan_path: Path, *, capture_output: bool = True):
|
||||
captured["path"] = plan_path
|
||||
captured["capture_output"] = capture_output
|
||||
return object()
|
||||
|
||||
monkeypatch.setattr(
|
||||
"qwenpaw.cli.update_cmd._spawn_update_worker",
|
||||
_fake_spawn,
|
||||
)
|
||||
|
||||
plan_path = tmp_path / "update-plan.json"
|
||||
plan_path.write_text("{}", encoding="utf-8")
|
||||
|
||||
_run_update_worker_detached(plan_path)
|
||||
|
||||
assert captured == {
|
||||
"path": plan_path,
|
||||
"capture_output": False,
|
||||
}
|
||||
|
||||
|
||||
def _write_plan(
|
||||
plan_path: Path,
|
||||
*,
|
||||
command: list[str],
|
||||
current_version: str = "1.0.0",
|
||||
latest_version: str = "9.9.9",
|
||||
) -> None:
|
||||
plan = {
|
||||
"current_version": current_version,
|
||||
"latest_version": latest_version,
|
||||
"installer_label": "integration-test",
|
||||
"command": command,
|
||||
"install": {},
|
||||
}
|
||||
plan_path.write_text(json.dumps(plan), encoding="utf-8")
|
||||
|
||||
|
||||
def test_update_worker_foreground_streams_output_and_cleans_plan(
|
||||
tmp_path: Path,
|
||||
capsys,
|
||||
) -> None:
|
||||
"""Test that the foreground worker streams child output and cleans up."""
|
||||
plan_path = tmp_path / "update-plan.json"
|
||||
_write_plan(
|
||||
plan_path,
|
||||
command=[
|
||||
sys.executable,
|
||||
"-u",
|
||||
"-c",
|
||||
(
|
||||
"print('installer: preparing', flush=True);"
|
||||
"print('installer: done', flush=True)"
|
||||
),
|
||||
],
|
||||
)
|
||||
|
||||
return_code = _run_update_worker_foreground(plan_path)
|
||||
captured = capsys.readouterr()
|
||||
|
||||
assert return_code == 0
|
||||
assert "[qwenpaw] Updating QwenPaw 1.0.0 -> 9.9.9..." in captured.out
|
||||
assert "[qwenpaw] Using installer: integration-test" in captured.out
|
||||
assert "installer: preparing" in captured.out
|
||||
assert "installer: done" in captured.out
|
||||
assert "[qwenpaw] Update completed successfully." in captured.out
|
||||
assert not plan_path.exists()
|
||||
|
||||
|
||||
def test_update_worker_foreground_propagates_failure_exit_code(
|
||||
tmp_path: Path,
|
||||
capsys,
|
||||
) -> None:
|
||||
"""Test that the foreground worker returns the installer exit code."""
|
||||
plan_path = tmp_path / "update-plan-fail.json"
|
||||
_write_plan(
|
||||
plan_path,
|
||||
command=[
|
||||
sys.executable,
|
||||
"-u",
|
||||
"-c",
|
||||
(
|
||||
"import sys;"
|
||||
"print('installer: failing', flush=True);"
|
||||
"sys.exit(7)"
|
||||
),
|
||||
],
|
||||
)
|
||||
|
||||
return_code = _run_update_worker_foreground(plan_path)
|
||||
captured = capsys.readouterr()
|
||||
|
||||
assert return_code == 7
|
||||
assert "installer: failing" in captured.out
|
||||
assert "[qwenpaw] Update failed with exit code 7." in captured.out
|
||||
assert not plan_path.exists()
|
||||
Reference in New Issue
Block a user