diff --git a/.gitignore b/.gitignore index b062295..b5bd9d7 100644 --- a/.gitignore +++ b/.gitignore @@ -28,3 +28,5 @@ docs/mkdocs/site/** /app.js atest/output_testdoc_custom_template.pdf atest/output_testdoc.pdf +atest/styles.css +atest/app.js diff --git a/atest/acceptance/test_cli.py b/atest/acceptance/test_cli.py index 684b125..5c4b07e 100644 --- a/atest/acceptance/test_cli.py +++ b/atest/acceptance/test_cli.py @@ -37,6 +37,49 @@ 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, SUITES", "suite.robot", "output"]) + + assert result.exit_code == 0, result.output + assert received_args[-1]["dashboard_by"] == ["tags", "subdirectories", "suites"] + + 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, suites") + assert args.dashboard_by == ["subdirectories", "tags", "suites"] + 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,suites", robot, str(output)]) + + assert result.exit_code == 0, result.output + assert '"dashboard_by": ["tags", "subdirectories", "suites"]' in output.read_text(encoding="utf-8") + app_script = (tmp_path / "app.js").read_text(encoding="utf-8") + assert "Tests per Subdirectory" in app_script + assert "Tests per Suite" in app_script + assert "Tests per Tag" in app_script + assert '
' in app_script + + def test_cli_cmd(): parent_dir = Path(__file__).parent.parent robot = os.path.join(parent_dir, "testdata", "acceptance") diff --git a/docs/mkdocs/docs/cli/cli.md b/docs/mkdocs/docs/cli/cli.md index 7f4122a..255669a 100644 --- a/docs/mkdocs/docs/cli/cli.md +++ b/docs/mkdocs/docs/cli/cli.md @@ -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), ``suites`` (Tests per Suite) | Default: ``tags``. Cards are shown in the given order, e.g. ``--dashboard-by=tags,subdirectories,suites``. TOML: ``dashboard_by = ["tags", "subdirectories", "suites"]`` | :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: | diff --git a/src/testdoc/cli.py b/src/testdoc/cli.py index 831b63b..4993754 100644 --- a/src/testdoc/cli.py +++ b/src/testdoc/cli.py @@ -19,6 +19,18 @@ ╚═╝ ╚══════╝╚══════╝ ╚═╝ ╚═════╝ ╚══════╝ ╚═════╝ """ +DASHBOARD_SECTIONS = ("tags", "subdirectories", "suites") + + +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. @@ -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, suites (e.g. --dashboard-by=tags,subdirectories,suites)", + )(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( @@ -105,6 +124,7 @@ def _collect_common_args( # noqa mkdocs_template_dir, include, exclude, + dashboard_by, output_format, configfile, verbose, @@ -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, @@ -183,6 +204,7 @@ def generate( # noqa mkdocs_template_dir, include, exclude, + dashboard_by, output_format, configfile, verbose, @@ -204,6 +226,7 @@ def generate( # noqa mkdocs_template_dir, include, exclude, + dashboard_by, output_format, configfile, verbose, @@ -230,6 +253,7 @@ def management( # noqa mkdocs_template_dir, include, exclude, + dashboard_by, output_format, configfile, verbose, @@ -253,6 +277,7 @@ def management( # noqa mkdocs_template_dir, include, exclude, + dashboard_by, output_format, configfile, verbose, diff --git a/src/testdoc/helper/cliargs.py b/src/testdoc/helper/cliargs.py index 13c31f5..0632239 100644 --- a/src/testdoc/helper/cliargs.py +++ b/src/testdoc/helper/cliargs.py @@ -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) diff --git a/src/testdoc/html/rendering/webapp.py b/src/testdoc/html/rendering/webapp.py index dc7b340..b6ed1ed 100644 --- a/src/testdoc/html/rendering/webapp.py +++ b/src/testdoc/html/rendering/webapp.py @@ -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 @@ -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(" right[1] - left[1] || left[0].localeCompare(right[0])); } - function renderTagStatistics(tests) { - const tags = tagCounts(tests); - if (!tags.length) return '
No tags defined.
'; + function directoryName(suite) { + return String(suite.suite.source || "").split(/[\\/]/).filter(Boolean).pop() || suite.suite.name; + } + + function sortSuitesByTestCount(suites, getLabel) { + return suites + .map((suite) => ({ suite, label: getLabel(suite), count: allTests(suite).length })) + .sort((left, right) => right.count - left.count || left.label.localeCompare(right.label)) + .map(({ suite }) => suite); + } - const maximum = tags[0][1]; - return `
${tags - .map(([tag, count]) => { + function subdirectoryCounts(suite, parentPath = []) { + let counts = []; + const subdirectories = sortSuitesByTestCount( + suite.suites.filter((subSuite) => subSuite.suite.is_folder), + directoryName + ); + for (const subSuite of subdirectories) { + 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 suiteCounts(suite) { + return allSuites(suite) + .filter((item) => !item.suite.is_folder) + .map((item) => ({ label: item.suite.name, count: allTests(item).length })) + .sort((left, right) => right.count - left.count || left.label.localeCompare(right.label)); + } + + function renderStatisticBars(rows, emptyMessage) { + if (!rows.length) return `
${escapeHtml(emptyMessage)}
`; + + const maximum = Math.max(1, ...rows.map((row) => row.count)); + return `
${rows + .map(({ label, title, depth, count }) => { const width = Math.max(4, Math.round((count / maximum) * 100)); - return `
${escapeHtml(tag)}
${count}
`; + const indent = depth ? ` style="padding-left:${depth * 14}px"` : ""; + return `
${escapeHtml(label)}
${count}
`; }) .join("")}
`; } + 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 renderSuiteStatistics(suite) { + return renderStatisticBars(suiteCounts(suite), "No suites found."); + } + + function renderDashboardBreakdown(title, content) { + return `
${escapeHtml(title)}${content}
`; + } + + function renderDashboardBreakdowns(tests) { + return DASHBOARD_BY.map((section) => { + if (section === "tags") return renderDashboardBreakdown("Tests per Tag", renderTagStatistics(tests)); + if (section === "subdirectories") return renderDashboardBreakdown("Tests per Subdirectory", renderSubdirectoryStatistics(ROOT)); + if (section === "suites") return renderDashboardBreakdown("Tests per Suite", renderSuiteStatistics(ROOT)); + return ""; + }).join(""); + } + function latestHistoryEntry(test) { return test.history.length ? test.history[test.history.length - 1] : null; } @@ -341,10 +403,7 @@ ${metricCard(documentedTests, "Documented Test Cases")} ${metricCard(tags.size, "Unique Tags")}
-
-

Tests per Tag

- ${renderTagStatistics(tests)} -
+ ${renderDashboardBreakdowns(tests)}

Test Documentation

Browse the test case repository to inspect suites, test cases, documentation, tags, and implementation steps.

@@ -372,10 +431,7 @@

Execution Trend

${renderExecutionTrendChart(executionSeries)}
-
-

Tests per Tag

- ${renderTagStatistics(tests)} -
+ ${renderDashboardBreakdowns(tests)}

Last recorded run

${lastRun ? `

${escapeHtml(formatRecordedAt(lastRun.recorded_at))}

` : '
No results recorded yet. Run "testdoc management" with a --report-file to record results.
'} diff --git a/src/testdoc/management/templates/styles.css b/src/testdoc/management/templates/styles.css index 0992695..c2e6b15 100644 --- a/src/testdoc/management/templates/styles.css +++ b/src/testdoc/management/templates/styles.css @@ -353,6 +353,37 @@ html, body { letter-spacing: -0.01em; } +.dashboard-breakdown > .card-title { + display: flex; + align-items: center; + justify-content: space-between; + margin: 0; + cursor: pointer; + list-style: none; +} + +.dashboard-breakdown > .card-title::-webkit-details-marker { + display: none; +} + +.dashboard-breakdown > .card-title::after { + content: ""; + width: 8px; + height: 8px; + border-right: 2px solid var(--color-text-muted); + border-bottom: 2px solid var(--color-text-muted); + transform: rotate(45deg); + transition: transform var(--transition-fast); +} + +.dashboard-breakdown[open] > .card-title { + margin-bottom: 14px; +} + +.dashboard-breakdown[open] > .card-title::after { + transform: rotate(225deg); +} + .grid-metrics { display: grid; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));