Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion src/ita/_layout.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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<N>` counter."""
absent, the window is titled with the lowest free `w<N>` 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
Expand Down
5 changes: 5 additions & 0 deletions src/ita/_session.py
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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
Expand Down
4 changes: 4 additions & 0 deletions src/ita/_tab.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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<N>` 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. "
Expand Down
54 changes: 52 additions & 2 deletions tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -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: <date> 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,
Expand Down Expand Up @@ -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:
Expand All @@ -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 —
Expand All @@ -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())
Expand Down Expand Up @@ -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}"
Expand All @@ -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}"
Expand Down
6 changes: 4 additions & 2 deletions tests/fixtures/sessions.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand 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")
Expand Down
Loading