fvpnctl 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 (31) hide show
  1. {fvpnctl-0.2.2 → fvpnctl-0.2.4}/PKG-INFO +30 -4
  2. {fvpnctl-0.2.2 → fvpnctl-0.2.4}/README.md +29 -3
  3. {fvpnctl-0.2.2 → fvpnctl-0.2.4}/pyproject.toml +1 -1
  4. {fvpnctl-0.2.2 → fvpnctl-0.2.4}/src/fvpnctl/__init__.py +1 -1
  5. {fvpnctl-0.2.2 → fvpnctl-0.2.4}/src/fvpnctl/cli.py +103 -17
  6. fvpnctl-0.2.4/src/fvpnctl/history.py +129 -0
  7. fvpnctl-0.2.4/src/fvpnctl/spinner.py +215 -0
  8. {fvpnctl-0.2.2 → fvpnctl-0.2.4}/tests/test_cli.py +79 -2
  9. fvpnctl-0.2.4/tests/test_history.py +91 -0
  10. fvpnctl-0.2.4/tests/test_spinner.py +168 -0
  11. {fvpnctl-0.2.2 → fvpnctl-0.2.4}/uv.lock +1 -1
  12. {fvpnctl-0.2.2 → fvpnctl-0.2.4}/.github/workflows/ci.yml +0 -0
  13. {fvpnctl-0.2.2 → fvpnctl-0.2.4}/.gitignore +0 -0
  14. {fvpnctl-0.2.2 → fvpnctl-0.2.4}/.pre-commit-config.yaml +0 -0
  15. {fvpnctl-0.2.2 → fvpnctl-0.2.4}/LICENSE +0 -0
  16. {fvpnctl-0.2.2 → fvpnctl-0.2.4}/contrib/com.fvpnctl.headless.plist +0 -0
  17. {fvpnctl-0.2.2 → fvpnctl-0.2.4}/docs/how-it-works.md +0 -0
  18. {fvpnctl-0.2.2 → fvpnctl-0.2.4}/docs/superpowers/specs/2026-06-20-fortivpn-cli-via-cdp-design.md +0 -0
  19. {fvpnctl-0.2.2 → fvpnctl-0.2.4}/src/fvpnctl/cdp.py +0 -0
  20. {fvpnctl-0.2.2 → fvpnctl-0.2.4}/src/fvpnctl/controller.py +0 -0
  21. {fvpnctl-0.2.2 → fvpnctl-0.2.4}/src/fvpnctl/errors.py +0 -0
  22. {fvpnctl-0.2.2 → fvpnctl-0.2.4}/src/fvpnctl/keychain.py +0 -0
  23. {fvpnctl-0.2.2 → fvpnctl-0.2.4}/src/fvpnctl/launcher.py +0 -0
  24. {fvpnctl-0.2.2 → fvpnctl-0.2.4}/src/fvpnctl/monitor.py +0 -0
  25. {fvpnctl-0.2.2 → fvpnctl-0.2.4}/tests/manual/test_live.py +0 -0
  26. {fvpnctl-0.2.2 → fvpnctl-0.2.4}/tests/test_cdp.py +0 -0
  27. {fvpnctl-0.2.2 → fvpnctl-0.2.4}/tests/test_controller.py +0 -0
  28. {fvpnctl-0.2.2 → fvpnctl-0.2.4}/tests/test_errors.py +0 -0
  29. {fvpnctl-0.2.2 → fvpnctl-0.2.4}/tests/test_keychain.py +0 -0
  30. {fvpnctl-0.2.2 → fvpnctl-0.2.4}/tests/test_launcher.py +0 -0
  31. {fvpnctl-0.2.2 → fvpnctl-0.2.4}/tests/test_monitor.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: fvpnctl
3
- Version: 0.2.2
3
+ Version: 0.2.4
4
4
  Summary: Control your FortiClient VPN from the macOS command line.
5
5
  Project-URL: Homepage, https://github.com/mpasternak/fortivpn-cli
6
6
  Project-URL: Repository, https://github.com/mpasternak/fortivpn-cli
@@ -224,6 +224,9 @@ Global options:
224
224
  - `--verbose` / `--quiet` — progress messages. Verbose is **on by default** and writes
225
225
  progress to **stderr**; `--quiet` silences it. Either way `stdout` carries only the
226
226
  machine-readable result, so `--json` output and shell pipelines are byte-identical.
227
+ While `connect` waits for the tunnel (it polls once a second), verbose mode shows a
228
+ Braille throbber with an elapsed-seconds counter on a TTY, falls back to a single
229
+ static line when stderr is piped or redirected, and shows nothing under `--quiet`.
227
230
 
228
231
  `list` and `status` additionally accept `--json` for machine-readable output.
229
232
 
@@ -333,15 +336,38 @@ tray for you (without quitting the app) — best effort, so a failure to hide ne
333
336
  otherwise-successful connect. Pass `--show-window` to keep the window up. (This only applies
334
337
  in the waited path; with `--no-wait` the popup happens after the command returns.)
335
338
 
336
- ### `fvpnctl disconnect <profile>`
339
+ While it waits, `connect` shows live progress on a TTY (verbose mode). The **first** time you
340
+ connect a profile there is no timing history, so it shows an indeterminate Braille throbber
341
+ with an elapsed-seconds counter. fvpnctl records how long each successful connect took (per
342
+ profile, the last 10 runs, under `~/.local/state/fvpnctl/` — overridable with
343
+ `$FVPNCTL_STATE_DIR`), so on **subsequent** connects it shows a **progress bar** that fills
344
+ toward the average of those past durations:
337
345
 
338
- Disconnect an IPsec profile.
346
+ ```console
347
+ $ fvpnctl connect office
348
+ Connecting profile office… [█████████████░░░░░░░░░░░] 54% 7s / ~13s
349
+ ```
350
+
351
+ The bar never claims 100% until the tunnel is actually up, and the `7s / ~13s` tail stays
352
+ honest if a connect runs longer than usual. Piped/redirected output falls back to a single
353
+ static line, and `--quiet` shows nothing.
354
+
355
+ ### `fvpnctl disconnect [profile]`
356
+
357
+ Disconnect a tunnel. The **profile argument is optional**: with no argument `disconnect`
358
+ tears down whatever is connected right now (it reads the active connection from the daemon),
359
+ so you don't have to remember which profile is up. Pass a name to target a specific profile.
339
360
 
340
361
  ```console
341
- $ fvpnctl disconnect office
362
+ $ fvpnctl disconnect # disconnect the active tunnel
363
+ DISCONNECTED office
364
+ $ fvpnctl disconnect office # or name it explicitly
342
365
  DISCONNECTED office
343
366
  ```
344
367
 
