diff --git a/src/ita/_layout.py b/src/ita/_layout.py index 9a05492..2953c86 100644 --- a/src/ita/_layout.py +++ b/src/ita/_layout.py @@ -2,6 +2,7 @@ """Layout commands: window group.""" import asyncio import json +import os import click import iterm2 from ._core import cli, run_iterm, confirm_or_skip, next_free_name, capture_focus, restore_focus @@ -26,7 +27,10 @@ def window_new(window_name, profile, background): """Create new window. Returns window ID. CONTRACT §2 "Mandatory naming on creation (#342)" — when `--name` is - absent, the window is titled with the lowest free `w` counter.""" + absent, the window is titled with the lowest free `w` counter. + #379: ITA_DEFAULT_BACKGROUND=1 env var is honored as a fallback default.""" + if not background and os.environ.get('ITA_DEFAULT_BACKGROUND') == '1': + background = True async def _run(connection): app = await iterm2.async_get_app(connection) captured = await capture_focus(app) if background else None diff --git a/src/ita/_session.py b/src/ita/_session.py index 8c63807..8413b5b 100644 --- a/src/ita/_session.py +++ b/src/ita/_session.py @@ -1,5 +1,6 @@ # src/_session.py """Session lifecycle commands: new, close, activate, name, restart, resize, clear, capture.""" +import os from pathlib import Path import shlex import click @@ -73,6 +74,10 @@ async def _session_records(connection): @click.option('--background', is_flag=True, help='Create without shifting focus; restore previously-focused target after creation (#346).') def new(new_window, profile, session_name, reuse, replace, cwd, run_cmd, as_json, wait_reqs, no_wait, background): + # #379: honor ITA_DEFAULT_BACKGROUND=1 as a fallback default so tests + # (and agents) don't need to pass --background on every creation. + if not background and os.environ.get('ITA_DEFAULT_BACKGROUND') == '1': + background = True """Create new tab (or window). Returns name (stdout) and session ID. By default waits until the session is alive and writable before returning diff --git a/src/ita/_tab.py b/src/ita/_tab.py index 831cc63..71d573b 100644 --- a/src/ita/_tab.py +++ b/src/ita/_tab.py @@ -2,6 +2,7 @@ """Tab commands: new, close, activate, navigate, list, info, detach, move, profile, title.""" import asyncio import json +import os import click import iterm2 from ._core import cli, run_iterm, next_free_name, _fresh_name, capture_focus, restore_focus @@ -29,6 +30,9 @@ def tab_new(window_id, tab_name, profile, background): is required; there is no implicit 'current window' fallback. CONTRACT §2 "Mandatory naming on creation (#342)" — when `--name` is absent, the tab is titled with the lowest free `t` counter.""" + # #379: env-var fallback so fixtures can opt into background suite-wide. + if not background and os.environ.get('ITA_DEFAULT_BACKGROUND') == '1': + background = True if not window_id: raise ItaError("bad-args", "no window specified. Use --window NAME or --window UUID-PREFIX. " diff --git a/tests/conftest.py b/tests/conftest.py index 170f647..372ec00 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -10,8 +10,29 @@ Layers L3 + L4 added here as the catastrophic-leak circuit breaker. """ import atexit +import os import pytest +# #379: fixtures create sessions/tabs/windows in the background by default +# so test runs don't steal focus from the human at the keyboard. Opt-out +# in a specific test with monkeypatch.delenv('ITA_DEFAULT_BACKGROUND', False). +os.environ.setdefault('ITA_DEFAULT_BACKGROUND', '1') + +# #384: suppress the "Last login: on ttys###" banner macOS prints +# when a new login shell starts. The banner scrolls every test-created +# session by two lines, which pushes prompts out of sight during capture +# assertions. Touching ~/.hushlogin is the Unix-standard way to silence +# it; zero-byte file, idempotent, creates only if missing. +try: + from pathlib import Path as _Path + _hush = _Path.home() / '.hushlogin' + if not _hush.exists(): + _hush.touch() +except Exception: + # Best-effort: a read-only home or weird permissions shouldn't break + # the test run. Banner is cosmetic, not correctness. + pass + from helpers import ( # noqa: F401 ITA, ita, ita_ok, _extract_sid, _all_session_ids, _open_test_sessions, _close_test_sessions, @@ -53,6 +74,12 @@ def _atexit_close_test_sessions(): LEAK_CEILING = 50 +# #383: mid-suite leak audit. Snapshots ita-test-* session count at the +# start of each test, compares in teardown, logs delta to stderr when it +# looks suspicious. Non-fatal (the L4 hard-ceiling handles that) — purpose +# is locating *which* test leaks when cumulative counts drift upward. +_LEAK_SNAPSHOT: dict[str, int] = {} + def _count_test_sessions() -> int: try: @@ -68,6 +95,15 @@ def _count_orphan_windows() -> int: return 0 +def pytest_runtest_setup(item): + """#383: snapshot session/window count before the test runs, so the + teardown hook can compute a delta. Skipped on the contract fast-lane + (same rationale as the teardown hook — those tests don't touch iTerm2).""" + if 'contract' in item.keywords and 'integration' not in item.keywords: + return + _LEAK_SNAPSHOT[item.nodeid] = _count_test_sessions() + + def pytest_runtest_teardown(item, nextitem): """After every test, check the leak ceiling on BOTH ita-test-* sessions AND orphan default windows (#348). Hard-abort if either exceeded — @@ -82,6 +118,17 @@ def pytest_runtest_teardown(item, nextitem): return sess_count = _count_test_sessions() win_count = _count_orphan_windows() + # #383: mid-suite leak audit. Log (non-fatally) when a test leaves more + # ita-test-* sessions than it started with — the teardown finalizers + # ran, so any delta means a fixture teardown missed something. + before = _LEAK_SNAPSHOT.pop(item.nodeid, None) + if before is not None and sess_count > before: + import sys + print( + f"\n[leak-audit #383] {item.nodeid}: " + f"ita-test-* sessions {before} -> {sess_count} (+{sess_count - before})", + file=sys.stderr, + ) if sess_count > LEAK_CEILING or win_count > LEAK_CEILING: try: _close_test_sessions(_open_test_sessions()) @@ -115,7 +162,9 @@ def session(request): closes that window in teardown too (otherwise iTerm2 leaves an orphan default-shell window behind when our session is closed). """ - safe_name = (TEST_SESSION_PREFIX + request.node.name[:30]).replace(' ', '_') + # #380: use the full test node name (with parametrize bracket spec) + # so every ita-test-* object is legible in iTerm2's session list. + safe_name = (TEST_SESSION_PREFIX + request.node.name).replace(' ', '_') windows_before = _all_window_ids() r = ita('new', '--name', safe_name) assert r.returncode == 0, f"Failed to create session: {r.stderr}" @@ -137,7 +186,8 @@ def shared_session(request): """Module-scoped session for read-only tests (faster than per-test). #348: same window-tracking discipline as `session`.""" - safe_name = (TEST_SESSION_PREFIX + 'shared-' + request.module.__name__[:20]).replace(' ', '_') + # #380: full module name for legibility. + safe_name = (TEST_SESSION_PREFIX + 'shared-' + request.module.__name__).replace(' ', '_') windows_before = _all_window_ids() r = ita('new', '--name', safe_name) assert r.returncode == 0, f"Failed to create module session: {r.stderr}" diff --git a/tests/fixtures/sessions.py b/tests/fixtures/sessions.py index e55f405..0c77a3d 100644 --- a/tests/fixtures/sessions.py +++ b/tests/fixtures/sessions.py @@ -33,7 +33,8 @@ def _teardown(): request.addfinalizer(_teardown) def create(n: int = 1) -> list[str]: - safe_base = (TEST_SESSION_PREFIX + request.node.name[:20]).replace(' ', '_') + # #380: full node name for legibility in iTerm2's session list. + safe_base = (TEST_SESSION_PREFIX + request.node.name).replace(' ', '_') # TESTING.md §4.1 L2: register each successful sid into all_sids # IMMEDIATELY (not after the batch). If create() raises mid-batch, # the addfinalizer still cleans up everything we managed to spin up. @@ -98,8 +99,9 @@ def shell(request): Fish is skipped automatically when ``shutil.which("fish")`` returns None. """ shell_name: str = request.param + # #380: full node name for legibility in iTerm2's session list. safe_name = ( - TEST_SESSION_PREFIX + "shell-" + shell_name + "-" + request.node.name[:15] + TEST_SESSION_PREFIX + "shell-" + shell_name + "-" + request.node.name ).replace(" ", "_") r = ita("new", "--name", safe_name, "--run", f"exec {shell_name}\n")