项目文件夹

文件
wehub-resource-sync 3e779be6f3
CI / lint (push) Failing after 13m4s
CI / test (3.11, ubuntu-latest) (push) Failing after 2m4s
CI / test (3.13, ubuntu-latest) (push) Successful in 13m30s
CI / test (3.14, ubuntu-latest) (push) Successful in 17m21s
CI / test (3.12, ubuntu-latest) (push) Successful in 17m55s
CI / discover-apps-ps (push) Successful in 1m56s
CI / test (3.9, ubuntu-latest) (push) Successful in 13m17s
CI / test (3.10, ubuntu-latest) (push) Successful in 26m21s
CI / audit (push) Successful in 13m38s
Deploy site / deploy (push) Has been cancelled
CI / test (3.14, ubuntu-24.04-arm) (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 12:32:37 +08:00

1077 行
37 KiB
Python

此文件含有模棱两可的 Unicode 字符
此文件含有可能会与其他字符混淆的 Unicode 字符。 如果您是想特意这样的,可以安全地忽略该警告。 使用 Escape 按钮显示他们。
# SPDX-License-Identifier: MIT
"""Tests for the auto bring-up workflow (BringUpMixin, v0.5.1).
The Qt dialog (``BringUpProgressDialog``) is GUI-smoke territory; the
checklist + tail-lifecycle tests at the bottom of this file exercise it
in headless mode with ``QApplication([])`` so we stay free of pytest-qt.
The worker logic is pure Python apart from the ``Signal.emit`` calls,
which we satisfy with a lightweight ``FakeSignal`` that records
emissions.
Covers:
- Happy path: all 5 phases fire in order, ``bringup_done(True, "")``.
- Cancellation during phase 1: worker exits within 1 s with
``bringup_done(False, "cancelled")``.
- Failure: phase 3 ``apply_windows_runtime_fixes`` raises, worker
emits ``bringup_done(False, "...<error>")``.
- Polling phase sub_detail strings carry an ``Attempt N`` counter
so the dialog can render progress feedback.
- Phase-ID cascade order: emissions use the stable ``phase_1_pod``
/ ``phase_2_agent`` / ... slugs so the dialog can route to the
correct checklist row.
- Dialog lifecycle: pod-log lines append to the view; the elapsed
QTimer starts on open and stops on accept.
"""
from __future__ import annotations
import threading
import time
from typing import Any
import pytest
pytest.importorskip("PySide6")
from winpodx.core.config import Config # noqa: E402
from winpodx.core.pod import PodState, PodStatus # noqa: E402
from winpodx.gui._main_window_bringup import BringUpMixin # noqa: E402
# Capture the real _dockur_progress before the autouse fixture stubs it, so the
# tests below can exercise the actual implementation (attribute access + parse).
_REAL_DOCKUR_PROGRESS = BringUpMixin._dockur_progress
class FakeSignal:
"""Minimal stand-in for a Qt Signal that records emits."""
def __init__(self) -> None:
self.emissions: list[tuple] = []
def emit(self, *args: Any) -> None:
self.emissions.append(args)
class Harness(BringUpMixin):
"""Bare host class exposing only what BringUpMixin reads."""
def __init__(self, cfg: Config) -> None:
self.cfg = cfg
self.bringup_phase = FakeSignal()
self.bringup_done = FakeSignal()
self.bringup_started = FakeSignal()
self.log_signal = FakeSignal()
# ----- shared helpers ----------------------------------------------------
@pytest.fixture(autouse=True)
def _stub_dockur_progress(monkeypatch):
"""Keep phase 1 hermetic: never shell out to ``podman logs`` against a real
container (a live winpodx-windows would leak its log state into the test).
Tests that exercise the boot-error path override this per-test."""
from winpodx.gui._main_window_bringup import BringUpMixin
monkeypatch.setattr(BringUpMixin, "_dockur_progress", lambda self: (None, None, False))
def _make_cfg() -> Config:
cfg = Config()
# Keep budgets short so the test exits fast on negative paths.
cfg.install.wait_ready_stage2_secs = 60
cfg.install.wait_ready_stage3_secs = 60
return cfg
def _phase_labels(emissions: list[tuple]) -> list[str]:
"""Distinct phase_label values in emission order."""
out: list[str] = []
for label, _detail in emissions:
if not out or out[-1] != label:
out.append(label)
return out
def _wait_for_done(harness: Harness, timeout: float = 5.0) -> None:
deadline = time.monotonic() + timeout
while time.monotonic() < deadline:
if harness.bringup_done.emissions:
return
time.sleep(0.05)
raise AssertionError(
f"bringup_done never fired within {timeout}s; "
f"phase emissions so far: {harness.bringup_phase.emissions}"
)
# ----- happy path --------------------------------------------------------
def test_happy_path_all_five_phases_fire_in_order(monkeypatch: pytest.MonkeyPatch) -> None:
cfg = _make_cfg()
monkeypatch.setattr(
"winpodx.core.pod.pod_status",
lambda _cfg: PodStatus(state=PodState.RUNNING, ip="127.0.0.1"),
)
monkeypatch.setattr(
"winpodx.core.pod.check_rdp_port",
lambda _ip, _port, timeout=3.0: True,
)
# AgentClient -- health() succeeds + auth_ready() returns (True, "").
class FakeClient:
def __init__(self, _cfg: Config) -> None:
pass
def health(self) -> dict:
return {"ok": True}
def auth_ready(self) -> tuple[bool, str]:
return True, ""
monkeypatch.setattr("winpodx.core.agent.AgentClient", FakeClient)
monkeypatch.setattr(
"winpodx.core.provisioner.apply_windows_runtime_fixes",
lambda _cfg: {
"max_sessions": "ok",
"rdp_timeouts": "ok",
"oem_runtime_fixes": "ok",
"vbs_launchers": "ok",
"multi_session": "ok",
},
)
monkeypatch.setattr("winpodx.core.discovery.scan", lambda _cfg: ["app1", "app2"])
monkeypatch.setattr(
"winpodx.core.discovery.persist_discovered",
lambda _apps: ["/tmp/app1.toml", "/tmp/app2.toml"],
)
monkeypatch.setattr("winpodx.cli.host_open._cmd_refresh", lambda _args: 0)
cfg.reverse_open.enabled = True
harness = Harness(cfg)
harness._run_full_bring_up()
_wait_for_done(harness)
assert harness.bringup_done.emissions == [(True, "")]
labels = _phase_labels(harness.bringup_phase.emissions)
expected_order = [
"phase_1_pod",
"phase_2_agent",
"phase_3_fixes",
"phase_4_discovery",
"phase_5_refresh",
]
# Each expected phase must appear in order. Allow extra polling
# repetitions but require the first occurrence sequence to match.
first_indices = []
for phase in expected_order:
assert phase in labels, f"expected phase {phase!r} missing: {labels}"
first_indices.append(labels.index(phase))
assert first_indices == sorted(first_indices), f"phases out of order: {labels}"
# ``bringup_started`` was emitted so the dialog kick happened.
assert harness.bringup_started.emissions == [()]
# ----- cancellation ------------------------------------------------------
def test_cancel_during_phase_one_exits_within_one_second(
monkeypatch: pytest.MonkeyPatch,
) -> None:
cfg = _make_cfg()
# Long Stage-2 budget so the natural exit is the timeout. We need
# cancellation to short-circuit before then.
cfg.install.wait_ready_stage2_secs = 600
# pod_status always reports STARTING so phase 1 keeps polling.
monkeypatch.setattr(
"winpodx.core.pod.pod_status",
lambda _cfg: PodStatus(state=PodState.STARTING, ip="127.0.0.1"),
)
monkeypatch.setattr(
"winpodx.core.pod.check_rdp_port",
lambda _ip, _port, timeout=3.0: False,
)
harness = Harness(cfg)
harness._run_full_bring_up()
# Brief delay so the worker enters phase 1's poll loop.
time.sleep(0.1)
harness._cancel_bringup()
# The worker must exit within 1 s -- the poll cadence is 2 s but
# _sleep_cancellable wakes on the event.
_wait_for_done(harness, timeout=2.5)
assert harness.bringup_done.emissions == [(False, "cancelled")]
# ----- failure path ------------------------------------------------------
def test_phase_three_failure_emits_bringup_done_false(
monkeypatch: pytest.MonkeyPatch,
) -> None:
cfg = _make_cfg()
# Phase 1: pod ready immediately.
monkeypatch.setattr(
"winpodx.core.pod.pod_status",
lambda _cfg: PodStatus(state=PodState.RUNNING, ip="127.0.0.1"),
)
monkeypatch.setattr(
"winpodx.core.pod.check_rdp_port",
lambda _ip, _port, timeout=3.0: True,
)
# Phase 2: agent healthy + token ready.
class FakeClient:
def __init__(self, _cfg: Config) -> None:
pass
def health(self) -> dict:
return {"ok": True}
def auth_ready(self) -> tuple[bool, str]:
return True, ""
monkeypatch.setattr("winpodx.core.agent.AgentClient", FakeClient)
# Phase 3 raises.
def _boom(_cfg: Config) -> dict[str, str]:
raise RuntimeError("simulated apply failure")
monkeypatch.setattr("winpodx.core.provisioner.apply_windows_runtime_fixes", _boom)
# Phase 4 / 5 should never be invoked, but stub safely so an
# unintended call would surface as a different assert.
monkeypatch.setattr(
"winpodx.core.discovery.scan",
lambda _cfg: pytest.fail("phase 4 should not run on phase 3 failure"),
)
monkeypatch.setattr(
"winpodx.cli.host_open._cmd_refresh",
lambda _args: pytest.fail("phase 5 should not run on phase 3 failure"),
)
harness = Harness(cfg)
harness._run_full_bring_up()
_wait_for_done(harness, timeout=5.0)
assert len(harness.bringup_done.emissions) == 1
success, msg = harness.bringup_done.emissions[0]
assert success is False
assert "simulated apply failure" in msg
# ----- ancillary ---------------------------------------------------------
def test_run_full_bring_up_returns_immediately(monkeypatch: pytest.MonkeyPatch) -> None:
"""The public entry point spawns a daemon thread; it must not block."""
cfg = _make_cfg()
cfg.install.wait_ready_stage2_secs = 600
monkeypatch.setattr(
"winpodx.core.pod.pod_status",
lambda _cfg: PodStatus(state=PodState.STARTING),
)
monkeypatch.setattr(
"winpodx.core.pod.check_rdp_port",
lambda _ip, _port, timeout=3.0: False,
)
harness = Harness(cfg)
started = time.monotonic()
harness._run_full_bring_up()
elapsed = time.monotonic() - started
# Should be effectively instant. Anything over a second points at
# blocking work landing on the caller.
assert elapsed < 0.5, f"_run_full_bring_up blocked for {elapsed:.3f}s"
harness._cancel_bringup()
_wait_for_done(harness, timeout=3.0)
def test_cancel_event_is_fresh_per_run(monkeypatch: pytest.MonkeyPatch) -> None:
"""A second bring-up must not inherit the first run's cancel flag."""
cfg = _make_cfg()
# Configure happy-path probes so a clean run completes.
monkeypatch.setattr(
"winpodx.core.pod.pod_status",
lambda _cfg: PodStatus(state=PodState.RUNNING, ip="127.0.0.1"),
)
monkeypatch.setattr(
"winpodx.core.pod.check_rdp_port",
lambda _ip, _port, timeout=3.0: True,
)
class FakeClient:
def __init__(self, _cfg: Config) -> None:
pass
def health(self) -> dict:
return {"ok": True}
def auth_ready(self) -> tuple[bool, str]:
return True, ""
monkeypatch.setattr("winpodx.core.agent.AgentClient", FakeClient)
monkeypatch.setattr(
"winpodx.core.provisioner.apply_windows_runtime_fixes",
lambda _cfg: {"max_sessions": "ok"},
)
monkeypatch.setattr("winpodx.core.discovery.scan", lambda _cfg: [])
monkeypatch.setattr("winpodx.core.discovery.persist_discovered", lambda _apps: [])
cfg.reverse_open.enabled = False
harness = Harness(cfg)
# Pre-set a cancel event left over from a hypothetical prior run.
harness._bringup_cancel = threading.Event()
harness._bringup_cancel.set()
harness._run_full_bring_up()
_wait_for_done(harness, timeout=5.0)
# _run_full_bring_up must allocate a fresh event, so the leftover
# set() doesn't poison this run.
assert harness.bringup_done.emissions[0] == (True, "")
# ----- polling-phase attempt counters ------------------------------------
def test_polling_phases_emit_attempt_counters(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""Phase 1/2 sub_detail strings must include an ``Attempt N`` counter.
The dialog renders this verbatim so the user sees real progress
during the long Phase-2 wait. We force Phase 1 to poll a few times
by returning STARTING then RUNNING, then verify the sub-details
were prefixed with the counter on each polling iteration.
"""
cfg = _make_cfg()
cfg.install.wait_ready_stage2_secs = 60
cfg.install.wait_ready_stage3_secs = 60
# Two STARTING + one RUNNING so phase 1 polls thrice.
pod_states = iter(
[
PodStatus(state=PodState.STARTING, ip="127.0.0.1"),
PodStatus(state=PodState.STARTING, ip="127.0.0.1"),
PodStatus(state=PodState.RUNNING, ip="127.0.0.1"),
]
)
def _next_status(_cfg):
try:
return next(pod_states)
except StopIteration:
return PodStatus(state=PodState.RUNNING, ip="127.0.0.1")
monkeypatch.setattr("winpodx.core.pod.pod_status", _next_status)
monkeypatch.setattr(
"winpodx.core.pod.check_rdp_port",
lambda _ip, _port, timeout=3.0: True,
)
class FakeClient:
def __init__(self, _cfg: Config) -> None:
pass
def health(self) -> dict:
return {"ok": True}
def auth_ready(self) -> tuple[bool, str]:
return True, ""
monkeypatch.setattr("winpodx.core.agent.AgentClient", FakeClient)
monkeypatch.setattr(
"winpodx.core.provisioner.apply_windows_runtime_fixes",
lambda _cfg: {"max_sessions": "ok"},
)
monkeypatch.setattr("winpodx.core.discovery.scan", lambda _cfg: [])
monkeypatch.setattr("winpodx.core.discovery.persist_discovered", lambda _apps: [])
cfg.reverse_open.enabled = False
# Speed the poll cadence so the test doesn't sit through 6 s of waits.
monkeypatch.setattr("winpodx.gui._main_window_bringup._POLL_CADENCE_SECS", 0.05)
harness = Harness(cfg)
harness._run_full_bring_up()
_wait_for_done(harness, timeout=10.0)
# phase_1_pod surfaces the pod's own state/progress (not an internal poll
# counter) — e.g. "Pod starting..." while the container comes up.
phase1_details = [
detail for pid, detail in harness.bringup_phase.emissions if pid == "phase_1_pod"
]
assert any(
("starting" in d.lower() or "windows" in d.lower() or "pod " in d.lower())
for d in phase1_details
), f"phase_1_pod never emitted a pod-progress detail; all: {harness.bringup_phase.emissions}"
# phase_2_agent still streams its Attempt-counter detail.
phase2_attempts = [
detail
for pid, detail in harness.bringup_phase.emissions
if pid == "phase_2_agent" and detail.startswith("Attempt ")
]
assert phase2_attempts, (
"phase_2_agent never emitted an Attempt-counter sub_detail; "
f"all emissions: {harness.bringup_phase.emissions}"
)
def test_phase_id_cascade_order(monkeypatch: pytest.MonkeyPatch) -> None:
"""First emission of each phase ID appears in the canonical order.
The dialog's checklist row routing depends on the phase-ID slug
sequence. A regression that re-orders or renames an ID would
silently break the checklist; this test pins the contract.
"""
cfg = _make_cfg()
monkeypatch.setattr(
"winpodx.core.pod.pod_status",
lambda _cfg: PodStatus(state=PodState.RUNNING, ip="127.0.0.1"),
)
monkeypatch.setattr(
"winpodx.core.pod.check_rdp_port",
lambda _ip, _port, timeout=3.0: True,
)
class FakeClient:
def __init__(self, _cfg: Config) -> None:
pass
def health(self) -> dict:
return {"ok": True}
def auth_ready(self) -> tuple[bool, str]:
return True, ""
monkeypatch.setattr("winpodx.core.agent.AgentClient", FakeClient)
monkeypatch.setattr(
"winpodx.core.provisioner.apply_windows_runtime_fixes",
lambda _cfg: {"max_sessions": "ok"},
)
monkeypatch.setattr("winpodx.core.discovery.scan", lambda _cfg: [])
monkeypatch.setattr("winpodx.core.discovery.persist_discovered", lambda _apps: [])
monkeypatch.setattr("winpodx.cli.host_open._cmd_refresh", lambda _args: 0)
cfg.reverse_open.enabled = True
harness = Harness(cfg)
harness._run_full_bring_up()
_wait_for_done(harness, timeout=5.0)
# Build the distinct-phase-id order.
distinct: list[str] = []
for pid, _detail in harness.bringup_phase.emissions:
if not distinct or distinct[-1] != pid:
distinct.append(pid)
expected = [
"phase_1_pod",
"phase_2_agent",
"phase_3_fixes",
"phase_4_discovery",
"phase_5_refresh",
]
# The first occurrence of each expected ID must appear in canonical
# order. Re-entries (extra polling iterations) are fine.
first_seen = {}
for i, pid in enumerate(distinct):
first_seen.setdefault(pid, i)
for pid in expected:
assert pid in first_seen, f"{pid!r} never emitted; distinct={distinct}"
ordered_indices = [first_seen[pid] for pid in expected]
assert ordered_indices == sorted(ordered_indices), (
f"phase IDs out of canonical order: {distinct}"
)
# ----- dialog-side smoke (headless) --------------------------------------
def _ensure_qapp():
"""Return a QApplication, creating one if needed."""
from PySide6.QtWidgets import QApplication
app = QApplication.instance()
if app is None:
app = QApplication([])
return app
def test_dialog_appends_pod_log_lines() -> None:
"""``append_pod_log_line`` adds the literal line to the view widget."""
_ensure_qapp()
from winpodx.gui._main_window_bringup import BringUpProgressDialog
cancelled: list[bool] = []
dlg = BringUpProgressDialog(None, on_cancel=lambda: cancelled.append(True), cfg=None)
try:
dlg.append_pod_log_line("[pod] BdsDxe: starting Boot0001")
dlg.append_pod_log_line("[pod] [Setup] Applying image...")
text = dlg.pod_log_view.toPlainText()
assert "[pod] BdsDxe: starting Boot0001" in text
assert "[pod] [Setup] Applying image..." in text
# Empty / falsy lines are ignored.
before = dlg.pod_log_view.toPlainText()
dlg.append_pod_log_line("")
assert dlg.pod_log_view.toPlainText() == before
finally:
dlg.reject()
def test_dialog_phase_routing_updates_checklist() -> None:
"""``on_phase`` ticks prior rows and marks the new one in-progress."""
_ensure_qapp()
from winpodx.gui._main_window_bringup import BringUpProgressDialog
dlg = BringUpProgressDialog(None, on_cancel=lambda: None, cfg=None)
try:
dlg.on_phase("phase_1_pod", "Attempt 1 - probing")
# Row 0 in-progress.
glyph0, _name0, _elapsed0 = dlg._row_widgets[0]
assert glyph0.text().startswith(">")
dlg.on_phase("phase_2_agent", "Attempt 1 - /health: ConnectionRefused")
# Row 0 should now be ticked, row 1 in-progress.
glyph0, _, _ = dlg._row_widgets[0]
glyph1, _, _ = dlg._row_widgets[1]
assert glyph0.text().startswith("✓")
assert glyph1.text().startswith(">")
finally:
dlg.reject()
def test_dialog_timer_lifecycle() -> None:
"""The 1-second tick timer starts on open and stops on accept/reject."""
_ensure_qapp()
from winpodx.gui._main_window_bringup import BringUpProgressDialog
dlg = BringUpProgressDialog(None, on_cancel=lambda: None, cfg=None)
# Active right after construction.
assert dlg._tick_timer.isActive()
dlg.on_done(True, "")
# Done freezes the timer.
assert not dlg._tick_timer.isActive()
# Same on reject (cancel path).
dlg2 = BringUpProgressDialog(None, on_cancel=lambda: None, cfg=None)
assert dlg2._tick_timer.isActive()
dlg2.reject()
assert not dlg2._tick_timer.isActive()
def test_dialog_done_freezes_active_phase_elapsed() -> None:
"""On done(success=True) all started rows get a finalised elapsed."""
_ensure_qapp()
from winpodx.gui._main_window_bringup import BringUpProgressDialog
dlg = BringUpProgressDialog(None, on_cancel=lambda: None, cfg=None)
try:
dlg.on_phase("phase_1_pod", "starting")
dlg.on_phase("phase_2_agent", "starting")
dlg.on_phase("phase_3_fixes", "starting")
dlg.on_done(True, "")
# All three started rows must be ticked.
for i in range(3):
glyph, _, elapsed_label = dlg._row_widgets[i]
assert glyph.text().startswith("✓"), f"row {i} not ticked"
assert elapsed_label.text(), f"row {i} elapsed empty"
finally:
dlg.reject()
# ----- phase 4 discovery retry (transient agent-channel hiccup) -----------
def _pass_phases_123(monkeypatch: pytest.MonkeyPatch) -> None:
"""Stub phases 1-3 so a test can focus on phase 4 (discovery)."""
monkeypatch.setattr(
"winpodx.core.pod.pod_status",
lambda _cfg: PodStatus(state=PodState.RUNNING, ip="127.0.0.1"),
)
monkeypatch.setattr("winpodx.core.pod.check_rdp_port", lambda _ip, _port, timeout=3.0: True)
class _FakeClient:
def __init__(self, _cfg: Config) -> None:
pass
def health(self) -> dict:
return {"ok": True}
def auth_ready(self) -> tuple[bool, str]:
return True, ""
monkeypatch.setattr("winpodx.core.agent.AgentClient", _FakeClient)
monkeypatch.setattr(
"winpodx.core.provisioner.apply_windows_runtime_fixes",
lambda _cfg: {
"max_sessions": "ok",
"rdp_timeouts": "ok",
"oem_runtime_fixes": "ok",
"vbs_launchers": "ok",
"multi_session": "ok",
},
)
def test_phase4_retries_transient_channel_error(monkeypatch: pytest.MonkeyPatch) -> None:
from winpodx.core.discovery import DiscoveryError
cfg = _make_cfg()
cfg.reverse_open.enabled = True
_pass_phases_123(monkeypatch)
monkeypatch.setattr("winpodx.gui._main_window_bringup._DISCOVERY_RETRY_SECS", 0.01)
calls = {"n": 0}
def _flaky(_cfg: Config) -> list[str]:
calls["n"] += 1
if calls["n"] < 3: # the agent /health flickered mid-scan twice
raise DiscoveryError(
"Discovery channel failure: /exec socket error: "
"Remote end closed connection without response",
kind="pod_not_running",
)
return ["app1"]
monkeypatch.setattr("winpodx.core.discovery.scan", _flaky)
monkeypatch.setattr("winpodx.core.discovery.persist_discovered", lambda _a: ["/tmp/app1.toml"])
monkeypatch.setattr("winpodx.cli.host_open._cmd_refresh", lambda _args: 0)
harness = Harness(cfg)
harness._run_full_bring_up()
_wait_for_done(harness, timeout=5.0)
assert calls["n"] == 3 # 2 transient retries + 1 success
assert harness.bringup_done.emissions == [(True, "")]
class _FakeRun:
def __init__(self, stdout: str = "", stderr: str = "") -> None:
self.stdout = stdout
self.stderr = stderr
def test_dockur_progress_reads_cfg_and_parses(monkeypatch: pytest.MonkeyPatch) -> None:
# Regression: _dockur_progress must use self.cfg (not self._cfg) — the latter
# raised AttributeError and crashed the whole bring-up.
harness = Harness(_make_cfg())
log = " Downloading Windows 11...\n50000K ........ 50% 84.0M 60s\n"
monkeypatch.setattr("subprocess.run", lambda *a, **k: _FakeRun(stdout=log))
err, progress, installing = _REAL_DOCKUR_PROGRESS(harness)
assert err is None
assert installing is True
assert progress and "50%" in progress
def test_dockur_progress_flags_qemu_error(monkeypatch: pytest.MonkeyPatch) -> None:
harness = Harness(_make_cfg())
log = " ERROR: qemu-system-x86_64: -device e1000,...: Property 'e1000.host_mtu' not found\n"
monkeypatch.setattr("subprocess.run", lambda *a, **k: _FakeRun(stdout=log))
err, _progress, _installing = _REAL_DOCKUR_PROGRESS(harness)
assert err and "host_mtu" in err
def test_dockur_progress_survives_no_backend(monkeypatch: pytest.MonkeyPatch) -> None:
harness = Harness(_make_cfg())
def _boom(*_a, **_k):
raise OSError("podman not found")
monkeypatch.setattr("subprocess.run", _boom)
assert _REAL_DOCKUR_PROGRESS(harness) == (None, None, False)
def test_phase1_fails_fast_on_qemu_boot_error(monkeypatch: pytest.MonkeyPatch) -> None:
# A boot-looping QEMU device error (e.g. dockur's host_mtu on e1000) should
# fail the bring-up fast with the real reason, not wait out the budget.
from winpodx.gui._main_window_bringup import BringUpMixin
cfg = _make_cfg()
monkeypatch.setattr(
"winpodx.core.pod.pod_status",
lambda _cfg: PodStatus(state=PodState.STOPPED, ip=""),
)
err = "qemu-system-x86_64: -device e1000,...: Property 'e1000.host_mtu' not found"
monkeypatch.setattr(BringUpMixin, "_dockur_progress", lambda self: (err, None, False))
harness = Harness(cfg)
harness._run_full_bring_up()
_wait_for_done(harness, timeout=8.0)
ok, msg = harness.bringup_done.emissions[0]
assert ok is False
assert "QEMU" in msg and "host_mtu" in msg
def test_phase1_fails_when_container_exits(monkeypatch: pytest.MonkeyPatch) -> None:
# No fixed timeout, but a container that has actually EXITED (not just still
# booting) must fail fast rather than spin forever.
from winpodx.gui._main_window_bringup import BringUpMixin
cfg = _make_cfg()
monkeypatch.setattr(
"winpodx.core.pod.pod_status",
lambda _cfg: PodStatus(state=PodState.STOPPED, ip=""),
)
monkeypatch.setattr(
BringUpMixin, "_dockur_progress", lambda self: (None, "Shutdown completed!", False)
)
harness = Harness(cfg)
harness._run_full_bring_up()
_wait_for_done(harness, timeout=8.0)
ok, msg = harness.bringup_done.emissions[0]
assert ok is False
assert "stopped" in msg.lower()
def test_phase4_does_not_retry_real_script_failure(monkeypatch: pytest.MonkeyPatch) -> None:
from winpodx.core.discovery import DiscoveryError
cfg = _make_cfg()
_pass_phases_123(monkeypatch)
monkeypatch.setattr("winpodx.gui._main_window_bringup._DISCOVERY_RETRY_SECS", 0.01)
calls = {"n": 0}
def _failing(_cfg: Config) -> list[str]:
calls["n"] += 1
raise DiscoveryError("Discovery script failed (rc=1): boom", kind="script_failed")
monkeypatch.setattr("winpodx.core.discovery.scan", _failing)
monkeypatch.setattr(
"winpodx.cli.host_open._cmd_refresh",
lambda _args: pytest.fail("phase 5 must not run when discovery fails"),
)
harness = Harness(cfg)
harness._run_full_bring_up()
_wait_for_done(harness, timeout=5.0)
assert calls["n"] == 1 # genuine script failure → no retry
success, msg = harness.bringup_done.emissions[0]
assert success is False
assert "Discovery script failed" in msg
# ----- phase 0: settings-driven recreate (#525) --------------------------
def _mock_happy_chain(monkeypatch: pytest.MonkeyPatch, cfg: Config) -> None:
"""Mock phases 1-5 so they all succeed, leaving the test free to focus on
the recreate phase 0 + agent-kick behavior."""
monkeypatch.setattr(
"winpodx.core.pod.pod_status",
lambda _cfg: PodStatus(state=PodState.RUNNING, ip="127.0.0.1"),
)
monkeypatch.setattr("winpodx.core.pod.check_rdp_port", lambda _ip, _port, timeout=3.0: True)
class FakeClient:
def __init__(self, _cfg: Config) -> None:
pass
def health(self) -> dict:
return {"ok": True}
def auth_ready(self) -> tuple[bool, str]:
return True, ""
monkeypatch.setattr("winpodx.core.agent.AgentClient", FakeClient)
monkeypatch.setattr(
"winpodx.core.provisioner.apply_windows_runtime_fixes",
lambda _cfg: {"max_sessions": "ok", "multi_session": "ok"},
)
monkeypatch.setattr("winpodx.core.discovery.scan", lambda _cfg: ["app1"])
monkeypatch.setattr("winpodx.core.discovery.persist_discovered", lambda _apps: ["/tmp/a.toml"])
monkeypatch.setattr("winpodx.cli.host_open._cmd_refresh", lambda _args: 0)
cfg.reverse_open.enabled = True
def test_recreate_runs_phase0_no_wipe_before_pod(monkeypatch: pytest.MonkeyPatch) -> None:
cfg = _make_cfg()
_mock_happy_chain(monkeypatch, cfg)
calls: list[str] = []
monkeypatch.setattr("winpodx.core.pod.stop_pod", lambda _cfg: calls.append("stop"))
monkeypatch.setattr("winpodx.cli.pod._wipe_pod_storage", lambda _cfg: calls.append("wipe"))
monkeypatch.setattr(
"winpodx.cli.setup_cmd._generate_compose", lambda _cfg: calls.append("compose")
)
monkeypatch.setattr(
"winpodx.cli.setup_cmd._recreate_container", lambda _cfg: calls.append("recreate")
)
harness = Harness(cfg)
harness._run_full_bring_up(recreate=True, wipe_storage=False)
_wait_for_done(harness)
assert harness.bringup_done.emissions == [(True, "")]
# No-wipe recreate: regenerate compose + recreate, NO stop/wipe.
assert calls == ["compose", "recreate"]
# Dialog kick fired before any slow work (immediate dialog).
assert harness.bringup_started.emissions == [()]
# The recreate progress lands BEFORE the agent phase.
raws = harness.bringup_phase.emissions
recreate_idx = next(i for i, (_p, d) in enumerate(raws) if "Recreating container" in d)
agent_idx = next(i for i, (p, _d) in enumerate(raws) if p == "phase_2_agent")
assert recreate_idx < agent_idx
def test_recreate_wipe_stops_and_wipes_first(monkeypatch: pytest.MonkeyPatch) -> None:
cfg = _make_cfg()
_mock_happy_chain(monkeypatch, cfg)
calls: list[str] = []
monkeypatch.setattr("winpodx.core.pod.stop_pod", lambda _cfg: calls.append("stop"))
monkeypatch.setattr("winpodx.cli.pod._wipe_pod_storage", lambda _cfg: calls.append("wipe"))
monkeypatch.setattr(
"winpodx.cli.setup_cmd._generate_compose", lambda _cfg: calls.append("compose")
)
monkeypatch.setattr(
"winpodx.cli.setup_cmd._recreate_container", lambda _cfg: calls.append("recreate")
)
harness = Harness(cfg)
harness._run_full_bring_up(recreate=True, wipe_storage=True)
_wait_for_done(harness)
assert harness.bringup_done.emissions == [(True, "")]
assert calls == ["stop", "wipe", "compose", "recreate"]
def test_recreate_failure_stops_chain(monkeypatch: pytest.MonkeyPatch) -> None:
cfg = _make_cfg()
_mock_happy_chain(monkeypatch, cfg)
monkeypatch.setattr("winpodx.core.pod.stop_pod", lambda _cfg: None)
monkeypatch.setattr("winpodx.cli.pod._wipe_pod_storage", lambda _cfg: None)
monkeypatch.setattr("winpodx.cli.setup_cmd._generate_compose", lambda _cfg: None)
def _boom(_cfg: Config) -> None:
raise RuntimeError("podman recreate busted")
monkeypatch.setattr("winpodx.cli.setup_cmd._recreate_container", _boom)
harness = Harness(cfg)
harness._run_full_bring_up(recreate=True, wipe_storage=False)
_wait_for_done(harness)
assert len(harness.bringup_done.emissions) == 1
success, msg = harness.bringup_done.emissions[0]
assert success is False
assert "Recreate failed" in msg and "podman recreate busted" in msg
# The wait/settle chain must NOT run after a failed recreate.
assert "phase_2_agent" not in _phase_labels(harness.bringup_phase.emissions)
def test_non_recreate_run_skips_phase0(monkeypatch: pytest.MonkeyPatch) -> None:
cfg = _make_cfg()
_mock_happy_chain(monkeypatch, cfg)
monkeypatch.setattr(
"winpodx.cli.setup_cmd._recreate_container",
lambda _cfg: pytest.fail("recreate must not run when recreate=False"),
)
harness = Harness(cfg)
harness._run_full_bring_up() # default recreate=False
_wait_for_done(harness)
assert harness.bringup_done.emissions == [(True, "")]
# ----- phase 2: agent-kick on a headless recreate boot (#525) ------------
class _DeadAgent:
def __init__(self, _cfg: Config) -> None:
pass
def health(self) -> dict:
raise RuntimeError("agent down (no interactive session)")
def auth_ready(self) -> tuple[bool, str]:
return False, "no agent"
def _short_kick_window(monkeypatch: pytest.MonkeyPatch) -> None:
import winpodx.gui._main_window_bringup as b
monkeypatch.setattr(b, "_AGENT_KICK_GRACE_SECS", 0.0)
monkeypatch.setattr(b, "_POLL_CADENCE_SECS", 0.01)
def test_phase2_kicks_session_when_initialized(monkeypatch: pytest.MonkeyPatch) -> None:
cfg = _make_cfg()
cfg.install.wait_ready_stage3_secs = 0.3
cfg.pod.initialized = True
monkeypatch.setattr("winpodx.core.agent.AgentClient", _DeadAgent)
_short_kick_window(monkeypatch)
kicks: list[int] = []
monkeypatch.setattr(Harness, "_kick_interactive_session", lambda self: kicks.append(1))
harness = Harness(cfg)
harness._bringup_cancel = threading.Event()
result = harness._phase2_wait_agent_settle()
assert result is False # agent never settled
assert kicks == [1] # kicked exactly once (not re-kicked every poll)
def test_phase2_no_kick_when_not_initialized(monkeypatch: pytest.MonkeyPatch) -> None:
cfg = _make_cfg()
cfg.install.wait_ready_stage3_secs = 0.2
cfg.pod.initialized = False # fresh install mid-Sysprep -- never RDP early
monkeypatch.setattr("winpodx.core.agent.AgentClient", _DeadAgent)
_short_kick_window(monkeypatch)
kicks: list[int] = []
monkeypatch.setattr(Harness, "_kick_interactive_session", lambda self: kicks.append(1))
harness = Harness(cfg)
harness._bringup_cancel = threading.Event()
harness._phase2_wait_agent_settle()
assert kicks == []
def test_kick_interactive_session_launches_desktop(monkeypatch: pytest.MonkeyPatch) -> None:
cfg = _make_cfg()
called: list[int] = []
monkeypatch.setattr("winpodx.core.rdp.launch_desktop", lambda _cfg, **_kw: called.append(1))
harness = Harness(cfg)
harness._kick_interactive_session()
assert called == [1]
assert any(p == "phase_2_agent" for p, _d in harness.bringup_phase.emissions)
def test_kick_interactive_session_never_raises(monkeypatch: pytest.MonkeyPatch) -> None:
cfg = _make_cfg()
def _boom(_cfg: Config, **_kw: object) -> None:
raise RuntimeError("no freerdp")
monkeypatch.setattr("winpodx.core.rdp.launch_desktop", _boom)
harness = Harness(cfg)
harness._kick_interactive_session() # must not raise
# ----- hardened mode: auto-build the patched-QEMU image (#246) ------------
def test_build_disguise_phase_runs_before_recreate(monkeypatch: pytest.MonkeyPatch) -> None:
cfg = _make_cfg()
_mock_happy_chain(monkeypatch, cfg)
order: list[str] = []
monkeypatch.setattr(
"winpodx.cli.disguise.build_disguise_image",
lambda _cfg, **_kw: order.append("build") or True,
)
monkeypatch.setattr("winpodx.core.pod.stop_pod", lambda _cfg: None)
monkeypatch.setattr("winpodx.cli.pod._wipe_pod_storage", lambda _cfg: None)
monkeypatch.setattr("winpodx.cli.setup_cmd._generate_compose", lambda _cfg: None)
monkeypatch.setattr(
"winpodx.cli.setup_cmd._recreate_container", lambda _cfg: order.append("recreate")
)
harness = Harness(cfg)
harness._run_full_bring_up(recreate=True, wipe_storage=True, build_disguise=True)
_wait_for_done(harness)
assert harness.bringup_done.emissions == [(True, "")]
assert order == ["build", "recreate"] # image built BEFORE the recreate
def test_build_disguise_failure_is_nonfatal(monkeypatch: pytest.MonkeyPatch) -> None:
cfg = _make_cfg()
_mock_happy_chain(monkeypatch, cfg)
# Build fails -> chain must still recreate + complete (hardened w/o image).
monkeypatch.setattr("winpodx.cli.disguise.build_disguise_image", lambda _cfg, **_kw: False)
recreated = {"n": 0}
monkeypatch.setattr("winpodx.cli.setup_cmd._generate_compose", lambda _cfg: None)
monkeypatch.setattr(
"winpodx.cli.setup_cmd._recreate_container",
lambda _cfg: recreated.__setitem__("n", recreated["n"] + 1),
)
harness = Harness(cfg)
harness._run_full_bring_up(recreate=True, wipe_storage=False, build_disguise=True)
_wait_for_done(harness)
assert harness.bringup_done.emissions == [(True, "")]
assert recreated["n"] == 1 # recreate still happened despite build failure
def test_no_build_disguise_when_flag_false(monkeypatch: pytest.MonkeyPatch) -> None:
cfg = _make_cfg()
_mock_happy_chain(monkeypatch, cfg)
monkeypatch.setattr(
"winpodx.cli.disguise.build_disguise_image",
lambda _cfg, **_kw: pytest.fail("must not build when build_disguise=False"),
)
harness = Harness(cfg)
harness._run_full_bring_up() # defaults: no recreate, no build
_wait_for_done(harness)
assert harness.bringup_done.emissions == [(True, "")]
# ----- dialog: pre-phase rows (build / recreate) render + route -----------
def test_dialog_renders_and_routes_prephase_rows() -> None:
"""Build + recreate get their own checklist rows when active, and phase
emissions route to the right row (not buried in 'Pod ready')."""
_ensure_qapp()
from winpodx.gui._main_window_bringup import (
_PHASE_BUILD,
_PHASE_DEFS,
_PHASE_RECREATE,
BringUpProgressDialog,
)
phases = (_PHASE_BUILD, _PHASE_RECREATE, *_PHASE_DEFS)
dlg = BringUpProgressDialog(None, on_cancel=lambda: None, cfg=None, phases=phases)
try:
assert len(dlg._row_widgets) == 7 # build + recreate + 5 standard
dlg.on_phase("phase_build_disguise", "compiling")
assert dlg._active_phase_idx == 0 # build is row 0
dlg.on_phase("phase_0_recreate", "recreating")
assert dlg._active_phase_idx == 1 # recreate is row 1
dlg.on_phase("phase_1_pod", "waiting")
assert dlg._active_phase_idx == 2 # standard chain shifts down
finally:
dlg.deleteLater()
def test_dialog_default_phases_unchanged() -> None:
"""A plain bring-up (no pre-phases) still renders the standard 5 rows."""
_ensure_qapp()
from winpodx.gui._main_window_bringup import BringUpProgressDialog
dlg = BringUpProgressDialog(None, on_cancel=lambda: None, cfg=None)
try:
assert len(dlg._row_widgets) == 5
dlg.on_phase("phase_1_pod", "waiting")
assert dlg._active_phase_idx == 0 # pod is row 0 when no pre-phases
finally:
dlg.deleteLater()