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: 6 additions & 0 deletions .gitattributes
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
# Normalize line endings to LF on checkout everywhere. The suite renders output
# with "\n" and the syrupy snapshot goldens (tests/__snapshots__/*.ambr) are
# compared byte-for-byte, so a Windows checkout converting them to CRLF (Git's
# default autocrlf) would fail every snapshot test. text=auto still lets Git
# auto-detect and leave binary files untouched.
* text=auto eol=lf
72 changes: 72 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,78 @@ jobs:
fi
echo "all py-version matrix cells passed"

windows:
name: tests (windows, py${{ matrix.python-version }})
runs-on: windows-latest
timeout-minutes: 20
# Windows can't run scripts/check.sh (it's bash plus Go/Homebrew/shell tooling), so
# this job runs only the pytest suite — enough to catch Windows-specific regressions
# (path handling, subprocess/encoding, POSIX-only assumptions). The lint/type/security
# gates stay on the Linux `check` job. Same Python ends as that matrix: 3.12 floor,
# 3.13 shipped; fail-fast off so one version's failure doesn't mask the other's.
strategy:
fail-fast: false
matrix:
python-version: ["3.12", "3.13"]
# Pin the interpreter every `uv run` resolves to, so the matrix exercises each version.
env:
UV_PYTHON: ${{ matrix.python-version }}
steps:
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with:
persist-credentials: false # no job pushes; don't leave the token in .git/config
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
with:
python-version: ${{ matrix.python-version }}
cache: pip

# ffmpeg must be on PATH: the `stream --sample`/`clip`/`caption` paths probe for it
# (require_ffmpeg) before doing their work, so without it those tests fail at the
# probe rather than exercising the mocked run. PortAudio needs no install — the
# sounddevice wheel bundles it on Windows. choco ships on the runner but its download
# occasionally flakes (one matrix cell got ffmpeg, the other didn't), so retry and
# verify ffmpeg is callable here — a real miss fails this step instead of surfacing as
# confusing "ffmpeg not on PATH" test failures. The shim lands in choco's bin dir,
# already on the runner PATH, so later steps pick it up.
- name: System deps (ffmpeg)
shell: pwsh
run: |
$ErrorActionPreference = "Stop"
$env:PATH = "C:\ProgramData\chocolatey\bin;$env:PATH"
for ($i = 1; $i -le 3; $i++) {
choco install ffmpeg --no-progress -y
if (Get-Command ffmpeg -ErrorAction SilentlyContinue) { break }
Write-Host "ffmpeg not yet on PATH (attempt $i); retrying…"
Start-Sleep -Seconds 5
}
ffmpeg -version

- name: Install uv
run: python -m pip install uv

# `uv run` syncs the locked project + dev group into .venv, then runs the default
# suite (e2e/install excluded via addopts).
- name: Run test suite
run: uv run pytest -q

# Stable, un-suffixed name for branch protection, mirroring `check-result`: green only
# when every Windows matrix cell passed (a failed/skipped/cancelled matrix can't satisfy
# it). Point branch protection at this one name and matrix changes won't break it.
windows-result:
name: tests (windows)
needs: [windows]
if: always()
runs-on: ubuntu-latest
timeout-minutes: 5
steps:
- name: Require every Windows matrix cell to have passed
run: |
if [ "${{ needs.windows.result }}" != "success" ]; then
echo "windows matrix result: ${{ needs.windows.result }}"
exit 1
fi
echo "all windows matrix cells passed"

lint-formula:
name: brew style (Homebrew formula)
runs-on: ubuntu-latest
Expand Down
8 changes: 7 additions & 1 deletion aai_cli/commands/caption/_exec.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@

from __future__ import annotations

import os
import tempfile
from dataclasses import dataclass
from pathlib import Path
Expand Down Expand Up @@ -56,7 +57,12 @@ def default_out_path(media: Path) -> Path:

