"""Tests for plugins/run-sleep.sh engine resolution and fallback logic. Verifies that the runner: 1. Uses the source checkout when skillopt_sleep/ is present (existing path). 2. Falls back to the skillopt-sleep CLI on PATH when no source dir is found. 3. Falls back to `python -m skillopt_sleep` when the package is importable but no CLI is on PATH. 4. Errors with a helpful message when no engine can be found. Pure-stdlib (unittest). No API key, no third-party deps. Run: python3 -m pytest tests/test_run_sleep_fallback.py -v """ import os import shutil import subprocess import sys import tempfile import unittest REPO = os.path.abspath(os.path.join(os.path.dirname(__file__), "..")) RUNNER = os.path.join(REPO, "plugins", "run-sleep.sh") BUNDLED = os.path.join(REPO, "plugins", "claude-code", "scripts", "run-sleep.sh") def _run(script, args, env, cwd): """Run a sleep runner script and return (returncode, stdout, stderr).""" cmd = ["bash", script] + args proc = subprocess.run( cmd, capture_output=True, text=True, env=env, cwd=cwd, timeout=30, ) return proc.returncode, proc.stdout, proc.stderr class TestRunnerSourceCheckout(unittest.TestCase): """When skillopt_sleep/ is found in the repo, the source-checkout path is used.""" def test_source_checkout_runs_engine(self): # The repo root contains skillopt_sleep/, so running from the repo # should find it via the SCRIPT_DIR/../skillopt_sleep check. env = {k: v for k, v in os.environ.items() if k not in ("SKILLOPT_SLEEP_REPO", "CLAUDE_PLUGIN_ROOT")} rc, out, err = _run(RUNNER, ["--help"], env, REPO) self.assertEqual(rc, 0, f"stderr:\n{err}") self.assertIn("skillopt_sleep", out) self.assertIn("run", out) self.assertIn("status", out) class TestRunnerCliFallback(unittest.TestCase): """When no source dir is found, fall back to the skillopt-sleep CLI on PATH.""" def setUp(self): # Copy the runner to a temp dir so SCRIPT_DIR/../skillopt_sleep doesn't # resolve to the repo's source checkout (SCRIPT_DIR is the script's # location, not CWD, so running the repo copy always finds the source). self._tmp = tempfile.TemporaryDirectory() self._script = os.path.join(self._tmp.name, "run-sleep.sh") shutil.copy2(RUNNER, self._script) # Build a fake skillopt-sleep CLI on PATH that records its args. self._bindir = os.path.join(self._tmp.name, "bin") os.makedirs(self._bindir) self._fake_cli = os.path.join(self._bindir, "skillopt-sleep") with open(self._fake_cli, "w") as f: f.write("#!/usr/bin/env bash\n") f.write('echo "fake-cli invoked: $@"\n') os.chmod(self._fake_cli, 0o755) def tearDown(self): self._tmp.cleanup() def _env_without_source(self): """Env with no source dir resolvable and fake CLI on PATH.""" env = {k: v for k, v in os.environ.items() if k not in ("SKILLOPT_SLEEP_REPO", "CLAUDE_PLUGIN_ROOT")} # Prepend fake bin dir so our skillopt-sleep is found first. env["PATH"] = self._bindir + os.pathsep + env.get("PATH", "") return env def test_cli_fallback_used_when_no_source_dir(self): env = self._env_without_source() rc, out, err = _run(self._script, ["status", "--project", "/tmp"], env, "/tmp") self.assertEqual(rc, 0, f"stderr:\n{err}") self.assertIn("fake-cli invoked: status --project /tmp", out) def test_cli_fallback_passes_through_all_args(self): env = self._env_without_source() rc, out, err = _run( self._script, ["run", "--project", "/tmp", "--backend", "mock"], env, "/tmp", ) self.assertEqual(rc, 0, f"stderr:\n{err}") self.assertIn("run --project /tmp --backend mock", out) def test_bundled_copy_also_uses_cli_fallback(self): """The byte-identical bundled copy in plugins/claude-code/scripts/ must also fall back to the CLI — it's what the marketplace install ships.""" bundled_copy = os.path.join(self._tmp.name, "bundled-run-sleep.sh") shutil.copy2(BUNDLED, bundled_copy) env = self._env_without_source() rc, out, err = _run(bundled_copy, ["status"], env, "/tmp") self.assertEqual(rc, 0, f"stderr:\n{err}") self.assertIn("fake-cli invoked: status", out) class TestRunnerNoEngine(unittest.TestCase): """When no source dir, no CLI on PATH, and no importable package, error cleanly.""" def test_error_message_mentions_install_options(self): # Copy the runner to a temp dir so SCRIPT_DIR/../skillopt_sleep doesn't # resolve to the repo's source checkout. with tempfile.TemporaryDirectory() as tmp: script = os.path.join(tmp, "run-sleep.sh") shutil.copy2(RUNNER, script) # Build an env with a minimal PATH so skillopt-sleep isn't found, # and no source dir resolvable. env = { "PATH": "/usr/bin:/bin", "HOME": os.environ.get("HOME", "/tmp"), } # Use a Python that almost certainly doesn't have skillopt_sleep # importable (system python3). If it does, skip — we can't easily # simulate "not installed" in that case. try: subprocess.run( [sys.executable, "-c", "import skillopt_sleep"], capture_output=True, check=True, timeout=10, ) has_package = True except (subprocess.CalledProcessError, subprocess.TimeoutExpired): has_package = False if has_package: self.skipTest("skillopt_sleep is importable in this env; " "cannot test the no-engine error path") rc, out, err = _run(script, ["status"], env, "/tmp") self.assertNotEqual(rc, 0) self.assertIn("could not locate the skillopt_sleep package", err) self.assertIn("uv tool install skillopt", err) self.assertIn("pip install skillopt", err) self.assertIn("SKILLOPT_SLEEP_REPO", err) class TestRunnerBundledMatchesShared(unittest.TestCase): """The bundled copy must stay in sync with the shared runner.""" def test_bundled_equals_shared(self): with open(RUNNER) as f: shared = f.read() with open(BUNDLED) as f: bundled = f.read() self.assertEqual(shared, bundled, "plugins/claude-code/scripts/run-sleep.sh has drifted " "from plugins/run-sleep.sh — they must stay in sync") if __name__ == "__main__": unittest.main()