wafer-cli 0.2.2__tar.gz → 0.2.4__tar.gz

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.
Files changed (50) hide show
  1. {wafer_cli-0.2.2 → wafer_cli-0.2.4}/PKG-INFO +2 -1
  2. {wafer_cli-0.2.2 → wafer_cli-0.2.4}/pyproject.toml +2 -1
  3. wafer_cli-0.2.4/tests/test_analytics.py +555 -0
  4. wafer_cli-0.2.4/wafer/analytics.py +307 -0
  5. {wafer_cli-0.2.2 → wafer_cli-0.2.4}/wafer/auth.py +4 -2
  6. {wafer_cli-0.2.2 → wafer_cli-0.2.4}/wafer/cli.py +661 -15
  7. {wafer_cli-0.2.2 → wafer_cli-0.2.4}/wafer/evaluate.py +760 -268
  8. {wafer_cli-0.2.2 → wafer_cli-0.2.4}/wafer/global_config.py +14 -3
  9. {wafer_cli-0.2.2 → wafer_cli-0.2.4}/wafer/gpu_run.py +5 -1
  10. wafer_cli-0.2.4/wafer/problems.py +357 -0
  11. {wafer_cli-0.2.2 → wafer_cli-0.2.4}/wafer/wevin_cli.py +22 -2
  12. {wafer_cli-0.2.2 → wafer_cli-0.2.4}/wafer_cli.egg-info/PKG-INFO +2 -1
  13. {wafer_cli-0.2.2 → wafer_cli-0.2.4}/wafer_cli.egg-info/SOURCES.txt +3 -0
  14. {wafer_cli-0.2.2 → wafer_cli-0.2.4}/wafer_cli.egg-info/requires.txt +1 -0
  15. {wafer_cli-0.2.2 → wafer_cli-0.2.4}/README.md +0 -0
  16. {wafer_cli-0.2.2 → wafer_cli-0.2.4}/setup.cfg +0 -0
  17. {wafer_cli-0.2.2 → wafer_cli-0.2.4}/tests/test_billing.py +0 -0
  18. {wafer_cli-0.2.2 → wafer_cli-0.2.4}/tests/test_cli_coverage.py +0 -0
  19. {wafer_cli-0.2.2 → wafer_cli-0.2.4}/tests/test_cli_parity_integration.py +0 -0
  20. {wafer_cli-0.2.2 → wafer_cli-0.2.4}/tests/test_config_integration.py +0 -0
  21. {wafer_cli-0.2.2 → wafer_cli-0.2.4}/tests/test_file_operations_integration.py +0 -0
  22. {wafer_cli-0.2.2 → wafer_cli-0.2.4}/tests/test_isa_cli.py +0 -0
  23. {wafer_cli-0.2.2 → wafer_cli-0.2.4}/tests/test_rocprof_compute_integration.py +0 -0
  24. {wafer_cli-0.2.2 → wafer_cli-0.2.4}/tests/test_ssh_integration.py +0 -0
  25. {wafer_cli-0.2.2 → wafer_cli-0.2.4}/tests/test_wevin_cli.py +0 -0
  26. {wafer_cli-0.2.2 → wafer_cli-0.2.4}/tests/test_workflow_integration.py +0 -0
  27. {wafer_cli-0.2.2 → wafer_cli-0.2.4}/wafer/GUIDE.md +0 -0
  28. {wafer_cli-0.2.2 → wafer_cli-0.2.4}/wafer/__init__.py +0 -0
  29. {wafer_cli-0.2.2 → wafer_cli-0.2.4}/wafer/api_client.py +0 -0
  30. {wafer_cli-0.2.2 → wafer_cli-0.2.4}/wafer/autotuner.py +0 -0
  31. {wafer_cli-0.2.2 → wafer_cli-0.2.4}/wafer/billing.py +0 -0
  32. {wafer_cli-0.2.2 → wafer_cli-0.2.4}/wafer/config.py +0 -0
  33. {wafer_cli-0.2.2 → wafer_cli-0.2.4}/wafer/corpus.py +0 -0
  34. {wafer_cli-0.2.2 → wafer_cli-0.2.4}/wafer/inference.py +0 -0
  35. {wafer_cli-0.2.2 → wafer_cli-0.2.4}/wafer/ncu_analyze.py +0 -0
  36. {wafer_cli-0.2.2 → wafer_cli-0.2.4}/wafer/nsys_analyze.py +0 -0
  37. {wafer_cli-0.2.2 → wafer_cli-0.2.4}/wafer/rocprof_compute.py +0 -0
  38. {wafer_cli-0.2.2 → wafer_cli-0.2.4}/wafer/rocprof_sdk.py +0 -0
  39. {wafer_cli-0.2.2 → wafer_cli-0.2.4}/wafer/rocprof_systems.py +0 -0
  40. {wafer_cli-0.2.2 → wafer_cli-0.2.4}/wafer/skills/wafer-guide/SKILL.md +0 -0
  41. {wafer_cli-0.2.2 → wafer_cli-0.2.4}/wafer/targets.py +0 -0
  42. {wafer_cli-0.2.2 → wafer_cli-0.2.4}/wafer/templates/__init__.py +0 -0
  43. {wafer_cli-0.2.2 → wafer_cli-0.2.4}/wafer/templates/ask_docs.py +0 -0
  44. {wafer_cli-0.2.2 → wafer_cli-0.2.4}/wafer/templates/optimize_kernel.py +0 -0
  45. {wafer_cli-0.2.2 → wafer_cli-0.2.4}/wafer/templates/trace_analyze.py +0 -0
  46. {wafer_cli-0.2.2 → wafer_cli-0.2.4}/wafer/tracelens.py +0 -0
  47. {wafer_cli-0.2.2 → wafer_cli-0.2.4}/wafer/workspaces.py +0 -0
  48. {wafer_cli-0.2.2 → wafer_cli-0.2.4}/wafer_cli.egg-info/dependency_links.txt +0 -0
  49. {wafer_cli-0.2.2 → wafer_cli-0.2.4}/wafer_cli.egg-info/entry_points.txt +0 -0
  50. {wafer_cli-0.2.2 → wafer_cli-0.2.4}/wafer_cli.egg-info/top_level.txt +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: wafer-cli
