← refacto-feature-scout
"""Setting up a project, driven the way a person would drive it."""
import json
import subprocess
import pytest
from scout import init as initmod
from scout.claude_runner import ClaudeResult
from scout.init import run_init
from scout.llm import Access, NoLLMAccess
from scout.projects import PROJECT_FILE, load_projects
@pytest.fixture(autouse=True)
def their_own_claude_is_fine(monkeypatch):
monkeypatch.setattr(initmod, "require_llm_access",
lambda *a, **k: Access("subscription", "your Claude login on this computer"))
def scripted(answers: list[str]):
"""Answers in order; anything past the end means 'just take the suggestion'."""
left = list(answers)
def ask(question: str, suggested: str = "") -> str:
return (left.pop(0) if left else "") or suggested
return ask
def a_project(tmp_path):
folder = tmp_path / "mealplan"
folder.mkdir()
(folder / "package.json").write_text(json.dumps({"scripts": {"test": "vitest run"}}))
(folder / "README.md").write_text("# Meal Planner")
subprocess.run(["git", "init", "-q"], cwd=folder, check=True)
return folder
def test_a_person_can_finish_setup_by_pressing_return(tmp_path, monkeypatch):
folder = a_project(tmp_path)
monkeypatch.setenv("SCOUT_PROJECTS_FILE", str(tmp_path / "projects.json"))
said: list[str] = []
project = run_init(
ask=scripted([str(folder), "Meal Planner", "Turns a list into meals.",
"Home cooks.", "home cooks", "", "mealprep, cooking", "meal planner app"]),
say=said.append, folder=folder,
tester=lambda f, c: (True, "ok"),
use_ai=False,
)
settings = json.loads((folder / PROJECT_FILE).read_text())
assert settings["name"] == "Meal Planner"
assert settings["test_command"] == "npm test" # detected, not typed
assert settings["install_hint"] == "npm ci"
assert settings["sources"]["urls"] == []
assert settings["sources"]["reddit"] == [
"https://www.reddit.com/r/mealprep/new/.rss",
"https://www.reddit.com/r/mealprep/top/.rss?t=week",
"https://www.reddit.com/r/cooking/new/.rss",
"https://www.reddit.com/r/cooking/top/.rss?t=week",
]
assert settings["spend_limit_usd"] == 20.0
assert settings["deploy_command"] is None # nothing goes live unless asked
assert project.key == "meal-planner"
assert "meal-planner" in load_projects(tmp_path / "projects.json")
assert any("feature-scout review" in line for line in said)
def test_community_names_can_be_typed_any_way_a_person_types_them(tmp_path, monkeypatch):
folder = a_project(tmp_path)
monkeypatch.setenv("SCOUT_PROJECTS_FILE", str(tmp_path / "projects.json"))
run_init(ask=scripted([str(folder), "App", "does", "people", "people", "",
"r/one, /r/two, https://example.com/feed.rss"]),
say=lambda m: None, folder=folder, tester=lambda f, c: (True, "ok"), use_ai=False)
feeds = json.loads((folder / PROJECT_FILE).read_text())["sources"]["reddit"]
assert "https://www.reddit.com/r/one/new/.rss" in feeds
assert "https://www.reddit.com/r/two/new/.rss" in feeds
assert "https://example.com/feed.rss" in feeds
def test_a_project_with_no_tests_is_told_what_that_means(tmp_path, monkeypatch):
folder = tmp_path / "plain"
folder.mkdir()
subprocess.run(["git", "init", "-q"], cwd=folder, check=True)
monkeypatch.setenv("SCOUT_PROJECTS_FILE", str(tmp_path / "projects.json"))
said: list[str] = []
run_init(ask=scripted([str(folder), "Plain", "does", "people", "people", "", "", ""]),
say=said.append, folder=folder, use_ai=False)
assert json.loads((folder / PROJECT_FILE).read_text())["test_command"] == ""
assert any("can't check them" in line for line in said)
def test_tests_that_do_not_pass_right_now_are_reported_before_anything_relies_on_them(tmp_path, monkeypatch):
folder = a_project(tmp_path)
monkeypatch.setenv("SCOUT_PROJECTS_FILE", str(tmp_path / "projects.json"))
said: list[str] = []
run_init(ask=scripted([str(folder), "App", "does", "people", "people", "", "", "",
"npm test", "no"]),
say=said.append, folder=folder,
tester=lambda f, c: (False, "3 failing"), use_ai=False)
assert any("didn't pass right now" in line for line in said)
assert json.loads((folder / PROJECT_FILE).read_text())["test_command"] == ""
def test_putting_things_live_is_never_turned_on_without_being_asked(tmp_path, monkeypatch):
folder = a_project(tmp_path)
(folder / "fly.toml").write_text("app = 'x'")
monkeypatch.setenv("SCOUT_PROJECTS_FILE", str(tmp_path / "projects.json"))
run_init(ask=scripted([str(folder), "App", "does", "people", "people", "", "", ""]),
say=lambda m: None, folder=folder, tester=lambda f, c: (True, "ok"), use_ai=False)
assert json.loads((folder / PROJECT_FILE).read_text())["deploy_command"] is None
run_init(ask=scripted([str(folder), "App", "does", "people", "people", "", "", "",
"npm test", "yes"]),
say=lambda m: None, folder=folder, tester=lambda f, c: (True, "ok"), use_ai=False)
assert json.loads((folder / PROJECT_FILE).read_text())["deploy_command"] == "fly deploy --remote-only"
def test_a_folder_not_saved_with_git_is_offered_a_starting_point(tmp_path, monkeypatch):
folder = tmp_path / "loose"
folder.mkdir()
(folder / "app.py").write_text("print('hi')")
monkeypatch.setenv("SCOUT_PROJECTS_FILE", str(tmp_path / "projects.json"))
run_init(ask=scripted([str(folder), "yes", "Loose", "does", "people", "people", "", "", ""]), # 2nd = set up git
say=lambda m: None, folder=folder, use_ai=False)
assert (folder / ".git").exists()
def test_reading_the_project_fills_the_answers_in(tmp_path, monkeypatch):
folder = a_project(tmp_path)
monkeypatch.setenv("SCOUT_PROJECTS_FILE", str(tmp_path / "projects.json"))
drafted = {"name": "Meal Planner", "what_it_is": "It plans meals.", "who_uses_it": "Cooks.",
"audience": "home cooks", "topic": ", a meal planning app",
"communities": ["mealprep"], "search_terms": ["meal planning app"]}
def runner(prompt, **kw):
assert kw["allowed_tools"] == ["Read", "Glob", "Grep"] # reading only
return ClaudeResult(ok=True, text=json.dumps(drafted))
run_init(ask=scripted([str(folder), "yes"]), say=lambda m: None, folder=folder,
runner=runner, tester=lambda f, c: (True, "ok"))
settings = json.loads((folder / PROJECT_FILE).read_text())
assert settings["what_it_is"] == "It plans meals." and settings["audience"] == "home cooks"
assert settings["topic"] == ", a meal planning app"
assert settings["sources"]["reddit"][0] == "https://www.reddit.com/r/mealprep/new/.rss"
def test_nothing_is_asked_of_someone_who_has_no_claude_account(tmp_path, monkeypatch):
folder = a_project(tmp_path)
monkeypatch.setattr(initmod, "require_llm_access",
lambda *a, **k: (_ for _ in ()).throw(NoLLMAccess("no account")))
asked = []
with pytest.raises(NoLLMAccess):
run_init(ask=lambda q, s="": asked.append(q) or s, say=lambda m: None, folder=folder,
use_ai=False)
assert asked == []