diff --git a/robotframework_dashboard/dashboard.py b/robotframework_dashboard/dashboard.py
index f620ec3e..bbc53198 100644
--- a/robotframework_dashboard/dashboard.py
+++ b/robotframework_dashboard/dashboard.py
@@ -68,6 +68,10 @@ def generate_dashboard(
'"placeholder_keywords"',
f'"{self._compress_and_encode(data["keywords"])}"',
)
+ dashboard_data = dashboard_data.replace(
+ '"placeholder_exceptions"',
+ f'"{self._compress_and_encode(data.get("exceptions", []))}"',
+ )
dashboard_data = dashboard_data.replace(
'"placeholder_amount"', str(quantity)
)
diff --git a/robotframework_dashboard/database.py b/robotframework_dashboard/database.py
index 0062735b..880ca63a 100644
--- a/robotframework_dashboard/database.py
+++ b/robotframework_dashboard/database.py
@@ -120,11 +120,15 @@ def get_keywords_length():
self.connection.cursor().execute(KEYWORD_TABLE_UPDATE_OWNER)
self.connection.commit()
keyword_table_length = get_keywords_length()
+ # exceptions table: added in 1.9.x, safe to create if missing
+ self.connection.cursor().execute(CREATE_EXCEPTIONS)
+ self.connection.commit()
else:
self.connection.cursor().execute(CREATE_RUNS)
self.connection.cursor().execute(CREATE_SUITES)
self.connection.cursor().execute(CREATE_TESTS)
self.connection.cursor().execute(CREATE_KEYWORDS)
+ self.connection.cursor().execute(CREATE_EXCEPTIONS)
self.connection.commit()
def close_database(self):
@@ -149,6 +153,7 @@ def insert_output_data(
self._insert_suites(output_data["suites"], run_alias, timezone)
self._insert_tests(output_data["tests"], run_alias, timezone)
self._insert_keywords(output_data["keywords"], run_alias, timezone)
+ self._insert_exceptions(output_data.get("exceptions", []), run_alias, timezone)
except Exception as error:
print(f" ERROR: something went wrong with the database: {error}")
@@ -216,6 +221,18 @@ def _insert_keywords(self, keywords: list, run_alias: str, timezone: str = ""):
self.connection.executemany(INSERT_INTO_KEYWORDS, full_keywords)
self.connection.commit()
+ def _insert_exceptions(self, exceptions: list, run_alias: str, timezone: str = ""):
+ """Helper function to insert the exception data"""
+ full_exceptions = []
+ for exc in exceptions:
+ exc = list(exc)
+ if timezone:
+ exc[0] = f"{exc[0]}{timezone}"
+ exc.append(run_alias)
+ full_exceptions.append(tuple(exc))
+ self.connection.executemany(INSERT_INTO_EXCEPTIONS, full_exceptions)
+ self.connection.commit()
+
@staticmethod
def _get_local_timezone_offset():
"""Helper function to get the local machine's timezone offset as a string like +01:00"""
@@ -239,7 +256,7 @@ def _has_timezone_offset(run_start: str):
def get_data(self):
"""This function gets all the data in the database"""
- data, runs, suites, tests, keywords, aliases = {}, [], [], [], [], {}
+ data, runs, suites, tests, keywords, exceptions, aliases = {}, [], [], [], [], [], {}
name_labels = {}
local_tz = self._get_local_timezone_offset()
alias_counter = 1
@@ -338,6 +355,21 @@ def get_data(self):
name_prefix_lookup.get(row["run_start"][:19], ""))
keywords.append(row)
data["keywords"] = keywords
+ # Get exceptions from exceptions table
+ try:
+ exception_rows = self.connection.cursor().execute(SELECT_FROM_EXCEPTIONS).fetchall()
+ for exception_row in exception_rows:
+ row = self._dict_from_row(exception_row)
+ if not self._has_timezone_offset(row["run_start"]):
+ row["run_start"] = f"{row['run_start']}{local_tz}"
+ row["run_alias"] = aliases.get(row["run_start"],
+ alias_prefix_lookup.get(row["run_start"][:19], ""))
+ row["run_name"] = name_labels.get(row["run_start"],
+ name_prefix_lookup.get(row["run_start"][:19], ""))
+ exceptions.append(row)
+ except Exception:
+ pass # table may not exist in older databases
+ data["exceptions"] = exceptions
return data
def _dict_from_row(self, row: sqlite3.Row):
@@ -490,6 +522,12 @@ def _remove_run(self, run_start: str):
self.connection.cursor().execute(
DELETE_FROM_KEYWORDS.format(run_start=run_start)
)
+ try:
+ self.connection.cursor().execute(
+ DELETE_FROM_EXCEPTIONS.format(run_start=run_start)
+ )
+ except Exception:
+ pass # table may not exist in older databases
self.connection.commit()
def vacuum_database(self):
diff --git a/robotframework_dashboard/js/filter.js b/robotframework_dashboard/js/filter.js
index c3203e44..8aa1d820 100644
--- a/robotframework_dashboard/js/filter.js
+++ b/robotframework_dashboard/js/filter.js
@@ -1,6 +1,6 @@
import { settings, get_run_label } from './variables/settings.js';
import { compareRunIds } from './variables/graphs.js';
-import { runs, suites, tests, keywords, unified_dashboard_title } from './variables/data.js';
+import { runs, suites, tests, keywords, exceptions, unified_dashboard_title } from './variables/data.js';
import { show_loading_overlay, hide_loading_overlay, strip_tz_suffix } from './common.js';
import { set_local_storage_item } from './localstorage.js';
import {
@@ -9,6 +9,7 @@ import {
filteredSuites,
filteredTests,
filteredKeywords,
+ filteredExceptions,
selectedRunSetting,
selectedTagSetting
} from './variables/globals.js';
@@ -29,16 +30,19 @@ function setup_filtered_data_and_filters() {
filteredSuites = remove_milliseconds(suites)
filteredTests = remove_milliseconds(tests)
filteredKeywords = remove_milliseconds(keywords)
+ filteredExceptions = remove_milliseconds(exceptions)
// convert timezones if enabled (must run before remove_timezones so the offset is still present)
filteredRuns = convert_timezone(filteredRuns);
filteredSuites = convert_timezone(filteredSuites);
filteredTests = convert_timezone(filteredTests);
filteredKeywords = convert_timezone(filteredKeywords);
+ filteredExceptions = convert_timezone(filteredExceptions);
// remove timezone display if disabled
filteredRuns = remove_timezones(filteredRuns);
filteredSuites = remove_timezones(filteredSuites);
filteredTests = remove_timezones(filteredTests);
filteredKeywords = remove_timezones(filteredKeywords);
+ filteredExceptions = remove_timezones(filteredExceptions);
// filter run data
filteredRuns = filter_runs(filteredRuns);
filteredRuns = filter_runtags(filteredRuns);
@@ -50,6 +54,7 @@ function setup_filtered_data_and_filters() {
filteredSuites = filter_data(filteredSuites);
filteredTests = filter_data(filteredTests);
filteredKeywords = filter_data(filteredKeywords);
+ filteredExceptions = filter_data(filteredExceptions);
// re-sort all filtered data by wall-clock run_start so mixed-timezone datasets
// appear in the correct chronological order on graphs (timestamps may have been
// converted or had their offsets stripped above, so re-sort here is the source of truth)
@@ -57,6 +62,7 @@ function setup_filtered_data_and_filters() {
filteredSuites = sort_wall_clock(filteredSuites);
filteredTests = sort_wall_clock(filteredTests);
filteredKeywords = sort_wall_clock(filteredKeywords);
+ filteredExceptions = sort_wall_clock(filteredExceptions);
// set titles with amount of filtered items
const runAmount = Object.keys(filteredRuns).length
const message = `
showing ${runAmount} of ${filteredAmount} runs
`
diff --git a/robotframework_dashboard/js/graph_creation/all.js b/robotframework_dashboard/js/graph_creation/all.js
index f915037a..55208cd1 100644
--- a/robotframework_dashboard/js/graph_creation/all.js
+++ b/robotframework_dashboard/js/graph_creation/all.js
@@ -63,6 +63,7 @@ import {
create_keyword_most_failed_graph,
create_keyword_most_time_consuming_graph,
create_keyword_most_used_graph,
+ create_keyword_exceptions_graph,
update_keyword_statistics_graph,
update_keyword_times_run_graph,
update_keyword_total_duration_graph,
@@ -71,7 +72,8 @@ import {
update_keyword_max_duration_graph,
update_keyword_most_failed_graph,
update_keyword_most_time_consuming_graph,
- update_keyword_most_used_graph
+ update_keyword_most_used_graph,
+ update_keyword_exceptions_graph
} from "./keyword.js";
import {
create_compare_statistics_graph,
@@ -86,10 +88,12 @@ import {
create_suite_table,
create_test_table,
create_keyword_table,
+ create_exception_table,
update_run_table,
update_suite_table,
update_test_table,
- update_keyword_table
+ update_keyword_table,
+ update_exception_table
} from "./tables.js";
// function that creates all graphs from scratch - used on first load of each tab
@@ -129,6 +133,7 @@ function create_dashboard_graphs() {
create_keyword_most_failed_graph();
create_keyword_most_time_consuming_graph();
create_keyword_most_used_graph();
+ create_keyword_exceptions_graph();
} else if (settings.menu.compare) {
create_compare_statistics_graph();
create_compare_suite_duration_graph();
@@ -138,6 +143,7 @@ function create_dashboard_graphs() {
create_suite_table();
create_test_table();
create_keyword_table();
+ create_exception_table();
}
}
@@ -179,6 +185,7 @@ function update_dashboard_graphs() {
update_keyword_most_failed_graph();
update_keyword_most_time_consuming_graph();
update_keyword_most_used_graph();
+ update_keyword_exceptions_graph();
} else if (settings.menu.compare) {
update_compare_statistics_graph();
update_compare_suite_duration_graph();
@@ -188,6 +195,7 @@ function update_dashboard_graphs() {
update_suite_table();
update_test_table();
update_keyword_table();
+ update_exception_table();
}
}
diff --git a/robotframework_dashboard/js/graph_creation/keyword.js b/robotframework_dashboard/js/graph_creation/keyword.js
index 3df62be2..efc8cadc 100644
--- a/robotframework_dashboard/js/graph_creation/keyword.js
+++ b/robotframework_dashboard/js/graph_creation/keyword.js
@@ -1,10 +1,12 @@
import { settings } from "../variables/settings.js";
-import { inFullscreen, inFullscreenGraph } from "../variables/globals.js";
+import { inFullscreen, inFullscreenGraph, filteredExceptions } from "../variables/globals.js";
import { get_statistics_graph_data } from "../graph_data/statistics.js";
import { get_duration_graph_data } from "../graph_data/duration.js";
import { get_graph_config } from "../graph_data/graph_config.js";
+import { get_exceptions_data } from "../graph_data/exceptions.js";
import { create_chart, update_chart } from "./chart_factory.js";
import { build_most_failed_config, build_most_time_consuming_config } from "./config_helpers.js";
+import { update_height } from "../graph_data/helpers.js";
// build functions
function _build_keyword_statistics_config() {
@@ -51,6 +53,83 @@ function _build_keyword_most_used_config() {
return build_most_time_consuming_config("keywordMostUsed", "keyword", "Keyword", filteredKeywords, "onlyLastRunKeywordMostUsed", "Most Used", true, (info, name) => `${name}: ran ${info.timesRun} times`);
}
+function _build_keyword_exceptions_config() {
+ const data = get_exceptions_data(settings.graphTypes.keywordExceptionsGraphType, filteredExceptions);
+ const graphData = data[0];
+ const callbackData = data[1];
+ const pointMeta = data[2] || null;
+ var config;
+ const limit = inFullscreen && inFullscreenGraph.includes("keywordExceptions") ? 50 : 10;
+ if (settings.graphTypes.keywordExceptionsGraphType == "bar") {
+ config = get_graph_config("bar", graphData, `Top ${limit}`, "Exception", "Count");
+ config.options.plugins.legend = { display: false };
+ config.options.plugins.tooltip = {
+ callbacks: {
+ label: function (tooltipItem) {
+ return callbackData[tooltipItem.label];
+ },
+ },
+ };
+ config.options.scales.x = {
+ ticks: {
+ minRotation: 45,
+ maxRotation: 45,
+ callback: function (value, index) {
+ return this.getLabelForValue(value).slice(0, 40);
+ },
+ },
+ title: {
+ display: settings.show.axisTitles,
+ text: "Exception",
+ },
+ };
+ delete config.options.onClick;
+ } else if (settings.graphTypes.keywordExceptionsGraphType == "timeline") {
+ config = get_graph_config("timeline", graphData, `Top ${limit}`, "Run", "Exception");
+ config.options.plugins.tooltip = {
+ callbacks: {
+ label: function (context) {
+ const runLabel = callbackData[context.raw.x[0]];
+ const exceptionLabel = context.raw.y;
+ const key = `${exceptionLabel}::${context.raw.x[0]}`;
+ const meta = pointMeta ? pointMeta[key] : null;
+ if (!meta) return `Run: ${runLabel}`;
+ return [
+ `Run: ${runLabel}`,
+ `Count: ${meta.amount}`,
+ `Message: ${meta.message.length > 120 ? meta.message.substring(0, 120) + "..." : meta.message}`,
+ ];
+ },
+ },
+ };
+ config.options.scales.x = {
+ ticks: {
+ minRotation: 45,
+ maxRotation: 45,
+ stepSize: 1,
+ callback: function (value, index, ticks) {
+ return callbackData[this.getLabelForValue(value)];
+ },
+ },
+ title: {
+ display: settings.show.axisTitles,
+ text: "Run",
+ },
+ type: "timelineScale",
+ };
+ config.options.scales.y.ticks = {
+ callback: function (value, index, ticks) {
+ return this.getLabelForValue(value).slice(0, 40);
+ },
+ autoSkip: false,
+ };
+ delete config.options.onClick;
+ if (!settings.show.dateLabels) { config.options.scales.x.ticks.display = false }
+ }
+ update_height("keywordExceptionsVertical", config.data.labels.length, settings.graphTypes.keywordExceptionsGraphType);
+ return config;
+}
+
// create functions
function create_keyword_statistics_graph() { create_chart("keywordStatisticsGraph", _build_keyword_statistics_config); }
function create_keyword_times_run_graph() { create_chart("keywordTimesRunGraph", _build_keyword_times_run_config); }
@@ -61,6 +140,7 @@ function create_keyword_max_duration_graph() { create_chart("keywordMaxDurationG
function create_keyword_most_failed_graph() { create_chart("keywordMostFailedGraph", _build_keyword_most_failed_config); }
function create_keyword_most_time_consuming_graph() { create_chart("keywordMostTimeConsumingGraph", _build_keyword_most_time_consuming_config); }
function create_keyword_most_used_graph() { create_chart("keywordMostUsedGraph", _build_keyword_most_used_config); }
+function create_keyword_exceptions_graph() { create_chart("keywordExceptionsGraph", _build_keyword_exceptions_config); }
// update functions
function update_keyword_statistics_graph() { update_chart("keywordStatisticsGraph", _build_keyword_statistics_config); }
@@ -72,6 +152,7 @@ function update_keyword_max_duration_graph() { update_chart("keywordMaxDurationG
function update_keyword_most_failed_graph() { update_chart("keywordMostFailedGraph", _build_keyword_most_failed_config); }
function update_keyword_most_time_consuming_graph() { update_chart("keywordMostTimeConsumingGraph", _build_keyword_most_time_consuming_config); }
function update_keyword_most_used_graph() { update_chart("keywordMostUsedGraph", _build_keyword_most_used_config); }
+function update_keyword_exceptions_graph() { update_chart("keywordExceptionsGraph", _build_keyword_exceptions_config); }
export {
create_keyword_statistics_graph,
@@ -83,6 +164,7 @@ export {
create_keyword_most_failed_graph,
create_keyword_most_time_consuming_graph,
create_keyword_most_used_graph,
+ create_keyword_exceptions_graph,
update_keyword_statistics_graph,
update_keyword_times_run_graph,
update_keyword_total_duration_graph,
@@ -91,5 +173,6 @@ export {
update_keyword_max_duration_graph,
update_keyword_most_failed_graph,
update_keyword_most_time_consuming_graph,
- update_keyword_most_used_graph
+ update_keyword_most_used_graph,
+ update_keyword_exceptions_graph
};
\ No newline at end of file
diff --git a/robotframework_dashboard/js/graph_creation/tables.js b/robotframework_dashboard/js/graph_creation/tables.js
index e6e31162..583fd757 100644
--- a/robotframework_dashboard/js/graph_creation/tables.js
+++ b/robotframework_dashboard/js/graph_creation/tables.js
@@ -1,4 +1,4 @@
-import { filteredRuns, filteredSuites, filteredTests, filteredKeywords } from "../variables/globals.js";
+import { filteredRuns, filteredSuites, filteredTests, filteredKeywords, filteredExceptions } from "../variables/globals.js";
// data builder functions
function _get_run_table_data() {
@@ -30,6 +30,12 @@ function _get_keyword_table_data() {
]);
}
+function _get_exception_table_data() {
+ return filteredExceptions.map(exception => [
+ exception.run_start, exception.message, exception.amount, exception.run_alias,
+ ]);
+}
+
// column definitions
const runColumns = [
{ title: "run" }, { title: "full_name" }, { title: "name" }, { title: "total" },
@@ -52,6 +58,9 @@ const keywordColumns = [
{ title: "average_execution_time" }, { title: "min_execution_time" },
{ title: "max_execution_time" }, { title: "alias" }, { title: "owner" },
];
+const exceptionColumns = [
+ { title: "run" }, { title: "message" }, { title: "amount" }, { title: "alias" },
+];
// create functions
function create_data_table(tableId, columns, getDataFn) {
@@ -66,6 +75,7 @@ function create_run_table() { create_data_table("runTable", runColumns, _get_run
function create_suite_table() { create_data_table("suiteTable", suiteColumns, _get_suite_table_data); }
function create_test_table() { create_data_table("testTable", testColumns, _get_test_table_data); }
function create_keyword_table() { create_data_table("keywordTable", keywordColumns, _get_keyword_table_data); }
+function create_exception_table() { create_data_table("exceptionTable", exceptionColumns, _get_exception_table_data); }
// update functions
function update_data_table(tableId, columns, getDataFn) {
@@ -78,14 +88,17 @@ function update_run_table() { update_data_table("runTable", runColumns, _get_run
function update_suite_table() { update_data_table("suiteTable", suiteColumns, _get_suite_table_data); }
function update_test_table() { update_data_table("testTable", testColumns, _get_test_table_data); }
function update_keyword_table() { update_data_table("keywordTable", keywordColumns, _get_keyword_table_data); }
+function update_exception_table() { update_data_table("exceptionTable", exceptionColumns, _get_exception_table_data); }
export {
create_run_table,
create_suite_table,
create_test_table,
create_keyword_table,
+ create_exception_table,
update_run_table,
update_suite_table,
update_test_table,
- update_keyword_table
+ update_keyword_table,
+ update_exception_table
};
\ No newline at end of file
diff --git a/robotframework_dashboard/js/graph_data/exceptions.js b/robotframework_dashboard/js/graph_data/exceptions.js
new file mode 100644
index 00000000..7c53b0d3
--- /dev/null
+++ b/robotframework_dashboard/js/graph_data/exceptions.js
@@ -0,0 +1,95 @@
+import { settings, get_run_label } from "../variables/settings.js";
+import { inFullscreen, inFullscreenGraph } from "../variables/globals.js";
+import { failedConfig } from "../variables/chartconfig.js";
+import { convert_timeline_data } from "./helpers.js";
+import { strip_tz_suffix } from "../common.js";
+
+// function to prepare the data in the correct format for exceptions graphs
+function get_exceptions_data(graphType, filteredData) {
+ // Aggregate: message → [{run_start, amount, run_alias, run_name}]
+ const data = new Map();
+ for (const value of filteredData) {
+ if (!data.has(value.message)) {
+ data.set(value.message, []);
+ }
+ data.get(value.message).push(value);
+ }
+ const limit = inFullscreen && inFullscreenGraph.includes("keywordExceptions") ? 50 : 10;
+ // Sort messages by total count descending
+ const sortedData = [...data.entries()].sort((a, b) => {
+ const totalB = b[1].reduce((sum, v) => sum + v.amount, 0);
+ const totalA = a[1].reduce((sum, v) => sum + v.amount, 0);
+ return totalB - totalA;
+ });
+
+ if (graphType === "bar") {
+ const labels = [];
+ const datasets = [];
+ const callbackData = {};
+ let count = 0;
+ for (const [message, entries] of sortedData) {
+ if (count === limit) break;
+ const total = entries.reduce((sum, v) => sum + v.amount, 0);
+ labels.push(message);
+ datasets.push(total);
+ callbackData[message] = entries.map(e => `${get_run_label(e)}: ${e.amount}`);
+ count++;
+ }
+ const graphData = {
+ labels,
+ datasets: [{
+ data: datasets,
+ ...failedConfig,
+ }],
+ };
+ return [graphData, callbackData];
+ } else if (graphType === "timeline") {
+ const labels = [];
+ const runStartsSet = new Set();
+ const runLabelsSet = new Set();
+ let count = 0;
+ for (const [message, entries] of sortedData) {
+ if (count === limit) break;
+ labels.push(message);
+ entries.forEach(e => runStartsSet.add(e.run_start));
+ count++;
+ }
+ const runStarts = Array.from(runStartsSet).sort((a, b) =>
+ new Date(strip_tz_suffix(a)).getTime() - new Date(strip_tz_suffix(b)).getTime()
+ );
+ var datasets = [];
+ let runAxis = 0;
+ const pointMeta = {};
+ for (const runStart of runStarts) {
+ for (const label of labels) {
+ const entries = (data.get(label) || []).filter(e => e.run_start === runStart);
+ if (entries.length > 0) {
+ const entry = entries[0];
+ pointMeta[`${label}::${runAxis}`] = {
+ amount: entry.amount,
+ message: entry.message,
+ };
+ datasets.push({
+ label: label,
+ data: [{ x: [runAxis, runAxis + 1], y: label }],
+ ...failedConfig,
+ });
+ runLabelsSet.add(get_run_label(entry));
+ }
+ }
+ runAxis++;
+ }
+ datasets = convert_timeline_data(datasets);
+ const runStartsArray = (settings.show.aliases === "alias" || settings.show.aliases === "run_name")
+ ? Array.from(runLabelsSet) : runStarts;
+ const graphData = {
+ labels,
+ datasets,
+ };
+ return [graphData, runStartsArray, pointMeta];
+ }
+}
+
+export {
+ get_exceptions_data
+};
diff --git a/robotframework_dashboard/js/variables/data.js b/robotframework_dashboard/js/variables/data.js
index 15f132d9..c94cd636 100644
--- a/robotframework_dashboard/js/variables/data.js
+++ b/robotframework_dashboard/js/variables/data.js
@@ -3,6 +3,7 @@ const runs = decode_and_decompress("placeholder_runs");
const suites = decode_and_decompress("placeholder_suites");
const tests = decode_and_decompress("placeholder_tests");
const keywords = decode_and_decompress("placeholder_keywords");
+const exceptions = decode_and_decompress("placeholder_exceptions");
function decode_and_decompress(base64Str) {
if (base64Str.includes("placeholder_")) return [];
@@ -27,6 +28,7 @@ export {
suites,
tests,
keywords,
+ exceptions,
message_config,
force_json_config,
json_config,
diff --git a/robotframework_dashboard/js/variables/globals.js b/robotframework_dashboard/js/variables/globals.js
index ddf6d2fe..61328e8d 100644
--- a/robotframework_dashboard/js/variables/globals.js
+++ b/robotframework_dashboard/js/variables/globals.js
@@ -15,6 +15,7 @@ var filteredRuns;
var filteredSuites;
var filteredTests;
var filteredKeywords;
+var filteredExceptions;
// vars to keep track of grids
var gridUnified = null
@@ -136,6 +137,7 @@ export {
filteredSuites,
filteredTests,
filteredKeywords,
+ filteredExceptions,
gridUnified,
gridRun,
gridSuite,
diff --git a/robotframework_dashboard/js/variables/graphmetadata.js b/robotframework_dashboard/js/variables/graphmetadata.js
index 97019c38..f5839d1e 100644
--- a/robotframework_dashboard/js/variables/graphmetadata.js
+++ b/robotframework_dashboard/js/variables/graphmetadata.js
@@ -620,6 +620,14 @@ const graphMetadata = [
`,
},
+ {
+ key: "keywordExceptions",
+ label: "Keyword Exceptions",
+ defaultType: "bar",
+ viewOptions: ["Bar", "Timeline"],
+ hasFullscreenButton: true,
+ html: _graphHtml("keywordExceptions", "Exceptions", ["Bar", "Timeline"], { hasVertical: true }),
+ },
{
key: "compareStatistics",
label: "Compare Statistics",
@@ -711,6 +719,15 @@ const graphMetadata = [
information: null,
html: _tableHtml("keywordTable", "Keyword"),
},
+ {
+ key: "exceptionTable",
+ label: "Table Exception",
+ defaultType: "table",
+ viewOptions: ["Table"],
+ hasFullscreenButton: false,
+ information: null,
+ html: _tableHtml("exceptionTable", "Exception"),
+ },
];
export { graphMetadata };
\ No newline at end of file
diff --git a/robotframework_dashboard/js/variables/information.js b/robotframework_dashboard/js/variables/information.js
index d133ca3c..29fbcf38 100644
--- a/robotframework_dashboard/js/variables/information.js
+++ b/robotframework_dashboard/js/variables/information.js
@@ -133,6 +133,8 @@ It helps identify tests with inconsistent execution times, which might be flaky
"keywordMostTimeConsumingGraphTimeline": "Timeline: Displays the slowest keyword for each run on a timeline. For every run, only the single most time-consuming keyword is shown. The regular view shows the Top 10 most frequently slowest keywords; fullscreen mode expands the list to the Top 50. When 'Only Last Run' is enabled, the timeline shows only the latest run, highlighting its Top 10 (or Top 50 in fullscreen) most time-consuming keywords by duration.",
"keywordMostUsedGraphBar": "Bar: Displays keywords ranked by how frequently they were used across all runs. Each bar represents how many times a keyword appeared in total. The regular view shows the Top 10 most used keywords; fullscreen mode expands the list to the Top 50. When 'Only Last Run' is enabled, this graph instead shows the Top 10 (or Top 50 in fullscreen) most used keywords *within the latest run only*, ranked by occurrence count.",
"keywordMostUsedGraphTimeline": "Timeline: Displays keyword usage trends over time. For each run, the most frequently used keyword (or keywords) is shown, illustrating how keyword usage changes across runs. The regular view highlights the Top 10 most frequently used keywords overall; fullscreen mode expands the list to the Top 50. When 'Only Last Run' is enabled, the timeline shows only the latest run, highlighting its Top 10 (or Top 50 in fullscreen) most used keywords by frequency.",
+ "keywordExceptionsGraphBar": "Bar: Displays exception messages caught by TRY/EXCEPT blocks, ranked by how many times each exception occurred across all runs. The regular view shows the Top 10; fullscreen mode expands to the Top 50.",
+ "keywordExceptionsGraphTimeline": "Timeline: Displays exception messages caught by TRY/EXCEPT blocks over time. Each row is a distinct exception message; each cell represents a run where that exception occurred, with the count shown.",
"filterProfileInformation": `Filter Profiles let you save and reapply a named combination of filter settings.
- Add Profile: enters edit mode where you name the profile and choose which filters to include using the checkmarks that appear next to each filter. Checkmarks are pre-filled based on which filters currently differ from their default (load-time) state.
- Save Profile: saves the profile with the selected filter values under the given name.
@@ -189,7 +191,7 @@ const graphKeys = [
"testStatistics", "testDuration", "testDurationDeviation", "testMessages",
"testMostFlaky", "testRecentMostFlaky", "testMostFailed", "testRecentMostFailed", "testMostTimeConsuming",
"keywordStatistics", "keywordTimesRun", "keywordTotalDuration", "keywordAverageDuration",
- "keywordMinDuration", "keywordMaxDuration", "keywordMostFailed", "keywordMostTimeConsuming", "keywordMostUsed",
+ "keywordMinDuration", "keywordMaxDuration", "keywordMostFailed", "keywordMostTimeConsuming", "keywordMostUsed", "keywordExceptions",
"compareStatistics", "compareSuiteDuration", "compareTests",
];
graphKeys.forEach(key => {
@@ -199,7 +201,7 @@ graphKeys.forEach(key => {
informationMap[`${key}Hidden`] = "Show Graph";
});
-["runTable", "suiteTable", "testTable", "keywordTable"].forEach(key => {
+["runTable", "suiteTable", "testTable", "keywordTable", "exceptionTable"].forEach(key => {
informationMap[`${key}MoveUp`] = "Move Up";
informationMap[`${key}MoveDown`] = "Move Down";
informationMap[`${key}Shown`] = "Hide Table";
diff --git a/robotframework_dashboard/processors.py b/robotframework_dashboard/processors.py
index 88736d97..f688edca 100644
--- a/robotframework_dashboard/processors.py
+++ b/robotframework_dashboard/processors.py
@@ -2,6 +2,7 @@
from robot.result.model import TestCase, TestSuite, Keyword
from datetime import datetime
from pathlib import Path
+from collections import Counter
class OutputProcessor:
@@ -43,6 +44,8 @@ def get_output_data(self):
self.execution_result.visit(
KeywordProcessor(self.generation_time, keyword_list)
)
+ exception_processor = ExceptionProcessor(self.generation_time)
+ self.execution_result.visit(exception_processor)
average_keyword_list = self.calculate_keyword_averages(keyword_list)
run_list, suite_list = self.merge_run_and_suite_metadata(run_list, suite_list)
return {
@@ -50,6 +53,7 @@ def get_output_data(self):
"suites": suite_list,
"tests": test_list,
"keywords": average_keyword_list,
+ "exceptions": exception_processor.get_aggregated_exceptions(),
}
def calculate_keyword_averages(self, keyword_list: list):
@@ -302,3 +306,37 @@ def end_keyword(self, keyword: Keyword):
owner,
)
)
+
+
+class ExceptionProcessor(ResultVisitor):
+ """Processor to collect exception messages from keywords inside TRY/EXCEPT blocks"""
+
+ def __init__(self, run_time: datetime):
+ self.run_time = run_time
+ self._try_depth = 0
+ self._exception_counts = Counter()
+ self._child_failed = []
+
+ def start_try_branch(self, branch):
+ if branch.type == "TRY":
+ self._try_depth += 1
+
+ def end_try_branch(self, branch):
+ if branch.type == "TRY":
+ self._try_depth -= 1
+
+ def start_keyword(self, keyword: Keyword):
+ self._child_failed.append(False)
+
+ def end_keyword(self, keyword: Keyword):
+ child_already_counted = self._child_failed.pop()
+ if self._try_depth > 0 and keyword.failed and keyword.message and not child_already_counted:
+ self._exception_counts[keyword.message[:150]] += 1
+ if self._child_failed and keyword.failed:
+ self._child_failed[-1] = True
+
+ def get_aggregated_exceptions(self):
+ return [
+ (self.run_time, message, count)
+ for message, count in self._exception_counts.items()
+ ]
diff --git a/robotframework_dashboard/queries.py b/robotframework_dashboard/queries.py
index 5581a79a..76444032 100644
--- a/robotframework_dashboard/queries.py
+++ b/robotframework_dashboard/queries.py
@@ -2,6 +2,7 @@
CREATE_SUITES = """ CREATE TABLE IF NOT EXISTS suites ("run_start" TEXT, "full_name" TEXT, "name" TEXT, "total" INTEGER, "passed" INTEGER, "failed" INTEGER, "skipped" INTEGER, "elapsed_s" TEXT, "start_time" TEXT, "run_alias" TEXT, "id" TEXT); """
CREATE_TESTS = """ CREATE TABLE IF NOT EXISTS tests ("run_start" TEXT, "full_name" TEXT, "name" TEXT, "passed" INTEGER, "failed" INTEGER, "skipped" INTEGER, "elapsed_s" TEXT, "start_time" TEXT, "message" TEXT, "tags" TEXT, "run_alias" TEXT, "id" TEXT); """
CREATE_KEYWORDS = """ CREATE TABLE IF NOT EXISTS keywords ("run_start" TEXT, "name" TEXT, "passed" INTEGER, "failed" INTEGER, "skipped" INTEGER, "times_run" TEXT, "total_time_s" TEXT, "average_time_s" TEXT, "min_time_s" TEXT, "max_time_s" TEXT, "run_alias" TEXT, "owner" TEXT); """
+CREATE_EXCEPTIONS = """ CREATE TABLE IF NOT EXISTS exceptions ("run_start" TEXT, "message" TEXT, "amount" INTEGER, "run_alias" TEXT); """
RUN_TABLE_EXISTS = (
"""SELECT name FROM sqlite_master WHERE type='table' AND name='runs';"""
@@ -29,6 +30,7 @@
INSERT_INTO_SUITES = """ INSERT INTO suites VALUES (?,?,?,?,?,?,?,?,?,?,?) """
INSERT_INTO_TESTS = """ INSERT INTO tests VALUES (?,?,?,?,?,?,?,?,?,?,?,?) """
INSERT_INTO_KEYWORDS = """ INSERT INTO keywords VALUES (?,?,?,?,?,?,?,?,?,?,?,?) """
+INSERT_INTO_EXCEPTIONS = """ INSERT INTO exceptions VALUES (?,?,?,?) """
SELECT_FROM_RUNS = """ SELECT * FROM runs """
SELECT_RUN_STARTS_FROM_RUNS = """ SELECT run_start FROM runs """
@@ -36,12 +38,13 @@
SELECT_FROM_SUITES = """ SELECT * FROM suites """
SELECT_FROM_TESTS = """ SELECT * FROM tests """
SELECT_FROM_KEYWORDS = """ SELECT * FROM keywords """
+SELECT_FROM_EXCEPTIONS = """ SELECT * FROM exceptions """
DELETE_FROM_RUNS = """ DELETE FROM runs WHERE run_start="{run_start}" """
DELETE_FROM_SUITES = """ DELETE FROM suites WHERE run_start="{run_start}" """
DELETE_FROM_TESTS = """ DELETE FROM tests WHERE run_start="{run_start}" """
DELETE_FROM_KEYWORDS = """ DELETE FROM keywords WHERE run_start="{run_start}" """
-
+DELETE_FROM_EXCEPTIONS = """ DELETE FROM exceptions WHERE run_start="{run_start}" """
UPDATE_RUN_PATH = """ UPDATE runs SET path="{path}" WHERE run_start="{run_start}" """
VACUUM_DATABASE = """ VACUUM """
diff --git a/tests/python/test_dashboard.py b/tests/python/test_dashboard.py
index 3d33fdf6..3e80ee84 100644
--- a/tests/python/test_dashboard.py
+++ b/tests/python/test_dashboard.py
@@ -279,6 +279,16 @@ def test_make_paths_relative_multiple_runs(tmp_path):
assert result[2]["path"] == ""
+def test_make_paths_relative_value_error_keeps_absolute(tmp_path):
+ from unittest.mock import patch
+ abs_path = str(tmp_path / "output.xml")
+ dashboard = tmp_path / "dashboard.html"
+ runs = [{"run_start": "2025-01-01", "path": abs_path}]
+ with patch("robotframework_dashboard.dashboard.relpath", side_effect=ValueError("different drive")):
+ result = _make_rel(dashboard, runs)
+ assert result[0]["path"] == abs_path
+
+
def test_generate_dashboard_uselogs_embeds_relative_paths(tmp_path):
import json, zlib, base64, re
output_xml = tmp_path / "output.xml"
diff --git a/tests/python/test_database.py b/tests/python/test_database.py
index 502aa842..15eea960 100644
--- a/tests/python/test_database.py
+++ b/tests/python/test_database.py
@@ -516,3 +516,92 @@ def test_remove_runs_exception_branch_logs_error(populated_db):
console = populated_db.remove_runs(["index=not_a_number"])
populated_db.close_database()
assert "ERROR" in console
+
+
+# --- _insert_exceptions / get_data exceptions / _remove_run exceptions ---
+
+def test_insert_and_get_exceptions(tmp_path):
+ """Exceptions inserted via _insert_exceptions appear in get_data()."""
+ db = DatabaseProcessor(tmp_path / "exc.db")
+ processor = OutputProcessor(SAMPLE_XML)
+ processor.get_run_start()
+ data = processor.get_output_data()
+ run_start = data["runs"][0][0]
+ data["exceptions"] = [(run_start, "Timeout error", 3)]
+ db.open_database()
+ db.insert_output_data(data, [], "alias", SAMPLE_XML, None, timezone="+02:00")
+ result = db.get_data()
+ db.close_database()
+ assert len(result["exceptions"]) == 1
+ assert result["exceptions"][0]["message"] == "Timeout error"
+ assert result["exceptions"][0]["amount"] == 3
+
+
+def test_insert_exceptions_without_timezone(tmp_path):
+ """Exceptions inserted without timezone get local tz appended by get_data()."""
+ db = DatabaseProcessor(tmp_path / "exc_notz.db")
+ processor = OutputProcessor(SAMPLE_XML)
+ processor.get_run_start()
+ data = processor.get_output_data()
+ run_start = data["runs"][0][0]
+ data["exceptions"] = [(run_start, "Connection refused", 1)]
+ db.open_database()
+ db.insert_output_data(data, [], "alias", SAMPLE_XML, None, timezone="")
+ result = db.get_data()
+ db.close_database()
+ assert len(result["exceptions"]) == 1
+ assert re.match(r".*[+-]\d{2}:\d{2}$", result["exceptions"][0]["run_start"])
+
+
+def test_remove_run_deletes_exceptions(tmp_path):
+ """_remove_run also deletes from the exceptions table."""
+ db = DatabaseProcessor(tmp_path / "exc_rm.db")
+ processor = OutputProcessor(SAMPLE_XML)
+ processor.get_run_start()
+ data = processor.get_output_data()
+ run_start = data["runs"][0][0]
+ data["exceptions"] = [(run_start, "Error X", 2)]
+ db.open_database()
+ db.insert_output_data(data, [], "alias", SAMPLE_XML, None, timezone="+01:00")
+ assert len(db.get_data()["exceptions"]) == 1
+ db.remove_runs(["index=0"])
+ assert len(db.get_data()["exceptions"]) == 0
+ db.close_database()
+
+
+def test_remove_run_without_exceptions_table(tmp_path):
+ """_remove_run handles missing exceptions table gracefully (legacy DB)."""
+ db_path = tmp_path / "legacy_exc.db"
+ conn = sqlite3.connect(str(db_path))
+ conn.execute("""CREATE TABLE runs ("run_start" TEXT, "full_name" TEXT, "name" TEXT,
+ "total" INTEGER, "passed" INTEGER, "failed" INTEGER, "skipped" INTEGER,
+ "elapsed_s" TEXT, "start_time" TEXT, "tags" TEXT, "run_alias" TEXT,
+ "path" TEXT, "run_config" TEXT, "project_version" TEXT,
+ UNIQUE(run_start, full_name))""")
+ conn.execute("""CREATE TABLE suites ("run_start" TEXT, "full_name" TEXT, "name" TEXT,
+ "total" INTEGER, "passed" INTEGER, "failed" INTEGER, "skipped" INTEGER,
+ "elapsed_s" TEXT, "start_time" TEXT, "run_alias" TEXT, "id" TEXT)""")
+ conn.execute("""CREATE TABLE tests ("run_start" TEXT, "full_name" TEXT, "name" TEXT,
+ "passed" INTEGER, "failed" INTEGER, "skipped" INTEGER, "elapsed_s" TEXT,
+ "start_time" TEXT, "message" TEXT, "tags" TEXT, "run_alias" TEXT, "id" TEXT)""")
+ conn.execute("""CREATE TABLE keywords ("run_start" TEXT, "name" TEXT,
+ "passed" INTEGER, "failed" INTEGER, "skipped" INTEGER, "times_run" TEXT,
+ "total_time_s" TEXT, "average_time_s" TEXT, "min_time_s" TEXT,
+ "max_time_s" TEXT, "run_alias" TEXT, "library" TEXT)""")
+ conn.execute("INSERT INTO runs VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?)",
+ ("2020-01-01 00:00:00+00:00", "Suite", "Suite", 1, 1, 0, 0, "1.0",
+ "2020-01-01", "tag", "alias", "/path.xml", "{}", None))
+ conn.commit()
+ conn.close()
+ db = DatabaseProcessor(str(db_path))
+ db.open_database()
+ # Drop exceptions table to simulate legacy DB that somehow lost it
+ db.connection.cursor().execute("DROP TABLE IF EXISTS exceptions")
+ db.connection.commit()
+ # get_data should handle missing exceptions table
+ data = db.get_data()
+ assert data["exceptions"] == []
+ # remove_runs should also handle missing exceptions table
+ db.remove_runs(["index=0"])
+ assert len(db.get_data()["runs"]) == 0
+ db.close_database()
diff --git a/tests/python/test_processors.py b/tests/python/test_processors.py
index 6105ba19..5021adec 100644
--- a/tests/python/test_processors.py
+++ b/tests/python/test_processors.py
@@ -1,7 +1,8 @@
from datetime import datetime
from pathlib import Path
+from types import SimpleNamespace
import pytest
-from robotframework_dashboard.processors import OutputProcessor
+from robotframework_dashboard.processors import OutputProcessor, ExceptionProcessor
OUTPUTS_DIR = Path(__file__).parent.parent / "robot" / "resources" / "outputs"
SAMPLE_XML = OUTPUTS_DIR / "output-20250313-002134.xml"
@@ -31,7 +32,7 @@ def test_get_run_start_all_xml_files(all_xml_outputs):
def test_get_output_data_returns_expected_keys(processed_output):
data = processed_output.get_output_data()
- assert set(data.keys()) == {"runs", "suites", "tests", "keywords"}
+ assert set(data.keys()) == {"runs", "suites", "tests", "keywords", "exceptions"}
def test_get_output_data_runs_has_one_entry(processed_output):
@@ -127,6 +128,121 @@ def test_calculate_keyword_averages_skipped_counted():
result = _make_processor().calculate_keyword_averages(keyword_list)
assert result[0][4] == 3 # skipped
+# --- ExceptionProcessor ---
+
+def _branch(branch_type):
+ return SimpleNamespace(type=branch_type)
+
+
+def _keyword(failed, message=""):
+ return SimpleNamespace(failed=failed, message=message)
+
+
+def test_exception_processor_start_try_branch_increments_depth():
+ ep = ExceptionProcessor(datetime(2025, 1, 1))
+ ep.start_try_branch(_branch("TRY"))
+ assert ep._try_depth == 1
+
+
+def test_exception_processor_start_try_branch_ignores_non_try():
+ ep = ExceptionProcessor(datetime(2025, 1, 1))
+ ep.start_try_branch(_branch("EXCEPT"))
+ assert ep._try_depth == 0
+
+
+def test_exception_processor_end_try_branch_decrements_depth():
+ ep = ExceptionProcessor(datetime(2025, 1, 1))
+ ep._try_depth = 1
+ ep.end_try_branch(_branch("TRY"))
+ assert ep._try_depth == 0
+
+
+def test_exception_processor_end_try_branch_ignores_non_try():
+ ep = ExceptionProcessor(datetime(2025, 1, 1))
+ ep._try_depth = 1
+ ep.end_try_branch(_branch("EXCEPT"))
+ assert ep._try_depth == 1
+
+
+def test_exception_processor_end_keyword_records_failure_in_try():
+ ep = ExceptionProcessor(datetime(2025, 1, 1))
+ ep._try_depth = 1
+ kw = _keyword(failed=True, message="Something went wrong")
+ ep.start_keyword(kw)
+ ep.end_keyword(kw)
+ assert ep._exception_counts["Something went wrong"] == 1
+
+
+def test_exception_processor_end_keyword_ignores_outside_try():
+ ep = ExceptionProcessor(datetime(2025, 1, 1))
+ kw = _keyword(failed=True, message="Error")
+ ep.start_keyword(kw)
+ ep.end_keyword(kw)
+ assert len(ep._exception_counts) == 0
+
+
+def test_exception_processor_end_keyword_ignores_passed():
+ ep = ExceptionProcessor(datetime(2025, 1, 1))
+ ep._try_depth = 1
+ kw = _keyword(failed=False, message="OK")
+ ep.start_keyword(kw)
+ ep.end_keyword(kw)
+ assert len(ep._exception_counts) == 0
+
+
+def test_exception_processor_end_keyword_ignores_empty_message():
+ ep = ExceptionProcessor(datetime(2025, 1, 1))
+ ep._try_depth = 1
+ kw = _keyword(failed=True, message="")
+ ep.start_keyword(kw)
+ ep.end_keyword(kw)
+ assert len(ep._exception_counts) == 0
+
+
+def test_exception_processor_aggregates_same_message():
+ ep = ExceptionProcessor(datetime(2025, 1, 1))
+ ep._try_depth = 1
+ kw1 = _keyword(failed=True, message="Timeout")
+ ep.start_keyword(kw1)
+ ep.end_keyword(kw1)
+ kw2 = _keyword(failed=True, message="Timeout")
+ ep.start_keyword(kw2)
+ ep.end_keyword(kw2)
+ assert ep._exception_counts["Timeout"] == 2
+
+
+def test_exception_processor_get_aggregated_exceptions():
+ run_time = datetime(2025, 1, 1)
+ ep = ExceptionProcessor(run_time)
+ ep._try_depth = 1
+ for msg in ["Error A", "Error A", "Error B"]:
+ kw = _keyword(failed=True, message=msg)
+ ep.start_keyword(kw)
+ ep.end_keyword(kw)
+ result = ep.get_aggregated_exceptions()
+ assert len(result) == 2
+ by_msg = {r[1]: r for r in result}
+ assert by_msg["Error A"] == (run_time, "Error A", 2)
+ assert by_msg["Error B"] == (run_time, "Error B", 1)
+
+
+def test_exception_processor_get_aggregated_exceptions_empty():
+ ep = ExceptionProcessor(datetime(2025, 1, 1))
+ assert ep.get_aggregated_exceptions() == []
+
+
+def test_exception_processor_only_counts_leaf_keyword():
+ ep = ExceptionProcessor(datetime(2025, 1, 1))
+ ep._try_depth = 1
+ # Simulate: parent keyword wraps a child that fails
+ parent = _keyword(failed=True, message="Error")
+ child = _keyword(failed=True, message="Error")
+ ep.start_keyword(parent)
+ ep.start_keyword(child)
+ ep.end_keyword(child) # leaf — counted
+ ep.end_keyword(parent) # parent — should NOT be counted
+ assert ep._exception_counts["Error"] == 1
+
def test_calculate_keyword_averages_from_real_xml(processed_output):
data = processed_output.get_output_data()