From 87c7e33198bb7327b7ddaa5eeae2aeacf6c280aa Mon Sep 17 00:00:00 2001
From: grdddj <jiri.musil06@seznam.cz>
Date: Thu, 4 May 2023 14:15:51 +0200
Subject: [PATCH] chore(tests): improve UI reports

---
 tests/ui_tests/reporting/create-gif.js  | 209 ++++++++++++++++++++++++
 tests/ui_tests/reporting/master_diff.py |   6 +-
 tests/ui_tests/reporting/testreport.css | 104 +++++++++++-
 tests/ui_tests/reporting/testreport.js  |  12 +-
 tests/ui_tests/reporting/testreport.py  |   2 +
 5 files changed, 327 insertions(+), 6 deletions(-)
 create mode 100644 tests/ui_tests/reporting/create-gif.js

diff --git a/tests/ui_tests/reporting/create-gif.js b/tests/ui_tests/reporting/create-gif.js
new file mode 100644
index 0000000000..a7d9141325
--- /dev/null
+++ b/tests/ui_tests/reporting/create-gif.js
@@ -0,0 +1,209 @@
+
+
+function createGif() {
+    // Finds all the screenshots on the screen, creates a new img
+    // element at the top and switches the src attribute every 200ms
+    // to create a notion of GIF.
+    // Adds some controlling possibilities - buttons, input fields
+    // and sliders to enable pausing, stepping back and forth, changing
+    // the delay, etc.
+
+    const allImages = document.body.querySelectorAll('img:not(#gif)');
+
+    // When no images there, do nothing
+    if (allImages.length === 0) {
+        return;
+    }
+
+    // Globals that will be changed by individual functions
+    let globCurrentIndex = 0;
+    let globTimerId = null;
+
+    // Global constants
+    const pauseText = 'Pause (Space)';
+    const continueText = 'Continue (Space)';
+    const prevText = 'Prev (<)';
+    const nextText = 'Next (>)';
+
+    const delayText = 'Delay (ms):';
+    const sliderText = 'Progress:';
+
+    const defaultDelay = 200;
+
+    const keyboardShortcutPrev = 'ArrowLeft';
+    const keyboardShortcutNext = 'ArrowRight';
+    const keyboardShortcutPauseContinue = 'Space';
+
+    const pauseColor = '#ffa500'; // Orange
+    const continueColor = '#4CAF50'; // Green
+
+    const btnClass = 'gifBtn';
+
+    // Gif itself
+    const gif = document.createElement('img');
+    gif.id = 'gif';
+
+    // Update the image source and the slider value according to the current index
+    // Lazy-loading all the lazy-loaded images
+    function updateGifSourceAndSlider() {
+        const currentImage = allImages[globCurrentIndex];
+        // When the currentImage is not loaded (because of `loading=lazy` attribute), load it
+        if (!currentImage.complete) {
+            const tempImg = new Image();
+            tempImg.src = currentImage.src;
+            tempImg.onload = function () {
+                currentImage.src = tempImg.src;
+            };
+        }
+        gif.src = currentImage.src;
+        slider.value = globCurrentIndex;
+    }
+
+    // Switching between running and paused state
+    function toggleGif() {
+        if (globTimerId) {
+            clearInterval(globTimerId);
+            globTimerId = null;
+            pauseContinueButton.textContent = continueText;
+            pauseContinueButton.style.backgroundColor = continueColor;
+            delayInput.disabled = false;
+            prevButton.disabled = false;
+            nextButton.disabled = false;
+        } else {
+            pauseContinueButton.textContent = pauseText;
+            pauseContinueButton.style.backgroundColor = pauseColor;
+            delayInput.disabled = true;
+            prevButton.disabled = true;
+            nextButton.disabled = true;
+            globTimerId = runGif();
+        }
+    }
+
+    // Start the gif, return the timer id
+    function runGif() {
+        const delay = parseInt(delayInput.value) || defaultDelay;
+        return setInterval(() => {
+            changeGifFrame(1);
+        }, delay);
+    }
+
+    // Go to the previous or next frame (when supplied with -1 or 1, respectively)
+    function changeGifFrame(delta) {
+        globCurrentIndex = (globCurrentIndex + delta + allImages.length) % allImages.length;
+        updateGifSourceAndSlider();
+    }
+
+    // Pause/continue button
+    const pauseContinueButton = document.createElement('button');
+    pauseContinueButton.id = 'pauseContinueButton';
+    pauseContinueButton.classList.add(btnClass);
+    pauseContinueButton.textContent = pauseText;
+    pauseContinueButton.style.backgroundColor = pauseColor;
+    pauseContinueButton.addEventListener('click', toggleGif);
+
+    // Prev button
+    const prevButton = document.createElement('button');
+    prevButton.id = 'prevButton';
+    prevButton.textContent = prevText;
+    prevButton.classList.add(btnClass);
+    prevButton.disabled = true; // Disabled until the gif is paused
+    prevButton.addEventListener('click', () => changeGifFrame(-1));
+
+    // Next button
+    const nextButton = document.createElement('button');
+    nextButton.id = 'nextButton';
+    nextButton.textContent = nextText;
+    nextButton.classList.add(btnClass);
+    nextButton.disabled = true; // Disabled until the gif is paused
+    nextButton.addEventListener('click', () => changeGifFrame(1));
+
+    // Delay label
+    const delayLabel = document.createElement('label');
+    delayLabel.id = 'delayLabel';
+    delayLabel.textContent = delayText;
+    delayLabel.htmlFor = 'delayInput';
+
+    // Delay input
+    const delayInput = document.createElement('input');
+    delayInput.id = 'delayInput';
+    delayInput.type = 'number';
+    delayInput.value = defaultDelay;
+    delayInput.size = '5';
+    delayInput.disabled = true; // Disabled until the gif is paused
+
+    // Slider label
+    const sliderLabel = document.createElement('label');
+    sliderLabel.id = 'sliderLabel';
+    sliderLabel.textContent = sliderText;
+    sliderLabel.htmlFor = 'slider';
+
+    // Slider
+    const slider = document.createElement('input');
+    slider.id = 'slider';
+    slider.type = 'range';
+    slider.min = '0';
+    slider.max = allImages.length - 1;
+    slider.value = globCurrentIndex;
+    slider.addEventListener('input', () => {
+        globCurrentIndex = parseInt(slider.value);
+        updateGifSourceAndSlider();
+    });
+
+    // Div for buttons
+    const buttonContainer = document.createElement('div');
+    buttonContainer.id = 'buttonContainer';
+    buttonContainer.appendChild(prevButton);
+    buttonContainer.appendChild(pauseContinueButton);
+    buttonContainer.appendChild(nextButton);
+
+    // Div for input
+    const inputContainer = document.createElement('div');
+    inputContainer.id = 'inputContainer';
+    inputContainer.appendChild(delayLabel);
+    inputContainer.appendChild(delayInput);
+
+    // Div for slider
+    const sliderContainer = document.createElement('div');
+    sliderContainer.id = 'sliderContainer';
+    sliderContainer.appendChild(sliderLabel);
+    sliderContainer.appendChild(slider);
+
+    // Insert everything above the <hr> or at the top of the page when missing
+    const hr = document.querySelector('hr');
+    if (hr) {
+        hr.parentNode.insertBefore(gif, hr);
+        hr.parentNode.insertBefore(buttonContainer, hr);
+        hr.parentNode.insertBefore(inputContainer, hr);
+        hr.parentNode.insertBefore(sliderContainer, hr);
+    } else {
+        document.body.insertBefore(gif, document.body.firstChild);
+        document.body.insertBefore(buttonContainer, document.body.firstChild);
+        document.body.insertBefore(inputContainer, document.body.firstChild);
+        document.body.insertBefore(sliderContainer, document.body.firstChild);
+    }
+
+    // Add keyboard shortcuts and disable default shortcuts behavior
+    document.addEventListener('keydown', (event) => {
+        switch (event.code) {
+            case keyboardShortcutPauseContinue:
+                event.preventDefault();
+                toggleGif();
+                break;
+            case keyboardShortcutPrev:
+                if (!prevButton.disabled) {
+                    event.preventDefault();
+                    changeGifFrame(-1);
+                }
+                break;
+            case keyboardShortcutNext:
+                if (!nextButton.disabled) {
+                    event.preventDefault();
+                    changeGifFrame(1);
+                }
+                break;
+        }
+    });
+
+    // Start the gif
+    globTimerId = runGif();
+}
diff --git a/tests/ui_tests/reporting/master_diff.py b/tests/ui_tests/reporting/master_diff.py
index e3bbd1537d..330bcc88d0 100644
--- a/tests/ui_tests/reporting/master_diff.py
+++ b/tests/ui_tests/reporting/master_diff.py
@@ -232,7 +232,11 @@ def create_reports() -> None:
 
     for test_name, test_hash in removed_tests.items():
         with tmpdir() as temp_dir:
