* feat(infra): AST route imports check in project-status * feat(infra): check_tests structure + AST stub-detector * feat(infra): check_readme full port from validateReadme * refactor(infra): drop validate mode from create-readme tool --------- Co-authored-by: opencode-agent <agent@opencode.local>
362 lines
12 KiB
Python
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
|
|
|
|

|
|
|
|
<!-- 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/support](https://slaid098.dev/support)**
|
|
"""
|
|
|
|
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}"
|