def subtitles_filter(srt: Path, font_size: int | None) -> str:
"""The ``-vf`` filtergraph burning ``srt`` into the video."""
spec = f"subtitles={str(srt).translate(_FILTER_ESCAPES)}"
# ffmpeg's filtergraph parser takes forward slashes on every platform; a Windows
# backslash path would otherwise need each separator escaped (and the drive colon
# mishandled). Normalize to "/" first, then escape the remaining metacharacters
# (notably the drive ":") — e.g. C:\a\b.srt -> C\:/a/b.srt. No-op on POSIX.
posix = str(srt).replace(os.sep, "/")
spec = f"subtitles={posix.translate(_FILTER_ESCAPES)}"
if font_size is not None:
spec += f":force_style=FontSize={font_size}"
return spec
Expand Down
75 changes: 59 additions & 16 deletions aai_cli/core/hotkey.py
Original file line number Diff line number Diff line change
@@ -1,18 +1,27 @@
"""Single-keypress input for hotkey-driven commands (`assembly dictate`).

``TerminalKeys`` switches stdin into cbreak mode for the lifetime of a ``with``
block, so individual keypresses arrive without Enter — while Ctrl-C still raises
KeyboardInterrupt (cbreak keeps ISIG, unlike full raw mode). POSIX-only: there
is no termios on Windows, so entering the context raises a clean CLIError there
instead of an ImportError traceback. Stdlib-only on purpose, mirroring the other
``TerminalKeys`` reads individual keypresses — without waiting for Enter — for the
lifetime of a ``with`` block, while Ctrl-C still ends the program. One interface,
two backends:

- POSIX puts stdin into cbreak mode (termios/tty) and waits with ``select``; cbreak
keeps ISIG, so Ctrl-C raises KeyboardInterrupt instead of arriving as a byte.
- Windows reads the console through stdlib ``msvcrt`` (``kbhit``/``getwch``), which is
already character-at-a-time, so there is no mode to enter or restore.

A platform that is neither (no termios and not Windows) raises a clean CLIError rather
than an ImportError traceback. Stdlib-only on purpose, mirroring the other
non-rendering layers.
"""

from __future__ import annotations

import importlib
import os
import select
import sys
import time
from collections.abc import Callable

from aai_cli.core.errors import CLIError

Expand All @@ -21,6 +30,10 @@
CTRL_D = "\x04"
ESC = "\x1b"

# How long the Windows key poll naps between kbhit() checks (msvcrt has no select()):
# short enough to feel instant at the dictate prompt, long enough not to spin a core.
_WINDOWS_POLL_INTERVAL = 0.01


def _stdin_fd() -> int:
"""The stdin file descriptor, or -1 when stdin has none (a captured/replaced
Expand All @@ -32,19 +45,38 @@ def _stdin_fd() -> int:
return -1


def _on_windows() -> bool:
"""True on Windows, where key input goes through msvcrt instead of termios. A
function (not a constant) so tests can drive the Windows backend on a POSIX host."""
return sys.platform == "win32"


class TerminalKeys:
"""Reads single keypresses from a terminal fd, cbreak-scoped via ``with``.
"""Reads single keypresses from a terminal, scoped via ``with``.

The fd is injectable (tests drive it through a pty pair); it defaults to
the process's stdin.
The fd is injectable (POSIX tests drive it through a pty pair) and defaults to the
process's stdin; the Windows backend reads the console directly and ignores it.
"""

def __init__(self, fd: int | None = None) -> None:
self._fd = fd if fd is not None else _stdin_fd()
# termios.tcgetattr's attribute list (typeshed's exact shape).
# termios.tcgetattr's attribute list (typeshed's exact shape); stays None on
# Windows, where there is no saved terminal state to restore.
self._saved: list[int | list[bytes | int]] | None = None
self._windows = _on_windows()

