test_workshop_research_runner.py 4.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100
  1. """Test runner sequencing with synthetic child outputs; real mutation is tested by Node."""
  2. import contextlib
  3. import importlib.util
  4. import io
  5. import json
  6. from pathlib import Path
  7. import shutil
  8. import subprocess
  9. import sys
  10. import tempfile
  11. import unittest
  12. from unittest.mock import patch
  13. ROOT = Path(__file__).resolve().parents[1]
  14. class RunnerTests(unittest.TestCase):
  15. def run_case(self, mode):
  16. with tempfile.TemporaryDirectory(prefix='ksf-runner-test-') as temp:
  17. temp = Path(temp)
  18. package = temp / 'package'
  19. shutil.copytree(ROOT / 'docs/workshop-skill-release-20261109-r4/package', package)
  20. spec = importlib.util.spec_from_file_location('runner_fixture', package / 'run-update.py')
  21. module = importlib.util.module_from_spec(spec)
  22. spec.loader.exec_module(module)
  23. calls = []
  24. def child(cmd, **kwargs):
  25. self.assertIsInstance(cmd, list)
  26. self.assertEqual(kwargs['capture_output'], True)
  27. self.assertNotIn('shell', kwargs)
  28. step = cmd[2]
  29. calls.append(step)
  30. if mode == 'dry-failure' and step == '--dry':
  31. return subprocess.CompletedProcess(cmd, 1, b'', b'{"ok":false,"error":{"code":"UNKNOWN_TARGET_SKILL"}}')
  32. if mode == 'apply-failure' and step == '--apply':
  33. return subprocess.CompletedProcess(cmd, 1, b'', b'{"ok":false,"backup":"fixture-backup","recoveryStatus":"FAILED_RESTORED"}')
  34. payload = {'ok': True, 'readOnly': True, 'alreadyApplied': mode == 'already',
  35. 'baselineId': 'r3' if mode == 'r3' else 'target' if mode == 'already' else 'r2',
  36. 'changedFiles': 0 if mode == 'already' else 12 if mode == 'r3' else 14,
  37. 'changes': [] if mode == 'already' else [{'action': 'update'}] * (11 if mode == 'r3' else 12) + [{'action': 'create'}] * (1 if mode == 'r3' else 2),
  38. 'skillSha256': 'fixture-skill', 'manifestSha256': 'fixture-manifest'}
  39. if mode == 'bad-plan':
  40. payload['changedFiles'] = 10
  41. if step == '--apply':
  42. payload['backup'] = 'fixture-backup'
  43. if step == '--verify':
  44. self.assertEqual(cmd[-2:], ['--backup', 'fixture-backup'])
  45. payload.update(installedFilesVerified=40, changedFilesVerified=12 if mode == 'r3' else 14,
  46. runtimeAndBindingPreserved=True, researchFilesUnchanged=True,
  47. legacyEntrypointRetired=True)
  48. return subprocess.CompletedProcess(cmd, 0, json.dumps(payload).encode(), b'')
  49. output = temp / 'results'
  50. with patch.object(sys, 'argv', ['run-update.py', '--out', str(output)]), \
  51. patch.object(module.sys, 'platform', 'linux'), \
  52. patch.object(module.os, 'getuid', return_value=10000, create=True), \
  53. patch.object(module.subprocess, 'run', side_effect=child), \
  54. contextlib.redirect_stdout(io.StringIO()):
  55. exit_code = module.main()
  56. receipt = json.loads((output / 'install-receipt.json').read_text(encoding='utf-8'))
  57. for step in calls:
  58. label = step.removeprefix('--')
  59. self.assertTrue((output / (label + '.exit')).exists())
  60. self.assertTrue((output / (label + '.stderr.json')).exists())
  61. self.assertFalse(receipt['agentLoaded'])
  62. self.assertFalse(receipt['currentReadVerified'])
  63. return exit_code, calls, receipt
  64. def test_success_runs_dry_apply_once_verify_in_order(self):
  65. code, calls, receipt = self.run_case('success')
  66. self.assertEqual((code, calls), (0, ['--dry', '--apply', '--verify']))
  67. self.assertTrue(receipt['filesInstalled'])
  68. def test_r3_success_runs_once(self):
  69. code, calls, receipt = self.run_case('r3')
  70. self.assertEqual((code, calls), (0, ['--dry', '--apply', '--verify']))
  71. self.assertEqual(receipt['changedFiles'], 12)
  72. def test_dry_failure_stops_without_apply(self):
  73. code, calls, receipt = self.run_case('dry-failure')
  74. self.assertEqual((code, calls), (1, ['--dry']))
  75. self.assertFalse(receipt['filesInstalled'])
  76. def test_unexpected_plan_stops_without_apply(self):
  77. code, calls, _ = self.run_case('bad-plan')
  78. self.assertEqual((code, calls), (1, ['--dry']))
  79. def test_apply_failure_never_retries(self):
  80. code, calls, _ = self.run_case('apply-failure')
  81. self.assertEqual((code, calls), (1, ['--dry', '--apply']))
  82. def test_already_applied_does_not_write_again(self):
  83. code, calls, receipt = self.run_case('already')
  84. self.assertEqual((code, calls), (0, ['--dry']))
  85. self.assertTrue(receipt['alreadyApplied'])
  86. if __name__ == '__main__':
  87. unittest.main()