From ce7a44e0a1e62119040ccac63289e6464806a82f Mon Sep 17 00:00:00 2001 From: Aman Mittal Date: Sun, 25 Oct 2026 13:26:45 +0530 Subject: [PATCH 1/2] rebuild /docs/1.16.0 and /docs/1.17.0-SNAPSHOT with Graphviz Both pages were built without Graphviz installed. `./gradlew asciidoctor` still succeeded, but PlantUML drew a "Cannot find Graphviz" error image in place of 8 diagrams per page (7 SVG, 1 PNG), among them the release process diagram at /docs/stable/#_release_process. Rebuilt from the same backend commits as before: - 1.16.0 from https://github.com/apache/fineract/commit/6da8a989b398cb46537e91d8fe8769655ea1810d (the 1.16.0 tag), with `fineract-site docs --replace-release` - 1.17.0-SNAPSHOT from https://github.com/apache/fineract/commit/f1723d724ec017a54c622c62c6e80607b7fad034 Only those 8 diagrams and the build timestamps change. --- docs/1.16.0/index.html | 20 ++++++++++---------- docs/1.17.0-SNAPSHOT/index.html | 20 ++++++++++---------- 2 files changed, 20 insertions(+), 20 deletions(-) diff --git a/docs/1.16.0/index.html b/docs/1.16.0/index.html index b3c005c..94c9393 100644 --- a/docs/1.16.0/index.html +++ b/docs/1.16.0/index.html @@ -927,7 +927,7 @@

Preface

Version: 1.16.0

-

Built on: Sat Oct 24 21:56:23 IST 2026

+

Built on: Sun Oct 25 13:19:31 IST 2026

Note the version in the URL path:

@@ -2715,7 +2715,7 @@
-Diagram +Diagram
@@ -46929,7 +46929,7 @@

Core Componen

-Diagram +Diagram
Figure 7. Command Processing Architecture Level 1 - Core
@@ -46947,7 +46947,7 @@

Dispatcher

-Diagram +Diagram
Figure 8. Command Processing Architecture Level 2 - Dispatcher
@@ -47033,7 +47033,7 @@

Command Handl
-Diagram +Diagram
Figure 9. Command Processing Architecture Level 2 - Handlers
@@ -47089,7 +47089,7 @@

Command Hooks
-Diagram +Diagram
Figure 10. Command Processing Architecture Level 2 - Hooks
@@ -47173,7 +47173,7 @@

Command Store
-Diagram +Diagram
Figure 11. Command Processing Architecture Level 2 - Store
@@ -66052,7 +66052,7 @@

Data Model

-Diagram +Diagram
Figure 14. Entity-Relationship — action tables reused by Disable / Enable
@@ -79035,7 +79035,7 @@

Release Proce
-Diagram +Diagram
Figure 19. Release Process Diagram
@@ -81227,7 +81227,7 @@

App diff --git a/docs/1.17.0-SNAPSHOT/index.html b/docs/1.17.0-SNAPSHOT/index.html index 9973239..63378f6 100644 --- a/docs/1.17.0-SNAPSHOT/index.html +++ b/docs/1.17.0-SNAPSHOT/index.html @@ -928,7 +928,7 @@

Preface

Version: 1.17.0-SNAPSHOT

-

Built on: Sat Oct 24 22:03:54 IST 2026

+

Built on: Sun Oct 25 13:21:51 IST 2026

Note the version in the URL path:

@@ -2716,7 +2716,7 @@
-Diagram +Diagram
-Diagram +Diagram
Figure 7. Command Processing Architecture Level 1 - Core
@@ -46963,7 +46963,7 @@

Dispatcher

-Diagram +Diagram
Figure 8. Command Processing Architecture Level 2 - Dispatcher
@@ -47049,7 +47049,7 @@

Command Handl
-Diagram +Diagram
Figure 9. Command Processing Architecture Level 2 - Handlers
@@ -47105,7 +47105,7 @@

Command Hooks
-Diagram +Diagram
Figure 10. Command Processing Architecture Level 2 - Hooks
@@ -47189,7 +47189,7 @@

Command Store
-Diagram +Diagram
Figure 11. Command Processing Architecture Level 2 - Store
@@ -66068,7 +66068,7 @@

Data Model

-Diagram +Diagram
Figure 14. Entity-Relationship — action tables reused by Disable / Enable
@@ -79291,7 +79291,7 @@

Release Proce
-Diagram +Diagram
Figure 19. Release Process Diagram
@@ -81484,7 +81484,7 @@