3
- Version: 0.2.2
3
+ Version: 0.2.4
4
4
  Summary: CLI tool for running commands on remote GPUs and GPU kernel optimization agent
5
5
  Requires-Python: >=3.11
6
6
  Requires-Dist: typer>=0.12.0
@@ -8,6 +8,7 @@ Requires-Dist: trio>=0.24.0
8
8
  Requires-Dist: trio-asyncio>=0.15.0
9
9
  Requires-Dist: wafer-core>=0.1.0
10
10
  Requires-Dist: perfetto>=0.16.0
11
+ Requires-Dist: posthog>=3.0.0
11
12
  Provides-Extra: dev
12
13
  Requires-Dist: pytest>=8.0.0; extra == "dev"
13
14
  Requires-Dist: pytest-cov>=4.1.0; extra == "dev"
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "wafer-cli"
3
- version = "0.2.2"
3
+ version = "0.2.4"
4
4
  description = "CLI tool for running commands on remote GPUs and GPU kernel optimization agent"
5
5
  requires-python = ">=3.11"
6
6
  dependencies = [
@@ -10,6 +10,7 @@ dependencies = [
10
10
  # Wafer core for environments and utils (includes rollouts)
11
11
  "wafer-core>=0.1.0",
12
12
  "perfetto>=0.16.0",
13
+ "posthog>=3.0.0", # Analytics tracking
13
14
  ]
14
15
 
15
16
  [project.scripts]
@@ -0,0 +1,555 @@
1
+ """Unit tests for CLI analytics module.
2
+
3
+ Tests cover:
4
+ - PostHog client initialization
5
+ - User identification
6
+ - Event tracking (commands, login, logout)
7
+ - Anonymous ID generation
8
+ - Analytics opt-out via preferences
9
+ - Graceful error handling
10
+
11
+ Run with: PYTHONPATH=apps/wafer-cli uv run pytest apps/wafer-cli/tests/test_analytics.py -v
12
+ """
13
+
14
+ import json
15
+ from pathlib import Path
16
+ from unittest.mock import MagicMock, patch
17
+
18
+ import pytest
19
+
20
+
21
+ class TestAnalyticsInit:
22
+ """Test analytics initialization."""
23
+
24
+ def test_init_analytics_creates_client(self) -> None:
25
+ """init_analytics should create PostHog client when enabled."""
26
+ # Reset module state
27
+ from wafer import analytics
28
+
29
+ analytics._initialized = False
30
+ analytics._posthog_client = None
31
+ analytics._distinct_id = None
32
+
33
+ with patch("wafer.analytics._is_analytics_enabled", return_value=True), \
34
+ patch("wafer.analytics._get_user_id_from_credentials", return_value=(None, None)), \
35
+ patch("posthog.Posthog") as mock_posthog:
36
+ mock_client = MagicMock()
37
+ mock_posthog.return_value = mock_client
38
+
39
+ result = analytics.init_analytics()
40
+
41
+ assert result is True
42
+ mock_posthog.assert_called_once()
43
+ assert analytics._posthog_client is mock_client
44
+
45
+ def test_init_analytics_respects_disabled_preference(self) -> None:
46
+ """init_analytics should not create client when analytics disabled."""
47
+ from wafer import analytics
48
+
49
+ analytics._initialized = False
50
+ analytics._posthog_client = None
51
+ analytics._distinct_id = None
52
+
53
+ with patch("wafer.analytics._is_analytics_enabled", return_value=False):
54
+ result = analytics.init_analytics()
55
+
56
+ assert result is False
57
+ assert analytics._posthog_client is None
58
+
59
+ def test_init_analytics_only_initializes_once(self) -> None:
60
+ """init_analytics should only create client once."""
61
+ from wafer import analytics
62
+
63
+ analytics._initialized = False
64
+ analytics._posthog_client = None
65
+
66
+ with patch("wafer.analytics._is_analytics_enabled", return_value=True), \
67
+ patch("wafer.analytics._get_user_id_from_credentials", return_value=(None, None)), \
68
+ patch("posthog.Posthog") as mock_posthog:
69
+ mock_client = MagicMock()
70
+ mock_posthog.return_value = mock_client
71
+
72
+ # First call
73
+ analytics.init_analytics()
74
+ # Second call
75
+ analytics.init_analytics()
76
+
77
+ # Should only be called once
78
+ assert mock_posthog.call_count == 1
79
+
80
+ def test_init_analytics_handles_import_error(self) -> None:
81
+ """init_analytics should handle missing posthog package."""
82
+ from wafer import analytics
83
+
84
+ analytics._initialized = False
85
+ analytics._posthog_client = None
86
+
87
+ with patch("wafer.analytics._is_analytics_enabled", return_value=True), \
88
+ patch.dict("sys.modules", {"posthog": None}):
89
+ # Force reimport to trigger ImportError
90
+ analytics._initialized = False
91
+ # This should not raise, just return False
92
+ # (In practice the import error is caught)
93
+
94
+
95
+ class TestAnonymousId:
96
+ """Test anonymous ID generation and persistence."""
97
+
98
+ def test_get_anonymous_id_creates_new(self, tmp_path: Path) -> None:
99
+ """_get_anonymous_id should create new ID if none exists."""
100
+ from wafer import analytics
101
+
102
+ # Use temp path for anonymous ID
103
+ analytics_id_file = tmp_path / ".analytics_id"
104
+
105
+ with patch.object(analytics, "ANONYMOUS_ID_FILE", analytics_id_file):
106
+ anon_id = analytics._get_anonymous_id()
107
+
108
+ assert anon_id.startswith("anon_")
109
+ assert len(anon_id) > 5
110
+ assert analytics_id_file.exists()
111
+ assert analytics_id_file.read_text().strip() == anon_id
112
+
113
+ def test_get_anonymous_id_reuses_existing(self, tmp_path: Path) -> None:
114
+ """_get_anonymous_id should reuse existing ID."""
115
+ from wafer import analytics
116
+
117
+ analytics_id_file = tmp_path / ".analytics_id"
118
+ existing_id = "anon_existing123"
119
+ analytics_id_file.write_text(existing_id)
120
+
121
+ with patch.object(analytics, "ANONYMOUS_ID_FILE", analytics_id_file):
122
+ anon_id = analytics._get_anonymous_id()
123
+
124
+ assert anon_id == existing_id
125
+
126
+
127
+ class TestUserIdentification:
128
+ """Test user identification functions."""
129
+
130
+ def test_identify_user_sets_distinct_id(self) -> None:
131
+ """identify_user should set distinct_id and call PostHog identify."""
132
+ from wafer import analytics
133
+
134
+ analytics._initialized = False
135
+ analytics._posthog_client = None
136
+ analytics._distinct_id = None
137
+
138
+ mock_client = MagicMock()
139
+
140
+ with patch("wafer.analytics._is_analytics_enabled", return_value=True), \
141
+ patch("wafer.analytics._get_user_id_from_credentials", return_value=(None, None)), \
142
+ patch("posthog.Posthog", return_value=mock_client):
143
+ analytics.init_analytics()
144
+ analytics.identify_user("user-123", "test@example.com")
145
+
146
+ assert analytics._distinct_id == "user-123"
147
+ mock_client.identify.assert_called()
148
+ call_kwargs = mock_client.identify.call_args[1]
149
+ assert call_kwargs["distinct_id"] == "user-123"
150
+ assert call_kwargs["properties"]["email"] == "test@example.com"
151
+
152
+ def test_identify_user_without_email(self) -> None:
153
+ """identify_user should work without email."""
154
+ from wafer import analytics
155
+
156
+ analytics._initialized = False
157
+ analytics._posthog_client = None
158
+ analytics._distinct_id = None
159
+
160
+ mock_client = MagicMock()
161
+
162
+ with patch("wafer.analytics._is_analytics_enabled", return_value=True), \
163
+ patch("wafer.analytics._get_user_id_from_credentials", return_value=(None, None)), \
164
+ patch("posthog.Posthog", return_value=mock_client):
165
+ analytics.init_analytics()
166
+ analytics.identify_user("user-456")
167
+
168
+ assert analytics._distinct_id == "user-456"
169
+ mock_client.identify.assert_called()
170
+
171
+ def test_reset_user_identity(self, tmp_path: Path) -> None:
172
+ """reset_user_identity should revert to anonymous ID."""
173
+ from wafer import analytics
174
+
175
+ analytics._distinct_id = "user-123"
176
+ analytics_id_file = tmp_path / ".analytics_id"
177
+ analytics_id_file.write_text("anon_saved")
178
+
179
+ with patch.object(analytics, "ANONYMOUS_ID_FILE", analytics_id_file):
180
+ analytics.reset_user_identity()
181
+
182
+ assert analytics._distinct_id == "anon_saved"
183
+
184
+
185
+ class TestEventTracking:
186
+ """Test event tracking functions."""
187
+
188
+ def test_track_event_sends_to_posthog(self) -> None:
189
+ """track_event should send event to PostHog."""
190
+ from wafer import analytics
191
+
192
+ analytics._initialized = False
193
+ analytics._posthog_client = None
194
+ analytics._distinct_id = "test-user"
195
+
196
+ mock_client = MagicMock()
197
+
198
+ with patch("wafer.analytics._is_analytics_enabled", return_value=True), \
199
+ patch("wafer.analytics._get_user_id_from_credentials", return_value=("test-user", None)), \
200
+ patch("posthog.Posthog", return_value=mock_client):
201
+ analytics.init_analytics()
202
+ analytics.track_event("test_event", {"key": "value"})
203
+
204
+ mock_client.capture.assert_called_once()
205
+ call_kwargs = mock_client.capture.call_args[1]
206
+ assert call_kwargs["event"] == "test_event"
207
+ assert call_kwargs["distinct_id"] == "test-user"
208
+ assert call_kwargs["properties"]["key"] == "value"
209
+ assert call_kwargs["properties"]["platform"] == "cli"
210
+ assert call_kwargs["properties"]["tool_id"] == "cli"
211
+
212
+ def test_track_command_sends_cli_command_executed(self) -> None:
213
+ """track_command should send cli_command_executed event."""
214
+ from wafer import analytics
215
+
216
+ analytics._initialized = False
217
+ analytics._posthog_client = None
218
+ analytics._distinct_id = "test-user"
219
+
220
+ mock_client = MagicMock()
221
+
222
+ with patch("wafer.analytics._is_analytics_enabled", return_value=True), \
223
+ patch("wafer.analytics._get_user_id_from_credentials", return_value=("test-user", None)), \
224
+ patch("posthog.Posthog", return_value=mock_client):
225
+ analytics.init_analytics()
226
+ analytics.track_command(
227
+ command="evaluate",
228
+ subcommand="kernelbench",
229
+ outcome="success",
230
+ duration_ms=1234,
231
+ )
232
+
233
+ mock_client.capture.assert_called_once()
234
+ call_kwargs = mock_client.capture.call_args[1]
235
+ assert call_kwargs["event"] == "cli_command_executed"
236
+ props = call_kwargs["properties"]
237
+ assert props["command"] == "evaluate"
238
+ assert props["subcommand"] == "kernelbench"
239
+ assert props["outcome"] == "success"
240
+ assert props["duration_ms"] == 1234
241
+
242
+ def test_track_login_identifies_and_tracks(self) -> None:
243
+ """track_login should identify user and track login event."""
244
+ from wafer import analytics
245
+
246
+ analytics._initialized = False
247
+ analytics._posthog_client = None
248
+ analytics._distinct_id = None
249
+
250
+ mock_client = MagicMock()
251
+
252
+ with patch("wafer.analytics._is_analytics_enabled", return_value=True), \
253
+ patch("wafer.analytics._get_user_id_from_credentials", return_value=(None, None)), \
254
+ patch("posthog.Posthog", return_value=mock_client):
255
+ analytics.init_analytics()
256
+ analytics.track_login("user-789", "login@example.com")
257
+
258
+ # Should call identify
259
+ identify_calls = [c for c in mock_client.method_calls if c[0] == "identify"]
260
+ assert len(identify_calls) >= 1
261
+
262
+ # Should track login event
263
+ capture_calls = [c for c in mock_client.method_calls if c[0] == "capture"]
264
+ assert len(capture_calls) >= 1
265
+ capture_kwargs = capture_calls[-1][2]
266
+ assert capture_kwargs["event"] == "cli_user_signed_in"
267
+
268
+ def test_track_logout_tracks_and_resets(self, tmp_path: Path) -> None:
269
+ """track_logout should track event and reset identity."""
270
+ from wafer import analytics
271
+
272
+ analytics._initialized = False
273
+ analytics._posthog_client = None
274
+ analytics._distinct_id = "user-123"
275
+
276
+ mock_client = MagicMock()
277
+ analytics_id_file = tmp_path / ".analytics_id"
278
+ analytics_id_file.write_text("anon_saved")
279
+
280
+ with patch("wafer.analytics._is_analytics_enabled", return_value=True), \
281
+ patch("wafer.analytics._get_user_id_from_credentials", return_value=("user-123", None)), \
282
+ patch("posthog.Posthog", return_value=mock_client), \
283
+ patch.object(analytics, "ANONYMOUS_ID_FILE", analytics_id_file):
284
+ analytics.init_analytics()
285
+ analytics.track_logout()
286
+
287
+ # Should track logout event
288
+ capture_calls = [c for c in mock_client.method_calls if c[0] == "capture"]
289
+ assert len(capture_calls) >= 1
290
+ capture_kwargs = capture_calls[-1][2]
291
+ assert capture_kwargs["event"] == "cli_user_signed_out"
292
+
293
+ # Should reset to anonymous ID
294
+ assert analytics._distinct_id == "anon_saved"
295
+
296
+ def test_track_event_does_nothing_when_disabled(self) -> None:
297
+ """track_event should not send when analytics disabled."""
298
+ from wafer import analytics
299
+
300
+ analytics._initialized = False
301
+ analytics._posthog_client = None
302
+
303
+ with patch("wafer.analytics._is_analytics_enabled", return_value=False):
304
+ # Should not raise
305
+ analytics.track_event("test_event", {"key": "value"})
306
+ # No client should exist
307
+ assert analytics._posthog_client is None
308
+
309
+
310
+ class TestBaseProperties:
311
+ """Test base properties included with events."""
312
+
313
+ def test_get_base_properties_includes_platform(self) -> None:
314
+ """_get_base_properties should include platform info."""
315
+ from wafer.analytics import _get_base_properties
316
+
317
+ props = _get_base_properties()
318
+
319
+ assert props["platform"] == "cli"
320
+ assert props["tool_id"] == "cli"
321
+ assert "os" in props
322
+ assert "python_version" in props
323
+
324
+
325
+ class TestPreferencesCheck:
326
+ """Test analytics_enabled preference checking."""
327
+
328
+ def test_is_analytics_enabled_default_true(self) -> None:
329
+ """_is_analytics_enabled should default to True."""
330
+ from wafer import analytics
331
+ from wafer.global_config import Preferences
332
+
333
+ mock_prefs = Preferences(mode="implicit", analytics_enabled=True)
334
+
335
+ with patch("wafer.global_config.get_preferences", return_value=mock_prefs):
336
+ assert analytics._is_analytics_enabled() is True
337
+
338
+ def test_is_analytics_enabled_respects_false(self) -> None:
339
+ """_is_analytics_enabled should respect False setting."""
340
+ from wafer import analytics
341
+ from wafer.global_config import Preferences
342
+
343
+ mock_prefs = Preferences(mode="implicit", analytics_enabled=False)
344
+
345
+ with patch("wafer.global_config.get_preferences", return_value=mock_prefs):
346
+ assert analytics._is_analytics_enabled() is False
347
+
348
+
349
+ class TestGlobalConfigAnalyticsEnabled:
350
+ """Test analytics_enabled in global config."""
351
+
352
+ def test_preferences_has_analytics_enabled(self) -> None:
353
+ """Preferences dataclass should have analytics_enabled field."""
354
+ from wafer.global_config import Preferences
355
+
356
+ prefs = Preferences()
357
+ assert hasattr(prefs, "analytics_enabled")
358
+ assert prefs.analytics_enabled is True # Default
359
+
360
+ def test_preferences_analytics_disabled(self) -> None:
361
+ """Preferences should accept analytics_enabled=False."""
362
+ from wafer.global_config import Preferences
363
+
364
+ prefs = Preferences(analytics_enabled=False)
365
+ assert prefs.analytics_enabled is False
366
+
367
+ def test_parse_config_with_analytics_enabled(self, tmp_path: Path) -> None:
368
+ """Config parser should read analytics_enabled from TOML."""
369
+ from wafer.global_config import _parse_config_file
370
+
371
+ config_file = tmp_path / "config.toml"
372
+ config_file.write_text("""
373
+ [api]
374
+ environment = "prod"
375
+
376
+ [preferences]
377
+ mode = "implicit"
378
+ analytics_enabled = false
379
+ """)
380
+
381
+ config = _parse_config_file(config_file)
382
+
383
+ assert config.preferences.analytics_enabled is False
384
+
385
+ def test_parse_config_analytics_enabled_default(self, tmp_path: Path) -> None:
386
+ """Config parser should default analytics_enabled to True."""
387
+ from wafer.global_config import _parse_config_file
388
+
389
+ config_file = tmp_path / "config.toml"
390
+ config_file.write_text("""
391
+ [api]
392
+ environment = "prod"
393
+ """)
394
+
395
+ config = _parse_config_file(config_file)
396
+
397
+ assert config.preferences.analytics_enabled is True
398
+
399
+
400
+ class TestCliCallback:
401
+ """Test CLI callback analytics tracking."""
402
+
403
+ def test_cli_callback_tracks_command(self) -> None:
404
+ """CLI callback should track command execution."""
405
+ from typer.testing import CliRunner
406
+ from wafer.cli import app
407
+
408
+ runner = CliRunner()
409
+
410
+ with patch("wafer.analytics.init_analytics") as mock_init, \
411
+ patch("wafer.analytics.track_command") as mock_track:
412
+ mock_init.return_value = True
413
+
414
+ # Run a simple command that doesn't require auth
415
+ result = runner.invoke(app, ["guide"])
416
+
417
+ # Analytics should be initialized
418
+ mock_init.assert_called()
419
+
420
+ # Note: track_command is called via atexit, which may not run
421
+ # in CliRunner context. We verify init is called at minimum.
422
+
423
+ def test_cli_help_does_not_crash_analytics(self) -> None:
424
+ """CLI --help should not crash due to analytics."""
425
+ from typer.testing import CliRunner
426
+ from wafer.cli import app
427
+
428
+ runner = CliRunner()
429
+
430
+ result = runner.invoke(app, ["--help"])
431
+
432
+ assert result.exit_code == 0
433
+ assert "GPU development toolkit" in result.output
434
+
435
+ def test_cli_subcommand_help_works(self) -> None:
436
+ """Subcommand --help should work with analytics."""
437
+ from typer.testing import CliRunner
438
+ from wafer.cli import app
439
+
440
+ runner = CliRunner()
441
+
442
+ result = runner.invoke(app, ["config", "--help"])
443
+
444
+ assert result.exit_code == 0
445
+
446
+
447
+ class TestLoginLogoutAnalytics:
448
+ """Test login/logout analytics integration."""
449
+
450
+ def test_login_calls_track_login(self) -> None:
451
+ """Login command should call track_login on success."""
452
+ from unittest.mock import MagicMock
453
+
454
+ from typer.testing import CliRunner
455
+ from wafer.cli import app
456
+
457
+ runner = CliRunner()
458
+
459
+ mock_user_info = MagicMock()
460
+ mock_user_info.user_id = "test-user-id"
461
+ mock_user_info.email = "test@example.com"
462
+
463
+ with patch("wafer.auth.browser_login", return_value=("test-token", "refresh-token")), \
464
+ patch("wafer.auth.verify_token", return_value=mock_user_info), \
465
+ patch("wafer.auth.save_credentials"), \
466
+ patch("wafer.analytics.track_login") as mock_track_login, \
467
+ patch("wafer.analytics.init_analytics", return_value=True):
468
+
469
+ result = runner.invoke(app, ["login", "--token", "test-token"])
470
+
471
+ # track_login should be called
472
+ mock_track_login.assert_called_once_with("test-user-id", "test@example.com")
473
+
474
+ def test_logout_calls_track_logout(self) -> None:
475
+ """Logout command should call track_logout."""
476
+ from typer.testing import CliRunner
477
+ from wafer.cli import app
478
+
479
+ runner = CliRunner()
480
+
481
+ with patch("wafer.auth.clear_credentials", return_value=True), \
482
+ patch("wafer.analytics.track_logout") as mock_track_logout, \
483
+ patch("wafer.analytics.init_analytics", return_value=True):
484
+
485
+ result = runner.invoke(app, ["logout"])
486
+
487
+ assert result.exit_code == 0
488
+ mock_track_logout.assert_called_once()
489
+
490
+
491
+ class TestShutdown:
492
+ """Test analytics shutdown."""
493
+
494
+ def test_shutdown_flushes_and_closes(self) -> None:
495
+ """shutdown_analytics should flush and close client."""
496
+ from wafer import analytics
497
+
498
+ mock_client = MagicMock()
499
+ analytics._posthog_client = mock_client
500
+
501
+ analytics.shutdown_analytics()
502
+
503
+ mock_client.flush.assert_called_once()
504
+ mock_client.shutdown.assert_called_once()
505
+ assert analytics._posthog_client is None
506
+
507
+ def test_shutdown_handles_none_client(self) -> None:
508
+ """shutdown_analytics should handle None client gracefully."""
509
+ from wafer import analytics
510
+
511
+ analytics._posthog_client = None
512
+
513
+ # Should not raise
514
+ analytics.shutdown_analytics()
515
+
516
+
517
+ class TestGetDistinctId:
518
+ """Test get_distinct_id function."""
519
+
520
+ def test_get_distinct_id_returns_cached(self) -> None:
521
+ """get_distinct_id should return cached ID."""
522
+ from wafer import analytics
523
+
524
+ analytics._distinct_id = "cached-user-id"
525
+
526
+ result = analytics.get_distinct_id()
527
+
528
+ assert result == "cached-user-id"
529
+
530
+ def test_get_distinct_id_fetches_from_credentials(self, tmp_path: Path) -> None:
531
+ """get_distinct_id should fetch from credentials if not cached."""
532
+ from wafer import analytics
533
+
534
+ analytics._distinct_id = None
535
+ analytics_id_file = tmp_path / ".analytics_id"
536
+
537
+ with patch("wafer.analytics._get_user_id_from_credentials", return_value=("cred-user", None)), \
538
+ patch.object(analytics, "ANONYMOUS_ID_FILE", analytics_id_file):
539
+ result = analytics.get_distinct_id()
540
+
541
+ assert result == "cred-user"
542
+
543
+ def test_get_distinct_id_falls_back_to_anonymous(self, tmp_path: Path) -> None:
544
+ """get_distinct_id should fall back to anonymous ID."""
545
+ from wafer import analytics
546
+
547
+ analytics._distinct_id = None
548
+ analytics_id_file = tmp_path / ".analytics_id"
549
+ analytics_id_file.write_text("anon_fallback")
550
+
551
+ with patch("wafer.analytics._get_user_id_from_credentials", return_value=(None, None)), \
552
+ patch.object(analytics, "ANONYMOUS_ID_FILE", analytics_id_file):
553
+ result = analytics.get_distinct_id()
554
+
555
+ assert result == "anon_fallback"