import json import os from pathlib import Path import shutil import subprocess import sys import tempfile import unittest ROOT = Path(__file__).resolve().parent.parent.parent HELPER = ROOT / 'scripts/lib/selection-state.py' class SelectionTests(unittest.TestCase): def setUp(self): self.temporary = tempfile.TemporaryDirectory() self.addCleanup(self.temporary.cleanup) self.home = Path(self.temporary.name) self.path = self.home / '.my-ai-configuration/selection.json' def run_state(self, action, *arguments): return subprocess.run([sys.executable, str(HELPER), action, '--home', str(self.home), '--manifest', str(ROOT / 'adapters/plugins.tsv'), *arguments], capture_output=True, text=True, timeout=10) def save(self, *arguments): result = self.run_state('write', '--shell', 'powershell', '--flashbang', 'false', '--statusline', 'false', '--client', 'both', *arguments) self.assertEqual(result.returncode, 0, result.stderr) def test_missing_selection_fails_without_creating_state(self): result = self.run_state('read') self.assertNotEqual(result.returncode, 0) self.assertIn('without Quick', result.stderr) self.assertFalse(self.path.exists()) def test_selection_round_trip_and_last_successful_write(self): self.save('--selected', 'Superpowers', 'Anthropic Frontend Design', '--deselected', 'Context7') original = self.path.read_bytes() state = json.loads(self.run_state('read').stdout) self.assertEqual(state['shell'], 'powershell') self.assertEqual(state['client'], 'both') self.assertEqual(state['selected'], ['Superpowers', 'Anthropic Frontend Design']) self.assertEqual(state['deselected'], ['Context7']) self.assertEqual(self.path.read_bytes(), original) self.assertIn('selected\tAnthropic Frontend Design', self.run_state('read', '--format', 'tsv').stdout) self.save('--selected', 'Context7') self.assertEqual(json.loads(self.run_state('read').stdout)['selected'], ['Context7']) def test_empty_selection_does_not_select_everything(self): self.save() self.assertEqual(json.loads(self.run_state('read').stdout)['selected'], []) def test_legacy_state_requires_normal_update_and_can_supply_defaults(self): self.path.parent.mkdir() self.path.write_text(json.dumps(dict(version=1, platform='windows', client='both', selected=[], deselected=[]))) result = self.run_state('read') self.assertNotEqual(result.returncode, 0) self.assertIn('without Quick', result.stderr) migrated = json.loads(self.run_state('read', '--allow-legacy').stdout) self.assertEqual(migrated['shell'], 'powershell') self.assertTrue(migrated['flashbang']) self.assertTrue(migrated['statusline']) def test_boolean_options_are_required_and_preserved(self): self.save() state = json.loads(self.run_state('read').stdout) self.assertFalse(state['flashbang']) self.assertFalse(state['statusline']) state['flashbang'] = 'false' self.path.write_text(json.dumps(state)) self.assertNotEqual(self.run_state('read').returncode, 0) def test_semantic_retrieval_option_round_trips(self): result = self.run_state('write', '--shell', 'bash', '--client', 'both', '--flashbang', 'true', '--statusline', 'true', '--semantic-retrieval', 'true') self.assertEqual(result.returncode, 0, result.stderr) state = json.loads(self.run_state('read').stdout) self.assertTrue(state['semantic_retrieval']) self.assertTrue(state['statusline']) self.assertIn('semantic_retrieval\ttrue', self.run_state('read', '--format', 'tsv').stdout) def test_legacy_skill_profile_is_ignored(self): self.save() state = json.loads(self.run_state('read').stdout) state['skill_profile'] = 'core' self.path.write_text(json.dumps(state)) self.assertNotIn('skill_profile', json.loads(self.run_state('read').stdout)) self.assertNotIn('skill_profile', self.run_state('read', '--format', 'tsv').stdout) def test_invalid_write_preserves_last_selection(self): self.save('--selected', 'Superpowers') original = self.path.read_bytes() result = self.run_state('write', '--shell', 'bash', '--flashbang', 'true', '--client', 'codex', '--selected', 'Unknown') self.assertNotEqual(result.returncode, 0) self.assertEqual(self.path.read_bytes(), original) def test_corrupt_or_conflicting_state_is_rejected(self): self.path.parent.mkdir() for state in ('{', '[]', json.dumps(dict(version=1, platform='windows', client='both', selected=['Context7'], deselected=['Context7']))): self.path.write_text(state) self.assertNotEqual(self.run_state('read').returncode, 0) def test_removed_plugins_are_ignored_and_new_plugins_not_selected(self): self.save('--selected', 'Superpowers') state = json.loads(self.path.read_text()) state['selected'].append('Removed plugin') self.path.write_text(json.dumps(state)) self.assertEqual(json.loads(self.run_state('read').stdout)['selected'], ['Superpowers']) def test_powershell_quick_preview_uses_saved_selection_without_writes(self): shell = shutil.which('powershell') or shutil.which('pwsh') if not shell: self.skipTest('PowerShell unavailable') environment = dict(os.environ, TEST_SELECTION_HOME=str(self.home), TEST_REPOSITORY_ROOT=str(ROOT)) save = subprocess.run([shell, '-NoProfile', '-Command', "$ErrorActionPreference = 'Stop'; . (Join-Path $env:TEST_REPOSITORY_ROOT 'scripts/lib/selection-state.ps1'); " "Save-UpdateSelection -HomePath $env:TEST_SELECTION_HOME -RepositoryRoot $env:TEST_REPOSITORY_ROOT " "-Shell PowerShell -Client Both -Flashbang $false -StatusLine $false -Plugins @{ Selected = @([pscustomobject]@{name='Superpowers'}); Deselected = @() }"], env=environment, capture_output=True, text=True, timeout=10) self.assertEqual(save.returncode, 0, save.stdout + save.stderr) original = self.path.read_bytes() source = (ROOT / 'scripts/commands/update.ps1').read_text(encoding='utf-8-sig') source = source.replace("[Environment]::GetFolderPath('UserProfile')", '$env:TEST_SELECTION_HOME') with tempfile.NamedTemporaryFile(mode='w', suffix='.tmp.ps1', dir=ROOT / 'scripts/commands', encoding='utf-8', delete=False) as stream: stream.write(source) entry = Path(stream.name) self.addCleanup(entry.unlink) result = subprocess.run([shell, '-NoProfile', '-File', str(entry), '-Quick', '-DryRun'], env=environment, stdin=subprocess.DEVNULL, capture_output=True, text=True, timeout=90) self.assertEqual(result.returncode, 0, result.stdout + result.stderr) self.assertNotIn('Select plugins', result.stdout) self.assertIn('DRYRUN ensure codex plugin: Superpowers', result.stdout) self.assertNotIn('DRYRUN ensure codex plugin: Context7', result.stdout) self.assertEqual(self.path.read_bytes(), original) self.assertFalse((self.home / '.codex').exists()) failed = subprocess.run([shell, '-NoProfile', '-File', str(entry), '-Quick'], env=environment, stdin=subprocess.DEVNULL, capture_output=True, text=True, timeout=10) self.assertNotEqual(failed.returncode, 0) self.assertEqual(self.path.read_bytes(), original) def test_bash_quick_preview_uses_saved_selection_without_writes(self): shell = shutil.which('bash') if os.name == 'nt': candidate = Path(os.environ.get('ProgramFiles', 'C:/Program Files')) / 'Git/bin/bash.exe' shell = str(candidate) if candidate.exists() else None if not shell: self.skipTest('Bash unavailable') save = subprocess.run([shell, '-c', 'set -euo pipefail; root=$1; home_path=$2; shell=powershell; client=both; flashbang=false; statusline=false; ' 'selected_plugins=(Superpowers); deselected_plugins=(); ' '. "$root/scripts/lib/selection-state.sh"; save_update_selection', 'test', ROOT.as_posix(), self.home.as_posix()], capture_output=True, text=True, timeout=10) self.assertEqual(save.returncode, 0, save.stdout + save.stderr) original = self.path.read_bytes() environment = dict(os.environ, HOME=self.home.as_posix()) result = subprocess.run([shell, str(ROOT / 'scripts/commands/update.sh'), '--quick', '--dry-run'], env=environment, stdin=subprocess.DEVNULL, capture_output=True, text=True, timeout=90) self.assertEqual(result.returncode, 0, result.stdout + result.stderr) self.assertNotIn('Select plugins', result.stdout) self.assertIn('DRYRUN ensure codex plugin: Superpowers', result.stdout) self.assertNotIn('DRYRUN ensure codex plugin: Context7', result.stdout) self.assertEqual(self.path.read_bytes(), original) self.assertFalse((self.home / '.codex').exists()) if __name__ == '__main__': unittest.main(argv=[sys.argv[0]], verbosity=1)