"""Tests for .opencode/tools/create-readme.ts — the create-readme custom tool. Mirrors tests/test_create_pr_tool.py / test_commit_tool.py: exercises the tool's ``execute()`` function via ``tests/_ts_loader.mjs`` using the ``exec_stub_json`` mode (multi-arg tools). The loader is parameterized via the ``TS_FILE`` env var. These tests set ``TS_FILE=.opencode/tools/create-readme.ts``. Modes used: - ``load`` — sanity-check that the tool loads and declares repo_name, tagline_en/ru, features_en/ru, repo, file_path args. - ``exec_stub_json`` — call execute with a stubbed spawnSync to verify: (a) local create: writes README to file_path (absolute path in tmp dir), overwriting existing content — regression for issue #148 (create mode did not overwrite existing local README because file_path was resolved against the plugin process CWD, not context.worktree). (b) local create: creates a new file when none exists. (c) remote create: 2 spawnSync calls (GET sha + PUT), returns API message. (d) validation errors: missing required args, bad repo_name, latin tagline_ru. Note: the ``validate`` mode was removed in issue #243 — README validation now lives exclusively in ``project-status.py:check_readme`` (read-only oracle). These tests cover only the ``create`` mode. Regression note (issue #148): the local mode previously called ``writeFileSync(file_path, ...)`` / ``readFileSync(file_path, ...)`` with a relative ``file_path`` (default ``"README.md"``) that resolved against the plugin process CWD rather than ``context.worktree``. The fix wraps the path via ``path.resolve(context.worktree, file_path)``. These tests use an absolute ``file_path`` (in a tmp dir) so the write/read target is unambiguous; the ``worktree`` passed by the loader is ``REPO_ROOT`` (hardcoded in _ts_loader.mjs), so a relative path would resolve to REPO_ROOT/README.md and clobber the repo's own README — tests avoid that by passing absolute paths. """ import json import os import subprocess import tempfile from pathlib import Path import pytest REPO_ROOT = Path(__file__).resolve().parent.parent LOADER = REPO_ROOT / "tests" / "_ts_loader.mjs" TS_FILE = REPO_ROOT / ".opencode" / "tools" / "create-readme.ts" TS_FILE_REL = ".opencode/tools/create-readme.ts" VALID_CREATE_ARGS = { "repo_name": "test-repo", "tagline_en": "One-line tagline.", "tagline_ru": "Короткий теглайн.", "why_en": "Why this exists.", "what_en": "What it does.", "why_ru": "Зачем этот проект.", "what_ru": "Что делает.", "quick_start": "pip install -r requirements.txt", "features_en": [{"emoji": "X", "name": "Feat", "description": "desc"}], "features_ru": [{"emoji": "X", "name": "Фича", "description": "описание"}], } VALID_README = """# 🚀 test-repo ![Cover](assets/cover.png) > One tagline. > Короткий теглайн. [English](#-english) | [Русский](#-русский) --- ## 🇺🇸 English ### ❓ Why Why. ### ✅ What What. ### Features | Feature | Description | |---------|-------------| | X Foo | desc | ### ⚡ Quick Start ```bash pip install -r requirements.txt ``` --- ## 🇷🇺 Русский ### ❓ Зачем Зачем. ### ✅ Что Что. ### Фичи | Фича | Описание | |------|----------| | X Фича | описание | ### ⚡ Быстрый старт ```bash pip install -r requirements.txt ``` --- ## 💬 Support and contacts / Поддержка и контакты 👉 **[slaid098.dev/contacts](https://slaid098.dev/contacts)** """ DELIMITERS = [ "tagline-en:start", "tagline-en:end", "tagline-ru:start", "tagline-ru:end", "summary-en:start", "summary-en:end", "features-en:start", "features-en:end", "summary-ru:start", "summary-ru:end", "features-ru:start", "features-ru:end", ] def _run_loader(*args: str) -> dict: """Invoke the loader with TS_FILE env set to create-readme.ts and parse JSON stdout.""" env = {**os.environ, "TS_FILE": TS_FILE_REL} proc = subprocess.run( ["node", str(LOADER), *args], capture_output=True, text=True, check=False, cwd=str(REPO_ROOT), timeout=60, env=env, ) if proc.returncode != 0: raise RuntimeError( f"_ts_loader.mjs {' '.join(args)} failed (exit {proc.returncode}):\n" f"stdout: {proc.stdout}\nstderr: {proc.stderr}" ) return json.loads(proc.stdout) def _run_exec(args: dict, responses: list[dict]) -> dict: """Helper: exec_stub_json mode with JSON args + sequential stub responses.""" return _run_loader("exec_stub_json", json.dumps(args), json.dumps(responses)) def _valid_args(file_path: str) -> dict: """Return a copy of VALID_CREATE_ARGS with file_path set (local mode).""" return {**VALID_CREATE_ARGS, "file_path": file_path} def test_loader_can_load_tool(): """Sanity: create-readme.ts loads and declares the expected arguments.""" if not TS_FILE.exists(): pytest.skip("create-readme.ts not present") out = _run_loader("load") assert "description" in out args = out["args"] for key in ( "repo_name", "tagline_en", "tagline_ru", "why_en", "what_en", "why_ru", "what_ru", "features_en", "features_ru", "repo", "file_path", ): assert key in args, f"missing {key} arg: {args}" assert "mode" not in args, f"mode arg should be removed (validate mode dropped in #243): {args}" def test_local_create_overwrites_existing_readme(): """Regression #148: local create overwrites an existing README at file_path. A pre-existing README (non-empty, old content) must be replaced with the generated content: mtime updates, delimiter tags present, old content gone. """ with tempfile.TemporaryDirectory() as tmp: readme = Path(tmp) / "README.md" readme.write_text("# OLD CONTENT\nshould be overwritten\n", encoding="utf-8") mtime_before = readme.stat().st_mtime_ns out = _run_exec(_valid_args(str(readme)), []) result = out["result"] assert "created at" in result, f"expected success, got: {result!r}" mtime_after = readme.stat().st_mtime_ns assert mtime_after > mtime_before, ( f"mtime did not advance: before={mtime_before} after={mtime_after}" ) content = readme.read_text(encoding="utf-8") assert "# OLD CONTENT" not in content, "old content survived — overwrite failed" for d in DELIMITERS: tag = f"" assert tag in content, f"missing delimiter {tag} in generated README" assert "" in content def test_local_create_creates_new_readme(): """Local create writes a new README when file_path does not exist yet.""" with tempfile.TemporaryDirectory() as tmp: readme = Path(tmp) / "README.md" assert not readme.exists() out = _run_exec(_valid_args(str(readme)), []) result = out["result"] assert "created at" in result, f"expected success, got: {result!r}" assert readme.exists(), "README was not created" content = readme.read_text(encoding="utf-8") assert "# 🚀 test-repo" in content assert "assets/cover.png" in content def test_local_create_custom_subdir_file_path(): """Local create writes to a nested file_path (subdir must be auto-created?). Note: writeFileSync does NOT create parent dirs. This test confirms the tool writes successfully when the parent directory exists (tmp subdir). """ with tempfile.TemporaryDirectory() as tmp: subdir = Path(tmp) / "docs" subdir.mkdir() readme = subdir / "README.md" out = _run_exec(_valid_args(str(readme)), []) result = out["result"] assert "created at" in result, f"expected success, got: {result!r}" assert readme.exists() def test_remote_create_makes_two_spawnsync_calls(): """Remote create (args.repo set) makes GET (sha) + PUT via gh api. Regression guard: the remote path must not be broken by the local-mode path.resolve fix. Stub returns sha on GET, {} on PUT. """ args = {**VALID_CREATE_ARGS, "repo": "slaid098/test-repo"} responses = [ {"status": 0, "stdout": json.dumps({"sha": "abc123"}), "stderr": ""}, {"status": 0, "stdout": "{}", "stderr": ""}, ] out = _run_exec(args, responses) result = out["result"] assert "updated in slaid098/test-repo via GitHub API" in result, ( f"expected remote success, got: {result!r}" ) calls = out["calls"] assert len(calls) == 2, f"expected 2 gh api calls, got {len(calls)}" assert calls[0]["args"][1] == "repos/slaid098/test-repo/contents/README.md" assert calls[1]["args"][2] == "PUT" def test_remote_create_passes_worktree_cwd(): """Remote create spawnSync opts carry cwd=context.worktree (ADR-023).""" args = {**VALID_CREATE_ARGS, "repo": "slaid098/test-repo"} responses = [ {"status": 0, "stdout": json.dumps({"sha": "abc123"}), "stderr": ""}, {"status": 0, "stdout": "{}", "stderr": ""}, ] out = _run_exec(args, responses) for call in out["calls"]: opts = call["opts"] assert opts is not None, "spawnSync called without opts — expected cwd" assert "cwd" in opts, f"opts missing cwd key: {opts}" assert opts["cwd"] == str(REPO_ROOT), ( f"cwd must equal context.worktree ({REPO_ROOT}), got {opts['cwd']!r}" ) def test_create_missing_required_repo_name(): """create without repo_name → error mentioning required.""" args = {**VALID_CREATE_ARGS} del args["repo_name"] with tempfile.TemporaryDirectory() as tmp: args["file_path"] = str(Path(tmp) / "README.md") out = _run_exec(args, []) result = out["result"] assert "repo_name is required" in result, f"expected required error, got: {result!r}" def test_create_missing_required_tagline_ru(): """create without tagline_ru → error mentioning required.""" args = {**VALID_CREATE_ARGS} del args["tagline_ru"] with tempfile.TemporaryDirectory() as tmp: args["file_path"] = str(Path(tmp) / "README.md") out = _run_exec(args, []) result = out["result"] assert "tagline_ru is required" in result, f"expected required error, got: {result!r}" def test_create_bad_repo_name_uppercase(): """create with uppercase repo_name → kebab-case validation error.""" with tempfile.TemporaryDirectory() as tmp: args = { **VALID_CREATE_ARGS, "repo_name": "TestRepo", "file_path": str(Path(tmp) / "README.md"), } out = _run_exec(args, []) result = out["result"] assert "kebab-case" in result, f"expected kebab-case error, got: {result!r}" def test_create_latin_tagline_ru(): """create with latin-only tagline_ru → Cyrillic requirement error.""" with tempfile.TemporaryDirectory() as tmp: args = { **VALID_CREATE_ARGS, "tagline_ru": "latin only", "file_path": str(Path(tmp) / "README.md"), } out = _run_exec(args, []) result = out["result"] assert "Cyrillic" in result, f"expected Cyrillic error, got: {result!r}" def test_create_cyrillic_tagline_en(): """create with Cyrillic tagline_en → English-only requirement error.""" with tempfile.TemporaryDirectory() as tmp: args = { **VALID_CREATE_ARGS, "tagline_en": "кириллица тут", "file_path": str(Path(tmp) / "README.md"), } out = _run_exec(args, []) result = out["result"] assert "no Cyrillic" in result, f"expected no-Cyrillic error, got: {result!r}" def test_create_missing_features_en(): """create with empty features_en → error mentioning required.""" with tempfile.TemporaryDirectory() as tmp: args = {**VALID_CREATE_ARGS, "features_en": [], "file_path": str(Path(tmp) / "README.md")} out = _run_exec(args, []) result = out["result"] assert "features_en is required" in result, f"expected required error, got: {result!r}"