Skip to content
Merged
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
2 changes: 1 addition & 1 deletion docs/graphs-tables.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ The Overview tab provides a high-level summary of all test runs across projects.

| Section | Description |
|---------|-------------|
| **Latest Runs** | Displays the most recent run for each project as a card. Each card shows pass/fail/skip counts and duration, color-coded to indicate performance relative to previous runs. Clicking a project card filters the Overview to that project. |
| **Latest Runs** | Displays the most recent run for each project as a card. Each card shows pass/fail/skip counts, duration (color-coded to indicate performance relative to previous runs) and how long ago the run was executed (hover it to see the exact run timestamp). Clicking a project card filters the Overview to that project. |
| **Total Stats** | Shows aggregate statistics across all runs grouped by project: total passed, failed, skipped runs, average duration, and average pass rate. |

### Graphs
Expand Down
16 changes: 16 additions & 0 deletions robotframework_dashboard/css/components.css
Original file line number Diff line number Diff line change
Expand Up @@ -423,6 +423,22 @@
height: 200px;
}

/* relative run time, pinned to the bottom right corner of a run card without
affecting the layout of the stats/duration columns next to the donut */
.overview-card .card-body {
position: relative;
}

.run-card-run-time {
position: absolute;
right: 1rem;
bottom: 0.55rem;
color: var(--color-text-muted);
cursor: help;
font-size: 0.85em;
white-space: nowrap;
}

