wafer-cli 0.2.2__tar.gz → 0.2.3__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.
- {wafer_cli-0.2.2 → wafer_cli-0.2.3}/PKG-INFO +2 -1
- {wafer_cli-0.2.2 → wafer_cli-0.2.3}/pyproject.toml +2 -1
- wafer_cli-0.2.3/tests/test_analytics.py +555 -0
- wafer_cli-0.2.3/wafer/analytics.py +307 -0
- {wafer_cli-0.2.2 → wafer_cli-0.2.3}/wafer/auth.py +4 -2
- {wafer_cli-0.2.2 → wafer_cli-0.2.3}/wafer/cli.py +189 -4
- {wafer_cli-0.2.2 → wafer_cli-0.2.3}/wafer/global_config.py +14 -3
- {wafer_cli-0.2.2 → wafer_cli-0.2.3}/wafer_cli.egg-info/PKG-INFO +2 -1
- {wafer_cli-0.2.2 → wafer_cli-0.2.3}/wafer_cli.egg-info/SOURCES.txt +2 -0
- {wafer_cli-0.2.2 → wafer_cli-0.2.3}/wafer_cli.egg-info/requires.txt +1 -0
- {wafer_cli-0.2.2 → wafer_cli-0.2.3}/README.md +0 -0
- {wafer_cli-0.2.2 → wafer_cli-0.2.3}/setup.cfg +0 -0
- {wafer_cli-0.2.2 → wafer_cli-0.2.3}/tests/test_billing.py +0 -0
- {wafer_cli-0.2.2 → wafer_cli-0.2.3}/tests/test_cli_coverage.py +0 -0
- {wafer_cli-0.2.2 → wafer_cli-0.2.3}/tests/test_cli_parity_integration.py +0 -0
- {wafer_cli-0.2.2 → wafer_cli-0.2.3}/tests/test_config_integration.py +0 -0
- {wafer_cli-0.2.2 → wafer_cli-0.2.3}/tests/test_file_operations_integration.py +0 -0
- {wafer_cli-0.2.2 → wafer_cli-0.2.3}/tests/test_isa_cli.py +0 -0
- {wafer_cli-0.2.2 → wafer_cli-0.2.3}/tests/test_rocprof_compute_integration.py +0 -0
- {wafer_cli-0.2.2 → wafer_cli-0.2.3}/tests/test_ssh_integration.py +0 -0
- {wafer_cli-0.2.2 → wafer_cli-0.2.3}/tests/test_wevin_cli.py +0 -0
- {wafer_cli-0.2.2 → wafer_cli-0.2.3}/tests/test_workflow_integration.py +0 -0
- {wafer_cli-0.2.2 → wafer_cli-0.2.3}/wafer/GUIDE.md +0 -0
- {wafer_cli-0.2.2 → wafer_cli-0.2.3}/wafer/__init__.py +0 -0
- {wafer_cli-0.2.2 → wafer_cli-0.2.3}/wafer/api_client.py +0 -0
- {wafer_cli-0.2.2 → wafer_cli-0.2.3}/wafer/autotuner.py +0 -0
- {wafer_cli-0.2.2 → wafer_cli-0.2.3}/wafer/billing.py +0 -0
- {wafer_cli-0.2.2 → wafer_cli-0.2.3}/wafer/config.py +0 -0
- {wafer_cli-0.2.2 → wafer_cli-0.2.3}/wafer/corpus.py +0 -0
- {wafer_cli-0.2.2 → wafer_cli-0.2.3}/wafer/evaluate.py +0 -0
- {wafer_cli-0.2.2 → wafer_cli-0.2.3}/wafer/gpu_run.py +0 -0
- {wafer_cli-0.2.2 → wafer_cli-0.2.3}/wafer/inference.py +0 -0
- {wafer_cli-0.2.2 → wafer_cli-0.2.3}/wafer/ncu_analyze.py +0 -0
- {wafer_cli-0.2.2 → wafer_cli-0.2.3}/wafer/nsys_analyze.py +0 -0
- {wafer_cli-0.2.2 → wafer_cli-0.2.3}/wafer/rocprof_compute.py +0 -0
- {wafer_cli-0.2.2 → wafer_cli-0.2.3}/wafer/rocprof_sdk.py +0 -0
- {wafer_cli-0.2.2 → wafer_cli-0.2.3}/wafer/rocprof_systems.py +0 -0
- {wafer_cli-0.2.2 → wafer_cli-0.2.3}/wafer/skills/wafer-guide/SKILL.md +0 -0
- {wafer_cli-0.2.2 → wafer_cli-0.2.3}/wafer/targets.py +0 -0
- {wafer_cli-0.2.2 → wafer_cli-0.2.3}/wafer/templates/__init__.py +0 -0
- {wafer_cli-0.2.2 → wafer_cli-0.2.3}/wafer/templates/ask_docs.py +0 -0
- {wafer_cli-0.2.2 → wafer_cli-0.2.3}/wafer/templates/optimize_kernel.py +0 -0
- {wafer_cli-0.2.2 → wafer_cli-0.2.3}/wafer/templates/trace_analyze.py +0 -0
- {wafer_cli-0.2.2 → wafer_cli-0.2.3}/wafer/tracelens.py +0 -0
- {wafer_cli-0.2.2 → wafer_cli-0.2.3}/wafer/wevin_cli.py +0 -0
- {wafer_cli-0.2.2 → wafer_cli-0.2.3}/wafer/workspaces.py +0 -0
- {wafer_cli-0.2.2 → wafer_cli-0.2.3}/wafer_cli.egg-info/dependency_links.txt +0 -0
- {wafer_cli-0.2.2 → wafer_cli-0.2.3}/wafer_cli.egg-info/entry_points.txt +0 -0
- {wafer_cli-0.2.2 → wafer_cli-0.2.3}/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.
|
|
3
|
+
Version: 0.2.3
|
|
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.
|
|
3
|
+
version = "0.2.3"
|
|
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"
|