1
+import os
2
+import sys
3
+import threading
4
+from datetime import datetime
5
+from pathlib import Path
6
+
7
+import pytest
8
+import pytz
9
+from langchain_core.documents import Document
10
+
11
+PROJECT_ROOT = Path(__file__).resolve().parents[1]
12
+if str(PROJECT_ROOT) not in sys.path:
13
+ sys.path.insert(0, str(PROJECT_ROOT))
14
+
15
+import helpers.localization as localization_module
16
+import helpers.plugins as plugins_module
17
+import helpers.settings as settings_module
18
+from helpers.localization import Localization
19
+from helpers.task_scheduler import (
20
+ TaskPlan,
21
+ parse_task_plan,
22
+ serialize_task_plan,
23
+)
24
+from plugins._memory.api.memory_dashboard import MemoryDashboard
25
+from plugins._office.helpers import libreoffice_desktop
26
+
27
+
28
+@pytest.fixture
29
+def isolated_localization(monkeypatch):
30
+ saved: list[tuple[str, str]] = []
31
+
32
+ def fake_get_dotenv_value(key, default=None):
33
+ if key == "DEFAULT_USER_TIMEZONE":
34
+ return default or "UTC"
35
+ if key == "DEFAULT_USER_UTC_OFFSET_MINUTES":
36
+ return None
37
+ return default
38
+
39
+ monkeypatch.setattr(localization_module, "get_dotenv_value", fake_get_dotenv_value)
40
+ monkeypatch.setattr(
41
+ localization_module,
42
+ "save_dotenv_value",
43
+ lambda key, value: saved.append((key, str(value))),
44
+ )
45
+ monkeypatch.setattr(
46
+ localization_module.PrintStyle,
47
+ "error",
48
+ staticmethod(lambda *args, **kwargs: None),
49
+ )
50
+ Localization._instance = None
51
+ original_tz = os.environ.get("TZ")
52
+
53
+ yield saved
54
+
55
+ Localization._instance = None
56
+ if original_tz is None:
57
+ monkeypatch.delenv("TZ", raising=False)
58
+ else:
59
+ monkeypatch.setenv("TZ", original_tz)
60
+
61
+
62
+def set_test_timezone(timezone: str) -> Localization:
63
+ Localization._instance = Localization(timezone)
64
+ return Localization.get()
65
+
66
+
67
+def test_invalid_timezone_preserves_current_user_timezone(isolated_localization):
68
+ saved = isolated_localization
69
+ localization = set_test_timezone("Europe/Rome")
70
+ saved.clear()
71
+
72
+ localization.set_timezone("Mars/Olympus")
73
+
74
+ assert localization.get_timezone() == "Europe/Rome"
75
+ assert os.environ["TZ"] == "Europe/Rome"
76
+ assert not any(item == ("DEFAULT_USER_TIMEZONE", "UTC") for item in saved)
77
+ assert not any(item == ("DEFAULT_USER_TIMEZONE", "Mars/Olympus") for item in saved)
78
+
79
+
80
+def test_startup_refreshes_stale_persisted_offset(monkeypatch):
81
+ saved: list[tuple[str, str]] = []
82
+
83
+ def fake_get_dotenv_value(key, default=None):
84
+ if key == "DEFAULT_USER_TIMEZONE":
85
+ return "Europe/Rome"
86
+ if key == "DEFAULT_USER_UTC_OFFSET_MINUTES":
87
+ return "0"
88
+ return default
89
+
90
+ monkeypatch.setattr(localization_module, "get_dotenv_value", fake_get_dotenv_value)
91
+ monkeypatch.setattr(
92
+ localization_module,
93
+ "save_dotenv_value",
94
+ lambda key, value: saved.append((key, str(value))),
95
+ )
96
+ Localization._instance = None
97
+
98
+ localization = Localization.get()
99
+ expected_offset = int(
100
+ datetime.now(pytz.timezone("Europe/Rome")).utcoffset().total_seconds() // 60
101
+ )
102
+
103
+ assert localization.get_timezone() == "Europe/Rome"
104
+ assert localization.get_offset_minutes() == expected_offset
105
+ assert ("DEFAULT_USER_UTC_OFFSET_MINUTES", str(expected_offset)) in saved
106
+
107
+
108
+def test_scheduler_naive_plan_times_round_trip_as_user_local(isolated_localization):
109
+ set_test_timezone("Europe/Rome")
110
+
111
+ plan = parse_task_plan({"todo": ["2026-05-03T09:30:00"], "in_progress": None, "done": []})
112
+ serialized = serialize_task_plan(plan)
113
+
114
+ assert serialized["todo"] == ["2026-05-03T09:30:00+02:00"]
115
+
116
+
117
+def test_settings_auto_timezone_resolves_to_browser_timezone(isolated_localization, monkeypatch):
118
+ set_test_timezone("UTC")
119
+ hooks: list[dict] = []
120
+ base_settings = settings_module.get_default_settings()
121
+ monkeypatch.setattr(
122
+ settings_module,
123
+ "_settings",
124
+ {**base_settings, "timezone": settings_module.TIMEZONE_AUTO},
125
+ )
126
+ monkeypatch.setattr(
127
+ plugins_module,
128
+ "call_plugin_hook",
129
+ lambda plugin_name, hook_name, *args, **kwargs: hooks.append(
130
+ {
131
+ "plugin_name": plugin_name,
132
+ "hook_name": hook_name,
133
+ "kwargs": kwargs,
134
+ }
135
+ ),
136
+ )
137
+
138
+ settings_module._apply_timezone_setting(
139
+ {**base_settings, "timezone": settings_module.TIMEZONE_AUTO},
140
+ browser_timezone="Europe/Rome",
141
+ )
142
+
143
+ assert Localization.get().get_timezone() == "Europe/Rome"
144
+ assert hooks[0]["plugin_name"] == "_office"
145
+ assert hooks[0]["hook_name"] == "timezone_changed"
146
+ assert hooks[0]["kwargs"]["previous_timezone"] == "UTC"
147
+ assert hooks[0]["kwargs"]["timezone"] == "Europe/Rome"
148
+
149
+
150
+def test_settings_fixed_timezone_ignores_browser_timezone(isolated_localization, monkeypatch):
151
+ set_test_timezone("Europe/Rome")
152
+ base_settings = settings_module.get_default_settings()
153
+ monkeypatch.setattr(
154
+ settings_module,
155
+ "_settings",
156
+ {**base_settings, "timezone": "America/New_York"},
157
+ )
158
+ monkeypatch.setattr(plugins_module, "call_plugin_hook", lambda *args, **kwargs: None)
159
+
160
+ settings_module._apply_timezone_setting(
161
+ {**base_settings, "timezone": settings_module.TIMEZONE_AUTO},
162
+ browser_timezone="Europe/Rome",
163
+ )
164
+
165
+ assert Localization.get().get_timezone() == "America/New_York"
166
+
167
+
168
+def test_settings_fixed_timezone_reapplies_when_runtime_drifted(isolated_localization, monkeypatch):
169
+ set_test_timezone("Europe/Rome")
170
+ base_settings = settings_module.get_default_settings()
171
+ fixed_settings = {**base_settings, "timezone": "America/New_York"}
172
+ hooks: list[dict] = []
173
+ monkeypatch.setattr(settings_module, "_settings", fixed_settings)
174
+ monkeypatch.setattr(
175
+ plugins_module,
176
+ "call_plugin_hook",
177
+ lambda plugin_name, hook_name, *args, **kwargs: hooks.append(
178
+ {
179
+ "plugin_name": plugin_name,
180
+ "hook_name": hook_name,
181
+ "kwargs": kwargs,
182
+ }
183
+ ),
184
+ )
185
+
186
+ settings_module._apply_timezone_setting(
187
+ {**base_settings, "timezone": "America/New_York"},
188
+ browser_timezone="Europe/Rome",
189
+ )
190
+
191
+ assert Localization.get().get_timezone() == "America/New_York"
192
+ assert hooks[0]["plugin_name"] == "_office"
193
+ assert hooks[0]["hook_name"] == "timezone_changed"
194
+ assert hooks[0]["kwargs"]["previous_timezone"] == "Europe/Rome"
195
+ assert hooks[0]["kwargs"]["timezone"] == "America/New_York"
196
+
197
+
198
+def test_settings_rejects_invalid_timezone_value():
199
+ settings_data = settings_module.get_default_settings()
200
+ normalized = settings_module.normalize_settings(
201
+ {**settings_data, "timezone": "Mars/Olympus"}
202
+ )
203
+
204
+ assert normalized["timezone"] == settings_module.TIMEZONE_AUTO
205
+
206
+
207
+def test_settings_rejects_invalid_time_format_value():
208
+ settings_data = settings_module.get_default_settings()
209
+
210
+ invalid = settings_module.normalize_settings(
211
+ {**settings_data, "time_format": "bananas"}
212
+ )
213
+ twenty_four = settings_module.normalize_settings(
214
+ {**settings_data, "time_format": settings_module.TIME_FORMAT_24H}
215
+ )
216
+
217
+ assert invalid["time_format"] == settings_module.TIME_FORMAT_12H
218
+ assert twenty_four["time_format"] == settings_module.TIME_FORMAT_24H
219
+
220
+
221
+def test_scheduler_naive_plan_times_follow_changed_user_timezone(isolated_localization):
222
+ set_test_timezone("America/New_York")
223
+
224
+ plan = parse_task_plan({"todo": ["2026-05-03T09:30:00"], "in_progress": None, "done": []})
225
+ serialized = serialize_task_plan(plan)
226
+
227
+ assert serialized["todo"] == ["2026-05-03T09:30:00-04:00"]
228
+
229
+
230
+def test_task_plan_create_localizes_naive_datetimes(isolated_localization):
231
+ set_test_timezone("Europe/Rome")
232
+
233
+ plan = TaskPlan.create(todo=[datetime(2026, 5, 3, 9, 30)])
234
+
235
+ assert plan.todo[0].isoformat() == "2026-05-03T09:30:00+02:00"
236
+
237
+
238
+def test_memory_dashboard_normalizes_legacy_naive_timestamps(isolated_localization):
239
+ set_test_timezone("Europe/Rome")
240
+ dashboard = MemoryDashboard(app=None, thread_lock=threading.RLock())
241
+
242
+ formatted = dashboard._format_memory_for_dashboard(
243
+ Document(
244
+ page_content="legacy memory",
245
+ metadata={
246
+ "id": "memory-1",
247
+ "area": "main",
248
+ "timestamp": "2026-05-02 18:27:51",
249
+ },
250
+ )
251
+ )
252
+
253
+ assert formatted["timestamp"] == "2026-05-02T18:27:51+02:00"
254
+ assert formatted["metadata"]["timestamp"] == "2026-05-02 18:27:51"
255
+
256
+
257
+def test_memory_dashboard_converts_aware_timestamps_to_user_timezone(isolated_localization):
258
+ set_test_timezone("Europe/Rome")
259
+ dashboard = MemoryDashboard(app=None, thread_lock=threading.RLock())
260
+
261
+ assert (
262
+ dashboard._serialize_memory_timestamp("2026-05-02T16:27:51+00:00")
263
+ == "2026-05-02T18:27:51+02:00"
264
+ )
265
+
266
+
267
+class FakeProcess:
268
+ pid = 4242
269
+
270
+ def poll(self):
271
+ return None
272
+
273
+
274
+def test_desktop_session_env_uses_session_timezone(isolated_localization, tmp_path):
275
+ set_test_timezone("Europe/Rome")
276
+ session = libreoffice_desktop.DesktopSession(
277
+ session_id=libreoffice_desktop.SYSTEM_SESSION_ID,
278
+ file_id=libreoffice_desktop.SYSTEM_FILE_ID,
279
+ extension="desktop",
280
+ path=str(tmp_path),
281
+ title="Desktop",
282
+ display=120,
283
+ xpra_port=14500,
284
+ token=libreoffice_desktop.SYSTEM_SESSION_ID,
285
+ url="/desktop/session/agent-zero-desktop/index.html",
286
+ profile_dir=tmp_path / "profile",
287
+ timezone="America/New_York",
288
+ )
289
+
290
+ env = libreoffice_desktop.LibreOfficeDesktopManager()._session_env(session)
291
+
292
+ assert env["TZ"] == "America/New_York"
293
+
294
+
295
+def test_desktop_timezone_sync_restarts_active_system_desktop(
296
+ isolated_localization,
297
+ monkeypatch,
298
+ tmp_path,
299
+):
300
+ set_test_timezone("America/New_York")
301
+ manager = libreoffice_desktop.LibreOfficeDesktopManager()
302
+ old_session = libreoffice_desktop.DesktopSession(
303
+ session_id=libreoffice_desktop.SYSTEM_SESSION_ID,
304
+ file_id=libreoffice_desktop.SYSTEM_FILE_ID,
305
+ extension="desktop",
306
+ path=str(tmp_path),
307
+ title="Desktop",
308
+ display=120,
309
+ xpra_port=14500,
310
+ token=libreoffice_desktop.SYSTEM_SESSION_ID,
311
+ url="/desktop/session/agent-zero-desktop/index.html",
312
+ profile_dir=tmp_path / "profile-old",
313
+ timezone="Europe/Rome",
314
+ processes={"xpra": FakeProcess()},
315
+ )
316
+ replacement = libreoffice_desktop.DesktopSession(
317
+ session_id=libreoffice_desktop.SYSTEM_SESSION_ID,
318
+ file_id=libreoffice_desktop.SYSTEM_FILE_ID,
319
+ extension="desktop",
320
+ path=str(tmp_path),
321
+ title="Desktop",
322
+ display=120,
323
+ xpra_port=14500,
324
+ token=libreoffice_desktop.SYSTEM_SESSION_ID,
325
+ url="/desktop/session/agent-zero-desktop/index.html",
326
+ profile_dir=tmp_path / "profile-new",
327
+ timezone="America/New_York",
328
+ processes={"xpra": FakeProcess()},
329
+ )
330
+ manager._sessions[libreoffice_desktop.SYSTEM_SESSION_ID] = old_session
331
+ restarted: list[libreoffice_desktop.DesktopSession] = []
332
+
333
+ def fake_restart(session):
334
+ restarted.append(session)
335
+ manager._sessions[libreoffice_desktop.SYSTEM_SESSION_ID] = replacement
336
+ return replacement
337
+
338
+ monkeypatch.setattr(manager, "_restart_system_desktop_for_timezone_locked", fake_restart)
339
+
340
+ result = manager.sync_timezone("America/New_York")
341
+
342
+ assert result == {
343
+ "ok": True,
344
+ "restarted": True,
345
+ "session_id": libreoffice_desktop.SYSTEM_SESSION_ID,
346
+ "timezone": "America/New_York",
347
+ }
348
+ assert restarted == [old_session]