artifact presenter & global Agents updates & plan for nono profiles

This commit is contained in:
jb
2026-08-26 21:57:56 -04:00
parent 645c38d43c
commit 51eace1fa8
7 changed files with 1723 additions and 91 deletions
+1 -1
View File
@@ -43,7 +43,7 @@ Run the helper using its absolute path resolved from this skill directory:
python3 <skill-directory>/scripts/present.py <artifact-path>
```
The helper reuses the pane labeled `artifact presenter` in the current Herdr workspace. When none exists, it creates a right split without taking focus. It invokes `mcat <artifact-path>` directly so mcat can infer and display documents, source, archives, PDFs, images, and videos. It falls back to artifact-only delivery when Herdr or mcat is unavailable.
The helper reuses the pane labeled `artifact presenter` in the current Herdr workspace. It serializes pane discovery, creation, and replacement per workspace, with an additional per-pane lock during viewer handoff. When that pane is already displaying an artifact, it closes the active `mcat` viewer and waits for the pane shell before launching the replacement through a shell-neutral helper command with encoded path arguments. It confirms the helper started the viewer rather than trusting terminal input delivery alone. It leaves unrelated foreground programs untouched and creates a new viewer pane instead. When no reusable pane exists, it creates a right split without taking focus. It launches `mcat <artifact-path>` so mcat can infer and display documents, source, archives, PDFs, images, and videos. It falls back to artifact-only delivery when Herdr or mcat is unavailable.
Completion criterion: capture whether the helper opened, reused, or skipped the viewer pane.
@@ -4,15 +4,44 @@
from __future__ import annotations
import argparse
import base64
import fcntl
import hashlib
import json
import os
import re
import secrets
import shutil
import subprocess
import tempfile
import time
from contextlib import contextmanager
from dataclasses import dataclass
from pathlib import Path
from typing import Callable, Mapping, Sequence
PANE_LABEL = "artifact presenter"
VIEWER_PROCESS_NAME = "mcat"
PANE_READY_ATTEMPTS = 40
PANE_READY_DELAY_SECONDS = 0.05
PANE_SHELL_STABLE_CHECKS = 2
VIEWER_START_ATTEMPTS = 40
SAFE_COMMAND_TOKEN = re.compile(r"^[A-Za-z0-9_./=-]+$")
VIEWER_LAUNCH_SCRIPT = """#!/usr/bin/env python3
import base64
import os
import sys
from pathlib import Path
def decode(value: str) -> str:
return base64.urlsafe_b64decode(value.encode("ascii")).decode("utf-8")
acknowledgment, token, viewer, artifact = map(decode, sys.argv[1:])
Path(acknowledgment).write_text(token, encoding="utf-8")
os.execv(viewer, [viewer, artifact])
"""
RunCommand = Callable[..., subprocess.CompletedProcess[str]]
FindExecutable = Callable[[str], str | None]
@@ -74,43 +103,174 @@ def created_pane_id(response: Mapping[str, object]) -> str:
return pane_id
@dataclass(frozen=True)
class PaneProcessState:
shell_pid: int
foreground_pids: frozenset[int]
foreground_names: frozenset[str]
@property
def shell_is_ready(self) -> bool:
return self.shell_pid in self.foreground_pids
@property
def viewer_is_active(self) -> bool:
return VIEWER_PROCESS_NAME in self.foreground_names
def pane_process_state(response: Mapping[str, object]) -> PaneProcessState:
result = response.get("result")
process_info = result.get("process_info") if isinstance(result, dict) else None
if not isinstance(process_info, dict):
raise ValueError("Herdr process response is missing process info")
shell_pid = process_info.get("shell_pid")
processes = process_info.get("foreground_processes")
if not isinstance(shell_pid, int) or not isinstance(processes, list):
raise ValueError("Herdr process response is missing shell state")
pids: set[int] = set()
names: set[str] = set()
for process in processes:
if not isinstance(process, dict):
continue
pid = process.get("pid")
name = process.get("name")
if isinstance(pid, int):
pids.add(pid)
if isinstance(name, str) and name:
names.add(name)
return PaneProcessState(shell_pid, frozenset(pids), frozenset(names))
def prepare_pane_for_command(
herdr: str,
pane_id: str,
run: RunCommand,
*,
allow_startup_transition: bool,
) -> None:
viewer_closed = False
stable_shell_checks = 0
for attempt in range(PANE_READY_ATTEMPTS):
response = run_json(herdr, ["pane", "process-info", "--pane", pane_id], run)
state = pane_process_state(response)
if state.shell_is_ready:
stable_shell_checks += 1
if stable_shell_checks >= PANE_SHELL_STABLE_CHECKS:
return
else:
stable_shell_checks = 0
if state.viewer_is_active:
if not viewer_closed:
run(
[herdr, "pane", "send-keys", pane_id, "q"],
check=True,
capture_output=True,
text=True,
)
viewer_closed = True
elif (
state.foreground_pids
and not state.shell_is_ready
and not viewer_closed
and not allow_startup_transition
):
processes = ", ".join(sorted(state.foreground_names)) or "unknown"
raise ValueError(f"pane is running an unrelated foreground process: {processes}")
if attempt + 1 < PANE_READY_ATTEMPTS:
time.sleep(PANE_READY_DELAY_SECONDS)
raise ValueError("pane shell did not become ready for the artifact command")
def encode_argument(value: str) -> str:
return base64.urlsafe_b64encode(value.encode("utf-8")).decode("ascii")
@contextmanager
def lock_pane(pane_id: str):
runtime_directory = Path(os.environ.get("XDG_RUNTIME_DIR", tempfile.gettempdir()))
lock_directory = runtime_directory / "pi-artifact-presenter-locks"
lock_directory.mkdir(mode=0o700, exist_ok=True)
lock_name = hashlib.sha256(pane_id.encode("utf-8")).hexdigest() + ".lock"
descriptor = os.open(
lock_directory / lock_name,
os.O_CREAT | os.O_RDWR | os.O_CLOEXEC,
0o600,
)
with os.fdopen(descriptor, "w") as handle:
fcntl.flock(handle, fcntl.LOCK_EX)
try:
yield
finally:
fcntl.flock(handle, fcntl.LOCK_UN)
def run_mcat(
herdr: str,
mcat: str,
pane_id: str,
artifact: Path,
run: RunCommand,
) -> None:
run(
[herdr, "pane", "run", pane_id, mcat, str(artifact)],
check=True,
capture_output=True,
text=True,
)
def present_document(
document: Path,
*,
env: Mapping[str, str] = os.environ,
which: FindExecutable = shutil.which,
run: RunCommand = subprocess.run,
reused: bool = False,
) -> None:
with lock_pane(pane_id):
prepare_pane_for_command(
herdr,
pane_id,
run,
allow_startup_transition=not reused,
)
token = secrets.token_hex(12)
with tempfile.TemporaryDirectory(
prefix="pi-artifact-presenter-",
dir="/tmp",
) as acknowledgment_directory:
temporary = Path(acknowledgment_directory)
acknowledgment = temporary / "viewer-started"
launcher = temporary / "launch-viewer.py"
launcher.write_text(VIEWER_LAUNCH_SCRIPT, encoding="utf-8")
command_parts = [
"python3",
str(launcher),
encode_argument(str(acknowledgment)),
encode_argument(token),
encode_argument(mcat),
encode_argument(str(artifact)),
]
if not all(SAFE_COMMAND_TOKEN.fullmatch(part) for part in command_parts):
raise ValueError("viewer launcher produced an unsafe terminal token")
command = " ".join(command_parts)
run(
[herdr, "pane", "send-text", pane_id, command + "\r"],
check=True,
capture_output=True,
text=True,
)
for attempt in range(VIEWER_START_ATTEMPTS):
try:
if acknowledgment.read_text(encoding="utf-8") == token:
return
except FileNotFoundError:
pass
if attempt + 1 < VIEWER_START_ATTEMPTS:
time.sleep(PANE_READY_DELAY_SECONDS)
raise ValueError("artifact command was delivered but did not start")
def present_in_workspace(
artifact: Path,
herdr: str,
mcat: str,
workspace_id: str,
source_pane_id: str,
run: RunCommand,
) -> PresentationResult:
artifact = document.expanduser().resolve()
if not artifact.is_file():
raise ValueError(f"artifact is not a file: {artifact}")
herdr = which("herdr")
mcat = which("mcat")
workspace_id = env.get("HERDR_WORKSPACE_ID")
source_pane_id = env.get("HERDR_PANE_ID")
if not herdr:
return PresentationResult("artifact-only", artifact, reason="herdr is unavailable")
if not mcat:
return PresentationResult("artifact-only", artifact, reason="mcat is unavailable")
if not workspace_id or not source_pane_id:
return PresentationResult("artifact-only", artifact, reason="not running in a Herdr pane")
try:
pane_list = run_json(herdr, ["pane", "list", "--workspace", workspace_id], run)
existing_panes = panes_with_label(pane_list)
@@ -119,9 +279,9 @@ def present_document(
for existing_pane in existing_panes:
try:
run_mcat(herdr, mcat, existing_pane, artifact, run)
run_mcat(herdr, mcat, existing_pane, artifact, run, reused=True)
return PresentationResult("reused", artifact, pane_id=existing_pane)
except (OSError, subprocess.SubprocessError):
except (OSError, subprocess.SubprocessError, ValueError):
try:
run(
[herdr, "pane", "rename", existing_pane, f"{PANE_LABEL} stale"],
@@ -161,7 +321,7 @@ def present_document(
)
run_mcat(herdr, mcat, pane_id, artifact, run)
return PresentationResult("opened", artifact, pane_id=pane_id)
except (OSError, subprocess.SubprocessError) as error:
except (OSError, subprocess.SubprocessError, ValueError) as error:
try:
run(
[herdr, "pane", "close", pane_id],
@@ -174,6 +334,39 @@ def present_document(
return PresentationResult("artifact-only", artifact, reason=f"could not initialize Herdr viewer: {error}")
def present_document(
document: Path,
*,
env: Mapping[str, str] = os.environ,
which: FindExecutable = shutil.which,
run: RunCommand = subprocess.run,
) -> PresentationResult:
artifact = document.expanduser().resolve()
if not artifact.is_file():
raise ValueError(f"artifact is not a file: {artifact}")
herdr = which("herdr")
mcat = which("mcat")
workspace_id = env.get("HERDR_WORKSPACE_ID")
source_pane_id = env.get("HERDR_PANE_ID")
if not herdr:
return PresentationResult("artifact-only", artifact, reason="herdr is unavailable")
if not mcat:
return PresentationResult("artifact-only", artifact, reason="mcat is unavailable")
if not workspace_id or not source_pane_id:
return PresentationResult("artifact-only", artifact, reason="not running in a Herdr pane")
with lock_pane(f"workspace:{workspace_id}"):
return present_in_workspace(
artifact,
herdr,
mcat,
workspace_id,
source_pane_id,
run,
)
def create_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("artifact", type=Path, help="Local file to present with mcat")
@@ -1,10 +1,14 @@
from __future__ import annotations
import base64
import importlib.util
import json
import os
import subprocess
import sys
import tempfile
import threading
import time
import unittest
from pathlib import Path
@@ -23,21 +27,92 @@ class FakeRunner:
*,
failed_run_panes: set[str] | None = None,
fail_rename: bool = False,
foreground_processes: dict[str, list[str]] | None = None,
shell_processes: dict[str, str] | None = None,
viewer_teardown_processes: dict[str, list[list[str]]] | None = None,
write_start_acknowledgment: bool = True,
list_delay_seconds: float = 0,
) -> None:
self.panes = panes or []
self.failed_run_panes = failed_run_panes or set()
self.fail_rename = fail_rename
self.foreground_processes = {
pane_id: list(processes)
for pane_id, processes in (foreground_processes or {}).items()
}
self.shell_processes = shell_processes or {}
self.viewer_teardown_processes = {
pane_id: [list(processes) for processes in snapshots]
for pane_id, snapshots in (viewer_teardown_processes or {}).items()
}
self.viewer_closing_panes: set[str] = set()
self.write_start_acknowledgment = write_start_acknowledgment
self.pending_commands: dict[str, str] = {}
self.list_delay_seconds = list_delay_seconds
self.calls: list[list[str]] = []
def __call__(self, command, **_kwargs):
argv = [str(value) for value in command]
self.calls.append(argv)
if argv[1:3] == ["pane", "run"] and argv[3] in self.failed_run_panes:
raise subprocess.CalledProcessError(1, argv)
if argv[1:3] == ["pane", "send-text"]:
pane_id = argv[3]
if pane_id in self.failed_run_panes:
raise subprocess.CalledProcessError(1, argv)
self.pending_commands[pane_id] = argv[4]
command_parts = argv[4].strip().split()
decoded = [
base64.urlsafe_b64decode(value.encode("ascii")).decode("utf-8")
for value in command_parts[-4:]
]
if self.write_start_acknowledgment and decoded[-2] == "/usr/bin/mcat":
Path(decoded[-4]).write_text(decoded[-3], encoding="utf-8")
if argv[1:3] == ["pane", "send-keys"] and argv[4] == "q":
pane_id = argv[3]
self.viewer_closing_panes.add(pane_id)
if argv[1:3] == ["pane", "rename"] and self.fail_rename:
raise subprocess.CalledProcessError(1, argv)
if argv[1:3] == ["pane", "rename"]:
pane_id, label = argv[3], argv[4]
pane = next(
(candidate for candidate in self.panes if candidate["pane_id"] == pane_id),
None,
)
if pane is None:
self.panes.append({"pane_id": pane_id, "label": label})
else:
pane["label"] = label
if argv[1:3] == ["pane", "list"]:
output = {"result": {"panes": self.panes}}
pane_snapshot = [dict(pane) for pane in self.panes]
if self.list_delay_seconds:
time.sleep(self.list_delay_seconds)
output = {"result": {"panes": pane_snapshot}}
elif argv[1:3] == ["pane", "process-info"]:
pane_id = argv[4]
shell_name = self.shell_processes.get(pane_id, "fish")
teardown = self.viewer_teardown_processes.get(pane_id, [])
if pane_id in self.viewer_closing_panes and teardown:
process_names = teardown.pop(0)
elif pane_id in self.viewer_closing_panes:
process_names = [shell_name]
self.viewer_closing_panes.remove(pane_id)
self.foreground_processes[pane_id] = process_names
else:
process_names = self.foreground_processes.get(pane_id, [shell_name])
output = {
"result": {
"process_info": {
"pane_id": pane_id,
"shell_pid": 100,
"foreground_processes": [
{
"name": name,
"pid": 100 if name == shell_name else index + 101,
}
for index, name in enumerate(process_names)
],
}
}
}
elif argv[1:3] == ["pane", "split"]:
output = {"result": {"pane": {"pane_id": "workspace:p-new"}}}
else:
@@ -54,7 +129,10 @@ class ArtifactPresenterTests(unittest.TestCase):
"HERDR_WORKSPACE_ID": "workspace",
"HERDR_PANE_ID": "workspace:p-source",
}
self.executables = {"herdr": "/usr/bin/herdr", "mcat": "/usr/bin/mcat"}
self.executables = {
"herdr": "/usr/bin/herdr",
"mcat": "/usr/bin/mcat",
}
def tearDown(self) -> None:
self.tempdir.cleanup()
@@ -62,6 +140,25 @@ class ArtifactPresenterTests(unittest.TestCase):
def which(self, name: str) -> str | None:
return self.executables.get(name)
def mcat_run_call(
self,
runner: FakeRunner,
pane_id: str,
artifact: Path | None = None,
) -> list[str]:
expected_artifact = str((artifact or self.artifact).resolve())
return next(
call
for call in runner.calls
if call[1:3] == ["pane", "send-text"]
and call[3] == pane_id
and [
base64.urlsafe_b64decode(value.encode("ascii")).decode("utf-8")
for value in call[4].strip().split()[-2:]
]
== ["/usr/bin/mcat", expected_artifact]
)
def test_reuses_named_pane(self) -> None:
runner = FakeRunner(
[{"pane_id": "workspace:p-viewer", "label": MODULE.PANE_LABEL}],
@@ -76,18 +173,183 @@ class ArtifactPresenterTests(unittest.TestCase):
self.assertEqual(result.status, "reused")
self.assertEqual(result.pane_id, "workspace:p-viewer")
self.mcat_run_call(runner, "workspace:p-viewer")
self.assertFalse(any(call[1:3] == ["pane", "split"] for call in runner.calls))
def test_closes_active_mcat_before_reusing_named_pane(self) -> None:
runner = FakeRunner(
[{"pane_id": "workspace:p-viewer", "label": MODULE.PANE_LABEL}],
foreground_processes={"workspace:p-viewer": ["mcat", "less"]},
)
result = MODULE.present_document(
self.artifact,
env=self.env,
which=self.which,
run=runner,
)
self.assertEqual(result.status, "reused")
close_viewer = [
"/usr/bin/herdr",
"pane",
"send-keys",
"workspace:p-viewer",
"q",
]
launch_viewer = self.mcat_run_call(runner, "workspace:p-viewer")
self.assertIn(close_viewer, runner.calls)
self.assertLess(runner.calls.index(close_viewer), runner.calls.index(launch_viewer))
def test_waits_through_transient_viewer_teardown_process(self) -> None:
runner = FakeRunner(
[{"pane_id": "workspace:p-viewer", "label": MODULE.PANE_LABEL}],
foreground_processes={"workspace:p-viewer": ["mcat", "less"]},
viewer_teardown_processes={"workspace:p-viewer": [["less"]]},
)
result = MODULE.present_document(
self.artifact,
env=self.env,
which=self.which,
run=runner,
)
self.assertEqual(result.status, "reused")
process_checks = [
call for call in runner.calls if call[1:3] == ["pane", "process-info"]
]
self.assertEqual(len(process_checks), 4)
self.mcat_run_call(runner, "workspace:p-viewer")
def test_requires_start_acknowledgment_after_launch(self) -> None:
runner = FakeRunner(
[{"pane_id": "workspace:p-viewer", "label": MODULE.PANE_LABEL}],
)
result = MODULE.present_document(
self.artifact,
env=self.env,
which=self.which,
run=runner,
)
self.assertEqual(result.status, "reused")
launch = self.mcat_run_call(runner, "workspace:p-viewer")
command = launch[4].strip().split()
decoded = [
base64.urlsafe_b64decode(value.encode("ascii")).decode("utf-8")
for value in command[-4:]
]
acknowledgment = Path(decoded[-4])
self.assertEqual(Path(command[1]).name, "launch-viewer.py")
self.assertEqual(acknowledgment.name, "viewer-started")
self.assertEqual(len(decoded[-3]), 24)
def test_does_not_report_success_without_start_acknowledgment(self) -> None:
runner = FakeRunner(
[{"pane_id": "workspace:p-viewer", "label": MODULE.PANE_LABEL}],
write_start_acknowledgment=False,
)
original_attempts = MODULE.VIEWER_START_ATTEMPTS
original_delay = MODULE.PANE_READY_DELAY_SECONDS
MODULE.VIEWER_START_ATTEMPTS = 2
MODULE.PANE_READY_DELAY_SECONDS = 0
try:
result = MODULE.present_document(
self.artifact,
env=self.env,
which=self.which,
run=runner,
)
finally:
MODULE.VIEWER_START_ATTEMPTS = original_attempts
MODULE.PANE_READY_DELAY_SECONDS = original_delay
self.assertEqual(result.status, "artifact-only")
self.assertIn("could not initialize Herdr viewer", result.reason)
def test_accepts_idle_shell_by_pid_without_shell_name_allowlist(self) -> None:
runner = FakeRunner(
[{"pane_id": "workspace:p-viewer", "label": MODULE.PANE_LABEL}],
foreground_processes={"workspace:p-viewer": ["xonsh"]},
shell_processes={"workspace:p-viewer": "xonsh"},
)
result = MODULE.present_document(
self.artifact,
env=self.env,
which=self.which,
run=runner,
)
self.assertEqual(result.status, "reused")
self.assertFalse(
any(
call[1:3] == ["pane", "send-keys"] and call[4] == "q"
for call in runner.calls
)
)
def test_empty_foreground_state_is_not_treated_as_ready(self) -> None:
runner = FakeRunner(
[{"pane_id": "workspace:p-transitioning", "label": MODULE.PANE_LABEL}],
foreground_processes={"workspace:p-transitioning": []},
)
original_attempts = MODULE.PANE_READY_ATTEMPTS
original_delay = MODULE.PANE_READY_DELAY_SECONDS
MODULE.PANE_READY_ATTEMPTS = 2
MODULE.PANE_READY_DELAY_SECONDS = 0
try:
result = MODULE.present_document(
self.artifact,
env=self.env,
which=self.which,
run=runner,
)
finally:
MODULE.PANE_READY_ATTEMPTS = original_attempts
MODULE.PANE_READY_DELAY_SECONDS = original_delay
self.assertEqual(result.status, "opened")
process_checks = [
call
for call in runner.calls
if call[1:3] == ["pane", "process-info"]
and call[4] == "workspace:p-transitioning"
]
self.assertEqual(len(process_checks), 2)
def test_does_not_interrupt_unrelated_foreground_process(self) -> None:
runner = FakeRunner(
[{"pane_id": "workspace:p-busy", "label": MODULE.PANE_LABEL}],
foreground_processes={"workspace:p-busy": ["vim"]},
)
result = MODULE.present_document(
self.artifact,
env=self.env,
which=self.which,
run=runner,
)
self.assertEqual(result.status, "opened")
self.assertFalse(
any(
call[1:3] == ["pane", "send-keys"] and call[4] == "q"
for call in runner.calls
)
)
self.assertIn(
[
"/usr/bin/herdr",
"pane",
"run",
"workspace:p-viewer",
"/usr/bin/mcat",
str(self.artifact.resolve()),
"rename",
"workspace:p-busy",
f"{MODULE.PANE_LABEL} stale",
],
runner.calls,
)
self.assertFalse(any(call[1:3] == ["pane", "split"] for call in runner.calls))
def test_tries_each_named_pane_before_creating_another(self) -> None:
runner = FakeRunner(
@@ -134,18 +396,28 @@ class ArtifactPresenterTests(unittest.TestCase):
)
self.assertEqual(result.status, "reused")
self.assertIn(
[
"/usr/bin/herdr",
"pane",
"run",
"workspace:p-viewer",
"/usr/bin/mcat",
str(video.resolve()),
],
runner.calls,
self.mcat_run_call(runner, "workspace:p-viewer", video)
def test_encodes_artifact_paths_before_terminal_delivery(self) -> None:
artifact = self.artifact.with_name("report '$(touch unsafe)'.md")
artifact.write_text("# Safe\n", encoding="utf-8")
runner = FakeRunner(
[{"pane_id": "workspace:p-viewer", "label": MODULE.PANE_LABEL}],
)
result = MODULE.present_document(
artifact,
env=self.env,
which=self.which,
run=runner,
)
self.assertEqual(result.status, "reused")
launch = self.mcat_run_call(runner, "workspace:p-viewer", artifact)
command_parts = launch[4].strip().split()
self.assertTrue(all(MODULE.SAFE_COMMAND_TOKEN.fullmatch(part) for part in command_parts))
self.assertNotIn("$(touch unsafe)", launch[4])
def test_creates_right_pane_when_viewer_is_absent(self) -> None:
runner = FakeRunner()
@@ -214,6 +486,74 @@ class ArtifactPresenterTests(unittest.TestCase):
self.assertEqual(result.status, "artifact-only")
self.assertEqual(result.reason, "mcat is unavailable")
def test_pane_lock_serializes_concurrent_presenters(self) -> None:
first_acquired = threading.Event()
release_first = threading.Event()
second_acquired = threading.Event()
original_runtime = os.environ.get("XDG_RUNTIME_DIR")
os.environ["XDG_RUNTIME_DIR"] = self.tempdir.name
def first_presenter() -> None:
with MODULE.lock_pane("workspace:p-viewer"):
first_acquired.set()
release_first.wait(timeout=1)
def second_presenter() -> None:
first_acquired.wait(timeout=1)
with MODULE.lock_pane("workspace:p-viewer"):
second_acquired.set()
first = threading.Thread(target=first_presenter)
second = threading.Thread(target=second_presenter)
try:
first.start()
second.start()
self.assertTrue(first_acquired.wait(timeout=1))
self.assertFalse(second_acquired.wait(timeout=0.05))
release_first.set()
self.assertTrue(second_acquired.wait(timeout=1))
finally:
release_first.set()
first.join(timeout=1)
second.join(timeout=1)
if original_runtime is None:
os.environ.pop("XDG_RUNTIME_DIR", None)
else:
os.environ["XDG_RUNTIME_DIR"] = original_runtime
def test_workspace_lock_prevents_duplicate_viewer_creation(self) -> None:
runner = FakeRunner(list_delay_seconds=0.05)
results: list[MODULE.PresentationResult] = []
results_lock = threading.Lock()
original_runtime = os.environ.get("XDG_RUNTIME_DIR")
os.environ["XDG_RUNTIME_DIR"] = self.tempdir.name
def present() -> None:
result = MODULE.present_document(
self.artifact,
env=self.env,
which=self.which,
run=runner,
)
with results_lock:
results.append(result)
threads = [threading.Thread(target=present) for _ in range(2)]
try:
for thread in threads:
thread.start()
for thread in threads:
thread.join(timeout=2)
finally:
if original_runtime is None:
os.environ.pop("XDG_RUNTIME_DIR", None)
else:
os.environ["XDG_RUNTIME_DIR"] = original_runtime
self.assertEqual(sorted(result.status for result in results), ["opened", "reused"])
splits = [call for call in runner.calls if call[1:3] == ["pane", "split"]]
self.assertEqual(len(splits), 1)
def test_rejects_missing_artifact(self) -> None:
with self.assertRaisesRegex(ValueError, "artifact is not a file"):
MODULE.present_document(
@@ -0,0 +1,116 @@
from __future__ import annotations
import importlib.util
import os
import shutil
import subprocess
import sys
import tempfile
import time
import unittest
from pathlib import Path
MODULE_PATH = Path(__file__).parents[1] / "scripts" / "present.py"
SPEC = importlib.util.spec_from_file_location("artifact_presenter_integration", MODULE_PATH)
assert SPEC and SPEC.loader
MODULE = importlib.util.module_from_spec(SPEC)
sys.modules[SPEC.name] = MODULE
SPEC.loader.exec_module(MODULE)
@unittest.skipUnless(
os.environ.get("HERDR_INTEGRATION_TESTS") == "1",
"set HERDR_INTEGRATION_TESTS=1 inside a Herdr pane",
)
class ArtifactPresenterIntegrationTests(unittest.TestCase):
def wait_for_mcat(self, herdr: str, pane_id: str, artifact: Path) -> None:
for _attempt in range(100):
state = MODULE.run_json(
herdr,
["pane", "process-info", "--pane", pane_id],
subprocess.run,
)
processes = state["result"]["process_info"]["foreground_processes"]
if any(
process.get("name") == "mcat"
and str(artifact) in process.get("argv", [])
for process in processes
):
return
time.sleep(0.05)
self.fail(f"mcat did not open {artifact}")
def test_replaces_active_mcat_in_real_herdr_pane(self) -> None:
herdr = shutil.which("herdr")
mcat = shutil.which("mcat")
source_pane = os.environ.get("HERDR_PANE_ID")
if not all((herdr, mcat, source_pane)):
self.skipTest("Herdr, mcat, and HERDR_PANE_ID are required")
with tempfile.TemporaryDirectory() as directory:
root = Path(directory)
first = root / "first.md"
second = root / "second.md"
first.write_text(
"# First artifact\n" + ("first viewer line " * 20 + "\n") * 1_000,
encoding="utf-8",
)
second.write_text(
"# Second artifact\n" + ("second viewer line " * 20 + "\n") * 1_000,
encoding="utf-8",
)
split = MODULE.run_json(
herdr,
[
"pane",
"split",
"--pane",
source_pane,
"--direction",
"right",
"--cwd",
directory,
"--env",
"PAGER=less",
"--no-focus",
],
subprocess.run,
)
pane_id = MODULE.created_pane_id(split)
try:
MODULE.run_mcat(
herdr,
mcat,
pane_id,
first,
subprocess.run,
)
self.wait_for_mcat(herdr, pane_id, first)
MODULE.run_mcat(
herdr,
mcat,
pane_id,
second,
subprocess.run,
reused=True,
)
self.wait_for_mcat(herdr, pane_id, second)
finally:
subprocess.run(
[herdr, "pane", "send-keys", pane_id, "q"],
check=False,
capture_output=True,
text=True,
)
subprocess.run(
[herdr, "pane", "close", pane_id],
check=False,
capture_output=True,
text=True,
)
if __name__ == "__main__":
unittest.main()