Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
62 commits
Select commit Hold shift + click to select a range
7b2b4c7
add integration-tests command
Nifacy Aug 7, 2025
cc04f9f
add negative case check support
Nifacy Aug 8, 2025
c4cea57
add multiple test cases support
Nifacy Aug 8, 2025
5d9486a
temp: add example of test case configuration
Nifacy Aug 8, 2025
e7ea8e8
add env config
Nifacy Aug 16, 2025
f9ae070
add lite spport
Nifacy Sep 6, 2025
607e5ed
update structurizr lite
Nifacy Sep 7, 2025
406bf75
fix structurizr lite exporter
Nifacy Dec 28, 2025
ad69d97
add cached download
Nifacy Dec 28, 2025
9d42d23
remove unused functions
Nifacy Dec 28, 2025
f830b8b
add todo comment
Nifacy Dec 28, 2025
ff2a4ab
improve logging
Nifacy Dec 28, 2025
1ee7aae
remove information about exit code
Nifacy Jan 2, 2026
bb919e1
move log_action in logging tools
Nifacy Jan 2, 2026
4916902
move release extraction logic in separate module
Nifacy Jan 2, 2026
90c2a86
move exporter factory in separate module
Nifacy Jan 2, 2026
11f0f05
move test case info extractor in separate module
Nifacy Jan 2, 2026
a0b2c4d
use pytest for running tests
Nifacy Jan 2, 2026
9bbdb7a
split test on exporter releases
Nifacy Jan 2, 2026
e6b0c10
move test case info in separate module
Nifacy Jan 2, 2026
a71e2f1
make sample dir path as option
Nifacy Jan 2, 2026
8ebb238
path to result file is relative to test configuration file
Nifacy Jan 3, 2026
0c0f285
move test configuration in code
Nifacy Jan 3, 2026
6e2f373
move integration test run logic in test code
Nifacy Jan 3, 2026
e339b1a
restructure test parametrization
Nifacy Jan 3, 2026
ab4dcb3
add run integration tests job
Nifacy Jan 3, 2026
fa1479c
fix: update java binary dir path calculation logic
Nifacy Jan 3, 2026
7a440ea
tmp: test running structurizr
Nifacy Jan 3, 2026
00641ac
tmp
Nifacy Jan 3, 2026
709c15b
tmp
Nifacy Jan 3, 2026
7bc1e31
tmp: add delay
Nifacy Jan 4, 2026
9515709
tmp: remove structurizr cli tests
Nifacy Jan 4, 2026
1926729
tmp: fix structurizr lite
Nifacy Jan 4, 2026
906d917
fix: extend error information
Nifacy Jan 4, 2026
105e15f
tmp: run only 1 test case
Nifacy Jan 4, 2026
c9abb8a
fix: logging percentage in cached downloader
Nifacy Jan 4, 2026
f441d67
fix: add timeout value check before sending request
Nifacy Jan 4, 2026
485bcae
fix: syntax plugin path
Nifacy Jan 4, 2026
7e78cb4
fix: add path validation
Nifacy Jan 4, 2026
941f67f
fix: update paths to temp directory
Nifacy Jan 4, 2026
1dd9ec8
run only layered test case
Nifacy Jan 4, 2026
92f48c9
fix: update base64 encoded content of workspace
Nifacy Jan 4, 2026
6e21b67
enable all structurizr lite test cases
Nifacy Jan 4, 2026
0f9275e
fix: make shell file executable
Nifacy Jan 4, 2026
f3168f7
enable structurizr cli test cases
Nifacy Jan 4, 2026
3db2d39
enable only 1 structurizr cli test case
Nifacy Jan 4, 2026
8aa4aad
fix: extend structurizr cli error information
Nifacy Jan 4, 2026
7bc4514
fix: extend path environment variable instead of override
Nifacy Jan 4, 2026
abec134
enable all test cases
Nifacy Jan 4, 2026
a6da3d9
fix: add CI environment flag
Nifacy Jan 4, 2026
d11abe7
fix: remove unused modules
Nifacy Jan 4, 2026
d1c64f4
fix: remove unused requirements
Nifacy Jan 4, 2026
405771c
fix: remove unused temp directory
Nifacy Jan 4, 2026
2cc7815
enable all pr-check workflow
Nifacy Jan 4, 2026
42b76d7
fix: pattern syntax plugin destination path
Nifacy Jan 4, 2026
04c12de
fix: move fixture in conftest file
Nifacy Jan 4, 2026
7c6fdf8
fix: update changelog
Nifacy Jan 4, 2026
54423bb
fix: remove extra changelog
Nifacy Jan 4, 2026
fffedaf
fix: update changelog
Nifacy Jan 4, 2026
b8eee2a
fix: use logger instead of print in structurizr lite
Nifacy Jan 4, 2026
2fcc215
fix: fix datadir package version
Nifacy Jan 4, 2026
b4af6ab
commit
Nifacy Jan 4, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
46 changes: 46 additions & 0 deletions .github/workflows/pr-check.yml
Original file line number Diff line number Diff line change
Expand Up @@ -310,3 +310,49 @@ jobs:
with:
name: vs-code-extension
path: ${{ github.workspace }}/vs-code-extension.vsix

