Files
marketplace/tests/test_page_tree.py
T
naudachu 1d7abc11ae fix: resolve the login pin from a git worktree
`_gitea.require_login` walked up from CWD and nowhere else. A worktree is a
sibling of the main checkout, not a descendant, and `settings.local.json` is
untracked — so the pin lives in the main checkout only, is not on the
worktree's parent chain, and the whole tracker half of the plugin died there
with "no login pinned". In the same directory the guard resolved it fine,
because it had a search of its own: one order, written twice, disagreeing.

It is written once now, in skills/auth/scripts/pin.py, and both callers import
it — the transport and hooks/tea-guard.sh. $CLAUDE_PROJECT_DIR, then a hint the
caller supplies (the hook passes its payload's cwd), then the current
directory; each searched up its parent chain, and only if that finds nothing,
across into the main working tree of a linked worktree met on the way, reached
by reading `gitdir:` out of the `.git` FILE and following `commondir`. No
subprocess — a PreToolUse hook runs before every Bash call and must not fork to
answer this.

The search still starts at the working directory and never at `__file__`,
deliberately asymmetric with `issue.store_root` and `_gitea.PAYLOAD_ROOT`.
Where an installation keeps its files is a fact about the installation; whose
login a project runs under is a fact about the project, and a plugin pointed at
somebody else's tree must not answer that from its own directory. pin.py says
so in as many words, so the next reader does not "fix" the inconsistency.

Two consequences fall out of it. `/tea:auth` no longer has any reason to run
inside a worktree, so no second pin lands in a directory that is deleted with
the branch — the skill now says to write it beside the common `.git`. And the
scripts can run where the work is: the workaround the bug forced, cwd in the
main checkout, made push.py send that checkout's branch as `ref`, which is the
one thing `branch:` exists to record.

tests/test_login_pin.py holds both halves: the hop against a hand-built layout
and against a real `git worktree add`, a run from the worktree finding the
login, no pin anywhere still erroring, the scripts' own directory not becoming
a source, `ref` coming out as the worktree's branch, and the hook and a script
answering the same directory alike. Two mechanical checks keep the callers from
growing a second copy of the walk. Three existing fixtures now copy
skills/auth/scripts, which the transport imports.

Refs #24.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-10 18:14:18 +05:00

523 lines
23 KiB
Python

#!/usr/bin/env python3
"""
How a directory of markdown becomes a page tree, and that the tree survives a
round trip through the wiki layer's bookkeeping.
python3 -m unittest discover -s tests -v
Stdlib unittest, no third-party anything. `skills/*/scripts/` are not packages,
so the modules under test are imported by path.
Nothing here touches tmp/wiki/. The subprocess cases build a throwaway
repository in a temp directory — a `.git` marker, a copy of both script layers,
a directory of fixture artifacts — and run the real scripts inside it. That is
the only honest way to test behaviour that depends on where a script is run
from, and it keeps the developer's own cache out of the blast radius.
"""
import json
import os
import shutil
import subprocess
import sys
import tempfile
import unittest
REPO = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
PAGE_SCRIPTS = os.path.join(REPO, "skills", "page", "scripts")
WIKI_SCRIPTS = os.path.join(REPO, "skills", "wiki", "scripts")
SYNC_SCRIPTS = os.path.join(REPO, "skills", "sync", "scripts")
sys.path.insert(0, PAGE_SCRIPTS)
sys.path.insert(0, WIKI_SCRIPTS)
import page # noqa: E402
import wikimap # noqa: E402
# The fixture mirrors the shape a real discussion leaves behind: numbered files
# for ordering, a `00-` file standing in for its directory, headings that no
# mechanical rule could derive from the file names.
FIXTURE = {
"handoff.md": "# handoff — notification chains\n\nEntry point.\n",
"ideas/00-intro.md": "# Ideas for chain business requirements\n\nFlat list.\n",
"ideas/02-chain-core.md": "## Chain core\n\n- **B-01.** Something.\n",
"ideas/01-relations.md": "## Relations\n\nHow they relate.\n",
"questions/00-intro.md": "# Questions\n\nOpen questions.\n",
"questions/03-q-01-do-we-know-the-participant.md":
"## Q-01. Do We Know the Chain Participant by Name\n\n**Question.** …\n",
"notes/plain.md": "No heading here, only prose.\n",
}
def build_artifacts(root):
for rel, text in FIXTURE.items():
p = os.path.join(root, rel.replace("/", os.sep))
os.makedirs(os.path.dirname(p), exist_ok=True)
with open(p, "w", encoding="utf-8") as f:
f.write(text)
return root
# --------------------------------------------------------------------------
# names, titles, order — pure
# --------------------------------------------------------------------------
class TestNames(unittest.TestCase):
def test_order_comes_from_a_numeric_prefix(self):
self.assertEqual(page.order_of("02-chain-core.md"), 2)
self.assertEqual(page.order_of("00-intro.md"), 0)
self.assertIsNone(page.order_of("handoff.md"))
def test_zero_is_an_order_and_not_a_missing_one(self):
"""`00-` means "this is the directory's own page", so the difference
between 0 and None decides where a page lands in the tree."""
self.assertIsNot(page.order_of("00-intro.md"), None)
def test_the_prefix_never_reaches_the_title(self):
self.assertEqual(page.title_from_name("02-chain-core.md"), "Chain core")
def test_only_the_first_letter_is_raised(self):
"""Title-casing would wreck every name that already knows how it is
spelled."""
self.assertEqual(page.title_from_name("sqlc-and-APNs.md"), "Sqlc and APNs")
def test_a_heading_beats_a_file_name(self):
text = "## Q-01. Do We Know the Chain Participant by Name\n"
self.assertEqual(page.title_from_body(text),
"Q-01. Do We Know the Chain Participant by Name")
def test_only_the_first_heading_counts(self):
self.assertEqual(page.title_from_body("# One\n\n## Two\n"), "One")
def test_a_heading_after_prose_is_a_section_not_a_name(self):
self.assertIsNone(page.title_from_body("Prose first.\n\n# Late\n"))
def test_markup_is_stripped_from_a_title(self):
"""A page list does not render markdown, so inline code in a heading is
noise in the name."""
self.assertEqual(page.sanitize_title("Inventory — `P-NN`"),
"Inventory — P-NN")
def test_a_slash_in_a_heading_does_not_invent_hierarchy(self):
self.assertEqual(page.sanitize_title("Send/receive timing"),
"Send-receive timing")
class TestPaths(unittest.TestCase):
def test_a_title_becomes_one_path_component_per_segment(self):
self.assertEqual(page.path_for_title("Simple Chains/Ideas/Chain core"),
os.path.join("Simple-Chains", "Ideas", "Chain-core.md"))
def test_shell_hostile_characters_leave_the_path_but_not_the_title(self):
title = "Simple Chains/Don't send to this one"
self.assertEqual(page.path_for_title(title),
os.path.join("Simple-Chains", "Dont-send-to-this-one.md"))
self.assertIn("'", title)
def test_an_empty_title_still_produces_a_file(self):
self.assertEqual(page.path_for_title(""), "untitled.md")
# --------------------------------------------------------------------------
# importing
# --------------------------------------------------------------------------
class TestPlanImport(unittest.TestCase):
def setUp(self):
self.tmp = tempfile.TemporaryDirectory()
self.src = build_artifacts(os.path.join(self.tmp.name, "artifacts"))
self.pages, self.collisions = page.plan_import(self.src, "Simple Chains")
self.titles = {p["title"] for p in self.pages}
def tearDown(self):
self.tmp.cleanup()
def test_nothing_collides(self):
self.assertEqual(self.collisions, [])
def test_an_order_zero_file_becomes_the_directorys_own_page(self):
self.assertIn("Simple Chains/Ideas", self.titles)
def test_that_page_is_named_for_the_directory_not_its_heading(self):
"""`ideas/00-intro.md` opens with "Ideas for chain business
requirements". A child's title must extend its parent's exactly, and no
child would ever be prefixed by that."""
self.assertNotIn("Simple Chains/Ideas for chain business requirements",
self.titles)
def test_every_child_extends_its_parents_title(self):
self.assertIn("Simple Chains/Ideas/Chain core", self.titles)
self.assertIn("Simple Chains/Questions/"
"Q-01. Do We Know the Chain Participant by Name",
self.titles)
def test_a_file_without_a_heading_falls_back_to_its_name(self):
self.assertIn("Simple Chains/Notes/Plain", self.titles)
def test_the_prefix_hangs_everything_under_one_title(self):
self.assertTrue(all(t.startswith("Simple Chains/") for t in self.titles))
def test_numeric_prefixes_order_siblings(self):
ideas = [p for p in self.pages
if p["title"].startswith("Simple Chains/Ideas/")]
self.assertEqual([p["title"].split("/")[-1] for p in ideas],
["Relations", "Chain core"])
def test_a_collision_is_reported_and_not_resolved(self):
"""Two headings that sanitize to one path. Picking a winner is how a
discussion loses a document."""
d = os.path.join(self.tmp.name, "clash")
os.makedirs(d)
for name, heading in (("a.md", "# Send timing"), ("b.md", "# Send/timing")):
with open(os.path.join(d, name), "w") as f:
f.write(heading + "\n")
_, collisions = page.plan_import(d)
self.assertEqual(len(collisions), 1)
# --------------------------------------------------------------------------
# the index
# --------------------------------------------------------------------------
class TestIndex(unittest.TestCase):
def setUp(self):
self.m = page.blank_manifest("s")
for title, order in (("Top", None),
("Top/Ideas", 0),
("Top/Ideas/Relations", 1),
("Top/Ideas/Chain core", 2),
("Top/Zeta", None),
("Top/Parked/Decisions", None)):
self.m["pages"][page.path_for_title(title)] = page.entry(title, order)
def test_nesting_follows_titles_not_manifest_path_order(self):
"""On disk `Top/Zeta.md` sorts before `Top/Ideas/Chain-core.md`; in the
hierarchy Zeta is a child and Chain core a grandchild."""
body = page.render_index(self.m, "Top")
lines = [l for l in body.splitlines() if l.strip().startswith("- ")
or l.strip().startswith(" - ")]
ideas = next(i for i, l in enumerate(lines) if "|Ideas]]" in l)
core = next(i for i, l in enumerate(lines) if "Chain core]]" in l)
zeta = next(i for i, l in enumerate(lines) if "|Zeta]]" in l)
self.assertLess(ideas, core)
self.assertLess(core, zeta)
def test_a_parent_with_no_page_still_holds_its_children(self):
"""Nothing is published at `Top/Parked`; dropping it would hide
Decisions entirely."""
body = page.render_index(self.m, "Top")
self.assertIn("- [[Top/Parked|Parked]]", body)
self.assertIn(" - [[Top/Parked/Decisions|Decisions]]", body)
def test_an_unpublished_page_is_linked_by_wiki_syntax(self):
self.assertIn("[[Top/Ideas|Ideas]]", page.render_index(self.m, "Top"))
def test_a_published_page_is_linked_by_its_sub_url(self):
"""sub_url is the only address Gitea guarantees, and it appears only
after a push — so rebuilding the index after publishing upgrades the
links."""
rel = page.path_for_title("Top/Ideas")
self.m["pages"][rel]["sub_url"] = "Top%2FIdeas"
self.assertIn("- [Ideas](Top%2FIdeas)", page.render_index(self.m, "Top"))
def test_the_prefix_itself_is_not_listed_inside_its_own_index(self):
self.assertNotIn("|Top]]", page.render_index(self.m, "Top"))
# --------------------------------------------------------------------------
# the manifest
# --------------------------------------------------------------------------
class TestManifest(unittest.TestCase):
def setUp(self):
self.tmp = tempfile.TemporaryDirectory()
def tearDown(self):
self.tmp.cleanup()
def test_a_missing_manifest_loads_blank(self):
m = page.load_manifest("a/b", self.tmp.name)
self.assertEqual(m["pages"], {})
def test_wiki_bookkeeping_survives_a_round_trip(self):
"""The domain never reads sub_url, and must never drop it either — a
lost sub_url is a duplicate page on the next push."""
m = page.blank_manifest("a/b")
m["pages"]["X.md"] = page.entry("X", 1, sub_url="X", pushed="deadbeef")
page.save_manifest(m, self.tmp.name)
back = page.load_manifest("a/b", self.tmp.name)
self.assertEqual(back["pages"]["X.md"]["sub_url"], "X")
self.assertEqual(back["pages"]["X.md"]["pushed"], "deadbeef")
self.assertEqual(back["pages"]["X.md"]["order"], 1)
def test_domain_keys_are_written_first(self):
"""The manifest lands in a diff on every sync; a readable one gets
checked."""
m = page.blank_manifest("a/b")
m["pages"]["X.md"] = page.entry("X", 1, sub_url="X")
with open(page.save_manifest(m, self.tmp.name), encoding="utf-8") as f:
raw = f.read()
self.assertLess(raw.index('"title"'), raw.index('"sub_url"'))
def test_children_of_is_a_prefix_test_and_not_a_substring_one(self):
m = page.blank_manifest("s")
for t in ("Top", "Top/A", "Topaz", "Topaz/B"):
m["pages"][page.path_for_title(t)] = page.entry(t)
got = {e["title"] for _, e in page.children_of(m, "Top")}
self.assertEqual(got, {"Top", "Top/A"})
# --------------------------------------------------------------------------
# md <-> wiki JSON
# --------------------------------------------------------------------------
class TestWikiMap(unittest.TestCase):
def test_a_body_survives_encode_and_decode(self):
text = "# Заголовок — DC\n\n- [x] пункт\n"
self.assertEqual(wikimap.decode({"content_base64": wikimap.encode(text)}),
text)
def test_an_empty_page_decodes_to_an_empty_string(self):
"""A page that exists with no body is a real state; the caller writing
a file should not have to tell it from a missing key."""
self.assertEqual(wikimap.decode({}), "")
self.assertEqual(wikimap.decode({"content_base64": None}), "")
def test_from_payload_takes_the_address_gitea_returned(self):
got = wikimap.from_payload({
"title": "A/B", "sub_url": "A%2FB.-", "html_url": "https://x/A%2FB.-",
"last_commit": {"sha": "abc", "author": {"date": "2026-08-10T11:15:39Z"}},
})
self.assertEqual(got["sub_url"], "A%2FB.-")
self.assertEqual(got["sha"], "abc")
self.assertEqual(got["remote-updated"], "2026-08-10T11:15:39Z")
def test_a_sub_url_goes_into_the_endpoint_verbatim(self):
"""Gitea hands it back already escaped; re-encoding it produces a path
that resolves to nothing."""
self.assertEqual(
wikimap.page_endpoint("repos/o/r", "A%2FB.-"),
"repos/o/r/wiki/page/A%2FB.-")
def test_prefix_matching_needs_a_separator(self):
self.assertTrue(wikimap.matches_prefix("Top", "Top"))
self.assertTrue(wikimap.matches_prefix("Top/A", "Top"))
self.assertFalse(wikimap.matches_prefix("Topaz", "Top"))
def test_an_empty_prefix_matches_everything(self):
self.assertTrue(wikimap.matches_prefix("anything", ""))
def test_a_payload_carries_the_operators_message(self):
p = wikimap.new_payload("A/B", "body", "why it changed")
self.assertEqual(p["message"], "why it changed")
self.assertEqual(wikimap.decode(p), "body")
# --------------------------------------------------------------------------
# the scripts, in a throwaway repository
# --------------------------------------------------------------------------
class TestImportScript(unittest.TestCase):
"""The real scripts, run as subprocesses inside a scratch repo."""
def setUp(self):
self.tmp = tempfile.TemporaryDirectory()
self.root = self.tmp.name
os.makedirs(os.path.join(self.root, ".git"))
# auth is in the list because the transport resolves the login pin
# through skills/auth/scripts/pin.py — one search order, one module.
for layer in ("page", "wiki", "sync", "auth"):
shutil.copytree(os.path.join(REPO, "skills", layer, "scripts"),
os.path.join(self.root, "skills", layer, "scripts"),
ignore=shutil.ignore_patterns("__pycache__"))
self.src = build_artifacts(os.path.join(self.root, "artifacts"))
self.scripts = os.path.join(self.root, "skills", "page", "scripts")
self.space = os.path.join(self.root, "tmp", "wiki", "s")
def tearDown(self):
self.tmp.cleanup()
def run_script(self, name, *args, cwd=None):
return subprocess.run(
[sys.executable, os.path.join(self.scripts, name)] + list(args),
capture_output=True, text=True, cwd=cwd or self.root)
def manifest(self):
with open(os.path.join(self.space, ".pages.json"), encoding="utf-8") as f:
return json.load(f)
def do_import(self, *extra):
return self.run_script("page_import.py", "--from", self.src,
"--space", "s", "--prefix", "Top", *extra)
def test_dry_run_writes_nothing(self):
r = self.do_import("--dry-run")
self.assertEqual(r.returncode, 0, r.stderr)
self.assertFalse(os.path.exists(self.space))
def test_import_writes_the_tree_and_the_manifest(self):
self.assertEqual(self.do_import().returncode, 0)
self.assertTrue(os.path.isfile(
os.path.join(self.space, "Top", "Ideas", "Chain-core.md")))
self.assertIn("Top/Ideas/Chain core",
{e["title"] for e in self.manifest()["pages"].values()})
def test_creating_a_space_is_announced(self):
"""Nothing creates a store as a silent side effect of a write — that is
how a typo in --space makes a second one nobody notices."""
self.assertIn("created space", self.do_import().stderr)
def test_the_cache_is_found_from_a_subdirectory(self):
"""The anchor is the script's own location, not cwd. A `cd` outlives
the command that ran it."""
self.do_import()
deep = os.path.join(self.src, "ideas")
r = self.run_script("page_ls.py", "--space", "s", cwd=deep)
self.assertEqual(r.returncode, 0, r.stderr)
self.assertIn("Chain core", r.stdout)
def test_a_reimport_keeps_the_title_and_the_wiki_bookkeeping(self):
self.do_import()
m = self.manifest()
rel = "Top/Ideas/Chain-core.md"
m["pages"][rel]["sub_url"] = "Top%2FIdeas%2FChain-core"
with open(os.path.join(self.space, ".pages.json"), "w") as f:
json.dump(m, f)
# The heading changes. Without the manifest that would rename a
# published page, which does not rename it — it publishes a second one.
with open(os.path.join(self.src, "ideas", "02-chain-core.md"), "w") as f:
f.write("## A completely different heading\n\nchanged\n")
self.do_import()
after = self.manifest()["pages"][rel]
self.assertEqual(after["title"], "Top/Ideas/Chain core")
self.assertEqual(after["sub_url"], "Top%2FIdeas%2FChain-core")
with open(os.path.join(self.space, rel), encoding="utf-8") as f:
self.assertIn("A completely different heading", f.read())
def test_retitle_moves_the_page_and_keeps_its_address(self):
"""A retitle changes the path, so the entry has to be found by source.
Found by path it would look new, and the next push would publish a
duplicate beside the page it was meant to rename."""
self.do_import()
m = self.manifest()
m["pages"]["Top/Ideas/Chain-core.md"]["sub_url"] = "Top%2FIdeas%2FChain-core"
m["pages"]["Top/Ideas/Chain-core.md"]["pushed"] = "deadbeef"
with open(os.path.join(self.space, ".pages.json"), "w") as f:
json.dump(m, f)
with open(os.path.join(self.src, "ideas", "02-chain-core.md"), "w") as f:
f.write("## Chain core, renamed\n")
self.do_import("--retitle")
pages = self.manifest()["pages"]
self.assertNotIn("Top/Ideas/Chain-core.md", pages)
moved = pages["Top/Ideas/Chain-core-renamed.md"]
self.assertEqual(moved["title"], "Top/Ideas/Chain core, renamed")
self.assertEqual(moved["sub_url"], "Top%2FIdeas%2FChain-core")
self.assertFalse(os.path.exists(
os.path.join(self.space, "Top", "Ideas", "Chain-core.md")))
def test_a_rename_makes_the_next_push_send_the_page(self):
"""The body can be byte-identical after a rename, and push decides by
body hash alone — so a stale `pushed` would skip the rename forever."""
self.do_import()
m = self.manifest()
rel = "Top/Ideas/Chain-core.md"
with open(os.path.join(self.space, rel), encoding="utf-8") as f:
body = f.read()
m["pages"][rel]["sub_url"] = "x"
m["pages"][rel]["pushed"] = __import__("hashlib").sha1(
body.encode()).hexdigest()
with open(os.path.join(self.space, ".pages.json"), "w") as f:
json.dump(m, f)
src = os.path.join(self.src, "ideas", "02-chain-core.md")
with open(src, encoding="utf-8") as f:
text = f.read()
with open(src, "w") as f:
f.write(text.replace("## Chain core", "## Chain core renamed"))
self.do_import("--retitle")
moved = self.manifest()["pages"]["Top/Ideas/Chain-core-renamed.md"]
self.assertNotIn("pushed", moved)
def test_ls_reports_an_unpublished_page_as_local(self):
self.do_import()
r = self.run_script("page_ls.py", "--space", "s")
self.assertIn("local", r.stdout)
self.assertNotIn("synced", r.stdout)
def test_ls_distinguishes_a_missing_space_from_an_empty_one(self):
r = self.run_script("page_ls.py", "--space", "nope")
self.assertNotEqual(r.returncode, 0)
self.assertIn("no such space", r.stderr)
def test_index_is_written_as_an_ordinary_page(self):
self.do_import()
r = self.run_script("page_index.py", "--space", "s", "--prefix", "Top")
self.assertEqual(r.returncode, 0, r.stderr)
self.assertIn("Top.md", self.manifest()["pages"])
with open(os.path.join(self.space, "Top.md"), encoding="utf-8") as f:
body = f.read()
self.assertIn("- [[Top/Ideas|Ideas]]", body)
self.assertIn(" - [[Top/Ideas/Chain core|Chain core]]", body)
# --------------------------------------------------------------------------
# the layering rule, mechanically
# --------------------------------------------------------------------------
class TestLayering(unittest.TestCase):
def test_the_page_layer_is_stdlib_only(self):
"""skills/page must keep working with skills/wiki deleted — so no
transport, and above all no subprocess, in the domain layer."""
imported = set()
for name in sorted(os.listdir(PAGE_SCRIPTS)):
if not name.endswith(".py"):
continue
with open(os.path.join(PAGE_SCRIPTS, name)) as f:
for line in f:
if line.startswith(("import ", "from ")):
imported.add(line.split()[1].split(".")[0])
foreign = imported - {"page"} - sys.stdlib_module_names
self.assertEqual(foreign, set(),
"non-stdlib import in the page layer: %s"
% ", ".join(sorted(foreign)))
self.assertNotIn("subprocess", imported)
def test_the_page_layer_never_mentions_a_tracker(self):
"""A sub_url, a login, an HTTP verb in skills/page means the concept is
in the wrong layer."""
banned = ("tea api", "_gitea", "GITEA_LOGIN", "content_base64")
for name in sorted(os.listdir(PAGE_SCRIPTS)):
if not name.endswith(".py"):
continue
with open(os.path.join(PAGE_SCRIPTS, name)) as f:
body = f.read()
for word in banned:
self.assertNotIn(word, body,
"%s mentions %r" % (name, word))
def test_wikimap_is_pure(self):
"""The translation layer holds no transport and no I/O: give it a
payload, get a page; give it a page, get a request body. Checked on the
imports, not on the prose — the docstring names the things it refuses
to do."""
with open(os.path.join(WIKI_SCRIPTS, "wikimap.py")) as f:
imported = {line.split()[1].split(".")[0] for line in f
if line.startswith(("import ", "from "))}
self.assertEqual(imported, {"base64"},
"wikimap.py imports more than the translation needs")
if __name__ == "__main__":
unittest.main()