-            download.fetch_recorded(test_hash, temp_dir)
+            try:
+                download.fetch_recorded(test_hash, temp_dir)
+            except RuntimeError:
+                print("Could not download recorded files for", test_name)
+                continue
             removed(temp_dir, test_name)
 
     for test_name, test_hash in added_tests.items():
diff --git a/tests/ui_tests/reporting/testreport.css b/tests/ui_tests/reporting/testreport.css
index db9f284f53..24b53ce505 100644
--- a/tests/ui_tests/reporting/testreport.css
+++ b/tests/ui_tests/reporting/testreport.css
@@ -2,11 +2,13 @@
     color: blue;
 }
 
-tr.ok a, tr.ok a:visited {
+tr.ok a,
+tr.ok a:visited {
     color: grey;
 }
 
-tr.bad a, tr.bad a:visited {
+tr.bad a,
+tr.bad a:visited {
     color: darkred;
 }
 
@@ -61,7 +63,103 @@ tr.bad a, tr.bad a:visited {
     display: none;
 }
 
-.model-T1 img, .model-TR img {
+.model-T1 img,
+.model-TR img {
     image-rendering: pixelated;
     width: 256px;
 }
+
+/* GIF styling */
+
+/* Style the input field */
+#delayInput {
+    padding: 8px;
+    border: 1px solid #ccc;
+    border-radius: 4px;
+    font-size: 16px;
+    margin-top: 10px;
+    margin-left: 10px;
+}
+
+/* Style the buttons */
+button.gifBtn {
+    padding: 8px;
+    border: none;
+    border-radius: 4px;
+    font-size: 16px;
+    background-color: #4CAF50;
+    color: white;
+    cursor: pointer;
+    transition: background-color 0.3s ease;
+    /* Add a 10 px space to the right of the button */
+    margin-right: 10px;
+}
+
+/* Remove the margin-right from the last button */
+button.gifBtn:last-child {
+    margin-right: 0;
+}
+
+/* Apply a gray background to disabled buttons */
+button.gifBtn:disabled {
+    background-color: #ccc;
+    cursor: not-allowed;
+}
+
+button.gifBtn:hover {
+    background-color: #3e8e41;
+}
+
+/* Style the slider */
+#slider {
+    width: 30%;
+    margin-top: 10px;
+}
+
+/* Style the progress bar */
+#slider::-webkit-slider-runnable-track {
+    height: 8px;
+    background-color: #ddd;
+    border-radius: 4px;
+}
+
+#slider::-webkit-slider-thumb {
+    height: 20px;
+    width: 20px;
+    border-radius: 50%;
+    background-color: #4CAF50;
+    cursor: pointer;
+    -webkit-appearance: none;
+    margin-top: -7px;
+}
+
+#slider::-moz-range-track {
+    height: 8px;
+    background-color: #ddd;
+    border-radius: 4px;
+}
+
+#slider::-moz-range-thumb {
+    height: 20px;
+    width: 20px;
+    border-radius: 50%;
+    background-color: #4CAF50;
+    cursor: pointer;
+}
+
+#slider::-ms-track {
+    height: 8px;
+    background-color: #ddd;
+    border-radius: 4px;
+    border: none;
+    color: transparent;
+}
+
+#slider::-ms-thumb {
+    height: 20px;
+    width: 20px;
+    border-radius: 50%;
+    background-color: #4CAF50;
+    cursor: pointer;
+    margin-top: 0;
+}
diff --git a/tests/ui_tests/reporting/testreport.js b/tests/ui_tests/reporting/testreport.js
index 8897a347fa..bf192774e7 100644
--- a/tests/ui_tests/reporting/testreport.js
+++ b/tests/ui_tests/reporting/testreport.js
@@ -38,7 +38,7 @@ async function markState(state) {
                 "test": stem,
                 "hash": document.body.dataset.actualHash
             })
