From 9a1fe6029d25fe95627751805330f2dfe25c931b Mon Sep 17 00:00:00 2001 From: Tim de Groot Date: Mon, 28 Sep 2026 14:25:58 +0200 Subject: [PATCH 1/2] perf: speed up filtering and graph data preparation for large datasets (#364) Applying a filter on a large dataset spent most of its time in our own JavaScript rather than in Chart.js: - filter_data looked up every suite/test/keyword row in an array of run starts (rows x runs); it now uses a Set. - The run_start transformations (milliseconds, timezone conversion, timezone display) copied every row on every filter apply. They only depend on three settings, so the copies are cached per combination and shared with the filter option availability, which had its own cache. - The timeline views of Most Failed, Most Flaky and Messages scanned all rows for every run and label. Rows are now grouped once by label and run_start. With a message config every rule's regex is built once. - The suite section filters were read from the DOM for every row; get_suite_data_exclusion reads them once and returns the check. - sort_wall_clock computes its keys once, the test select no longer does a quadratic duplicate check, and the base64 payload is decoded with a plain loop instead of Uint8Array.from with a callback. On a 504 run / 208k test dataset applying the default filter goes from 1.18 s to 0.37 s, all runs from 8.1 s to 3.1 s, and the initial load from 3.2 s to 2.3 s. Chart data, filtered data and select options are identical to before across timestamp settings, amounts, bar/timeline views, suite paths and a message config. The message config timeline also no longer relies on a block-scoped function and an undeclared variable, which threw in strict mode. Co-Authored-By: Claude Opus 5.5 --- .../js/filter/availability.js | 39 +------- .../js/filter/pipeline.js | 53 ++++++----- .../js/filter/section_selects.js | 15 ++- .../js/graph_creation/suite.js | 13 ++- .../js/graph_data/duration.js | 5 +- .../js/graph_data/failed.js | 9 +- .../js/graph_data/flaky.js | 26 ++--- .../js/graph_data/helpers.js | 56 +++++++---- .../js/graph_data/messages.js | 33 ++++--- .../js/graph_data/statistics.js | 5 +- robotframework_dashboard/js/variables/data.js | 7 +- tests/javascript/filter.test.js | 62 +++++++++--- tests/javascript/graph_data/helpers.test.js | 95 ++++++++++++++++++- .../graph_data/messages_config.test.js | 61 ++++++++++++ 14 files changed, 329 insertions(+), 150 deletions(-) create mode 100644 tests/javascript/graph_data/messages_config.test.js diff --git a/robotframework_dashboard/js/filter/availability.js b/robotframework_dashboard/js/filter/availability.js index 69e87d35..23cc6e00 100644 --- a/robotframework_dashboard/js/filter/availability.js +++ b/robotframework_dashboard/js/filter/availability.js @@ -1,6 +1,5 @@ import { settings } from '../variables/settings.js'; -import { runs, suites } from '../variables/data.js'; -import { apply_custom_filter_dimension, apply_date_filter, apply_metadata_filter, apply_project_version_filter, apply_run_name_filter, apply_runtag_filter, build_date_range, collect_custom_filter_dimensions, convert_timezone, get_custom_filter_value, get_hidden_custom_filters, get_project_version_value, remove_milliseconds, remove_timezones } from './pipeline.js'; +import { apply_custom_filter_dimension, apply_date_filter, apply_metadata_filter, apply_project_version_filter, apply_run_name_filter, apply_runtag_filter, build_date_range, collect_custom_filter_dimensions, get_custom_filter_value, get_hidden_custom_filters, get_project_version_value, get_transformed_data } from './pipeline.js'; import { get_metadata_options } from './modal_options.js'; import { apply_suite_path_run_filter } from './suite_path.js'; import { capture_current_filters } from './profiles.js'; @@ -11,36 +10,6 @@ import { capture_current_filters } from './profiles.js'; // The counts follow the usual faceted-search rule: the count of an option in filter X is // computed with every filter except X applied, so selecting a value in X never makes the other // values of X disappear. The amount filter ("last X runs") is not a category and is left out. -let filterBaseRunsCache = { key: null, runs: null, suites: null }; - -// key of the settings that change run_start representation, and with it which runs a date -// range matches and which suites belong to which run -function get_filter_base_cache_key() { - return `${settings.show.milliseconds}|${settings.show.convertTimezone}|${settings.show.timezones}`; -} - -function apply_run_start_transformations(data) { - return remove_timezones(convert_timezone(remove_milliseconds(data))); -} - -// all runs with the same run_start transformations the filter pipeline applies, so the -// availability computation compares the same timestamps as the real filters do -function get_filter_base_runs() { - const key = get_filter_base_cache_key(); - if (filterBaseRunsCache.key !== key) { - filterBaseRunsCache = { key: key, runs: apply_run_start_transformations(runs), suites: null }; - } - return filterBaseRunsCache.runs; -} - -// suites are only needed while a suite path is selected, so they are transformed on demand -function get_filter_base_suites() { - get_filter_base_runs(); - if (filterBaseRunsCache.suites === null) { - filterBaseRunsCache.suites = apply_run_start_transformations(suites); - } - return filterBaseRunsCache.suites; -} // turn the profile object of capture_current_filters() into the selection shape the apply_* // functions take @@ -85,7 +54,7 @@ function apply_filters_except(runList, selections, facet, dimName = null, suiteL result = apply_date_filter(result, selections.dateRange.from, selections.dateRange.to); } if (selections.suitePath && selections.suitePath !== "All") { - result = apply_suite_path_run_filter(result, selections.suitePath, suiteList ?? get_filter_base_suites()); + result = apply_suite_path_run_filter(result, selections.suitePath, suiteList ?? get_transformed_data("suites")); } return result; } @@ -164,7 +133,7 @@ function compute_filter_option_availability(runList, selections, suiteList = nul // amount filter is left out for the same reason it is left out of the option counts. function get_runs_for_date_histogram() { const selections = normalize_filter_selections(capture_current_filters()); - return apply_filters_except(get_filter_base_runs(), selections, "dates"); + return apply_filters_except(get_transformed_data("runs"), selections, "dates"); } // add or update the "(X)" count of one filter option row. The count is a sibling of the @@ -237,7 +206,7 @@ function refresh_filter_option_availability() { return; } const selections = normalize_filter_selections(capture_current_filters()); - const availability = compute_filter_option_availability(get_filter_base_runs(), selections); + const availability = compute_filter_option_availability(get_transformed_data("runs"), selections); apply_availability_to_select(document.getElementById("runs"), availability.runs); apply_availability_to_select(document.getElementById("metadata"), availability.metadata); apply_availability_to_checkbox_list(document.getElementById("runTag"), availability.runTags); diff --git a/robotframework_dashboard/js/filter/pipeline.js b/robotframework_dashboard/js/filter/pipeline.js index ef78ed4c..733c6634 100644 --- a/robotframework_dashboard/js/filter/pipeline.js +++ b/robotframework_dashboard/js/filter/pipeline.js @@ -8,11 +8,26 @@ import { filter_runs_by_suite_path, filter_suite_path_data } from './suite_path. // Sort an array of run objects by wall-clock run_start (timezone offset stripped), // ensuring correct chronological order when timestamps span mixed timezone offsets. function sort_wall_clock(data) { - return [...data].sort((a, b) => { - const ak = strip_tz_suffix(a.run_start); - const bk = strip_tz_suffix(b.run_start); - return ak < bk ? -1 : ak > bk ? 1 : 0; - }); + return data + .map((item, index) => ({ item, index, key: strip_tz_suffix(item.run_start) })) + .sort((a, b) => a.key < b.key ? -1 : a.key > b.key ? 1 : a.index - b.index) + .map(entry => entry.item); +} + +// the run_start transformations only depend on these settings, so they are cached per combination +// instead of copying every row on each filter apply; the filter availability uses the same copies +const transformSourceData = { runs, suites, tests, keywords, exceptions }; +let transformedDataCache = { key: null, data: {} }; + +function get_transformed_data(name) { + const key = `${settings.show.milliseconds}|${settings.show.convertTimezone}|${settings.show.timezones}`; + if (transformedDataCache.key !== key) { + transformedDataCache = { key: key, data: {} }; + } + if (!(name in transformedDataCache.data)) { + transformedDataCache.data[name] = remove_timezones(convert_timezone(remove_milliseconds(transformSourceData[name]))); + } + return transformedDataCache.data[name]; } const dashboardPages = ["overview", "dashboard", "compare", "tables"]; @@ -38,23 +53,11 @@ function apply_custom_filter_visibility() { // function updates the data in the graphs whenever filters are updated function setup_filtered_data_and_filters() { apply_custom_filter_visibility(); - filteredRuns = remove_milliseconds(runs) - 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); + filteredRuns = get_transformed_data("runs"); + filteredSuites = get_transformed_data("suites"); + filteredTests = get_transformed_data("tests"); + filteredKeywords = get_transformed_data("keywords"); + filteredExceptions = get_transformed_data("exceptions"); // determine filteredRuns with all run-level filters (suite path + amount last) filteredRuns = filter_runs(filteredRuns); filteredRuns = filter_runtags(filteredRuns); @@ -437,8 +440,8 @@ function filter_metadata(filteredRuns) { // function to filter suites/tests/keywords based on the already filtered runs function filter_data(data) { - const validRunStarts = filteredRuns.map(v => v.run_start); - let filteredData = data.filter(v => validRunStarts.includes(v.run_start)); + const validRunStarts = new Set(filteredRuns.map(v => v.run_start)); + let filteredData = data.filter(v => validRunStarts.has(v.run_start)); if (filteredData.length > 0 && "owner" in filteredData[0]) { const libraries = settings.libraries || {}; filteredData = filteredData.filter(item => { @@ -470,8 +473,10 @@ export { get_hidden_custom_filters, get_project_version_value, get_run_start_date, + get_transformed_data, parse_custom_filters, remove_milliseconds, remove_timezones, setup_filtered_data_and_filters, + sort_wall_clock, }; diff --git a/robotframework_dashboard/js/filter/section_selects.js b/robotframework_dashboard/js/filter/section_selects.js index cb05e182..c6b9b7a3 100644 --- a/robotframework_dashboard/js/filter/section_selects.js +++ b/robotframework_dashboard/js/filter/section_selects.js @@ -103,20 +103,19 @@ function setup_tests_in_select() { const testTagsSelect = document.getElementById("testTagsSelect").value; const testSelect = document.getElementById("testSelect"); testSelect.innerHTML = ""; - const testNames = filteredTests.reduce((names, test) => { + const testNames = new Set(); + for (const test of filteredTests) { + if (testNames.has(test.name)) continue; const isInSuite = settings.switch.suitePathsTestSection ? test.full_name.includes(`${suiteSelectTests}.${test.name}`) || suiteSelectTests === "All" : test.full_name.includes(`.${suiteSelectTests}.${test.name}`) || suiteSelectTests === "All" const hasTag = testTagsSelect === "All" || test.tags.includes(testTagsSelect); - - if (isInSuite && hasTag && !names.includes(test.name)) { - names.push(test.name); + if (isInSuite && hasTag) { + testNames.add(test.name); } - - return names; - }, []); + } testSelect.options.add(new Option("All", "All")); - testNames.sort().forEach(testName => testSelect.options.add(new Option(testName, testName))); + [...testNames].sort().forEach(testName => testSelect.options.add(new Option(testName, testName))); } // function to update the available testtags to select in the filters diff --git a/robotframework_dashboard/js/graph_creation/suite.js b/robotframework_dashboard/js/graph_creation/suite.js index 00ce3fa7..313b2143 100644 --- a/robotframework_dashboard/js/graph_creation/suite.js +++ b/robotframework_dashboard/js/graph_creation/suite.js @@ -3,7 +3,7 @@ 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 { build_tooltip_meta, lookup_tooltip_meta, format_status } from '../graph_data/tooltip_helpers.js'; -import { exclude_from_suite_data } from '../graph_data/helpers.js'; +import { get_suite_data_exclusion } from '../graph_data/helpers.js'; import { setup_suites_in_suite_select } from '../filter/section_selects.js'; import { format_duration } from '../common.js'; import { dataLabelConfig } from '../variables/chartconfig.js'; @@ -120,7 +120,7 @@ function _build_suite_statistics_config() { const callbackData = data[1] const suiteSelectSuites = document.getElementById("suiteSelectSuites").value; const isCombined = suiteSelectSuites === "All Suites Combined"; - const relevantSuites = filteredSuites.filter(s => !exclude_from_suite_data("suite", s)); + const relevantSuites = get_relevant_suites(); const tooltipMeta = build_tooltip_meta(relevantSuites, 'elapsed_s', isCombined); var config; if (settings.graphTypes.suiteStatisticsGraphType == "line") { @@ -179,7 +179,7 @@ function _build_suite_duration_config() { const suiteSelectSuites = document.getElementById("suiteSelectSuites").value; const isCombined = suiteSelectSuites === "All Suites Combined"; // Filter suites the same way get_duration_graph_data does, so tooltip meta matches - const relevantSuites = filteredSuites.filter(s => !exclude_from_suite_data("suite", s)); + const relevantSuites = get_relevant_suites(); const tooltipMeta = build_tooltip_meta(relevantSuites, 'elapsed_s', isCombined); var config; if (settings.graphTypes.suiteDurationGraphType == "bar") { @@ -197,9 +197,14 @@ function _build_suite_duration_config() { return config; } +function get_relevant_suites() { + const exclude_from_suite_data = get_suite_data_exclusion("suite"); + return filteredSuites.filter(suite => !exclude_from_suite_data(suite)); +} + function _get_suite_most_filtered_data() { if (!settings.switch.sectionFiltersApplySuite) return filteredSuites; - return filteredSuites.filter(s => !exclude_from_suite_data("suite", s)); + return get_relevant_suites(); } function _build_suite_most_failed_config() { diff --git a/robotframework_dashboard/js/graph_data/duration.js b/robotframework_dashboard/js/graph_data/duration.js index ec461927..79fb1b10 100644 --- a/robotframework_dashboard/js/graph_data/duration.js +++ b/robotframework_dashboard/js/graph_data/duration.js @@ -2,7 +2,7 @@ import { settings, get_run_label } from "../variables/settings.js"; import { inFullscreen, inFullscreenGraph } from "../variables/globals.js"; import { barConfig, lineConfig } from "../variables/chartconfig.js"; import { compareRunIds } from "../variables/graphs.js"; -import { exclude_from_suite_data } from "./helpers.js"; +import { get_suite_data_exclusion } from "./helpers.js"; import { strip_tz_suffix } from "../common.js"; // function to prepare the data in the correct format for duration graphs @@ -14,6 +14,7 @@ function get_duration_graph_data(dataType, graphType, objectDataAttribute, filte const keywordSelect = document.getElementById("keywordSelect").value; const useLibraryNames = settings?.switch?.useLibraryNames === true; const limit = inFullscreen && inFullscreenGraph.includes("Duration") ? 100 : 30; + const exclude_from_suite_data = get_suite_data_exclusion(dataType); const should_include = (value) => { if (dataType === "keyword") { const keywordKey = useLibraryNames && value.owner @@ -22,7 +23,7 @@ function get_duration_graph_data(dataType, graphType, objectDataAttribute, filte if (keywordKey !== keywordSelect) return false; } - if (exclude_from_suite_data(dataType, value)) return false; + if (exclude_from_suite_data(value)) return false; if (settings.switch.suitePathsTestSection) { if (dataType === "test" && suiteSelectTests !== "All" && value.full_name !== `${suiteSelectTests}.${value.name}`) { diff --git a/robotframework_dashboard/js/graph_data/failed.js b/robotframework_dashboard/js/graph_data/failed.js index 06348e53..8146ebb5 100644 --- a/robotframework_dashboard/js/graph_data/failed.js +++ b/robotframework_dashboard/js/graph_data/failed.js @@ -1,6 +1,6 @@ import { settings, get_run_label } from "../variables/settings.js"; import { inFullscreen, inFullscreenGraph } from "../variables/globals.js"; -import { convert_timeline_data, parse_test_attempts } from "./helpers.js"; +import { convert_timeline_data, group_timeline_values, parse_test_attempts } from "./helpers.js"; import { failedConfig, rerunBorderColor, rerunBorderWidth } from "../variables/chartconfig.js"; import { strip_tz_suffix } from "../common.js"; @@ -93,13 +93,10 @@ function get_most_failed_data(dataType, graphType, filteredData, recent) { let datasets = []; let runAxis = 0; const pointMeta = {}; + const groups = group_timeline_values(filteredData, labels, value => value.failed > 0 ? [getTestKey(value, dataType)] : []); for (const runStart of runStarts) { for (const label of labels) { - const foundValues = filteredData.filter(value => - getTestKey(value, dataType) === label && - value.run_start === runStart && - value.failed > 0 - ); + const foundValues = groups.get(label).get(runStart) ?? []; if (foundValues.length > 0) { const value = foundValues[0]; // tests re-executed with robot --rerunfailed (rebot --merge history) get the rerun border diff --git a/robotframework_dashboard/js/graph_data/flaky.js b/robotframework_dashboard/js/graph_data/flaky.js index 1198224d..10efaa75 100644 --- a/robotframework_dashboard/js/graph_data/flaky.js +++ b/robotframework_dashboard/js/graph_data/flaky.js @@ -1,6 +1,6 @@ import { settings, get_run_label } from "../variables/settings.js"; import { passedConfig, failedConfig, skippedConfig, rerunBorderColor, rerunBorderWidth } from "../variables/chartconfig.js"; -import { convert_timeline_data, parse_test_attempts, resolve_test_status, count_attempt_flips } from "./helpers.js"; +import { convert_timeline_data, group_timeline_values, parse_test_attempts, resolve_test_status, count_attempt_flips } from "./helpers.js"; import { strip_tz_suffix } from "../common.js"; // function to prepare the data in the correct format for (recent) most flaky test graph @@ -77,36 +77,28 @@ function get_most_flaky_data(dataType, graphType, filteredData, ignore, recent, }; return [graphData, data]; } else if (graphType == "timeline") { - var [labels, runStarts, count, run_labels] = [[], [], 0, []]; + var [labels, runStartsSet, count, runLabelsSet] = [[], new Set(), 0, new Set()]; for (const key in sortedData) { if (count == limit) { break; } labels.push(sortedData[key][0]); for (const runStart of sortedData[key][1].run_starts) { - if (!runStarts.includes(runStart)) { - runStarts.push(runStart); - } + runStartsSet.add(runStart); } count += 1; } var datasets = []; var runAxis = 0; const pointMeta = {}; - runStarts = runStarts.sort((a, b) => new Date(strip_tz_suffix(a)).getTime() - new Date(strip_tz_suffix(b)).getTime()) + var runStarts = [...runStartsSet].sort((a, b) => new Date(strip_tz_suffix(a)).getTime() - new Date(strip_tz_suffix(b)).getTime()) + const groups = group_timeline_values(filteredData, labels, value => [settings.switch.suitePathsTestSection ? value.full_name : value.name]); for (const runStart of runStarts) { for (const label of labels) { - var foundValues = []; - for (value of filteredData) { - const compareKey = settings.switch.suitePathsTestSection ? value.full_name : value.name; - if (compareKey == label && value.run_start == runStart) { - foundValues.push(value); - const runLabel = get_run_label(value); - if (!run_labels.includes(runLabel)) { run_labels.push(runLabel) } - } - } + const foundValues = groups.get(label).get(runStart) ?? []; + foundValues.forEach(value => runLabelsSet.add(get_run_label(value))); if (foundValues.length > 0) { - var value = foundValues[0]; + const value = foundValues[0]; const [status, attempts] = status_of(value); const statusName = status === "passed" ? "PASS" : status === "failed" ? "FAIL" : "SKIP"; pointMeta[`${label}::${runAxis}`] = { @@ -127,7 +119,7 @@ function get_most_flaky_data(dataType, graphType, filteredData, ignore, recent, } runAxis += 1; } - if (settings.show.aliases === "alias" || settings.show.aliases === "run_name") { runStarts = run_labels } + if (settings.show.aliases === "alias" || settings.show.aliases === "run_name") { runStarts = [...runLabelsSet] } datasets = convert_timeline_data(datasets) var graphData = { labels: labels, diff --git a/robotframework_dashboard/js/graph_data/helpers.js b/robotframework_dashboard/js/graph_data/helpers.js index a60257fa..21df83ac 100644 --- a/robotframework_dashboard/js/graph_data/helpers.js +++ b/robotframework_dashboard/js/graph_data/helpers.js @@ -3,43 +3,58 @@ import { barConfig } from "../variables/chartconfig.js"; import { inFullscreen} from "../variables/globals.js"; // helper function to more easily use the logic of filtering suite graph data based on the selected filters -// returns true if the value should be excluded, false if it should be included -function exclude_from_suite_data(dataType, value) { - if (dataType !== "suite") return false; +// returns a function that returns true if a value should be excluded, false if it should be included +// the selected filters are read once here instead of once per value +function get_suite_data_exclusion(dataType) { + if (dataType !== "suite") return () => false; const suiteSelectSuites = document.getElementById("suiteSelectSuites").value; - const suiteSelectSuitesOptions = [...document.getElementById("suiteSelectSuites").options].map(o => o.value); + const suiteSelectSuitesOptions = new Set([...document.getElementById("suiteSelectSuites").options].map(o => o.value)); const suiteFolder = document.getElementById("suiteFolder").innerText; const isFolderAll = suiteFolder === "All"; const isSuiteAll = suiteSelectSuites === "All Suites Separate" || suiteSelectSuites === "All Suites Combined"; const usingSuitePaths = settings.switch.suitePathsSuiteSection; const folderMatches = (val) => val.full_name === suiteFolder || val.full_name.startsWith(`${suiteFolder}.`); - const suiteNameMatches = (val) => suiteSelectSuitesOptions.includes(val.name); - const fullNameMatches = (val) => suiteSelectSuitesOptions.includes(val.full_name); + const suiteNameMatches = (val) => suiteSelectSuitesOptions.has(val.name); + const fullNameMatches = (val) => suiteSelectSuitesOptions.has(val.full_name); + const selectedSuiteMismatch = (val) => usingSuitePaths + ? val.full_name !== suiteSelectSuites + : val.name !== suiteSelectSuites; if (isFolderAll && isSuiteAll) { // All folders, all suites: include all - return false; + return () => false; } if (isFolderAll && !isSuiteAll) { // All folders, specific suite - return usingSuitePaths - ? value.full_name !== suiteSelectSuites - : value.name !== suiteSelectSuites; + return selectedSuiteMismatch; } if (!isFolderAll && isSuiteAll) { // Specific folder, all suites - if (!folderMatches(value)) return true; - - return usingSuitePaths - ? !fullNameMatches(value) - : !suiteNameMatches(value); + return (value) => { + if (!folderMatches(value)) return true; + return usingSuitePaths + ? !fullNameMatches(value) + : !suiteNameMatches(value); + }; } // Specific folder, specific suite - if (!folderMatches(value)) return true; - return usingSuitePaths - ? value.full_name !== suiteSelectSuites - : value.name !== suiteSelectSuites; + return (value) => !folderMatches(value) || selectedSuiteMismatch(value); +} + +// function to group timeline rows by label and run_start in one pass +// returns Map(label -> Map(run_start -> [rows])), rows whose labels are not in labels are skipped +function group_timeline_values(data, labels, get_labels) { + const groups = new Map(labels.map(label => [label, new Map()])); + for (const value of data) { + for (const label of get_labels(value)) { + const byRunStart = groups.get(label); + if (!byRunStart) continue; + if (!byRunStart.has(value.run_start)) byRunStart.set(value.run_start, []); + byRunStart.get(value.run_start).push(value); + } + } + return groups; } // function to update the height of the test statistics graph and enable scrolling @@ -170,7 +185,8 @@ function format_attempt_lines(attempts, maxMessageLength = 80) { } export { - exclude_from_suite_data, + get_suite_data_exclusion, + group_timeline_values, update_height, convert_timeline_data, parse_test_attempts, diff --git a/robotframework_dashboard/js/graph_data/messages.js b/robotframework_dashboard/js/graph_data/messages.js index 3e764d49..7e5b3c4c 100644 --- a/robotframework_dashboard/js/graph_data/messages.js +++ b/robotframework_dashboard/js/graph_data/messages.js @@ -2,7 +2,7 @@ import { settings, get_run_label } from "../variables/settings.js"; import { inFullscreen, inFullscreenGraph } from "../variables/globals.js"; import { failedConfig, rerunBorderColor, rerunBorderWidth } from "../variables/chartconfig.js"; import { message_config } from "../variables/data.js"; -import { convert_timeline_data, parse_test_attempts } from "./helpers.js"; +import { convert_timeline_data, group_timeline_values, parse_test_attempts } from "./helpers.js"; import { strip_tz_suffix } from "../common.js"; // function to prepare the data in the correct format for messages graphs @@ -22,13 +22,18 @@ function get_messages_data(dataType, graphType, filteredData) { run_names.get(value.message).push(value.run_name); } } - // If there is a message config use that to merge the data - if (!message_config.includes("placeholder_message_config")) { - function matches_message_config(str, rule) { - rule = rule.replace(/\$\{.*?\}/g, "*") // match any ${something} string - var escapeRegex = (str) => str.replace(/([.*+?^=!:${}()|\[\]\/\\])/g, "\\$1"); // escape the test messages to prevent regex mismatches - return new RegExp("^" + rule.split("*").map(escapeRegex).join(".*") + "$").test(str); + const useMessageConfig = !message_config.includes("placeholder_message_config"); + const messageConfigRegexes = new Map(); + function matches_message_config(str, rule) { + if (!messageConfigRegexes.has(rule)) { + const wildcardRule = rule.replace(/\$\{.*?\}/g, "*") // match any ${something} string + const escapeRegex = (str) => str.replace(/([.*+?^=!:${}()|\[\]\/\\])/g, "\\$1"); // escape the test messages to prevent regex mismatches + messageConfigRegexes.set(rule, new RegExp("^" + wildcardRule.split("*").map(escapeRegex).join(".*") + "$")); } + return messageConfigRegexes.get(rule).test(str); + } + // If there is a message config use that to merge the data + if (useMessageConfig) { for (const config of message_config) { for (const [message, runStarts] of data) { if (message == config) { continue } @@ -43,8 +48,7 @@ function get_messages_data(dataType, graphType, filteredData) { } for (const [message, runStarts] of data) { - arrayWithDuplicates = data.get(message) - data.set(message, [...new Set(arrayWithDuplicates)]) + data.set(message, [...new Set(runStarts)]) } } const limit = inFullscreen && inFullscreenGraph.includes("Messages") ? 50 : 10; @@ -94,14 +98,13 @@ function get_messages_data(dataType, graphType, filteredData) { var datasets = []; let runAxis = 0; const pointMeta = {}; - function check_label(message, label) { - return !message_config.includes("placeholder_message_config") - ? matches_message_config(message, label) - : message === label; - } + // with a message config one message can match more than one label + const groups = group_timeline_values(filteredData, labels, value => useMessageConfig + ? labels.filter(label => matches_message_config(value.message, label)) + : [value.message]); for (const runStart of runStarts) { for (const label of labels) { - const foundValues = filteredData.filter(value => check_label(value.message, label) && value.run_start === runStart); + const foundValues = groups.get(label).get(runStart) ?? []; if (foundValues.length > 0) { const value = foundValues[0]; // tests re-executed with robot --rerunfailed (rebot --merge history) get the rerun border diff --git a/robotframework_dashboard/js/graph_data/statistics.js b/robotframework_dashboard/js/graph_data/statistics.js index 67027039..a9184665 100644 --- a/robotframework_dashboard/js/graph_data/statistics.js +++ b/robotframework_dashboard/js/graph_data/statistics.js @@ -14,7 +14,7 @@ import { rerunBorderWidth } from "../variables/chartconfig.js"; import { settings, get_run_label } from "../variables/settings.js"; -import { convert_timeline_data, exclude_from_suite_data, parse_test_attempts, resolve_test_status } from "./helpers.js"; +import { convert_timeline_data, get_suite_data_exclusion, parse_test_attempts, resolve_test_status } from "./helpers.js"; import { compareRunIds } from "../variables/graphs.js"; // function to prepare the data in the correct format for statistics graphs @@ -33,8 +33,9 @@ function get_statistics_graph_data(dataType, graphType, filteredData) { runNames.push(value.run_name ?? value.name); names.push(value.name); }; + const exclude_from_suite_data = get_suite_data_exclusion(dataType); for (const value of filteredData) { - if (exclude_from_suite_data(dataType, value)) continue; + if (exclude_from_suite_data(value)) continue; if (dataType === "keyword") { const keywordKey = useLibraryNames && value.owner ? `${value.owner}.${value.name}` diff --git a/robotframework_dashboard/js/variables/data.js b/robotframework_dashboard/js/variables/data.js index 3de99b69..8d6c6e80 100644 --- a/robotframework_dashboard/js/variables/data.js +++ b/robotframework_dashboard/js/variables/data.js @@ -7,7 +7,12 @@ const exceptions = decode_and_decompress("placeholder_exceptions"); function decode_and_decompress(base64Str) { if (base64Str.includes("placeholder_")) return []; - const compressedData = Uint8Array.from(atob(base64Str), c => c.charCodeAt(0)); + // a plain loop is much faster than Uint8Array.from with a map callback on large payloads + const binaryStr = atob(base64Str); + const compressedData = new Uint8Array(binaryStr.length); + for (let i = 0; i < binaryStr.length; i++) { + compressedData[i] = binaryStr.charCodeAt(i); + } const decompressedData = pako.inflate(compressedData, { toText: true }); return JSON.parse(decompressedData); } diff --git a/tests/javascript/filter.test.js b/tests/javascript/filter.test.js index aaee9864..ad6b6788 100644 --- a/tests/javascript/filter.test.js +++ b/tests/javascript/filter.test.js @@ -5,13 +5,15 @@ vi.mock('@js/variables/data.js', () => import('./mocks/data.js')); vi.mock('@js/variables/graphs.js', () => import('./mocks/graphs.js')); import { strip_tz_suffix, get_run_projects } from '@js/common.js'; -import { strip_tz_suffix } from '@js/common.js'; import { dashboardPages, get_active_page, get_hidden_custom_filters, + get_transformed_data, parse_custom_filters, + sort_wall_clock, } from '@js/filter/pipeline.js'; +import { runs } from '@js/variables/data.js'; import { settings } from '@js/variables/settings.js'; // settings is module level state, so it is restored between tests @@ -27,15 +29,6 @@ beforeEach(() => { // so here we test the reusable logic patterns (sorting, data transformations) // that the filter functions rely on. -// Reimplementation of sort_wall_clock from filter.js for direct testing -function sort_wall_clock(data) { - return [...data].sort((a, b) => { - const ak = strip_tz_suffix(a.run_start); - const bk = strip_tz_suffix(b.run_start); - return ak < bk ? -1 : ak > bk ? 1 : 0; - }); -} - describe('filter.js pure logic', () => { describe('sort_wall_clock logic', () => { @@ -87,6 +80,47 @@ describe('filter.js pure logic', () => { sort_wall_clock(data); expect(data).toEqual(original); }); + + it('keeps the original order of items with the same wall-clock time', () => { + const data = [ + { run_start: '2025-01-15 10:00:00', name: 'b' }, + { run_start: '2025-01-15 09:00:00', name: 'c' }, + { run_start: '2025-01-15 10:00:00+02:00', name: 'a' }, + ]; + expect(sort_wall_clock(data).map(item => item.name)).toEqual(['c', 'b', 'a']); + }); + }); + + describe('get_transformed_data', () => { + beforeEach(() => { + runs.length = 0; + runs.push({ run_start: '2025-01-15 09:05:03.123+02:00', name: 'run1' }); + settings.show.milliseconds = true; + settings.show.timezones = true; + settings.show.convertTimezone = false; + }); + + it('applies the run_start transformations of the settings', () => { + settings.show.milliseconds = false; + settings.show.timezones = false; + expect(get_transformed_data('runs')[0].run_start).toBe('2025-01-15 09:05:03'); + expect(runs[0].run_start).toBe('2025-01-15 09:05:03.123+02:00'); + }); + + it('reuses the transformed data while the settings stay the same', () => { + settings.show.milliseconds = false; + const first = get_transformed_data('runs'); + expect(get_transformed_data('runs')).toBe(first); + }); + + it('transforms again when one of the settings changes', () => { + settings.show.milliseconds = false; + expect(get_transformed_data('runs')[0].run_start).toBe('2025-01-15 09:05:03+02:00'); + settings.show.timezones = false; + expect(get_transformed_data('runs')[0].run_start).toBe('2025-01-15 09:05:03'); + settings.show.milliseconds = true; + expect(get_transformed_data('runs')[0].run_start).toBe('2025-01-15 09:05:03.123'); + }); }); describe('remove_milliseconds logic', () => { @@ -174,8 +208,8 @@ describe('filter.js pure logic', () => { { run_start: '2025-01-15 10:00:00', name: 'suite2' }, { run_start: '2025-01-15 11:00:00', name: 'suite3' }, ]; - const validRunStarts = filteredRuns.map(v => v.run_start); - const result = data.filter(v => validRunStarts.includes(v.run_start)); + const validRunStarts = new Set(filteredRuns.map(v => v.run_start)); + const result = data.filter(v => validRunStarts.has(v.run_start)); expect(result).toHaveLength(2); expect(result[0].name).toBe('suite1'); expect(result[1].name).toBe('suite2'); @@ -186,8 +220,8 @@ describe('filter.js pure logic', () => { const data = [ { run_start: '2025-01-15 09:00:00', name: 'suite1' }, ]; - const validRunStarts = filteredRuns.map(v => v.run_start); - const result = data.filter(v => validRunStarts.includes(v.run_start)); + const validRunStarts = new Set(filteredRuns.map(v => v.run_start)); + const result = data.filter(v => validRunStarts.has(v.run_start)); expect(result).toHaveLength(0); }); }); diff --git a/tests/javascript/graph_data/helpers.test.js b/tests/javascript/graph_data/helpers.test.js index 3cd0211c..466e002f 100644 --- a/tests/javascript/graph_data/helpers.test.js +++ b/tests/javascript/graph_data/helpers.test.js @@ -1,4 +1,4 @@ -import { describe, it, expect, vi } from 'vitest'; +import { describe, it, expect, vi, afterEach } from 'vitest'; vi.mock('@js/variables/settings.js', () => ({ settings: { @@ -17,7 +17,8 @@ vi.mock('@js/variables/globals.js', () => ({ inFullscreenGraph: '', })); -import { convert_timeline_data, parse_test_attempts, resolve_test_status, format_attempt_lines, count_attempt_flips, get_rerun_summary } from '@js/graph_data/helpers.js'; +import { convert_timeline_data, parse_test_attempts, resolve_test_status, format_attempt_lines, count_attempt_flips, get_rerun_summary, group_timeline_values, get_suite_data_exclusion } from '@js/graph_data/helpers.js'; +import { settings } from '@js/variables/settings.js'; describe('convert_timeline_data', () => { @@ -189,3 +190,93 @@ describe('get_rerun_summary', () => { }); }); + +describe('group_timeline_values', () => { + const rows = [ + { name: 'A', run_start: 'r1', id: 1 }, + { name: 'B', run_start: 'r1', id: 2 }, + { name: 'A', run_start: 'r2', id: 3 }, + { name: 'A', run_start: 'r1', id: 4 }, + { name: 'C', run_start: 'r1', id: 5 }, + ]; + + it('groups the rows by label and run_start in their original order', () => { + const groups = group_timeline_values(rows, ['A', 'B'], row => [row.name]); + expect(groups.get('A').get('r1').map(row => row.id)).toEqual([1, 4]); + expect(groups.get('A').get('r2').map(row => row.id)).toEqual([3]); + expect(groups.get('B').get('r1').map(row => row.id)).toEqual([2]); + expect(groups.get('B').get('r2')).toBeUndefined(); + }); + + it('only keeps the given labels', () => { + const groups = group_timeline_values(rows, ['B'], row => [row.name]); + expect([...groups.keys()]).toEqual(['B']); + }); + + it('adds a row to every label it belongs to and skips rows without a label', () => { + const groups = group_timeline_values(rows, ['A', 'any'], row => row.name === 'A' ? ['A', 'any'] : []); + expect(groups.get('any').get('r1').map(row => row.id)).toEqual([1, 4]); + expect(groups.get('A').get('r1').map(row => row.id)).toEqual([1, 4]); + }); +}); + +describe('get_suite_data_exclusion', () => { + const suites = [ + { name: 'Login', full_name: 'Web.Login' }, + { name: 'Cart', full_name: 'Web.Cart' }, + { name: 'Login', full_name: 'Api.Login' }, + ]; + let reads = 0; + function stub_document(suiteSelect, options, folder) { + reads = 0; + globalThis.document = { + getElementById: (id) => { + reads++; + if (id === 'suiteSelectSuites') return { value: suiteSelect, options: options.map(value => ({ value })) }; + if (id === 'suiteFolder') return { innerText: folder }; + return null; + }, + }; + } + const kept = (exclude) => suites.filter(suite => !exclude(suite)).map(suite => suite.full_name); + + afterEach(() => { + delete globalThis.document; + settings.switch.suitePathsSuiteSection = false; + }); + + it('never excludes data that is not suite data', () => { + expect(get_suite_data_exclusion('test')(suites[0])).toBe(false); + }); + + it('keeps every suite with all folders and all suites selected', () => { + stub_document('All Suites Separate', [], 'All'); + expect(kept(get_suite_data_exclusion('suite'))).toEqual(['Web.Login', 'Web.Cart', 'Api.Login']); + }); + + it('keeps the selected suite by name, or by full name with suite paths', () => { + stub_document('Login', [], 'All'); + expect(kept(get_suite_data_exclusion('suite'))).toEqual(['Web.Login', 'Api.Login']); + settings.switch.suitePathsSuiteSection = true; + stub_document('Api.Login', [], 'All'); + expect(kept(get_suite_data_exclusion('suite'))).toEqual(['Api.Login']); + }); + + it('keeps the suites of the selected folder that are in the suite select', () => { + stub_document('All Suites Combined', ['All Suites Separate', 'All Suites Combined', 'Login', 'Cart'], 'Web'); + expect(kept(get_suite_data_exclusion('suite'))).toEqual(['Web.Login', 'Web.Cart']); + }); + + it('keeps the selected suite inside the selected folder', () => { + stub_document('Login', [], 'Web'); + expect(kept(get_suite_data_exclusion('suite'))).toEqual(['Web.Login']); + }); + + it('reads the selected filters once instead of once per suite', () => { + stub_document('Login', ['Login', 'Cart'], 'Web'); + const exclude = get_suite_data_exclusion('suite'); + const readsAfterSetup = reads; + suites.forEach(suite => exclude(suite)); + expect(reads).toBe(readsAfterSetup); + }); +}); diff --git a/tests/javascript/graph_data/messages_config.test.js b/tests/javascript/graph_data/messages_config.test.js new file mode 100644 index 00000000..8981dabe --- /dev/null +++ b/tests/javascript/graph_data/messages_config.test.js @@ -0,0 +1,61 @@ +import { describe, it, expect, vi } from 'vitest'; + +// message_config is module state, so the message config tests live in their own file +vi.mock('@js/variables/settings.js', () => ({ + settings: { show: { aliases: 'run_start' }, switch: { testRerunView: 'reruns' } }, + get_run_label: (item) => item.run_start, +})); +vi.mock('@js/variables/globals.js', () => ({ + inFullscreen: false, + inFullscreenGraph: '', +})); +vi.mock('@js/variables/chartconfig.js', () => ({ + failedConfig: { backgroundColor: 'rgba(206, 62, 1, 0.7)', borderColor: '#ce3e01' }, + rerunBorderColor: '#36a2eb', + rerunBorderWidth: 3, +})); +vi.mock('@js/variables/data.js', () => ({ + message_config: ['Element ${locator} not found', 'Timeout after * seconds'], +})); +vi.mock('@js/graph_data/helpers.js', async () => ({ + ...(await vi.importActual('@js/graph_data/helpers.js')), + convert_timeline_data: (datasets) => datasets, +})); +vi.mock('@js/common.js', () => ({ + strip_tz_suffix: (s) => s.replace(/[+-]\d{2}:\d{2}$/, ''), +})); + +import { get_messages_data } from '@js/graph_data/messages.js'; + +function make_test(run_start, message) { + return { name: 'Test', run_start, run_alias: run_start, passed: 0, failed: 1, skipped: 0, elapsed_s: 1, message, attempts: '' }; +} + +describe('get_messages_data with a message config', () => { + const data = [ + make_test('2025-01-15 10:00:00', 'Element id:login not found'), + make_test('2025-01-15 10:00:00', 'Timeout after 5 seconds'), + make_test('2025-01-16 10:00:00', 'Element id:cart not found'), + make_test('2025-01-16 10:00:00', 'Element id:cart not found'), + make_test('2025-01-17 10:00:00', 'Something else'), + ]; + + it('merges the messages that match a rule in the bar graph', () => { + const [graphData] = get_messages_data('test', 'bar', data); + expect(graphData.labels).toEqual(['Element ${locator} not found', 'Something else', 'Timeout after * seconds']); + expect(graphData.datasets[0].data).toEqual([2, 1, 1]); + }); + + it('puts every matching message in the cell of its rule and run in the timeline', () => { + const [graphData, runStarts, pointMeta] = get_messages_data('test', 'timeline', data); + expect(runStarts).toEqual(['2025-01-15 10:00:00', '2025-01-16 10:00:00', '2025-01-17 10:00:00']); + const cells = graphData.datasets.map(dataset => `${dataset.label}@${dataset.data[0].x[0]}`); + expect(cells).toEqual([ + 'Element ${locator} not found@0', + 'Timeout after * seconds@0', + 'Element ${locator} not found@1', + 'Something else@2', + ]); + expect(pointMeta['Element ${locator} not found::1'].message).toBe('Element id:cart not found'); + }); +}); From d6ee8cca5c783814a3a9253315d3c1a7d314a82c Mon Sep 17 00:00:00 2001 From: Tim de Groot Date: Mon, 28 Sep 2026 15:07:48 +0200 Subject: [PATCH 2/2] perf: lazy overview donuts, typed table columns and native decompression (#364) - Overview: every run card created its own Chart.js donut, twice, on every update, and the donuts of removed cards were never destroyed, so Chart.js kept all of them alive. A donut is now created when its card comes near the viewport and destroyed before its card is removed. Updating the overview with 504 runs goes from 4.0 s to 0.07 s. - Tables: DataTables detected the type of every column by checking every cell on every update. The columns now get the type it detected, except the version which depends on the project. 3.9 s to 2.4 s. - Data loading: the embedded payloads are inflated in parallel with the native DecompressionStream instead of pako, which is removed. main() awaits load_data() before anything reads the data, so the transform cache in the filter pipeline reads the arrays when it is called. - build_tooltip_meta parses every run_start once instead of per row. The initial load of the 504 run dashboard goes from 3.8 s to 2.3 s. Co-Authored-By: HuntTheSun <53446567+HuntTheSun@users.noreply.github.com> Co-Authored-By: Claude Opus 5.5 --- .claude/skills/js-bundling/SKILL.md | 7 ++- docs/advanced-cli-examples.md | 1 - robotframework_dashboard/dependencies.py | 6 --- robotframework_dashboard/dependencies/pako.js | 2 - .../js/filter/pipeline.js | 4 +- .../js/graph_creation/overview.js | 52 ++++++++++++++++--- .../js/graph_creation/tables.js | 38 ++++++++------ .../js/graph_data/tooltip_helpers.js | 7 ++- robotframework_dashboard/js/main.js | 4 +- robotframework_dashboard/js/variables/data.js | 30 +++++++---- .../licenses/THIRD_PARTY_LICENSES.txt | 28 ---------- tests/python/test_dashboard.py | 25 ++++----- 12 files changed, 115 insertions(+), 89 deletions(-) delete mode 100644 robotframework_dashboard/dependencies/pako.js diff --git a/.claude/skills/js-bundling/SKILL.md b/.claude/skills/js-bundling/SKILL.md index 07e195e6..9ff7d82a 100644 --- a/.claude/skills/js-bundling/SKILL.md +++ b/.claude/skills/js-bundling/SKILL.md @@ -90,7 +90,6 @@ Current third-party libraries: | @sgratzl/chartjs-chart-boxplot | Box plot chart type | | chartjs-chart-matrix | Matrix/heatmap chart type | | GridStack | Drag-and-drop dashboard layout | -| Pako | `pako.inflate()` — decompress embedded data in the browser | | Bootstrap | Modals, layout, admin page UI | | DataTables (3.x, no jQuery) | Tables page, admin page tables | @@ -128,8 +127,8 @@ json.dumps(data).encode("utf-8") # serialize **JavaScript (browser):** ```javascript // js/variables/data.js -const runs = decode_and_decompress("placeholder_runs"); -// atob() → Uint8Array → pako.inflate() → JSON.parse() +await load_data(); // main() awaits this before anything reads runs/suites/tests/... +// atob() → Uint8Array → native DecompressionStream("deflate") → Response.json() ``` -Pako (the JS zlib port) is the only dependency required to decode data, so it must always be loaded before `data.js` runs. +Decoding needs no library: the browser's `DecompressionStream` inflates the zlib payload. It is async, so the data arrays are empty `let` bindings until `load_data()` resolves — never read them at module load time, only from code that runs after `main()` awaited it. diff --git a/docs/advanced-cli-examples.md b/docs/advanced-cli-examples.md index 5a1e9dba..2229a687 100644 --- a/docs/advanced-cli-examples.md +++ b/docs/advanced-cli-examples.md @@ -209,7 +209,6 @@ The following dependencies are bundled with the package and will be used offline | **datatables_css** | CSS | https://cdn.datatables.net/v/bs5/dt-3.0.4/datatables.min.css | Table component styling | | **bootstrap_js** | JS | https://cdnjs.cloudflare.com/ajax/libs/twitter-bootstrap/5.3.8/js/bootstrap.bundle.min.js | Bootstrap interactive components | | **datatables_js** | JS | https://cdn.datatables.net/v/bs5/dt-3.0.4/datatables.min.js | DataTables functionality | -| **pako** | JS | https://cdn.jsdelivr.net/npm/pako@3.0.2/dist/browser/pako_inflate.umd.min.js | Compression/decompression used for smaller dashboard file sizes | ### Behaviour diff --git a/robotframework_dashboard/dependencies.py b/robotframework_dashboard/dependencies.py index 78e69c54..33674502 100644 --- a/robotframework_dashboard/dependencies.py +++ b/robotframework_dashboard/dependencies.py @@ -73,12 +73,6 @@ "local": "dependencies/datatables.js", "admin_page": True, }, - "pako": { - "type": "js", - "cdn": "https://cdn.jsdelivr.net/npm/pako@3.0.2/dist/browser/pako_inflate.umd.min.js", - "local": "dependencies/pako.js", - "admin_page": False, - }, } diff --git a/robotframework_dashboard/dependencies/pako.js b/robotframework_dashboard/dependencies/pako.js deleted file mode 100644 index 59f75756..00000000 --- a/robotframework_dashboard/dependencies/pako.js +++ /dev/null @@ -1,2 +0,0 @@ -(function(e,t){typeof exports==`object`&&typeof module<`u`?t(exports):typeof define==`function`&&define.amd?define([`exports`],t):(e=typeof globalThis<`u`?globalThis:e||self,t(e.pako={}))})(this,function(e){Object.defineProperty(e,Symbol.toStringTag,{value:`Module`});var t=4,n=0,r=1,i=2;function a(e){let t=e.length;for(;--t>=0;)e[t]=0}var o=0,s=1,c=2,l=29,u=256,d=286,f=30,p=19,m=573,h=15,g=16,_=256,v=16,y=17,b=18,x=new Uint8Array([0,0,0,0,0,0,0,0,1,1,1,1,2,2,2,2,3,3,3,3,4,4,4,4,5,5,5,5,0]),S=new Uint8Array([0,0,0,0,1,1,2,2,3,3,4,4,5,5,6,6,7,7,8,8,9,9,10,10,11,11,12,12,13,13]);new Uint8Array([0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,2,3,7]);var C=new Uint8Array([16,17,18,0,8,7,9,6,10,5,11,4,12,3,13,2,14,1,15]),w=512,T=Array(288*2);a(T);var E=Array(f*2);a(E);var D=Array(w);a(D);var O=Array(256);a(O);var k=Array(l);a(k);var A=Array(f);a(A);var j=e=>e<256?D[e]:D[256+(e>>>7)],M=(e,t)=>{e.pending_buf[e.pending++]=t&255,e.pending_buf[e.pending++]=t>>>8&255},N=(e,t,n)=>{e.bi_valid>g-n?(e.bi_buf|=t<>g-e.bi_valid,e.bi_valid+=n-g):(e.bi_buf|=t<{N(e,n[t*2],n[t*2+1])},ee=(e,t)=>{let n=0;do n|=e&1,e>>>=1,n<<=1;while(--t>0);return n>>>1},te=(e,t)=>{let n=t.dyn_tree,r=t.max_code,i=t.stat_desc.static_tree,a=t.stat_desc.has_stree,o=t.stat_desc.extra_bits,s=t.stat_desc.extra_base,c=t.stat_desc.max_length,l,u,d,f,p,g,_=0;for(f=0;f<=h;f++)e.bl_count[f]=0;for(n[e.heap[e.heap_max]*2+1]=0,l=e.heap_max+1;lc&&(f=c,_++),n[u*2+1]=f,!(u>r)&&(e.bl_count[f]++,p=0,u>=s&&(p=o[u-s]),g=n[u*2],e.opt_len+=g*(f+p),a&&(e.static_len+=g*(i[u*2+1]+p)));if(_!==0){do{for(f=c-1;e.bl_count[f]===0;)f--;e.bl_count[f]--,e.bl_count[f+1]+=2,e.bl_count[c]--,_-=2}while(_>0);for(f=c;f!==0;f--)for(u=e.bl_count[f];u!==0;)d=e.heap[--l],!(d>r)&&(n[d*2+1]!==f&&(e.opt_len+=(f-n[d*2+1])*n[d*2],n[d*2+1]=f),u--)}},ne=(e,t,n)=>{let r=Array(16),i=0,a,o;for(a=1;a<=h;a++)i=i+n[a-1]<<1,r[a]=i;for(o=0;o<=t;o++){let t=e[o*2+1];t!==0&&(e[o*2]=ee(r[t]++,t))}},re=e=>{let t;for(t=0;t{e.bi_valid>8?M(e,e.bi_buf):e.bi_valid>0&&(e.pending_buf[e.pending++]=e.bi_buf),e.bi_buf=0,e.bi_valid=0},ae=(e,t,n,r)=>{let i=t*2,a=n*2;return e[i]{let r=e.heap[n],i=n<<1;for(;i<=e.heap_len&&(i{let r,i,a=0,o,s;if(e.sym_next!==0)do r=e.pending_buf[e.sym_buf+a++]&255,r+=(e.pending_buf[e.sym_buf+a++]&255)<<8,i=e.pending_buf[e.sym_buf+a++],r===0?P(e,i,t):(o=O[i],P(e,o+u+1,t),s=x[o],s!==0&&(i-=k[o],N(e,i,s)),r--,o=j(r),P(e,o,n),s=S[o],s!==0&&(r-=A[o],N(e,r,s)));while(a{let n=t.dyn_tree,r=t.stat_desc.static_tree,i=t.stat_desc.has_stree,a=t.stat_desc.elems,o,s,c=-1,l;for(e.heap_len=0,e.heap_max=m,o=0;o>1;o>=1;o--)oe(e,n,o);l=a;do o=e.heap[1],e.heap[1]=e.heap[e.heap_len--],oe(e,n,1),s=e.heap[1],e.heap[--e.heap_max]=o,e.heap[--e.heap_max]=s,n[l*2]=n[o*2]+n[s*2],e.depth[l]=(e.depth[o]>=e.depth[s]?e.depth[o]:e.depth[s])+1,n[o*2+1]=n[s*2+1]=l,e.heap[1]=l++,oe(e,n,1);while(e.heap_len>=2);e.heap[--e.heap_max]=e.heap[1],te(e,t),ne(n,c,e.bl_count)},le=(e,t,n)=>{let r,i=-1,a,o=t[1],s=0,c=7,l=4;for(o===0&&(c=138,l=3),t[(n+1)*2+1]=65535,r=0;r<=n;r++)a=o,o=t[(r+1)*2+1],!(++s{let r,i=-1,a,o=t[1],s=0,c=7,l=4;for(o===0&&(c=138,l=3),r=0;r<=n;r++)if(a=o,o=t[(r+1)*2+1],!(++s{let t;for(le(e,e.dyn_ltree,e.l_desc.max_code),le(e,e.dyn_dtree,e.d_desc.max_code),ce(e,e.bl_desc),t=p-1;t>=3&&e.bl_tree[C[t]*2+1]===0;t--);return e.opt_len+=3*(t+1)+5+5+4,t},fe=(e,t,n,r)=>{let i;for(N(e,t-257,5),N(e,n-1,5),N(e,r-4,4),i=0;i{let t=4093624447,i;for(i=0;i<=31;i++,t>>>=1)if(t&1&&e.dyn_ltree[i*2]!==0)return n;if(e.dyn_ltree[18]!==0||e.dyn_ltree[20]!==0||e.dyn_ltree[26]!==0)return r;for(i=32;i{N(e,(o<<1)+ +!!r,3),ie(e),M(e,n),M(e,~n),n&&e.pending_buf.set(e.window.subarray(t,t+n),e.pending),e.pending+=n},he=(e,n,r,a)=>{let o,l,u=0;e.level>0?(e.strm.data_type===i&&(e.strm.data_type=pe(e)),ce(e,e.l_desc),ce(e,e.d_desc),u=de(e),o=e.opt_len+3+7>>>3,l=e.static_len+3+7>>>3,(l<=o||e.strategy===t)&&(o=l)):o=l=r+5,r+4<=o&&n!==-1?me(e,n,r,a):e.strategy===t||l===o?(N(e,(s<<1)+ +!!a,3),se(e,T,E)):(N(e,(c<<1)+ +!!a,3),fe(e,e.l_desc.max_code+1,e.d_desc.max_code+1,u+1),se(e,e.dyn_ltree,e.dyn_dtree)),re(e),a&&ie(e)},F=(e,t,n)=>(e.pending_buf[e.sym_buf+e.sym_next++]=t,e.pending_buf[e.sym_buf+e.sym_next++]=t>>8,e.pending_buf[e.sym_buf+e.sym_next++]=n,t===0?e.dyn_ltree[n*2]++:(e.matches++,t--,e.dyn_ltree[(O[n]+u+1)*2]++,e.dyn_dtree[j(t)*2]++),e.sym_next===e.sym_end),ge=(e,t,n,r)=>{let i=e&65535|0,a=e>>>16&65535|0,o=0;for(;n!==0;){o=n>2e3?2e3:n,n-=o;do i=i+t[r++]|0,a=a+i|0;while(--o);i%=65521,a%=65521}return i|a<<16|0},_e=new Uint32Array((()=>{let e,t=[];for(var n=0;n<256;n++){e=n;for(var r=0;r<8;r++)e=e&1?3988292384^e>>>1:e>>>1;t[n]=e}return t})()),I=(e,t,n,r)=>{let i=_e,a=r+n;e^=-1;for(let n=r;n>>8^i[(e^t[n])&255];return e^-1},ve={2:`need dictionary`,1:`stream end`,0:``,"-1":`file error`,"-2":`stream error`,"-3":`data error`,"-4":`insufficient memory`,"-5":`buffer error`,"-6":`incompatible version`},L=3,ye=258,R=262,z=1,be=2,xe=3,Se=4,Ce=e=>{let t,n,r,i=e.w_size;t=e.hash_size,r=t;do n=e.head[--r],e.head[r]=n>=i?n-i:0;while(--t);t=i,r=t;do n=e.prev[--r],e.prev[r]=n>=i?n-i:0;while(--t)},we=(e,t,n)=>(t<{let n;if(e.legacy_hash)n=e.ins_h=we(e,e.ins_h,e.window[t+L-1]);else{let r=e.window,i=r[t]|r[t+1]<<8|r[t+2]<<16|r[t+3]<<24;n=e.ins_h=Math.imul(i,66521)+66521>>>16&e.hash_mask}let r=e.prev[t&e.w_mask]=e.head[n];return e.head[n]=t,r},Te=e=>{let t=e.state,n=t.pending;n>e.avail_out&&(n=e.avail_out),n!==0&&(e.output.set(t.pending_buf.subarray(t.pending_out,t.pending_out+n),e.next_out),e.next_out+=n,t.pending_out+=n,e.total_out+=n,e.avail_out-=n,t.pending-=n,t.pending===0&&(t.pending_out=0))},V=(e,t)=>{he(e,e.block_start>=0?e.block_start:-1,e.strstart-e.block_start,t),e.block_start=e.strstart,Te(e.strm)},Ee=(e,t,n,r)=>{let i=e.avail_in;return i>r&&(i=r),i===0?0:(e.avail_in-=i,t.set(e.input.subarray(e.next_in,e.next_in+i),n),e.state.wrap===1?e.adler=ge(e.adler,t,i,n):e.state.wrap===2&&(e.adler=I(e.adler,t,i,n)),e.next_in+=i,e.total_in+=i,i)},De=(e,t)=>{let n=e.max_chain_length,r=e.strstart,i,a,o=e.prev_length,s=e.nice_match,c=e.strstart>e.w_size-R?e.strstart-(e.w_size-R):0,l=e.window,u=e.w_mask,d=e.prev,f=e.strstart+ye,p=l[r+o-1],m=l[r+o];e.prev_length>=e.good_match&&(n>>=2),s>e.lookahead&&(s=e.lookahead);do{if(i=t,l[i+o]!==m||l[i+o-1]!==p||l[i]!==l[r]||l[++i]!==l[r+1])continue;r+=2,i++;do;while(l[++r]===l[++i]&&l[++r]===l[++i]&&l[++r]===l[++i]&&l[++r]===l[++i]&&l[++r]===l[++i]&&l[++r]===l[++i]&&l[++r]===l[++i]&&l[++r]===l[++i]&&ro){if(e.match_start=t,o=a,a>=s)break;p=l[r+o-1],m=l[r+o]}}while((t=d[t&u])>c&&--n!==0);return o<=e.lookahead?o:e.lookahead},Oe=e=>{let t=e.w_size,n,r,i;do{if(r=e.window_size-e.lookahead-e.strstart,e.strstart>=t+(t-R)&&(e.window.set(e.window.subarray(t,t+t-r),0),e.match_start-=t,e.strstart-=t,e.block_start-=t,e.insert>e.strstart&&(e.insert=e.strstart),Ce(e),r+=t),e.strm.avail_in===0)break;if(n=Ee(e.strm,e.window,e.strstart+e.lookahead,r),e.lookahead+=n,!e.legacy_hash){if(e.lookahead+e.insert>L)for(i=e.strstart-e.insert;e.insert&&(B(e,i),i++,e.insert--,!(e.lookahead+e.insert<=L)););}else if(e.lookahead+e.insert>=L)for(i=e.strstart-e.insert,e.ins_h=e.window[i],e.ins_h=we(e,e.ins_h,e.window[i+1]);e.insert&&(B(e,i),i++,e.insert--,!(e.lookahead+e.insert{let n=e.pending_buf_size-5>e.w_size?e.w_size:e.pending_buf_size-5,r,i,a,o=0,s=e.strm.avail_in;do{if(r=65535,a=e.bi_valid+42>>3,e.strm.avail_outi+e.strm.avail_in&&(r=i+e.strm.avail_in),r>a&&(r=a),r>8,e.pending_buf[e.pending-2]=~r,e.pending_buf[e.pending-1]=~r>>8,Te(e.strm),i&&(i>r&&(i=r),e.strm.output.set(e.window.subarray(e.block_start,e.block_start+i),e.strm.next_out),e.strm.next_out+=i,e.strm.avail_out-=i,e.strm.total_out+=i,e.block_start+=i,r-=i),r&&(Ee(e.strm,e.strm.output,e.strm.next_out,r),e.strm.next_out+=r,e.strm.avail_out-=r,e.strm.total_out+=r)}while(o===0);return s-=e.strm.avail_in,s&&(s>=e.w_size?(e.matches=2,e.window.set(e.strm.input.subarray(e.strm.next_in-e.w_size,e.strm.next_in),0),e.strstart=e.w_size,e.insert=e.strstart):(e.window_size-e.strstart<=s&&(e.strstart-=e.w_size,e.window.set(e.window.subarray(e.w_size,e.w_size+e.strstart),0),e.matches<2&&e.matches++,e.insert>e.strstart&&(e.insert=e.strstart)),e.window.set(e.strm.input.subarray(e.strm.next_in-s,e.strm.next_in),e.strstart),e.strstart+=s,e.insert+=s>e.w_size-e.insert?e.w_size-e.insert:s),e.block_start=e.strstart),e.high_watera&&e.block_start>=e.w_size&&(e.block_start-=e.w_size,e.strstart-=e.w_size,e.window.set(e.window.subarray(e.w_size,e.w_size+e.strstart),0),e.matches<2&&e.matches++,a+=e.w_size,e.insert>e.strstart&&(e.insert=e.strstart)),a>e.strm.avail_in&&(a=e.strm.avail_in),a&&(Ee(e.strm,e.window,e.strstart,a),e.strstart+=a,e.insert+=a>e.w_size-e.insert?e.w_size-e.insert:a),e.high_water>3,a=e.pending_buf_size-a>65535?65535:e.pending_buf_size-a,n=a>e.w_size?e.w_size:a,i=e.strstart-e.block_start,(i>=n||(i||t===4)&&t!==0&&e.strm.avail_in===0&&i<=a)&&(r=i>a?a:i,o=+(t===4&&e.strm.avail_in===0&&r===i),me(e,e.block_start,r,o),e.block_start+=r,Te(e.strm)),o?xe:z)},Ae=(e,t)=>{let n,r;for(;;){if(e.lookahead=L&&(n=B(e,e.strstart)),n!==0&&e.strstart-n<=e.w_size-R&&(e.match_length=De(e,n)),e.match_length>=L)if(r=F(e,e.strstart-e.match_start,e.match_length-L),e.lookahead-=e.match_length,e.match_length<=e.max_lazy_match&&e.lookahead>=L){e.match_length--;do e.strstart++,n=B(e,e.strstart);while(--e.match_length!==0);e.strstart++}else e.strstart+=e.match_length,e.match_length=0,e.legacy_hash&&(e.ins_h=e.window[e.strstart],e.ins_h=we(e,e.ins_h,e.window[e.strstart+1]));else r=F(e,0,e.window[e.strstart]),e.lookahead--,e.strstart++;if(r&&(V(e,!1),e.strm.avail_out===0))return z}return e.insert=e.strstart{let n,r,i;for(;;){if(e.lookahead=L&&(n=B(e,e.strstart)),e.prev_length=e.match_length,e.prev_match=e.match_start,e.match_length=L-1,n!==0&&e.prev_length4096)&&(e.match_length=L-1)),e.prev_length>=L&&e.match_length<=e.prev_length){i=e.strstart+e.lookahead-L,r=F(e,e.strstart-1-e.prev_match,e.prev_length-L),e.lookahead-=e.prev_length-1,e.prev_length-=2;do++e.strstart<=i&&(n=B(e,e.strstart));while(--e.prev_length!==0);if(e.match_available=0,e.match_length=L-1,e.strstart++,r&&(V(e,!1),e.strm.avail_out===0))return z}else if(e.match_available){if(r=F(e,0,e.window[e.strstart-1]),r&&V(e,!1),e.strstart++,e.lookahead--,e.strm.avail_out===0)return z}else e.match_available=1,e.strstart++,e.lookahead--}return e.match_available&&(r=F(e,0,e.window[e.strstart-1]),e.match_available=0),e.insert=e.strstart>>24,f>>>=y,p-=y,y=v>>>16&255,y===0)T[i++]=v&65535;else if(y&16){b=v&65535,y&=15,y&&(p>>=y,p-=y),p<15&&(f+=w[n++]<>>24,f>>>=y,p-=y,y=v>>>16&255,y&16){if(x=v&65535,y&=15,ps){e.msg=`invalid distance too far back`,E.mode=W;break top}if(f>>>=y,p-=y,y=i-a,x>y){if(y=x-y,y>l&&E.sane){e.msg=`invalid distance too far back`,E.mode=W;break top}if(S=0,C=d,u===0){if(S+=c-y,y2;)T[i++]=C[S++],T[i++]=C[S++],T[i++]=C[S++],b-=3;b&&(T[i++]=C[S++],b>1&&(T[i++]=C[S++]))}else{S=i-x;do T[i++]=T[S++],T[i++]=T[S++],T[i++]=T[S++],b-=3;while(b>2);b&&(T[i++]=T[S++],b>1&&(T[i++]=T[S++]))}}else if(y&64){e.msg=`invalid distance code`,E.mode=W;break top}else{v=h[(v&65535)+(f&(1<>3,n-=b,p-=b<<3,f&=(1<{let c=s.bits,l=0,u=0,d=0,f=0,p=0,m=0,h=0,g=0,_=0,v=0,y,b,x,S,C,w=null,T,E=new Uint16Array(16),D=new Uint16Array(16),O=null,k,A,j;for(l=0;l<=G;l++)E[l]=0;for(u=0;u=1&&E[f]===0;f--);if(p>f&&(p=f),f===0)return i[a++]=20971520,i[a++]=20971520,s.bits=1,0;for(d=1;d0&&(e===Fe||f!==1))return-1;for(D[1]=0,l=1;lNe||e===Le&&_>Pe)return 1;for(;;){k=l-h,o[u]+1=T?(A=O[o[u]-T],j=w[o[u]-T]):(A=96,j=0),y=1<>h)+b]=k<<24|A<<16|j|0;while(b!==0);for(y=1<>=1;if(y===0?v=0:(v&=y-1,v+=y),u++,--E[l]===0){if(l===f)break;l=t[n+o[u]]}if(l>p&&(v&S)!==x){for(h===0&&(h=p),C+=d,m=l-h,g=1<Ne||e===Le&&_>Pe)return 1;x=v&S,i[x]=p<<24|m<<16|C-a|0}}return v!==0&&(i[C+v]=l-h<<24|4194304),s.bits=p,0},He=0,Ue=1,We=2,q=16180,Ge=16181,Ke=16182,qe=16183,Je=16184,Ye=16185,Xe=16186,Ze=16187,Qe=16188,$e=16189,J=16190,Y=16191,et=16192,tt=16193,nt=16194,rt=16195,it=16196,at=16197,ot=16198,st=16199,ct=16200,lt=16201,ut=16202,dt=16203,ft=16204,pt=16205,mt=16206,ht=16207,gt=16208,X=16209,_t=16210,vt=16211,yt=852,bt=592,xt=e=>(e>>>24&255)+(e>>>8&65280)+((e&65280)<<8)+((e&255)<<24),St=class{constructor(){this.strm=null,this.mode=0,this.last=!1,this.wrap=0,this.havedict=!1,this.flags=0,this.dmax=0,this.check=0,this.total=0,this.head=null,this.wbits=0,this.wsize=0,this.whave=0,this.wnext=0,this.window=null,this.hold=0,this.bits=0,this.length=0,this.offset=0,this.extra=0,this.lencode=null,this.distcode=null,this.lenbits=0,this.distbits=0,this.ncode=0,this.nlen=0,this.ndist=0,this.have=0,this.next=null,this.lens=new Uint16Array(320),this.work=new Uint16Array(288),this.lendyn=null,this.distdyn=null,this.sane=0,this.back=0,this.was=0}},Z=e=>{if(!e)return 1;let t=e.state;return+(!t||t.strm!==e||t.modevt)},Ct=e=>{if(Z(e))return-2;let t=e.state;return e.total_in=e.total_out=t.total=0,e.msg=``,t.wrap&&(e.adler=t.wrap&1),t.mode=q,t.last=0,t.havedict=0,t.flags=-1,t.dmax=32768,t.head=null,t.hold=0,t.bits=0,t.lencode=t.lendyn=new Int32Array(yt),t.distcode=t.distdyn=new Int32Array(bt),t.sane=1,t.back=-1,0},wt=e=>{if(Z(e))return-2;let t=e.state;return t.wsize=0,t.whave=0,t.wnext=0,Ct(e)},Tt=(e,t)=>{let n;if(Z(e))return-2;let r=e.state;return t<0?(n=0,t=-t):(n=(t>>4)+5,t<48&&(t&=15)),t&&(t<8||t>15)?-2:(r.window!==null&&r.wbits!==t&&(r.window=null),r.wrap=n,r.wbits=t,wt(e))},Et=(e,t)=>{if(!e)return-2;let n=new St;e.state=n,n.strm=e,n.window=null,n.mode=q;let r=Tt(e,t);return r!==0&&(e.state=null),r},Dt=!0,Ot,kt,At=e=>{if(Dt){Ot=new Int32Array(512),kt=new Int32Array(32);let t=0;for(;t<144;)e.lens[t++]=8;for(;t<256;)e.lens[t++]=9;for(;t<280;)e.lens[t++]=7;for(;t<288;)e.lens[t++]=8;for(K(Ue,e.lens,0,288,Ot,0,e.work,{bits:9}),t=0;t<32;)e.lens[t++]=5;K(We,e.lens,0,32,kt,0,e.work,{bits:5}),Dt=!1}e.lencode=Ot,e.lenbits=9,e.distcode=kt,e.distbits=5},jt=(e,t,n,r)=>{let i,a=e.state;return a.window===null&&(a.window=new Uint8Array(1<=a.wsize?(a.window.set(t.subarray(n-a.wsize,n),0),a.wnext=0,a.whave=a.wsize):(i=a.wsize-a.wnext,i>r&&(i=r),a.window.set(t.subarray(n-r,n-r+i),a.wnext),r-=i,r?(a.window.set(t.subarray(n-r,n),0),a.wnext=r,a.whave=a.wsize):(a.wnext+=i,a.wnext===a.wsize&&(a.wnext=0),a.whave{let n,r,i,a,o,s,c,l,u,d,f,p,m,h,g=0,_,v,y,b,x,S,C,w,T=new Uint8Array(4),E,D,O=new Uint8Array([16,17,18,0,8,7,9,6,10,5,11,4,12,3,13,2,14,1,15]);if(Z(e)||!e.output||!e.input&&e.avail_in!==0)return-2;n=e.state,n.mode===Y&&(n.mode=et),o=e.next_out,i=e.output,c=e.avail_out,a=e.next_in,r=e.input,s=e.avail_in,l=n.hold,u=n.bits,d=s,f=c,w=0;inf_leave:for(;;)switch(n.mode){case q:if(n.wrap===0){n.mode=et;break}for(;u<16;){if(s===0)break inf_leave;s--,l+=r[a++]<>>8&255,n.check=I(n.check,T,2,0),l=0,u=0,n.mode=Ge;break}if(n.head&&(n.head.done=!1),!(n.wrap&1)||(((l&255)<<8)+(l>>8))%31){e.msg=`incorrect header check`,n.mode=X;break}if((l&15)!=8){e.msg=`unknown compression method`,n.mode=X;break}if(l>>>=4,u-=4,C=(l&15)+8,n.wbits===0&&(n.wbits=C),C>15||C>n.wbits){e.msg=`invalid window size`,n.mode=X;break}n.dmax=1<>8&1),n.flags&512&&n.wrap&4&&(T[0]=l&255,T[1]=l>>>8&255,n.check=I(n.check,T,2,0)),l=0,u=0,n.mode=Ke;case Ke:for(;u<32;){if(s===0)break inf_leave;s--,l+=r[a++]<>>0),n.flags&512&&n.wrap&4&&(T[0]=l&255,T[1]=l>>>8&255,T[2]=l>>>16&255,T[3]=l>>>24&255,n.check=I(n.check,T,4,0)),l=0,u=0,n.mode=qe;case qe:for(;u<16;){if(s===0)break inf_leave;s--,l+=r[a++]<>8),n.flags&512&&n.wrap&4&&(T[0]=l&255,T[1]=l>>>8&255,n.check=I(n.check,T,2,0)),l=0,u=0,n.mode=Je;case Je:if(n.flags&1024){for(;u<16;){if(s===0)break inf_leave;s--,l+=r[a++]<>>8&255,n.check=I(n.check,T,2,0)),l=0,u=0}else n.head&&(n.head.extra=null);n.mode=Ye;case Ye:if(n.flags&1024&&(p=n.length,p>s&&(p=s),p&&(n.head&&(C=n.head.extra_len-n.length,n.head.extra||(n.head.extra=new Uint8Array(n.head.extra_len)),n.head.extra.set(r.subarray(a,a+p),C)),n.flags&512&&n.wrap&4&&(n.check=I(n.check,r,p,a)),s-=p,a+=p,n.length-=p),n.length))break inf_leave;n.length=0,n.mode=Xe;case Xe:if(n.flags&2048){if(s===0)break inf_leave;p=0;do C=r[a+p++],n.head&&C&&n.length<65536&&(n.head.name+=String.fromCharCode(C));while(C&&p>9&1,n.head.done=!0),e.adler=n.check=0,n.mode=Y;break;case $e:for(;u<32;){if(s===0)break inf_leave;s--,l+=r[a++]<>>=u&7,u-=u&7,n.mode=mt;break}for(;u<3;){if(s===0)break inf_leave;s--,l+=r[a++]<>>=1,--u,l&3){case 0:n.mode=tt;break;case 1:if(At(n),n.mode=st,t===6){l>>>=2,u-=2;break inf_leave}break;case 2:n.mode=it;break;case 3:e.msg=`invalid block type`,n.mode=X}l>>>=2,u-=2;break;case tt:for(l>>>=u&7,u-=u&7;u<32;){if(s===0)break inf_leave;s--,l+=r[a++]<>>16^65535)){e.msg=`invalid stored block lengths`,n.mode=X;break}if(n.length=l&65535,l=0,u=0,n.mode=nt,t===6)break inf_leave;case nt:n.mode=rt;case rt:if(p=n.length,p){if(p>s&&(p=s),p>c&&(p=c),p===0)break inf_leave;i.set(r.subarray(a,a+p),o),s-=p,a+=p,c-=p,o+=p,n.length-=p;break}n.mode=Y;break;case it:for(;u<14;){if(s===0)break inf_leave;s--,l+=r[a++]<>>=5,u-=5,n.ndist=(l&31)+1,l>>>=5,u-=5,n.ncode=(l&15)+4,l>>>=4,u-=4,n.nlen>286||n.ndist>30){e.msg=`too many length or distance symbols`,n.mode=X;break}n.have=0,n.mode=at;case at:for(;n.have>>=3,u-=3}for(;n.have<19;)n.lens[O[n.have++]]=0;if(n.lencode=n.lendyn,n.lenbits=7,E={bits:n.lenbits},w=K(He,n.lens,0,19,n.lencode,0,n.work,E),n.lenbits=E.bits,w){e.msg=`invalid code lengths set`,n.mode=X;break}n.have=0,n.mode=ot;case ot:for(;n.have>>24,v=g>>>16&255,y=g&65535,!(_<=u);){if(s===0)break inf_leave;s--,l+=r[a++]<>>=_,u-=_,n.lens[n.have++]=y;else{if(y===16){for(D=_+2;u>>=_,u-=_,n.have===0){e.msg=`invalid bit length repeat`,n.mode=X;break}C=n.lens[n.have-1],p=3+(l&3),l>>>=2,u-=2}else if(y===17){for(D=_+3;u>>=_,u-=_,C=0,p=3+(l&7),l>>>=3,u-=3}else{for(D=_+7;u>>=_,u-=_,C=0,p=11+(l&127),l>>>=7,u-=7}if(n.have+p>n.nlen+n.ndist){e.msg=`invalid bit length repeat`,n.mode=X;break}for(;p--;)n.lens[n.have++]=C}}if(n.mode===X)break;if(n.lens[256]===0){e.msg=`invalid code -- missing end-of-block`,n.mode=X;break}if(n.lenbits=9,E={bits:n.lenbits},w=K(Ue,n.lens,0,n.nlen,n.lencode,0,n.work,E),n.lenbits=E.bits,w){e.msg=`invalid literal/lengths set`,n.mode=X;break}if(n.distbits=6,n.distcode=n.distdyn,E={bits:n.distbits},w=K(We,n.lens,n.nlen,n.ndist,n.distcode,0,n.work,E),n.distbits=E.bits,w){e.msg=`invalid distances set`,n.mode=X;break}if(n.mode=st,t===6)break inf_leave;case st:n.mode=ct;case ct:if(s>=6&&c>=258){e.next_out=o,e.avail_out=c,e.next_in=a,e.avail_in=s,n.hold=l,n.bits=u,Me(e,f),o=e.next_out,i=e.output,c=e.avail_out,a=e.next_in,r=e.input,s=e.avail_in,l=n.hold,u=n.bits,n.mode===Y&&(n.back=-1);break}for(n.back=0;g=n.lencode[l&(1<>>24,v=g>>>16&255,y=g&65535,!(_<=u);){if(s===0)break inf_leave;s--,l+=r[a++]<>b)],_=g>>>24,v=g>>>16&255,y=g&65535,!(b+_<=u);){if(s===0)break inf_leave;s--,l+=r[a++]<>>=b,u-=b,n.back+=b}if(l>>>=_,u-=_,n.back+=_,n.length=y,v===0){n.mode=pt;break}if(v&32){n.back=-1,n.mode=Y;break}if(v&64){e.msg=`invalid literal/length code`,n.mode=X;break}n.extra=v&15,n.mode=lt;case lt:if(n.extra){for(D=n.extra;u>>=n.extra,u-=n.extra,n.back+=n.extra}n.was=n.length,n.mode=ut;case ut:for(;g=n.distcode[l&(1<>>24,v=g>>>16&255,y=g&65535,!(_<=u);){if(s===0)break inf_leave;s--,l+=r[a++]<>b)],_=g>>>24,v=g>>>16&255,y=g&65535,!(b+_<=u);){if(s===0)break inf_leave;s--,l+=r[a++]<>>=b,u-=b,n.back+=b}if(l>>>=_,u-=_,n.back+=_,v&64){e.msg=`invalid distance code`,n.mode=X;break}n.offset=y,n.extra=v&15,n.mode=dt;case dt:if(n.extra){for(D=n.extra;u>>=n.extra,u-=n.extra,n.back+=n.extra}if(n.offset>n.dmax){e.msg=`invalid distance too far back`,n.mode=X;break}n.mode=ft;case ft:if(c===0)break inf_leave;if(p=f-c,n.offset>p){if(p=n.offset-p,p>n.whave&&n.sane){e.msg=`invalid distance too far back`,n.mode=X;break}p>n.wnext?(p-=n.wnext,m=n.wsize-p):m=n.wnext-p,p>n.length&&(p=n.length),h=n.window}else h=i,m=o-n.offset,p=n.length;p>c&&(p=c),c-=p,n.length-=p;do i[o++]=h[m++];while(--p);n.length===0&&(n.mode=ct);break;case pt:if(c===0)break inf_leave;i[o++]=n.length,c--,n.mode=ct;break;case mt:if(n.wrap){for(;u<32;){if(s===0)break inf_leave;s--,l|=r[a++]<{if(Z(e))return-2;let t=e.state;return t.window&&(t.window=null),e.state=null,0},Pt=(e,t)=>{let n=t.length,r,i,a;return Z(e)||(r=e.state,r.wrap!==0&&r.mode!==J)?-2:r.mode===J&&(i=1,i=ge(i,t,n,0),i!==r.check)?-3:(a=jt(e,t,n,n),a?(r.mode=_t,-4):(r.havedict=1,0))},Ft=class{constructor(){this.input=null,this.next_in=0,this.avail_in=0,this.total_in=0,this.output=null,this.next_out=0,this.avail_out=0,this.total_out=0,this.msg=``,this.state=null,this.data_type=2,this.adler=0}},It=e=>{let t=new Uint8Array(e.reduce((e,t)=>e+t.length,0)),n=0;for(let r of e)t.set(r,n),n+=r.length;return t};function Q(e){"@babel/helpers - typeof";return Q=typeof Symbol==`function`&&typeof Symbol.iterator==`symbol`?function(e){return typeof e}:function(e){return e&&typeof Symbol==`function`&&e.constructor===Symbol&&e!==Symbol.prototype?`symbol`:typeof e},Q(e)}function Lt(e,t){if(Q(e)!=`object`||!e)return e;var n=e[Symbol.toPrimitive];if(n!==void 0){var r=n.call(e,t||`default`);if(Q(r)!=`object`)return r;throw TypeError(`@@toPrimitive must return a primitive value.`)}return(t===`string`?String:Number)(e)}function Rt(e){var t=Lt(e,`string`);return Q(t)==`symbol`?t:t+``}function $(e,t,n){return(t=Rt(t))in e?Object.defineProperty(e,t,{value:n,enumerable:!0,configurable:!0,writable:!0}):e[t]=n,e}function zt(e,t){var n=Object.keys(e);if(Object.getOwnPropertySymbols){var r=Object.getOwnPropertySymbols(e);t&&(r=r.filter(function(t){return Object.getOwnPropertyDescriptor(e,t).enumerable})),n.push.apply(n,r)}return n}function Bt(e){for(var t=1;t=0&&t.windowBits<16&&(t.windowBits=-t.windowBits,t.windowBits===0&&(t.windowBits=-15)),t.windowBits>=0&&t.windowBits<16&&!e.windowBits&&(t.windowBits+=32),t.windowBits>15&&t.windowBits<48&&(t.windowBits&15||(t.windowBits|=15)),this.err=0,this.msg=``,this.ended=!1,this.started=!1,this.chunks=[],this.result=new Uint8Array,this.strm=new Ft,this.strm.avail_out=0;let n=Et(this.strm,t.windowBits);if(n!==0)throw Error(ve[n]);Vt.call(t.dictionary)===`[object ArrayBuffer]`&&(t.dictionary=new Uint8Array(t.dictionary));let r=t.dictionary;if(t.raw&&r.length&&(n=Pt(this.strm,r),n!==0))throw Error(ve[n])}push(e,t=!1){let n=this.strm,r=this.options.chunkSize,i,a,o;if(this.ended)return this.err===0;for(a=typeof t==`number`?t:t===!0?4:0,Vt.call(e)===`[object ArrayBuffer]`?n.input=new Uint8Array(e):n.input=e,n.next_in=0,n.avail_in=n.input.length,this.started||(this.started=!0,this.onStart(n));;){if(n.avail_out===0&&(n.output=new Uint8Array(r),n.next_out=0,n.avail_out=r),i=Mt(n,a),i===2){let e=this.options.dictionary;e.length&&(i=Pt(n,e),i===0?i=Mt(n,a):i===-3&&(i=2))}for(;n.avail_in>0&&i===1&&n.state.wrap&2&&n.state.flags!==0&&n.input[n.next_in]!==0;)wt(n),i=Mt(n,a);if(i===-2||i===-3||i===2||i===-4)break;if(o=n.avail_out,n.next_out&&(n.avail_out===0||i===1||a>0)&&(this.onData(n.output.length===n.next_out?n.output:n.output.subarray(0,n.next_out)),n.avail_out=0,n.next_out=0),!((i===0||i===-5)&&o===0)){if(i===1){i=Nt(this.strm);break}if(n.avail_in===0){if(a===4){i=Nt(this.strm),i===0&&(i=-5);break}return!0}}}return this.err=i,this.msg=n.msg||ve[i],this.ended=!0,this.onEnd(i),i===0}onStart(e){}onData(e){this.chunks.push(e)}onEnd(e){e===0&&(this.result=It(this.chunks)),this.chunks=[]}};function Wt(e,t={}){let n=new Ut(t);if(n.push(e,!0),n.err)throw Error(n.msg);let r=n.result;return t.toText?new TextDecoder().decode(r):r}function Gt(e,t={}){return Wt(e,Bt(Bt({},t),{},{raw:!0}))}e.Inflate=Ut,e.inflate=Wt,e.ungzip=Wt,e.inflateRaw=Gt}); -//# sourceMappingURL=pako_inflate.umd.min.js.map \ No newline at end of file diff --git a/robotframework_dashboard/js/filter/pipeline.js b/robotframework_dashboard/js/filter/pipeline.js index 733c6634..6030f657 100644 --- a/robotframework_dashboard/js/filter/pipeline.js +++ b/robotframework_dashboard/js/filter/pipeline.js @@ -16,7 +16,6 @@ function sort_wall_clock(data) { // the run_start transformations only depend on these settings, so they are cached per combination // instead of copying every row on each filter apply; the filter availability uses the same copies -const transformSourceData = { runs, suites, tests, keywords, exceptions }; let transformedDataCache = { key: null, data: {} }; function get_transformed_data(name) { @@ -25,7 +24,8 @@ function get_transformed_data(name) { transformedDataCache = { key: key, data: {} }; } if (!(name in transformedDataCache.data)) { - transformedDataCache.data[name] = remove_timezones(convert_timezone(remove_milliseconds(transformSourceData[name]))); + const sourceData = { runs, suites, tests, keywords, exceptions }[name]; + transformedDataCache.data[name] = remove_timezones(convert_timezone(remove_milliseconds(sourceData))); } return transformedDataCache.data[name]; } diff --git a/robotframework_dashboard/js/graph_creation/overview.js b/robotframework_dashboard/js/graph_creation/overview.js index e25f957a..67a7b4ed 100644 --- a/robotframework_dashboard/js/graph_creation/overview.js +++ b/robotframework_dashboard/js/graph_creation/overview.js @@ -449,15 +449,13 @@ function create_project_cards_container(projectName, projectRuns, percent = null } const container = document.getElementById(`${projectName}RunCardsContainer`); + destroy_overview_donuts(container); container.innerHTML = ''; const projectRunsToShow = projectRuns.slice().reverse(); - // create cards and charts for each run card + // create cards and charts for each run card, create_project_run_card also creates the donut projectRunsToShow.forEach((run, idx) => { const runNumber = projectRunsToShow.length - idx; - const createdRunCardId = create_project_run_card(run, projectName, idx, runNumber, passRate, percent, durations, false); - const createdRunCard = document.getElementById(createdRunCardId); - container.appendChild(createdRunCard); - create_overview_run_donut(run, idx, projectName); + create_project_run_card(run, projectName, idx, runNumber, passRate, percent, durations, false); }); } @@ -467,6 +465,7 @@ function create_overview_latest_graphs(preFilteredRuns = null) { if (!orderEl) return; const order = orderEl.value; const overviewCardsContainer = document.getElementById("overviewLatestRunCardsContainer"); + destroy_overview_donuts(overviewCardsContainer); overviewCardsContainer.innerHTML = ''; const allProjects = { ...projects_by_name, ...projects_by_tag }; const durationsByProject = {}; @@ -527,6 +526,7 @@ function create_overview_latest_graphs(preFilteredRuns = null) { function create_overview_total_graphs(preFilteredRuns = null) { const overviewCardsContainer = document.getElementById("overviewTotalRunCardsContainer"); if (!overviewCardsContainer) return; + destroy_overview_donuts(overviewCardsContainer); overviewCardsContainer.innerHTML = ''; const allProjects = { ...projects_by_name, ...projects_by_tag }; const durationsByProject = {}; @@ -621,6 +621,7 @@ function create_project_run_card(run, projectName, runIndex, runNumber, passRate ) const existingRunCard = document.getElementById(`${projectNameForId}Card${runIndex}`); if (existingRunCard) { + destroy_overview_donuts(existingRunCard); // preserves listeners of element existingRunCard.replaceWith(document.createRange().createContextualFragment(projectRunCardHTML)); } else { @@ -660,7 +661,7 @@ function create_overview_run_donut(run, chartElementPostfix, projectName) { ); return; } - if (el.chartInstance) el.chartInstance.destroy(); + destroy_overview_donut(el); const chartData = { labels: [], datasets: [{ @@ -684,7 +685,44 @@ function create_overview_run_donut(run, chartElementPostfix, projectName) { const config = get_graph_config('donut', chartData, 'Run Status'); delete config.options.plugins.datalabels; config.options.plugins.legend.display = false; - el.chartInstance = new Chart(el, config); + // the run cards can hold hundreds of donuts of which only a few are on screen, so a donut + // is only created once its card comes near the viewport + el.pendingChartConfig = config; + get_lazy_donut_observer().observe(el); +} + +let lazyDonutObserver = null; + +function get_lazy_donut_observer() { + if (!lazyDonutObserver) { + lazyDonutObserver = new IntersectionObserver(entries => { + for (const entry of entries) { + if (!entry.isIntersecting) continue; + const el = entry.target; + lazyDonutObserver.unobserve(el); + if (el.pendingChartConfig) { + el.chartInstance = new Chart(el, el.pendingChartConfig); + delete el.pendingChartConfig; + } + } + }, { rootMargin: "500px" }); + } + return lazyDonutObserver; +} + +function destroy_overview_donut(el) { + lazyDonutObserver?.unobserve(el); + delete el.pendingChartConfig; + if (el.chartInstance) { + el.chartInstance.destroy(); + delete el.chartInstance; + } +} + +// Chart.js keeps a reference to every chart until it is destroyed, so the donuts have to be +// destroyed before their cards are removed from the page +function destroy_overview_donuts(container) { + container.querySelectorAll(".overview-canvas canvas").forEach(destroy_overview_donut); } function update_overview_latest_heading() { diff --git a/robotframework_dashboard/js/graph_creation/tables.js b/robotframework_dashboard/js/graph_creation/tables.js index 2f6e44d0..dff796a3 100644 --- a/robotframework_dashboard/js/graph_creation/tables.js +++ b/robotframework_dashboard/js/graph_creation/tables.js @@ -38,31 +38,37 @@ function _get_exception_table_data() { ]); } +// DataTables detects the type of a column by checking every cell on every update, which is slow +// for large tables, so the columns get the type it would detect. Only the version is left to the +// detection, because it can be numeric or free text depending on the project. +function run_start_column() { return { title: "run", type: "date" }; } +function number_column(title) { return { title, type: "num" }; } +function text_column(title, type = "string") { return { title, type }; } const runColumns = [ - { title: "run" }, { title: "full_name" }, { title: "name" }, { title: "total" }, - { title: "passed" }, { title: "failed" }, { title: "skipped" }, { title: "elapsed_s" }, - { title: "start_time" }, { title: "version" }, { title: "tags" }, { title: "alias" }, { title: "metadata" }, - { title: "custom_filters" }, + run_start_column(), text_column("full_name"), text_column("name"), number_column("total"), + number_column("passed"), number_column("failed"), number_column("skipped"), number_column("elapsed_s"), + text_column("start_time"), { title: "version" }, text_column("tags"), text_column("alias"), text_column("metadata"), + text_column("custom_filters"), ]; const suiteColumns = [ - { title: "run" }, { title: "full_name" }, { title: "name" }, { title: "total" }, - { title: "passed" }, { title: "failed" }, { title: "skipped" }, { title: "elapsed_s" }, - { title: "start_time" }, { title: "alias" }, { title: "id" }, + run_start_column(), text_column("full_name"), text_column("name"), number_column("total"), + number_column("passed"), number_column("failed"), number_column("skipped"), number_column("elapsed_s"), + text_column("start_time"), text_column("alias"), text_column("id"), ]; const testColumns = [ - { title: "run" }, { title: "full_name" }, { title: "name" }, - { title: "passed" }, { title: "failed" }, { title: "skipped" }, { title: "elapsed_s" }, - { title: "start_time" }, { title: "message" }, { title: "tags" }, { title: "alias" }, { title: "id" }, - { title: "attempts" }, + run_start_column(), text_column("full_name"), text_column("name"), + number_column("passed"), number_column("failed"), number_column("skipped"), number_column("elapsed_s"), + text_column("start_time"), text_column("message", "html"), text_column("tags"), text_column("alias"), text_column("id"), + text_column("attempts", "string-utf8"), ]; const keywordColumns = [ - { title: "run" }, { title: "name" }, { title: "passed" }, { title: "failed" }, - { title: "skipped" }, { title: "times_run" }, { title: "total_execution_time" }, - { title: "average_execution_time" }, { title: "min_execution_time" }, - { title: "max_execution_time" }, { title: "alias" }, { title: "owner" }, + run_start_column(), text_column("name"), number_column("passed"), number_column("failed"), + number_column("skipped"), number_column("times_run"), number_column("total_execution_time"), + number_column("average_execution_time"), number_column("min_execution_time"), + number_column("max_execution_time"), text_column("alias"), text_column("owner"), ]; const exceptionColumns = [ - { title: "run" }, { title: "message" }, { title: "amount" }, { title: "alias" }, + run_start_column(), text_column("message"), number_column("amount"), text_column("alias"), ]; function create_data_table(tableId, columns, getDataFn) { diff --git a/robotframework_dashboard/js/graph_data/tooltip_helpers.js b/robotframework_dashboard/js/graph_data/tooltip_helpers.js index 545b6a14..781c3a0d 100644 --- a/robotframework_dashboard/js/graph_data/tooltip_helpers.js +++ b/robotframework_dashboard/js/graph_data/tooltip_helpers.js @@ -4,6 +4,8 @@ function build_tooltip_meta(filteredData, durationField = 'elapsed_s', aggregate = false) { const byLabel = {}; const byTime = {}; + // many rows share a run_start, so every run_start is parsed only once + const timeKeys = new Map(); for (const item of filteredData) { const elapsed = parseFloat(item[durationField]) || 0; const p = item.passed || 0; @@ -11,7 +13,10 @@ function build_tooltip_meta(filteredData, durationField = 'elapsed_s', aggregate const s = item.skipped || 0; const msg = item.message || ''; const keys = [item.run_start, item.run_alias]; - const timeKey = new Date(item.run_start.replace(" ", "T")).getTime(); + if (!timeKeys.has(item.run_start)) { + timeKeys.set(item.run_start, new Date(item.run_start.replace(" ", "T")).getTime()); + } + const timeKey = timeKeys.get(item.run_start); const meta = { elapsed_s: elapsed, passed: p, failed: f, skipped: s, message: msg }; for (const key of keys) { if (aggregate && byLabel[key]) { diff --git a/robotframework_dashboard/js/main.js b/robotframework_dashboard/js/main.js index 72f415cc..e1df835c 100644 --- a/robotframework_dashboard/js/main.js +++ b/robotframework_dashboard/js/main.js @@ -6,10 +6,12 @@ import { setup_collapsables } from "./eventlisteners/collapsables.js"; import { setup_filter_modal } from "./eventlisteners/filter_modal.js"; import { setup_settings_modal } from "./eventlisteners/settings_modal.js"; import { setup_menu, setup_navbar_overflow } from "./menu.js"; +import { load_data } from "./variables/data.js"; // function that triggers all functions that should be executed when the dashboard is loaded first // in the correct order! -function main() { +async function main() { + await load_data(); setup_local_storage(); setup_database_stats(); setup_dashboard_section_layout_buttons(); diff --git a/robotframework_dashboard/js/variables/data.js b/robotframework_dashboard/js/variables/data.js index 8d6c6e80..82d15f64 100644 --- a/robotframework_dashboard/js/variables/data.js +++ b/robotframework_dashboard/js/variables/data.js @@ -1,11 +1,22 @@ -// prepare input data -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"); +// the input data, filled by load_data() which main() awaits before anything reads it +let runs = []; +let suites = []; +let tests = []; +let keywords = []; +let exceptions = []; -function decode_and_decompress(base64Str) { +async function load_data() { + [runs, suites, tests, keywords, exceptions] = await Promise.all([ + decode_and_decompress("placeholder_runs"), + decode_and_decompress("placeholder_suites"), + decode_and_decompress("placeholder_tests"), + decode_and_decompress("placeholder_keywords"), + decode_and_decompress("placeholder_exceptions"), + ]); +} + +// the payloads are zlib compressed JSON, inflated with the native DecompressionStream +async function decode_and_decompress(base64Str) { if (base64Str.includes("placeholder_")) return []; // a plain loop is much faster than Uint8Array.from with a map callback on large payloads const binaryStr = atob(base64Str); @@ -13,8 +24,8 @@ function decode_and_decompress(base64Str) { for (let i = 0; i < binaryStr.length; i++) { compressedData[i] = binaryStr.charCodeAt(i); } - const decompressedData = pako.inflate(compressedData, { toText: true }); - return JSON.parse(decompressedData); + const stream = new Blob([compressedData]).stream().pipeThrough(new DecompressionStream("deflate")); + return new Response(stream).json(); } var unified_dashboard_title = '"placeholder_dashboard_title"' @@ -29,6 +40,7 @@ const no_auto_update = "placeholder_no_autoupdate" if (!message_config.includes("placeholder_message_config")) { message_config = JSON.parse(message_config) } export { + load_data, runs, suites, tests, diff --git a/robotframework_dashboard/licenses/THIRD_PARTY_LICENSES.txt b/robotframework_dashboard/licenses/THIRD_PARTY_LICENSES.txt index 6dffbe48..c7e47585 100644 --- a/robotframework_dashboard/licenses/THIRD_PARTY_LICENSES.txt +++ b/robotframework_dashboard/licenses/THIRD_PARTY_LICENSES.txt @@ -229,31 +229,3 @@ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. - -======================================== -Pako -https://github.com/nodeca/pako -Version: 3.0.2 -License: MIT -======================================== -(The MIT License) - -Copyright (C) 2014-2017 by Vitaly Puzrin and Andrei Tuputcyn - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in -all copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN -THE SOFTWARE. diff --git a/tests/python/test_dashboard.py b/tests/python/test_dashboard.py index fd1c5180..aaea2c05 100644 --- a/tests/python/test_dashboard.py +++ b/tests/python/test_dashboard.py @@ -5,6 +5,15 @@ import pytest from robotframework_dashboard.dashboard import DashboardGenerator +EMBEDDED_PAYLOAD_ORDER = ["runs", "suites", "tests", "keywords", "exceptions"] + + +def _embedded_payload(content, name): + """Decode one embedded data payload, load_data() in data.js decodes them in this order.""" + payloads = re.findall(r'decode_and_decompress\("([^"]+)"\)', content) + assert len(payloads) == len(EMBEDDED_PAYLOAD_ORDER), "data payloads not found in dashboard" + return json.loads(zlib.decompress(base64.b64decode(payloads[EMBEDDED_PAYLOAD_ORDER.index(name)]))) + def test_compress_and_encode_returns_string(): result = DashboardGenerator()._compress_and_encode({"key": "value"}) @@ -317,9 +326,7 @@ def test_generate_dashboard_uselogs_embeds_relative_paths(tmp_path): ) content = dashboard.read_text(encoding="utf-8") # Extract the base64 payload for runs - match = re.search(r'const runs = decode_and_decompress\("([^"]+)"\)', content) - assert match, "runs payload not found in dashboard" - decoded = json.loads(zlib.decompress(base64.b64decode(match.group(1)))) + decoded = _embedded_payload(content, "runs") assert decoded[0]["path"] == "output.xml" assert str(tmp_path).replace("\\", "/") not in decoded[0]["path"] @@ -346,9 +353,7 @@ def test_generate_dashboard_server_mode_keeps_absolute_paths(tmp_path): no_autoupdate=False, ) content = dashboard.read_text(encoding="utf-8") - match = re.search(r'const runs = decode_and_decompress\("([^"]+)"\)', content) - assert match, "runs payload not found in dashboard" - decoded = json.loads(zlib.decompress(base64.b64decode(match.group(1)))) + decoded = _embedded_payload(content, "runs") assert decoded[0]["path"] == abs_path @@ -374,9 +379,7 @@ def test_generate_dashboard_embeds_exceptions(tmp_path): no_autoupdate=False, ) content = dashboard.read_text(encoding="utf-8") - match = re.search(r'const exceptions = decode_and_decompress\("([^"]+)"\)', content) - assert match, "exceptions payload not found in dashboard" - decoded = json.loads(zlib.decompress(base64.b64decode(match.group(1)))) + decoded = _embedded_payload(content, "exceptions") assert decoded[0]["message"] == "Timeout error" assert decoded[0]["amount"] == 2 @@ -401,7 +404,5 @@ def test_generate_dashboard_missing_exceptions_key_defaults_to_empty(tmp_path): ) content = dashboard.read_text(encoding="utf-8") import json, zlib, base64, re - match = re.search(r'const exceptions = decode_and_decompress\("([^"]+)"\)', content) - assert match, "exceptions payload not found in dashboard" - decoded = json.loads(zlib.decompress(base64.b64decode(match.group(1)))) + decoded = _embedded_payload(content, "exceptions") assert decoded == []