Skip to content

Commit d3f3dfe

Browse files
committed
fix(publication): reject failed Mermaid outputs
1 parent af026cd commit d3f3dfe

2 files changed

Lines changed: 97 additions & 10 deletions

File tree

tests/test_workflows.py

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,23 @@
7878
'''
7979

8080

81+
FAKE_MMDC = r'''#!/usr/bin/env python3
82+
import os, pathlib, sys
83+
args = sys.argv[1:]
84+
output = pathlib.Path(args[args.index("-o") + 1])
85+
attempt_file = pathlib.Path(os.environ["MMDC_ATTEMPT_FILE"])
86+
attempt = int(attempt_file.read_text()) if attempt_file.exists() else 0
87+
attempt_file.write_text(str(attempt + 1), encoding="utf-8")
88+
output.write_text(
89+
f'<svg id="my-svg"><text>candidate-{attempt + 1}</text></svg>',
90+
encoding="utf-8",
91+
)
92+
if attempt < int(os.environ["MMDC_FAIL_ATTEMPTS"]):
93+
print(f"fatal renderer attempt {attempt + 1}", file=sys.stderr)
94+
raise SystemExit(1)
95+
'''
96+
97+
8198
def step_script(text: str, name: str) -> str:
8299
marker = f" - name: {name}\n"
83100
start = text.index(marker) + len(marker)
@@ -92,6 +109,54 @@ def text(self, name: str) -> str:
92109
self.assertTrue(path.is_file(), path)
93110
return path.read_text(encoding="utf-8")
94111

112+
def run_renderer(self, failed_attempts: int):
113+
with tempfile.TemporaryDirectory() as directory:
114+
root = Path(directory)
115+
book = root / "book"
116+
binary = root / "bin"
117+
output = root / "svg"
118+
book.mkdir()
119+
binary.mkdir()
120+
(book / "SUMMARY.md").write_text("* [A](a.md)\n", encoding="utf-8")
121+
(book / "a.md").write_text(
122+
"# A\n\n```mermaid\ngraph TD\nA-->B\n```\n", encoding="utf-8"
123+
)
124+
chrome = binary / "chrome"
125+
chrome.write_text("", encoding="utf-8")
126+
chrome.chmod(0o755)
127+
mmdc = binary / "mmdc"
128+
mmdc.write_text(FAKE_MMDC, encoding="utf-8")
129+
mmdc.chmod(0o755)
130+
attempt_file = root / "attempt"
131+
env = os.environ.copy()
132+
env.update(
133+
{
134+
"CHROME_BIN": str(chrome),
135+
"MMDC_ATTEMPT_FILE": str(attempt_file),
136+
"MMDC_FAIL_ATTEMPTS": str(failed_attempts),
137+
"PATH": f"{binary}{os.pathsep}{env.get('PATH', '')}",
138+
}
139+
)
140+
result = subprocess.run(
141+
[
142+
"python3",
143+
str(ROOT / "tools" / "render_mermaid.py"),
144+
"--book-dir",
145+
str(book),
146+
"--svg-out",
147+
str(output),
148+
"--require-all",
149+
],
150+
cwd=ROOT,
151+
env=env,
152+
text=True,
153+
capture_output=True,
154+
check=False,
155+
)
156+
attempts = int(attempt_file.read_text())
157+
rendered = (output / "d-1.svg").read_text() if (output / "d-1.svg").is_file() else None
158+
return result, attempts, rendered
159+
95160
def test_all_actions_are_full_sha_pinned_with_version_comments(self):
96161
failures = []
97162
for path in WORKFLOWS:
@@ -161,6 +226,24 @@ def test_renderer_does_not_kill_unrelated_processes_and_can_fail_closed(self):
161226
for name in ("ci.yaml", "auto-release.yml", "preview-pdf.yml"):
162227
self.assertIn("--require-all", self.text(name), name)
163228

229+
def test_renderer_rejects_outputs_from_every_failed_command(self):
230+
result, attempts, rendered = self.run_renderer(failed_attempts=99)
231+
232+
self.assertNotEqual(result.returncode, 0)
233+
self.assertEqual(attempts, 5)
234+
self.assertIsNone(rendered)
235+
self.assertIn("fatal renderer attempt 5", result.stderr)
236+
self.assertIn("Mermaid rendering failed", result.stderr)
237+
238+
def test_renderer_promotes_only_a_successful_retry(self):
239+
result, attempts, rendered = self.run_renderer(failed_attempts=1)
240+
241+
self.assertEqual(result.returncode, 0, result.stderr)
242+
self.assertEqual(attempts, 2)
243+
self.assertIn("candidate-2", rendered)
244+
self.assertNotIn("candidate-1", rendered)
245+
self.assertIn("fatal renderer attempt 1", result.stderr)
246+
164247
def test_preview_release_notes_preserve_markdown_literals(self):
165248
script = step_script(self.text("preview-pdf.yml"), "Write release notes")
166249
with tempfile.TemporaryDirectory() as directory:

tools/render_mermaid.py

Lines changed: 14 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -172,12 +172,13 @@ def render(indices: list[int]) -> None:
172172
)
173173
if result.returncode != 0:
174174
print(result.stderr.strip() or result.stdout.strip(), file=sys.stderr)
175-
for rendered_index, source_index in enumerate(indices, 1):
176-
candidate = svg_path / f"_c-{rendered_index}.svg"
177-
if len(indices) == 1 and not candidate.is_file():
178-
candidate = svg_path / "_c.svg"
179-
if candidate.is_file() and candidate.stat().st_size > 0:
180-
candidate.replace(svg_path / f"d-{source_index + 1}.svg")
175+
else:
176+
for rendered_index, source_index in enumerate(indices, 1):
177+
candidate = svg_path / f"_c-{rendered_index}.svg"
178+
if len(indices) == 1 and not candidate.is_file():
179+
candidate = svg_path / "_c.svg"
180+
if candidate.is_file() and candidate.stat().st_size > 0:
181+
candidate.replace(svg_path / f"d-{source_index + 1}.svg")
181182
for stale in glob.glob(str(svg_path / "_c*.svg")):
182183
Path(stale).unlink()
183184

@@ -186,13 +187,16 @@ def render(indices: list[int]) -> None:
186187
render(list(range(start, min(start + args.chunk, total))))
187188
print(f" chunk {chunk_index}: {rendered_count()}/{total}", flush=True)
188189

189-
for attempt in range(4):
190+
for attempt, retry_batch_size in enumerate((8, 4, 2, 1), 1):
190191
missing = [index for index in range(total) if not (svg_path / f"d-{index + 1}.svg").is_file()]
191192
if not missing:
192193
break
193-
print(f" retry {attempt + 1}: {len(missing)} missing", flush=True)
194-
for start in range(0, len(missing), 8):
195-
render(missing[start:start + 8])
194+
print(
195+
f" retry {attempt} (batch={retry_batch_size}): {len(missing)} missing",
196+
flush=True,
197+
)
198+
for start in range(0, len(missing), retry_batch_size):
199+
render(missing[start:start + retry_batch_size])
196200

197201
for temporary in (puppeteer_config, render_config, svg_path / "_chunk.md"):
198202
temporary.unlink(missing_ok=True)

0 commit comments

Comments
 (0)