Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
39 changes: 39 additions & 0 deletions atest/acceptance/test_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,45 @@ def test_cli_defaults_do_not_override_toml_and_explicit_options_override_toml(tm
assert received_args[-1]["include"] == ["from-cli"]
assert received_args[-1]["output_format"] == "md"

def test_cli_dashboard_by_is_parsed_and_validated(monkeypatch):
received_args = []
monkeypatch.setattr(TestDoc, "main", lambda self: received_args.append(CommandLineArguments().all_as_dict.copy()))
runner = CliRunner()

result = runner.invoke(main, ["--dashboard-by", "Tags, subdirectories", "suite.robot", "output"])

assert result.exit_code == 0, result.output
assert received_args[-1]["dashboard_by"] == ["tags", "subdirectories"]

result = runner.invoke(main, ["--dashboard-by", "tags,owners", "suite.robot", "output"])

assert result.exit_code != 0
assert "--dashboard-by" in result.output


def test_dashboard_by_accepts_toml_string_or_list():
args = CommandLineArguments()
args.set_args(dashboard_by="subdirectories, tags")
assert args.dashboard_by == ["subdirectories", "tags"]
args.set_args(dashboard_by=["Subdirectories"])
assert args.dashboard_by == ["subdirectories"]
args.set_args()
assert args.dashboard_by == ["tags"]


def test_cli_dashboard_by_is_embedded_in_web_app(tmp_path):
parent_dir = Path(__file__).parent.parent
robot = os.path.join(parent_dir, "testdata", "acceptance")
output = tmp_path / "dashboard.html"
runner = CliRunner()

result = runner.invoke(main, ["--dashboard-by=tags,subdirectories", robot, str(output)])

assert result.exit_code == 0, result.output
assert '"dashboard_by": ["tags", "subdirectories"]' in output.read_text(encoding="utf-8")
assert "Tests per Subdirectory" in (tmp_path / "app.js").read_text(encoding="utf-8")


def test_cli_cmd():
parent_dir = Path(__file__).parent.parent
robot = os.path.join(parent_dir, "testdata", "acceptance")
Expand Down
1 change: 1 addition & 0 deletions docs/mkdocs/docs/cli/cli.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
| ``--custom-pdf-template`` | Define a custom Jinja2 template for PDF overview and suite pages | Only used with ``-f pdf`` | :x: |
| ``--include``, ``-i`` | Define Robot Framework tags to include only specific tests | Can be used multiple times | :x: |
| ``--exclude``, ``-e`` | Define Robot Framework tags to exclude specific tests | Can be used multiple times | :x: |
| ``--dashboard-by`` | Comma-separated breakdowns shown on the dashboard: ``tags`` (Tests per Tag), ``subdirectories`` (Tests per Subdirectory) | Default: ``tags``. Cards are shown in the given order, e.g. ``--dashboard-by=tags,subdirectories``. TOML: ``dashboard_by = ["tags", "subdirectories"]`` | :x: |
| ``--output-format``, ``-f`` | Choose the output format: ``html``, ``json``, ``md`` or ``pdf`` | Default: ``html``. See [Output Formats](../usage/output_formats.md) | :x: |
| ``--configfile``, ``-c`` | Define a path to a ``.toml`` configuration file | - | :x: |
| ``--verbose``, ``-v`` | Print debug output | - | :x: |
Expand Down
25 changes: 25 additions & 0 deletions src/testdoc/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,18 @@
╚═╝ ╚══════╝╚══════╝ ╚═╝ ╚═════╝ ╚══════╝ ╚═════╝
"""

DASHBOARD_SECTIONS = ("tags", "subdirectories")


def _parse_dashboard_by(ctx, param, value):
if value is None:
return None
sections = [item.strip().lower() for item in value.split(",") if item.strip()]
invalid = [item for item in sections if item not in DASHBOARD_SECTIONS]
if not sections or invalid:
raise click.BadParameter(f"expected a comma-separated list of {', '.join(DASHBOARD_SECTIONS)} - got '{value}'")
return sections


class DefaultCommandGroup(click.Group):
"""Group that falls back to `default_command` when the first arg is not a known subcommand.
Expand Down Expand Up @@ -60,6 +72,13 @@ def common_options(f):
show_default=True,
help="Output format for the test documentation",
)(f)
f = click.option(
"--dashboard-by",
default="tags",
show_default=True,
callback=_parse_dashboard_by,
help="Comma-separated breakdowns shown on the dashboard: tags, subdirectories (e.g. --dashboard-by=tags,subdirectories)",
)(f)
f = click.option("-e", "--exclude", multiple=True, required=False, help="Exclude test cases with given tags")(f)
f = click.option("-i", "--include", multiple=True, required=False, help="Include test cases with given tags")(f)
f = click.option(
Expand Down Expand Up @@ -105,6 +124,7 @@ def _collect_common_args( # noqa
mkdocs_template_dir,
include,
exclude,
dashboard_by,
output_format,
configfile,
verbose,
Expand All @@ -123,6 +143,7 @@ def _collect_common_args( # noqa
"mkdocs_template_dir": mkdocs_template_dir,
"include": list(include),
"exclude": list(exclude),
"dashboard_by": dashboard_by,
"output_format": output_format,
"config_file": configfile,
"verbose_mode": verbose,
Expand Down Expand Up @@ -183,6 +204,7 @@ def generate( # noqa
mkdocs_template_dir,
include,
exclude,
dashboard_by,
output_format,
configfile,
verbose,
Expand All @@ -204,6 +226,7 @@ def generate( # noqa
mkdocs_template_dir,
include,
exclude,
dashboard_by,
output_format,
configfile,
verbose,
Expand All @@ -230,6 +253,7 @@ def management( # noqa
mkdocs_template_dir,
include,
exclude,
dashboard_by,
output_format,
configfile,
verbose,
Expand All @@ -253,6 +277,7 @@ def management( # noqa
mkdocs_template_dir,
include,
exclude,
dashboard_by,
output_format,
configfile,
verbose,
Expand Down
7 changes: 7 additions & 0 deletions src/testdoc/helper/cliargs.py
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,13 @@ def include(self):
def exclude(self):
return self._args.get("exclude", [])

@property
def dashboard_by(self):
value = self._args.get("dashboard_by", ["tags"])
if isinstance(value, str):
value = value.split(",")
return [item.strip().lower() for item in value if item.strip()]

@property
def config_file(self):
return self._args.get("config_file", None)
Expand Down
2 changes: 2 additions & 0 deletions src/testdoc/html/rendering/webapp.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@

from jinja2 import Environment, FileSystemLoader, select_autoescape

from ...helper.cliargs import CommandLineArguments
from ...parser.testcaseparser import TestCaseParser
from .jinja2_support.lexer_support import highlight_robot_in_pre

Expand Down Expand Up @@ -72,6 +73,7 @@ def _render(self, management_suite: Any, output_dir: Path, show_history: bool) -
"generated_at": datetime.now(timezone.utc).isoformat(),
"root": _serialize_suite(management_suite),
"show_history": show_history,
"dashboard_by": CommandLineArguments().dashboard_by,
}
data_json = json.dumps(data).replace("</", "<\\/")

Expand Down
60 changes: 45 additions & 15 deletions src/testdoc/management/templates/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
const DATA = JSON.parse(document.getElementById("management-data").textContent);
const ROOT = DATA.root;
const SHOW_HISTORY = DATA.show_history !== false;
const DASHBOARD_BY = Array.isArray(DATA.dashboard_by) ? DATA.dashboard_by : ["tags"];

const viewRoot = document.getElementById("view-root");
const headerTitle = document.getElementById("header-title");
Expand Down Expand Up @@ -67,19 +68,54 @@
return Object.entries(counts).sort((left, right) => right[1] - left[1] || left[0].localeCompare(right[0]));
}

function renderTagStatistics(tests) {
const tags = tagCounts(tests);
if (!tags.length) return '<div class="empty-state">No tags defined.</div>';
function directoryName(suite) {
return String(suite.suite.source || "").split(/[\\/]/).filter(Boolean).pop() || suite.suite.name;
}

function subdirectoryCounts(suite, parentPath = []) {
let counts = [];
for (const subSuite of suite.suites) {
if (!subSuite.suite.is_folder) continue;
const path = parentPath.concat([directoryName(subSuite)]);
const count = allTests(subSuite).length;
if (count) counts.push({ label: path[path.length - 1], title: path.join("/"), depth: parentPath.length, count });
counts = counts.concat(subdirectoryCounts(subSuite, path));
}
return counts;
}

function renderStatisticBars(rows, emptyMessage) {
if (!rows.length) return `<div class="empty-state">${escapeHtml(emptyMessage)}</div>`;

const maximum = tags[0][1];
return `<div class="tag-statistics">${tags
.map(([tag, count]) => {
const maximum = Math.max(...rows.map((row) => row.count));
return `<div class="tag-statistics">${rows
.map(({ label, title, depth, count }) => {
const width = Math.max(4, Math.round((count / maximum) * 100));
return `<div class="tag-stat-row"><div class="tag-stat-label" title="${escapeHtml(tag)}">${escapeHtml(tag)}</div><div class="tag-stat-track"><div class="tag-stat-bar" style="width:${width}%"><span>${count}</span></div></div></div>`;
const indent = depth ? ` style="padding-left:${depth * 14}px"` : "";
return `<div class="tag-stat-row"><div class="tag-stat-label" title="${escapeHtml(title || label)}"${indent}>${escapeHtml(label)}</div><div class="tag-stat-track"><div class="tag-stat-bar" style="width:${width}%"><span>${count}</span></div></div></div>`;
})
.join("")}</div>`;
}

function renderTagStatistics(tests) {
return renderStatisticBars(
tagCounts(tests).map(([tag, count]) => ({ label: tag, count })),
"No tags defined."
);
}

function renderSubdirectoryStatistics(suite) {
return renderStatisticBars(subdirectoryCounts(suite), "No subdirectories found.");
}

function renderDashboardBreakdowns(tests) {
return DASHBOARD_BY.map((section) => {
if (section === "tags") return `<div class="card"><h2 class="card-title">Tests per Tag</h2>${renderTagStatistics(tests)}</div>`;
if (section === "subdirectories") return `<div class="card"><h2 class="card-title">Tests per Subdirectory</h2>${renderSubdirectoryStatistics(ROOT)}</div>`;
return "";
}).join("");
}

function latestHistoryEntry(test) {
return test.history.length ? test.history[test.history.length - 1] : null;
}
Expand Down Expand Up @@ -341,10 +377,7 @@
${metricCard(documentedTests, "Documented Test Cases")}
${metricCard(tags.size, "Unique Tags")}
</div>
<div class="card">
<h2 class="card-title">Tests per Tag</h2>
${renderTagStatistics(tests)}
</div>
${renderDashboardBreakdowns(tests)}
<div class="card">
<h2 class="card-title">Test Documentation</h2>
<p>Browse the test case repository to inspect suites, test cases, documentation, tags, and implementation steps.</p>
Expand Down Expand Up @@ -372,10 +405,7 @@
<h2 class="card-title">Execution Trend</h2>
${renderExecutionTrendChart(executionSeries)}
</div>
<div class="card">
<h2 class="card-title">Tests per Tag</h2>
${renderTagStatistics(tests)}
</div>
${renderDashboardBreakdowns(tests)}
<div class="card">
<h2 class="card-title">Last recorded run</h2>
${lastRun ? `<p>${escapeHtml(formatRecordedAt(lastRun.recorded_at))}</p>` : '<div class="empty-state">No results recorded yet. Run "testdoc management" with a --report-file to record results.</div>'}
Expand Down