368
+ If nothing is connected, `disconnect` says so and exits `0` (the desired end state already
369
+ holds), so it is safe to run unconditionally in scripts.
370
+
345
371
  ### `fvpnctl ip`
346
372
 
347
373
  Print just the assigned tunnel IP — handy in scripts. Exits `1` with a message on stderr if
@@ -200,6 +200,9 @@ Global options:
200
200
  - `--verbose` / `--quiet` — progress messages. Verbose is **on by default** and writes
201
201
  progress to **stderr**; `--quiet` silences it. Either way `stdout` carries only the
202
202
  machine-readable result, so `--json` output and shell pipelines are byte-identical.
203
+ While `connect` waits for the tunnel (it polls once a second), verbose mode shows a
204
+ Braille throbber with an elapsed-seconds counter on a TTY, falls back to a single
205
+ static line when stderr is piped or redirected, and shows nothing under `--quiet`.
203
206
 
204
207
  `list` and `status` additionally accept `--json` for machine-readable output.
205
208
 
@@ -309,15 +312,38 @@ tray for you (without quitting the app) — best effort, so a failure to hide ne
309
312
  otherwise-successful connect. Pass `--show-window` to keep the window up. (This only applies
310
313
  in the waited path; with `--no-wait` the popup happens after the command returns.)
311
314
 
312
- ### `fvpnctl disconnect <profile>`
315
+ While it waits, `connect` shows live progress on a TTY (verbose mode). The **first** time you
316
+ connect a profile there is no timing history, so it shows an indeterminate Braille throbber
317
+ with an elapsed-seconds counter. fvpnctl records how long each successful connect took (per
318
+ profile, the last 10 runs, under `~/.local/state/fvpnctl/` — overridable with
319
+ `$FVPNCTL_STATE_DIR`), so on **subsequent** connects it shows a **progress bar** that fills
320
+ toward the average of those past durations:
313
321
 
314
- Disconnect an IPsec profile.
322
+ ```console
323
+ $ fvpnctl connect office
324
+ Connecting profile office… [█████████████░░░░░░░░░░░] 54% 7s / ~13s
325
+ ```
326
+
327
+ The bar never claims 100% until the tunnel is actually up, and the `7s / ~13s` tail stays
328
+ honest if a connect runs longer than usual. Piped/redirected output falls back to a single
329
+ static line, and `--quiet` shows nothing.
330
+
331
+ ### `fvpnctl disconnect [profile]`
332
+
333
+ Disconnect a tunnel. The **profile argument is optional**: with no argument `disconnect`
334
+ tears down whatever is connected right now (it reads the active connection from the daemon),
335
+ so you don't have to remember which profile is up. Pass a name to target a specific profile.
315
336
 
316
337
  ```console
317
- $ fvpnctl disconnect office
338
+ $ fvpnctl disconnect # disconnect the active tunnel
339
+ DISCONNECTED office
340
+ $ fvpnctl disconnect office # or name it explicitly
318
341
  DISCONNECTED office
319
342
  ```
320
343
 
344
+ If nothing is connected, `disconnect` says so and exits `0` (the desired end state already
345
+ holds), so it is safe to run unconditionally in scripts.
346
+
321
347
  ### `fvpnctl ip`
322
348
 
323
349
  Print just the assigned tunnel IP — handy in scripts. Exits `1` with a message on stderr if
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "fvpnctl"
3
- version = "0.2.2"
3
+ version = "0.2.4"
4
4
  requires-python = ">=3.11"
5
5
  readme = "README.md"
6
6
  description = "Control your FortiClient VPN from the macOS command line."
@@ -1,3 +1,3 @@
1
1
  """fvpnctl — control FortiClient IPsec VPN on macOS via the Chrome DevTools Protocol."""
2
2
 
3
- __version__ = "0.2.2"
3
+ __version__ = "0.2.4"
@@ -62,16 +62,19 @@ import argparse
62
62
  import json
63
63
  import os
64
64
  import sys
65
+ import time
65
66
 
66
- from fvpnctl import launcher, monitor
67
+ from fvpnctl import history, launcher, monitor
67
68
  from fvpnctl.cdp import CDPSession
68
69
  from fvpnctl.controller import FortiVPN
69
70
  from fvpnctl.errors import CDPEvaluateError, FortiError, NotRunningError
71
+ from fvpnctl.spinner import ProgressBar, Spinner
70
72
 
71
- # IPsec ``ipsec_state`` value meaning "tunnel up" (docs/how-it-works.md section 2
72
- # / design spec 4.2). The CLI only needs the CONNECTED sentinel; the controller
73
- # owns the full enum.
73
+ # IPsec ``ipsec_state`` values the CLI needs as sentinels (docs/how-it-works.md
74
+ # section 2 / design spec 4.2). The controller owns the full enum; the CLI only
75
+ # compares against "tunnel up" and "tunnel fully down".
74
76
  _CONNECTED = 2
77
+ _DISCONNECTED = 0
75
78
 
76
79
  # Connection type for v1. Only IPsec is supported (SSL is out of scope); the CLI
77
80
  # always queries/derives with this type. See design spec section 2.
@@ -214,8 +217,23 @@ def _build_parser() -> argparse.ArgumentParser:
214
217
  )
215
218
  p_connect.set_defaults(func=_cmd_connect)
216
219
 
217
- p_disconnect = sub.add_parser("disconnect", parents=[common], help="Disconnect a profile.")
218
- p_disconnect.add_argument("profile", help="The profile (connection_name) to disconnect.")
220
+ p_disconnect = sub.add_parser(
221
+ "disconnect",
222
+ parents=[common],
223
+ help="Disconnect the active tunnel (or a named profile).",
224
+ description=(
225
+ "Disconnect the currently active tunnel. The profile argument is "
226
+ "optional: with no argument it disconnects whatever is connected now "
227
+ "(read from the daemon's reported connection); pass a name to target a "
228
+ "specific profile."
229
+ ),
230
+ )
231
+ p_disconnect.add_argument(
232
+ "profile",
233
+ nargs="?",
234
+ default=None,
235
+ help="The profile (connection_name) to disconnect (default: the active one).",
236
+ )
219
237
  p_disconnect.set_defaults(func=_cmd_disconnect)
220
238
 
