Files
MyAiConfiguration/scripts/tests/test-client-smoke.py
T
Julian lechnerandClaude Opus 5.5 82391bdfbf 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>
2026-10-01 11:02:05 +02:00

203 lines
9.4 KiB
Python

"""Behavioral client smoke tests against an isolated installation.
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
with an invalid API key and is stopped at its init event; Codex is queried
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 os
from pathlib import Path
import queue
import re
import shutil
import subprocess
import sys
import tempfile
import threading
import unittest
ROOT = Path(__file__).resolve().parent.parent.parent
SHELL = 'powershell' if os.name == 'nt' else 'bash'
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):
names = set()
for path in directory.rglob(pattern):
match = re.search(r'(?m)^name\s*[:=]\s*"?([^"\r\n]+)"?\s*$', path.read_text(encoding='utf-8-sig'))
if match:
names.add(match.group(1).strip())
return names
def marker_command(marker):
return f'"{sys.executable}" -c "{MARKER_SCRIPT}" "{marker}"'
def clean_env(**values):
env = {key: value for key, value in os.environ.items()
if not key.startswith(('BUN_INSPECT', 'CLAUDE', 'ANTHROPIC', 'CODEX', 'OPENAI'))}
env.update(values)
return env
def install(client, home):
if SHELL == 'powershell':
script = ('$ErrorActionPreference = "Stop"; '
f'. "{ROOT}/scripts/lib/manifest.ps1"; . "{ROOT}/scripts/lib/install-targets.ps1"; '
'$command = "powershell -NoProfile -ExecutionPolicy Bypass -File"; '
f'foreach ($item in (Get-InstallTargets -Generated "{ROOT}/generated" -HomePath "{home}" -Shell PowerShell -Client {client})) {{ '
'Sync-ManagedDestination -Source $item.Source -Destination $item.Destination -Stamp smoke '
'-AiConfigRoot $item.Destination.Replace("\\","/") -ShellCommand $command -PowerShellCommand $command -Summary | Out-Null }')
args = ['powershell', '-NoProfile', '-ExecutionPolicy', 'Bypass', '-Command', script]
else:
script = (f'set -e; . "{ROOT}/scripts/lib/manifest.sh"; . "{ROOT}/scripts/lib/install-targets.sh"; '
f'get_install_targets "{ROOT}/generated" "{home}" bash {client.lower()} | '
'while IFS="|" read -r source destination; do '
'sync_managed_destination "$source" "$destination" smoke "$destination" bash "pwsh -NoProfile -File" false true >/dev/null; done')
args = ['bash', '-c', script]
result = subprocess.run(args, capture_output=True, text=True, timeout=INSTALL_TIMEOUT)
if result.returncode:
raise RuntimeError(f"install failed: {result.stdout}{result.stderr}")
class ClientSmokeTests(unittest.TestCase):
def setUp(self):
self.base = Path(tempfile.mkdtemp(prefix='ai-config-client-smoke-'))
self.home = self.base / 'home'
self.work = self.base / 'work'
self.work.mkdir(parents=True)
subprocess.run(['git', 'init', '-q'], cwd=self.work, check=True, timeout=60)
self.marker = self.base / 'foreign-hook.txt'
def tearDown(self):
shutil.rmtree(self.base, ignore_errors=True)
def test_claude_discovers_package_and_keeps_foreign_settings(self):
claude = client_cli(self, 'claude')
package = ROOT / f'generated/claude-{SHELL}'
config = self.home / '.claude'
config.mkdir(parents=True)
foreign_hook = {'hooks': [{'type': 'command', 'command': marker_command(self.marker), 'timeout': 10}]}
(config / 'settings.json').write_text(json.dumps({'foreignSetting': 'keep', 'hooks': {'SessionStart': [foreign_hook]}}))
install('Claude', self.home)
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'),
stdin=subprocess.DEVNULL, stdout=subprocess.PIPE, stderr=subprocess.DEVNULL, text=True, encoding='utf-8')
events = []
try:
reader = LineReader(process.stdout)
while not events or events[-1].get('subtype') != 'init':
events.append(json.loads(reader.next('the Claude init event')))
finally:
stop(process)
init = events[-1]
with self.subTest('skills'):
self.assertEqual(sorted(frontmatter_names(package / 'skills', 'SKILL.md') - set(init['skills'])), [])
with self.subTest('agents'):
self.assertEqual(sorted(frontmatter_names(package / 'agents', '*.md') - set(init['agents'])), [])
with self.subTest('hooks'):
failed = [(event.get('hook_name'), event.get('exit_code'), event.get('stderr', '')[:200])
for event in events if event.get('subtype') == 'hook_response' and event.get('exit_code') != 0]
self.assertEqual(failed, [])
self.assertTrue(self.marker.exists(), 'foreign SessionStart hook did not run')
with self.subTest('foreign settings'):
self.assertEqual(json.loads((config / 'settings.json').read_text(encoding='utf-8-sig')).get('foreignSetting'), 'keep')
def test_codex_discovers_package_and_keeps_foreign_settings(self):
codex = client_cli(self, 'codex')
package = ROOT / f'generated/codex-{SHELL}'
codex_home = self.home / '.codex'
codex_home.mkdir(parents=True)
command = json.dumps(marker_command(self.marker))
(codex_home / 'config.toml').write_text(
'[mcp_servers.smoke_demo]\ncommand = "smoke-demo"\nenabled = false\n\n'
f'[[hooks.SessionStart]]\n[[hooks.SessionStart.hooks]]\ntype = "command"\ncommand = {command}\ntimeout = 10\n')
install('Codex', self.home)
process = subprocess.Popen([codex, 'app-server'], cwd=self.work,
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')
reader = LineReader(process.stdout)
def call(request_id, method, params):
process.stdin.write(json.dumps({'jsonrpc': '2.0', 'id': request_id, 'method': method, 'params': params}) + '\n')
process.stdin.flush()
while True:
message = json.loads(reader.next(method))
if message.get('id') == request_id:
self.assertNotIn('error', message, method)
return message['result']
try:
call(1, 'initialize', {'clientInfo': {'name': 'ai-config-smoke', 'version': '1'}})
process.stdin.write(json.dumps({'jsonrpc': '2.0', 'method': 'initialized'}) + '\n')
# Codex resolves ~/.agents/skills from the real Windows profile, so register the isolated root explicitly.
skills_root = self.home / '.agents/skills'
call(2, 'skills/extraRoots/set', {'extraRoots': [str(skills_root)]})
skills = call(3, 'skills/list', {'cwds': [str(self.work)], 'forceReload': True})['data'][0]['skills']
hooks = call(4, 'hooks/list', {'cwds': [str(self.work)]})['data'][0]['hooks']
config = call(5, 'config/read', {})['config']
finally:
stop(process)
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')
events = {(hook['eventName'], hook['enabled']) for hook in hooks if hook.get('source') == 'user'}
self.assertIn(('stop', True), events, 'managed Stop hook not registered')
self.assertIn(('sessionStart', True), events, 'foreign SessionStart hook lost')
self.assertEqual(config['mcp_servers']['smoke_demo']['command'], 'smoke-demo')
self.assertTrue((codex_home / 'agents').is_dir() and frontmatter_names(codex_home / 'agents', '*.toml') == frontmatter_names(package / 'agents', '*.toml'))
if __name__ == '__main__':
unittest.main()