Skip to content

Commit cde3740

Browse files
committed
test(shell): follow-up tweaks to update, feedback, and TUI tests
Adjust update_orchestrator, mcp_status, feedback_repo, and live_view alongside their tests; add update CLI test coverage.
1 parent bceebba commit cde3740

12 files changed

Lines changed: 295 additions & 33 deletions

src/pythinker_code/feedback_repo.py

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,11 @@
11
from __future__ import annotations
22

33
DEFAULT_FEEDBACK_GITHUB_REPO = "TechMatrix-labs/pythinker-code"
4+
_LEGACY_FEEDBACK_GITHUB_OWNER = "mohamed-elkholy95"
45

56
_LEGACY_DEFAULT_FEEDBACK_GITHUB_REPOS = {
6-
"mohamed-elkholy95/Pythinker-Code",
7-
"mohamed-elkholy95/pythinker-code",
7+
_LEGACY_FEEDBACK_GITHUB_OWNER + "/Pythinker-Code",
8+
_LEGACY_FEEDBACK_GITHUB_OWNER + "/pythinker-code",
89
}
910
_LEGACY_DEFAULT_FEEDBACK_GITHUB_REPOS_LOWER = {
1011
repo.lower() for repo in _LEGACY_DEFAULT_FEEDBACK_GITHUB_REPOS

src/pythinker_code/ui/shell/mcp_status.py

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -8,12 +8,12 @@
88
from rich.text import Text
99

1010
from pythinker_code.ui.shell.components.render_utils import sanitize_ansi
11-
from pythinker_code.ui.shell.glyphs import TRANSCRIPT_ACTIVE_MARKER
1211
from pythinker_code.ui.shell.motion import reduced_motion_enabled
1312
from pythinker_code.ui.theme import get_mcp_prompt_colors, tui_rich_style
1413
from pythinker_code.wire.types import MCPServerSnapshot, MCPStatusSnapshot
1514

1615
_STARTING_STATUSES = frozenset({"pending", "connecting"})
16+
_MCP_STARTUP_GLYPH = "•"
1717

1818

1919
def _safe_text(text: str) -> str:
@@ -56,7 +56,7 @@ def mcp_startup_header(snapshot: MCPStatusSnapshot) -> str | None:
5656
def render_mcp_startup_text(snapshot: MCPStatusSnapshot, *, now: float | None = None) -> Text:
5757
"""Render the animated MCP startup status used by live prompt/status areas."""
5858
t = time.monotonic() if now is None else now
59-
glyph = TRANSCRIPT_ACTIVE_MARKER if reduced_motion_enabled() or int(t / 0.8) % 2 == 0 else " "
59+
glyph = _MCP_STARTUP_GLYPH if reduced_motion_enabled() or int(t / 0.8) % 2 == 0 else " "
6060
line = Text(f"{glyph} ", style=tui_rich_style("muted"))
6161
line.append(
6262
mcp_startup_header(snapshot) or "Starting MCP servers",
@@ -97,7 +97,7 @@ def render_mcp_console(snapshot: MCPStatusSnapshot) -> RenderableType:
9797

9898
def render_mcp_inventory_loading(*, now: float | None = None) -> RenderableType:
9999
t = time.monotonic() if now is None else now
100-
glyph = TRANSCRIPT_ACTIVE_MARKER if reduced_motion_enabled() or int(t / 0.8) % 2 == 0 else " "
100+
glyph = _MCP_STARTUP_GLYPH if reduced_motion_enabled() or int(t / 0.8) % 2 == 0 else " "
101101
line = Text(f"{glyph} ", style=tui_rich_style("muted"))
102102
line.append("Loading MCP inventory", style=tui_rich_style("tool_title") + Style(bold=True))
103103
line.append("…", style=tui_rich_style("muted"))
@@ -135,7 +135,7 @@ def render_mcp_prompt(snapshot: MCPStatusSnapshot, *, now: float | None = None)
135135

136136
colors = get_mcp_prompt_colors()
137137
t = time.monotonic() if now is None else now
138-
glyph = TRANSCRIPT_ACTIVE_MARKER if reduced_motion_enabled() or int(t / 0.8) % 2 == 0 else " "
138+
glyph = _MCP_STARTUP_GLYPH if reduced_motion_enabled() or int(t / 0.8) % 2 == 0 else " "
139139
prefix = f"{glyph} "
140140
return FormattedText([(colors.text, f"{prefix}{header}"), ("", "\n")])
141141

src/pythinker_code/ui/shell/update_orchestrator.py

Lines changed: 21 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -377,13 +377,11 @@ async def run_update_job(
377377
if result is UpdateResult.UPDATED and not check_only:
378378
smoke_ok, smoke_message = run_post_install_smoke_check()
379379
append_update_log(smoke_message)
380-
if not smoke_ok:
381-
reported_result = UpdateResult.FAILED
382-
final_state = UpdateJobState.FAILED
383-
message = f"Updated, but smoke check failed: {smoke_message}"
384-
else:
380+
if smoke_ok:
385381
message = smoke_message
386-
_write_last_success(job_id=job_id, message=message)
382+
else:
383+
message = f"Updated, but smoke check did not pass: {smoke_message}"
384+
_write_last_success(job_id=job_id, message=message)
387385

388386
write_update_status(
389387
_new_status(
@@ -433,7 +431,21 @@ def _write_last_success(*, job_id: str, message: str) -> None:
433431
def _smoke_check_command() -> list[str]:
434432
if is_native_build():
435433
return [sys.executable, "--version"]
436-
return [sys.executable, "-m", "pythinker_code", "--version"]
434+
return [sys.executable, "-P", "-m", "pythinker_code", "--version"]
435+
436+
437+
def _smoke_check_cwd() -> Path:
438+
try:
439+
return Path(sys.executable).resolve().parent
440+
except OSError:
441+
return Path.home()
442+
443+
444+
def _smoke_check_env() -> dict[str, str]:
445+
env = get_clean_env()
446+
env["PYTHONSAFEPATH"] = "1"
447+
env.pop("PYTHONPATH", None)
448+
return env
437449

438450

439451
def run_post_install_smoke_check() -> tuple[bool, str]:
@@ -446,7 +458,8 @@ def run_post_install_smoke_check() -> tuple[bool, str]:
446458
encoding="utf-8",
447459
errors="replace",
448460
timeout=_SMOKE_CHECK_TIMEOUT_SECONDS,
449-
env=get_clean_env(),
461+
env=_smoke_check_env(),
462+
cwd=_smoke_check_cwd(),
450463
check=False,
451464
)
452465
except (OSError, subprocess.TimeoutExpired) as exc:

src/pythinker_code/ui/shell/visualize/_live_view.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -521,7 +521,7 @@ def compose_agent_output(
521521
# it too, so a still-running agent is separated from a finished
522522
# one already committed to scrollback.
523523
_append_action_block(blocks, tool_call.compose(), leading=True)
524-
for hook_block in self._hook_blocks.values():
524+
for hook_block in getattr(self, "_hook_blocks", {}).values():
525525
_append_action_block(blocks, hook_block.compose(), leading=True)
526526
if include_working_indicator and self._active_turn_depth > 0:
527527
# Keep a stable activity indicator visible even while content or

tests/auth/test_openai_auth.py

Lines changed: 15 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -518,10 +518,7 @@ def get(self, url, *, headers, raise_for_status):
518518
base_url="https://proxy.example/backend-api/codex/",
519519
)
520520

521-
assert (
522-
captured["url"]
523-
== "https://proxy.example/backend-api/codex/models?client_version=1.0.0"
524-
)
521+
assert captured["url"] == "https://proxy.example/backend-api/codex/models?client_version=1.0.0"
525522
assert [model.id for model in models] == ["gpt-5.3-codex"]
526523

527524

@@ -632,18 +629,20 @@ async def fake_exchange_code_for_tokens(code, verifier, redirect_uri):
632629
assert redirect_uri == "https://auth.openai.com/deviceauth/callback"
633630
return {
634631
"access_token": "access-token",
632+
"id_token": _jwt_with_chatgpt_account("acc_headless"),
635633
"refresh_token": "refresh-token",
636634
"expires_in": 3600,
637635
"token_type": "Bearer",
638636
"scope": "openid profile email offline_access",
639637
}
640638

641639
async def fake_exchange_id_token_for_api_key(id_token):
640+
assert id_token == _jwt_with_chatgpt_account("acc_headless")
642641
return ""
643642

644643
async def fake_discover_chatgpt_models(api_key, *, account_id=None):
645644
assert api_key == "access-token"
646-
assert account_id is None
645+
assert account_id == "acc_headless"
647646
return [_model("gpt-5.1-codex", reasoning=True)]
648647

649648
monkeypatch.setattr("pythinker_code.auth.openai._request_device_code", fake_request_device_code)
@@ -668,6 +667,7 @@ async def fake_discover_chatgpt_models(api_key, *, account_id=None):
668667
token = load_tokens(OAuthRef(storage="file", key=OPENAI_CHATGPT_OAUTH_KEY))
669668
assert token is not None
670669
assert token.access_token == "access-token"
670+
assert token.account_id == "acc_headless"
671671
provider = config.providers[managed_provider_key(OPENAI_CHATGPT_PLATFORM_ID)]
672672
assert provider.type == "openai_codex"
673673
assert provider.oauth == OAuthRef(storage="file", key=OPENAI_CHATGPT_OAUTH_KEY)
@@ -689,14 +689,19 @@ async def fake_exchange_code_for_tokens(code, verifier, redirect_uri):
689689
assert redirect_uri.startswith("http://localhost:")
690690
return {
691691
"access_token": "access-token",
692+
"id_token": _jwt_with_chatgpt_account("acc_browser"),
692693
"refresh_token": "refresh-token",
693694
"expires_in": 3600,
694695
"token_type": "Bearer",
695696
"scope": "openid profile email offline_access",
696697
}
697698

699+
async def fake_exchange_id_token_for_api_key(id_token):
700+
assert id_token == _jwt_with_chatgpt_account("acc_browser")
701+
return ""
702+
698703
async def fake_discover_chatgpt_models(api_key, *, account_id=None):
699-
assert account_id is None
704+
assert account_id == "acc_browser"
700705
return [_model("gpt-5.1-codex", reasoning=True)]
701706

702707
monkeypatch.setattr(
@@ -705,6 +710,10 @@ async def fake_discover_chatgpt_models(api_key, *, account_id=None):
705710
monkeypatch.setattr(
706711
"pythinker_code.auth.openai._exchange_code_for_tokens", fake_exchange_code_for_tokens
707712
)
713+
monkeypatch.setattr(
714+
"pythinker_code.auth.openai._exchange_id_token_for_api_key",
715+
fake_exchange_id_token_for_api_key,
716+
)
708717
monkeypatch.setattr(
709718
"pythinker_code.auth.openai.discover_chatgpt_models", fake_discover_chatgpt_models
710719
)

tests/cli/test_update_cli.py

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
from __future__ import annotations
2+
3+
import os
4+
5+
from typer.testing import CliRunner
6+
7+
from pythinker_code.cli import cli
8+
from pythinker_code.ui.shell import update_orchestrator as orchestrator
9+
10+
11+
def _isolate_update_files(monkeypatch, tmp_path):
12+
monkeypatch.setattr(orchestrator, "UPDATE_STATUS_FILE", tmp_path / "update_status.json")
13+
monkeypatch.setattr(orchestrator, "UPDATE_LOG_FILE", tmp_path / "update.log")
14+
monkeypatch.setattr(orchestrator, "UPDATE_LOCK_FILE", tmp_path / "update.lock")
15+
monkeypatch.setattr(
16+
orchestrator,
17+
"UPDATE_LAST_SUCCESS_FILE",
18+
tmp_path / "update_last_success.json",
19+
)
20+
21+
22+
def test_update_status_command_renders_recorded_status(monkeypatch, tmp_path):
23+
_isolate_update_files(monkeypatch, tmp_path)
24+
orchestrator.write_update_status(
25+
orchestrator.UpdateJobStatus(
26+
job_id="job-1",
27+
state=orchestrator.UpdateJobState.UPDATED,
28+
started_at=1.0,
29+
finished_at=2.0,
30+
current_version="1.0.0",
31+
target_version="1.1.0",
32+
result="UPDATED",
33+
message="Smoke check passed: pythinker, version 1.1.0",
34+
log_path=str(orchestrator.UPDATE_LOG_FILE),
35+
pid=os.getpid(),
36+
source="test",
37+
)
38+
)
39+
40+
result = CliRunner().invoke(cli, ["update", "status"])
41+
42+
assert result.exit_code == 0, result.output
43+
assert "State: updated" in result.output
44+
assert "Result: UPDATED" in result.output
45+
assert "Current version: 1.0.0" in result.output
46+
assert "Target version: 1.1.0" in result.output
47+
assert "Message: Smoke check passed" in result.output
48+
assert f"Log: {orchestrator.UPDATE_LOG_FILE}" in result.output
49+
50+
51+
def test_update_log_command_respects_line_count(monkeypatch, tmp_path):
52+
_isolate_update_files(monkeypatch, tmp_path)
53+
for idx in range(4):
54+
orchestrator.append_update_log(f"line {idx}")
55+
56+
result = CliRunner().invoke(cli, ["update", "log", "--lines", "2"])
57+
58+
assert result.exit_code == 0, result.output
59+
assert result.output.splitlines() == ["line 2", "line 3"]

tests/core/test_config.py

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -97,9 +97,8 @@ def test_load_config_text_json():
9797

9898

9999
def test_load_config_migrates_legacy_feedback_repo_default():
100-
config = load_config_from_string(
101-
'[feedback]\ngithub_repo = "mohamed-elkholy95/Pythinker-Code"\n'
102-
)
100+
old_owner = "mohamed-elkholy95"
101+
config = load_config_from_string(f'[feedback]\ngithub_repo = "{old_owner}/Pythinker-Code"\n')
103102

104103
assert config.feedback.github_repo == "TechMatrix-labs/pythinker-code"
105104

tests/ui_and_conv/test_shell_feedback_slash.py

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -298,11 +298,12 @@ def post(self, *_args: object, **_kwargs: object) -> FakeResponse:
298298

299299
def test_feedback_issue_url_migrates_legacy_default_repo(self) -> None:
300300
payload = {"type": "other", "content": "hi"}
301+
old_owner = "mohamed-elkholy95"
301302

302-
url = build_feedback_issue_url(payload, "mohamed-elkholy95/Pythinker-Code")
303+
url = build_feedback_issue_url(payload, old_owner + "/Pythinker-Code")
303304

304305
assert "github.com/TechMatrix-labs/pythinker-code/issues/new" in url
305-
assert "mohamed-elkholy95" not in url
306+
assert old_owner not in url
306307

307308
def test_feedback_issue_url_uses_compact_body_for_large_payload(self) -> None:
308309
payload = {

tests/ui_and_conv/test_shell_update.py

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -698,6 +698,71 @@ def fake_run(*args, **kwargs):
698698
assert spawned and "pythinker-code" in spawned[0]
699699

700700

701+
def test_run_upgrade_command_streams_subprocess_output(monkeypatch):
702+
messages: list[str] = []
703+
launched: list[list[str]] = []
704+
705+
class FakeProc:
706+
stdout = ["first line\n", "second line\n"]
707+
708+
def wait(self, *, timeout: float) -> int:
709+
assert timeout == update.UPGRADE_COMMAND_TIMEOUT_SECONDS
710+
return 0
711+
712+
def fake_popen(command, **kwargs):
713+
launched.append(command)
714+
assert kwargs["stdout"] is update.subprocess.PIPE
715+
assert kwargs["stderr"] is update.subprocess.STDOUT
716+
assert kwargs["text"] is True
717+
return FakeProc()
718+
719+
monkeypatch.setattr(update.subprocess, "Popen", fake_popen)
720+
721+
returncode = update._run_upgrade_command(
722+
["uv", "tool", "upgrade", "pythinker-code"],
723+
print_output=False,
724+
output_callback=messages.append,
725+
)
726+
727+
assert returncode == 0
728+
assert launched == [["uv", "tool", "upgrade", "pythinker-code"]]
729+
assert messages == ["first line", "second line"]
730+
731+
732+
@pytest.mark.asyncio
733+
async def test_do_update_reports_non_native_upgrade_failure_to_callback(monkeypatch, tmp_path):
734+
messages: list[str] = []
735+
736+
async def fake_get_latest(session):
737+
return "999.0.0"
738+
739+
async def fake_unavailable(session, latest_version: str, upgrade_command: list[str]):
740+
return None
741+
742+
def fake_run_upgrade_command(command, *, print_output: bool, output_callback):
743+
assert command == ["uv", "tool", "upgrade", "pythinker-code"]
744+
assert print_output is False
745+
assert output_callback is not None
746+
output_callback("installer said no")
747+
return 2
748+
749+
monkeypatch.setattr(update, "LATEST_VERSION_FILE", tmp_path / "latest.txt")
750+
monkeypatch.setattr(update, "_get_latest_version", fake_get_latest)
751+
monkeypatch.setattr(update, "_update_candidate_unavailable_reason", fake_unavailable)
752+
monkeypatch.setattr(
753+
update,
754+
"_detect_upgrade_command",
755+
lambda: ["uv", "tool", "upgrade", "pythinker-code"],
756+
)
757+
monkeypatch.setattr(update, "_run_upgrade_command", fake_run_upgrade_command)
758+
759+
result = await update.do_update(print_output=False, output_callback=messages.append)
760+
761+
assert result is update.UpdateResult.FAILED
762+
assert "installer said no" in messages
763+
assert any("Upgrade failed" in message for message in messages)
764+
765+
701766
@pytest.mark.asyncio
702767
async def test_do_update_uses_native_installer_marker(monkeypatch, tmp_path):
703768
native_versions: list[str] = []

tests/ui_and_conv/test_shell_welcome_info.py

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -116,6 +116,48 @@ def test_welcome_strapline_and_help_on_separate_lines(monkeypatch):
116116
assert not any("Build with confidence." in ln and "Type /help" in ln for ln in out.splitlines())
117117

118118

119+
def test_welcome_banner_layout_width_matrix(monkeypatch):
120+
from pythinker_code.ui.shell import WelcomeInfoItem
121+
from pythinker_code.ui.shell.components.render_utils import cell_width
122+
123+
monkeypatch.setattr(shell_module, "get_version", lambda: "9.9.9")
124+
items = [
125+
WelcomeInfoItem(name="Directory", value="/home/ai/Projects/pythinker-code-main"),
126+
WelcomeInfoItem(name="Model", value="gpt-5.1-codex"),
127+
WelcomeInfoItem(name="Branch", value="feat/welcome-banner-redesign"),
128+
WelcomeInfoItem(
129+
name="Tip",
130+
value="Use /update after release promotion completes and /help for commands.",
131+
),
132+
]
133+
134+
for width in (60, 80, 120):
135+
console = Console(record=True, width=width, color_system=None)
136+
monkeypatch.setattr(shell_module, "console", console)
137+
138+
shell_module._print_welcome_info(
139+
"Pythinker Code",
140+
items,
141+
banner=Text("↑ Update available — v9.9.10 · /update"),
142+
)
143+
144+
output = console.export_text()
145+
lines = [line.rstrip() for line in output.splitlines() if line.strip()]
146+
max_panel_width = min(width, shell_module._WELCOME_MAX_WIDTH)
147+
assert all(cell_width(line) <= max_panel_width for line in lines)
148+
assert "Pythinker Code v9.9.9" in lines[0]
149+
assert "Welcome to Pythinker" in output
150+
assert "Directory" in output
151+
assert "gpt-5.1-codex" in output
152+
assert "Tips" in output
153+
assert "/update" in lines[-1]
154+
assert "/help" in output
155+
if width == 60:
156+
assert "▛" not in output
157+
else:
158+
assert "▛" in output
159+
160+
119161
def test_welcome_auto_save_path_is_middle_truncated_not_wrapped(monkeypatch):
120162
from pythinker_code.ui.shell import WelcomeInfoItem
121163

0 commit comments

Comments
 (0)