opencode-config/tests/test_create_readme_tool.py
Sergey 70b8203704
feat(readme): switch support link to /contacts (#270)
* feat(tool): switch create-readme support link to /contacts

* feat(scripts): warn on deprecated support link in readme check

* feat(templates): switch cookiecutter readme support links to contacts

* docs(skill): drop removed validate mode from repo-readme docs

* test(status): cover contacts link warn/fail/ok cases

* fix(ci): shorten lines to satisfy ruff

* fix(ci): apply ruff format

---------

Co-authored-by: opencode-agent <agent@opencode.local>
2026-08-05 03:54:59 +03:00

362 lines
12 KiB
Python

"""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)
<!-- tagline-en:start -->
> One tagline.
<!-- tagline-en:end -->
<!-- tagline-ru:start -->
> Короткий теглайн.
<!-- tagline-ru:end -->
[English](#-english) | [Русский](#-русский)
---
## 🇺🇸 English
<!-- summary-en:start -->
### ❓ Why
Why.
### ✅ What
What.
<!-- summary-en:end -->
<!-- features-en:start -->
### Features
| Feature | Description |
|---------|-------------|
| X Foo | desc |
<!-- features-en:end -->
### ⚡ Quick Start
```bash
pip install -r requirements.txt
```
---
## 🇷🇺 Русский
<!-- summary-ru:start -->
### ❓ Зачем
Зачем.
### ✅ Что
Что.
<!-- summary-ru:end -->
<!-- features-ru:start -->
### Фичи
| Фича | Описание |
|------|----------|
| X Фича | описание |
<!-- features-ru:end -->
### ⚡ Быстрый старт
```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"<!-- {d} -->"
assert tag in content, f"missing delimiter {tag} in generated README"
assert "<!-- summary-en:start -->" 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}"