claude-dev-env 8.14.0 → 8.15.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,331 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import subprocess
5
+ from collections.abc import Callable
6
+ from pathlib import Path
7
+ from typing import Any
8
+
9
+ import pytest
10
+
11
+ import claude_account_worker
12
+ from claude_account_worker import (
13
+ AccountSelection,
14
+ WorkerDependencies,
15
+ build_argument_parser,
16
+ select_account,
17
+ run_worker,
18
+ )
19
+ from dev_env_scripts_constants.claude_account_worker_constants import (
20
+ CHOICE_MAIN,
21
+ CHOICE_SECOND,
22
+ CHOICE_WAIT,
23
+ CLAUDE_CONFIG_DIR_ENV_VAR,
24
+ STDOUT_TAIL_CHARACTER_LIMIT,
25
+ TIMEOUT_EXIT_CODE,
26
+ WAIT_EXIT_CODE,
27
+ )
28
+
29
+
30
+ class FakeProcess:
31
+ def __init__(
32
+ self,
33
+ *,
34
+ stdout: str = '{"result":"ready","is_error":false}',
35
+ returncode: int = 0,
36
+ timeout_first: bool = False,
37
+ timeout_output: str = "",
38
+ drain_timeout_count: int = 0,
39
+ ) -> None:
40
+ self.stdout = stdout
41
+ self.returncode = returncode
42
+ self.timeout_first = timeout_first
43
+ self.timeout_output = timeout_output
44
+ self.drain_timeout_count = drain_timeout_count
45
+ self.pid = 42
46
+ self.communicate_calls: list[tuple[str | None, float | None]] = []
47
+ self.terminated = False
48
+
49
+ def communicate(
50
+ self,
51
+ input: str | None = None,
52
+ timeout: float | None = None,
53
+ ) -> tuple[str, str]:
54
+ self.communicate_calls.append((input, timeout))
55
+ if self.timeout_first and len(self.communicate_calls) == 1:
56
+ raise subprocess.TimeoutExpired(
57
+ cmd="claude",
58
+ timeout=timeout,
59
+ output=self.timeout_output,
60
+ )
61
+ if self.drain_timeout_count:
62
+ self.drain_timeout_count -= 1
63
+ raise subprocess.TimeoutExpired(
64
+ cmd="claude",
65
+ timeout=timeout,
66
+ output=self.timeout_output,
67
+ )
68
+ return self.stdout, ""
69
+
70
+ def poll(self) -> int | None:
71
+ return self.returncode if self.terminated else None
72
+
73
+ def kill(self) -> None:
74
+ self.terminated = True
75
+ self.returncode = -9
76
+
77
+
78
+ def _selection(
79
+ account: str,
80
+ *,
81
+ config_dir: Path | None = None,
82
+ reason: str = "selected by test",
83
+ ) -> AccountSelection:
84
+ return AccountSelection(account=account, config_dir=config_dir, reason=reason)
85
+
86
+
87
+ def _make_runner(
88
+ process: FakeProcess,
89
+ captured: dict[str, Any],
90
+ ) -> Callable[..., FakeProcess]:
91
+ def run(arguments: list[str], **options: Any) -> FakeProcess:
92
+ captured["arguments"] = arguments
93
+ captured.update(options)
94
+ return process
95
+
96
+ return run
97
+
98
+
99
+ def _run_worker(
100
+ tmp_path: Path,
101
+ *,
102
+ selection: AccountSelection,
103
+ process: FakeProcess,
104
+ parent_environment: dict[str, str] | None = None,
105
+ process_terminator: Callable[[FakeProcess], None] | None = None,
106
+ model: str | None = None,
107
+ permission_mode: str = "auto",
108
+ ) -> tuple[int, dict[str, Any], dict[str, Any]]:
109
+ prompt_file = tmp_path / "brief.md"
110
+ prompt_file.write_text("standalone brief", encoding="utf-8")
111
+ report_file = tmp_path / "report.json"
112
+ captured: dict[str, Any] = {}
113
+ runner = _make_runner(process, captured)
114
+ dependencies = WorkerDependencies(
115
+ process_factory=runner,
116
+ all_parent_environment_variables=(
117
+ {"KEEP_ME": "present"} if parent_environment is None else parent_environment
118
+ ),
119
+ binary_resolver=lambda _: "claude.exe",
120
+ process_terminator=(
121
+ process_terminator if process_terminator is not None else lambda _: None
122
+ ),
123
+ monotonic_clock=lambda: 1.0,
124
+ )
125
+ exit_code = run_worker(
126
+ prompt_file=prompt_file,
127
+ cwd=tmp_path,
128
+ report_file=report_file,
129
+ model=model,
130
+ permission_mode=permission_mode,
131
+ timeout_minutes=60,
132
+ decision_selector=lambda: selection,
133
+ dependencies=dependencies,
134
+ )
135
+ return exit_code, json.loads(report_file.read_text(encoding="utf-8")), captured
136
+
137
+
138
+ def test_should_set_second_profile_and_pass_parent_environment(
139
+ tmp_path: Path,
140
+ ) -> None:
141
+ profile_dir = tmp_path / "second-profile"
142
+ process = FakeProcess()
143
+ exit_code, report, captured = _run_worker(
144
+ tmp_path,
145
+ selection=_selection(CHOICE_SECOND, config_dir=profile_dir),
146
+ process=process,
147
+ parent_environment={
148
+ CLAUDE_CONFIG_DIR_ENV_VAR: "inherited-profile",
149
+ "KEEP_ME": "present",
150
+ },
151
+ model="sonnet",
152
+ permission_mode="plan",
153
+ )
154
+
155
+ assert exit_code == 0
156
+ assert report["account"] == CHOICE_SECOND
157
+ assert captured["env"][CLAUDE_CONFIG_DIR_ENV_VAR] == str(profile_dir)
158
+ assert captured["env"]["KEEP_ME"] == "present"
159
+ assert captured["arguments"] == [
160
+ "claude.exe",
161
+ "-p",
162
+ "--output-format",
163
+ "json",
164
+ "--permission-mode",
165
+ "plan",
166
+ "--model",
167
+ "sonnet",
168
+ ]
169
+ assert captured["cwd"] == str(tmp_path)
170
+ assert process.communicate_calls[0][0] == "standalone brief"
171
+
172
+
173
+ def test_should_remove_inherited_profile_for_main_account(tmp_path: Path) -> None:
174
+ process = FakeProcess()
175
+ exit_code, report, captured = _run_worker(
176
+ tmp_path,
177
+ selection=_selection(CHOICE_MAIN, config_dir=tmp_path / "main-profile"),
178
+ process=process,
179
+ parent_environment={
180
+ CLAUDE_CONFIG_DIR_ENV_VAR: "inherited-profile",
181
+ "KEEP_ME": "present",
182
+ },
183
+ )
184
+
185
+ assert exit_code == 0
186
+ assert report["account"] == CHOICE_MAIN
187
+ assert CLAUDE_CONFIG_DIR_ENV_VAR not in captured["env"]
188
+ assert captured["env"]["KEEP_ME"] == "present"
189
+
190
+
191
+ def test_should_write_wait_reason_and_start_no_process(tmp_path: Path) -> None:
192
+ report_file = tmp_path / "wait-report.json"
193
+ process_started = False
194
+
195
+ def forbidden_runner(*_: Any, **__: Any) -> FakeProcess:
196
+ nonlocal process_started
197
+ process_started = True
198
+ raise AssertionError("wait must not start a process")
199
+
200
+ exit_code = run_worker(
201
+ prompt_file=tmp_path / "missing-brief.md",
202
+ cwd=tmp_path,
203
+ report_file=report_file,
204
+ model=None,
205
+ permission_mode="auto",
206
+ timeout_minutes=60,
207
+ decision_selector=lambda: _selection(
208
+ CHOICE_WAIT,
209
+ reason="second account resets at 2026-09-24T12:00:00Z",
210
+ ),
211
+ dependencies=WorkerDependencies(
212
+ process_factory=forbidden_runner,
213
+ all_parent_environment_variables={},
214
+ binary_resolver=lambda _: (_ for _ in ()).throw(
215
+ AssertionError("wait must not resolve a binary")
216
+ ),
217
+ process_terminator=lambda _: None,
218
+ monotonic_clock=lambda: 1.0,
219
+ ),
220
+ )
221
+ report = json.loads(report_file.read_text(encoding="utf-8"))
222
+
223
+ assert exit_code == WAIT_EXIT_CODE
224
+ assert process_started is False
225
+ assert report["account"] == CHOICE_WAIT
226
+ assert report["reason"] == "second account resets at 2026-09-24T12:00:00Z"
227
+ assert report["is_error"] is True
228
+
229
+
230
+ def test_should_kill_child_on_timeout_and_exit_124(tmp_path: Path) -> None:
231
+ process = FakeProcess(
232
+ stdout='{"result":"partial"}',
233
+ returncode=-9,
234
+ timeout_first=True,
235
+ timeout_output='{"result":"partial"}',
236
+ )
237
+ termination_calls: list[FakeProcess] = []
238
+ exit_code, report, _ = _run_worker(
239
+ tmp_path,
240
+ selection=_selection(CHOICE_SECOND, config_dir=tmp_path / "profile"),
241
+ process=process,
242
+ process_terminator=lambda child: termination_calls.append(child),
243
+ )
244
+
245
+ assert exit_code == TIMEOUT_EXIT_CODE
246
+ assert termination_calls == [process]
247
+ assert len(process.communicate_calls) == 2
248
+ assert report["exit_code"] == TIMEOUT_EXIT_CODE
249
+ assert report["is_error"] is True
250
+
251
+
252
+ def test_should_copy_parsed_result_into_report(tmp_path: Path) -> None:
253
+ process = FakeProcess(stdout='{"result":"ready","is_error":false}')
254
+ exit_code, report, _ = _run_worker(
255
+ tmp_path,
256
+ selection=_selection(CHOICE_SECOND, config_dir=tmp_path / "profile"),
257
+ process=process,
258
+ )
259
+
260
+ assert exit_code == 0
261
+ assert report["result"] == "ready"
262
+ assert report["is_error"] is False
263
+
264
+
265
+ def test_should_write_raw_stdout_tail_when_output_is_not_json(tmp_path: Path) -> None:
266
+ stdout_text = "x" * (STDOUT_TAIL_CHARACTER_LIMIT - 4) + "tail"
267
+ process = FakeProcess(stdout=stdout_text)
268
+ exit_code, report, _ = _run_worker(
269
+ tmp_path,
270
+ selection=_selection(CHOICE_SECOND, config_dir=tmp_path / "profile"),
271
+ process=process,
272
+ )
273
+
274
+ assert exit_code == 0
275
+ assert report["result"] == stdout_text[-STDOUT_TAIL_CHARACTER_LIMIT:]
276
+ assert report["is_error"] is True
277
+
278
+
279
+ def test_should_preserve_child_exit_code_three_for_selected_account(
280
+ tmp_path: Path,
281
+ ) -> None:
282
+ process = FakeProcess(returncode=3)
283
+ exit_code, report, _ = _run_worker(
284
+ tmp_path,
285
+ selection=_selection(CHOICE_SECOND, config_dir=tmp_path / "profile"),
286
+ process=process,
287
+ )
288
+
289
+ assert exit_code == 3
290
+ assert report["account"] == CHOICE_SECOND
291
+ assert report["exit_code"] == 3
292
+ assert report["is_error"] is True
293
+
294
+
295
+ def test_should_keep_claude_json_error_flag(tmp_path: Path) -> None:
296
+ process = FakeProcess(stdout='{"result":"failed","is_error":true}')
297
+ exit_code, report, _ = _run_worker(
298
+ tmp_path,
299
+ selection=_selection(CHOICE_SECOND, config_dir=tmp_path / "profile"),
300
+ process=process,
301
+ )
302
+
303
+ assert exit_code == 0
304
+ assert report["result"] == "failed"
305
+ assert report["is_error"] is True
306
+
307
+
308
+ def test_should_pick_second_account_when_neither_meter_is_readable(
309
+ monkeypatch: pytest.MonkeyPatch, tmp_path: Path
310
+ ) -> None:
311
+ main_home = tmp_path / "main-home"
312
+ second_home = tmp_path / "second-home"
313
+ monkeypatch.setattr(Path, "home", lambda: main_home)
314
+ monkeypatch.setattr(claude_account_worker, "default_profile_home", lambda: second_home)
315
+
316
+ selection = select_account()
317
+
318
+ assert selection.account == CHOICE_SECOND
319
+ assert selection.config_dir == second_home
320
+
321
+
322
+ def test_should_require_prompt_file_and_report_file_flags() -> None:
323
+ parser = build_argument_parser()
324
+
325
+ arguments = parser.parse_args(
326
+ ["--prompt-file", "brief.md", "--report-file", "report.json"]
327
+ )
328
+
329
+ assert arguments.prompt_file == Path("brief.md")
330
+ assert arguments.report_file == Path("report.json")
331
+ assert arguments.permission_mode == "auto"
@@ -0,0 +1,47 @@
1
+ """Check vendor rate parsing through the task report command."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import subprocess
7
+ import sys
8
+ from pathlib import Path
9
+
10
+ SCRIPT_PATH = Path(__file__).with_name("task_usage_report.py")
11
+
12
+
13
+ def test_rejects_rates_missing_a_token_category() -> None:
14
+ all_events = [
15
+ {"type": "thread.started", "thread_id": "thread-1"},
16
+ {"type": "turn.completed", "model": "gpt-x", "usage": {"input_tokens": 1}},
17
+ ]
18
+ completed = subprocess.run(
19
+ [
20
+ sys.executable,
21
+ str(SCRIPT_PATH),
22
+ "run",
23
+ "--task-id",
24
+ "task-1",
25
+ "--run-id",
26
+ "run-1",
27
+ "--repository",
28
+ "team/project",
29
+ "--outcome",
30
+ "pass",
31
+ "--source-format",
32
+ "codex-json",
33
+ "--rate",
34
+ "gpt-x=1,2,3",
35
+ "--rate-source",
36
+ "vendor-price-card",
37
+ "--rate-effective-date",
38
+ "2026-09-24",
39
+ ],
40
+ input="\n".join(map(json.dumps, all_events)),
41
+ capture_output=True,
42
+ text=True,
43
+ check=False,
44
+ )
45
+ assert completed.returncode == 2
46
+ assert completed.stdout == ""
47
+ assert "rate needs four prices per million tokens" in completed.stderr