App From b541d10c9c9303fe8d825cfc3d52c94bceca0c1c Mon Sep 17 00:00:00 2001 From: Aman Mittal Date: Sun, 25 Oct 2026 13:26:45 +0530 Subject: [PATCH 2/2] fail the site build on diagrams rendered as PlantUML errors Add scripts/check_doc_diagrams.py, run by `fineract-site build` and so by site-pr-check.yml. It decodes every diagram embedded in docs/*/index.html and fails on PlantUML error images: SVGs by their error text, and PlantUML PNGs, whose metadata is the same either way, by the #33FF02 green PlantUML draws that text in. `fineract-site docs` now refuses to copy a page with such diagrams, and gains --replace-release to republish a released version from its tag. The check also found a broken PNG in docs/1.13.0, published that way in October 2025. 1.13.0 is EOL, so it is recorded in KNOWN_BROKEN with its exact count rather than rebuilt; any other broken diagram still fails. Tests (scripts/test_check_doc_diagrams.py, run by `fineract-site test`) use the broken and rebuilt batch-jobs PNGs from docs/1.16.0 as fixtures. --- .gitignore | 3 + README.md | 11 +- scripts/check_doc_diagrams.py | 196 +++++++++++++++++++ scripts/generate_docs.py | 21 +- scripts/site-tool.sh | 4 +- scripts/test_check_doc_diagrams.py | 182 +++++++++++++++++ scripts/testdata/plantuml-batch-jobs.png | Bin 0 -> 20343 bytes scripts/testdata/plantuml-graphviz-error.png | Bin 0 -> 8135 bytes 8 files changed, 413 insertions(+), 4 deletions(-) create mode 100644 scripts/check_doc_diagrams.py create mode 100644 scripts/test_check_doc_diagrams.py create mode 100644 scripts/testdata/plantuml-batch-jobs.png create mode 100644 scripts/testdata/plantuml-graphviz-error.png diff --git a/.gitignore b/.gitignore index cd9ceee..598a2f5 100644 --- a/.gitignore +++ b/.gitignore @@ -12,3 +12,6 @@ site-src/.hugo_build.lock # Generated commit message for docs/VERSION updates (scripts/generate_docs.py) /DOCS-LOG-MESSAGE + +# Python bytecode from running the scripts/ unit tests +__pycache__/ diff --git a/README.md b/README.md index 0f24691..ec416e1 100644 --- a/README.md +++ b/README.md @@ -74,6 +74,14 @@ backend. To refresh it: Make sure the clone's working tree is clean (`git status`) -- the commit hash is recorded so the copied doc can be traced back to the exact source commit. + Install Graphviz (`dot`), plus `vl2vg` and `vg2svg` (`npm install -g vega-cli + vega-lite`), first. Without them the build still succeeds but leaves + diagrams out: PlantUML draws a "Cannot find Graphviz" error image in place of + each of its diagrams. `fineract-site docs` refuses such a page, and + `fineract-site build` fails on any already in `docs/` + (`scripts/check_doc_diagrams.py`). After installing Graphviz, delete + `fineract-doc/build` before rebuilding, as generated diagrams are cached. + 2. Run the `docs` command in the site tool image, mounting both repos: ```bash @@ -91,7 +99,8 @@ This copies `fineract-doc/build/docs/html/en/index.html` from the backend clone into `docs/VERSION/index.html`, then rewrites the Google Fonts and Font Awesome CDN links to point at the site's local `css/` stylesheets (per Apache project website requirements). Versions ending in `-SNAPSHOT` are overwritten; released -versions are not. +versions are not, unless you pass `--replace-release` to republish one from the +same release tag (e.g. to fix a broken build). It also suggests a commit message. Use it like so: diff --git a/scripts/check_doc_diagrams.py b/scripts/check_doc_diagrams.py new file mode 100644 index 0000000..0f67af8 --- /dev/null +++ b/scripts/check_doc_diagrams.py @@ -0,0 +1,196 @@ +#!/usr/bin/env python3 +# +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. +# +"""Fail on diagrams that PlantUML rendered as an error image. + +When Graphviz is missing, `./gradlew asciidoctor` still succeeds: PlantUML +draws "Cannot find Graphviz" in place of each diagram, and the docs embed that +image as base64. That is how docs/1.16.0 and docs/1.17.0-SNAPSHOT were first +published. This decodes every embedded diagram: SVGs are searched for +PlantUML's error text, and PlantUML PNGs, whose metadata is the same whether or +not they rendered, for the green PlantUML draws that text in. +""" + +import argparse +import base64 +import binascii +import re +import struct +import sys +import zlib +from pathlib import Path +from typing import List, Optional + +EMBEDDED_IMAGE = re.compile( + r"""src=["']data:image/(svg\+xml|png);base64,([A-Za-z0-9+/=]+)["']""" +) + +# Text PlantUML draws into its error images. +ERROR_MARKERS = ( + "Cannot find Graphviz", + "Dot Executable:", + "Syntax Error?", +) + +PNG_SIGNATURE = b"\x89PNG\r\n\x1a\n" +PLANTUML_PNG_TAG = b"Generated by http://plantuml.com" +# The colour of PlantUML's error text (#33FF02). No working diagram in +# docs/1.12.1 to docs/1.15.0 uses it. +ERROR_GREEN = b"\x33\xff\x02" +PNG_ERROR_MARKER = "PlantUML error image (PNG)" + + +def png_chunks(png: bytes): + """Yield (type, data) for each chunk of a PNG.""" + pos = len(PNG_SIGNATURE) + while pos + 8 <= len(png): + length, kind = struct.unpack(">I4s", png[pos : pos + 8]) + yield kind, png[pos + 8 : pos + 8 + length] + pos += 12 + length + + +def paeth(left: int, up: int, up_left: int) -> int: + p = left + up - up_left + pa, pb, pc = abs(p - left), abs(p - up), abs(p - up_left) + if pa <= pb and pa <= pc: + return left + if pb <= pc: + return up + return up_left + + +def unfilter_row(kind: int, row: bytearray, previous: bytearray, bpp: int) -> None: + """Undo a PNG row filter in place (https://www.w3.org/TR/png/#9Filters).""" + for x in range(len(row)): + left = row[x - bpp] if x >= bpp else 0 + up = previous[x] + if kind == 1: + predictor = left + elif kind == 2: + predictor = up + elif kind == 3: + predictor = (left + up) // 2 + elif kind == 4: + predictor = paeth(left, up, previous[x - bpp] if x >= bpp else 0) + else: + return + row[x] = (row[x] + predictor) & 0xFF + + +def png_rows(png: bytes) -> Optional[List[bytes]]: + """RGB pixel rows of an 8-bit, non-interlaced RGB or RGBA PNG, else None.""" + if not png.startswith(PNG_SIGNATURE): + return None + chunks = list(png_chunks(png)) + header = next((data for kind, data in chunks if kind == b"IHDR"), None) + if header is None: + return None + width, height, bit_depth, colour_type, _, _, interlace = struct.unpack(">IIBBBBB", header) + if bit_depth != 8 or colour_type not in (2, 6) or interlace != 0: + return None + bpp = 3 if colour_type == 2 else 4 + stride = width * bpp + raw = zlib.decompress(b"".join(data for kind, data in chunks if kind == b"IDAT")) + rows, previous = [], bytearray(stride) + for y in range(height): + start = y * (stride + 1) + row = bytearray(raw[start + 1 : start + 1 + stride]) + unfilter_row(raw[start], row, previous, bpp) + # Drop the alpha channel so every row is plain RGB triples. + rows.append(bytes(row) if bpp == 3 else bytes(b for i, b in enumerate(row) if i % 4 != 3)) + previous = row + return rows + + +def is_plantuml_error_png(png: bytes) -> bool: + # Only PlantUML's own PNGs are decoded; the large screenshots are skipped. + if PLANTUML_PNG_TAG not in png: + return False + rows = png_rows(png) + if rows is None: + return False + return any( + row[i : i + 3] == ERROR_GREEN for row in rows for i in range(0, len(row), 3) + ) + + +def broken_diagrams(html: str) -> List[str]: + """Return the error marker found in each broken embedded diagram, in page order.""" + found = [] + for match in EMBEDDED_IMAGE.finditer(html): + try: + image = base64.b64decode(match.group(2)) + except binascii.Error: + continue + if match.group(1) == "png": + if is_plantuml_error_png(image): + found.append(PNG_ERROR_MARKER) + continue + svg = image.decode("utf-8", errors="ignore") + for marker in ERROR_MARKERS: + if marker in svg: + found.append(marker) + break + return found + + +# Broken diagrams already published before this check existed, per version. +# A version is only allowed exactly this many; any other count fails. +KNOWN_BROKEN = { + # The batch-jobs "Fault-tolerance scenarios" diagram (a PNG), broken since + # the 1.13.0 release. 1.13.0 is EOL, so it is not being rebuilt. + "1.13.0": 1, +} + + +def check_docs(docs_root: Path, known_broken=None) -> List[str]: + known_broken = KNOWN_BROKEN if known_broken is None else known_broken + errors = [] + for page in sorted(docs_root.glob("*/index.html")): + markers = broken_diagrams(page.read_text(encoding="utf-8", errors="ignore")) + if len(markers) == known_broken.get(page.parent.name, 0): + continue + for marker in markers: + errors.append(f"{page}: diagram rendered as a PlantUML error ('{marker}')") + if not markers: + errors.append(f"{page}: no broken diagrams left; remove it from KNOWN_BROKEN") + return errors + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__.splitlines()[0]) + parser.add_argument( + "--docs-root", + default="docs", + help="Directory holding VERSION/index.html pages (default: docs).", + ) + args = parser.parse_args() + + errors = check_docs(Path(args.docs_root)) + if errors: + print("Broken diagrams found. Install Graphviz (`dot`) and rebuild the docs:") + for error in errors: + print(f" - {error}") + return 1 + print("Docs diagram check passed.") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/scripts/generate_docs.py b/scripts/generate_docs.py index 817744f..b2a72a5 100644 --- a/scripts/generate_docs.py +++ b/scripts/generate_docs.py @@ -31,6 +31,8 @@ import sys from pathlib import Path +from check_doc_diagrams import broken_diagrams + REPO_ROOT = Path(__file__).resolve().parent.parent DEFAULT_BACKEND_DIR = "/fineract" DOC_HTML_RELATIVE_PATH = Path("fineract-doc/build/docs/html/en/index.html") @@ -64,6 +66,12 @@ def parse_args(): help="Path to the apache/fineract clone " f"(default: $FINERACT_REPO_DIR or {DEFAULT_BACKEND_DIR})", ) + parser.add_argument( + "--replace-release", + action="store_true", + help="Overwrite docs for a released version, e.g. to republish a broken " + "build from the same release tag", + ) args = parser.parse_args() if not args.version: parser.error("a version is required: pass --version or set FINERACT_DOC_VERSION") @@ -132,6 +140,14 @@ def main(): "'./gradlew asciidoctor' in the backend clone." ) + broken = broken_diagrams(source_html.read_text(encoding="utf-8", errors="ignore")) + if broken: + sys.exit( + f"{source_html} has {len(broken)} diagram(s) rendered as a PlantUML error " + f"('{broken[0]}'). Install Graphviz (`dot`), then rebuild with " + "'./gradlew asciidoctor' after deleting fineract-doc/build." + ) + require_clean_git_status(backend_dir) commit_hash = get_commit_hash(backend_dir) print(f"Backend commit: {commit_hash}") @@ -140,10 +156,11 @@ def main(): dest_html = dest_dir / "index.html" is_snapshot = args.version.endswith("-SNAPSHOT") - if dest_html.exists() and not is_snapshot: + if dest_html.exists() and not is_snapshot and not args.replace_release: sys.exit( f"{dest_html} already exists and {args.version} is not a -SNAPSHOT " - "version. Refusing to overwrite a released doc." + "version. Refusing to overwrite a released doc (pass --replace-release " + "to republish it from the same release tag)." ) dest_dir.mkdir(parents=True, exist_ok=True) diff --git a/scripts/site-tool.sh b/scripts/site-tool.sh index f821c33..7860e7f 100644 --- a/scripts/site-tool.sh +++ b/scripts/site-tool.sh @@ -9,7 +9,7 @@ Commands: build Build site into /src/.build/site and run checks check Run internal link check against /src/.build/site docs Copy generated Fineract API docs into /src/docs/VERSION - test Run unit tests for whimsy checks + test Run unit tests for whimsy checks and docs tooling serve Run hugo server on port 1313 shell Open an interactive shell EOF @@ -46,11 +46,13 @@ run_whimsy_checks() { run_checks() { python3 "${REPO_ROOT}/scripts/check_internal_links.py" --site-root "${REPO_ROOT}/.build/site" + python3 "${REPO_ROOT}/scripts/check_doc_diagrams.py" --docs-root "${REPO_ROOT}/docs" run_whimsy_checks } run_tests() { ruby "${REPO_ROOT}/scripts/test_run_whimsy_checks.rb" + python3 -m unittest discover -s "${REPO_ROOT}/scripts" -p 'test_*.py' } generate_docs() { diff --git a/scripts/test_check_doc_diagrams.py b/scripts/test_check_doc_diagrams.py new file mode 100644 index 0000000..004e1d8 --- /dev/null +++ b/scripts/test_check_doc_diagrams.py @@ -0,0 +1,182 @@ +#!/usr/bin/env python3 +# +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. +# +"""Tests for check_doc_diagrams.py.""" + +import base64 +import struct +import tempfile +import unittest +import zlib +from pathlib import Path + +import check_doc_diagrams + +TESTDATA = Path(__file__).resolve().parent / "testdata" +# The batch-jobs diagram as first published in docs/1.16.0, and as rebuilt. +ERROR_PNG = (TESTDATA / "plantuml-graphviz-error.png").read_bytes() +GOOD_PNG = (TESTDATA / "plantuml-batch-jobs.png").read_bytes() + +# The text of one of the broken diagrams first published in docs/1.16.0. +GRAPHVIZ_ERROR_SVG = ( + "" + "Command Processing Architecture (Level 1) - Core Components" + "Dot Executable: /opt/local/bin/dot" + "File does not exist" + "Cannot find Graphviz. You should try" + "@startumltestdot@enduml" + "" +) +GOOD_SVG = ( + "" + "CommandHandler" +) + + +def img(svg): + encoded = base64.b64encode(svg.encode("utf-8")).decode("ascii") + return f'diagram' + + +def png_img(png): + return f'' + + +def chunk(kind, data): + return struct.pack(">I", len(data)) + kind + data + struct.pack(">I", zlib.crc32(kind + data)) + + +def make_png(pixels, filter_type=0, plantuml=True, alpha=False): + """Encode rows of RGB tuples as an 8-bit PNG using one row filter throughout.""" + bpp = 4 if alpha else 3 + raw, previous = b"", bytes(len(pixels[0]) * bpp) + for row_pixels in pixels: + row = b"".join(bytes(p) + (b"\xff" if alpha else b"") for p in row_pixels) + encoded = bytearray(row) + for x in range(len(row)): + left = row[x - bpp] if x >= bpp else 0 + up = previous[x] + up_left = previous[x - bpp] if x >= bpp else 0 + predictor = (0, left, up, (left + up) // 2, check_doc_diagrams.paeth(left, up, up_left))[filter_type] + encoded[x] = (row[x] - predictor) & 0xFF + raw += bytes([filter_type]) + bytes(encoded) + previous = row + header = struct.pack(">IIBBBBB", len(pixels[0]), len(pixels), 8, 6 if alpha else 2, 0, 0, 0) + tag = chunk(b"tEXt", b"copyleft\0" + check_doc_diagrams.PLANTUML_PNG_TAG) if plantuml else b"" + return ( + check_doc_diagrams.PNG_SIGNATURE + + chunk(b"IHDR", header) + + tag + + chunk(b"IDAT", zlib.compress(raw)) + + chunk(b"IEND", b"") + ) + + +WHITE, BLACK, GREEN = (255, 255, 255), (0, 0, 0), (0x33, 0xFF, 0x02) +WITH_GREEN = [[WHITE, BLACK, WHITE], [BLACK, GREEN, WHITE], [WHITE, WHITE, BLACK]] +WITHOUT_GREEN = [[WHITE, BLACK, WHITE], [BLACK, (0x33, 0xFF, 0x03), WHITE], [WHITE, WHITE, BLACK]] + + +class PlantumlPngTest(unittest.TestCase): + def test_published_graphviz_error_png_is_detected(self): + self.assertTrue(check_doc_diagrams.is_plantuml_error_png(ERROR_PNG)) + + def test_rebuilt_png_passes(self): + self.assertFalse(check_doc_diagrams.is_plantuml_error_png(GOOD_PNG)) + + def test_every_row_filter_is_decoded(self): + for filter_type in range(5): + for alpha in (False, True): + with self.subTest(filter_type=filter_type, alpha=alpha): + self.assertTrue( + check_doc_diagrams.is_plantuml_error_png(make_png(WITH_GREEN, filter_type, alpha=alpha)) + ) + self.assertFalse( + check_doc_diagrams.is_plantuml_error_png(make_png(WITHOUT_GREEN, filter_type, alpha=alpha)) + ) + + def test_png_not_made_by_plantuml_is_not_decoded(self): + self.assertFalse(check_doc_diagrams.is_plantuml_error_png(make_png(WITH_GREEN, plantuml=False))) + + +class BrokenDiagramsTest(unittest.TestCase): + def test_graphviz_error_image_is_reported(self): + html = f"

{img(GOOD_SVG)}

{img(GRAPHVIZ_ERROR_SVG)}

" + self.assertEqual(check_doc_diagrams.broken_diagrams(html), ["Cannot find Graphviz"]) + + def test_good_diagrams_pass(self): + self.assertEqual(check_doc_diagrams.broken_diagrams(img(GOOD_SVG) * 3), []) + + def test_error_text_outside_an_image_is_ignored(self): + # The docs may quote PlantUML's messages in prose; only images count. + html = "

If you see Cannot find Graphviz, install it.

" + self.assertEqual(check_doc_diagrams.broken_diagrams(html), []) + + def test_broken_png_is_reported_in_page_order(self): + html = png_img(GOOD_PNG) + png_img(ERROR_PNG) + img(GRAPHVIZ_ERROR_SVG) + self.assertEqual( + check_doc_diagrams.broken_diagrams(html), + [check_doc_diagrams.PNG_ERROR_MARKER, "Cannot find Graphviz"], + ) + + def test_truncated_png_and_invalid_base64_are_skipped(self): + html = '' + self.assertEqual(check_doc_diagrams.broken_diagrams(html), []) + + +class CheckDocsTest(unittest.TestCase): + def test_reports_each_broken_diagram_per_version(self): + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + for version, body in ( + ("1.16.0", img(GRAPHVIZ_ERROR_SVG) * 2), + ("1.15.0", img(GOOD_SVG)), + ): + (root / version).mkdir() + (root / version / "index.html").write_text(body, encoding="utf-8") + + errors = check_doc_diagrams.check_docs(root, known_broken={}) + + self.assertEqual(len(errors), 2) + self.assertTrue(all("1.16.0" in error for error in errors)) + + def write_page(self, root, version, body): + (root / version).mkdir() + (root / version / "index.html").write_text(body, encoding="utf-8") + + def test_known_broken_count_is_allowed(self): + with tempfile.TemporaryDirectory() as tmp: + self.write_page(Path(tmp), "1.13.0", png_img(ERROR_PNG)) + self.assertEqual(check_doc_diagrams.check_docs(Path(tmp), {"1.13.0": 1}), []) + + def test_new_breakage_in_a_known_version_fails(self): + with tempfile.TemporaryDirectory() as tmp: + self.write_page(Path(tmp), "1.13.0", png_img(ERROR_PNG) + img(GRAPHVIZ_ERROR_SVG)) + self.assertEqual(len(check_doc_diagrams.check_docs(Path(tmp), {"1.13.0": 1})), 2) + + def test_fixed_known_version_asks_for_the_entry_to_go(self): + with tempfile.TemporaryDirectory() as tmp: + self.write_page(Path(tmp), "1.13.0", png_img(GOOD_PNG)) + errors = check_doc_diagrams.check_docs(Path(tmp), {"1.13.0": 1}) + self.assertEqual(len(errors), 1) + self.assertIn("remove it from KNOWN_BROKEN", errors[0]) + + +if __name__ == "__main__": + unittest.main() diff --git a/scripts/testdata/plantuml-batch-jobs.png b/scripts/testdata/plantuml-batch-jobs.png new file mode 100644 index 0000000000000000000000000000000000000000..c538fdb609fed25552d952fb8ac018c451bd911d GIT binary patch literal 20343 zcmeIabyQV-7cOceD3a3MN{5t$A`Q~QrehN#jVKKQq9COp8&Cv65Rlx2fD(#yN;gO< z-5vYRt?&DO-+S*FcieIQxZ~b4j(_0Jwb%N^oX>ooXRc5UH3fV;D!daXPT(sk%4(fB zaZ2FCiIc16Pr)mK*2G-!-xW7Ggd5Vy+0)+2+UeRZVbC#*Ca@~2qBy-LJ7Onk>% zvG8Q7%-}gIM$O($vw?sI3$*!*`e}#0OTly z#_CUID4sauMHX)mB!aAKejcQ}Ow<>0hFV*Min66BwV>i4YUP)oOvOFrrU9{C(&O55 zbcJ(10+Ah!gwp2Z0-0yltIawG2scdD-+Qrsj2_`{esQeKzSW7WidRSdJg2*KbzA1x z{E~YnZIaEAwNDRu7KMRD`grH6lF^j#T}-uhnwG=S!%ayR@!f|hksd6|hgsAGh`UM^ z%|8ni7JM$2eNyC!nYI|$eq9eTu6Z#(JiV*K3f8+Be~mVD2cx%VzYQ z1!1=?azSZp54_ub@ioXGA!`34?~^1$oOYS=7X04r>W>%HgS;`KXAS7jCEe$*&$YjH z@GRoWk-ZjzoBmvKol#fbmOsx~7JdPf{wmKLLjM!4jXen^Hz^!WoVcy7BzsfG!(?gH z(}#5HNZOeceJ8;A%!>n*GjduthmQ^B*L1w6?D$?AWmn zTDEwEhJ?t-Q+yS|D}EW)Y9|U_eqzE?f>Xw0Z-}JTKFw~1&^4wFrmH6=3XVxj zW4Y#V>WNivujVgv_?D=WqM}AO+kbt8q&|8Z`wMEjw_hp{!Os!Dq2Uj!qD?aUA}5TdJglRTGGOiYZAk1w+6_@MCI2RmBd zb@Pl8;^Q;P%+^o3tMFE=26_=T;lRkB{kb{U&0Bp~XyDb9eyzwAEa^@Y|1L1cZnCIPD$cK;BzCri`Q)8~^J<9!LI6s*D=dv868#)q>AEN~qC zlxtY};d0!m0=PU@6;vk9OMC2QdAv5&kUfJs_2gX!okrpB!_-GmXY?81QA8$r-Dtw@ zkDDG_y?VU{Dkr~nY7=k_Vo&3IczUWC*IDcg-aOBOg1Dkpa<6xB(SC5fO=R52cdK{w zvwP_#9(QPHD1@exp?SlCqqFr|48MQ#1oFz&tM~TSX9}&FUtYP3JA;`2p7Jfv_;Zi$ zc;FH*ALnXAoB>l*{MLEqM9t~a!S2=cQTBO+o1Keh&%a@LGd1o z=mt8U)tjA&kM@HBr*W7BySlrnh3+yO|4gd1QBzY}8Y-#t=TT5IdeD0(gw;|Bc2I_T z@^TZVn0j3)rwFc9(tDfqH6yo4#V*Rwv8lbi-p>3e6>Z)2YJIukIF)YrgQb5%$zBZt z?+kXj3C*}tEXUo)ean`0 zL#sE7HdWQs>S<%`o%-`=EpQ`XHi@}Ia7LO#Ob^Z8tW;yF7P)@h$*VrzmkT3fY_)uM z4aH)$k&_*LMB6`m;qQ_7fiAXeiiX7&no+j*9PKvSnlLbc5Yo-kN|y=7dn5jUSS4kd zUd&Zim4H@UNJ?r9{d3r zt~P9LYPzXgxi|aTW_@V@{jExEZlgmQGoPK$_X34_@IsY0) z4RkS#pUGgLRAQ>sbow{%nLjsBBDy~9eJEJC5Z}) z{ybw?@sI6)jaOIuz9IgIw{z>4eyQWAQVd&Yz30YkZ=?I}s(AzX9bMLge1nqaDq&L! zl>lrYB0LqE-sQHCTbhSJ%Q)cQ2~WTAdy}WE<{Q9KU&qDp`h&)fX`ntpE|1xCMm^~rfc=I>zSygVZ2 zM`AWnvDIftNl7_>l9l>*%uA0mDLi&}#`}JOx8g0GvMYr4)E_ibK8Qm-M!b>jrD451 zqpZJPe;Yw9!V@lqA;Xi8pn9-2DYqpWO@ckj(&65Qy7bXzQw)3Wyo%bG*FtWG)PbsG z$nwA^H9DPCWZTqb+ zFHB*BU#-~q*-^bepDk$J`om^}lwB<`BrHrtRaJ+8^73PdfN~F}8(+kkc-huG#ywOq z+}-R}C+ZcYDladef023uMl#bQ@w)pZ5uB-wi2F_WA|r2wW697{itk`df6mNgDLg#| zaD~a20oB8XCj!6Ng@aT&{r|uJ@r)tJJ_hrI@ifqQTw-~VXgvYwI4Mw^KAR*!b37lgVR*`Mui0JMDc zJM!wpDb@b<{r&CN2N3l}%J2W`12j-pR)*p>gp_))eaUr(@8RM=B<=N&j-y`R-`AEfspy6B3^vC@Uaut;~ZHGT{f6P#c<0<<|GCY;7*<;sG&gXx8INSCbGb>W< zITayN@FrEWpl?4dE$!jbP|7VxN>TLEJhPMWKR8BTIt_nx=(DQ)NLyR@`QZ|A5uM-e z1Z20N14OBARdHq1(SzJ0U)l`?dfy%|zo0rq*9l~(%&TzLo|)>y8Q%CCP1?~@de!@} z@i%%owco3{x(d9P-kT=Txuu#2d8$O6Ykj90LwhsOai%#orle!+X7Pn}9A)Fr4@b-I zXTbGLa-s?(nf3}KJ3Bl5sH{>-JL>T4dNS9JcE*wWuvcaH!pMoI3lkC&9?bVNH8pi+VGOx`VhxEhqH(GtfC!E3&4 z&-vS0Es6Tu;cNXy8{O*Cp-J19QfaZ!PXcqe;bB~R3$AOXGE4+hlEb-nS!H`^t(W6M zr7w>om$M2Z6#!=-v=&0|_C;LB0s7TI?ymAmBb-lQhjg_l!pSmWCT zMVLnUn8y^p9lae_lgLWuoQ2n1SGw>1=8HG6jwTtGh)wplhn*Te@cDK~?r@+jjg`E- zK82_E^ytjYez6;*!$d^h*ULdLZp^Mue5vS9XMb?b^vGu2{Y;2eZECrenG8cyUcjEF$+5YT{nd#{t z++u9eF)`j-^HxjRDKf#`8S37?J#HhPbw23eYEHEA_LcADgj%dH*i>#0*+ogLhlpUF zszIptvzUWjahX$>^feJZy=et{6A)j|hdU}d1CVL82zaDh^ zfMAWrolwMvKyp(9CbjNl=?>Z{7fFAx(0m-(aJV>F6nt=TEctjd!)t3k)RGwe*tYns z@sjcc6lEzFO1AbF@&UK>R{0)`d9PFg=o$6dXj>XC#Z1Sj=ZsEHP6CAE^%J+rpIcr2 zykfx$$i`vx(>1*GXpZemg-5WD8`>zx+;$fFR{)4@kf@9u3f&tiTjMSr5pWzS%gp3R zkCF_ap{D&=lPGq-;GlH$;jmzb#1torfuA3J`?WCa06-&ghmEI&ZFg5E2Cf%#(tA3O ze66UcxYzrkuC{h}9yxA+T^l8iACBUYUm^Nww&LS0GrW**)U3c`TMe;Q{rieY)wWm0 z$McM@+l5Io;P({SsE=p}I}DN9{#RK+08l zz}k%;7gCM?mJbRRCR#3oV#P7;Jxm7!=_FwCv>><^0RGG6j8paBKeaeJ&3lmKf{5K6SxcU@q>@p(xO7F{r;@CV!#&^b&2CKuiUkacV)3kjzrw zUr(IVT$cJg`bn`!;Awq{;gqp9b{^=d`W++OZ(|E#-@8ViuZ>+h3n#mo^1na)|B2D) z(r|QK1_=8|;lYz-p}bM5l~;ji%V&@U^hX;yDAnOE2ZuZ`L~bCZpn-Zk&2~q$HUA{^}Ccv-0eI?@=hTtGBUDi?)~fK${mwLwi0?|6U}w^WqUy9fQui`ojiT6 z0;-aG1YLoRY2o1)qZ)!5QdzX^?d_prR*0ZVZ$EE>Zs@8DBd5N04}Qq-V@?;st*)d^ z7hzMtPhyX;nErWQH0iv80E&-GuH|poB|d}XqD%vBf#M7V9xEu$|R zpV{Ms6>0b75x`Q*6&vk<=8>D&0$_ffbUi15g;YENo4S9X_P-HG=iJFAQ;rhxp8@#x zEG+DnZYAI@li^)7kJSGKcqJobR3O}AfyC63AlpZW;aNNUK0VBWDaNbrvfhv0y0Op) zsc6*%0cB=&_0<@gl@(rmKM}HNWiSc$=;WZ>wx$83Gn(aR3iSzog6oJI*qyC*L zfLJl2Nwnf_17$8#qweDoa;x!iaX&r8b8xf=WfnpUI^-@yvRtD2Xbv?46Dkc0-_Y%R zsEe*pNbPSeAl-OODk#*|6Ga0$nV(#eXXM%4+xUr@;r0KjZG=>gP5Exqk(SCrGH>kW zCd8&u(U)hu&}Qmi?mA;hi;kji>B1aQzKZ`fRKmI3bJRdw2q{wP>t+MJ|44!PtxiYN zpsn7C->9%4kAzjrwzaiYw#wSd%AAV1+Iwf&g2zPXF{g>aVpjeduQ)q9dsusBf2#3d z4U|(;q9NOm?aZ%8KUK`Z(kt=ubL1j4+sA!mwZsNArAAmV)42o%*2e63!|tHF%NJA6 znagR+=WyTq?|dFVg86lmpj|f`D*bLJMzmc%H#dh^1$cOuQqVg58p^qtzxeoQ#lM9t z6m5x;KAa%%FnpYSsrw(m_e#X+7!aQs;neN60`qU%%cI#mN-5m^3T(K+ifk3bw9?QuyPT2W?c0rwp#@=5QoKsYhbf z;{r=&=Nyenwr=h`RL^&1(}88nh86s%91A@ou+VdNFFt>5Wni!>n4_xkL`7|(RSTBX z$sfOEX`HdsWiotVXt??bmIMVZZv+W@^8U~c-sP(y^}X48m}<-prdCKd>G|^?9^%tq zi=9&XU6~etgS*n7Exgq)(Z$|7jw5%y)~CKl!qr2yKJkI18;Xae?u?F^4a|X*3m_*7 zey57HCjwzIx_RTO#DU81fl@Suz~Dh|477$s>T;s^17#)BhBanL@4sL=6gq1+C3i-& z&A5yKz^vQGUcI9+ew1?)j}Mg-OO?JiWS3v}?g3H7v&4rqn8-Ivh9RNA@B6QQ#S!H~ zg!Zp67QPSL(b_8ADg797#ucvYC>O2sqly`I?ji-l`xi|2XIot)XkCd8$h^4OYoy50 zEPkl-5X_#Vh3xvcWM_Ggcp4A_VKBMXroaIKvUNIsUe|bwzsCE9#BDXT<=*VL2RHHb z%THQH6M6_#9`7#zYqbNsW_MRt#h#Tr(1Xf+0}WA$TX3q9>y6A~hspD@^73R9DnK0$ zmboy$*X({RWIO4PPdv7sEahvE^OZ_PnRz)tV{l+Egy`e^+e^qG~a14mdrLlA&IJ+L!kgeumg z$hyt>^jMXz_u4lrJeTs5HqnHN0>I_!MCZMTK84JJsxV4w|85+Qaq7EwYxBqb40UyNz4@l5hej&xGNHvHY;0^FZM)BL zRxjjzp3Xm7svGU!bzig}mvQnV*2&i8W@D4*Sy&)6e^WZ*+5$xc{{?C`f|AN=`^x_q+py55hrNk3iFc8%BpTFRmwATl}+bMaDa40E81690@ETmf~)|z5{V; z*PrKmtBTsGg8%B(4?==Xh9eIO-0qcfD%Q))T3f%Tx_0tgA>x*o*FMA^L*U1<_42PT zmz@r(hRQ+(#ieN)CG$uuJrtcL@N+~Pu|dk1Aj{^-bHtA{F&XPhSNRO!8zi>&H`fId zWMcmLVA%%g)r6O-g+mpd8;?$&cE;_=HPk!&#)wBd`;x6bx;gQ_I@Ky5NIwB3ai85) zs8~mH^oz8P7f0VSi+a zcQuCNOoH|+3YDw(+a>2}0cpl7QT^vL;SCA`{4twm{~V6q8=e=N|MQ&H4xYPN{PUdi zH}QjGoOpZOL2U{G?E zE{gX0lh9jFFX2-PoIw{umODN=2)hJ?Q^lV&@a4b8Aj6r(0+;RfE4N{XQha(T8g#M! zU?J3)rp3&tyvl6@3D^G`qAN5|d#{c_y-JZc`PAc@U0*Hp5IGm(d$t}guR`I&f5xlk zML6pK)PjSBR&@$=#3Urc#kM50w7&hu?rY2h1_J*NM)`koa{k}@TGMM3%G&osqPG=~ zX5*w{A2qUxLC~gs!!cN>iMUUIy2VOfYkTRvZ0Tm+`9WkEm5yZ9~beg8RS z(i+id(0m23fefpjsXB&sg3}D#dCS)?z-pd}^Km zMqTicr=sHP&kXhE$ug>jPXlWVzw;DrnFxV+VmlKE6VV-yS|NiTHCe`$$m`3Q5E*oI zUQ#rb-PvDq^3Mhxvlb!H4g}x5{=CstxeJNjpnP)QV^4jKt%4b`5?J7ikz!lzNJ|Gk zlqwJ++0;B=LB-@yEw=5JkDLZ&G`r}E1V2jk<@4tnMb@{mM*!uMlnt))>1&qoX<$y< z-dxuMF%%F`Diq{UzW*2jDrMe2l`o)k7!X{zC3blQp)E6iCV5}_Nf$MLF|}a*aH9js z5=AK}|K_H77`z8M+{o260)i_e<`+R2W!7g8XSIQi037JEmYrws>HH5}N|eYaYk@J~ zm6SA=EMaYWY)%p=MAyWa`JG%(R<;)S59VYbA$F57hZ$DKyc8-QKtQaouLq&+0nn** zcs5yCS-7~kP;i)3`RqZx&?{)1BWmBs5nNU_4;KsB4>Z8AjBr*b2J~a((a8OdT%&R= z$T3T8b78Vbo8c157{4r6$7WD` z;?OL#pz79!5Q%ckYqA@&OlzzAbUF_ZdY<}R2E+ZN;rdF2Ac#udS5N|if(t!aF(6!^ z;LO}t#ztEZ#O;Y!p^_5rY8n?L*0|Jyi#*v|(k zW&=N;F*-V0H{T>T@{tUjf8n~nODG}`_%EQ29KJmzP~hSQ)(~nQ@}-b;yZlPBsPJ^m zap9|K3O;2YpF?nC^tuqTT%u;WE=?X6&U&T;IJEFZBI(*}{USEH&MLF7fk5+k?%k3I ziHMNAKO64lreq2{3JII4M#IhLqV^$+hek#MC^O)Uty)l*={*%yRqeaq-v$yLpk7^+ z#38ws*HE$TdJ{EPElzkv{Cj8rz#yBPuBa`|r&ism0?3++Sg}Kha;O}YG zhPAXHEPVNOHS=iTilm>px&RfzYGCTMy(k7UO35`vkV2p&YHV!OurbmC5j4O0*uWt@ zaJSqQ{#mytCZJI7+so2ep4<*#|3sLxaM3xj(edOq!LunTabYq!CHry>S34fty0V#o zm)E4p^*}~G+Y@*7&WA;)&!l{TN?!*2)#BrSm|R_3TWdkd@%2*KDbvy~m$ZIJ0zA|L zg7fO$d`}khFd*o9kVKm@V0}XDzXvQasCZl`gU=6h6FChQ7lns1d_yywE2~M{kJhF!rO_ZL3*G(xWgq7TR@;ItX~XYe+B|0Aa0sb!YHEs8 zH-|z|KtDgxA9FB{Mx)og)sAeUOW&Grz^*%j+L*i>*zJ2j!XK|i`8VpH#<_I(&q*He zs%k0KMo(>v3X_FdjJV1b^c(zI^;ZoPCqHg^^#y^avDTm^D=RA_gJs46E%A)6V@m{% z5|5u_)&3Y*sG%Do$oh*TWIxy##KpZJhu@{-Gy4*&XA*kN>X#8aR&MFiM4ST9 z_`Sdrp!Lifno9@q{78e3_E(aQw@a(*Xj2+&6+L*~+@1OIOdo<{*+MkNKOf6rJd6KA z1>xaI+_&22jJF6^nN|u*x7}%0BRNCE!;K(%AMI})Y|c%UNk&G{(KC-!MQsOg zd*LCd)p+)ilBv}Vw%37;t+J@YLpOqQR3MBwJvKFEaNnpF-C4z)YCHgC0Wb~d^YZUJ zOlKDERzT@9aISdjHo=pdUS4V6+}-)kaXS_2`*2%K5z}Lh}kl z=8zQFNQIzL(Y?FbLu-T*9h^J_dYICeZ*2e>CAs?}$iiil)UICR%XM&*WFWmp%3LT& zQiE8YkeZx-M>5Ly3b5}p{9#ag;;*~?(rA@*`$f1cf#NmgAvdbkU8k7p+P#tID+K<# zr)!v>tFDs?**ky9FQ0vNbg+Y@ck#rUB)p$Zg-_{M`FOf~p^d7@q3yfRvippcD{z=M zaeQ;rE%B(fR@TZznt#fldhuup1`f#z+w`0Fy~Q$*`7+6EdDC}|RZ)?r4Q2c*dY>QZ z<(n+I45?KZ-;g5{U}I1Y8>Mi=j?`A7FHe~uV9V|xS^n`fV3{LC+!KP zq=Bw3>X*J5SJPHV-O}hP8Nua|Pc0AWeK(1ODTbpqSS_w_oW1lK3tt4}N56hHmhk-% zfWzmiLFv-QDVeJPL zQ`#Oo9I6#ripiXdmsbS7Fzd@nr|Lc$au<6YGCg5?;2-*YcXq4Z=h&RYxk+bJkwx4c znW02uZc06iiRLZH8RSQu(rH9I3lG<^$byt>L1iP(&3#*Ss*lcP3md1}^&XN*KdyUC zJ7W6IEj2nd?cscnQfut3lTJr@pXRS>-kbQekf-hE#qfmmlfIsmMQsQWp<(47D{NFQ zKT{6pFWkaoCGOQ2lWXed-hZO+E$;Z*k{}+!&&1Iigy2I)M#h`PNBhQ@%6Q@K{yxycHV1sO)kfAqML*6-eFsJ8?8!dFQx{isYj??< zFNAMA6^!uOnTaz^wQ7oH-JXvUQ7$u^)_Q26I3fPh$r2V25g9ANeMlABouc=8I2J~u zA6o&BmYkHdT<&w8%;lS}w}7<}(k@IN77`I8|<;8g^c|bNyxu ziBvpD)zKm0tdw*&>$C!f-)@p~H$UQaW(}O$9-#J(U~2Fs%{gsYxU*C822#`2Wf^Q{ zGVAd?MX|bV*XBahARpEJq)8tPh1yc7l#QLMmsOf=qgD2X_%Lx5Nd2N#cWh>2MB=Vw z@rTqAI6yEn0AcJRIsXM4V0FmvyJ(lV&M52P(|iXO$Yb6ATfikze^5DB2GGpbR+g4O z-h@@K7!=>-_doVgKaPrB=*`}N>q%-V1!E7Ua+}pcF@>6kyF%esG9V#}6)PYdIcG1? zh!VaI*m0qdT&dWIG+O=J)Dz&YJ|Kf8Uw;r*W!{$*i5*-ba`LCH49UQ*-pN5|9{w^7 z1+=)8TxO;=&EqbpzCZO8U%1S5rdgEMhUdtD z`p#+Y$;yxU%)FeD5(T=Mw5dd;LBHnMQ8{5Pm->>DPJFyM$7}HNr0EakGYv~j?w?k9N zHMhBbfu-w4a5!sXhE1|BSq5HR9#lvhvu!a&&R+FV^ieMY)>x#^1(i}m1)=a0*1Gxh z#G#e7^)@IN;Plee)D+!WkN)!E-t={s$=bZbs1dU(7vDiu-4aJQeM9y)?=CBAreb+$ zHf-X(bS+U)_We3FZN=GdjrI4|D@P=_RY%#RrofE?_CuarRwHx3@hax|LP=T=URKOw92x?&izU)Wv?+y{qY zlmZQDN-@B0Wkf!CH zj4e|@U6FE~4iUc;SZjXEQjd$A9NBaLd{+dGn3m+Hw5;OdVy0s$-5&*vTBQATbx=~h zd`Wuz;maAo;j*YiX@3*ilfgCSw^%MuVc$)LmB-4?o~`goD6qx4CsPw^-!11o1*L|% zi>Xm~x@l{on6}Tg3v=w;-1ts2&lU>NP||(7d;s76)CzjU!FmfXm_0Lbl4^7xJ``TI zg%xbdC2isi=VyI2B`ge`*4)-srx_U)6^Efos_B8G0lcRuh)q!4e#j^kR5tmZK&=HMCUO#k)A!5%g+2&a&qk&*p0C`5(;*+dE~RYXJt z*5(H87>QXhJKR=O1T)>{`b-P3mzi|5On8*xtVbyv(mNyfLH*KEDS7c9`h|{-2@gM< zwe2rn1-$EeEk%RG>sdHqAu=bBN;V*S-ed!}Q05)*y~9<)Dp!%Tl${o?k8YQbqH8-~ zk^YMap}WT9dKsx?2xm025`#xdPw&6KX}dK*^un7$W$4)-7Q^7loai4gRE^P*M@xvtp)we#ScKtk=QHnp*tiUaMFWV{dC^zEkd&Pi)8h{d6 zxm92}kpWAmdO!nlI0gS?l>Z?!8w8h5n6r?O^Op5VlHT84VaeSLI(H}Eq!MyJ%plk% zY5yvsrOLX+!8QhNAjgU|`PPWYNKolY5$69gu%}o5dMm^vM|ct31z)?tWf@X^>Z4P5 ztg3*eA)FT4?45iB@CYnF)e;2GiUba<0}EpOF%4X^n!n7r=B}(FhCaJHL5c+$&Y|`e zzJl!Kk1MM! z9b}(L9Ik%z2;JP=1R^uXq;d!pCs{67O$-s=@s&P4SkQgk4T+|11pMC?%kZb52Gmy0 zFiuc{Qi5P@+_1<3Fi}QcKKYCjYu+qoVQ23Lr&+6<%n!$N5Bwnz^qcxiF3U_uL^de2 z<`|TG0IX_3m9+}YTZ?|=Q7oe7La6ODiA;?PvR3LerAy&!1>#{sT?huZ_ z1~3|NxM3HUB3J0=bf6%xLS)h*^}Fo6Pam3DfrEdu+c^jY1ZY1`yvaQ~7)j28VGl^1 z%lpjJ1y?&yW;t*E(&Tqoa!Rl5f=-cIAtn|eBKzwVnNZ(zL0Bks0ooFHL4c*nEL3QB zP&rE%r$W%Oq~a7RUU(9yRRl~=PEHO!weVZbC3oDKET6q~P{`?2QW-8oTf!=!eG4x@ z`+bPQv5_;xDuvY{tQhL4^5XL!mB(~E*Zr-wpoV=`p6s`ub(iMaJ<=7>-_HM8bR$4Y zg8AGBS`8|_w#Gn>5Zr_H7oeZ)f4rw>>KhyiIT_eF&}UUk?{-=oAMVA+OZj?1(}jAH zM8b;~0oRF5(JTjs`qWG4=`w!Zb$69Uxe@Dap(xM zasgY?pJCPEnn+@1Vmjq6GpX%>W!Fw^t&Gzb0ZIJyQxpn9s@f|drOjj!CzA*t=q*}? zD*cb!{W*V?h0=B$eV_&v{BZdu(e;ITM3db2T$tv8W8iO_c5kL({fQJKAhVNKJFG|j zHKbShP&LMb3>bkX>Ax1Z(h6b-yq@5c0M4<8GPuUSSD91`JNon0!jI!%`N;$CU0?q} z5N`pN3D#A2sXjVsw*3vKRML&J`-?>#5bAoE6=fky%w3U`l!SVd`^&T?U{zppw4)e^ z${)j`g)#Nq{MC8yB-?AyFv`kAw^skeK%kxm0iE^=sK&hpN*k6*4^lyP*__LE0hBE& zG1oMMCrwP;+!6E-8St)!vx0p#ZB<0IDpA6d80~v}=(&|i^dg%=jXp1Cl>z<_EwCSFN41;m8MSXCWXP&+WNoWcjH|rke?R_TWgN&tAWwr0 zzi!4mSow))1(bp+>gsyxCxWlu$2*H`@|_JA#(*E5ikkX51YWmT>5$ zf>tjOQ*}CUf+_y_xTywHviEX1bdG!oj!~9VR#sM3ZH08uOL8lvHc{p)!Zlg!U0Wv_ z*&rWI;c~d^Qi9N$Q*O*iqQj${CB>_cK4_(Mc=!)MxfVTA06+qYg)H_^nIN&?K=icK zefig;P?35jlWP41Q;>qRGOg8L7}oNXj6Q}0*UNM z6njS|=V~Y~Uk`x4r%29GFhhe6HDc3#x2UyNep+CSSNzgU+pI_|-C;1*6 zne#97BF3!u&yn@P#F16BXX z=L0ZThUQ-{#AaJP;F8 zD#T3ahshEDYzeKvX^>r^y%4%Hyg=^QSsBmYE&v^AlSCq%^{UA~!=!tRoCA|m#qy^G zfR20-_P{L@^w6=M{)2xkt*r2Y6=_P_F8JbrWx#s+Q~(55{O1e~8@fOsk9y5g(mVCF zO08@pR=_ z(04`b2MR!XGStPWaD9DtnG^UM#`iX@@h%_%SztRApaDU?1^gFz73<3O>X2*Hpi>K$ zBP^^bTj)&zeQ~yF{G!go3UTp9Jp$(NbPeR+o78#+==CBk=(c!D=k*H=D18rmyeyT} z)GiHauElX1KB)&}wzW98Q5#B97zI7CKO%oZ&oyLed@3Orx7t7icOd@#7nXX)4`TBX zHA8eBKLUuDOdla9FVV!V}`TA)O_us`;G3*CRMGDeKWP@u4gw;&8EJkSqH0*`umlj-LvR8s2y z+2>ckAl9e-4^Oh196FvON%mO}z3ac;#ICt4_ipQBJ(WtK@tJP`z<{J<_nxe3(Bt|F zDNXOaY68d#%ntw{J!-o#70s%$_`7uuAccuR17^VVXnijw@?X0F)*^uCoyEZ*4k-Ge z@X4tlznSa*E9EI-+z_qY$Cxmy{-5U(;vCq|%mLqg(94fx zd5-D1T;g}QyB0!Ld&Ay7$NnN8itj~YVz?-@{{I329TM;&*y>7kz&{6z*dM&8cd@Qb z^j=f5(yRG<`$F_s;=2>$O6VT$g{ncB-0#MGFlDa8m>F z{S43lM7TWAKTG+=gvsK}{3&&R)SvlWBZK`)hyU>g^rz%jIliC4#2f@tJ+SH_JgCM0)Ij(NY{H5%8)OU^C`x)s0o-e-y3XkR+ zm?O);_MBU--rU?g5A%DATF3(RE9e-}++nk^`pm<);*vKAzXa~)6gQQ z5rz;O#Cc5v(9@XV@Mn3`eNM*r(HV+s$1^DUCk@de{ui+eLI;xYhxQFG;L^Y|;ju?r zDjNwM_NWS*8ZCq_4+mh%w}!Ag*FMcx_!yAkDL~qp6CZMZFcDZgHla){;y40^iQM}~ z4bkfk^35Vw2dj@7r#6?A|I;1a*#)fF$cRx#dZI=j@3+$~T_tvULc)J^+?%=j1^J$- zONMcO3S93^qUwa_EqKQw=k9|_p6WBDqh!YAxzXSpbPk{I39to{HtjSEIY1##oN3B{ zrXh`@U#`CxyezJ8agsj{zCdolGIfWq#@8xZZ(PQ_0Z>8xJN$l%^>;8b0}3x^!9Xo^ z{kmc6I=u>Ai?qzvK$T|G+Hh3!P?fPQbajF8@q!6a1VZ*9iQM=nzs~rWn4l9-+lbek zc6$B-2b}R2ecGZ6t!E75p5Nin^0?e8XTY4yV<`G4f>PX#W0p?b?L4PZl}~x0hl@EE zF2y%!JgqPPLQ#J*Dl~Kt0&*}mwY1Q;-2_uiFT*Dq@sK10UW?a;|6393Rtoei7#_`Q z)Y&{scz$D+O*)6NE(jPdKf$8*JRf{W5RAr3olG^*I@{|`I?*}femZ^INAezCSdmrg zv=RgP8i~qpJ<%?vAR6d+-Bx@{>?vCfPETV!L!o4~mmPCoCa<5Z;=fCTxmC%+hhkYH zu^r(HbP=+jR?wfZ%>sFQvacL+ghepo4)~lfs_XM69611eNN#l# zdQ^vIQ#nb~V{Jl&CTdrf*QsNDd=E7BGgd@~%3P43wP=t|8wV#Nw5o({|E_FL>ldZv z=Tg;2p`%(WxN=c{+D(Xq<4CO$t*o~sv45F+2Q8hvUSR(0<=1C*jx6HQ3MfzOFD+xv|I8YnZf*|Gx@j<(n23 zOzmgu`OdA&lMTHo&Y=(_*}_D zZy;4J3`T7fk{*3i%<*KTqM!FM!SJW904xm{MJW_p*+gr?BL>^^l zzv2&K>t(|PnDTQ~)=gE`{M@Lz?4E4B6|nzE#5h5{UCot{|0RM_)Y%NnR3|`Z8wMI! zIl{=v>!Tm3%2=D7K&DL`BO=XbVmt!;r*HZZ6D)ikDrkpB2DjyrgeDeXL(}X>KiwH& zpLkPsfLnz5r5Z1Nv9GJZhnBN-N1A_ zJ++tY;dk5;Bu1e!4MqAWM0Ma7rsKYTZdnCY(DoR|b$TWa9?TdE}bOH>)4u%BX9LqTl9p}KKk}rYB$qULPI4{Sti6?Lqil!Bm z18@c4PX)*~LsbRVA?P{%1i7&A7Qlgmk8pn!tRzj6d_iFf9?vipzTTzxn#7c13#)F> zl`DYdlsjmA!A$GjEN?GN1z+XHbZ7g+r9e;Ih~`Vg)m7?4yhVnyV}gu~DG+)9+{<`i ziDZ)sL~+A!^El>RloTKzNr=yIhlB#PHGI{^An1Wyz_`8=*vMI{xk!Tl8?qM{Vy&qk~M^}9r7T!Yol>U&9&2YRQj(jNN!OzP4; zRDXLl&VMQCQGc%HyZXSh7jhyVh13`~&i7_pUFZwSB-DH?;W3+Fv%kH>`4i5CpN*}a zPGuDu2=VD85VpncMR>xf$LvWSlX;()3tQcxb+8mWr6=p7r^uJ}F><9-dZ(H%o%Q>f zv^ck5w{ynQ5UPYFjL*qZTEHGfe%HAf6fj`)@m$|H;jS5?x_0)7sm~hJu-qWFX4Dds zHF&MgLj%K$2-KEX@%iAebnGn-utpY27)zjz-QoTtk)~xf5v)!7v5yLG1&o)od++ZL zNt#1wDnrJov%d89K7jrjG3OsQ)cxqpX5ju6^p0~9d*ip(x>R_vUe3KyEtqfciCiUF%2bu*@?cDK-HS+I6f|DnIl!Q=Gu>_p#&18d-#L4mTcOAf2{qb;= zSU2)Buz`y9^Gl`Uk@EdW+h5;PU`yv)RSCiw2BT${&-0;ZV8;DAlJfJ)p&1cWRsB}OU^|iGO3c1z78}LKFx8135)&|~@nDL_Dx39=QQ|w1H&Qm>J zp0QaB>nnEGqjY!XHq@_H*K00@i5`zehp;ZzL05Q8Lc$y_7pTMFIMna7z}T@wMIj1z zNF!h;)e`+0E?t3SiZ%HC=c0a(7j`RsMc`T4bR=1Ey)cA{i3Ao8Ta)jC3`6;6o{-R} zF}ZPpG;n)yFy`gUv?Re)E$GN}Ps_-F8wq93JeT`2z@Akn-6!Kwwsu_D`UwvroKS-05g>g&OKkYy8+=?35O z1z8q4QX&l4vPeVy;~G>`HL$(*&*S4M$jRBOc%qcGq0n_iz}+nxlSef}%(yf&L4tV0 zXI`_rHU&3DI)=`?L`iSm+_yTa&h{>k!QmqbJvh*>c(zn|zKOw=0Pa7aVq!7|Wjec7 v6Zn7eG1e# literal 0 HcmV?d00001 diff --git a/scripts/testdata/plantuml-graphviz-error.png b/scripts/testdata/plantuml-graphviz-error.png new file mode 100644 index 0000000000000000000000000000000000000000..8d9444c6f60c920309fa8a723f5733ebaa725c9a GIT binary patch literal 8135 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πSY3l8C6GF27swxFT4FU<-oY(sQx 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 literal 0 HcmV?d00001