221
239
  p_ip = sub.add_parser(
@@ -364,21 +382,48 @@ def _cmd_connect(fvpnctl: FortiVPN, args: argparse.Namespace) -> int:
364
382
  and returns; otherwise, on a CONNECTED terminal state, it fetches the VPN IP
365
383
  and prints ``CONNECTED <profile> <ip>``. The password is resolved inside the
366
384
  controller (from the Keychain) and is never accepted or printed here.
385
+
386
+ The waited path blocks in the controller's once-a-second poll loop while the
387
+ daemon negotiates, which otherwise looks frozen — so it is wrapped in a live
388
+ indicator on stderr (a single static line when piped, nothing under
389
+ ``--quiet``; gated by ``_VERBOSE`` exactly like :func:`report`). When this
390
+ profile has connect-time history a :class:`~fvpnctl.spinner.ProgressBar`
391
+ fills toward the mean past duration (the ETA); with no history yet it falls
392
+ back to the indeterminate :class:`~fvpnctl.spinner.Spinner` throbber. Each
393
+ successful waited connect's wall-clock time is recorded so the *next* connect
394
+ can show the bar. ``--no-wait`` returns immediately, so it keeps the plain
395
+ progress line instead.
367
396
  """
368
397
  wait = not args.no_wait
369
- report(f"Connecting profile {args.profile}…")
370
- state = fvpnctl.connect(
371
- args.profile,
372
- username=args.user,
373
- wait=wait,
374
- timeout=args.timeout,
375
- )
398
+ msg = f"Connecting profile {args.profile}…"
376
399
 
377
400
  if not wait:
401
+ report(msg)
402
+ fvpnctl.connect(args.profile, username=args.user, wait=False, timeout=args.timeout)
378
403
  print(f"connecting {args.profile} ...")
379
404
  return 0
380
405
 
406
+ eta = _connect_eta(args.profile)
407
+ indicator = (
408
+ ProgressBar(msg, eta, stream=sys.stderr, enabled=_VERBOSE)
409
+ if eta is not None
410
+ else Spinner(msg, stream=sys.stderr, enabled=_VERBOSE)
411
+ )
412
+
413
+ start = time.monotonic()
414
+ with indicator:
415
+ state = fvpnctl.connect(
416
+ args.profile,
417
+ username=args.user,
418
+ wait=True,
419
+ timeout=args.timeout,
420
+ )
421
+ elapsed = time.monotonic() - start
422
+
381
423
  if state.ipsec_state == _CONNECTED:
424
+ # Record the duration only on a successful waited connect — that is what
425
+ # the user actually waited through and what the next ETA should average.
426
+ _record_connect_time(args.profile, elapsed)
382
427
  ip = fvpnctl.connection_ip(args.profile, _IPSEC)
383
428
  vpn_ip = ip.get("vpn_ip", "")
384
429
  # FortiClient pops its window on connect even under --hide-gui; hide it
@@ -386,7 +431,7 @@ def _cmd_connect(fvpnctl: FortiVPN, args: argparse.Namespace) -> int:
386
431
  # waited path: with --no-wait the popup happens after we return.
387
432
  if not args.show_window:
388
433
  _hide_window_best_effort(fvpnctl)
389
- report(f"Connected: {vpn_ip}")
434
+ report(f"Connected: {vpn_ip} (in {elapsed:.0f}s)")
390
435
  print(f"CONNECTED {args.profile} {vpn_ip}")
391
436
  else:
392
437
  # Reached here only with wait=True and a non-connected terminal state the
@@ -396,10 +441,51 @@ def _cmd_connect(fvpnctl: FortiVPN, args: argparse.Namespace) -> int:
396
441
  return 0
397
442
 
398
443
 
444
+ def _connect_eta(profile: str) -> float | None:
445
+ """Mean past connect time for ``profile`` (None ⇒ show the throbber instead).
446
+
447
+ Reads :func:`history.average`. The history is a best-effort UX aid: a missing,
448
+ corrupt, or unreadable store must never block a connect, so any
449
+ ``OSError``/``ValueError`` is reported (when verbose) and treated as "no ETA"
450
+ — the CLI then uses the indeterminate spinner.
451
+ """
452
+ try:
453
+ return history.average(profile)
454
+ except (OSError, ValueError) as e:
455
+ report(f"(connect-time history unavailable: {e})")
456
+ return None
457
+
458
+
459
+ def _record_connect_time(profile: str, seconds: float) -> None:
460
+ """Persist this connect's duration; report and continue if it can't be saved.
461
+
462
+ A failed write (corrupt store, read-only dir) must not undo a connect that
463
+ already succeeded, so the error is surfaced (when verbose) and swallowed here.
464
+ """
465
+ try:
466
+ history.record(profile, seconds)
467
+ except (OSError, ValueError) as e:
468
+ report(f"(could not save connect time: {e})")
469
+
470
+
399
471
  def _cmd_disconnect(fvpnctl: FortiVPN, args: argparse.Namespace) -> int:
400
- """``fvpnctl disconnect <profile>`` — tear down the tunnel and confirm."""
401
- fvpnctl.disconnect(args.profile)
402
- print(f"DISCONNECTED {args.profile}")
472
+ """``fvpnctl disconnect [profile]`` — tear down a tunnel and confirm.
473
+
474
+ With no ``profile`` it disconnects whatever is active: it reads ``state()``
475
+ and uses the daemon's reported ``connection_name``. If nothing is active it
476
+ says so and returns 0 — the desired end state (disconnected) already holds, so
477
+ ``fvpnctl disconnect`` is idempotent. An explicit ``profile`` is still honored
478
+ (and still works even if the daemon reports a different/empty active name).
479
+ """
480
+ profile = args.profile
481
+ if profile is None:
482
+ state = fvpnctl.state()
483
+ if state.ipsec_state == _DISCONNECTED or not state.name:
484
+ print("No active tunnel to disconnect.", file=sys.stderr)
485
+ return 0
486
+ profile = state.name
487
+ fvpnctl.disconnect(profile)
488
+ print(f"DISCONNECTED {profile}")
403
489
  return 0
404
490
 
405
491
 
@@ -0,0 +1,129 @@
1
+ """Connect-time history — per-profile durations that feed the progress bar.
2
+
3
+ What this is
4
+ ------------
5
+ ``fvpnctl connect`` blocks while the daemon negotiates the tunnel. The first time
6
+ a profile is connected there is nothing to estimate against, so the CLI shows an
7
+ indeterminate Braille throbber (``spinner.Spinner``). Every *successful* waited
8
+ connect records its wall-clock duration here; on the next connect the CLI reads
9
+ back the mean of the recent samples and shows a **determinate progress bar**
10
+ (``spinner.ProgressBar``) that fills toward that ETA.
11
+
12
+ Why per-profile, why a recent window
13
+ ------------------------------------
14
+ Different gateways have very different handshake times, so durations are keyed by
15
+ ``connection_name``. Only the last :data:`_HISTORY_LEN` samples are kept and
16
+ averaged, so the estimate tracks *current* network conditions instead of being
17
+ dragged by months-old measurements.
18
+
19
+ Where it lives
20
+ --------------
21
+ A small JSON file under the per-user XDG *state* dir (these are derived numbers
22
+ fvpnctl can always rebuild — state, not config). The location is resolved by
23
+ :func:`state_dir`; ``$FVPNCTL_STATE_DIR`` overrides everything (the test suite
24
+ points it at a tmp dir so it never touches real user state).
25
+
26
+ Error policy (project rule: never swallow silently)
27
+ ---------------------------------------------------
28
+ A *missing* file is the normal first-run case and yields an empty history — not
29
+ an error. A *corrupt* file makes :func:`load`/:func:`average` raise ``ValueError``
30
+ so the CLI can surface it (``cli._connect_eta`` reports it and falls back to the
31
+ throbber). :func:`record` additionally self-heals a corrupt file rather than
32
+ failing a connect that just succeeded — the one place a narrow catch is justified.
33
+ """
34
+
35
+ import json
36
+ import os
37
+ from pathlib import Path
38
+
39
+ # Keep at most this many recent measurements per profile; the progress-bar ETA is
40
+ # their mean. A recent window (not all-time) so the estimate adapts to changing
41
+ # network conditions.
42
+ _HISTORY_LEN = 10
43
+
44
+ _FILENAME = "connect-durations.json"
45
+
46
+
47
+ def state_dir() -> Path:
48
+ """Resolve the directory holding fvpnctl's persistent state.
49
+
50
+ Order: ``$FVPNCTL_STATE_DIR`` (explicit override; used by the tests), then
51
+ ``$XDG_STATE_HOME/fvpnctl``, else ``~/.local/state/fvpnctl``.
52
+ """
53
+ override = os.environ.get("FVPNCTL_STATE_DIR")
54
+ if override:
55
+ return Path(override).expanduser()
56
+ xdg = os.environ.get("XDG_STATE_HOME")
57
+ base = Path(xdg).expanduser() if xdg else Path.home() / ".local" / "state"
58
+ return base / "fvpnctl"
59
+
60
+
61
+ def _path() -> Path:
62
+ return state_dir() / _FILENAME
63
+
64
+
65
+ def load() -> dict[str, list]:
66
+ """Load the per-profile duration history.
67
+
68
+ :returns: the raw ``{profile: [seconds, ...]}`` mapping (``{}`` when no file
69
+ exists yet — the normal first-run case).
70
+ :raises ValueError: the file exists but is not valid JSON, or is valid JSON of
71
+ the wrong shape (``json.JSONDecodeError`` is a ``ValueError`` subclass).
72
+ Callers surface this rather than mask it.
73
+ """
74
+ try:
75
+ raw = _path().read_text(encoding="utf-8")
76
+ except FileNotFoundError:
77
+ return {}
78
+ data = json.loads(raw)
79
+ if not isinstance(data, dict):
80
+ raise ValueError(f"{_path()} is not a JSON object")
81
+ return data
82
+
83
+
84
+ def average(profile: str) -> float | None:
85
+ """Mean recorded connect duration for ``profile`` — the progress-bar ETA.
86
+
87
+ :returns: the mean of the stored samples, or ``None`` when the profile has no
88
+ usable history yet. Non-numeric stray entries are ignored defensively so a
89
+ single bad value never breaks the estimate.
90
+ :raises ValueError: if the underlying store is corrupt (propagated from
91
+ :func:`load`).
92
+ """
93
+ samples = [float(x) for x in load().get(profile, []) if isinstance(x, (int, float))]
94
+ if not samples:
95
+ return None
96
+ return sum(samples) / len(samples)
97
+
98
+
99
+ def record(profile: str, seconds: float) -> None:
100
+ """Append one connect duration for ``profile`` and persist (keep last N).
101
+
102
+ :raises OSError: if the store cannot be written (caller decides whether to
103
+ surface or ignore — a failed write must never undo a successful connect).
104
+ """
105
+ try:
106
+ data = load()
107
+ except ValueError:
108
+ # A corrupt history file is non-critical: reset rather than fail the
109
+ # connect that just succeeded. This measurement re-creates a valid file.
110
+ data = {}
111
+ samples = data.get(profile)
112
+ if not isinstance(samples, list):
113
+ samples = []
114
+ samples.append(round(float(seconds), 1))
115
+ data[profile] = samples[-_HISTORY_LEN:]
116
+ save(data)
117
+
118
+
119
+ def save(data: dict) -> None:
120
+ """Atomically write the history JSON (``mkdir -p`` + temp file + rename).
121
+
122
+ The temp-file-then-``replace`` keeps the file readable at all times: a crash
123
+ mid-write leaves the previous good file intact rather than a truncated one.
124
+ """
125
+ path = _path()
126
+ path.parent.mkdir(parents=True, exist_ok=True)
127
+ tmp = path.with_name(path.name + ".tmp")
128
+ tmp.write_text(json.dumps(data), encoding="utf-8")
129
+ tmp.replace(path)
@@ -0,0 +1,215 @@
1
+ """Connect progress indicators — a Braille throbber and an ETA progress bar.
2
+
3
+ Why this exists
4
+ ---------------
5
+ ``fvpnctl connect <profile>`` (without ``--no-wait``) blocks in
6
+ :meth:`FortiVPN._wait_for_connection`, which polls ``getConnectionState`` once a
7
+ second for up to ``--timeout`` seconds while the daemon negotiates the IPsec
8
+ tunnel. That loop is silent, so a slow handshake looks like a frozen process.
9
+ This module paints live feedback so the user can see the wait is *working*:
10
+
11
+ * :class:`Spinner` — an indeterminate Braille throbber + elapsed seconds, used
12
+ the first time a profile is connected (no timing history to estimate against).
13
+ * :class:`ProgressBar` — a determinate bar that fills toward an ETA, used once
14
+ ``history`` has recorded past connect times for the profile (the ETA is their
15
+ mean; see ``history.average``).
16
+
17
+ Both share the lifecycle in :class:`_Animated`; they differ only in the single
18
+ line they paint each tick (:meth:`_Animated._line`).
19
+
20
+ How they stay out of the controller's way
21
+ -----------------------------------------
22
+ The poll loop lives in ``controller.py`` and must stay UI-agnostic (the same
23
+ controller/transport/render split the rest of the codebase follows — see
24
+ ``monitor.py``). So the indicator does **not** reach into the loop: the CLI wraps
25
+ the blocking ``connect()`` call in the context manager, the indicator animates on
26
+ a daemon thread while the main thread does the CDP I/O, and ``__exit__`` tears
27
+ the animation down. The controller is untouched.
28
+
29
+ Three modes, chosen from the stream + verbosity (:func:`select_mode`)
30
+ ---------------------------------------------------------------------
31
+ * **off** — ``--quiet`` (``enabled=False``): write nothing at all.
32
+ * **static** — verbose but the stream is not a TTY (piped / redirected): one
33
+ plain ``message`` line, no ANSI and no ``\\r`` spam, so logs stay readable.
34
+ * **animate**— verbose on a real TTY: hide the cursor, paint the first frame
35
+ synchronously (instant feedback, and one deterministic frame for tests), then
36
+ animate on a background thread until ``__exit__``.
37
+
38
+ stderr, never stdout
39
+ --------------------
40
+ Like ``cli.report``, indicators write to **stderr** so stdout stays the
41
+ machine-readable channel (``CONNECTED <profile> <ip>``, ``--json``, pipelines).
42
+ ``__exit__`` wipes the line and restores the cursor *before* the caller prints
43
+ its result/error, so success and failure both start on a clean line.
44
+
45
+ Pure vs. I/O
46
+ ------------
47
+ :func:`render_frame`, :func:`render_bar` and :func:`select_mode` are pure (data
48
+ in, string out) and unit-tested directly; only :class:`_Animated` touches a
49
+ stream, a thread, and the clock.
50
+ """
51
+
52
+ import sys
53
+ import threading
54
+ import time
55
+
56
+ # Braille "dots" spinner — ten frames that read as a smooth rotation. Zero
57
+ # dependencies (project rule): the frames are just literal Unicode here.
58
+ _FRAMES = "⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏"
59
+
60
+ # ANSI control sequences (defined locally so this module stays standalone, like
61
+ # ``monitor.py`` keeps its own copy). Named so the I/O below reads as intent.
62
+ _HIDE_CURSOR = "\x1b[?25l"
63
+ _SHOW_CURSOR = "\x1b[?25h"
64
+ _CLEAR_EOL = "\x1b[K"
65
+
66
+ # Progress-bar geometry and glyphs.
67
+ _BAR_W = 24
68
+ _BAR_FILL = "█"
69
+ _BAR_EMPTY = "░"
70
+ # Never paint a full bar while the connect is still running — 100% is reserved
71
+ # for "actually finished". The bar saturates just below full and the elapsed/ETA
72
+ # tail keeps counting, so a slower-than-usual connect reads as "nearly there,
73
+ # overdue" rather than a bar stuck at 100%.
74
+ _BAR_CAP = 0.99
75
+
76
+
77
+ def render_frame(frame: str, message: str, elapsed: int) -> str:
78
+ """Build one throbber line: ``\\r<frame> <message> <elapsed>s`` + clear-to-EOL.
79
+
80
+ Leads with ``\\r`` so each frame overwrites the previous in place, and ends
81
+ with :data:`_CLEAR_EOL` so a shrinking line never leaves stale trailing
82
+ characters. Pure — no I/O.
83
+ """
84
+ return f"\r{frame} {message} {elapsed:d}s{_CLEAR_EOL}"
85
+
86
+
87
+ def render_bar(message: str, elapsed: float, eta: float, *, width: int = _BAR_W) -> str:
88
+ """Build one progress-bar line filled to ``elapsed/eta`` (capped below 100%).
89
+
90
+ The fraction is clamped to :data:`_BAR_CAP` and at least one cell is always
91
+ left empty while running, so the bar never claims completion before the
92
+ connect actually returns. The ``<elapsed>s / ~<eta>s`` tail stays honest when
93
+ a connect overruns its estimate (e.g. ``41s / ~30s`` at 99%). Pure — no I/O.
94
+ """
95
+ frac = 0.0 if eta <= 0 else min(elapsed / eta, _BAR_CAP)
96
+ filled = int(frac * width)
97
+ if frac < 1.0:
98
+ filled = min(filled, width - 1) # keep one empty cell until truly done
99
+ bar = _BAR_FILL * filled + _BAR_EMPTY * (width - filled)
100
+ pct = int(frac * 100)
101
+ tail = f"{int(elapsed)}s / ~{int(round(eta))}s"
102
+ return f"\r{message} [{bar}] {pct:>2d}% {tail}{_CLEAR_EOL}"
103
+
104
+
105
+ def select_mode(stream, *, enabled: bool) -> str:
106
+ """Choose ``off``/``static``/``animate`` from verbosity + the stream.
107
+
108
+ ``enabled`` is the CLI's verbose flag: ``False`` (``--quiet``) → ``off``
109
+ regardless of the stream. Otherwise a real TTY animates and anything else
110
+ (a pipe, a file, a captured test buffer) gets the single static line.
111
+ """
112
+ if not enabled:
113
+ return "off"
114
+ isatty = getattr(stream, "isatty", None)
115
+ if isatty and isatty():
116
+ return "animate"
117
+ return "static"
118
+
119
+
120
+ class _Animated:
121
+ """Shared lifecycle for the connect indicators: mode, thread, cursor, cleanup.
122
+
123
+ Subclasses implement :meth:`_line` to render one frame for a given elapsed
124
+ time and tick count. In ``animate`` mode a daemon thread repaints every
125
+ ``interval`` seconds; ``__exit__`` stops it, joins it, wipes the line and
126
+ restores the cursor — including when the wrapped block raises (a
127
+ ``ConnectTimeout``/``ConnectFailed`` must still leave a clean terminal).
128
+ """
129
+
130
+ def __init__(self, message: str, *, stream=None, enabled: bool = True, interval: float = 0.1):
131
+ self._message = message
132
+ self._stream = stream if stream is not None else sys.stderr
133
+ self._interval = interval
134
+ self._mode = select_mode(self._stream, enabled=enabled)
135
+ self._stop = threading.Event()
136
+ self._thread: threading.Thread | None = None
137
+ self._start: float | None = None
138
+ self._tick = 0
139
+
140
+ def _line(self, elapsed: float, tick: int) -> str:
141
+ """Return the full ``\\r…`` line to paint this frame (subclass hook)."""
142
+ raise NotImplementedError
143
+
144
+ def __enter__(self) -> "_Animated":
145
+ if self._mode == "off":
146
+ return self
147
+ if self._mode == "static":
148
+ # Pipe/log path: one plain line, no animation.
149
+ self._stream.write(self._message + "\n")
150
+ self._stream.flush()
151
+ return self
152
+ # animate: hide the cursor and paint the first frame right away so there
153
+ # is instant feedback (and one deterministic frame for the tests) before
154
+ # the background thread takes over.
155
+ self._start = time.monotonic()
156
+ self._stream.write(_HIDE_CURSOR)
157
+ self._paint()
158
+ self._thread = threading.Thread(target=self._run, daemon=True)
159
+ self._thread.start()
160
+ return self
161
+
162
+ def _paint(self) -> None:
163
+ """Write one frame for the current elapsed time + tick, then flush."""
164
+ elapsed = time.monotonic() - self._start if self._start is not None else 0.0
165
+ self._stream.write(self._line(elapsed, self._tick))
166
+ self._stream.flush()
167
+ self._tick += 1
168
+
169
+ def _run(self) -> None:
170
+ """Background loop: repaint every ``interval`` until stopped.
171
+
172
+ Uses ``Event.wait(interval)`` rather than ``time.sleep`` so ``__exit__``
173
+ setting the stop event ends the wait *immediately* — teardown never
174
+ delays the caller by up to ``interval``.
175
+ """
176
+ while not self._stop.wait(self._interval):
177
+ self._paint()
178
+
179
+ def __exit__(self, *exc) -> bool:
180
+ if self._mode != "animate":
181
+ return False
182
+ self._stop.set()
183
+ if self._thread is not None:
184
+ self._thread.join()
185
+ # Wipe the line and bring the cursor back so the caller's next write
186
+ # (result on stdout / error on stderr) starts on a clean line.
187
+ self._stream.write("\r" + _CLEAR_EOL + _SHOW_CURSOR)
188
+ self._stream.flush()
189
+ return False # never suppress the wrapped block's exception
190
+
191
+
192
+ class Spinner(_Animated):
193
+ """Indeterminate Braille throbber — used when there is no ETA to estimate."""
194
+
195
+ def _line(self, elapsed: float, tick: int) -> str:
196
+ return render_frame(_FRAMES[tick % len(_FRAMES)], self._message, int(elapsed))
197
+
198
+
199
+ class ProgressBar(_Animated):
200
+ """Determinate progress bar driven by ``eta`` (mean of past connect times)."""
201
+
202
+ def __init__(
203
+ self,
204
+ message: str,
205
+ eta: float,
206
+ *,
207
+ stream=None,
208
+ enabled: bool = True,
209
+ interval: float = 0.1,
210
+ ):
211
+ super().__init__(message, stream=stream, enabled=enabled, interval=interval)
212
+ self._eta = eta
213
+
214
+ def _line(self, elapsed: float, tick: int) -> str:
215
+ return render_bar(self._message, elapsed, self._eta)
@@ -142,13 +142,15 @@ class _FakeProfile:
142
142
 
143
143
 
144
144
  @pytest.fixture(autouse=True)
145
- def patched(monkeypatch):
145
+ def patched(monkeypatch, tmp_path):
146
146
  """Swap the two seams and reset per-test state.
147
147
 
148
148
  Every test gets fresh ``FakeSession.instances`` and ``FakeController.config``
149
149
  so assertions about construction args / routed calls never bleed between
150
150
  tests. The ``FORTI_CDP_PORT`` env var is cleared so the default-port tests
151
- are not perturbed by the developer's environment.
151
+ are not perturbed by the developer's environment. ``FVPNCTL_STATE_DIR`` is
152
+ pointed at a throwaway ``tmp_path`` so the connect-time history the CLI now
153
+ reads/writes never touches the developer's real state dir.
152
154
  """
153
155
  FakeSession.instances = []
154
156
  FakeController.config = {}
@@ -156,6 +158,8 @@ def patched(monkeypatch):
156
158
  monkeypatch.setattr(cli, "CDPSession", FakeSession)
157
159
  monkeypatch.setattr(cli, "FortiVPN", FakeController)
158
160
  monkeypatch.delenv("FORTI_CDP_PORT", raising=False)
161
+ monkeypatch.setenv("FVPNCTL_STATE_DIR", str(tmp_path / "state"))
162
+ monkeypatch.delenv("XDG_STATE_HOME", raising=False)
159
163
  return FakeController
160
164
 
161
165
 
@@ -343,6 +347,30 @@ def test_disconnect_routes_and_prints(capsys):
343
347
  assert out == "DISCONNECTED office"
344
348
 
345
349
 
350
+ def test_disconnect_no_arg_uses_active_profile(capsys):
351
+ # No profile given → read state(), disconnect whatever the daemon reports.
352
+ FakeController.config["state"] = FakeState(ipsec_state=2, name="apoz")
353
+ FakeController.config["disconnect"] = None
354
+
355
+ rc = cli.main(["disconnect"])
356
+
357
+ assert rc == 0
358
+ disconnect_calls = [c for c in FakeController.last.calls if c[0] == "disconnect"]
359
+ assert disconnect_calls[0][1][0] == "apoz"
360
+ assert capsys.readouterr().out.strip() == "DISCONNECTED apoz"
361
+
362
+
363
+ def test_disconnect_no_arg_when_nothing_connected(capsys):
364
+ # Idempotent: nothing active → say so, never call DisconnectTunnel, exit 0.
365
+ FakeController.config["state"] = FakeState(ipsec_state=0, name="", state_label="DISCONNECTED")
366
+
367
+ rc = cli.main(["disconnect"])
368
+
369
+ assert rc == 0
370
+ assert [c for c in FakeController.last.calls if c[0] == "disconnect"] == []
371
+ assert "no active" in capsys.readouterr().err.lower()
372
+
373
+
346
374
  def test_connect_hides_window_by_default():
347
375
  FakeController.config["connect"] = FakeState(ipsec_state=2, name="office")
348
376
  FakeController.config["connection_ip"] = {"vpn_ip": "172.16.200.2"}
@@ -388,6 +416,55 @@ def test_connect_hide_failure_does_not_fail_connect(capsys):
388
416
  assert "CONNECTED" in capsys.readouterr().out
389
417
 
390
418
 
419
+ def test_connect_records_duration_for_next_time():
420
+ # A successful waited connect persists its duration (isolated tmp state dir),
421
+ # so the NEXT connect can show a progress bar.
422
+ from fvpnctl import history
423
+
424
+ FakeController.config["connect"] = FakeState(ipsec_state=2, name="apoz")
425
+ FakeController.config["connection_ip"] = {"vpn_ip": "10.0.0.2"}
426
+
427
+ assert history.average("apoz") is None # nothing recorded yet
428
+ rc = cli.main(["connect", "apoz"])
429
+
430
+ assert rc == 0
431
+ assert history.average("apoz") is not None # one measurement now stored
432
+
433
+
434
+ def test_connect_with_history_succeeds_via_progress_bar_branch(capsys):
435
+ # Pre-seeded history → eta is not None → the ProgressBar branch is taken.
436
+ # Output is captured (non-TTY) so the bar renders as its static line; the
437
+ # connect must still succeed and print the machine-readable result.
438
+ from fvpnctl import history
439
+
440
+ history.record("apoz", 8.0)
441
+ FakeController.config["connect"] = FakeState(ipsec_state=2, name="apoz")
442
+ FakeController.config["connection_ip"] = {"vpn_ip": "10.0.0.2"}
443
+
444
+ rc = cli.main(["connect", "apoz"])
445
+
446
+ assert rc == 0
447
+ assert "CONNECTED apoz 10.0.0.2" in capsys.readouterr().out
448
+
449
+
450
+ def test_connect_history_failure_does_not_break_connect(monkeypatch, capsys):
451
+ # A corrupt/unreadable history store must never block a connect: the ETA read
452
+ # degrades to the throbber and the connect still succeeds.
453
+ from fvpnctl import history
454
+
455
+ def boom(_profile):
456
+ raise ValueError("corrupt history")
457
+
458
+ monkeypatch.setattr(history, "average", boom)
459
+ FakeController.config["connect"] = FakeState(ipsec_state=2, name="apoz")
460
+ FakeController.config["connection_ip"] = {"vpn_ip": "10.0.0.2"}
461
+
462
+ rc = cli.main(["connect", "apoz"])
463
+
464
+ assert rc == 0
465
+ assert "CONNECTED apoz" in capsys.readouterr().out
466
+
467
+
391
468
  def test_hide_window_command_routes():
392
469
  rc = cli.main(["hide-window"])
393
470
 
@@ -0,0 +1,91 @@
1
+ """Tests for the connect-time history (``fvpnctl.history``).
2
+
3
+ CI-safe and isolated: every test points ``$FVPNCTL_STATE_DIR`` at a per-test
4
+ ``tmp_path`` (autouse fixture), so nothing reads or writes the developer's real
5
+ ``~/.local/state/fvpnctl``. The store is a tiny JSON file, so the tests exercise
6
+ it end to end (record → reload → average) plus the resolution and resilience
7
+ edges (env overrides, missing file, corrupt file, recent-window trimming).
8
+ """
9
+
10
+ import json
11
+
12
+ import pytest
13
+
14
+ from fvpnctl import history
15
+
16
+
17
+ @pytest.fixture(autouse=True)
18
+ def isolated_state(monkeypatch, tmp_path):
19
+ monkeypatch.setenv("FVPNCTL_STATE_DIR", str(tmp_path / "state"))
20
+ # XDG must not leak in and override the explicit dir under test.
21
+ monkeypatch.delenv("XDG_STATE_HOME", raising=False)
22
+ return tmp_path
23
+
24
+
25
+ def test_average_is_none_on_first_run():
26
+ # No file yet → empty history → no ETA (CLI falls back to the throbber).
27
+ assert history.average("apoz") is None
28
+
29
+
30
+ def test_record_then_average_round_trips():
31
+ history.record("apoz", 12.0)
32
+ history.record("apoz", 8.0)
33
+ assert history.average("apoz") == pytest.approx(10.0)
34
+
35
+
36
+ def test_history_is_per_profile():
37
+ history.record("apoz", 30.0)
38
+ history.record("office", 4.0)
39
+ assert history.average("apoz") == pytest.approx(30.0)
40
+ assert history.average("office") == pytest.approx(4.0)
41
+
42
+
43
+ def test_only_recent_n_samples_are_kept_and_averaged():
44
+ for _ in range(history._HISTORY_LEN):
45
+ history.record("apoz", 2.0)
46
+ # An 11th very different sample evicts the oldest; the window stays at N and
47
+ # the mean reflects only the recent window, not all-time.
48
+ history.record("apoz", 24.0)
49
+ samples = history.load()["apoz"]
50
+ assert len(samples) == history._HISTORY_LEN
51
+ expected = (2.0 * (history._HISTORY_LEN - 1) + 24.0) / history._HISTORY_LEN
52
+ assert history.average("apoz") == pytest.approx(expected)
53
+
54
+
55
+ def test_state_dir_prefers_explicit_override(monkeypatch, tmp_path):
56
+ monkeypatch.setenv("FVPNCTL_STATE_DIR", str(tmp_path / "explicit"))
57
+ monkeypatch.setenv("XDG_STATE_HOME", str(tmp_path / "xdg"))
58
+ assert history.state_dir() == (tmp_path / "explicit")
59
+
60
+
61
+ def test_state_dir_falls_back_to_xdg_then_home(monkeypatch, tmp_path):
62
+ monkeypatch.delenv("FVPNCTL_STATE_DIR", raising=False)
63
+ monkeypatch.setenv("XDG_STATE_HOME", str(tmp_path / "xdg"))
64
+ assert history.state_dir() == (tmp_path / "xdg" / "fvpnctl")
65
+ monkeypatch.delenv("XDG_STATE_HOME", raising=False)
66
+ monkeypatch.setattr(history.Path, "home", classmethod(lambda cls: tmp_path / "home"))
67
+ assert history.state_dir() == (tmp_path / "home" / ".local" / "state" / "fvpnctl")
68
+
69
+
70
+ def test_corrupt_file_makes_average_raise():
71
+ path = history._path()
72
+ path.parent.mkdir(parents=True, exist_ok=True)
73
+ path.write_text("{not json", encoding="utf-8")
74
+ with pytest.raises(ValueError):
75
+ history.average("apoz")
76
+
77
+
78
+ def test_record_self_heals_a_corrupt_file():
79
+ path = history._path()
80
+ path.parent.mkdir(parents=True, exist_ok=True)
81
+ path.write_text("garbage", encoding="utf-8")
82
+ # record() must not propagate the corruption: it resets and writes a valid file.
83
+ history.record("apoz", 5.0)
84
+ assert json.loads(path.read_text(encoding="utf-8")) == {"apoz": [5.0]}
85
+ assert history.average("apoz") == pytest.approx(5.0)
86
+
87
+
88
+ def test_save_is_atomic_no_tmp_left_behind():
89
+ history.record("apoz", 3.0)
90
+ leftovers = list(history.state_dir().glob("*.tmp"))
91
+ assert leftovers == []
@@ -0,0 +1,168 @@
1
+ """Tests for the connect throbber (``fvpnctl.spinner``).
2
+
3
+ CI-safe: no real terminal, no real sleeping that the suite depends on. The pure
4
+ helpers (:func:`spinner.render_frame`, :func:`spinner.select_mode`) are tested
5
+ directly. The :class:`spinner.Spinner` context manager is driven over a fake
6
+ stream whose ``isatty()`` is scripted, so each of the three modes
7
+ (``off``/``static``/``animate``) is exercised deterministically:
8
+
9
+ * ``off`` — ``enabled=False`` (the ``--quiet`` path): writes nothing.
10
+ * ``static``— a non-TTY stream (pipe/log): one plain line, no ANSI, no ``\\r``.
11
+ * ``animate``— a TTY stream: hides the cursor, paints frame 0 *synchronously* on
12
+ enter (so at least one frame is deterministic without waiting on the thread),
13
+ and on exit wipes the line and restores the cursor.
14
+
15
+ The animate test uses a deliberately huge ``interval`` so the background thread
16
+ is still parked in its first ``wait`` when ``__exit__`` sets the stop event —
17
+ that makes "exactly the synchronous frame 0 was painted" reliable rather than
18
+ racing the thread's clock.
19
+ """
20
+
21
+ import io
22
+
23
+ from fvpnctl import spinner
24
+
25
+
26
+ class FakeStream(io.StringIO):
27
+ """A ``StringIO`` with a scripted ``isatty()`` so mode selection is testable."""
28
+
29
+ def __init__(self, *, tty):
30
+ super().__init__()
31
+ self._tty = tty
32
+
33
+ def isatty(self):
34
+ return self._tty
35
+
36
+
37
+ # -- pure helpers ------------------------------------------------------------
38
+
39
+
40
+ def test_render_frame_carries_braille_message_and_elapsed():
41
+ out = spinner.render_frame("⠋", "Connecting profile apoz…", 7)
42
+ assert out.startswith("\r")
43
+ assert "⠋" in out
44
+ assert "Connecting profile apoz…" in out
45
+ assert "7s" in out
46
+ # Clears to end of line so a shrinking message never leaves stale tail chars.
47
+ assert out.endswith(spinner._CLEAR_EOL)
48
+
49
+
50
+ def test_select_mode_off_when_disabled_even_on_tty():
51
+ assert spinner.select_mode(FakeStream(tty=True), enabled=False) == "off"
52
+
53
+
54
+ def test_select_mode_static_on_non_tty():
55
+ assert spinner.select_mode(FakeStream(tty=False), enabled=True) == "static"
56
+
57
+
58
+ def test_select_mode_animate_on_tty():
59
+ assert spinner.select_mode(FakeStream(tty=True), enabled=True) == "animate"
60
+
61
+
62
+ # -- context manager: the three modes ----------------------------------------
63
+
64
+
65
+ def test_off_mode_writes_nothing():
66
+ stream = FakeStream(tty=True)
67
+ with spinner.Spinner("Connecting profile apoz…", stream=stream, enabled=False):
68
+ pass
69
+ assert stream.getvalue() == ""
70
+
71
+
72
+ def test_static_mode_writes_one_plain_line():
73
+ stream = FakeStream(tty=False)
74
+ with spinner.Spinner("Connecting profile apoz…", stream=stream, enabled=True):
75
+ pass
76
+ out = stream.getvalue()
77
+ assert out == "Connecting profile apoz…\n"
78
+ # No ANSI, no carriage-return animation in the pipe/log path.
79
+ assert "\x1b" not in out
80
+ assert "\r" not in out
81
+
82
+
83
+ def test_animate_mode_paints_frame0_and_cleans_up():
84
+ stream = FakeStream(tty=True)
85
+ # Huge interval: the worker thread parks in its first wait() and never paints
86
+ # a second frame before __exit__ stops it — so only the synchronous frame 0
87
+ # is guaranteed, making the assertions deterministic.
88
+ with spinner.Spinner("Connecting profile apoz…", stream=stream, enabled=True, interval=1000):
89
+ pass
90
+ out = stream.getvalue()
91
+ assert spinner._HIDE_CURSOR in out # cursor hidden on enter
92
+ assert spinner._FRAMES[0] in out # first braille frame painted synchronously
93
+ assert "Connecting profile apoz…" in out
94
+ assert spinner._SHOW_CURSOR in out # cursor restored on exit
95
+ # The line is wiped on exit so the next print starts clean.
96
+ assert out.rstrip().endswith(spinner._SHOW_CURSOR) or spinner._CLEAR_EOL in out
97
+
98
+
99
+ def test_animate_mode_restores_cursor_even_on_exception():
100
+ stream = FakeStream(tty=True)
101
+ try:
102
+ with spinner.Spinner(
103
+ "Connecting profile apoz…", stream=stream, enabled=True, interval=1000
104
+ ):
105
+ raise RuntimeError("connect blew up")
106
+ except RuntimeError:
107
+ pass
108
+ out = stream.getvalue()
109
+ # A failed connect (ConnectTimeout etc.) must still leave a clean terminal.
110
+ assert spinner._SHOW_CURSOR in out
111
+
112
+
113
+ # -- progress bar: pure render ----------------------------------------------
114
+
115
+
116
+ def test_render_bar_fills_proportionally():
117
+ out = spinner.render_bar("Connecting profile apoz…", 6.0, 12.0)
118
+ assert out.startswith("\r")
119
+ assert "Connecting profile apoz…" in out
120
+ assert "50%" in out # halfway to the ETA
121
+ assert spinner._BAR_FILL in out and spinner._BAR_EMPTY in out
122
+ assert "6s / ~12s" in out
123
+ assert out.endswith(spinner._CLEAR_EOL)
124
+
125
+
126
+ def test_render_bar_caps_below_100_when_overdue():
127
+ out = spinner.render_bar("x", 50.0, 10.0) # 5x past the ETA
128
+ assert "99%" in out # capped, never 100% while still running
129
+ assert "100%" not in out
130
+ assert spinner._BAR_EMPTY in out # one empty cell remains until truly done
131
+ assert "50s / ~10s" in out # honest about the overrun
132
+
133
+
134
+ def test_render_bar_handles_zero_eta_without_dividing():
135
+ out = spinner.render_bar("x", 1.0, 0.0)
136
+ assert "0%" in out # no ZeroDivisionError, just an empty bar
137
+
138
+
139
+ # -- progress bar: context manager modes ------------------------------------
140
+
141
+
142
+ def test_progress_bar_animate_paints_and_cleans_up():
143
+ stream = FakeStream(tty=True)
144
+ with spinner.ProgressBar(
145
+ "Connecting profile apoz…", 12.0, stream=stream, enabled=True, interval=1000
146
+ ):
147
+ pass
148
+ out = stream.getvalue()
149
+ assert spinner._HIDE_CURSOR in out
150
+ assert spinner._BAR_EMPTY in out # the (mostly empty) bar was painted at t≈0
151
+ assert "Connecting profile apoz…" in out
152
+ assert spinner._SHOW_CURSOR in out
153
+
154
+
155
+ def test_progress_bar_off_writes_nothing():
156
+ stream = FakeStream(tty=True)
157
+ with spinner.ProgressBar("m", 5.0, stream=stream, enabled=False):
158
+ pass
159
+ assert stream.getvalue() == ""
160
+
161
+
162
+ def test_progress_bar_static_writes_one_plain_line():
163
+ stream = FakeStream(tty=False)
164
+ with spinner.ProgressBar("Connecting profile apoz…", 5.0, stream=stream, enabled=True):
165
+ pass
166
+ out = stream.getvalue()
167
+ assert out == "Connecting profile apoz…\n"
168
+ assert "\x1b" not in out and "\r" not in out
@@ -12,7 +12,7 @@ supported-markers = [
12
12
 
13
13
  [[package]]
14
14
  name = "fvpnctl"
15
- version = "0.2.2"
15
+ version = "0.2.4"
16
16
  source = { editable = "." }
17
17
 
18
18
  [package.dev-dependencies]
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes