jac-client 0.2.12__py3-none-any.whl → 0.2.14__py3-none-any.whl

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 (51) hide show
  1. jac_client/examples/all-in-one/components/Header.jac +1 -1
  2. jac_client/examples/all-in-one/components/ProfitOverview.jac +1 -1
  3. jac_client/examples/all-in-one/components/Summary.jac +1 -1
  4. jac_client/examples/all-in-one/components/TransactionList.jac +2 -2
  5. jac_client/examples/all-in-one/components/navigation.jac +3 -9
  6. jac_client/examples/all-in-one/context/BudgetContext.jac +1 -1
  7. jac_client/examples/all-in-one/main.jac +5 -386
  8. jac_client/examples/all-in-one/pages/(auth)/index.jac +299 -0
  9. jac_client/examples/all-in-one/pages/{nestedDemo.jac → (auth)/nested.jac} +3 -13
  10. jac_client/examples/all-in-one/pages/{loginPage.jac → (public)/login.jac} +1 -1
  11. jac_client/examples/all-in-one/pages/{signupPage.jac → (public)/signup.jac} +1 -1
  12. jac_client/examples/all-in-one/pages/{notFound.jac → [...notFound].jac} +2 -1
  13. jac_client/examples/all-in-one/pages/budget.jac +11 -0
  14. jac_client/examples/all-in-one/pages/budget_planner_ui.cl.jac +1 -1
  15. jac_client/examples/all-in-one/pages/features.jac +8 -0
  16. jac_client/examples/all-in-one/pages/features_test_ui.cl.jac +7 -7
  17. jac_client/examples/all-in-one/pages/{LandingPage.jac → landing.jac} +4 -9
  18. jac_client/examples/all-in-one/pages/layout.jac +20 -0
  19. jac_client/examples/nested-folders/nested-advance/src/ButtonRoot.jac +1 -1
  20. jac_client/examples/nested-folders/nested-advance/src/level1/ButtonSecondL.jac +1 -1
  21. jac_client/examples/nested-folders/nested-advance/src/level1/level2/ButtonThirdL.jac +1 -1
  22. jac_client/plugin/cli.jac +3 -3
  23. jac_client/plugin/client_runtime.cl.jac +7 -4
  24. jac_client/plugin/impl/client_runtime.impl.jac +29 -7
  25. jac_client/plugin/plugin_config.jac +4 -11
  26. jac_client/plugin/src/compiler.jac +19 -1
  27. jac_client/plugin/src/config_loader.jac +1 -0
  28. jac_client/plugin/src/impl/compiler.impl.jac +232 -62
  29. jac_client/plugin/src/impl/config_loader.impl.jac +8 -0
  30. jac_client/plugin/src/impl/package_installer.impl.jac +3 -2
  31. jac_client/plugin/src/impl/route_scanner.impl.jac +201 -0
  32. jac_client/plugin/src/impl/vite_bundler.impl.jac +54 -15
  33. jac_client/plugin/src/route_scanner.jac +44 -0
  34. jac_client/plugin/src/targets/desktop/sidecar/main.py +42 -23
  35. jac_client/plugin/src/targets/desktop_target.jac +4 -2
  36. jac_client/plugin/src/targets/impl/desktop_target.impl.jac +324 -112
  37. jac_client/plugin/src/vite_bundler.jac +18 -3
  38. jac_client/plugin/utils/impl/bun_installer.impl.jac +16 -19
  39. jac_client/plugin/utils/impl/client_deps.impl.jac +12 -16
  40. jac_client/templates/fullstack.jacpack +3 -2
  41. jac_client/tests/test_cli.py +74 -0
  42. jac_client/tests/test_desktop_api_url.py +854 -0
  43. jac_client/tests/test_e2e.py +31 -40
  44. jac_client/tests/test_it.py +209 -11
  45. {jac_client-0.2.12.dist-info → jac_client-0.2.14.dist-info}/METADATA +2 -2
  46. {jac_client-0.2.12.dist-info → jac_client-0.2.14.dist-info}/RECORD +49 -44
  47. jac_client/examples/all-in-one/pages/BudgetPlanner.jac +0 -140
  48. jac_client/examples/all-in-one/pages/FeaturesTest.jac +0 -157
  49. {jac_client-0.2.12.dist-info → jac_client-0.2.14.dist-info}/WHEEL +0 -0
  50. {jac_client-0.2.12.dist-info → jac_client-0.2.14.dist-info}/entry_points.txt +0 -0
  51. {jac_client-0.2.12.dist-info → jac_client-0.2.14.dist-info}/top_level.txt +0 -0