run-integration-tests:
name: Run integration tests
runs-on: ubuntu-latest
needs: [ build-examples, build-pattern-syntax-plugin ]

steps:
- name: Checkout code
uses: actions/checkout@v4

- name: Install uv
uses: astral-sh/setup-uv@v6
with:
version: "0.7.13"

- name: Prepare 'dev-tools' environment
run: uv sync
working-directory: dev-tools

- name: Prepare samples directory
uses: actions/download-artifact@v4
with:
name: examples
path: ${{ github.workspace }}/.samples

- name: Download syntax plugin
uses: actions/download-artifact@v4
with:
name: pattern-syntax-plugin
path: ${{ github.workspace }}/.pattern-syntax-plugin

- name: Setup java
uses: actions/setup-java@v5
with:
distribution: 'temurin'
java-version: '17'

- name: Run integration tests
run: |
uv run cli.py integration-tests \
--plugin-path ${{ github.workspace }}/.pattern-syntax-plugin/pattern-syntax-plugin-1.0.jar \
--java-path $JAVA_HOME/bin \
--samples-dir ${{ github.workspace }}/.samples
working-directory: dev-tools
env:
CI: "1"
1 change: 1 addition & 0 deletions CHANGES.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,3 +11,4 @@

- Added CHANGELOG validation in Pull Requests ([#63](https://github.com/Nifacy/c4-patterns/issues/63))
- Added source code formatting support ([#33](https://github.com/Nifacy/c4-patterns/issues/33))
- Added integration tests for syntax plugin ([#68](https://github.com/Nifacy/c4-patterns/issues/68))
3 changes: 2 additions & 1 deletion dev-tools/.gitignore
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
__pycache__/
.venv/
*.pyc
*.pyc
.cache/
12 changes: 0 additions & 12 deletions dev-tools/CHANGES.md

This file was deleted.

89 changes: 89 additions & 0 deletions dev-tools/_cached_downloader.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
import hashlib
from pathlib import Path
import logging
from typing import Final
import requests
import shutil

import _logging_tools


# TODO: add support of case when 2 urls have same cache key
class _CacheManager:
def __init__(self, cache_path: Path) -> None:
self.__cache_path = self.__validate_cache_path(cache_path)

def get_cache_file(self, key: str) -> Path | None:
cache_path = self.__cache_path / key
return cache_path if cache_path.exists() else None

def save_cache_file(self, key: str, src: Path) -> None:
cache_path = self.__cache_path / key
shutil.copy(src, cache_path)

@staticmethod
def __validate_cache_path(cache_path: Path) -> Path:
if not cache_path.exists():
cache_path.mkdir(parents=True)

if not cache_path.is_dir():
raise ValueError(f"'{cache_path}' is not directory")

return cache_path


class CachedDownloader:
_LOG_PREFIX: Final = "CachedDownloader"

def __init__(self, log: logging.Logger, cache_path: Path) -> None:
self.__log = _logging_tools.with_prefix(log, self._LOG_PREFIX)
self.__cache_manager = _CacheManager(cache_path)

def install_file(
self,
url: str,
output_path: Path,
*,
percent_threshold: float = 10.0,
) -> None:
self.__log.debug(f"Install content from url '{url}' ...")

cache_key = self.__get_cache_key(url)
if (cache_path := self.__cache_manager.get_cache_file(cache_key)) is not None:
self.__log.debug("Use cached content")
shutil.copy(cache_path, output_path)

else:
self.__log.debug("Not found cached value. Install from server ...")

with requests.get(url, stream=True) as response:
with output_path.open("wb") as file:
total_installed_bytes = 0
last_logged_percent = 0.0
content_length = response.headers.get("Content-Length", None)
file_size = int(content_length) if content_length is not None else None

for chunk in response.iter_content(chunk_size=8_192):
if not isinstance(chunk, bytes) or not chunk:
continue

file.write(chunk)
total_installed_bytes += len(chunk)

if file_size is None:
continue

percent = total_installed_bytes / file_size * 100.0

if percent - last_logged_percent < percent_threshold:
continue

self.__log.debug(f"Installed {percent:.2f}%")
last_logged_percent = percent

self.__cache_manager.save_cache_file(cache_key, output_path)
self.__log.debug("Cache saved")

@staticmethod
def __get_cache_key(url: str) -> str:
return hashlib.sha256(url.encode("utf-8")).hexdigest()
84 changes: 84 additions & 0 deletions dev-tools/_exporter_factory.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
import stat
import sys
from typing import Final, Protocol
import zipfile

import _exporters
from _cached_downloader import CachedDownloader
import _logging_tools
import _exporter_release

from pathlib import Path
import logging


_STRUCTURIZR_CLI_ARCHIVE_NAME: Final = "structurizr-cli.zip"
_STRUCTURIZR_CLI_DIR: Final = "structurizr-cli"
_STRUCTURIZR_CLI_SHELL_FILE: Final = "structurizr.sh"


class ExporterFactory(Protocol):
def __call__(self, java_path: Path, syntax_plugin_path: Path) -> _exporters.StructurizrWorkspaceExporter:
...


def _get_structurizr_cli_exporter_factory(downloader: CachedDownloader, release: _exporter_release.StructurizrCliRelease, temp_dir_path: Path, log: logging.Logger) -> ExporterFactory:
structurizr_archive_path = temp_dir_path / _STRUCTURIZR_CLI_ARCHIVE_NAME
structurizr_cli_dir = temp_dir_path / _STRUCTURIZR_CLI_DIR

with _logging_tools.log_action(log, "Install structurizr cli"):
downloader.install_file(
url=release.url,
output_path=structurizr_archive_path,
)

with _logging_tools.log_action(log, "Extract structurizr cli"):
with zipfile.ZipFile(structurizr_archive_path) as archive:
archive.extractall(structurizr_cli_dir)

if sys.platform != "win32":
script_path = structurizr_cli_dir / _STRUCTURIZR_CLI_SHELL_FILE
current_permissions = script_path.stat().st_mode
script_path.chmod(current_permissions | stat.S_IXUSR)

def _create_structurizr_cli_exporter(java_path: Path, syntax_plugin_path: Path) -> _exporters.StructurizrCli:
return _exporters.StructurizrCli(
structurizr_cli_dir=structurizr_cli_dir,
java_path=java_path,
syntax_plugin_path=syntax_plugin_path,
)

return _create_structurizr_cli_exporter


def _get_structurizr_lite_exporter_factory(downloader: CachedDownloader, release: _exporter_release.StructurizrLiteRelease, temp_dir_path: Path, log: logging.Logger) -> ExporterFactory:
structurizr_lite_dir = temp_dir_path / "structurizr-lite"
structurizr_lite_dir.mkdir()

structurizr_lite_war_file = structurizr_lite_dir / "structurizr-lite.war"

with _logging_tools.log_action(log, "Install structurizr lite"):
downloader.install_file(
url=release.url,
output_path=structurizr_lite_war_file,
)

def _create_structurizr_lite_exporter(java_path: Path, syntax_plugin_path: Path) -> _exporters.StructurizrLite:
return _exporters.StructurizrLite(
structurizr_lite_dir=structurizr_lite_dir,
java_path=java_path,
syntax_plugin_path=syntax_plugin_path,
stdout_path=temp_dir_path / "stdout.txt",
stderr_path=temp_dir_path / "stderr.txt",
log=log,
)

return _create_structurizr_lite_exporter


def get_exporter_factory(downloader: CachedDownloader, release: _exporter_release.ExporterRelease, temp_dir_path: Path, log: logging.Logger) -> ExporterFactory:
match release:
case _exporter_release.StructurizrCliRelease() as cli_release:
return _get_structurizr_cli_exporter_factory(downloader, cli_release, temp_dir_path, log)
case _exporter_release.StructurizrLiteRelease() as lite_release:
return _get_structurizr_lite_exporter_factory(downloader, lite_release, temp_dir_path, log)
22 changes: 22 additions & 0 deletions dev-tools/_exporter_release.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import dataclasses


@dataclasses.dataclass(frozen=True, slots=True)
class StructurizrCliRelease:
version: str
url: str

def __str__(self) -> str:
return f"StructurizrCli(version='{self.version}')"


@dataclasses.dataclass(frozen=True, slots=True)
class StructurizrLiteRelease:
version: str
url: str

def __str__(self) -> str:
return f"StructurizrLite(version='{self.version}')"


type ExporterRelease = StructurizrCliRelease | StructurizrLiteRelease
16 changes: 16 additions & 0 deletions dev-tools/_exporters/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
from ._interface import StructurizrWorkspaceExporter
from ._interface import ExportedWorkspace
from ._interface import ExportResult
from ._interface import ExportFailure
from ._structurizr_cli import StructurizrCli
from ._structurizr_lite import StructurizrLite


__all__ = [
"ExportedWorkspace",
"ExportFailure",
"ExportResult",
"StructurizrCli",
"StructurizrWorkspaceExporter",
"StructurizrLite",
]
20 changes: 20 additions & 0 deletions dev-tools/_exporters/_interface.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
from dataclasses import dataclass
from typing import Any, NewType, Protocol
from pathlib import Path


ExportedWorkspace = NewType("ExportedWorkspace", dict[str, Any])


@dataclass(frozen=True, slots=True)
class ExportFailure:
error_message: str


type ExportResult = ExportedWorkspace | ExportFailure


class StructurizrWorkspaceExporter(Protocol):
def export_to_json(self, workspace_path: Path) -> ExportResult: ...

def close(self) -> None: ...
84 changes: 84 additions & 0 deletions dev-tools/_exporters/_structurizr_cli.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
import json
from pathlib import Path
import subprocess
import sys
from typing import Final, Iterable
import os

from ._interface import ExportedWorkspace
from ._interface import ExportResult
from ._interface import ExportFailure
from ._interface import StructurizrWorkspaceExporter


class StructurizrCliProcessError(Exception):
def __init__(self, command: Iterable[str], exit_code: int, stdout: str, stderr: str) -> None:
self.command = command
self.exit_code = exit_code
self.stdout = stdout
self.stderr = stderr

def __str__(self) -> str:
return f"Structurizr CLI command {self.command} returned non-zero exit status {self.exit_code}\nStdout:\n{self.stdout}\nStderr:\n{self.stderr}"


class StructurizrCli(StructurizrWorkspaceExporter):
_EXEC_NAME: Final = (
"structurizr.bat" if sys.platform == "win32" else "structurizr.sh"
)
_OUTPUT_DIR: Final = "output"

def __init__(
self, structurizr_cli_dir: Path, java_path: Path, syntax_plugin_path: Path
):
self.__structurizr_cli_dir = structurizr_cli_dir
self.__java_path = java_path
self.__syntax_plugin_path = syntax_plugin_path

def export_to_json(self, workspace_path: Path) -> ExportResult:
executable_path = (self.__structurizr_cli_dir / self._EXEC_NAME).absolute()
output_dir = (self.__structurizr_cli_dir / self._OUTPUT_DIR).absolute()

command = [
str(executable_path),
"export",
"--format",
"json",
"--output",
str(output_dir),
"--workspace",
str(workspace_path.absolute()),
]

process = subprocess.run(
command,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
env={
"PATH": f"{os.environ['PATH']}:{self.__java_path.absolute()}",
"JAVA_TOOL_OPTIONS": f"-javaagent:{self.__syntax_plugin_path.absolute()}",
},
encoding="utf-8",
errors="replace",
)

try:
process.check_returncode()
except subprocess.SubprocessError:
if process.returncode == 1:
return ExportFailure(process.stderr)

raise StructurizrCliProcessError(
command=command,
exit_code=process.returncode,
stdout=process.stdout,
stderr=process.stderr,
)

workspace_name = workspace_path.name.removesuffix(workspace_path.suffix)
converted_workspace_path = output_dir / f"{workspace_name}.json"

return ExportedWorkspace(json.loads(converted_workspace_path.read_text()))

def close(self) -> None:
pass
Loading