test(clients): add smoke test timeouts and required-client mode

Read client output on a thread with a deadline so a silent or exited client
fails instead of hanging, and fail on a missing CLI when
AI_CONFIG_REQUIRE_CLIENTS=1.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Julian lechnerandClaude Opus 5.5 committed 2026-10-01 11:02:05 +02:00
1 parent c14101e60d
commit 82391bdfbf
1 file changed
+60 -23
+60 -23
View File
@@ -4,21 +4,67 @@ Each test installs a generated package into a temporary home that already holds
foreign settings, then asks the real client CLI what it discovered. Claude runs foreign settings, then asks the real client CLI what it discovered. Claude runs
with an invalid API key and is stopped at its init event; Codex is queried with an invalid API key and is stopped at its init event; Codex is queried
through `codex app-server`. No model request completes. through `codex app-server`. No model request completes.
Set AI_CONFIG_REQUIRE_CLIENTS=1 to fail instead of skip when a client CLI is missing.
""" """
import json import json
import os import os
from pathlib import Path from pathlib import Path
import queue
import re import re
import shutil import shutil
import subprocess import subprocess
import sys import sys
import tempfile import tempfile
import threading
import unittest import unittest
ROOT = Path(__file__).resolve().parent.parent.parent ROOT = Path(__file__).resolve().parent.parent.parent
SHELL = 'powershell' if os.name == 'nt' else 'bash' SHELL = 'powershell' if os.name == 'nt' else 'bash'
MARKER_SCRIPT = "import sys; open(sys.argv[1], 'w').write('ok')" MARKER_SCRIPT = "import sys; open(sys.argv[1], 'w').write('ok')"
REQUIRE_CLIENTS = os.environ.get('AI_CONFIG_REQUIRE_CLIENTS') == '1'
INSTALL_TIMEOUT = 300
OUTPUT_TIMEOUT = 90
def client_cli(test, name):
path = shutil.which(name)
if path:
return path
if REQUIRE_CLIENTS:
test.fail(f'{name} CLI unavailable but AI_CONFIG_REQUIRE_CLIENTS=1')
test.skipTest(f'{name} CLI unavailable')
class LineReader:
"""Read process output lines on a thread so every wait has a deadline."""
def __init__(self, stream):
self.lines = queue.Queue()
threading.Thread(target=self._pump, args=(stream,), daemon=True).start()
def _pump(self, stream):
for line in stream:
self.lines.put(line)
self.lines.put(None)
def next(self, what):
try:
line = self.lines.get(timeout=OUTPUT_TIMEOUT)
except queue.Empty:
raise AssertionError(f'no output for {OUTPUT_TIMEOUT}s while waiting for {what}') from None
if line is None:
raise AssertionError(f'client exited while waiting for {what}')
return line
def stop(process):
process.kill()
for stream in (process.stdin, process.stdout):
if stream:
stream.close()
process.wait(timeout=30)
def frontmatter_names(directory, pattern): def frontmatter_names(directory, pattern):
@@ -56,7 +102,7 @@ def install(client, home):
'while IFS="|" read -r source destination; do ' 'while IFS="|" read -r source destination; do '
'sync_managed_destination "$source" "$destination" smoke "$destination" bash "pwsh -NoProfile -File" false true >/dev/null; done') 'sync_managed_destination "$source" "$destination" smoke "$destination" bash "pwsh -NoProfile -File" false true >/dev/null; done')
args = ['bash', '-c', script] args = ['bash', '-c', script]
result = subprocess.run(args, capture_output=True, text=True) result = subprocess.run(args, capture_output=True, text=True, timeout=INSTALL_TIMEOUT)
if result.returncode: if result.returncode:
raise RuntimeError(f"install failed: {result.stdout}{result.stderr}") raise RuntimeError(f"install failed: {result.stdout}{result.stderr}")
@@ -67,14 +113,14 @@ class ClientSmokeTests(unittest.TestCase):
self.home = self.base / 'home' self.home = self.base / 'home'
self.work = self.base / 'work' self.work = self.base / 'work'
self.work.mkdir(parents=True) self.work.mkdir(parents=True)
subprocess.run(['git', 'init', '-q'], cwd=self.work, check=True) subprocess.run(['git', 'init', '-q'], cwd=self.work, check=True, timeout=60)
self.marker = self.base / 'foreign-hook.txt' self.marker = self.base / 'foreign-hook.txt'
def tearDown(self): def tearDown(self):
shutil.rmtree(self.base, ignore_errors=True) shutil.rmtree(self.base, ignore_errors=True)
@unittest.skipUnless(shutil.which('claude'), 'Claude CLI unavailable')
def test_claude_discovers_package_and_keeps_foreign_settings(self): def test_claude_discovers_package_and_keeps_foreign_settings(self):
claude = client_cli(self, 'claude')
package = ROOT / f'generated/claude-{SHELL}' package = ROOT / f'generated/claude-{SHELL}'
config = self.home / '.claude' config = self.home / '.claude'
config.mkdir(parents=True) config.mkdir(parents=True)
@@ -82,23 +128,17 @@ class ClientSmokeTests(unittest.TestCase):
(config / 'settings.json').write_text(json.dumps({'foreignSetting': 'keep', 'hooks': {'SessionStart': [foreign_hook]}})) (config / 'settings.json').write_text(json.dumps({'foreignSetting': 'keep', 'hooks': {'SessionStart': [foreign_hook]}}))
install('Claude', self.home) install('Claude', self.home)
process = subprocess.Popen([shutil.which('claude'), '-p', 'smoke', '--output-format', 'stream-json', '--verbose', '--max-turns', '1'], process = subprocess.Popen([claude, '-p', 'smoke', '--output-format', 'stream-json', '--verbose', '--max-turns', '1'],
cwd=self.work, env=clean_env(CLAUDE_CONFIG_DIR=str(config), ANTHROPIC_API_KEY='sk-ant-invalid-smoke'), cwd=self.work, env=clean_env(CLAUDE_CONFIG_DIR=str(config), ANTHROPIC_API_KEY='sk-ant-invalid-smoke'),
stdin=subprocess.DEVNULL, stdout=subprocess.PIPE, stderr=subprocess.DEVNULL, text=True, encoding='utf-8') stdin=subprocess.DEVNULL, stdout=subprocess.PIPE, stderr=subprocess.DEVNULL, text=True, encoding='utf-8')
events = [] events = []
try: try:
for line in process.stdout: reader = LineReader(process.stdout)
events.append(json.loads(line)) while not events or events[-1].get('subtype') != 'init':
if events[-1].get('subtype') == 'init': events.append(json.loads(reader.next('the Claude init event')))
break
finally: finally:
process.kill() stop(process)
for stream in (process.stdin, process.stdout): init = events[-1]
if stream:
stream.close()
process.wait(timeout=30)
init = next((event for event in events if event.get('subtype') == 'init'), None)
self.assertIsNotNone(init, 'Claude emitted no init event')
with self.subTest('skills'): with self.subTest('skills'):
self.assertEqual(sorted(frontmatter_names(package / 'skills', 'SKILL.md') - set(init['skills'])), []) self.assertEqual(sorted(frontmatter_names(package / 'skills', 'SKILL.md') - set(init['skills'])), [])
@@ -112,8 +152,8 @@ class ClientSmokeTests(unittest.TestCase):
with self.subTest('foreign settings'): with self.subTest('foreign settings'):
self.assertEqual(json.loads((config / 'settings.json').read_text(encoding='utf-8-sig')).get('foreignSetting'), 'keep') self.assertEqual(json.loads((config / 'settings.json').read_text(encoding='utf-8-sig')).get('foreignSetting'), 'keep')
@unittest.skipUnless(shutil.which('codex'), 'Codex CLI unavailable')
def test_codex_discovers_package_and_keeps_foreign_settings(self): def test_codex_discovers_package_and_keeps_foreign_settings(self):
codex = client_cli(self, 'codex')
package = ROOT / f'generated/codex-{SHELL}' package = ROOT / f'generated/codex-{SHELL}'
codex_home = self.home / '.codex' codex_home = self.home / '.codex'
codex_home.mkdir(parents=True) codex_home.mkdir(parents=True)
@@ -123,15 +163,16 @@ class ClientSmokeTests(unittest.TestCase):
f'[[hooks.SessionStart]]\n[[hooks.SessionStart.hooks]]\ntype = "command"\ncommand = {command}\ntimeout = 10\n') f'[[hooks.SessionStart]]\n[[hooks.SessionStart.hooks]]\ntype = "command"\ncommand = {command}\ntimeout = 10\n')
install('Codex', self.home) install('Codex', self.home)
process = subprocess.Popen([shutil.which('codex'), 'app-server'], cwd=self.work, process = subprocess.Popen([codex, 'app-server'], cwd=self.work,
env=clean_env(CODEX_HOME=str(codex_home), OPENAI_API_KEY='sk-invalid-smoke'), env=clean_env(CODEX_HOME=str(codex_home), OPENAI_API_KEY='sk-invalid-smoke'),
stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.DEVNULL, text=True, encoding='utf-8') stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.DEVNULL, text=True, encoding='utf-8')
reader = LineReader(process.stdout)
def call(request_id, method, params): def call(request_id, method, params):
process.stdin.write(json.dumps({'jsonrpc': '2.0', 'id': request_id, 'method': method, 'params': params}) + '\n') process.stdin.write(json.dumps({'jsonrpc': '2.0', 'id': request_id, 'method': method, 'params': params}) + '\n')
process.stdin.flush() process.stdin.flush()
while True: while True:
message = json.loads(process.stdout.readline()) message = json.loads(reader.next(method))
if message.get('id') == request_id: if message.get('id') == request_id:
self.assertNotIn('error', message, method) self.assertNotIn('error', message, method)
return message['result'] return message['result']
@@ -146,11 +187,7 @@ class ClientSmokeTests(unittest.TestCase):
hooks = call(4, 'hooks/list', {'cwds': [str(self.work)]})['data'][0]['hooks'] hooks = call(4, 'hooks/list', {'cwds': [str(self.work)]})['data'][0]['hooks']
config = call(5, 'config/read', {})['config'] config = call(5, 'config/read', {})['config']
finally: finally:
process.kill() stop(process)
for stream in (process.stdin, process.stdout):
if stream:
stream.close()
process.wait(timeout=30)
isolated = {skill['name'] for skill in skills if Path(skill['path']).resolve().is_relative_to(skills_root.resolve())} isolated = {skill['name'] for skill in skills if Path(skill['path']).resolve().is_relative_to(skills_root.resolve())}
self.assertEqual(sorted(frontmatter_names(package / 'skills', 'SKILL.md') - isolated), [], 'skills not discovered') self.assertEqual(sorted(frontmatter_names(package / 'skills', 'SKILL.md') - isolated), [], 'skills not discovered')