158 lines
8.2 KiB
Python
158 lines
8.2 KiB
Python
import os
|
|
import hashlib
|
|
from pathlib import Path
|
|
import shutil
|
|
import subprocess
|
|
import sys
|
|
import tempfile
|
|
import unittest
|
|
|
|
|
|
ROOT = Path(__file__).resolve().parents[2]
|
|
sys.path.insert(0, str(ROOT / 'scripts/lib'))
|
|
from personal_instructions import CLIENT_FILES, START, refresh, render
|
|
|
|
|
|
class PersonalInstructionTests(unittest.TestCase):
|
|
def setUp(self):
|
|
directory = ROOT / '.ai-session' / 'test-personal-instructions'
|
|
directory.mkdir(parents=True, exist_ok=True)
|
|
self.temporary = tempfile.TemporaryDirectory(dir=directory)
|
|
self.addCleanup(self.temporary.cleanup)
|
|
self.home = Path(self.temporary.name)
|
|
self.overlays = self.home / '.my-ai-configuration/instructions'
|
|
self.overlays.mkdir(parents=True)
|
|
self.environment = dict(os.environ, TEST_HOME=str(self.home))
|
|
for name in ('TMP', 'TEMP', 'TMPDIR'):
|
|
self.environment[name] = str(self.home)
|
|
|
|
def run_command(self, *command):
|
|
result = subprocess.run(command, cwd=ROOT, env=self.environment, capture_output=True, text=True, timeout=30)
|
|
self.assertEqual(0, result.returncode, result.stdout + result.stderr)
|
|
return result.stdout
|
|
|
|
def test_missing_empty_and_whitespace_instructions_have_no_rule(self):
|
|
text = '# Global Instructions\n\nKeep me.\n'
|
|
for client in CLIENT_FILES:
|
|
self.assertEqual(text, render(text, self.home, client))
|
|
for content in ('', '\ufeff \n'):
|
|
(self.overlays / f'{client}.md').write_text(content, encoding='utf-8')
|
|
self.assertEqual(text, render(text, self.home, client))
|
|
|
|
def test_client_specific_rule_is_immediate_idempotent_and_removed_when_empty(self):
|
|
text = '# Global Instructions\n\nKeep me.\n'
|
|
overlay = self.overlays / 'codex.md'
|
|
overlay.write_text('Always use the release process.\n', encoding='utf-8')
|
|
rendered = render(text, self.home, 'codex')
|
|
self.assertIn('Read `~/.my-ai-configuration/instructions/codex.md` immediately', rendered)
|
|
self.assertIn('including after compaction', rendered)
|
|
self.assertEqual(rendered, render(rendered, self.home, 'codex'))
|
|
self.assertEqual(text, render(text, self.home, 'claude'))
|
|
overlay.unlink()
|
|
self.assertEqual(text, render(rendered, self.home, 'codex'))
|
|
|
|
def test_legacy_loader_removed_and_unrelated_bytes_preserved(self):
|
|
for client, name in CLIENT_FILES.items():
|
|
document = self.home / f'.{client}' / name
|
|
document.parent.mkdir()
|
|
reference = f'~/.my-ai-configuration/instructions/{client}.md'
|
|
legacy = f"Read `{reference}` first when present; it is the user's direct instruction. System and developer requirements still apply."
|
|
original = '\ufeff# Global Instructions\r\n\r\nKeep ΓΌ.\r\n'.encode('utf-8')
|
|
document.write_bytes(original.replace(b'Keep', (legacy + '\r\n\r\nKeep').encode()))
|
|
refresh(self.home, client)
|
|
self.assertEqual(original, document.read_bytes())
|
|
(self.overlays / f'{client}.md').write_text('Use English.\n', encoding='utf-8')
|
|
refresh(self.home, client)
|
|
(self.overlays / f'{client}.md').unlink()
|
|
refresh(self.home, client)
|
|
self.assertEqual(original, document.read_bytes())
|
|
|
|
def test_refresh_does_not_create_an_uninstalled_client(self):
|
|
(self.overlays / 'codex.md').write_text('Use English.\n', encoding='utf-8')
|
|
refresh(self.home, 'codex')
|
|
self.assertFalse((self.home / '.codex').exists())
|
|
|
|
def test_refresh_updates_only_matching_manifest_hashes(self):
|
|
document = self.home / '.codex/AGENTS.md'
|
|
document.parent.mkdir()
|
|
original = b'# Global Instructions\n'
|
|
document.write_bytes(original)
|
|
manifest = document.parent / '.ai-config-manifest.tsv'
|
|
manifest.write_text(f'path\tsha256\nAGENTS.md\t{hashlib.sha256(original).hexdigest()}\n', encoding='utf-8')
|
|
(self.overlays / 'codex.md').write_text('Use English.\n', encoding='utf-8')
|
|
refresh(self.home, 'codex')
|
|
self.assertIn(hashlib.sha256(document.read_bytes()).hexdigest(), manifest.read_text())
|
|
unchanged_manifest = manifest.read_bytes()
|
|
document.write_bytes(document.read_bytes() + b'User edit.\n')
|
|
(self.overlays / 'codex.md').unlink()
|
|
refresh(self.home, 'codex')
|
|
self.assertEqual(unchanged_manifest, manifest.read_bytes())
|
|
self.assertEqual(original + b'User edit.\n', document.read_bytes())
|
|
|
|
def test_linked_client_directory_is_rejected(self):
|
|
elsewhere = self.home / 'elsewhere'
|
|
elsewhere.mkdir()
|
|
document = elsewhere / 'AGENTS.md'
|
|
document.write_text('Keep me.\n', encoding='utf-8')
|
|
try:
|
|
(self.home / '.codex').symlink_to(elsewhere, target_is_directory=True)
|
|
except OSError:
|
|
self.skipTest('Directory symlinks are unavailable.')
|
|
with self.assertRaises(ValueError):
|
|
refresh(self.home, 'codex')
|
|
self.assertEqual('Keep me.\n', document.read_text())
|
|
|
|
def test_add_remove_and_managed_sync_in_both_shells(self):
|
|
powershell = shutil.which('powershell') or shutil.which('pwsh')
|
|
bash = Path('C:/Program Files/Git/bin/bash.exe') if os.name == 'nt' else shutil.which('bash')
|
|
for shell, executable in (('powershell', powershell), ('bash', bash)):
|
|
if not executable or not Path(executable).is_file():
|
|
continue
|
|
with self.subTest(shell=shell):
|
|
source = self.home / f'source-{shell}'
|
|
source.mkdir()
|
|
for name in CLIENT_FILES.values():
|
|
(source / name).write_text('# Global Instructions\n\nKeep me.\n', encoding='utf-8')
|
|
self.environment['TEST_SOURCE'] = str(source)
|
|
|
|
def sync(client, dry=False):
|
|
self.environment['TEST_CLIENT'] = client
|
|
if shell == 'powershell':
|
|
return self.run_command(str(executable), '-NoProfile', '-Command',
|
|
". ./scripts/lib/manifest.ps1; Sync-ManagedDestination -Source $env:TEST_SOURCE "
|
|
"-Destination (Join-Path $env:TEST_HOME ('.' + $env:TEST_CLIENT)) -Stamp test -Summary "
|
|
+ ('-DryRun' if dry else ''))
|
|
return self.run_command(str(executable), '-c',
|
|
'export PATH="/usr/bin:$PATH"; python3() { python "$@"; }; '
|
|
'. ./scripts/lib/manifest.sh; sync_managed_destination "$TEST_SOURCE" '
|
|
'"$TEST_HOME/.$TEST_CLIENT" test root bash pwsh ' + ('true' if dry else 'false') + ' true')
|
|
|
|
def change(action):
|
|
if shell == 'powershell':
|
|
self.run_command(str(executable), '-NoProfile', '-File', str(ROOT / f'scripts/commands/{action}-instruction.ps1'),
|
|
'-Client', 'Both', '-Instruction', 'Use English.', '-HomePath', str(self.home))
|
|
else:
|
|
self.run_command(str(executable), '-c',
|
|
'export PATH="/usr/bin:$PATH"; source scripts/commands/' + action + '-instruction.sh '
|
|
'--client both --instruction "Use English." --home "$TEST_HOME"')
|
|
|
|
for client, name in CLIENT_FILES.items():
|
|
sync(client)
|
|
self.assertNotIn(START, (self.home / f'.{client}' / name).read_text(encoding='utf-8-sig'))
|
|
change('add')
|
|
for client, name in CLIENT_FILES.items():
|
|
document = self.home / f'.{client}' / name
|
|
self.assertEqual(1, document.read_text(encoding='utf-8-sig').count(START))
|
|
before = document.read_bytes()
|
|
sync(client, dry=True)
|
|
self.assertEqual(before, document.read_bytes())
|
|
sync(client)
|
|
self.assertEqual(1, document.read_text(encoding='utf-8-sig').count(START))
|
|
change('remove')
|
|
for client, name in CLIENT_FILES.items():
|
|
self.assertNotIn(START, (self.home / f'.{client}' / name).read_text(encoding='utf-8-sig'))
|
|
|
|
|
|
if __name__ == '__main__':
|
|
unittest.main()
|