def __enter__(self) -> TerminalKeys:
if not os.isatty(self._fd):
raise CLIError(
"This command needs an interactive terminal: it waits for hotkey presses on stdin.",
error_type="not_a_tty",
exit_code=2,
suggestion="Run it directly in a terminal, without piping or redirecting stdin.",
)
if self._windows:
# The Windows console is already character-at-a-time; there is no cbreak mode
# to enter or restore, so read() goes straight through msvcrt.
return self
try:
import termios
import tty
Expand All @@ -54,13 +86,6 @@ def __enter__(self) -> TerminalKeys:
error_type="unsupported_platform",
exit_code=2,
) from exc
if not os.isatty(self._fd):
raise CLIError(
"This command needs an interactive terminal: it waits for hotkey presses on stdin.",
error_type="not_a_tty",
exit_code=2,
suggestion="Run it directly in a terminal, without piping or redirecting stdin.",
)
self._saved = termios.tcgetattr(self._fd)
tty.setcbreak(self._fd)
return self
Expand All @@ -78,10 +103,28 @@ def read(self, timeout: float | None) -> str | None:
``timeout=None`` blocks until a key arrives; ``timeout=0`` polls without
waiting (the in-recording check between audio chunks).
"""
if self._windows:
return self._read_windows(timeout)
ready, _, _ = select.select([self._fd], [], [], timeout)
if not ready:
return None
data = os.read(self._fd, 1)
if not data:
return None
return data.decode("utf-8", "replace")

def _read_windows(self, timeout: float | None) -> str | None:
"""Windows key read: getwch() blocks (timeout=None) or kbhit() polls to a deadline."""
msvcrt = importlib.import_module("msvcrt")
# Bind the win32-only members to typed locals: typeshed hides them off-Windows,
# so the type-checkers reject `msvcrt.getwch` directly but accept these.
kbhit: Callable[[], bool] = msvcrt.kbhit
getwch: Callable[[], str] = msvcrt.getwch
if timeout is None:
return getwch()
deadline = time.monotonic() + timeout # pragma: no mutate
while not kbhit():
if time.monotonic() >= deadline: # pragma: no mutate
return None
time.sleep(_WINDOWS_POLL_INTERVAL) # pragma: no mutate
return getwch()
25 changes: 25 additions & 0 deletions tests/conftest.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import os
import sys
import time

import keyring
Expand All @@ -22,6 +23,16 @@ def pytest_collection_modifyitems(items: list[pytest.Item]) -> None:
for item in items:
if any(item.get_closest_marker(name) for name in _NETWORK_MARKERS):
item.add_marker(pytest.mark.enable_socket)
elif sys.platform == "win32" and not any(
item.get_closest_marker(m) for m in ("allow_hosts", "enable_socket", "disable_socket")
):
# On Windows the asyncio event loop's self-pipe is an AF_INET socketpair(),
# which the suite-wide --disable-socket would block — so every in-process
# async test (FastAPI TestClient, the scaffolded template apps) would fail.
# POSIX uses an os.pipe() self-pipe, so this only bites on Windows. Permit
# loopback while still blocking external network (the hermeticity guarantee
# that matters), unless the test already pins its own allow_hosts.
item.add_marker(pytest.mark.allow_hosts(["127.0.0.1", "::1"]))


@pytest.fixture
Expand Down Expand Up @@ -74,6 +85,20 @@ def isolate_env(monkeypatch):
monkeypatch.delenv(var, raising=False)


@pytest.fixture(autouse=True)
def _disable_legacy_windows(monkeypatch):
# On a Windows CI runner Rich detects a "legacy" console (ColorSystem.WINDOWS) and
# subtracts 1 from the render width to dodge the auto-wrap cursor bug — so COLUMNS=80
# renders at 79 and every byte-exact help snapshot rewraps and fails. Modern Windows
# terminals (Windows Terminal, VT-enabled) report non-legacy, which is what real users
# get, so pin non-legacy here to keep rendering deterministic across platforms. No-op
# off Windows (detect_legacy_windows already returns False there).
if sys.platform == "win32":
import rich.console

monkeypatch.setattr(rich.console, "detect_legacy_windows", lambda: False)


@pytest.fixture(autouse=True)
def pin_timezone(monkeypatch):
# Pin the host timezone so any time rendering is deterministic across machines and
Expand Down
6 changes: 5 additions & 1 deletion tests/test_caption_exec.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
import contextlib
import dataclasses
import json
import re
import subprocess
from pathlib import Path
from types import SimpleNamespace
Expand Down Expand Up @@ -58,7 +59,10 @@ def record_ffmpeg(monkeypatch, *, returncode: int = 0, stderr: str = ""):

def run(args: list[str]) -> subprocess.CompletedProcess[str]:
recorded["args"] = args
srt_path = args[8].removeprefix("subtitles=").split(":force_style")[0]
escaped = args[8].removeprefix("subtitles=").split(":force_style")[0]
# subtitles_filter escapes filtergraph metacharacters (and the Windows drive
# colon) with a leading backslash; reverse that to recover the real on-disk path.
srt_path = re.sub(r"\\(.)", r"\1", escaped)
recorded["srt"] = Path(srt_path).read_text(encoding="utf-8")
return subprocess.CompletedProcess(
args=args, returncode=returncode, stdout="", stderr=stderr
Expand Down
2 changes: 2 additions & 0 deletions tests/test_coding_agent.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@
def _isolate_home(tmp_path, monkeypatch):
"""Keep skill reads inside a temp HOME so tests never touch ~/.claude."""
monkeypatch.setenv("HOME", str(tmp_path))
# Path.home() reads USERPROFILE on Windows, not HOME, so isolate both.
monkeypatch.setenv("USERPROFILE", str(tmp_path))
monkeypatch.delenv("CLAUDE_CONFIG_DIR", raising=False)


Expand Down
6 changes: 5 additions & 1 deletion tests/test_dictate_command.py
Original file line number Diff line number Diff line change
Expand Up @@ -81,4 +81,8 @@ def test_outside_a_terminal_is_a_usage_error_not_a_login():
# terminal requirement, not start an authentication flow.
result = runner.invoke(app, ["dictate"])
assert result.exit_code == 2
assert "interactive terminal" in result.output
# POSIX surfaces the not-a-tty requirement; Windows (no termios) surfaces the
# unsupported-platform message first. Either is the point: a usage error, not a login.
assert (
"interactive terminal" in result.output or "not supported on this platform" in result.output
)
Loading
Loading