@@ -0,0 +1,854 @@
1
+ """Tests for desktop API URL resolution and sidecar port discovery.
2
+
3
+ These tests validate the changes introduced for desktop app URL resolution:
4
+ 1. Sidecar dynamic port allocation (_find_free_port, port 0 handling)
5
+ 2. JAC_SIDECAR_PORT= stdout protocol
6
+ 3. Generated main.rs content (CONFIGURED_BASE_URL, dynamic discovery)
7
+ 4. ViteBundler API base URL resolution priority chain
8
+ 5. Env var cleanup (try/finally) in build/start flows
9
+ 6. Helper functions (_make_localhost_url, _get_toml_api_base_url)
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import importlib
15
+ import importlib.util
16
+ import os
17
+ import socket
18
+ import subprocess
19
+ import sys
20
+ import tempfile
21
+ import types
22
+ from pathlib import Path
23
+ from unittest.mock import MagicMock
24
+
25
+ import pytest
26
+
27
+ # =============================================================================
28
+ # Shared path constants (avoid repeating long path constructions)
29
+ # =============================================================================
30
+
31
+ _plugin_src = Path(__file__).parent.parent / "plugin" / "src"
32
+
33
+ _sidecar_main_path = _plugin_src / "targets" / "desktop" / "sidecar" / "main.py"
34
+ _desktop_target_impl_path = _plugin_src / "targets" / "impl" / "desktop_target.impl.jac"
35
+ _vite_bundler_jac_path = _plugin_src / "vite_bundler.jac"
36
+ _vite_bundler_impl_path = _plugin_src / "impl" / "vite_bundler.impl.jac"
37
+
38
+
39
+ def _import_sidecar_main() -> types.ModuleType:
40
+ """Import sidecar main.py as a module.
41
+
42
+ Uses importlib to load the module from its file path, since the sidecar
43
+ package does not have __init__.py files.
44
+ """
45
+ spec = importlib.util.spec_from_file_location("sidecar_main", _sidecar_main_path)
46
+ if not spec or not spec.loader:
47
+ raise ImportError("Could not load sidecar main.py module")
48
+ assert spec is not None, f"Could not create ModuleSpec for {_sidecar_main_path}"
49
+ assert spec.loader is not None, f"ModuleSpec has no loader for {_sidecar_main_path}"
50
+ module = importlib.util.module_from_spec(spec)
51
+ spec.loader.exec_module(module)
52
+ return module
53
+
54
+
55
+ # =============================================================================
56
+ # Test: Sidecar _find_free_port
57
+ # =============================================================================
58
+
59
+
60
+ def test_find_free_port_returns_valid_port() -> None:
61
+ """Test that _find_free_port returns a port in the valid range."""
62
+ print("[DEBUG] Starting test_find_free_port_returns_valid_port")
63
+
64
+ sidecar = _import_sidecar_main()
65
+ port = sidecar._find_free_port()
66
+
67
+ print(f"[DEBUG] Got free port: {port}")
68
+
69
+ assert isinstance(port, int), "Port should be an integer"
70
+ assert 1 <= port <= 65535, f"Port {port} should be in valid range 1-65535"
71
+
72
+
73
+ def test_find_free_port_returns_different_ports() -> None:
74
+ """Test that consecutive calls return different ports (not stuck on one)."""
75
+ print("[DEBUG] Starting test_find_free_port_returns_different_ports")
76
+
77
+ sidecar = _import_sidecar_main()
78
+ ports = {sidecar._find_free_port() for _ in range(5)}
79
+
80
+ print(f"[DEBUG] Got ports: {ports}")
81
+
82
+ # At least 2 different ports from 5 attempts (OS should give unique ports)
83
+ assert len(ports) >= 2, f"Expected multiple unique ports, got {ports}"
84
+
85
+
86
+ def test_find_free_port_is_actually_free() -> None:
87
+ """Test that the returned port can actually be bound to."""
88
+ print("[DEBUG] Starting test_find_free_port_is_actually_free")
89
+
90
+ sidecar = _import_sidecar_main()
91
+ port = sidecar._find_free_port()
92
+
93
+ print(f"[DEBUG] Checking port {port} is bindable")
94
+
95
+ # Verify we can bind to the port
96
+ with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
97
+ s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
98
+ try:
99
+ s.bind(("127.0.0.1", port))
100
+ print(f"[DEBUG] Successfully bound to port {port}")
101
+ except OSError:
102
+ # TOCTOU race - port was taken between _find_free_port and our bind.
103
+ # This is the expected limitation. Skip rather than fail.
104
+ pytest.skip(f"Port {port} was taken between check and bind (TOCTOU)")
105
+
106
+
107
+ def test_find_free_port_with_custom_host() -> None:
108
+ """Test _find_free_port with explicit localhost host."""
109
+ print("[DEBUG] Starting test_find_free_port_with_custom_host")
110
+
111
+ sidecar = _import_sidecar_main()
112
+ port = sidecar._find_free_port(host="127.0.0.1")
113
+
114
+ print(f"[DEBUG] Got free port on 127.0.0.1: {port}")
115
+
116
+ assert isinstance(port, int), "Port should be an integer"
117
+ assert 1 <= port <= 65535, f"Port {port} should be in valid range"
118
+
119
+
120
+ # =============================================================================
121
+ # Test: Sidecar port 0 handling and JAC_SIDECAR_PORT marker
122
+ # =============================================================================
123
+
124
+
125
+ def test_sidecar_port_zero_resolves_to_real_port() -> None:
126
+ """Test that --port 0 gets resolved to an actual port before server starts.
127
+
128
+ This verifies the sidecar's port 0 handling: when port 0 is passed,
129
+ _find_free_port() should be called to allocate a real port.
130
+ """
131
+ print("[DEBUG] Starting test_sidecar_port_zero_resolves_to_real_port")
132
+
133
+ sidecar = _import_sidecar_main()
134
+
135
+ # Mock argparse to simulate --port 0
136
+ mock_args = MagicMock()
137
+ mock_args.port = 0
138
+ mock_args.host = "127.0.0.1"
139
+
140
+ # The code under test:
141
+ # port = args.port
142
+ # if port == 0:
143
+ # port = _find_free_port(args.host)
144
+ port = mock_args.port
145
+ if port == 0:
146
+ port = sidecar._find_free_port(mock_args.host)
147
+
148
+ print(f"[DEBUG] Port 0 resolved to: {port}")
149
+
150
+ assert port != 0, "Port 0 should be resolved to an actual port"
151
+ assert 1 <= port <= 65535, f"Resolved port {port} should be in valid range"
152
+
153
+
154
+ def test_sidecar_nonzero_port_preserved() -> None:
155
+ """Test that a specific port (non-zero) is preserved as-is."""
156
+ print("[DEBUG] Starting test_sidecar_nonzero_port_preserved")
157
+
158
+ # Simulate the port resolution logic
159
+ original_port = 9000
160
+ port = original_port
161
+ if port == 0:
162
+ port = 99999 # Should not be reached
163
+
164
+ assert port == original_port, "Non-zero port should be preserved"
165
+
166
+
167
+ def test_sidecar_port_marker_format() -> None:
168
+ """Test that the JAC_SIDECAR_PORT= marker matches the expected format.
169
+
170
+ The Rust code in generated main.rs parses this exact format:
171
+ line.strip_prefix("JAC_SIDECAR_PORT=")
172
+ """
173
+ print("[DEBUG] Starting test_sidecar_port_marker_format")
174
+
175
+ port = 12345
176
+ marker = f"JAC_SIDECAR_PORT={port}"
177
+
178
+ print(f"[DEBUG] Marker: {marker}")
179
+
180
+ # Verify format matches what the Rust parser expects
181
+ assert marker.startswith("JAC_SIDECAR_PORT="), "Marker must start with exact prefix"
182
+ port_str = marker.split("=", 1)[1]
183
+ parsed_port = int(port_str.strip())
184
+ assert parsed_port == port, f"Parsed port {parsed_port} should match {port}"
185
+
186
+
187
+ # =============================================================================
188
+ # Test: Generated main.rs content
189
+ # =============================================================================
190
+
191
+
192
+ def test_setup_generates_main_rs_with_sidecar_support() -> None:
193
+ """Test that jac setup desktop generates main.rs with sidecar port discovery.
194
+
195
+ After setup, the generated main.rs should contain:
196
+ - CONFIGURED_BASE_URL constant
197
+ - JAC_SIDECAR_PORT= parsing logic
198
+ - API_BASE_URL global storage
199
+ - initialization_script injection (runs before page JS)
200
+ """
201
+ print("[DEBUG] Starting test_setup_generates_main_rs_with_sidecar_support")
202
+
203
+ from .test_helpers import get_env_with_bun, get_jac_command
204
+
205
+ # Check if jac setup is available
206
+ jac_cmd = get_jac_command()
207
+ result = subprocess.run(
208
+ [*jac_cmd, "--help"], capture_output=True, text=True, timeout=30
209
+ )
210
+ if "setup" not in result.stdout:
211
+ pytest.skip("'jac setup' CLI command not available")
212
+
213
+ with tempfile.TemporaryDirectory() as temp_dir:
214
+ project_dir = Path(temp_dir) / "main-rs-test"
215
+ project_dir.mkdir(parents=True)
216
+
217
+ # Minimal project
218
+ (project_dir / "main.jac").write_text(
219
+ '"""Test."""\ndef:pub hello() -> str { return "hi"; }\n'
220
+ )
221
+ (project_dir / "jac.toml").write_text(
222
+ '[project]\nname = "test"\nversion = "1.0.0"\nentry-point = "main.jac"\n'
223
+ )
224
+
225
+ # Run setup
226
+ env = get_env_with_bun()
227
+ setup_result = subprocess.run(
228
+ [*jac_cmd, "setup", "desktop"],
229
+ cwd=project_dir,
230
+ capture_output=True,
231
+ text=True,
232
+ env=env,
233
+ timeout=120,
234
+ )
235
+
236
+ if setup_result.returncode != 0:
237
+ if "invalid choice: 'setup'" in setup_result.stderr:
238
+ pytest.skip("'jac setup' command not registered")
239
+ pytest.fail(
240
+ f"jac setup desktop failed:\n"
241
+ f"STDOUT:\n{setup_result.stdout}\n"
242
+ f"STDERR:\n{setup_result.stderr}"
243
+ )
244
+
245
+ # Read generated main.rs
246
+ main_rs_path = project_dir / "src-tauri" / "src" / "main.rs"
247
+ assert main_rs_path.exists(), "main.rs should be generated by setup"
248
+
249
+ main_rs_content = main_rs_path.read_text()
250
+ print(f"[DEBUG] main.rs length: {len(main_rs_content)} chars")
251
+
252
+ # Verify key patterns for sidecar port discovery
253
+ assert "CONFIGURED_BASE_URL" in main_rs_content, (
254
+ "main.rs should contain CONFIGURED_BASE_URL constant"
255
+ )
256
+ assert "API_BASE_URL" in main_rs_content, (
257
+ "main.rs should contain API_BASE_URL global storage"
258
+ )
259
+ assert "JAC_SIDECAR_PORT=" in main_rs_content, (
260
+ "main.rs should contain JAC_SIDECAR_PORT= parsing logic"
261
+ )
262
+ assert "--port" in main_rs_content, "main.rs should pass --port to sidecar"
263
+ assert '"0"' in main_rs_content, (
264
+ "main.rs should use port 0 for dynamic allocation"
265
+ )
266
+ assert "initialization_script" in main_rs_content, (
267
+ "main.rs should use initialization_script to inject URL before page JS"
268
+ )
269
+ assert "__JAC_API_BASE_URL__" in main_rs_content, (
270
+ "main.rs should set globalThis.__JAC_API_BASE_URL__"
271
+ )
272
+
273
+ # Verify it does NOT have a hardcoded fallback port
274
+ assert '"http://127.0.0.1:8000"' not in main_rs_content, (
275
+ "main.rs should not have hardcoded port 8000 fallback"
276
+ )
277
+
278
+ print("[DEBUG] main.rs content verification passed!")
279
+
280
+
281
+ # =============================================================================
282
+ # Test: Desktop target interface has api_port parameter
283
+ # =============================================================================
284
+
285
+
286
+ def test_desktop_target_interface_has_api_port() -> None:
287
+ """Test that DesktopTarget.dev and .start declare api_port parameter."""
288
+ print("[DEBUG] Starting test_desktop_target_interface_has_api_port")
289
+
290
+ desktop_target_jac = (
291
+ Path(__file__).parent.parent
292
+ / "plugin"
293
+ / "src"
294
+ / "targets"
295
+ / "desktop_target.jac"
296
+ )
297
+ assert desktop_target_jac.exists(), (
298
+ f"desktop_target.jac not found at {desktop_target_jac}"
299
+ )
300
+
301
+ content = desktop_target_jac.read_text()
302
+
303
+ # Both dev and start should accept api_port
304
+ assert "api_port: int = 8000" in content, (
305
+ "desktop_target.jac should declare api_port parameter"
306
+ )
307
+ # Should appear twice (once for dev, once for start)
308
+ assert content.count("api_port: int = 8000") == 2, (
309
+ "api_port should be declared in both dev() and start() methods"
310
+ )
311
+
312
+ print("[DEBUG] Desktop target interface verification passed!")
313
+
314
+
315
+ # =============================================================================
316
+ # Test: ViteBundler interface has API_BASE_URL_ENV_VAR and _resolve_api_base_url
317
+ # =============================================================================
318
+
319
+
320
+ def test_vite_bundler_has_api_base_url_constant() -> None:
321
+ """Test that vite_bundler.jac defines the API_BASE_URL_ENV_VAR constant."""
322
+ print("[DEBUG] Starting test_vite_bundler_has_api_base_url_constant")
323
+
324
+ assert _vite_bundler_jac_path.exists()
325
+
326
+ content = _vite_bundler_jac_path.read_text()
327
+
328
+ assert "API_BASE_URL_ENV_VAR" in content, (
329
+ "vite_bundler.jac should define API_BASE_URL_ENV_VAR"
330
+ )
331
+ assert '"JAC_CLIENT_API_BASE_URL"' in content, (
332
+ "API_BASE_URL_ENV_VAR should equal 'JAC_CLIENT_API_BASE_URL'"
333
+ )
334
+ assert "_resolve_api_base_url" in content, (
335
+ "vite_bundler.jac should declare _resolve_api_base_url method"
336
+ )
337
+
338
+ print("[DEBUG] ViteBundler constant verification passed!")
339
+
340
+
341
+ def test_vite_bundler_config_methods_accept_override() -> None:
342
+ """Test that create_vite_config and create_dev_vite_config accept api_base_url_override."""
343
+ print("[DEBUG] Starting test_vite_bundler_config_methods_accept_override")
344
+
345
+ content = _vite_bundler_jac_path.read_text()
346
+
347
+ # Both config methods should declare api_base_url_override
348
+ assert content.count('api_base_url_override: str = ""') >= 3, (
349
+ "api_base_url_override should appear in _resolve_api_base_url, "
350
+ "create_vite_config, and create_dev_vite_config"
351
+ )
352
+
353
+ print("[DEBUG] ViteBundler method signatures verification passed!")
354
+
355
+
356
+ # =============================================================================
357
+ # Test: _resolve_api_base_url priority chain (impl verification)
358
+ # =============================================================================
359
+
360
+
361
+ def test_resolve_api_base_url_priority_chain_in_impl() -> None:
362
+ """Test that _resolve_api_base_url implements the correct priority chain.
363
+
364
+ Priority: jac.toml base_url > direct override > env var > "" (same-origin)
365
+
366
+ We verify the implementation file contains the correct logic pattern.
367
+ """
368
+ print("[DEBUG] Starting test_resolve_api_base_url_priority_chain_in_impl")
369
+
370
+ assert _vite_bundler_impl_path.exists()
371
+
372
+ content = _vite_bundler_impl_path.read_text()
373
+
374
+ # Find the _resolve_api_base_url implementation
375
+ assert "impl ViteBundler._resolve_api_base_url" in content, (
376
+ "Implementation should contain _resolve_api_base_url"
377
+ )
378
+ assert "toml_base_url or api_base_url_override or env_override" in content, (
379
+ "Resolution should use or-chain: toml > override > env"
380
+ )
381
+ assert "API_BASE_URL_ENV_VAR" in content, (
382
+ "Should use the named constant, not a string literal"
383
+ )
384
+
385
+ # Verify create_vite_config uses the method (not inline logic)
386
+ # Find the create_vite_config function and check it delegates
387
+ assert "self._resolve_api_base_url(api_base_url_override)" in content, (
388
+ "create_vite_config should delegate to _resolve_api_base_url"
389
+ )
390
+
391
+ # Count usages - should be called in both config methods
392
+ resolve_calls = content.count("self._resolve_api_base_url(")
393
+ assert resolve_calls >= 2, (
394
+ f"_resolve_api_base_url should be called in both config methods, "
395
+ f"found {resolve_calls} call(s)"
396
+ )
397
+
398
+ print("[DEBUG] Resolution priority chain verification passed!")
399
+
400
+
401
+ # =============================================================================
402
+ # Test: Env var cleanup (try/finally) in build and start
403
+ # =============================================================================
404
+
405
+
406
+ def test_env_var_cleanup_pattern_in_build() -> None:
407
+ """Test that build() cleans up JAC_CLIENT_API_BASE_URL in a finally block."""
408
+ print("[DEBUG] Starting test_env_var_cleanup_pattern_in_build")
409
+
410
+ assert _desktop_target_impl_path.exists()
411
+
412
+ content = _desktop_target_impl_path.read_text()
413
+
414
+ # Find the build method's env var handling section
415
+ # It should use try/finally for cleanup
416
+ build_section = content[content.index("impl DesktopTarget.build") :]
417
+ # Cut at next impl to isolate the build method
418
+ next_impl = build_section.index("impl ", 10)
419
+ build_section = build_section[:next_impl]
420
+
421
+ assert "try {" in build_section, (
422
+ "build() should use try block around web_target.build()"
423
+ )
424
+ assert "} finally {" in build_section, (
425
+ "build() should use finally block for env var cleanup"
426
+ )
427
+ assert "os.environ.pop(API_BASE_URL_ENV_VAR, None)" in build_section, (
428
+ "build() should clean up env var in finally block"
429
+ )
430
+
431
+ print("[DEBUG] build() env var cleanup verification passed!")
432
+
433
+
434
+ def test_env_var_cleanup_pattern_in_start() -> None:
435
+ """Test that start() cleans up JAC_CLIENT_API_BASE_URL in a finally block."""
436
+ print("[DEBUG] Starting test_env_var_cleanup_pattern_in_start")
437
+
438
+ content = _desktop_target_impl_path.read_text()
439
+
440
+ # Find the start method's env var handling section
441
+ start_section = content[content.index("impl DesktopTarget.start") :]
442
+
443
+ assert "try {" in start_section, (
444
+ "start() should use try block around web_target.build()"
445
+ )
446
+ assert "} finally {" in start_section, (
447
+ "start() should use finally block for env var cleanup"
448
+ )
449
+ assert "os.environ.pop(API_BASE_URL_ENV_VAR, None)" in start_section, (
450
+ "start() should clean up env var in finally block"
451
+ )
452
+
453
+ print("[DEBUG] start() env var cleanup verification passed!")
454
+
455
+
456
+ def test_env_var_not_leaked_after_import() -> None:
457
+ """Test that JAC_CLIENT_API_BASE_URL is not present in environment by default.
458
+
459
+ This verifies that no module import accidentally sets the env var.
460
+ """
461
+ print("[DEBUG] Starting test_env_var_not_leaked_after_import")
462
+
463
+ env_var = "JAC_CLIENT_API_BASE_URL"
464
+
465
+ # Clean state
466
+ os.environ.pop(env_var, None)
467
+
468
+ # Import the sidecar module (should not set env var)
469
+ _import_sidecar_main()
470
+
471
+ assert env_var not in os.environ, (
472
+ f"{env_var} should not be set after importing sidecar module"
473
+ )
474
+
475
+ print("[DEBUG] Env var leak check passed!")
476
+
477
+
478
+ # =============================================================================
479
+ # Test: Helper functions in desktop_target.impl.jac
480
+ # =============================================================================
481
+
482
+
483
+ def test_make_localhost_url_in_impl() -> None:
484
+ """Test that _make_localhost_url is defined and produces correct format."""
485
+ print("[DEBUG] Starting test_make_localhost_url_in_impl")
486
+
487
+ content = _desktop_target_impl_path.read_text()
488
+
489
+ # Verify _make_localhost_url is defined as a module-level function
490
+ assert "def _make_localhost_url(port: int) -> str" in content, (
491
+ "_make_localhost_url should be defined as a function"
492
+ )
493
+ assert 'return f"http://127.0.0.1:{port}"' in content, (
494
+ "_make_localhost_url should return http://127.0.0.1:{port}"
495
+ )
496
+
497
+ # Verify it's used in dev() and start()
498
+ usage_count = content.count("_make_localhost_url(")
499
+ # Definition (1) + at least 2 usages (dev + start)
500
+ assert usage_count >= 3, (
501
+ f"_make_localhost_url should be used in dev() and start(), "
502
+ f"found {usage_count} occurrence(s)"
503
+ )
504
+
505
+ print("[DEBUG] _make_localhost_url verification passed!")
506
+
507
+
508
+ def test_get_toml_api_base_url_in_impl() -> None:
509
+ """Test that _get_toml_api_base_url is defined and used."""
510
+ print("[DEBUG] Starting test_get_toml_api_base_url_in_impl")
511
+
512
+ content = _desktop_target_impl_path.read_text()
513
+
514
+ # Verify _get_toml_api_base_url is defined
515
+ assert "def _get_toml_api_base_url(project_dir: Path) -> str" in content, (
516
+ "_get_toml_api_base_url should be defined as a function"
517
+ )
518
+ assert "JacClientConfig(project_dir)" in content, (
519
+ "_get_toml_api_base_url should create a JacClientConfig"
520
+ )
521
+
522
+ # Verify it's used in build() and start()
523
+ usage_count = content.count("_get_toml_api_base_url(")
524
+ # Definition (1) + at least 2 usages (build + start)
525
+ assert usage_count >= 3, (
526
+ f"_get_toml_api_base_url should be used in build() and start(), "
527
+ f"found {usage_count} occurrence(s)"
528
+ )
529
+
530
+ print("[DEBUG] _get_toml_api_base_url verification passed!")
531
+
532
+
533
+ def test_no_magic_string_jac_client_api_base_url() -> None:
534
+ """Test that 'JAC_CLIENT_API_BASE_URL' string literal only appears once (as constant)."""
535
+ print("[DEBUG] Starting test_no_magic_string_jac_client_api_base_url")
536
+
537
+ # Check desktop_target.impl.jac - should use API_BASE_URL_ENV_VAR constant
538
+ desktop_content = _desktop_target_impl_path.read_text()
539
+ assert '"JAC_CLIENT_API_BASE_URL"' not in desktop_content, (
540
+ "desktop_target.impl.jac should use API_BASE_URL_ENV_VAR constant, "
541
+ "not the string literal"
542
+ )
543
+
544
+ # Check vite_bundler.impl.jac - should use API_BASE_URL_ENV_VAR constant
545
+ vite_content = _vite_bundler_impl_path.read_text()
546
+ assert '"JAC_CLIENT_API_BASE_URL"' not in vite_content, (
547
+ "vite_bundler.impl.jac should use API_BASE_URL_ENV_VAR constant, "
548
+ "not the string literal"
549
+ )
550
+
551
+ # The constant definition should only be in vite_bundler.jac
552
+ vite_jac_content = _vite_bundler_jac_path.read_text()
553
+ assert vite_jac_content.count('"JAC_CLIENT_API_BASE_URL"') == 1, (
554
+ "The string literal should appear exactly once as the constant definition"
555
+ )
556
+
557
+ print("[DEBUG] Magic string elimination verification passed!")
558
+
559
+
560
+ # =============================================================================
561
+ # Test: CLI port passthrough
562
+ # =============================================================================
563
+
564
+
565
+ def test_cli_passes_port_to_desktop_target() -> None:
566
+ """Test that cli.jac extracts --port and passes api_port to desktop target."""
567
+ print("[DEBUG] Starting test_cli_passes_port_to_desktop_target")
568
+
569
+ cli_jac_path = Path(__file__).parent.parent / "plugin" / "cli.jac"
570
+ assert cli_jac_path.exists()
571
+
572
+ content = cli_jac_path.read_text()
573
+
574
+ # Verify port is extracted from CLI context
575
+ assert 'ctx.get_arg("port"' in content, (
576
+ "cli.jac should extract port from CLI context"
577
+ )
578
+
579
+ # Verify it's passed to target.dev and target.start
580
+ assert "api_port=api_port" in content, (
581
+ "cli.jac should pass api_port to target methods"
582
+ )
583
+
584
+ # Should appear at least twice (once for dev, once for start)
585
+ assert content.count("api_port=api_port") >= 2, (
586
+ "api_port should be passed in both dev and start calls"
587
+ )
588
+
589
+ print("[DEBUG] CLI port passthrough verification passed!")
590
+
591
+
592
+ # =============================================================================
593
+ # Test: Sidecar port marker via subprocess
594
+ # =============================================================================
595
+
596
+
597
+ def test_sidecar_prints_port_marker_to_stdout() -> None:
598
+ """Test that the sidecar prints JAC_SIDECAR_PORT=<port> to stdout.
599
+
600
+ This runs the sidecar with --help to verify the module is importable,
601
+ then checks the port marker format by inspecting the source code
602
+ (full sidecar startup requires jaclang runtime).
603
+ """
604
+ print("[DEBUG] Starting test_sidecar_prints_port_marker_to_stdout")
605
+
606
+ # Verify the sidecar module is importable
607
+ assert _sidecar_main_path.exists(), (
608
+ f"sidecar main.py not found at {_sidecar_main_path}"
609
+ )
610
+
611
+ # Verify the source contains the port marker written to stdout
612
+ content = _sidecar_main_path.read_text()
613
+ assert 'sys.stdout.write(f"JAC_SIDECAR_PORT={port}' in content, (
614
+ "sidecar main.py should write JAC_SIDECAR_PORT marker to stdout"
615
+ )
616
+ assert "sys.stdout.flush()" in content, "Port marker should be flushed immediately"
617
+
618
+ # Verify the marker is printed BEFORE server.start()
619
+ marker_pos = content.index("JAC_SIDECAR_PORT=")
620
+ start_pos = content.index("server.start(")
621
+ assert marker_pos < start_pos, (
622
+ "Port marker should be printed before server.start() is called"
623
+ )
624
+
625
+ print("[DEBUG] Sidecar port marker verification passed!")
626
+
627
+
628
+ def test_sidecar_help_shows_port_zero() -> None:
629
+ """Test that sidecar --help mentions port 0 for auto-assignment."""
630
+ print("[DEBUG] Starting test_sidecar_help_shows_port_zero")
631
+
632
+ result = subprocess.run(
633
+ [sys.executable, str(_sidecar_main_path), "--help"],
634
+ capture_output=True,
635
+ text=True,
636
+ timeout=10,
637
+ )
638
+
639
+ print(f"[DEBUG] Help output: {result.stdout[:500]}")
640
+
641
+ assert result.returncode == 0, "Sidecar --help should succeed"
642
+ assert "auto" in result.stdout.lower() or "0" in result.stdout, (
643
+ "Sidecar help should mention port 0 / auto-assign"
644
+ )
645
+
646
+ print("[DEBUG] Sidecar help verification passed!")
647
+
648
+
649
+ # =============================================================================
650
+ # Test: Import consistency
651
+ # =============================================================================
652
+
653
+
654
+ def test_sidecar_no_stdout_after_port_marker() -> None:
655
+ """Test that the sidecar does NOT write to stdout after the port marker.
656
+
657
+ After the Tauri host reads JAC_SIDECAR_PORT= from stdout, it drops the
658
+ pipe reader. Any further stdout writes would crash the sidecar with
659
+ BrokenPipeError. The only stdout write must be the port marker via
660
+ sys.stdout.write(); all other output uses console (stderr) or sys.stderr.
661
+ """
662
+ content = _sidecar_main_path.read_text()
663
+
664
+ main_start = content.index("def main():")
665
+ main_body = content[main_start:]
666
+
667
+ import re
668
+
669
+ # No bare print() calls should exist — use console or sys.stderr.write()
670
+ bare_prints = re.findall(r"(?<!\.)print\(", main_body)
671
+ assert len(bare_prints) == 0, (
672
+ f"sidecar should not use bare print() — "
673
+ f"use console or sys.stderr.write() instead. "
674
+ f"Found {len(bare_prints)} bare print() call(s)"
675
+ )
676
+
677
+ # The only sys.stdout usage should be the port marker + flush
678
+ stdout_writes = re.findall(r"sys\.stdout\.write\(", main_body)
679
+ assert len(stdout_writes) == 1, (
680
+ f"Expected exactly 1 sys.stdout.write (port marker), found {len(stdout_writes)}"
681
+ )
682
+ assert "JAC_SIDECAR_PORT=" in main_body[main_body.index("sys.stdout.write(") :], (
683
+ "The only stdout write should be the port marker"
684
+ )
685
+
686
+
687
+ def test_sidecar_uses_console_after_import() -> None:
688
+ """Test that the sidecar uses jaclang console for output after import.
689
+
690
+ After jaclang is successfully imported, the sidecar should use
691
+ console.print() / console.error() for all user-facing output.
692
+ Pre-import errors use sys.stderr.write() as a fallback.
693
+ """
694
+ content = _sidecar_main_path.read_text()
695
+
696
+ assert "from jaclang.cli.console import console" in content, (
697
+ "sidecar should import console from jaclang.cli.console"
698
+ )
699
+ assert "console.print(" in content, (
700
+ "sidecar should use console.print() for status output"
701
+ )
702
+ assert "console.error(" in content, (
703
+ "sidecar should use console.error() for error output"
704
+ )
705
+
706
+
707
+ def test_desktop_target_imports_api_base_url_env_var() -> None:
708
+ """Test that desktop_target.impl.jac imports API_BASE_URL_ENV_VAR from vite_bundler."""
709
+ print("[DEBUG] Starting test_desktop_target_imports_api_base_url_env_var")
710
+
711
+ content = _desktop_target_impl_path.read_text()
712
+
713
+ assert (
714
+ "import from jac_client.plugin.src.vite_bundler { API_BASE_URL_ENV_VAR }"
715
+ in content
716
+ ), "desktop_target.impl.jac should import API_BASE_URL_ENV_VAR from vite_bundler"
717
+
718
+ print("[DEBUG] Import consistency verification passed!")
719
+
720
+
721
+ def test_main_rs_uses_initialization_script_not_eval() -> None:
722
+ """Test that generated main.rs uses initialization_script instead of webview.eval.
723
+
724
+ webview.eval() in setup() executes AFTER page JS has already run, causing
725
+ a race condition where API calls use same-origin before the URL is injected.
726
+ initialization_script() runs BEFORE any page JS, fixing the timing issue.
727
+ """
728
+ print("[DEBUG] Starting test_main_rs_uses_initialization_script_not_eval")
729
+
730
+ content = _desktop_target_impl_path.read_text()
731
+
732
+ # Find the generated main.rs template (the f-string in _generate_main_rs)
733
+ assert "initialization_script" in content, (
734
+ "Generated main.rs should use initialization_script for URL injection"
735
+ )
736
+ assert "WebviewWindowBuilder" in content, (
737
+ "Generated main.rs should create window manually via WebviewWindowBuilder"
738
+ )
739
+ # Verify webview.eval() is NOT used for URL injection
740
+ # (the old pattern: app.get_webview_window("main") + webview.eval)
741
+ assert 'get_webview_window("main")' not in content, (
742
+ "Generated main.rs should NOT use get_webview_window — "
743
+ "window is created manually with initialization_script"
744
+ )
745
+
746
+ print("[DEBUG] initialization_script pattern verification passed!")
747
+
748
+
749
+ def test_tauri_config_has_empty_windows() -> None:
750
+ """Test that _generate_tauri_config sets windows to empty array.
751
+
752
+ main.rs creates the window manually via WebviewWindowBuilder to support
753
+ initialization_script. An auto-created window from config would conflict.
754
+ """
755
+ print("[DEBUG] Starting test_tauri_config_has_empty_windows")
756
+
757
+ content = _desktop_target_impl_path.read_text()
758
+
759
+ # The _generate_tauri_config function should set windows to []
760
+ assert '"windows": []' in content, (
761
+ "_generate_tauri_config should set windows to empty array"
762
+ )
763
+
764
+ # The config update functions should also clear windows
765
+ # Find both _update_tauri_config_for_build and _update_tauri_config_for_dev
766
+ assert content.count('["windows"] = []') >= 2, (
767
+ "Both config update functions should clear the windows array"
768
+ )
769
+
770
+ print("[DEBUG] Empty windows config verification passed!")
771
+
772
+
773
+ # =============================================================================
774
+ # Test: Backend server auto-start
775
+ # =============================================================================
776
+
777
+
778
+ def test_start_backend_server_helper_exists() -> None:
779
+ """Test that _start_backend_server helper is defined and uses subprocess."""
780
+ content = _desktop_target_impl_path.read_text()
781
+
782
+ assert "def _start_backend_server(" in content, (
783
+ "_start_backend_server helper should be defined"
784
+ )
785
+ # Should use subprocess.Popen to launch the server
786
+ assert "subprocess.Popen" in content, (
787
+ "_start_backend_server should use subprocess.Popen"
788
+ )
789
+ # Should pass --no_client to skip client bundling
790
+ assert '"--no_client"' in content, (
791
+ "_start_backend_server should pass --no_client flag"
792
+ )
793
+ # Should pass --port
794
+ assert '"--port"' in content, "_start_backend_server should pass --port flag"
795
+
796
+
797
+ def test_resolve_server_port_helper_exists() -> None:
798
+ """Test that _resolve_server_port helper is defined and parses URLs."""
799
+ content = _desktop_target_impl_path.read_text()
800
+
801
+ assert "def _resolve_server_port(" in content, (
802
+ "_resolve_server_port helper should be defined"
803
+ )
804
+ assert "urlparse" in content, (
805
+ "_resolve_server_port should use urlparse to extract port from URL"
806
+ )
807
+
808
+
809
+ def test_start_method_launches_backend_server() -> None:
810
+ """Test that start() method launches the backend server before Tauri."""
811
+ content = _desktop_target_impl_path.read_text()
812
+
813
+ # Find start() method
814
+ start_idx = content.index("impl DesktopTarget.start(")
815
+ # Find the next impl or end of file
816
+ next_impl = content.find("\nimpl ", start_idx + 1)
817
+ if next_impl == -1:
818
+ next_impl = content.find("\ndef _", start_idx + 100)
819
+ start_body = (
820
+ content[start_idx:next_impl] if next_impl != -1 else content[start_idx:]
821
+ )
822
+
823
+ assert "_start_backend_server(" in start_body, (
824
+ "start() should call _start_backend_server"
825
+ )
826
+ assert "_resolve_server_port(" in start_body, (
827
+ "start() should call _resolve_server_port to determine server port"
828
+ )
829
+ # Server process should be terminated in cleanup
830
+ assert "server_process" in start_body, (
831
+ "start() should manage server_process lifecycle"
832
+ )
833
+
834
+
835
+ def test_dev_method_launches_backend_server() -> None:
836
+ """Test that dev() method launches the backend server before Tauri."""
837
+ content = _desktop_target_impl_path.read_text()
838
+
839
+ # Find dev() method
840
+ dev_idx = content.index("impl DesktopTarget.dev(")
841
+ # Find next impl
842
+ next_impl = content.find("\nimpl ", dev_idx + 1)
843
+ if next_impl == -1:
844
+ next_impl = content.find('\n"""Update tauri', dev_idx + 100)
845
+ dev_body = content[dev_idx:next_impl] if next_impl != -1 else content[dev_idx:]
846
+
847
+ assert "_start_backend_server(" in dev_body, (
848
+ "dev() should call _start_backend_server"
849
+ )
850
+ assert "_resolve_server_port(" in dev_body, (
851
+ "dev() should call _resolve_server_port to determine server port"
852
+ )
853
+ # Server process should be terminated in cleanup
854
+ assert "server_process" in dev_body, "dev() should manage server_process lifecycle"