-         })
+        })
         window.localStorage.setItem(itemKeyFromOneTest(), 'ok')
     } else {
         window.localStorage.setItem(itemKeyFromOneTest(), state)
@@ -139,7 +139,6 @@ function onLoadTestCase() {
     }
 }
 
-
 function onLoad() {
     if (window.location.protocol === "file") return
 
@@ -147,9 +146,18 @@ function onLoad() {
         elem.classList.remove("script-hidden")
     }
 
+    // Comes from create-gif.js, which is loaded in the final HTML
+    // Do it only in case of individual tests (which have "UI comparison" written on page),
+    // not on the main `index.html` page nor on `differing_screens.html` or other screen pages.
+    if (document.body.textContent.includes("UI comparison")) {
+        createGif()
+    }
+
     if (document.body.dataset.index) {
         onLoadIndex()
     } else {
+        // TODO: this is triggering some exception in console:
+        // Uncaught DOMException: Permission denied to access property "document" on cross-origin object
         onLoadTestCase()
     }
 }
diff --git a/tests/ui_tests/reporting/testreport.py b/tests/ui_tests/reporting/testreport.py
index a883a3c7d7..2f72de39df 100644
--- a/tests/ui_tests/reporting/testreport.py
+++ b/tests/ui_tests/reporting/testreport.py
@@ -21,6 +21,7 @@ SCREEN_TEXT_FILE = TESTREPORT_PATH / "screen_text.txt"
 
 STYLE = (HERE / "testreport.css").read_text()
 SCRIPT = (HERE / "testreport.js").read_text()
+GIF_SCRIPT = (HERE / "create-gif.js").read_text()
 
 # These two html files are referencing each other
 ALL_SCREENS = "all_screens.html"
@@ -37,6 +38,7 @@ def document(
     style = t.style()
     style.add_raw_string(STYLE)
     script = t.script()
+    script.add_raw_string(GIF_SCRIPT)
     script.add_raw_string(SCRIPT)
     doc.head.add(style, script)