.overview-card {
cursor: pointer;
min-width: 300px;
Expand Down
65 changes: 65 additions & 0 deletions robotframework_dashboard/js/common.js
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,68 @@ function format_date_to_string(date) {
return `${year}-${month}-${day} ${hours}:${minutes}:${seconds}`;
}

// function to parse a run_start string ("YYYY-MM-DD HH:MM:SS[.ffffff][±HH:MM]") into a Date
// timestamps without a timezone offset are interpreted as local time, returns null when unparsable
function parse_run_start(run_start) {
if (!run_start) return null;
let value = String(run_start).trim();
let timezone = "";
const suffix = value.slice(-6);
if (/^[+-]\d{2}:\d{2}$/.test(suffix)) {
timezone = suffix;
value = value.slice(0, -6);
} else if (value.endsWith("Z")) {
timezone = "Z";
value = value.slice(0, -1);
}
// normalize to ISO-8601 with at most milliseconds, browsers handle microseconds inconsistently
value = value.replace(" ", "T").replace(/(\.\d{3})\d+$/, "$1");
const date = new Date(`${value}${timezone}`);
return isNaN(date.getTime()) ? null : date;
}

// function to format how long ago a run was executed, showing the 2 largest relevant units
// e.g. "15 minutes ago", "3 hours 20 minutes ago", "4 days 2 hours ago"
function format_relative_time(run_start, now = new Date()) {
const date = parse_run_start(run_start);
if (!date) return "";
const pluralize = (amount, unit) => `${amount} ${unit}${amount === 1 ? "" : "s"}`;
const totalSeconds = Math.floor((now.getTime() - date.getTime()) / 1000);
if (totalSeconds < 0) return "just now"; // future timestamps (clock skew/timezone mismatch)
if (totalSeconds < 60) return `${pluralize(totalSeconds, "second")} ago`;
const totalMinutes = Math.floor(totalSeconds / 60);
if (totalMinutes < 60) return `${pluralize(totalMinutes, "minute")} ago`;
const totalHours = Math.floor(totalMinutes / 60);
if (totalHours < 24) {
const minutes = totalMinutes % 60;
return minutes
? `${pluralize(totalHours, "hour")} ${pluralize(minutes, "minute")} ago`
: `${pluralize(totalHours, "hour")} ago`;
}
const days = Math.floor(totalHours / 24);
const hours = totalHours % 24;
return hours
? `${pluralize(days, "day")} ${pluralize(hours, "hour")} ago`
: `${pluralize(days, "day")} ago`;
}

// function to format a run_start into a readable absolute timestamp (used for tooltips)
// keeps the stored timezone offset when present and drops sub-second precision
function format_run_start_exact(run_start) {
if (!run_start) return "";
let value = String(run_start).trim();
let timezone = "";
const suffix = value.slice(-6);
if (/^[+-]\d{2}:\d{2}$/.test(suffix)) {
timezone = ` ${suffix}`;
value = value.slice(0, -6);
} else if (value.endsWith("Z")) {
timezone = " +00:00";
value = value.slice(0, -1);
}
return `${value.replace("T", " ").replace(/\.\d+$/, "")}${timezone}`;
}

// function to transform an output.xml path to a log.html path
function transform_file_path(filePath) {
const normalizedPath = filePath.replace(/\\/g, "/");
Expand Down Expand Up @@ -271,6 +333,9 @@ export {
space_to_camelcase,
underscore_to_camelcase,
format_date_to_string,
parse_run_start,
format_relative_time,
format_run_start_exact,
transform_file_path,
combine_paths,
add_alert,
Expand Down
10 changes: 10 additions & 0 deletions robotframework_dashboard/js/graph_creation/overview.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@ import {
compare_to_average,
transform_file_path,
format_duration,
format_relative_time,
format_run_start_exact,
debounce,
show_loading_overlay,
hide_loading_overlay,
Expand Down Expand Up @@ -134,6 +136,7 @@ function generate_overview_card_html(
isForOverview = false,
isTotalStats = false,
sectionPrefix = 'overview',
runStart = null,
) {
const normalizedProjectVersion = projectVersion ?? "None";
// ensure overview stats and project bar card ids unique
Expand Down Expand Up @@ -195,6 +198,11 @@ function generate_overview_card_html(
const totalStatsHeader = isTotalStats ? `<div>Run Stats</div>` : '';
const totalStatsAverage = isTotalStats ? `<div>Average Run Duration</div>` : '';
const logLinkHtml = log_name ? `<a href="#" onclick="event.stopPropagation(); open_log_from_path('${log_path}'); return false;" target="_blank">${log_name}</a>` : '';
// relative run time (e.g. "3 hours 20 minutes ago") with the exact datetime as tooltip
const relativeRunTime = isTotalStats ? '' : format_relative_time(runStart);
const runTimeHtml = relativeRunTime
? `<div class="run-card-run-time information" data-title="Run executed at ${format_run_start_exact(runStart)}">${relativeRunTime}</div>`
: '';
return `
<div class="col-4 overview-card" id="${projectNameForElementId}Card${idPostfix}" data-project-version="${normalizedProjectVersion}">
<div class="card border-3 border-${status}">
Expand Down Expand Up @@ -234,6 +242,7 @@ function generate_overview_card_html(
</div>
</div>
</div>
${runTimeHtml}
</div>
</div>
</div>`;
Expand Down Expand Up @@ -724,6 +733,7 @@ function create_project_run_card(run, projectName, runIndex, runNumber, passRate
isForOverview,
isTotalStats,
sectionPrefix,
run.run_start,
)
const existingRunCard = document.getElementById(`${projectNameForId}Card${runIndex}`);
if (existingRunCard) {
Expand Down
123 changes: 123 additions & 0 deletions tests/javascript/common.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,9 @@ import {
underscore_to_camelcase,
camelcase_to_underscore,
format_date_to_string,
parse_run_start,
format_relative_time,
format_run_start_exact,
transform_file_path,
combine_paths,
debounce,
Expand Down Expand Up @@ -181,6 +184,126 @@ describe('format_date_to_string', () => {
});


describe('parse_run_start', () => {
it('parses a timestamp without timezone as local time', () => {
const date = parse_run_start('2025-01-15 09:05:03');
expect(date.getTime()).toBe(new Date(2025, 0, 15, 9, 5, 3).getTime());
});

it('parses microsecond precision by truncating to milliseconds', () => {
const date = parse_run_start('2025-01-15 09:05:03.123456');
expect(date.getTime()).toBe(new Date(2025, 0, 15, 9, 5, 3, 123).getTime());
});

it('honours a +HH:MM timezone offset', () => {
const date = parse_run_start('2025-01-15 09:05:03+02:00');
expect(date.toISOString()).toBe('2025-01-15T07:05:03.000Z');
});

it('honours a -HH:MM timezone offset', () => {
const date = parse_run_start('2025-01-15 09:05:03-05:00');
expect(date.toISOString()).toBe('2025-01-15T14:05:03.000Z');
});

it('honours a Z suffix', () => {
const date = parse_run_start('2025-01-15T09:05:03Z');
expect(date.toISOString()).toBe('2025-01-15T09:05:03.000Z');
});

it('returns null for empty or invalid input', () => {
expect(parse_run_start('')).toBeNull();
expect(parse_run_start(null)).toBeNull();
expect(parse_run_start(undefined)).toBeNull();
expect(parse_run_start('not a date')).toBeNull();
});
});


describe('format_relative_time', () => {
const now = new Date(2025, 0, 15, 12, 0, 0); // Jan 15, 2025 12:00:00 local time

it('formats seconds', () => {
expect(format_relative_time('2025-01-15 11:59:15', now)).toBe('45 seconds ago');
});

it('formats a single second', () => {
expect(format_relative_time('2025-01-15 11:59:59', now)).toBe('1 second ago');
});

it('formats minutes', () => {
expect(format_relative_time('2025-01-15 11:45:00', now)).toBe('15 minutes ago');
});

it('formats a single minute', () => {
expect(format_relative_time('2025-01-15 11:59:00', now)).toBe('1 minute ago');
});

it('formats hours and minutes', () => {
expect(format_relative_time('2025-01-15 08:40:00', now)).toBe('3 hours 20 minutes ago');
});

it('omits minutes on an exact hour boundary', () => {
expect(format_relative_time('2025-01-15 09:00:00', now)).toBe('3 hours ago');
});

it('formats a single hour and minute', () => {
expect(format_relative_time('2025-01-15 10:59:00', now)).toBe('1 hour 1 minute ago');
});

it('formats days and hours', () => {
expect(format_relative_time('2025-01-11 10:00:00', now)).toBe('4 days 2 hours ago');
});

it('omits hours on an exact day boundary', () => {
expect(format_relative_time('2025-01-14 12:00:00', now)).toBe('1 day ago');
});

it('rounds down to whole units', () => {
// 14 minutes and 59.877 seconds ago
expect(format_relative_time('2025-01-15 11:45:00.123456', now)).toBe('14 minutes ago');
});

it('takes the timezone offset into account', () => {
const utcNow = new Date('2025-01-15T12:00:00Z');
expect(format_relative_time('2025-01-15 14:45:00+03:00', utcNow)).toBe('15 minutes ago');
});

it('returns "just now" for future timestamps', () => {
expect(format_relative_time('2025-01-15 12:30:00', now)).toBe('just now');
});

it('returns an empty string for missing or invalid input', () => {
expect(format_relative_time('', now)).toBe('');
expect(format_relative_time(null, now)).toBe('');
expect(format_relative_time('not a date', now)).toBe('');
});
});


describe('format_run_start_exact', () => {
it('drops microseconds', () => {
expect(format_run_start_exact('2025-01-15 09:05:03.123456')).toBe('2025-01-15 09:05:03');
});

it('keeps the timezone offset', () => {
expect(format_run_start_exact('2025-01-15 09:05:03.123456+02:00')).toBe('2025-01-15 09:05:03 +02:00');
});

it('normalizes the ISO T separator', () => {
expect(format_run_start_exact('2025-01-15T09:05:03')).toBe('2025-01-15 09:05:03');
});

it('converts a Z suffix into an offset', () => {
expect(format_run_start_exact('2025-01-15T09:05:03Z')).toBe('2025-01-15 09:05:03 +00:00');
});

it('returns an empty string for missing input', () => {
expect(format_run_start_exact('')).toBe('');
expect(format_run_start_exact(null)).toBe('');
});
});


describe('transform_file_path', () => {
it('transforms output.xml to log.html with forward slashes', () => {
expect(transform_file_path('/path/to/output.xml')).toBe('/path/to/log.html');
Expand Down
3 changes: 3 additions & 0 deletions tests/robot/resources/keywords/dashboard-keywords.resource
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,9 @@ Open Dashboard
New Context colorScheme=dark viewport={"width": 1920, "height": 1600}
New Page url=file:${ROOT_FOLDER}/robotdashboard_${DASHBOARD_INDEX}.html
Wait For Elements State selector=id=loading state=hidden
# the relative run times on the overview cards ("2 hours ago") change as time passes,
# hide them so the screenshot comparisons stay deterministic
Add Style Tag content=.run-card-run-time { visibility: hidden; }
Click selector=id=settings
Fill Text selector=id=toggleAnimationDuration txt=0
Click selector=id=closeSettings
Expand Down
Loading