From 40db3abd00187a4a899e3257f5e70b5ab089c49e Mon Sep 17 00:00:00 2001
From: Troels Ugilt Jensen <6103205+tuj@users.noreply.github.com>
Date: Tue, 21 Apr 2026 07:07:37 +0200
Subject: [PATCH 60/73] 7228: Reorganized files
---
assets/client/app.jsx | 8 ++++----
assets/client/{context => }/client-state-context.jsx | 0
assets/client/components/error-boundary.jsx | 2 +-
assets/client/components/region.jsx | 4 ++--
assets/client/components/screen.jsx | 4 ++--
assets/client/components/slide.jsx | 2 +-
assets/client/components/touch-region.jsx | 2 +-
assets/client/{util => core}/api-query.js | 2 +-
assets/client/{util => core}/app-storage.js | 0
assets/client/{util => core}/client-config-loader.js | 4 ++--
assets/client/{util => core}/local-storage-keys.js | 0
assets/client/index.jsx | 2 +-
assets/client/{logger => }/logger.js | 0
assets/client/redux/base-query.js | 2 +-
assets/client/service/content-service.js | 8 ++++----
assets/client/{data-sync => service}/data-sync.js | 0
assets/client/{data-sync => service}/pull-strategy.js | 6 +++---
assets/client/service/release-service.js | 6 +++---
assets/client/service/schedule-service.js | 4 ++--
assets/client/service/tenant-service.js | 4 ++--
assets/client/service/token-service.js | 6 +++---
21 files changed, 33 insertions(+), 33 deletions(-)
rename assets/client/{context => }/client-state-context.jsx (100%)
rename assets/client/{util => core}/api-query.js (98%)
rename assets/client/{util => core}/app-storage.js (100%)
rename assets/client/{util => core}/client-config-loader.js (96%)
rename assets/client/{util => core}/local-storage-keys.js (100%)
rename assets/client/{logger => }/logger.js (100%)
rename assets/client/{data-sync => service}/data-sync.js (100%)
rename assets/client/{data-sync => service}/pull-strategy.js (99%)
diff --git a/assets/client/app.jsx b/assets/client/app.jsx
index ac72de203..791d118a5 100644
--- a/assets/client/app.jsx
+++ b/assets/client/app.jsx
@@ -1,10 +1,10 @@
import { useEffect, useRef, useState } from "react";
import Screen from "./components/screen.jsx";
import ContentService from "./service/content-service";
-import ClientConfigLoader from "./util/client-config-loader.js";
-import logger from "./logger/logger";
+import ClientConfigLoader from "./core/client-config-loader.js";
+import logger from "./logger";
import fallback from "./assets/fallback.png";
-import appStorage from "./util/app-storage";
+import appStorage from "./core/app-storage";
import defaults from "./util/defaults";
import tokenService from "./service/token-service";
import releaseService from "./service/release-service";
@@ -12,7 +12,7 @@ import tenantService from "./service/tenant-service";
import statusService from "./service/status-service";
import constants from "./util/constants";
import reauthenticateRef from "./redux/reauthenticate-ref";
-import { useClientState } from "./context/client-state-context.jsx";
+import { useClientState } from "./client-state-context.jsx";
import "./app.scss";
/**
diff --git a/assets/client/context/client-state-context.jsx b/assets/client/client-state-context.jsx
similarity index 100%
rename from assets/client/context/client-state-context.jsx
rename to assets/client/client-state-context.jsx
diff --git a/assets/client/components/error-boundary.jsx b/assets/client/components/error-boundary.jsx
index 68a3e5b54..7c3b70895 100644
--- a/assets/client/components/error-boundary.jsx
+++ b/assets/client/components/error-boundary.jsx
@@ -1,5 +1,5 @@
import { Component } from "react";
-import logger from "../logger/logger";
+import logger from "../logger";
import fallback from "../assets/fallback.png";
import "./error-boundary.scss";
diff --git a/assets/client/components/region.jsx b/assets/client/components/region.jsx
index 44fc284b4..1d21a53c5 100644
--- a/assets/client/components/region.jsx
+++ b/assets/client/components/region.jsx
@@ -3,10 +3,10 @@ import { createGridArea } from "../../shared/grid-generator/grid-generator";
import { TransitionGroup, CSSTransition } from "react-transition-group";
import ErrorBoundary from "./error-boundary.jsx";
import idFromPath from "../util/id-from-path";
-import logger from "../logger/logger";
+import logger from "../logger";
import Slide from "./slide.jsx";
import constants from "../util/constants";
-import { useClientState } from "../context/client-state-context.jsx";
+import { useClientState } from "../client-state-context.jsx";
import "./region.scss";
/**
diff --git a/assets/client/components/screen.jsx b/assets/client/components/screen.jsx
index 917e110a6..a9597f1c6 100644
--- a/assets/client/components/screen.jsx
+++ b/assets/client/components/screen.jsx
@@ -2,9 +2,9 @@ import { useEffect, useRef } from "react";
import SunCalc from "suncalc";
import { createGrid } from "../../shared/grid-generator/grid-generator";
import Region from "./region.jsx";
-import logger from "../logger/logger";
+import logger from "../logger";
import TouchRegion from "./touch-region.jsx";
-import ClientConfigLoader from "../util/client-config-loader.js";
+import ClientConfigLoader from "../core/client-config-loader.js";
import constants from "../util/constants";
import "./screen.scss";
diff --git a/assets/client/components/slide.jsx b/assets/client/components/slide.jsx
index 99fec16b5..59a9fe8bc 100644
--- a/assets/client/components/slide.jsx
+++ b/assets/client/components/slide.jsx
@@ -1,5 +1,5 @@
import ErrorBoundary from "./error-boundary.jsx";
-import logger from "../logger/logger";
+import logger from "../logger";
import { renderSlide } from "../../shared/slide-utils/templates.js";
import constants from "../util/constants";
import "./slide.scss";
diff --git a/assets/client/components/touch-region.jsx b/assets/client/components/touch-region.jsx
index 9b72e760b..9b0f3a836 100644
--- a/assets/client/components/touch-region.jsx
+++ b/assets/client/components/touch-region.jsx
@@ -5,7 +5,7 @@ import idFromPath from "../util/id-from-path";
import IconClose from "../assets/icon-close.svg";
import IconPointer from "../assets/icon-pointer.svg";
import Slide from "./slide.jsx";
-import { useClientState } from "../context/client-state-context.jsx";
+import { useClientState } from "../client-state-context.jsx";
import "./touch-region.scss";
/**
diff --git a/assets/client/util/api-query.js b/assets/client/core/api-query.js
similarity index 98%
rename from assets/client/util/api-query.js
rename to assets/client/core/api-query.js
index 648cddc97..0c2709ee5 100644
--- a/assets/client/util/api-query.js
+++ b/assets/client/core/api-query.js
@@ -1,4 +1,4 @@
-import logger from "../logger/logger";
+import logger from "../logger";
import { clientStore } from "../redux/store.js";
import { clientApi } from "../redux/generated-api.ts";
diff --git a/assets/client/util/app-storage.js b/assets/client/core/app-storage.js
similarity index 100%
rename from assets/client/util/app-storage.js
rename to assets/client/core/app-storage.js
diff --git a/assets/client/util/client-config-loader.js b/assets/client/core/client-config-loader.js
similarity index 96%
rename from assets/client/util/client-config-loader.js
rename to assets/client/core/client-config-loader.js
index 15c4651ca..eb00b3e43 100644
--- a/assets/client/util/client-config-loader.js
+++ b/assets/client/core/client-config-loader.js
@@ -1,6 +1,6 @@
import appStorage from "./app-storage.js";
-import logger from "../logger/logger";
-import defaults from "./defaults.js";
+import logger from "../logger";
+import defaults from "../util/defaults.js";
// Defaults.
let configData = null;
diff --git a/assets/client/util/local-storage-keys.js b/assets/client/core/local-storage-keys.js
similarity index 100%
rename from assets/client/util/local-storage-keys.js
rename to assets/client/core/local-storage-keys.js
diff --git a/assets/client/index.jsx b/assets/client/index.jsx
index e5e819c11..67a8a27a6 100644
--- a/assets/client/index.jsx
+++ b/assets/client/index.jsx
@@ -1,7 +1,7 @@
import { createRoot } from "react-dom/client";
import { Provider } from "react-redux";
import { clientStore } from "./redux/store.js";
-import { ClientStateProvider } from "./context/client-state-context.jsx";
+import { ClientStateProvider } from "./client-state-context.jsx";
import App from "./app.jsx";
const url = new URL(window.location.href);
diff --git a/assets/client/logger/logger.js b/assets/client/logger.js
similarity index 100%
rename from assets/client/logger/logger.js
rename to assets/client/logger.js
diff --git a/assets/client/redux/base-query.js b/assets/client/redux/base-query.js
index 1c925d9c6..92462763f 100644
--- a/assets/client/redux/base-query.js
+++ b/assets/client/redux/base-query.js
@@ -1,5 +1,5 @@
import { fetchBaseQuery } from "@reduxjs/toolkit/query/react";
-import localStorageKeys from "../util/local-storage-keys";
+import localStorageKeys from "../core/local-storage-keys";
import reauthenticateRef from "./reauthenticate-ref";
const clientBaseQuery = async (args, api, extraOptions) => {
diff --git a/assets/client/service/content-service.js b/assets/client/service/content-service.js
index 5480b004b..95dfa6b64 100644
--- a/assets/client/service/content-service.js
+++ b/assets/client/service/content-service.js
@@ -4,12 +4,12 @@ import {
screenForPlaylistPreview,
screenForSlidePreview,
} from "../util/preview";
-import logger from "../logger/logger";
+import logger from "../logger";
import idFromPath from "../util/id-from-path";
-import DataSync from "../data-sync/data-sync";
+import DataSync from "./data-sync";
import ScheduleService from "./schedule-service";
-import ClientConfigLoader from "../util/client-config-loader.js";
-import { query } from "../util/api-query.js";
+import ClientConfigLoader from "../core/client-config-loader.js";
+import { query } from "../core/api-query.js";
/**
* ContentService.
diff --git a/assets/client/data-sync/data-sync.js b/assets/client/service/data-sync.js
similarity index 100%
rename from assets/client/data-sync/data-sync.js
rename to assets/client/service/data-sync.js
diff --git a/assets/client/data-sync/pull-strategy.js b/assets/client/service/pull-strategy.js
similarity index 99%
rename from assets/client/data-sync/pull-strategy.js
rename to assets/client/service/pull-strategy.js
index c38b1473a..b28c74614 100644
--- a/assets/client/data-sync/pull-strategy.js
+++ b/assets/client/service/pull-strategy.js
@@ -1,9 +1,9 @@
import isPublished from "../util/is-published";
-import logger from "../logger/logger";
+import logger from "../logger";
import idFromPath from "../util/id-from-path";
import { cloneDeep } from "lodash";
-import ClientConfigLoader from "../util/client-config-loader.js";
-import { query, queryAllPages } from "../util/api-query.js";
+import ClientConfigLoader from "../core/client-config-loader.js";
+import { query, queryAllPages } from "../core/api-query.js";
import constants from "../util/constants.js";
import defaults from "../util/defaults.js";
diff --git a/assets/client/service/release-service.js b/assets/client/service/release-service.js
index b4f38ffbc..74ab015d3 100644
--- a/assets/client/service/release-service.js
+++ b/assets/client/service/release-service.js
@@ -1,8 +1,8 @@
-import ClientConfigLoader from "../util/client-config-loader.js";
+import ClientConfigLoader from "../core/client-config-loader.js";
import defaults from "../util/defaults";
import idFromPath from "../util/id-from-path";
-import appStorage from "../util/app-storage";
-import logger from "../logger/logger";
+import appStorage from "../core/app-storage";
+import logger from "../logger";
import statusService from "./status-service";
import constants from "../util/constants";
import releaseLoader from "../../shared/release-loader.js";
diff --git a/assets/client/service/schedule-service.js b/assets/client/service/schedule-service.js
index f0004d8e4..ebbe2d444 100644
--- a/assets/client/service/schedule-service.js
+++ b/assets/client/service/schedule-service.js
@@ -2,8 +2,8 @@ import sha256 from "crypto-js/sha256";
import Md5 from "crypto-js/md5";
import Base64 from "crypto-js/enc-base64";
import isPublished from "../util/is-published";
-import logger from "../logger/logger";
-import ClientConfigLoader from "../util/client-config-loader.js";
+import logger from "../logger";
+import ClientConfigLoader from "../core/client-config-loader.js";
import ScheduleUtils from "../util/schedule";
import defaults from "../util/defaults";
diff --git a/assets/client/service/tenant-service.js b/assets/client/service/tenant-service.js
index 12d5c1666..a47140922 100644
--- a/assets/client/service/tenant-service.js
+++ b/assets/client/service/tenant-service.js
@@ -1,5 +1,5 @@
-import appStorage from "../util/app-storage";
-import logger from "../logger/logger";
+import appStorage from "../core/app-storage";
+import logger from "../logger";
import { clientStore } from "../redux/store.js";
import { clientApi } from "../redux/generated-api.ts";
diff --git a/assets/client/service/token-service.js b/assets/client/service/token-service.js
index 5c6d21b92..e47369928 100644
--- a/assets/client/service/token-service.js
+++ b/assets/client/service/token-service.js
@@ -1,6 +1,6 @@
-import logger from "../logger/logger";
-import appStorage from "../util/app-storage";
-import ClientConfigLoader from "../util/client-config-loader.js";
+import logger from "../logger";
+import appStorage from "../core/app-storage";
+import ClientConfigLoader from "../core/client-config-loader.js";
import defaults from "../util/defaults";
import statusService from "./status-service";
import constants from "../util/constants";
From b28942885e1dcddecb3b13efb1990e099c29324e Mon Sep 17 00:00:00 2001
From: Troels Ugilt Jensen <6103205+tuj@users.noreply.github.com>
Date: Tue, 21 Apr 2026 07:31:42 +0200
Subject: [PATCH 61/73] 7228: Changed unsubscribe to reset
---
assets/client/app.jsx | 1 +
assets/client/service/token-service.js | 4 ++--
2 files changed, 3 insertions(+), 2 deletions(-)
diff --git a/assets/client/app.jsx b/assets/client/app.jsx
index 791d118a5..4399fbfe3 100644
--- a/assets/client/app.jsx
+++ b/assets/client/app.jsx
@@ -122,6 +122,7 @@ function App({ preview, previewId }) {
}
})
.catch(() => {
+ logger.warn("Failed to check login.");
restartLoginTimeout();
});
}
diff --git a/assets/client/service/token-service.js b/assets/client/service/token-service.js
index e47369928..a2325c941 100644
--- a/assets/client/service/token-service.js
+++ b/assets/client/service/token-service.js
@@ -117,7 +117,7 @@ class TokenService {
.finally(() => {
this.refreshingToken = false;
this.refreshPromise = null;
- request.unsubscribe();
+ request.reset();
});
}
@@ -207,7 +207,7 @@ class TokenService {
};
})
.finally(() => {
- request.unsubscribe();
+ request.reset();
});
};
From 1fc98eb2c805636c8f2576e1bd3c1cfa3a2a3d8d Mon Sep 17 00:00:00 2001
From: Troels Ugilt Jensen <6103205+tuj@users.noreply.github.com>
Date: Tue, 21 Apr 2026 07:56:23 +0200
Subject: [PATCH 62/73] 7228: Moved logger to core
---
assets/client/app.jsx | 2 +-
assets/client/components/error-boundary.jsx | 2 +-
assets/client/components/region.jsx | 2 +-
assets/client/components/screen.jsx | 2 +-
assets/client/components/slide.jsx | 2 +-
assets/client/core/api-query.js | 2 +-
assets/client/core/client-config-loader.js | 2 +-
assets/client/{ => core}/logger.js | 0
assets/client/service/content-service.js | 2 +-
assets/client/service/pull-strategy.js | 2 +-
assets/client/service/release-service.js | 2 +-
assets/client/service/schedule-service.js | 2 +-
assets/client/service/tenant-service.js | 2 +-
assets/client/service/token-service.js | 2 +-
14 files changed, 13 insertions(+), 13 deletions(-)
rename assets/client/{ => core}/logger.js (100%)
diff --git a/assets/client/app.jsx b/assets/client/app.jsx
index 4399fbfe3..4e02efa72 100644
--- a/assets/client/app.jsx
+++ b/assets/client/app.jsx
@@ -2,7 +2,7 @@ import { useEffect, useRef, useState } from "react";
import Screen from "./components/screen.jsx";
import ContentService from "./service/content-service";
import ClientConfigLoader from "./core/client-config-loader.js";
-import logger from "./logger";
+import logger from "./core/logger.js";
import fallback from "./assets/fallback.png";
import appStorage from "./core/app-storage";
import defaults from "./util/defaults";
diff --git a/assets/client/components/error-boundary.jsx b/assets/client/components/error-boundary.jsx
index 7c3b70895..54259f111 100644
--- a/assets/client/components/error-boundary.jsx
+++ b/assets/client/components/error-boundary.jsx
@@ -1,5 +1,5 @@
import { Component } from "react";
-import logger from "../logger";
+import logger from "../core/logger.js";
import fallback from "../assets/fallback.png";
import "./error-boundary.scss";
diff --git a/assets/client/components/region.jsx b/assets/client/components/region.jsx
index 1d21a53c5..2cb0605da 100644
--- a/assets/client/components/region.jsx
+++ b/assets/client/components/region.jsx
@@ -3,7 +3,7 @@ import { createGridArea } from "../../shared/grid-generator/grid-generator";
import { TransitionGroup, CSSTransition } from "react-transition-group";
import ErrorBoundary from "./error-boundary.jsx";
import idFromPath from "../util/id-from-path";
-import logger from "../logger";
+import logger from "../core/logger.js";
import Slide from "./slide.jsx";
import constants from "../util/constants";
import { useClientState } from "../client-state-context.jsx";
diff --git a/assets/client/components/screen.jsx b/assets/client/components/screen.jsx
index a9597f1c6..de3378a2e 100644
--- a/assets/client/components/screen.jsx
+++ b/assets/client/components/screen.jsx
@@ -2,7 +2,7 @@ import { useEffect, useRef } from "react";
import SunCalc from "suncalc";
import { createGrid } from "../../shared/grid-generator/grid-generator";
import Region from "./region.jsx";
-import logger from "../logger";
+import logger from "../core/logger.js";
import TouchRegion from "./touch-region.jsx";
import ClientConfigLoader from "../core/client-config-loader.js";
import constants from "../util/constants";
diff --git a/assets/client/components/slide.jsx b/assets/client/components/slide.jsx
index 59a9fe8bc..5f767fc9f 100644
--- a/assets/client/components/slide.jsx
+++ b/assets/client/components/slide.jsx
@@ -1,5 +1,5 @@
import ErrorBoundary from "./error-boundary.jsx";
-import logger from "../logger";
+import logger from "../core/logger.js";
import { renderSlide } from "../../shared/slide-utils/templates.js";
import constants from "../util/constants";
import "./slide.scss";
diff --git a/assets/client/core/api-query.js b/assets/client/core/api-query.js
index 0c2709ee5..585c35a1f 100644
--- a/assets/client/core/api-query.js
+++ b/assets/client/core/api-query.js
@@ -1,4 +1,4 @@
-import logger from "../logger";
+import logger from "./logger.js";
import { clientStore } from "../redux/store.js";
import { clientApi } from "../redux/generated-api.ts";
diff --git a/assets/client/core/client-config-loader.js b/assets/client/core/client-config-loader.js
index eb00b3e43..d5a2df3b7 100644
--- a/assets/client/core/client-config-loader.js
+++ b/assets/client/core/client-config-loader.js
@@ -1,5 +1,5 @@
import appStorage from "./app-storage.js";
-import logger from "../logger";
+import logger from "./logger.js";
import defaults from "../util/defaults.js";
// Defaults.
diff --git a/assets/client/logger.js b/assets/client/core/logger.js
similarity index 100%
rename from assets/client/logger.js
rename to assets/client/core/logger.js
diff --git a/assets/client/service/content-service.js b/assets/client/service/content-service.js
index 95dfa6b64..765810266 100644
--- a/assets/client/service/content-service.js
+++ b/assets/client/service/content-service.js
@@ -4,7 +4,7 @@ import {
screenForPlaylistPreview,
screenForSlidePreview,
} from "../util/preview";
-import logger from "../logger";
+import logger from "../core/logger.js";
import idFromPath from "../util/id-from-path";
import DataSync from "./data-sync";
import ScheduleService from "./schedule-service";
diff --git a/assets/client/service/pull-strategy.js b/assets/client/service/pull-strategy.js
index b28c74614..5c0056f0b 100644
--- a/assets/client/service/pull-strategy.js
+++ b/assets/client/service/pull-strategy.js
@@ -1,5 +1,5 @@
import isPublished from "../util/is-published";
-import logger from "../logger";
+import logger from "../core/logger.js";
import idFromPath from "../util/id-from-path";
import { cloneDeep } from "lodash";
import ClientConfigLoader from "../core/client-config-loader.js";
diff --git a/assets/client/service/release-service.js b/assets/client/service/release-service.js
index 74ab015d3..83d4e59ef 100644
--- a/assets/client/service/release-service.js
+++ b/assets/client/service/release-service.js
@@ -2,7 +2,7 @@ import ClientConfigLoader from "../core/client-config-loader.js";
import defaults from "../util/defaults";
import idFromPath from "../util/id-from-path";
import appStorage from "../core/app-storage";
-import logger from "../logger";
+import logger from "../core/logger.js";
import statusService from "./status-service";
import constants from "../util/constants";
import releaseLoader from "../../shared/release-loader.js";
diff --git a/assets/client/service/schedule-service.js b/assets/client/service/schedule-service.js
index ebbe2d444..54e7519b0 100644
--- a/assets/client/service/schedule-service.js
+++ b/assets/client/service/schedule-service.js
@@ -2,7 +2,7 @@ import sha256 from "crypto-js/sha256";
import Md5 from "crypto-js/md5";
import Base64 from "crypto-js/enc-base64";
import isPublished from "../util/is-published";
-import logger from "../logger";
+import logger from "../core/logger.js";
import ClientConfigLoader from "../core/client-config-loader.js";
import ScheduleUtils from "../util/schedule";
import defaults from "../util/defaults";
diff --git a/assets/client/service/tenant-service.js b/assets/client/service/tenant-service.js
index a47140922..50e28b329 100644
--- a/assets/client/service/tenant-service.js
+++ b/assets/client/service/tenant-service.js
@@ -1,5 +1,5 @@
import appStorage from "../core/app-storage";
-import logger from "../logger";
+import logger from "../core/logger.js";
import { clientStore } from "../redux/store.js";
import { clientApi } from "../redux/generated-api.ts";
diff --git a/assets/client/service/token-service.js b/assets/client/service/token-service.js
index a2325c941..400d7ea04 100644
--- a/assets/client/service/token-service.js
+++ b/assets/client/service/token-service.js
@@ -1,4 +1,4 @@
-import logger from "../logger";
+import logger from "../core/logger.js";
import appStorage from "../core/app-storage";
import ClientConfigLoader from "../core/client-config-loader.js";
import defaults from "../util/defaults";
From 8b3cc0176c9aecf36c2dfcd6d13f3d43b7111094 Mon Sep 17 00:00:00 2001
From: Troels Ugilt Jensen <6103205+tuj@users.noreply.github.com>
Date: Tue, 21 Apr 2026 11:08:00 +0200
Subject: [PATCH 63/73] 7228: Added e2e tests for client
---
Taskfile.yml | 2 +-
assets/tests/client/client-e2e-fixtures.js | 176 +++++++++++++++
assets/tests/client/client-e2e.spec.js | 244 +++++++++++++++++++++
playwright.config.ts | 1 +
4 files changed, 422 insertions(+), 1 deletion(-)
create mode 100644 assets/tests/client/client-e2e-fixtures.js
create mode 100644 assets/tests/client/client-e2e.spec.js
diff --git a/Taskfile.yml b/Taskfile.yml
index 1bb10e142..97639dd7c 100644
--- a/Taskfile.yml
+++ b/Taskfile.yml
@@ -202,7 +202,7 @@ tasks:
test:frontend-local:
desc: "Runs frontend tests from the local machine."
cmds:
- - BASE_URL="https://display.local.itkdev.dk" npx playwright test
+ - BASE_URL="https://display.local.itkdev.dk" npx playwright test {{.CLI_ARGS}}
test:frontend-local-ui:
desc: "Runs frontend tests from the local machine in UI mode."
diff --git a/assets/tests/client/client-e2e-fixtures.js b/assets/tests/client/client-e2e-fixtures.js
new file mode 100644
index 000000000..b01c87e8d
--- /dev/null
+++ b/assets/tests/client/client-e2e-fixtures.js
@@ -0,0 +1,176 @@
+import { clientConfigJson } from "../admin/data-fixtures.js";
+
+// --- IDs (must be exactly 26 alphanumeric chars for idFromPath regex) ---
+const SCREEN_ID = "SCREEN0001AAAAAAAAAAAAAAAA";
+const LAYOUT_ID = "LAYOUT0001AAAAAAAAAAAAAAAA";
+const REGION_ID = "REGION0001AAAAAAAAAAAAAAAA";
+const PLAYLIST_1_ID = "PLAYLS0001AAAAAAAAAAAAAAAA";
+const PLAYLIST_2_ID = "PLAYLS0002AAAAAAAAAAAAAAAA";
+const SLIDE_1_ID = "SLIDES0001AAAAAAAAAAAAAAAA";
+const SLIDE_2_ID = "SLIDES0002AAAAAAAAAAAAAAAA";
+const SLIDE_3_ID = "SLIDES0003AAAAAAAAAAAAAAAA";
+const TEMPLATE_ID = "01FP2SNGFN0BZQH03KCBXHKYHG"; // Must match image-text.json id
+const MEDIA_1_ID = "MEDIAS0001AAAAAAAAAAAAAAAA";
+const MEDIA_2_ID = "MEDIAS0002AAAAAAAAAAAAAAAA";
+const TENANT_ID = "TENANT0001AAAAAAAAAAAAAAAA";
+
+// Minimal JWT with exp in 2099. jwt-decode only decodes, no signature check.
+// Header: {"alg":"none","typ":"JWT"} Payload: {"iat":1700000000,"exp":4102444800}
+const TEST_JWT_TOKEN =
+ "eyJhbGciOiJub25lIiwidHlwIjoiSldUIn0.eyJpYXQiOjE3MDAwMDAwMDAsImV4cCI6NDEwMjQ0NDgwMH0.";
+
+const releaseJson = {
+ releaseTimestamp: null,
+ releaseVersion: null,
+ releaseTime: null,
+};
+
+const loginReadyJson = {
+ status: "ready",
+ token: TEST_JWT_TOKEN,
+ refresh_token: "test-refresh-token",
+ screenId: SCREEN_ID,
+ tenantKey: "TestTenantKey",
+ tenantId: TENANT_ID,
+};
+
+const loginBindKeyJson = {
+ status: "awaitingBindKey",
+ bindKey: "TEST-BIND-KEY",
+};
+
+const screenJson = {
+ "@id": `/v2/screens/${SCREEN_ID}`,
+ layout: `/v2/layouts/${LAYOUT_ID}`,
+ regions: [`/v2/screens/${SCREEN_ID}/regions/${REGION_ID}/playlists`],
+ relationsChecksum: {
+ campaigns: "aaa",
+ inScreenGroups: "bbb",
+ layout: "ccc",
+ regions: "ddd",
+ },
+};
+
+const layoutJson = {
+ "@id": `/v2/layouts/${LAYOUT_ID}`,
+ grid: { rows: 1, columns: 1 },
+ regions: [{ "@id": `/v2/layouts/regions/${REGION_ID}`, gridArea: ["a"] }],
+};
+
+const regionPlaylistsJson = {
+ "hydra:member": [
+ {
+ playlist: {
+ "@id": `/v2/playlists/${PLAYLIST_1_ID}`,
+ title: "Playlist 1",
+ published: { from: "2020-01-01T00:00:00.000Z" },
+ schedules: [],
+ slides: `/v2/playlists/${PLAYLIST_1_ID}/slides`,
+ },
+ },
+ {
+ playlist: {
+ "@id": `/v2/playlists/${PLAYLIST_2_ID}`,
+ title: "Playlist 2",
+ published: { from: "2020-01-01T00:00:00.000Z" },
+ schedules: [],
+ slides: `/v2/playlists/${PLAYLIST_2_ID}/slides`,
+ },
+ },
+ ],
+ "hydra:totalItems": 2,
+};
+
+function makeSlide(id, mediaIds, title, text, checksumSuffix) {
+ return {
+ "@id": `/v2/slides/${id}`,
+ templateInfo: { "@id": `/v2/templates/${TEMPLATE_ID}` },
+ media: mediaIds.map((mid) => `/v2/media/${mid}`),
+ content: {
+ duration: 2000,
+ title,
+ text,
+ boxAlign: "left",
+ fontSize: "font-size-m",
+ },
+ published: { from: "2020-01-01T00:00:00.000Z" },
+ relationsChecksum: {
+ templateInfo: `t${checksumSuffix}`,
+ media: `m${checksumSuffix}`,
+ },
+ };
+}
+
+const playlist1SlidesJson = {
+ "hydra:member": [
+ { slide: makeSlide(SLIDE_1_ID, [MEDIA_1_ID], "Slide 1 Title", "Slide 1 text", "1") },
+ { slide: makeSlide(SLIDE_2_ID, [MEDIA_2_ID], "Slide 2 Title", "Slide 2 text", "2") },
+ ],
+ "hydra:totalItems": 2,
+};
+
+const playlist2SlidesJson = {
+ "hydra:member": [
+ { slide: makeSlide(SLIDE_3_ID, [], "Slide 3 Title", "Slide 3 text", "3") },
+ ],
+ "hydra:totalItems": 1,
+};
+
+const templateJson = {
+ "@id": `/v2/templates/${TEMPLATE_ID}`,
+ id: TEMPLATE_ID,
+ resources: {},
+};
+
+const media1Json = {
+ "@id": `/v2/media/${MEDIA_1_ID}`,
+ assets: { uri: "/fixtures/template/images/mountain1.jpeg" },
+};
+
+const media2Json = {
+ "@id": `/v2/media/${MEDIA_2_ID}`,
+ assets: { uri: "/fixtures/template/images/mountain2.jpeg" },
+};
+
+const tenantJson = {
+ "@id": `/v2/tenants/${TENANT_ID}`,
+ fallbackImageUrl: null,
+};
+
+const emptyHydraJson = {
+ "hydra:member": [],
+ "hydra:totalItems": 0,
+};
+
+// Short login check timeout for bind-key test (2 seconds).
+const clientConfigShortLoginJson = {
+ ...clientConfigJson,
+ loginCheckTimeout: 2000,
+};
+
+export {
+ SCREEN_ID,
+ LAYOUT_ID,
+ REGION_ID,
+ PLAYLIST_1_ID,
+ PLAYLIST_2_ID,
+ TEMPLATE_ID,
+ MEDIA_1_ID,
+ MEDIA_2_ID,
+ TENANT_ID,
+ releaseJson,
+ clientConfigJson,
+ clientConfigShortLoginJson,
+ loginReadyJson,
+ loginBindKeyJson,
+ screenJson,
+ layoutJson,
+ regionPlaylistsJson,
+ playlist1SlidesJson,
+ playlist2SlidesJson,
+ templateJson,
+ media1Json,
+ media2Json,
+ tenantJson,
+ emptyHydraJson,
+};
diff --git a/assets/tests/client/client-e2e.spec.js b/assets/tests/client/client-e2e.spec.js
new file mode 100644
index 000000000..2e5e2be7a
--- /dev/null
+++ b/assets/tests/client/client-e2e.spec.js
@@ -0,0 +1,244 @@
+import { test, expect } from "@playwright/test";
+import {
+ SCREEN_ID,
+ LAYOUT_ID,
+ REGION_ID,
+ PLAYLIST_1_ID,
+ PLAYLIST_2_ID,
+ TEMPLATE_ID,
+ MEDIA_1_ID,
+ MEDIA_2_ID,
+ TENANT_ID,
+ releaseJson,
+ clientConfigJson,
+ clientConfigShortLoginJson,
+ loginReadyJson,
+ loginBindKeyJson,
+ screenJson,
+ layoutJson,
+ regionPlaylistsJson,
+ playlist1SlidesJson,
+ playlist2SlidesJson,
+ templateJson,
+ media1Json,
+ media2Json,
+ tenantJson,
+ emptyHydraJson,
+} from "./client-e2e-fixtures.js";
+
+/**
+ * Register all route mocks for the client application.
+ *
+ * Catch-all abort is registered first; specific routes registered after take
+ * priority (Playwright matches in LIFO order).
+ */
+async function setupClientRoutes(page, configOverride = null) {
+ // Catch-all: abort unregistered fetch/XHR requests (API calls).
+ // Document, script, stylesheet, and image requests pass through to the server.
+ await page.route("**/*", async (route) => {
+ const type = route.request().resourceType();
+ if (type === "fetch" || type === "xhr") {
+ await route.abort();
+ } else {
+ await route.continue();
+ }
+ });
+
+ // Release check.
+ await page.route("**/release.json*", async (route) => {
+ await route.fulfill({ json: releaseJson });
+ });
+
+ // Client config.
+ await page.route("**/config/client", async (route) => {
+ await route.fulfill({ json: configOverride ?? clientConfigJson });
+ });
+
+ // Screen authentication (POST only).
+ await page.route("**/v2/authentication/screen", async (route) => {
+ if (route.request().method() === "POST") {
+ await route.fulfill({ json: loginReadyJson });
+ } else {
+ await route.abort();
+ }
+ });
+
+ // Screen data.
+ await page.route(`**/v2/screens/${SCREEN_ID}`, async (route) => {
+ await route.fulfill({ json: screenJson });
+ });
+
+ // Layout.
+ await page.route(`**/v2/layouts/${LAYOUT_ID}`, async (route) => {
+ await route.fulfill({ json: layoutJson });
+ });
+
+ // Region playlists.
+ await page.route(
+ `**/v2/screens/${SCREEN_ID}/regions/${REGION_ID}/playlists*`,
+ async (route) => {
+ await route.fulfill({ json: regionPlaylistsJson });
+ },
+ );
+
+ // Playlist 1 slides.
+ await page.route(`**/v2/playlists/${PLAYLIST_1_ID}/slides*`, async (route) => {
+ await route.fulfill({ json: playlist1SlidesJson });
+ });
+
+ // Playlist 2 slides.
+ await page.route(`**/v2/playlists/${PLAYLIST_2_ID}/slides*`, async (route) => {
+ await route.fulfill({ json: playlist2SlidesJson });
+ });
+
+ // Template.
+ await page.route(`**/v2/templates/${TEMPLATE_ID}`, async (route) => {
+ await route.fulfill({ json: templateJson });
+ });
+
+ // Media.
+ await page.route(`**/v2/media/${MEDIA_1_ID}`, async (route) => {
+ await route.fulfill({ json: media1Json });
+ });
+ await page.route(`**/v2/media/${MEDIA_2_ID}`, async (route) => {
+ await route.fulfill({ json: media2Json });
+ });
+
+ // Campaigns (empty).
+ await page.route(`**/v2/screens/${SCREEN_ID}/campaigns*`, async (route) => {
+ await route.fulfill({ json: emptyHydraJson });
+ });
+
+ // Screen groups (empty).
+ await page.route(
+ `**/v2/screens/${SCREEN_ID}/screen-groups*`,
+ async (route) => {
+ await route.fulfill({ json: emptyHydraJson });
+ },
+ );
+
+ // Tenant.
+ await page.route(`**/v2/tenants/${TENANT_ID}`, async (route) => {
+ await route.fulfill({ json: tenantJson });
+ });
+}
+
+test.describe("Client E2E: login, playlists, slide progression", () => {
+ test("Login with bind key then authentication", async ({ page }) => {
+ // Use short login timeout so the bind-key -> ready transition is fast.
+ await setupClientRoutes(page, clientConfigShortLoginJson);
+
+ // Override auth to first return bind key.
+ let loginCallCount = 0;
+ await page.unroute("**/v2/authentication/screen");
+ await page.route("**/v2/authentication/screen", async (route) => {
+ if (route.request().method() !== "POST") {
+ await route.abort();
+ return;
+ }
+
+ loginCallCount += 1;
+
+ if (loginCallCount <= 1) {
+ await route.fulfill({ json: loginBindKeyJson });
+ } else {
+ await route.fulfill({ json: loginReadyJson });
+ }
+ });
+
+ await page.goto("/client");
+
+ // Bind key should be displayed.
+ await expect(page.locator(".bind-key")).toHaveText("TEST-BIND-KEY");
+
+ // After the next login poll, screen should appear.
+ await expect(page.locator(".screen")).toBeVisible({ timeout: 10000 });
+ });
+
+ test("Screen renders with region and slides from multiple playlists", async ({
+ page,
+ }) => {
+ await setupClientRoutes(page);
+ await page.goto("/client");
+
+ await expect(page.locator(".screen")).toBeVisible({ timeout: 10000 });
+ await expect(page.locator(".region")).toBeVisible();
+ await expect(page.locator(".slide")).toBeVisible();
+ await expect(page.locator(".template-image-text")).toBeVisible();
+ await expect(page.locator(".template-image-text h1")).toHaveText(
+ "Slide 1 Title",
+ );
+ });
+
+ test("slideDone is called - slides transition after duration", async ({
+ page,
+ }) => {
+ await setupClientRoutes(page);
+ await page.goto("/client");
+
+ // Wait for first slide.
+ await expect(page.locator(".template-image-text h1")).toHaveText(
+ "Slide 1 Title",
+ { timeout: 10000 },
+ );
+
+ // Capture initial data-run.
+ const initialRun = await page.locator(".slide").first().getAttribute("data-run");
+
+ // Wait for data-run to change (proves slideDone was called).
+ await page.waitForFunction(
+ (oldRun) => {
+ const slide = document.querySelector(".slide");
+ return slide && slide.dataset.run !== oldRun;
+ },
+ initialRun,
+ { timeout: 10000 },
+ );
+
+ // Second slide should now be visible (use .first() because TransitionGroup
+ // briefly keeps both old and new slides in the DOM during the transition).
+ await expect(page.locator(".template-image-text h1").first()).toHaveText(
+ "Slide 2 Title",
+ );
+ });
+
+ test("Progress never stops - slides cycle through all playlists and wrap", async ({
+ page,
+ }) => {
+ await setupClientRoutes(page);
+ await page.goto("/client");
+
+ // Expected slide order: Playlist 1 (Slide 1, Slide 2), Playlist 2 (Slide 3), then wrap.
+ const expectedTitles = [
+ "Slide 1 Title",
+ "Slide 2 Title",
+ "Slide 3 Title",
+ "Slide 1 Title", // Wrap — proves progress never stops.
+ ];
+
+ for (const expectedTitle of expectedTitles) {
+ // Use .first() because TransitionGroup briefly keeps both old and new
+ // slides in the DOM during the CSS transition.
+ await expect(page.locator(".template-image-text h1").first()).toHaveText(
+ expectedTitle,
+ { timeout: 10000 },
+ );
+
+ // Wait for this slide to finish (data-run changes).
+ if (expectedTitle !== expectedTitles[expectedTitles.length - 1]) {
+ const currentRun = await page
+ .locator(".slide")
+ .first()
+ .getAttribute("data-run");
+ await page.waitForFunction(
+ (oldRun) => {
+ const slide = document.querySelector(".slide");
+ return slide && slide.dataset.run !== oldRun;
+ },
+ currentRun,
+ { timeout: 10000 },
+ );
+ }
+ }
+ });
+});
diff --git a/playwright.config.ts b/playwright.config.ts
index 0a2463bbb..103de5202 100644
--- a/playwright.config.ts
+++ b/playwright.config.ts
@@ -13,6 +13,7 @@ const BASE_URL = process.env.BASE_URL ?? 'http://nginx:8080';
*/
export default defineConfig({
testDir: './assets/tests',
+ testMatch: '**/*.spec.js',
/* Run tests in files in parallel */
fullyParallel: true,
/* Fail the build on CI if you accidentally left test.only in the source code. */
From 659e5c674253863134ed61edd2f0e5fa1127b00f Mon Sep 17 00:00:00 2001
From: Troels Ugilt Jensen <6103205+tuj@users.noreply.github.com>
Date: Tue, 21 Apr 2026 12:41:39 +0200
Subject: [PATCH 64/73] 7228: Fixed invalidation setup in client
---
assets/client/core/api-query.js | 2 +-
assets/client/redux/enhanced-api.ts | 6 ++++--
assets/client/redux/store.js | 2 +-
assets/client/service/tenant-service.js | 2 +-
assets/client/service/token-service.js | 2 +-
5 files changed, 8 insertions(+), 6 deletions(-)
diff --git a/assets/client/core/api-query.js b/assets/client/core/api-query.js
index 585c35a1f..14ca4148f 100644
--- a/assets/client/core/api-query.js
+++ b/assets/client/core/api-query.js
@@ -1,6 +1,6 @@
import logger from "./logger.js";
import { clientStore } from "../redux/store.js";
-import { clientApi } from "../redux/generated-api.ts";
+import { clientApi } from "../redux/enhanced-api.ts";
/**
* Dispatch an RTK Query endpoint and return the unwrapped result.
diff --git a/assets/client/redux/enhanced-api.ts b/assets/client/redux/enhanced-api.ts
index 706d5e62d..e5654a543 100644
--- a/assets/client/redux/enhanced-api.ts
+++ b/assets/client/redux/enhanced-api.ts
@@ -7,7 +7,7 @@ const invalidatesTagsForEndpoints = {
postRefreshTokenItem: ["Authentication"],
};
-export const enhancedApi = generatedApi.enhanceEndpoints({
+const enhancedApi = generatedApi.enhanceEndpoints({
// @ts-ignore
endpoints: Object.fromEntries(
// @ts-ignore
@@ -16,7 +16,7 @@ export const enhancedApi = generatedApi.enhanceEndpoints({
...endpoint,
};
- if (invalidatesTagsForEndpoints.hasOwnProperty(key)) {
+ if (Object.prototype.hasOwnProperty.call(invalidatesTagsForEndpoints, key)) {
enhancedEndpoint.invalidatesTags = invalidatesTagsForEndpoints[key];
}
@@ -24,3 +24,5 @@ export const enhancedApi = generatedApi.enhanceEndpoints({
})
),
});
+
+export { enhancedApi as clientApi };
diff --git a/assets/client/redux/store.js b/assets/client/redux/store.js
index 5f3d12c73..738c1b5a6 100644
--- a/assets/client/redux/store.js
+++ b/assets/client/redux/store.js
@@ -1,5 +1,5 @@
import { configureStore } from "@reduxjs/toolkit";
-import { clientApi } from "./generated-api.ts";
+import { clientApi } from "./enhanced-api.ts";
/* eslint-disable-next-line import/prefer-default-export */
export const clientStore = configureStore({
diff --git a/assets/client/service/tenant-service.js b/assets/client/service/tenant-service.js
index 50e28b329..f948ca181 100644
--- a/assets/client/service/tenant-service.js
+++ b/assets/client/service/tenant-service.js
@@ -1,7 +1,7 @@
import appStorage from "../core/app-storage";
import logger from "../core/logger.js";
import { clientStore } from "../redux/store.js";
-import { clientApi } from "../redux/generated-api.ts";
+import { clientApi } from "../redux/enhanced-api.ts";
class TenantService {
loadTenantConfig = () => {
diff --git a/assets/client/service/token-service.js b/assets/client/service/token-service.js
index 400d7ea04..a3cbe32a2 100644
--- a/assets/client/service/token-service.js
+++ b/assets/client/service/token-service.js
@@ -5,7 +5,7 @@ import defaults from "../util/defaults";
import statusService from "./status-service";
import constants from "../util/constants";
import { clientStore } from "../redux/store.js";
-import { clientApi } from "../redux/generated-api.ts";
+import { clientApi } from "../redux/enhanced-api.ts";
class TokenService {
refreshingToken = false;
From 8c714c1ee844dea905fce7b206d40e13456d53f1 Mon Sep 17 00:00:00 2001
From: Troels Ugilt Jensen <6103205+tuj@users.noreply.github.com>
Date: Tue, 21 Apr 2026 12:44:26 +0200
Subject: [PATCH 65/73] 7228: Avoid recreating base query for each request
---
assets/client/redux/base-query.js | 9 +++------
1 file changed, 3 insertions(+), 6 deletions(-)
diff --git a/assets/client/redux/base-query.js b/assets/client/redux/base-query.js
index 92462763f..a8fc59db3 100644
--- a/assets/client/redux/base-query.js
+++ b/assets/client/redux/base-query.js
@@ -2,8 +2,9 @@ import { fetchBaseQuery } from "@reduxjs/toolkit/query/react";
import localStorageKeys from "../core/local-storage-keys";
import reauthenticateRef from "./reauthenticate-ref";
+const rawBaseQuery = fetchBaseQuery({ baseUrl: "/", credentials: "include" });
+
const clientBaseQuery = async (args, api, extraOptions) => {
- const baseUrl = "/";
const newArgs = { ...args };
@@ -36,11 +37,7 @@ const clientBaseQuery = async (args, api, extraOptions) => {
newArgs.headers["Authorization-Tenant-Key"] = tenantKey;
}
- const baseResult = await fetchBaseQuery({ baseUrl, credentials: "include" })(
- newArgs,
- api,
- extraOptions,
- );
+ const baseResult = await rawBaseQuery(newArgs, api, extraOptions);
// Handle authentication errors.
if (baseResult?.error?.status === 401) {
From 2455209630d34d24adf4a162a534ed8072221036 Mon Sep 17 00:00:00 2001
From: Troels Ugilt Jensen <6103205+tuj@users.noreply.github.com>
Date: Tue, 21 Apr 2026 13:09:38 +0200
Subject: [PATCH 66/73] 7228: Fixed tests
---
assets/client/core/api-query.js | 14 ++++++++++++--
assets/client/service/pull-strategy.js | 10 +++++++++-
assets/client/util/defaults.js | 4 ++++
assets/tests/client/app-storage.test.js | 2 +-
.../tests/client/client-config-loader.test.js | 12 ++++++------
assets/tests/client/error-boundary.test.jsx | 2 +-
assets/tests/client/pull-strategy.test.js | 12 ++++++------
assets/tests/client/region.test.jsx | 4 ++--
assets/tests/client/schedule-service.test.js | 4 ++--
assets/tests/client/screen.test.jsx | 6 +++---
assets/tests/client/slide.test.jsx | 2 +-
assets/tests/client/token-service.test.js | 17 ++++++++++++-----
assets/tests/client/touch-region.test.jsx | 4 ++--
13 files changed, 61 insertions(+), 32 deletions(-)
diff --git a/assets/client/core/api-query.js b/assets/client/core/api-query.js
index 14ca4148f..e26feaa2a 100644
--- a/assets/client/core/api-query.js
+++ b/assets/client/core/api-query.js
@@ -1,6 +1,7 @@
import logger from "./logger.js";
import { clientStore } from "../redux/store.js";
import { clientApi } from "../redux/enhanced-api.ts";
+import defaults from "../util/defaults.js";
/**
* Dispatch an RTK Query endpoint and return the unwrapped result.
@@ -14,8 +15,16 @@ export function query(endpoint, args, forceRefetch = false) {
const request = clientStore.dispatch(
clientApi.endpoints[endpoint].initiate(args, { forceRefetch }),
);
- return request
- .unwrap()
+
+ let timeoutId;
+ const timeout = new Promise((_, reject) => {
+ timeoutId = setTimeout(() => {
+ request.abort();
+ reject(new Error(`Request timeout: ${endpoint}`));
+ }, defaults.queryTimeoutDefault);
+ });
+
+ return Promise.race([request.unwrap(), timeout])
.catch((err) => {
const cached = clientApi.endpoints[endpoint].select(args)(
clientStore.getState(),
@@ -27,6 +36,7 @@ export function query(endpoint, args, forceRefetch = false) {
throw err;
})
.finally(() => {
+ clearTimeout(timeoutId);
request.unsubscribe();
});
}
diff --git a/assets/client/service/pull-strategy.js b/assets/client/service/pull-strategy.js
index 5c0056f0b..c72946188 100644
--- a/assets/client/service/pull-strategy.js
+++ b/assets/client/service/pull-strategy.js
@@ -589,11 +589,19 @@ class PullStrategy {
}
this.pulling = true;
- this.getScreen(this.entryPoint)
+ let timeoutId;
+ const guard = new Promise((_, reject) => {
+ timeoutId = setTimeout(() => {
+ reject(new Error("getScreen exceeded max execution time"));
+ }, defaults.getScreenTimeoutDefault);
+ });
+
+ Promise.race([this.getScreen(this.entryPoint), guard])
.catch((err) => {
logger.error(`Content update failed: ${err.message}`);
})
.finally(() => {
+ clearTimeout(timeoutId);
this.pulling = false;
if (this.stopped) {
diff --git a/assets/client/util/defaults.js b/assets/client/util/defaults.js
index 05504ba01..0881ac450 100644
--- a/assets/client/util/defaults.js
+++ b/assets/client/util/defaults.js
@@ -11,6 +11,10 @@ const defaults = {
pullStrategyIntervalDefault: 5 * 60 * 1000,
// Every 15 minutes. Fallback for config fetch interval.
configFetchIntervalDefault: 15 * 60 * 1000,
+ // 30 seconds. Timeout for individual API requests.
+ queryTimeoutDefault: 30 * 1000,
+ // 2 minutes. Max execution time for a full getScreen cycle.
+ getScreenTimeoutDefault: 2 * 60 * 1000,
};
export default defaults;
diff --git a/assets/tests/client/app-storage.test.js b/assets/tests/client/app-storage.test.js
index a0086604e..8efcbe584 100644
--- a/assets/tests/client/app-storage.test.js
+++ b/assets/tests/client/app-storage.test.js
@@ -4,7 +4,7 @@ vi.mock("jwt-decode", () => ({
default: () => ({ exp: 1700000000, iat: 1699990000 }),
}));
-import appStorage from "../../client/util/app-storage";
+import appStorage from "../../client/core/app-storage";
describe("AppStorage", () => {
beforeEach(() => {
diff --git a/assets/tests/client/client-config-loader.test.js b/assets/tests/client/client-config-loader.test.js
index 73addfb42..7dab7838b 100644
--- a/assets/tests/client/client-config-loader.test.js
+++ b/assets/tests/client/client-config-loader.test.js
@@ -1,9 +1,9 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
-vi.mock("../../client/util/app-storage.js", () => ({
+vi.mock("../../client/core/app-storage.js", () => ({
default: { setApiUrl: vi.fn() },
}));
-vi.mock("../../client/logger/logger", () => ({
+vi.mock("../../client/core/logger.js", () => ({
default: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), log: vi.fn() },
}));
@@ -23,17 +23,17 @@ describe("ClientConfigLoader", () => {
vi.resetModules();
// Re-mock after resetModules
- vi.doMock("../../client/util/app-storage.js", () => ({
+ vi.doMock("../../client/core/app-storage.js", () => ({
default: { setApiUrl: vi.fn() },
}));
- vi.doMock("../../client/logger/logger", () => ({
+ vi.doMock("../../client/core/logger.js", () => ({
default: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), log: vi.fn() },
}));
- const module = await import("../../client/util/client-config-loader.js");
+ const module = await import("../../client/core/client-config-loader.js");
ClientConfigLoader = module.default;
- const storageModule = await import("../../client/util/app-storage.js");
+ const storageModule = await import("../../client/core/app-storage.js");
appStorage = storageModule.default;
});
diff --git a/assets/tests/client/error-boundary.test.jsx b/assets/tests/client/error-boundary.test.jsx
index afa0c1a74..fbc81ba82 100644
--- a/assets/tests/client/error-boundary.test.jsx
+++ b/assets/tests/client/error-boundary.test.jsx
@@ -2,7 +2,7 @@ import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen } from "@testing-library/react";
import ErrorBoundary from "../../client/components/error-boundary.jsx";
-vi.mock("../../client/logger/logger", () => ({
+vi.mock("../../client/core/logger.js", () => ({
default: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), log: vi.fn() },
}));
vi.mock("../../client/assets/fallback.png", () => ({
diff --git a/assets/tests/client/pull-strategy.test.js b/assets/tests/client/pull-strategy.test.js
index f4af0b8b4..ec410e3b9 100644
--- a/assets/tests/client/pull-strategy.test.js
+++ b/assets/tests/client/pull-strategy.test.js
@@ -24,11 +24,11 @@ const { mockDispatch, endpoints } = vi.hoisted(() => {
return { mockDispatch, endpoints };
});
-vi.mock("../../client/logger/logger", () => ({
+vi.mock("../../client/core/logger.js", () => ({
default: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), log: vi.fn() },
}));
-vi.mock("../../client/util/client-config-loader.js", () => ({
+vi.mock("../../client/core/client-config-loader.js", () => ({
default: { loadConfig: vi.fn() },
}));
@@ -36,7 +36,7 @@ vi.mock("../../client/redux/store.js", () => ({
clientStore: { dispatch: mockDispatch, getState: () => ({}) },
}));
-vi.mock("../../client/redux/generated-api.ts", () => ({
+vi.mock("../../client/redux/enhanced-api.ts", () => ({
clientApi: {
endpoints,
reducerPath: "clientApi",
@@ -45,9 +45,9 @@ vi.mock("../../client/redux/generated-api.ts", () => ({
},
}));
-import PullStrategy from "../../client/data-sync/pull-strategy";
-import logger from "../../client/logger/logger";
-import ClientConfigLoader from "../../client/util/client-config-loader.js";
+import PullStrategy from "../../client/service/pull-strategy";
+import logger from "../../client/core/logger.js";
+import ClientConfigLoader from "../../client/core/client-config-loader.js";
// --- Test IDs (26 alphanumeric chars each, required by idFromPath) ---
const SCREEN_ID = "SCREEN0001AAAAAAAAAAAAAAAA";
diff --git a/assets/tests/client/region.test.jsx b/assets/tests/client/region.test.jsx
index 638e1bc09..bef943f8d 100644
--- a/assets/tests/client/region.test.jsx
+++ b/assets/tests/client/region.test.jsx
@@ -2,7 +2,7 @@ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { render, act, cleanup, within } from "@testing-library/react";
import Region from "../../client/components/region.jsx";
-vi.mock("../../client/logger/logger", () => ({
+vi.mock("../../client/core/logger.js", () => ({
default: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), log: vi.fn() },
}));
vi.mock("../../client/components/region.scss", () => ({}));
@@ -45,7 +45,7 @@ const mockCallbacks = {
};
let mockRegionSlides = {};
-vi.mock("../../client/context/client-state-context.jsx", () => ({
+vi.mock("../../client/client-state-context.jsx", () => ({
useClientState: () => ({
regionSlides: mockRegionSlides,
callbacks: mockCallbacks,
diff --git a/assets/tests/client/schedule-service.test.js b/assets/tests/client/schedule-service.test.js
index 63c51f5db..c5761ea99 100644
--- a/assets/tests/client/schedule-service.test.js
+++ b/assets/tests/client/schedule-service.test.js
@@ -1,9 +1,9 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
-vi.mock("../../client/logger/logger", () => ({
+vi.mock("../../client/core/logger.js", () => ({
default: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), log: vi.fn() },
}));
-vi.mock("../../client/util/client-config-loader.js", () => ({
+vi.mock("../../client/core/client-config-loader.js", () => ({
default: {
loadConfig: vi.fn().mockResolvedValue({ schedulingInterval: 60000 }),
},
diff --git a/assets/tests/client/screen.test.jsx b/assets/tests/client/screen.test.jsx
index d9e9faeb2..8d0996dac 100644
--- a/assets/tests/client/screen.test.jsx
+++ b/assets/tests/client/screen.test.jsx
@@ -2,7 +2,7 @@ import { describe, it, expect, vi } from "vitest";
import { render } from "@testing-library/react";
import Screen from "../../client/components/screen.jsx";
-vi.mock("../../client/logger/logger", () => ({
+vi.mock("../../client/core/logger.js", () => ({
default: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), log: vi.fn() },
}));
vi.mock("../../client/components/screen.scss", () => ({}));
@@ -25,7 +25,7 @@ vi.mock("../../shared/grid-generator/grid-generator", () => ({
createGridArea: (gridArea) => gridArea.join(" / "),
}));
-vi.mock("../../client/util/client-config-loader.js", () => ({
+vi.mock("../../client/core/client-config-loader.js", () => ({
default: {
loadConfig: vi.fn().mockResolvedValue({ colorScheme: { type: "browser" } }),
},
@@ -43,7 +43,7 @@ vi.mock("../../client/util/id-from-path", () => ({
default: (path) => path.split("/").pop(),
}));
-vi.mock("../../client/context/client-state-context.jsx", () => ({
+vi.mock("../../client/client-state-context.jsx", () => ({
useClientState: () => ({
regionSlides: {},
callbacks: {
diff --git a/assets/tests/client/slide.test.jsx b/assets/tests/client/slide.test.jsx
index c525e34b1..a54d5eb14 100644
--- a/assets/tests/client/slide.test.jsx
+++ b/assets/tests/client/slide.test.jsx
@@ -2,7 +2,7 @@ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { render, screen, cleanup } from "@testing-library/react";
import Slide from "../../client/components/slide.jsx";
-vi.mock("../../client/logger/logger", () => ({
+vi.mock("../../client/core/logger.js", () => ({
default: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), log: vi.fn() },
}));
vi.mock("../../client/assets/fallback.png", () => ({
diff --git a/assets/tests/client/token-service.test.js b/assets/tests/client/token-service.test.js
index 0ce123953..d386d1912 100644
--- a/assets/tests/client/token-service.test.js
+++ b/assets/tests/client/token-service.test.js
@@ -4,11 +4,11 @@ const { mockDispatch } = vi.hoisted(() => ({
mockDispatch: vi.fn(),
}));
-vi.mock("../../client/logger/logger", () => ({
+vi.mock("../../client/core/logger.js", () => ({
default: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), log: vi.fn() },
}));
-vi.mock("../../client/util/app-storage.js", () => ({
+vi.mock("../../client/core/app-storage.js", () => ({
default: {
getTokenExpire: vi.fn(),
getTokenIssueAt: vi.fn(),
@@ -29,7 +29,7 @@ vi.mock("../../client/service/status-service.js", () => ({
},
}));
-vi.mock("../../client/util/client-config-loader.js", () => ({
+vi.mock("../../client/core/client-config-loader.js", () => ({
default: {
loadConfig: vi.fn().mockResolvedValue({ refreshTokenTimeout: 900000 }),
},
@@ -39,7 +39,7 @@ vi.mock("../../client/redux/store.js", () => ({
clientStore: { dispatch: mockDispatch },
}));
-vi.mock("../../client/redux/generated-api.ts", () => ({
+vi.mock("../../client/redux/enhanced-api.ts", () => ({
clientApi: {
endpoints: {
postRefreshTokenItem: {
@@ -65,7 +65,7 @@ vi.mock("../../client/redux/empty-api.ts", () => ({
import tokenService from "../../client/service/token-service";
import constants from "../../client/util/constants";
-import appStorage from "../../client/util/app-storage.js";
+import appStorage from "../../client/core/app-storage.js";
import statusService from "../../client/service/status-service.js";
describe("TokenService", () => {
@@ -260,6 +260,7 @@ describe("TokenService", () => {
mockDispatch.mockReturnValue({
unwrap: () => Promise.resolve(loginData),
unsubscribe: vi.fn(),
+ reset: vi.fn(),
});
const result = await tokenService.checkLogin();
@@ -285,6 +286,7 @@ describe("TokenService", () => {
bindKey: "ABCD-1234",
}),
unsubscribe: vi.fn(),
+ reset: vi.fn(),
});
const result = await tokenService.checkLogin();
@@ -299,6 +301,7 @@ describe("TokenService", () => {
mockDispatch.mockReturnValue({
unwrap: () => Promise.resolve({ status: "something-else" }),
unsubscribe: vi.fn(),
+ reset: vi.fn(),
});
const result = await tokenService.checkLogin();
@@ -317,6 +320,7 @@ describe("TokenService", () => {
refresh_token: "new-refresh",
}),
unsubscribe: vi.fn(),
+ reset: vi.fn(),
});
await tokenService.refreshToken();
@@ -334,6 +338,7 @@ describe("TokenService", () => {
refresh_token: "new-refresh",
}),
unsubscribe: vi.fn(),
+ reset: vi.fn(),
});
await tokenService.refreshToken();
@@ -347,6 +352,7 @@ describe("TokenService", () => {
mockDispatch.mockReturnValue({
unwrap: () => Promise.reject(new Error("401")),
unsubscribe: vi.fn(),
+ reset: vi.fn(),
});
await expect(tokenService.refreshToken()).rejects.toThrow("401");
@@ -364,6 +370,7 @@ describe("TokenService", () => {
refresh_token: "new-refresh",
}),
unsubscribe: vi.fn(),
+ reset: vi.fn(),
});
const p1 = tokenService.refreshToken();
diff --git a/assets/tests/client/touch-region.test.jsx b/assets/tests/client/touch-region.test.jsx
index e7573cfb4..6f0b64dc0 100644
--- a/assets/tests/client/touch-region.test.jsx
+++ b/assets/tests/client/touch-region.test.jsx
@@ -2,7 +2,7 @@ import { describe, it, expect, vi, afterEach } from "vitest";
import { render, act, fireEvent, cleanup, within } from "@testing-library/react";
import TouchRegion from "../../client/components/touch-region.jsx";
-vi.mock("../../client/logger/logger", () => ({
+vi.mock("../../client/core/logger.js", () => ({
default: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), log: vi.fn() },
}));
vi.mock("../../client/components/touch-region.scss", () => ({}));
@@ -44,7 +44,7 @@ const mockCallbacks = {
};
let mockRegionSlides = {};
-vi.mock("../../client/context/client-state-context.jsx", () => ({
+vi.mock("../../client/client-state-context.jsx", () => ({
useClientState: () => ({
regionSlides: mockRegionSlides,
callbacks: mockCallbacks,
From cd17097ed46474b6da7b2e4ae05073da529d3a39 Mon Sep 17 00:00:00 2001
From: Troels Ugilt Jensen <6103205+tuj@users.noreply.github.com>
Date: Tue, 21 Apr 2026 21:21:18 +0200
Subject: [PATCH 67/73] 7228: Fixed for possible orphaned interval
---
assets/client/service/schedule-service.js | 9 +++++++++
1 file changed, 9 insertions(+)
diff --git a/assets/client/service/schedule-service.js b/assets/client/service/schedule-service.js
index 54e7519b0..173d7218a 100644
--- a/assets/client/service/schedule-service.js
+++ b/assets/client/service/schedule-service.js
@@ -129,6 +129,15 @@ class ScheduleService {
const region = this.regions[regionId];
+ if (!region) {
+ // Region was removed while the interval registration was in-flight.
+ if (Object.prototype.hasOwnProperty.call(this.intervals, regionId)) {
+ clearInterval(this.intervals[regionId]);
+ delete this.intervals[regionId];
+ }
+ return;
+ }
+
// Extract slides from playlists.
const slides = ScheduleService.findScheduledSlides(region.region, regionId);
From 173eecae90362ac5f3cdac0c40fff2ed1d581d5d Mon Sep 17 00:00:00 2001
From: Troels Ugilt Jensen <6103205+tuj@users.noreply.github.com>
Date: Tue, 21 Apr 2026 21:30:58 +0200
Subject: [PATCH 68/73] 7228: Added tests for content service
---
assets/tests/client/content-service.test.js | 443 ++++++++++++++++++++
1 file changed, 443 insertions(+)
create mode 100644 assets/tests/client/content-service.test.js
diff --git a/assets/tests/client/content-service.test.js b/assets/tests/client/content-service.test.js
new file mode 100644
index 000000000..6fba0d6c9
--- /dev/null
+++ b/assets/tests/client/content-service.test.js
@@ -0,0 +1,443 @@
+import { describe, it, expect, vi, beforeEach } from "vitest";
+
+vi.mock("../../client/core/logger.js", () => ({
+ default: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), log: vi.fn() },
+}));
+
+vi.mock("../../client/core/client-config-loader.js", () => ({
+ default: {
+ loadConfig: vi.fn().mockResolvedValue({ pullStrategyInterval: 30000 }),
+ },
+}));
+
+vi.mock("../../client/service/data-sync", () => {
+ const MockDataSync = vi.fn(function () {
+ this.start = vi.fn();
+ this.stop = vi.fn();
+ });
+ return { default: MockDataSync };
+});
+
+vi.mock("../../client/service/schedule-service", () => {
+ const MockScheduleService = vi.fn(function () {
+ this.updateRegion = vi.fn();
+ this.regionRemoved = vi.fn();
+ });
+ return { default: MockScheduleService };
+});
+
+const mockQuery = vi.fn();
+vi.mock("../../client/core/api-query.js", () => ({
+ query: (...args) => mockQuery(...args),
+}));
+
+vi.mock("../../client/util/id-from-path", () => ({
+ default: (path) => {
+ if (!path) return null;
+ const match = path.match(/[A-Za-z0-9]{26}/);
+ return match ? match[0] : null;
+ },
+}));
+
+vi.mock("../../client/util/preview", () => ({
+ screenForPlaylistPreview: vi.fn((playlist) => ({
+ "@id": "/v2/screens/PREVIEW",
+ regionData: { REGION01: [playlist] },
+ layoutData: { grid: { rows: 1, columns: 1 }, regions: [] },
+ })),
+ screenForSlidePreview: vi.fn((slide) => ({
+ "@id": "/v2/screens/PREVIEW",
+ regionData: { REGION01: [{ slidesData: [slide] }] },
+ layoutData: { grid: { rows: 1, columns: 1 }, regions: [] },
+ })),
+}));
+
+import ContentService from "../../client/service/content-service";
+import DataSync from "../../client/service/data-sync";
+import ScheduleService from "../../client/service/schedule-service";
+import logger from "../../client/core/logger.js";
+
+function makeCallbacks() {
+ return {
+ current: {
+ setScreen: vi.fn(),
+ setIsContentEmpty: vi.fn(),
+ updateRegionSlides: vi.fn(),
+ onRegionReady: vi.fn(),
+ onRegionRemoved: vi.fn(),
+ },
+ };
+}
+
+describe("ContentService", () => {
+ let service;
+ let callbacks;
+
+ beforeEach(() => {
+ vi.clearAllMocks();
+ callbacks = makeCallbacks();
+ service = new ContentService(callbacks);
+ });
+
+ describe("constructor", () => {
+ it("creates a ScheduleService with the callbacks", () => {
+ expect(ScheduleService).toHaveBeenCalledWith(callbacks);
+ expect(service.scheduleService).toBeDefined();
+ });
+ });
+
+ describe("start / stop", () => {
+ it("wires onRegionReady and onRegionRemoved callbacks", () => {
+ service.start();
+
+ expect(callbacks.current.onRegionReady).toBe(service.regionReady);
+ expect(callbacks.current.onRegionRemoved).toBe(service.regionRemoved);
+ });
+
+ it("does not start twice", () => {
+ service.start();
+ service.start();
+
+ expect(logger.warn).toHaveBeenCalledWith(
+ "Content service already started."
+ );
+ });
+
+ it("clears callbacks on stop", () => {
+ service.start();
+ service.stop();
+
+ expect(callbacks.current.onRegionReady).not.toBe(service.regionReady);
+ expect(callbacks.current.onRegionRemoved).not.toBe(service.regionRemoved);
+ });
+
+ it("stop is a no-op if not started", () => {
+ service.stop();
+
+ // Should not throw or log.
+ expect(logger.info).not.toHaveBeenCalledWith(
+ "Content service stopped."
+ );
+ });
+ });
+
+ describe("startSyncing / stopSync", () => {
+ it("creates a DataSync and starts it after config loads", async () => {
+ service.startSyncing("/v2/screens/ABC");
+ await vi.waitFor(() => expect(DataSync).toHaveBeenCalled());
+
+ const config = DataSync.mock.calls[0][0];
+ expect(config.entryPoint).toBe("/v2/screens/ABC");
+ expect(config.interval).toBe(30000);
+
+ const instance = DataSync.mock.results[0].value;
+ expect(instance.start).toHaveBeenCalled();
+ });
+
+ it("does not create DataSync if stopSync is called before config resolves", async () => {
+ service.startSyncing("/v2/screens/ABC");
+ service.stopSync();
+
+ // Let the config promise resolve.
+ await new Promise((r) => setTimeout(r, 0));
+
+ expect(DataSync).not.toHaveBeenCalled();
+ });
+
+ it("stops and nulls dataSync on stopSync", async () => {
+ service.startSyncing("/v2/screens/ABC");
+ await vi.waitFor(() => expect(DataSync).toHaveBeenCalled());
+
+ const instance = DataSync.mock.results[0].value;
+ service.stopSync();
+
+ expect(instance.stop).toHaveBeenCalled();
+ expect(service.dataSync).toBeNull();
+ });
+ });
+
+ describe("contentHandler", () => {
+ const makeScreen = (overrides = {}) => ({
+ "@id": "/v2/screens/SCREEN01234567890123456789",
+ title: "Test Screen",
+ regionData: {
+ region1: [{ "@id": "/v2/playlists/P1" }],
+ },
+ ...overrides,
+ });
+
+ it("calls setScreen when screen data changes", () => {
+ service.contentHandler(makeScreen());
+
+ expect(callbacks.current.setScreen).toHaveBeenCalledTimes(1);
+ const screenArg = callbacks.current.setScreen.mock.calls[0][0];
+ expect(screenArg["@id"]).toBe(
+ "/v2/screens/SCREEN01234567890123456789"
+ );
+ // regionData should be stripped from the screen passed to setScreen.
+ expect(screenArg.regionData).toBeUndefined();
+ });
+
+ it("does not call setScreen when screen data has not changed", () => {
+ const screen = makeScreen();
+ service.contentHandler(screen);
+ service.contentHandler(screen);
+
+ expect(callbacks.current.setScreen).toHaveBeenCalledTimes(1);
+ });
+
+ it("calls setScreen again when screen data changes", () => {
+ service.contentHandler(makeScreen());
+ service.contentHandler(makeScreen({ title: "Changed" }));
+
+ expect(callbacks.current.setScreen).toHaveBeenCalledTimes(2);
+ });
+
+ it("always pushes region data to schedule service", () => {
+ const screen = makeScreen();
+ service.contentHandler(screen);
+
+ expect(service.scheduleService.updateRegion).toHaveBeenCalledWith(
+ "region1",
+ screen.regionData.region1
+ );
+ });
+
+ it("pushes region data even when screen hash has not changed", () => {
+ const screen = makeScreen();
+ service.contentHandler(screen);
+ service.contentHandler(screen);
+
+ expect(service.scheduleService.updateRegion).toHaveBeenCalledTimes(2);
+ });
+
+ it("pushes data for all regions", () => {
+ const screen = makeScreen({
+ regionData: {
+ region1: [{ "@id": "/v2/playlists/P1" }],
+ region2: [{ "@id": "/v2/playlists/P2" }],
+ },
+ });
+ service.contentHandler(screen);
+
+ expect(service.scheduleService.updateRegion).toHaveBeenCalledTimes(2);
+ });
+ });
+
+ describe("regionReady", () => {
+ it("sends current region data to schedule service", () => {
+ service.currentScreen = {
+ regionData: {
+ region1: [{ "@id": "/v2/playlists/P1" }],
+ },
+ };
+
+ service.regionReady("region1");
+
+ expect(service.scheduleService.updateRegion).toHaveBeenCalledWith(
+ "region1",
+ service.currentScreen.regionData.region1
+ );
+ });
+
+ it("does nothing when no current screen exists", () => {
+ service.regionReady("region1");
+
+ expect(service.scheduleService.updateRegion).not.toHaveBeenCalled();
+ });
+ });
+
+ describe("regionRemoved", () => {
+ it("delegates to schedule service", () => {
+ service.regionRemoved("region1");
+
+ expect(service.scheduleService.regionRemoved).toHaveBeenCalledWith(
+ "region1"
+ );
+ });
+ });
+
+ describe("startPreview", () => {
+ it("starts syncing for screen mode", async () => {
+ const spy = vi.spyOn(service, "startSyncing");
+
+ await service.startPreview("screen", "SCREEN01234567890123456789");
+
+ expect(spy).toHaveBeenCalledWith(
+ "/v2/screens/SCREEN01234567890123456789"
+ );
+ });
+
+ it("fetches playlist and slides for playlist mode", async () => {
+ const playlist = {
+ "@id": "/v2/playlists/PLSTAAA0000000000000000001",
+ slides: "/v2/playlists/PLSTAAA0000000000000000001/slides",
+ };
+ const slidesResponse = {
+ "hydra:member": [
+ {
+ slide: {
+ "@id": "/v2/slides/SLIDEAAA000000000000000001",
+ templateInfo: { "@id": "/v2/templates/TMPLAAA0000000000000000001" },
+ media: [],
+ },
+ },
+ ],
+ };
+ const templateData = { "@id": "/v2/templates/TMPLAAA0000000000000000001" };
+
+ mockQuery
+ .mockResolvedValueOnce(playlist)
+ .mockResolvedValueOnce(slidesResponse)
+ .mockResolvedValueOnce(templateData);
+
+ await service.startPreview("playlist", "PLSTAAA0000000000000000001");
+
+ expect(mockQuery).toHaveBeenCalledWith(
+ "getV2PlaylistsById",
+ { id: "PLSTAAA0000000000000000001" },
+ true
+ );
+ expect(callbacks.current.setScreen).toHaveBeenCalled();
+ });
+
+ it("fetches slide and attaches references for slide mode", async () => {
+ const slide = {
+ "@id": "/v2/slides/SLIDEAAA000000000000000001",
+ templateInfo: { "@id": "/v2/templates/TMPLAAA0000000000000000001" },
+ media: ["/v2/media/MDIAAA00000000000000000001"],
+ };
+ const templateData = { "@id": "/v2/templates/TMPLAAA0000000000000000001" };
+ const mediaData = { "@id": "/v2/media/MDIAAA00000000000000000001" };
+
+ mockQuery
+ .mockResolvedValueOnce(slide)
+ .mockResolvedValueOnce(templateData)
+ .mockResolvedValueOnce(mediaData);
+
+ await service.startPreview("slide", "SLIDEAAA000000000000000001");
+
+ expect(mockQuery).toHaveBeenCalledWith(
+ "getV2SlidesById",
+ { id: "SLIDEAAA000000000000000001" },
+ true
+ );
+ expect(callbacks.current.setScreen).toHaveBeenCalled();
+ });
+
+ it("logs error for unsupported mode", async () => {
+ await service.startPreview("unknown", "123");
+
+ expect(logger.error).toHaveBeenCalledWith(
+ "Unsupported preview mode: unknown."
+ );
+ });
+
+ it("catches and logs errors", async () => {
+ mockQuery.mockRejectedValueOnce(new Error("Network error"));
+
+ await service.startPreview("slide", "SLIDEAAA000000000000000001");
+
+ expect(logger.error).toHaveBeenCalledWith(
+ expect.stringContaining("Preview failed")
+ );
+ });
+ });
+
+ describe("attachReferencesToSlide", () => {
+ it("fetches template, media, and feed data", async () => {
+ const slide = {
+ "@id": "/v2/slides/SLIDEAAA000000000000000001",
+ templateInfo: { "@id": "/v2/templates/TMPLAAA0000000000000000001" },
+ media: ["/v2/media/MDIAAA00000000000000000001"],
+ feed: { feedUrl: "/v2/feeds/FEEDAAA0000000000000000001" },
+ theme: "/v2/themes/THMEAAA0000000000000000001",
+ };
+
+ const templateData = { "@id": "/v2/templates/TMPLAAA0000000000000000001" };
+ const mediaData = { "@id": "/v2/media/MDIAAA00000000000000000001" };
+ const feedData = [{ title: "Feed item" }];
+ const themeData = { "@id": "/v2/themes/THMEAAA0000000000000000001" };
+
+ // Order: template, feed, then each media (loop), then theme.
+ mockQuery
+ .mockResolvedValueOnce(templateData)
+ .mockResolvedValueOnce(feedData)
+ .mockResolvedValueOnce(mediaData)
+ .mockResolvedValueOnce(themeData);
+
+ await ContentService.attachReferencesToSlide(slide);
+
+ expect(slide.templateData).toEqual(templateData);
+ expect(slide.mediaData["/v2/media/MDIAAA00000000000000000001"]).toEqual(
+ mediaData
+ );
+ expect(slide.feedData).toEqual(feedData);
+ expect(slide.theme).toEqual(themeData);
+ });
+
+ it("marks slide invalid when template fetch fails", async () => {
+ const slide = {
+ "@id": "/v2/slides/SLIDEAAA000000000000000001",
+ templateInfo: { "@id": "/v2/templates/TMPLAAA0000000000000000001" },
+ media: [],
+ };
+
+ mockQuery.mockRejectedValueOnce(new Error("Not found"));
+
+ await ContentService.attachReferencesToSlide(slide);
+
+ expect(slide.invalid).toBe(true);
+ expect(slide.templateData).toBeNull();
+ expect(slide.mediaData).toEqual({});
+ expect(slide.feedData).toBeNull();
+ });
+
+ it("sets feedData to empty array when no feed configured", async () => {
+ const slide = {
+ "@id": "/v2/slides/SLIDEAAA000000000000000001",
+ templateInfo: { "@id": "/v2/templates/TMPLAAA0000000000000000001" },
+ media: [],
+ };
+
+ mockQuery.mockResolvedValueOnce({ "@id": "/v2/templates/T" });
+
+ await ContentService.attachReferencesToSlide(slide);
+
+ expect(slide.feedData).toEqual([]);
+ });
+
+ it("sets media to null on fetch failure", async () => {
+ const slide = {
+ "@id": "/v2/slides/SLIDEAAA000000000000000001",
+ templateInfo: { "@id": "/v2/templates/TMPLAAA0000000000000000001" },
+ media: ["/v2/media/MDIAAA00000000000000000001"],
+ };
+
+ mockQuery
+ .mockResolvedValueOnce({ "@id": "/v2/templates/T" })
+ .mockRejectedValueOnce(new Error("Media error"));
+
+ await ContentService.attachReferencesToSlide(slide);
+
+ expect(slide.mediaData["/v2/media/MDIAAA00000000000000000001"]).toBeNull();
+ });
+
+ it("keeps theme as string when theme fetch fails", async () => {
+ const slide = {
+ "@id": "/v2/slides/SLIDEAAA000000000000000001",
+ templateInfo: { "@id": "/v2/templates/TMPLAAA0000000000000000001" },
+ media: [],
+ theme: "/v2/themes/THMEAAA0000000000000000001",
+ };
+
+ mockQuery
+ .mockResolvedValueOnce({ "@id": "/v2/templates/T" })
+ .mockRejectedValueOnce(new Error("Theme error"));
+
+ await ContentService.attachReferencesToSlide(slide);
+
+ expect(slide.theme).toBe("/v2/themes/THMEAAA0000000000000000001");
+ });
+ });
+});
From 17b3a89f150bc5e6523d41ad155866076f75d5df Mon Sep 17 00:00:00 2001
From: Troels Ugilt Jensen <6103205+tuj@users.noreply.github.com>
Date: Wed, 22 Apr 2026 05:37:47 +0200
Subject: [PATCH 69/73] 7228: Added tests for remaining code
---
assets/tests/client/data-sync.test.js | 46 ++++
assets/tests/client/pull-strategy.test.js | 127 +++++++++++
assets/tests/client/release-service.test.js | 220 ++++++++++++++++++++
assets/tests/client/tenant-service.test.js | 170 +++++++++++++++
4 files changed, 563 insertions(+)
create mode 100644 assets/tests/client/data-sync.test.js
create mode 100644 assets/tests/client/release-service.test.js
create mode 100644 assets/tests/client/tenant-service.test.js
diff --git a/assets/tests/client/data-sync.test.js b/assets/tests/client/data-sync.test.js
new file mode 100644
index 000000000..060929a68
--- /dev/null
+++ b/assets/tests/client/data-sync.test.js
@@ -0,0 +1,46 @@
+import { describe, it, expect, vi } from "vitest";
+
+const mockStart = vi.fn();
+const mockStop = vi.fn();
+
+vi.mock("../../client/service/pull-strategy", () => {
+ const MockPullStrategy = vi.fn(function (config, onContent) {
+ this.config = config;
+ this.onContent = onContent;
+ this.start = mockStart;
+ this.stop = mockStop;
+ });
+ return { default: MockPullStrategy };
+});
+
+import DataSync from "../../client/service/data-sync";
+import PullStrategy from "../../client/service/pull-strategy";
+
+describe("DataSync", () => {
+ it("creates a PullStrategy with config and onContent callback", () => {
+ const onContent = vi.fn();
+ const config = { entryPoint: "/v2/screens/ABC", interval: 5000, onContent };
+
+ new DataSync(config);
+
+ expect(PullStrategy).toHaveBeenCalledWith(config, onContent);
+ });
+
+ it("delegates start to the strategy", () => {
+ const config = { entryPoint: "/v2/screens/ABC", onContent: vi.fn() };
+ const sync = new DataSync(config);
+
+ sync.start();
+
+ expect(mockStart).toHaveBeenCalled();
+ });
+
+ it("delegates stop to the strategy", () => {
+ const config = { entryPoint: "/v2/screens/ABC", onContent: vi.fn() };
+ const sync = new DataSync(config);
+
+ sync.stop();
+
+ expect(mockStop).toHaveBeenCalled();
+ });
+});
diff --git a/assets/tests/client/pull-strategy.test.js b/assets/tests/client/pull-strategy.test.js
index ec410e3b9..b4d253d4f 100644
--- a/assets/tests/client/pull-strategy.test.js
+++ b/assets/tests/client/pull-strategy.test.js
@@ -129,6 +129,7 @@ function setupResponses(responseMap) {
return Promise.resolve(handler);
},
unsubscribe: vi.fn(),
+ abort: vi.fn(),
}));
}
@@ -528,3 +529,129 @@ describe("PullStrategy.getScreen", () => {
});
});
});
+
+describe("PullStrategy.pull", () => {
+ let strategy;
+ let contentCapture;
+
+ beforeEach(() => {
+ vi.useFakeTimers();
+ vi.setSystemTime(new Date("2025-06-15T12:00:00Z"));
+ vi.clearAllMocks();
+
+ ClientConfigLoader.loadConfig.mockResolvedValue({
+ relationsChecksumEnabled: false,
+ });
+
+ contentCapture = captureContentCallback();
+ strategy = new PullStrategy(
+ { entryPoint: SCREEN_PATH, interval: 60000 },
+ contentCapture.callback,
+ );
+ });
+
+ afterEach(() => {
+ strategy.stop();
+ vi.useRealTimers();
+ });
+
+ it("schedules next pull after completion", async () => {
+ setupBasicResponses();
+
+ strategy.pull();
+ await vi.advanceTimersByTimeAsync(0); // let getScreen resolve
+
+ expect(contentCapture.callCount).toBe(1);
+
+ // Next pull should be scheduled after the interval.
+ setupBasicResponses();
+ await vi.advanceTimersByTimeAsync(60000);
+
+ expect(contentCapture.callCount).toBe(2);
+ });
+
+ it("does not run concurrent pulls", async () => {
+ // Make getScreen hang for a while.
+ let resolveScreen;
+ setupResponses({
+ getV2ScreensById: () =>
+ new Promise((resolve) => {
+ resolveScreen = () => resolve(makeScreen());
+ }),
+ getV2ScreensByIdScreenGroups: { "hydra:member": [] },
+ getV2ScreensByIdCampaigns: { "hydra:member": [] },
+ getV2LayoutsById: makeLayout(),
+ getV2ScreensByIdRegionsAndRegionIdPlaylists: hydra([
+ { playlist: makePlaylist() },
+ ]),
+ getV2PlaylistsByIdSlides: hydra([{ slide: makeSlide() }]),
+ getV2TemplatesById: makeTemplateData(),
+ });
+
+ strategy.pull();
+ strategy.pull(); // Should be a no-op.
+
+ // Only one getScreen call should be in flight.
+ const screenCalls = getDispatchCallsFor("getV2ScreensById");
+ expect(screenCalls).toHaveLength(1);
+
+ resolveScreen();
+ });
+
+ it("recovers and schedules next pull when screen fetch fails", async () => {
+ setupResponses({
+ getV2ScreensById: () => Promise.reject(new Error("Boom")),
+ });
+
+ strategy.pull();
+ // Let the query timeout fire (30s) and then the getScreen catch.
+ await vi.advanceTimersByTimeAsync(30000);
+
+ expect(logger.warn).toHaveBeenCalledWith(
+ expect.stringContaining("not loaded. Aborting content update"),
+ );
+
+ // Should still schedule next pull.
+ setupBasicResponses();
+ await vi.advanceTimersByTimeAsync(60000);
+
+ expect(contentCapture.callCount).toBe(1);
+ });
+
+ it("does not schedule next pull after stop", async () => {
+ setupBasicResponses();
+
+ strategy.pull();
+ await vi.advanceTimersByTimeAsync(0);
+
+ strategy.stop();
+
+ setupBasicResponses();
+ await vi.advanceTimersByTimeAsync(60000);
+
+ // Should not have run a second time.
+ expect(contentCapture.callCount).toBe(1);
+ });
+
+ it("aborts getScreen cycle when it exceeds max execution time", async () => {
+ // Spy on getScreen to return a never-resolving promise, bypassing
+ // the internal query timeouts that would otherwise resolve it.
+ vi.spyOn(strategy, "getScreen").mockReturnValue(new Promise(() => {}));
+
+ strategy.pull();
+
+ // Advance past the 2 minute getScreen timeout.
+ await vi.advanceTimersByTimeAsync(2 * 60 * 1000);
+
+ expect(logger.error).toHaveBeenCalledWith(
+ expect.stringContaining("getScreen exceeded max execution time"),
+ );
+
+ // Should still schedule next pull after the guard fires.
+ strategy.getScreen.mockRestore();
+ setupBasicResponses();
+ await vi.advanceTimersByTimeAsync(60000);
+
+ expect(contentCapture.callCount).toBe(1);
+ });
+});
diff --git a/assets/tests/client/release-service.test.js b/assets/tests/client/release-service.test.js
new file mode 100644
index 000000000..61e35df7c
--- /dev/null
+++ b/assets/tests/client/release-service.test.js
@@ -0,0 +1,220 @@
+import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
+
+vi.mock("../../client/core/logger.js", () => ({
+ default: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), log: vi.fn() },
+}));
+
+vi.mock("../../client/core/client-config-loader.js", () => ({
+ default: {
+ loadConfig: vi
+ .fn()
+ .mockResolvedValue({ releaseTimestampIntervalTimeout: 600000 }),
+ },
+}));
+
+vi.mock("../../client/core/app-storage.js", () => ({
+ default: {
+ getPreviousBoot: vi.fn().mockReturnValue("1700000000000"),
+ },
+}));
+
+vi.mock("../../client/service/status-service.js", () => ({
+ default: {
+ error: null,
+ setError: vi.fn(),
+ setStatus: vi.fn(),
+ },
+}));
+
+vi.mock("../../client/util/id-from-path", () => ({
+ default: (path) => {
+ if (!path) return null;
+ const match = path.match(/[A-Za-z0-9]{26}/);
+ return match ? match[0] : null;
+ },
+}));
+
+const mockLoadRelease = vi.fn();
+vi.mock("../../shared/release-loader.js", () => ({
+ default: { loadRelease: (...args) => mockLoadRelease(...args) },
+}));
+
+import statusService from "../../client/service/status-service.js";
+import constants from "../../client/util/constants";
+import logger from "../../client/core/logger.js";
+
+describe("ReleaseService", () => {
+ let releaseService;
+ let mockReplace;
+ let mockReplaceState;
+
+ beforeEach(async () => {
+ vi.useFakeTimers();
+ vi.clearAllMocks();
+ vi.resetModules();
+
+ mockReplace = vi.fn();
+ mockReplaceState = vi.fn();
+
+ vi.stubGlobal("location", {
+ href: "http://localhost/?releaseTimestamp=100",
+ replace: mockReplace,
+ });
+ vi.stubGlobal("history", { replaceState: mockReplaceState });
+
+ const module = await import("../../client/service/release-service.js");
+ releaseService = module.default;
+ });
+
+ afterEach(() => {
+ releaseService.stopReleaseCheck();
+ vi.useRealTimers();
+ vi.unstubAllGlobals();
+ });
+
+ describe("checkForNewRelease", () => {
+ it("resolves when release timestamp matches current", async () => {
+ mockLoadRelease.mockResolvedValue({
+ releaseTimestamp: 100,
+ releaseVersion: "1.0",
+ });
+
+ await expect(releaseService.checkForNewRelease()).resolves.toBeUndefined();
+ expect(mockReplace).not.toHaveBeenCalled();
+ });
+
+ it("redirects when release timestamp differs", async () => {
+ mockLoadRelease.mockResolvedValue({
+ releaseTimestamp: 200,
+ releaseVersion: "2.0",
+ });
+
+ await expect(
+ releaseService.checkForNewRelease()
+ ).rejects.toBeUndefined();
+
+ expect(mockReplace).toHaveBeenCalledTimes(1);
+ const redirectUrl = mockReplace.mock.calls[0][0].toString();
+ expect(redirectUrl).toContain("releaseTimestamp=200");
+ expect(redirectUrl).toContain("releaseVersion=2.0");
+ });
+
+ it("redirects when no current timestamp in URL", async () => {
+ vi.stubGlobal("location", {
+ href: "http://localhost/",
+ replace: mockReplace,
+ });
+
+ mockLoadRelease.mockResolvedValue({
+ releaseTimestamp: 200,
+ releaseVersion: null,
+ });
+
+ // Re-import to pick up new location
+ vi.resetModules();
+ const mod = await import("../../client/service/release-service.js");
+
+ await expect(mod.default.checkForNewRelease()).rejects.toBeUndefined();
+ expect(mockReplace).toHaveBeenCalled();
+ });
+
+ it("sets error when release timestamp is null", async () => {
+ mockLoadRelease.mockResolvedValue({
+ releaseTimestamp: null,
+ releaseVersion: null,
+ });
+
+ await releaseService.checkForNewRelease();
+
+ expect(statusService.setError).toHaveBeenCalledWith(
+ constants.ERROR_RELEASE_FILE_NOT_LOADED
+ );
+ });
+
+ it("clears error when release loads after previous failure", async () => {
+ statusService.error = constants.ERROR_RELEASE_FILE_NOT_LOADED;
+
+ mockLoadRelease.mockResolvedValue({
+ releaseTimestamp: 100,
+ releaseVersion: "1.0",
+ });
+
+ await releaseService.checkForNewRelease();
+
+ expect(statusService.setError).toHaveBeenCalledWith(null);
+ });
+
+ it("resolves when loadRelease fails", async () => {
+ mockLoadRelease.mockRejectedValue(new Error("Network"));
+
+ await expect(releaseService.checkForNewRelease()).resolves.toBeUndefined();
+ expect(logger.error).toHaveBeenCalledWith(
+ expect.stringContaining("Failed to load release")
+ );
+ });
+ });
+
+ describe("setScreenIdInUrl", () => {
+ it("sets screenId in URL search params", () => {
+ releaseService.setScreenIdInUrl(
+ "/v2/screens/SCREEN0001AAAAAAAAAAAAAAAA"
+ );
+
+ expect(mockReplaceState).toHaveBeenCalled();
+ const url = mockReplaceState.mock.calls[0][2].toString();
+ expect(url).toContain("screenId=SCREEN0001AAAAAAAAAAAAAAAA");
+ });
+ });
+
+ describe("setPreviousBootInUrl", () => {
+ it("sets pb param from appStorage", () => {
+ releaseService.setPreviousBootInUrl();
+
+ expect(mockReplaceState).toHaveBeenCalled();
+ const url = mockReplaceState.mock.calls[0][2].toString();
+ expect(url).toContain("pb=1700000000000");
+ });
+ });
+
+ describe("startReleaseCheck / stopReleaseCheck", () => {
+ it("sets up an interval that calls checkForNewRelease", async () => {
+ mockLoadRelease.mockResolvedValue({
+ releaseTimestamp: 100,
+ releaseVersion: "1.0",
+ });
+
+ releaseService.startReleaseCheck();
+ await vi.advanceTimersByTimeAsync(0); // let config resolve
+
+ expect(releaseService.releaseCheckInterval).not.toBeNull();
+
+ // Advance past one interval tick.
+ await vi.advanceTimersByTimeAsync(600000);
+
+ expect(mockLoadRelease).toHaveBeenCalled();
+ });
+
+ it("clears interval on stop", async () => {
+ mockLoadRelease.mockResolvedValue({
+ releaseTimestamp: 100,
+ releaseVersion: "1.0",
+ });
+
+ releaseService.startReleaseCheck();
+ await vi.advanceTimersByTimeAsync(0);
+
+ releaseService.stopReleaseCheck();
+
+ expect(releaseService.releaseCheckInterval).toBeNull();
+ });
+
+ it("does not create interval if stopped before config resolves", async () => {
+ releaseService.startReleaseCheck();
+ releaseService.stopReleaseCheck();
+
+ await vi.advanceTimersByTimeAsync(0);
+
+ expect(releaseService.releaseCheckInterval).toBeNull();
+ });
+ });
+});
diff --git a/assets/tests/client/tenant-service.test.js b/assets/tests/client/tenant-service.test.js
new file mode 100644
index 000000000..048d9a99d
--- /dev/null
+++ b/assets/tests/client/tenant-service.test.js
@@ -0,0 +1,170 @@
+import { describe, it, expect, vi, beforeEach } from "vitest";
+
+const { mockDispatch } = vi.hoisted(() => ({
+ mockDispatch: vi.fn(),
+}));
+
+vi.mock("../../client/core/logger.js", () => ({
+ default: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), log: vi.fn() },
+}));
+
+vi.mock("../../client/core/app-storage.js", () => ({
+ default: {
+ getToken: vi.fn(),
+ getTenantKey: vi.fn(),
+ getTenantId: vi.fn(),
+ setFallbackImageUrl: vi.fn(),
+ },
+}));
+
+vi.mock("../../client/redux/store.js", () => ({
+ clientStore: { dispatch: mockDispatch },
+}));
+
+vi.mock("../../client/redux/enhanced-api.ts", () => ({
+ clientApi: {
+ endpoints: {
+ getV2TenantsById: {
+ initiate: vi.fn().mockReturnValue("tenantAction"),
+ },
+ },
+ reducerPath: "clientApi",
+ reducer: (state = {}) => state,
+ middleware: () => (next) => (action) => next(action),
+ },
+}));
+
+vi.mock("../../client/redux/empty-api.ts", () => ({
+ clientEmptySplitApi: {
+ injectEndpoints: vi.fn().mockReturnValue({ endpoints: {} }),
+ },
+}));
+
+import tenantService from "../../client/service/tenant-service";
+import appStorage from "../../client/core/app-storage.js";
+import logger from "../../client/core/logger.js";
+
+describe("TenantService", () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ });
+
+ it("dispatches getV2TenantsById when credentials are present", () => {
+ appStorage.getToken.mockReturnValue("jwt-token");
+ appStorage.getTenantKey.mockReturnValue("tenant-key");
+ appStorage.getTenantId.mockReturnValue("tenant-123");
+
+ mockDispatch.mockReturnValue({
+ unwrap: () => Promise.resolve({}),
+ unsubscribe: vi.fn(),
+ });
+
+ tenantService.loadTenantConfig();
+
+ expect(mockDispatch).toHaveBeenCalled();
+ });
+
+ it("sets fallback image URL from tenant data", async () => {
+ appStorage.getToken.mockReturnValue("jwt-token");
+ appStorage.getTenantKey.mockReturnValue("tenant-key");
+ appStorage.getTenantId.mockReturnValue("tenant-123");
+
+ mockDispatch.mockReturnValue({
+ unwrap: () =>
+ Promise.resolve({ fallbackImageUrl: "https://example.com/bg.png" }),
+ unsubscribe: vi.fn(),
+ });
+
+ tenantService.loadTenantConfig();
+
+ // Wait for the promise chain.
+ await new Promise((r) => setTimeout(r, 0));
+
+ expect(appStorage.setFallbackImageUrl).toHaveBeenCalledWith(
+ "https://example.com/bg.png"
+ );
+ });
+
+ it("does not set fallback image when not present in tenant data", async () => {
+ appStorage.getToken.mockReturnValue("jwt-token");
+ appStorage.getTenantKey.mockReturnValue("tenant-key");
+ appStorage.getTenantId.mockReturnValue("tenant-123");
+
+ mockDispatch.mockReturnValue({
+ unwrap: () => Promise.resolve({}),
+ unsubscribe: vi.fn(),
+ });
+
+ tenantService.loadTenantConfig();
+ await new Promise((r) => setTimeout(r, 0));
+
+ expect(appStorage.setFallbackImageUrl).not.toHaveBeenCalled();
+ });
+
+ it("does nothing when token is missing", () => {
+ appStorage.getToken.mockReturnValue(null);
+ appStorage.getTenantKey.mockReturnValue("tenant-key");
+ appStorage.getTenantId.mockReturnValue("tenant-123");
+
+ tenantService.loadTenantConfig();
+
+ expect(mockDispatch).not.toHaveBeenCalled();
+ });
+
+ it("does nothing when tenantKey is missing", () => {
+ appStorage.getToken.mockReturnValue("jwt-token");
+ appStorage.getTenantKey.mockReturnValue(null);
+ appStorage.getTenantId.mockReturnValue("tenant-123");
+
+ tenantService.loadTenantConfig();
+
+ expect(mockDispatch).not.toHaveBeenCalled();
+ });
+
+ it("does nothing when tenantId is missing", () => {
+ appStorage.getToken.mockReturnValue("jwt-token");
+ appStorage.getTenantKey.mockReturnValue("tenant-key");
+ appStorage.getTenantId.mockReturnValue(null);
+
+ tenantService.loadTenantConfig();
+
+ expect(mockDispatch).not.toHaveBeenCalled();
+ });
+
+ it("logs error and unsubscribes on fetch failure", async () => {
+ appStorage.getToken.mockReturnValue("jwt-token");
+ appStorage.getTenantKey.mockReturnValue("tenant-key");
+ appStorage.getTenantId.mockReturnValue("tenant-123");
+
+ const unsubscribe = vi.fn();
+ mockDispatch.mockReturnValue({
+ unwrap: () => Promise.reject(new Error("Network error")),
+ unsubscribe,
+ });
+
+ tenantService.loadTenantConfig();
+ await new Promise((r) => setTimeout(r, 0));
+
+ expect(logger.error).toHaveBeenCalledWith(
+ expect.stringContaining("Failed to load tenant config")
+ );
+ expect(unsubscribe).toHaveBeenCalled();
+ });
+
+ it("unsubscribes after successful fetch", async () => {
+ appStorage.getToken.mockReturnValue("jwt-token");
+ appStorage.getTenantKey.mockReturnValue("tenant-key");
+ appStorage.getTenantId.mockReturnValue("tenant-123");
+
+ const unsubscribe = vi.fn();
+ mockDispatch.mockReturnValue({
+ unwrap: () => Promise.resolve({}),
+ unsubscribe,
+ });
+
+ tenantService.loadTenantConfig();
+ await new Promise((r) => setTimeout(r, 0));
+
+ expect(unsubscribe).toHaveBeenCalled();
+ });
+});
From a304c8c36d6da0a3f8b83e6e90a71ca0629e8ebb Mon Sep 17 00:00:00 2001
From: Troels Ugilt Jensen <6103205+tuj@users.noreply.github.com>
Date: Wed, 22 Apr 2026 06:12:01 +0200
Subject: [PATCH 70/73] 7228: Added tests
---
assets/tests/client/api-query.test.js | 307 +++++++++++++++
assets/tests/client/app.test.jsx | 390 ++++++++++++++++++++
assets/tests/client/base-query.test.js | 149 ++++++++
assets/tests/client/data-sync.test.js | 19 +
assets/tests/client/error-boundary.test.jsx | 60 ++-
assets/tests/client/screen.test.jsx | 45 ++-
assets/tests/client/slide.test.jsx | 66 ++++
assets/tests/client/touch-region.test.jsx | 41 ++
8 files changed, 1069 insertions(+), 8 deletions(-)
create mode 100644 assets/tests/client/api-query.test.js
create mode 100644 assets/tests/client/app.test.jsx
create mode 100644 assets/tests/client/base-query.test.js
diff --git a/assets/tests/client/api-query.test.js b/assets/tests/client/api-query.test.js
new file mode 100644
index 000000000..3748f2785
--- /dev/null
+++ b/assets/tests/client/api-query.test.js
@@ -0,0 +1,307 @@
+import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
+
+// --- Hoisted mocks ---
+const { mockDispatch, endpoints, mockSelect, dispatchDefaults } = vi.hoisted(() => {
+ const mockSelect = vi.fn(() => () => undefined);
+ const mockAbort = vi.fn();
+ const mockUnsubscribe = vi.fn();
+
+ const dispatchDefaults = {
+ unwrapResult: Promise.resolve("data"),
+ makeReturnValue() {
+ return {
+ unwrap: () => dispatchDefaults.unwrapResult,
+ abort: mockAbort,
+ unsubscribe: mockUnsubscribe,
+ };
+ },
+ };
+
+ const mockDispatch = vi.fn(() => dispatchDefaults.makeReturnValue());
+
+ mockDispatch._abort = mockAbort;
+ mockDispatch._unsubscribe = mockUnsubscribe;
+
+ const endpoints = {};
+ const initiate = vi.fn((args, opts) => ({
+ _endpoint: "testEndpoint",
+ _args: args,
+ _opts: opts,
+ }));
+ endpoints.testEndpoint = { initiate, select: mockSelect };
+
+ return { mockDispatch, endpoints, mockSelect, dispatchDefaults };
+});
+
+vi.mock("../../client/core/logger.js", () => ({
+ default: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), log: vi.fn() },
+}));
+
+vi.mock("../../client/redux/store.js", () => ({
+ clientStore: { dispatch: mockDispatch, getState: () => ({}) },
+}));
+
+vi.mock("../../client/redux/enhanced-api.ts", () => ({
+ clientApi: {
+ endpoints,
+ reducerPath: "clientApi",
+ reducer: (state = {}) => state,
+ middleware: () => (next) => (action) => next(action),
+ },
+}));
+
+vi.mock("../../client/util/defaults.js", () => ({
+ default: { queryTimeoutDefault: 500 },
+}));
+
+import { query, queryAllPages } from "../../client/core/api-query.js";
+import logger from "../../client/core/logger.js";
+
+describe("query", () => {
+ beforeEach(() => {
+ vi.useFakeTimers();
+ vi.clearAllMocks();
+ // Restore default implementation (mockImplementation from prior tests persists through clearAllMocks).
+ mockDispatch.mockImplementation(() => dispatchDefaults.makeReturnValue());
+ dispatchDefaults.unwrapResult = Promise.resolve("data");
+ mockSelect.mockReturnValue(() => undefined);
+ });
+
+ afterEach(() => {
+ vi.useRealTimers();
+ });
+
+ it("should resolve with unwrapped data when fetch succeeds", async () => {
+ dispatchDefaults.unwrapResult = Promise.resolve({ id: 1 });
+
+ const result = await query("testEndpoint", { id: "abc" });
+
+ expect(result).toEqual({ id: 1 });
+ });
+
+ it("should pass forceRefetch false by default", () => {
+ query("testEndpoint", { id: "abc" });
+
+ expect(endpoints.testEndpoint.initiate).toHaveBeenCalledWith(
+ { id: "abc" },
+ { forceRefetch: false },
+ );
+ });
+
+ it("should pass forceRefetch true when requested", () => {
+ query("testEndpoint", { id: "abc" }, true);
+
+ expect(endpoints.testEndpoint.initiate).toHaveBeenCalledWith(
+ { id: "abc" },
+ { forceRefetch: true },
+ );
+ });
+
+ it("should reject with timeout error when fetch exceeds queryTimeoutDefault", async () => {
+ dispatchDefaults.unwrapResult = new Promise(() => {}); // never resolves
+
+ const promise = query("testEndpoint", {});
+ vi.advanceTimersByTime(500);
+
+ await expect(promise).rejects.toThrow("Request timeout: testEndpoint");
+ });
+
+ it("should call request.abort on timeout", async () => {
+ dispatchDefaults.unwrapResult = new Promise(() => {});
+
+ const promise = query("testEndpoint", {});
+ vi.advanceTimersByTime(500);
+
+ await promise.catch(() => {});
+ expect(mockDispatch._abort).toHaveBeenCalled();
+ });
+
+ it("should return cached data when fetch fails but cache exists", async () => {
+ dispatchDefaults.unwrapResult = Promise.reject(new Error("network"));
+ mockSelect.mockReturnValue(() => ({ data: { cached: true } }));
+
+ const result = await query("testEndpoint", { id: "abc" });
+
+ expect(result).toEqual({ cached: true });
+ });
+
+ it("should log warning when falling back to cached data", async () => {
+ dispatchDefaults.unwrapResult = Promise.reject(new Error("network"));
+ mockSelect.mockReturnValue(() => ({ data: { cached: true } }));
+
+ await query("testEndpoint", { id: "abc" });
+
+ expect(logger.warn).toHaveBeenCalledWith(
+ "Using cached data for testEndpoint after fetch failure.",
+ );
+ });
+
+ it("should re-throw when fetch fails and no cache exists", async () => {
+ dispatchDefaults.unwrapResult = Promise.reject(new Error("network"));
+ mockSelect.mockReturnValue(() => undefined);
+
+ await expect(query("testEndpoint", {})).rejects.toThrow("network");
+ });
+
+ it("should re-throw when cached data is undefined", async () => {
+ dispatchDefaults.unwrapResult = Promise.reject(new Error("network"));
+ mockSelect.mockReturnValue(() => ({ data: undefined }));
+
+ await expect(query("testEndpoint", {})).rejects.toThrow("network");
+ });
+
+ it("should call unsubscribe on success", async () => {
+ dispatchDefaults.unwrapResult = Promise.resolve("ok");
+
+ await query("testEndpoint", {});
+
+ expect(mockDispatch._unsubscribe).toHaveBeenCalled();
+ });
+
+ it("should call unsubscribe on error", async () => {
+ dispatchDefaults.unwrapResult = Promise.reject(new Error("fail"));
+ mockSelect.mockReturnValue(() => undefined);
+
+ await query("testEndpoint", {}).catch(() => {});
+
+ expect(mockDispatch._unsubscribe).toHaveBeenCalled();
+ });
+});
+
+describe("queryAllPages", () => {
+ beforeEach(() => {
+ vi.useFakeTimers();
+ vi.clearAllMocks();
+ mockDispatch.mockImplementation(() => dispatchDefaults.makeReturnValue());
+ dispatchDefaults.unwrapResult = Promise.resolve("data");
+ mockSelect.mockReturnValue(() => undefined);
+ });
+
+ afterEach(() => {
+ vi.useRealTimers();
+ });
+
+ it("should return hydra:member from a single page with no hydra:next", async () => {
+ dispatchDefaults.unwrapResult = Promise.resolve({
+ "hydra:member": [{ id: 1 }, { id: 2 }],
+ "hydra:view": {},
+ });
+
+ const result = await queryAllPages("testEndpoint", {});
+
+ expect(result).toEqual([{ id: 1 }, { id: 2 }]);
+ });
+
+ it("should concatenate members across multiple pages", async () => {
+ let callCount = 0;
+ mockDispatch.mockImplementation(() => {
+ callCount += 1;
+ const page = callCount;
+ return {
+ unwrap: () =>
+ Promise.resolve({
+ "hydra:member": [{ id: page }],
+ "hydra:view":
+ page < 3 ? { "hydra:next": `/page/${page + 1}` } : {},
+ }),
+ abort: vi.fn(),
+ unsubscribe: vi.fn(),
+ };
+ });
+
+ const result = await queryAllPages("testEndpoint", {});
+
+ expect(result).toEqual([{ id: 1 }, { id: 2 }, { id: 3 }]);
+ });
+
+ it("should stop when hydra:view has no hydra:next", async () => {
+ dispatchDefaults.unwrapResult = Promise.resolve({
+ "hydra:member": [{ id: 1 }],
+ "hydra:view": { "hydra:last": "/page/1" },
+ });
+
+ const result = await queryAllPages("testEndpoint", {});
+
+ expect(result).toEqual([{ id: 1 }]);
+ expect(mockDispatch).toHaveBeenCalledTimes(1);
+ });
+
+ it("should return partial results when mid-pagination fetch throws", async () => {
+ let callCount = 0;
+ mockDispatch.mockImplementation(() => {
+ callCount += 1;
+ const page = callCount;
+ if (page === 2) {
+ return {
+ unwrap: () => Promise.reject(new Error("fail page 2")),
+ abort: vi.fn(),
+ unsubscribe: vi.fn(),
+ };
+ }
+ return {
+ unwrap: () =>
+ Promise.resolve({
+ "hydra:member": [{ id: page }],
+ "hydra:view": { "hydra:next": `/page/${page + 1}` },
+ }),
+ abort: vi.fn(),
+ unsubscribe: vi.fn(),
+ };
+ });
+
+ const result = await queryAllPages("testEndpoint", {});
+
+ expect(result).toEqual([{ id: 1 }]);
+ });
+
+ it("should return empty array when page 1 returns null", async () => {
+ dispatchDefaults.unwrapResult = Promise.resolve(null);
+
+ const result = await queryAllPages("testEndpoint", {});
+
+ expect(result).toEqual([]);
+ });
+
+ it("should log error on null response", async () => {
+ dispatchDefaults.unwrapResult = Promise.resolve(null);
+
+ await queryAllPages("testEndpoint", {});
+
+ expect(logger.error).toHaveBeenCalledWith(
+ "Failed to fetch page 1 for testEndpoint",
+ );
+ });
+
+ it("should pass forceRefetch through to query", async () => {
+ dispatchDefaults.unwrapResult = Promise.resolve({
+ "hydra:member": [],
+ "hydra:view": {},
+ });
+
+ await queryAllPages("testEndpoint", { filter: "x" }, true);
+
+ expect(endpoints.testEndpoint.initiate).toHaveBeenCalledWith(
+ { filter: "x", page: 1 },
+ { forceRefetch: true },
+ );
+ });
+
+ it("should stop at MAX_PAGES and log warning", async () => {
+ mockDispatch.mockImplementation(() => ({
+ unwrap: () =>
+ Promise.resolve({
+ "hydra:member": [{ id: "item" }],
+ "hydra:view": { "hydra:next": "/next" },
+ }),
+ abort: vi.fn(),
+ unsubscribe: vi.fn(),
+ }));
+
+ const result = await queryAllPages("testEndpoint", {});
+
+ expect(result).toHaveLength(50);
+ expect(logger.warn).toHaveBeenCalledWith(
+ "Reached max page limit (50) for testEndpoint",
+ );
+ });
+});
diff --git a/assets/tests/client/app.test.jsx b/assets/tests/client/app.test.jsx
new file mode 100644
index 000000000..b5dc77f9c
--- /dev/null
+++ b/assets/tests/client/app.test.jsx
@@ -0,0 +1,390 @@
+import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
+import { render, cleanup, act, fireEvent } from "@testing-library/react";
+
+// --- Hoisted mocks ---
+const {
+ mockContentService,
+ mockTokenService,
+ mockReleaseService,
+ mockTenantService,
+ mockStatusService,
+ mockAppStorage,
+ mockConfigLoader,
+ mockReauthRef,
+ mockCallbacks,
+ mockScreen,
+ mockIsContentEmpty,
+} = vi.hoisted(() => {
+ const mockContentService = {
+ start: vi.fn(),
+ stop: vi.fn(),
+ startSyncing: vi.fn(),
+ stopSync: vi.fn(),
+ startPreview: vi.fn(),
+ };
+ const mockTokenService = {
+ checkLogin: vi.fn().mockResolvedValue({ status: "ready", screenId: "S1" }),
+ checkToken: vi.fn(),
+ refreshToken: vi.fn().mockResolvedValue(),
+ startRefreshing: vi.fn(),
+ stopRefreshing: vi.fn(),
+ };
+ const mockReleaseService = {
+ checkForNewRelease: vi.fn().mockResolvedValue(),
+ setPreviousBootInUrl: vi.fn(),
+ startReleaseCheck: vi.fn(),
+ stopReleaseCheck: vi.fn(),
+ setScreenIdInUrl: vi.fn(),
+ };
+ const mockTenantService = { loadTenantConfig: vi.fn() };
+ const mockStatusService = {
+ setStatus: vi.fn(),
+ setError: vi.fn(),
+ setStatusInUrl: vi.fn(),
+ error: null,
+ };
+ const mockAppStorage = {
+ getToken: vi.fn().mockReturnValue(null),
+ getScreenId: vi.fn().mockReturnValue(null),
+ getFallbackImageUrl: vi.fn().mockReturnValue(null),
+ setPreviousBoot: vi.fn(),
+ clearToken: vi.fn(),
+ clearRefreshToken: vi.fn(),
+ clearScreenId: vi.fn(),
+ clearTenant: vi.fn(),
+ clearFallbackImageUrl: vi.fn(),
+ clearAppStorage: vi.fn(),
+ };
+ const mockConfigLoader = {
+ loadConfig: vi.fn().mockResolvedValue({ debug: false }),
+ };
+ const mockReauthRef = { current: vi.fn() };
+ const mockCallbacks = {
+ current: {
+ setScreen: vi.fn(),
+ setIsContentEmpty: vi.fn(),
+ updateRegionSlides: vi.fn(),
+ onRegionReady: vi.fn(),
+ onRegionRemoved: vi.fn(),
+ onReauthenticate: vi.fn(),
+ },
+ };
+ const mockScreen = { value: null };
+ const mockIsContentEmpty = { value: false };
+
+ return {
+ mockContentService,
+ mockTokenService,
+ mockReleaseService,
+ mockTenantService,
+ mockStatusService,
+ mockAppStorage,
+ mockConfigLoader,
+ mockReauthRef,
+ mockCallbacks,
+ mockScreen,
+ mockIsContentEmpty,
+ };
+});
+
+vi.mock("../../client/core/logger.js", () => ({
+ default: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), log: vi.fn() },
+}));
+vi.mock("../../client/app.scss", () => ({}));
+vi.mock("../../client/assets/fallback.png", () => ({
+ default: "fallback.png",
+}));
+vi.mock("../../client/components/screen.jsx", () => ({
+ default: ({ screen }) => (
+
{screen["@id"]}
+ ),
+}));
+vi.mock("../../client/components/screen.scss", () => ({}));
+vi.mock("../../client/service/content-service", () => ({
+ default: vi.fn(function () {
+ return mockContentService;
+ }),
+}));
+vi.mock("../../client/core/client-config-loader.js", () => ({
+ default: mockConfigLoader,
+}));
+vi.mock("../../client/core/app-storage", () => ({
+ default: mockAppStorage,
+}));
+vi.mock("../../client/util/defaults", () => ({
+ default: {
+ loginCheckTimeoutDefault: 100,
+ refreshTokenTimeoutDefault: 100,
+ releaseTimestampIntervalTimeoutDefault: 100,
+ },
+}));
+vi.mock("../../client/service/token-service", () => ({
+ default: mockTokenService,
+}));
+vi.mock("../../client/service/release-service", () => ({
+ default: mockReleaseService,
+}));
+vi.mock("../../client/service/tenant-service", () => ({
+ default: mockTenantService,
+}));
+vi.mock("../../client/service/status-service", () => ({
+ default: mockStatusService,
+}));
+vi.mock("../../client/util/constants", () => {
+ const c = {
+ LOGIN_STATUS_READY: "ready",
+ LOGIN_STATUS_AWAITING_BIND_KEY: "awaitingBindKey",
+ STATUS_RUNNING: "running",
+ STATUS_LOGIN: "login",
+ ERROR_TOKEN_REFRESH_FAILED: "ER101",
+ SLIDE_ERROR_RECOVERY_TIMEOUT: 5000,
+ SLIDE_TRANSITION_TIMEOUT: 1000,
+ COLOR_SCHEME_REFRESH_INTERVAL: 300000,
+ };
+ return { default: c };
+});
+vi.mock("../../client/redux/reauthenticate-ref", () => ({
+ default: mockReauthRef,
+}));
+vi.mock("../../client/client-state-context.jsx", () => ({
+ useClientState: () => ({
+ screen: mockScreen.value,
+ isContentEmpty: mockIsContentEmpty.value,
+ callbacks: mockCallbacks,
+ }),
+}));
+
+import App from "../../client/app.jsx";
+import ContentService from "../../client/service/content-service";
+
+describe("App", () => {
+ beforeEach(() => {
+ vi.useFakeTimers();
+ vi.clearAllMocks();
+
+ // Reset state
+ mockScreen.value = null;
+ mockIsContentEmpty.value = false;
+ mockStatusService.error = null;
+
+ mockAppStorage.getToken.mockReturnValue(null);
+ mockAppStorage.getScreenId.mockReturnValue(null);
+ mockAppStorage.getFallbackImageUrl.mockReturnValue(null);
+ mockTokenService.checkLogin.mockResolvedValue({ status: "ready", screenId: "S1" });
+ mockTokenService.refreshToken.mockResolvedValue();
+ mockReleaseService.checkForNewRelease.mockResolvedValue();
+ mockConfigLoader.loadConfig.mockResolvedValue({ debug: false });
+ });
+
+ afterEach(() => {
+ cleanup();
+ vi.useRealTimers();
+ });
+
+ describe("preview mode", () => {
+ it("should start content with previewId for screen preview", async () => {
+ await act(async () => {
+ render(
);
+ });
+
+ expect(ContentService).toHaveBeenCalled();
+ expect(mockContentService.start).toHaveBeenCalled();
+ expect(mockContentService.startSyncing).toHaveBeenCalledWith(
+ "/v2/screens/SCREEN_ABC",
+ );
+ });
+
+ it("should call startPreview for non-screen preview", async () => {
+ await act(async () => {
+ render(
);
+ });
+
+ expect(ContentService).toHaveBeenCalled();
+ expect(mockContentService.start).toHaveBeenCalled();
+ expect(mockContentService.startPreview).toHaveBeenCalledWith(
+ "playlist",
+ "PL_123",
+ );
+ });
+
+ it("should not add keyboard listener in preview mode", async () => {
+ const addSpy = vi.spyOn(document, "addEventListener");
+
+ await act(async () => {
+ render(
);
+ });
+
+ expect(addSpy).not.toHaveBeenCalledWith(
+ "keydown",
+ expect.any(Function),
+ );
+ });
+ });
+
+ describe("normal mode - login flow", () => {
+ it("should use fast path when token and screenId exist in storage", async () => {
+ mockAppStorage.getToken.mockReturnValue("jwt-token");
+ mockAppStorage.getScreenId.mockReturnValue("SCREEN_FAST");
+
+ await act(async () => {
+ render(
);
+ await vi.advanceTimersByTimeAsync(0);
+ });
+
+ expect(mockContentService.startSyncing).toHaveBeenCalledWith(
+ "/v2/screens/SCREEN_FAST",
+ );
+ expect(mockTokenService.checkLogin).not.toHaveBeenCalled();
+ });
+
+ it("should display bindKey when login status is awaitingBindKey", async () => {
+ mockTokenService.checkLogin.mockResolvedValue({
+ status: "awaitingBindKey",
+ bindKey: "ABC-123",
+ });
+
+ let result;
+ await act(async () => {
+ result = render(
);
+ await vi.advanceTimersByTimeAsync(0);
+ });
+
+ expect(result.container.querySelector(".bind-key").textContent).toBe(
+ "ABC-123",
+ );
+ });
+
+ it("should retry login on checkLogin failure", async () => {
+ mockTokenService.checkLogin.mockRejectedValueOnce(new Error("fail"));
+
+ await act(async () => {
+ render(
);
+ await vi.advanceTimersByTimeAsync(0);
+ });
+
+ // restartLoginTimeout is called, which loads config then sets timeout
+ expect(mockConfigLoader.loadConfig).toHaveBeenCalled();
+ });
+ });
+
+ describe("reauthenticateHandler", () => {
+ async function mountAndGetReauthHandler() {
+ await act(async () => {
+ render(
);
+ await vi.advanceTimersByTimeAsync(0);
+ });
+ // reauthenticateRef.current is set during mount effect
+ return mockReauthRef.current;
+ }
+
+ it("should attempt token refresh on reauthenticate", async () => {
+ const handler = await mountAndGetReauthHandler();
+
+ await act(async () => {
+ handler();
+ await vi.advanceTimersByTimeAsync(0);
+ });
+
+ expect(mockTokenService.refreshToken).toHaveBeenCalled();
+ });
+
+ it("should clean up and restart login on refresh failure", async () => {
+ mockTokenService.refreshToken.mockRejectedValue(new Error("expired"));
+ const handler = await mountAndGetReauthHandler();
+
+ await act(async () => {
+ handler();
+ await vi.advanceTimersByTimeAsync(0);
+ });
+
+ expect(mockStatusService.setError).toHaveBeenCalledWith("ER101");
+ expect(mockAppStorage.clearToken).toHaveBeenCalled();
+ expect(mockAppStorage.clearRefreshToken).toHaveBeenCalled();
+ expect(mockAppStorage.clearScreenId).toHaveBeenCalled();
+ expect(mockAppStorage.clearTenant).toHaveBeenCalled();
+ expect(mockAppStorage.clearFallbackImageUrl).toHaveBeenCalled();
+ expect(mockCallbacks.current.setScreen).toHaveBeenCalledWith(null);
+ expect(mockTokenService.stopRefreshing).toHaveBeenCalled();
+ });
+
+ it("should guard against concurrent reauthentication", async () => {
+ // Make refreshToken hang (never resolve) so the first call stays in-flight.
+ mockTokenService.refreshToken.mockReturnValue(new Promise(() => {}));
+ const handler = await mountAndGetReauthHandler();
+
+ await act(async () => {
+ handler(); // first call
+ handler(); // second call while first is in-flight
+ });
+
+ expect(mockTokenService.refreshToken).toHaveBeenCalledTimes(1);
+ });
+ });
+
+ describe("keyboard handler", () => {
+ it("should clear storage and reload on Ctrl+I", async () => {
+ const reloadMock = vi.fn();
+ vi.stubGlobal("location", {
+ ...window.location,
+ href: "http://localhost/",
+ reload: reloadMock,
+ });
+
+ await act(async () => {
+ render(
);
+ await vi.advanceTimersByTimeAsync(0);
+ });
+
+ fireEvent.keyDown(document, { code: "KeyI", ctrlKey: true, repeat: false });
+
+ expect(mockAppStorage.clearAppStorage).toHaveBeenCalled();
+ expect(reloadMock).toHaveBeenCalled();
+
+ vi.unstubAllGlobals();
+ });
+
+ it("should not trigger on repeated keydown events", async () => {
+ const reloadMock = vi.fn();
+ vi.stubGlobal("location", {
+ ...window.location,
+ href: "http://localhost/",
+ reload: reloadMock,
+ });
+
+ await act(async () => {
+ render(
);
+ await vi.advanceTimersByTimeAsync(0);
+ });
+
+ fireEvent.keyDown(document, { code: "KeyI", ctrlKey: true, repeat: true });
+
+ expect(reloadMock).not.toHaveBeenCalled();
+
+ vi.unstubAllGlobals();
+ });
+ });
+
+ describe("cleanup on unmount", () => {
+ it("should stop content service and remove listeners on unmount", async () => {
+ // Start content first so contentServiceRef is populated.
+ mockAppStorage.getToken.mockReturnValue("jwt");
+ mockAppStorage.getScreenId.mockReturnValue("SCR1");
+ const removeSpy = vi.spyOn(document, "removeEventListener");
+
+ let unmountFn;
+ await act(async () => {
+ const { unmount } = render(
);
+ unmountFn = unmount;
+ await vi.advanceTimersByTimeAsync(0);
+ });
+
+ unmountFn();
+
+ expect(mockContentService.stopSync).toHaveBeenCalled();
+ expect(mockContentService.stop).toHaveBeenCalled();
+ expect(removeSpy).toHaveBeenCalledWith("keydown", expect.any(Function));
+ expect(mockTokenService.stopRefreshing).toHaveBeenCalled();
+ expect(mockReleaseService.stopReleaseCheck).toHaveBeenCalled();
+ });
+ });
+});
diff --git a/assets/tests/client/base-query.test.js b/assets/tests/client/base-query.test.js
new file mode 100644
index 000000000..5084c7002
--- /dev/null
+++ b/assets/tests/client/base-query.test.js
@@ -0,0 +1,149 @@
+import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
+
+// --- Hoisted mocks ---
+const { mockRawBaseQuery, mockReauthRef } = vi.hoisted(() => {
+ const mockRawBaseQuery = vi.fn().mockResolvedValue({ data: "ok" });
+ const mockReauthRef = { current: vi.fn() };
+ return { mockRawBaseQuery, mockReauthRef };
+});
+
+vi.mock("@reduxjs/toolkit/query/react", () => ({
+ fetchBaseQuery: () => mockRawBaseQuery,
+}));
+
+vi.mock("../../client/redux/reauthenticate-ref", () => ({
+ default: mockReauthRef,
+}));
+
+import clientBaseQuery from "../../client/redux/base-query";
+
+describe("clientBaseQuery", () => {
+ const api = {};
+ const extraOptions = {};
+
+ beforeEach(() => {
+ vi.clearAllMocks();
+ localStorage.clear();
+ mockRawBaseQuery.mockResolvedValue({ data: "ok" });
+ // Default: no preview params.
+ vi.stubGlobal("location", { href: "http://localhost/" });
+ });
+
+ afterEach(() => {
+ vi.unstubAllGlobals();
+ });
+
+ describe("headers", () => {
+ it("should set accept to application/ld+json when no accept header", async () => {
+ await clientBaseQuery({ url: "/test" }, api, extraOptions);
+
+ const passedArgs = mockRawBaseQuery.mock.calls[0][0];
+ expect(passedArgs.headers.accept).toBe("application/ld+json");
+ });
+
+ it("should preserve existing accept header", async () => {
+ await clientBaseQuery(
+ { url: "/test", headers: { accept: "text/html" } },
+ api,
+ extraOptions,
+ );
+
+ const passedArgs = mockRawBaseQuery.mock.calls[0][0];
+ expect(passedArgs.headers.accept).toBe("text/html");
+ });
+
+ it("should initialize headers object when args has none", async () => {
+ await clientBaseQuery({ url: "/test" }, api, extraOptions);
+
+ const passedArgs = mockRawBaseQuery.mock.calls[0][0];
+ expect(passedArgs.headers).toBeDefined();
+ expect(passedArgs.headers.accept).toBe("application/ld+json");
+ });
+ });
+
+ describe("authorization", () => {
+ it("should use preview-token from URL over localStorage token", async () => {
+ vi.stubGlobal("location", {
+ href: "http://localhost/?preview-token=preview-jwt",
+ });
+ localStorage.setItem("apiToken", "stored-jwt");
+
+ await clientBaseQuery({ url: "/test" }, api, extraOptions);
+
+ const passedArgs = mockRawBaseQuery.mock.calls[0][0];
+ expect(passedArgs.headers.authorization).toBe("Bearer preview-jwt");
+ });
+
+ it("should use localStorage apiToken when no preview-token", async () => {
+ localStorage.setItem("apiToken", "stored-jwt");
+
+ await clientBaseQuery({ url: "/test" }, api, extraOptions);
+
+ const passedArgs = mockRawBaseQuery.mock.calls[0][0];
+ expect(passedArgs.headers.authorization).toBe("Bearer stored-jwt");
+ });
+
+ it("should not set authorization when neither exists", async () => {
+ await clientBaseQuery({ url: "/test" }, api, extraOptions);
+
+ const passedArgs = mockRawBaseQuery.mock.calls[0][0];
+ expect(passedArgs.headers.authorization).toBeUndefined();
+ });
+ });
+
+ describe("tenant key", () => {
+ it("should use preview-tenant from URL over localStorage tenantKey", async () => {
+ vi.stubGlobal("location", {
+ href: "http://localhost/?preview-tenant=preview-tenant-key",
+ });
+ localStorage.setItem("tenantKey", "stored-tenant");
+
+ await clientBaseQuery({ url: "/test" }, api, extraOptions);
+
+ const passedArgs = mockRawBaseQuery.mock.calls[0][0];
+ expect(passedArgs.headers["Authorization-Tenant-Key"]).toBe(
+ "preview-tenant-key",
+ );
+ });
+
+ it("should use localStorage tenantKey when no preview-tenant", async () => {
+ localStorage.setItem("tenantKey", "stored-tenant");
+
+ await clientBaseQuery({ url: "/test" }, api, extraOptions);
+
+ const passedArgs = mockRawBaseQuery.mock.calls[0][0];
+ expect(passedArgs.headers["Authorization-Tenant-Key"]).toBe(
+ "stored-tenant",
+ );
+ });
+
+ it("should not set tenant header when neither exists", async () => {
+ await clientBaseQuery({ url: "/test" }, api, extraOptions);
+
+ const passedArgs = mockRawBaseQuery.mock.calls[0][0];
+ expect(
+ passedArgs.headers["Authorization-Tenant-Key"],
+ ).toBeUndefined();
+ });
+ });
+
+ describe("401 handling", () => {
+ it("should call reauthenticateRef.current on 401 error", async () => {
+ mockRawBaseQuery.mockResolvedValue({ error: { status: 401 } });
+
+ await clientBaseQuery({ url: "/test" }, api, extraOptions);
+
+ expect(mockReauthRef.current).toHaveBeenCalled();
+ });
+
+ it("should not call reauthenticateRef on non-401 errors or success", async () => {
+ mockRawBaseQuery.mockResolvedValue({ error: { status: 500 } });
+ await clientBaseQuery({ url: "/test" }, api, extraOptions);
+
+ mockRawBaseQuery.mockResolvedValue({ data: "ok" });
+ await clientBaseQuery({ url: "/test" }, api, extraOptions);
+
+ expect(mockReauthRef.current).not.toHaveBeenCalled();
+ });
+ });
+});
diff --git a/assets/tests/client/data-sync.test.js b/assets/tests/client/data-sync.test.js
index 060929a68..b2cc003c8 100644
--- a/assets/tests/client/data-sync.test.js
+++ b/assets/tests/client/data-sync.test.js
@@ -43,4 +43,23 @@ describe("DataSync", () => {
expect(mockStop).toHaveBeenCalled();
});
+
+ it("stores config on the instance", () => {
+ const config = { entryPoint: "/v2/screens/ABC", onContent: vi.fn() };
+ const sync = new DataSync(config);
+
+ expect(sync.config).toBe(config);
+ });
+
+ it("binds start and stop so they work when destructured", () => {
+ const config = { entryPoint: "/v2/screens/ABC", onContent: vi.fn() };
+ const sync = new DataSync(config);
+ const { start, stop } = sync;
+
+ start();
+ stop();
+
+ expect(mockStart).toHaveBeenCalled();
+ expect(mockStop).toHaveBeenCalled();
+ });
});
diff --git a/assets/tests/client/error-boundary.test.jsx b/assets/tests/client/error-boundary.test.jsx
index fbc81ba82..f7804ab6f 100644
--- a/assets/tests/client/error-boundary.test.jsx
+++ b/assets/tests/client/error-boundary.test.jsx
@@ -1,5 +1,5 @@
-import { describe, it, expect, vi, beforeEach } from "vitest";
-import { render, screen } from "@testing-library/react";
+import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
+import { render, screen, cleanup } from "@testing-library/react";
import ErrorBoundary from "../../client/components/error-boundary.jsx";
vi.mock("../../client/core/logger.js", () => ({
@@ -22,6 +22,10 @@ describe("ErrorBoundary", () => {
consoleError = vi.spyOn(console, "error").mockImplementation(() => {});
});
+ afterEach(() => {
+ cleanup();
+ });
+
it("renders children when no error", () => {
render(
@@ -60,4 +64,56 @@ describe("ErrorBoundary", () => {
);
expect(screen.getByText(/No handler/)).toBeInTheDocument();
});
+
+ it("recovers from error state when resetKey changes", () => {
+ const { rerender } = render(
+
+
+
+ );
+ expect(screen.getByText(/boom/)).toBeInTheDocument();
+
+ rerender(
+
+ OK
+
+ );
+ expect(screen.getByTestId("recovered")).toBeInTheDocument();
+ });
+
+ it("does not recover when resetKey stays the same", () => {
+ const { rerender } = render(
+
+
+
+ );
+ expect(screen.getByText(/boom/)).toBeInTheDocument();
+
+ rerender(
+
+ OK
+
+ );
+ expect(screen.queryByTestId("child")).not.toBeInTheDocument();
+ expect(screen.getByText(/boom/)).toBeInTheDocument();
+ });
+
+ it("calls errorHandler for each error in a sequence with different resetKeys", () => {
+ const errorHandler = vi.fn();
+ const { rerender } = render(
+
+
+
+ );
+ expect(errorHandler).toHaveBeenCalledTimes(1);
+ expect(errorHandler.mock.calls[0][0].message).toBe("error 1");
+
+ rerender(
+
+
+
+ );
+ expect(errorHandler).toHaveBeenCalledTimes(2);
+ expect(errorHandler.mock.calls[1][0].message).toBe("error 2");
+ });
});
diff --git a/assets/tests/client/screen.test.jsx b/assets/tests/client/screen.test.jsx
index 8d0996dac..bea2eefcd 100644
--- a/assets/tests/client/screen.test.jsx
+++ b/assets/tests/client/screen.test.jsx
@@ -1,5 +1,5 @@
-import { describe, it, expect, vi } from "vitest";
-import { render } from "@testing-library/react";
+import { describe, it, expect, vi, afterEach } from "vitest";
+import { render, cleanup, act } from "@testing-library/react";
import Screen from "../../client/components/screen.jsx";
vi.mock("../../client/core/logger.js", () => ({
@@ -121,12 +121,45 @@ describe("Screen", () => {
expect(container.querySelector(".touch-region")).toBeInTheDocument();
});
- it("applies grid styles from layout data", () => {
+ it("applies correct grid template values from layout data", () => {
const screen = makeScreen([], { rows: 2, columns: 3 });
const { container } = render();
const el = container.querySelector(".screen");
- // gridTemplateColumns and gridTemplateRows are set
- expect(el.style.gridTemplateColumns).toBeTruthy();
- expect(el.style.gridTemplateRows).toBeTruthy();
+ // jsdom trims trailing whitespace from CSS values.
+ expect(el.style.gridTemplateColumns).toBe("1fr 1fr 1fr");
+ expect(el.style.gridTemplateRows).toBe("1fr 1fr");
+ });
+
+ it("renders screen div with no child regions when regions array is empty", () => {
+ const screen = makeScreen([], { rows: 1, columns: 1 });
+ const { container } = render();
+ expect(container.querySelector(".screen")).toBeInTheDocument();
+ expect(container.querySelector(".region")).not.toBeInTheDocument();
+ expect(container.querySelector(".touch-region")).not.toBeInTheDocument();
+ });
+
+ it("removes color scheme classes from documentElement on unmount", async () => {
+ const screen = makeScreen();
+ screen.enableColorSchemeChange = true;
+
+ // Mock matchMedia for browser-based color scheme.
+ window.matchMedia = vi.fn().mockReturnValue({ matches: true });
+
+ let unmountFn;
+ await act(async () => {
+ const { unmount } = render();
+ unmountFn = unmount;
+ });
+
+ // After config loads, color scheme class should be set.
+ expect(
+ document.documentElement.classList.contains("color-scheme-dark") ||
+ document.documentElement.classList.contains("color-scheme-light"),
+ ).toBe(true);
+
+ unmountFn();
+
+ expect(document.documentElement.classList.contains("color-scheme-dark")).toBe(false);
+ expect(document.documentElement.classList.contains("color-scheme-light")).toBe(false);
});
});
diff --git a/assets/tests/client/slide.test.jsx b/assets/tests/client/slide.test.jsx
index a54d5eb14..6f56e81c2 100644
--- a/assets/tests/client/slide.test.jsx
+++ b/assets/tests/client/slide.test.jsx
@@ -106,4 +106,70 @@ describe("Slide", () => {
expect(slideError).toHaveBeenCalledWith(slide);
});
+
+ it("renders without crashing when slide has no executionId", () => {
+ const slideNoExecId = { "@id": "/v2/slides/TEST01234567890123456789", title: "No exec" };
+ const { container } = render(
+
+ );
+
+ const el = container.querySelector("#slide-no-exec");
+ expect(el).toBeInTheDocument();
+ expect(el.getAttribute("data-execution-id")).toBeNull();
+ });
+
+ it("does not call slideError if component unmounts before error timeout fires", () => {
+ vi.spyOn(console, "error").mockImplementation(() => {});
+
+ function ThrowingTemplate() {
+ throw new Error("template crash");
+ }
+ mockRenderSlide.mockReturnValue();
+
+ const slideError = vi.fn();
+ const { unmount } = render(
+
+ );
+
+ unmount();
+ vi.advanceTimersByTime(5000);
+
+ // Note: The current implementation does NOT clean up the timeout on unmount,
+ // so slideError will still fire. This test documents that behavior.
+ // If this assertion passes, it means the timeout was cleaned up (ideal).
+ // If it fails, it reveals a latent issue worth fixing.
+ // expect(slideError).not.toHaveBeenCalled();
+ // For now, just verify it was called (documenting current behavior):
+ expect(slideError).toHaveBeenCalledWith(slide);
+ });
+
+ it("attaches forwardRef to the slide div", () => {
+ const ref = { current: null };
+ render(
+
+ );
+
+ expect(ref.current).not.toBeNull();
+ expect(ref.current.id).toBe("slide-ref");
+ expect(ref.current.classList.contains("slide")).toBe(true);
+ });
});
diff --git a/assets/tests/client/touch-region.test.jsx b/assets/tests/client/touch-region.test.jsx
index 6f0b64dc0..4831c7a63 100644
--- a/assets/tests/client/touch-region.test.jsx
+++ b/assets/tests/client/touch-region.test.jsx
@@ -149,4 +149,45 @@ describe("TouchRegion", () => {
).not.toBeInTheDocument();
});
+ it("renders no buttons when regionSlides has empty array", () => {
+ mockRegionSlides = { TOUCH01: [] };
+ const { container } = render();
+
+ const buttons = within(container).queryAllByRole("button");
+ expect(buttons).toHaveLength(0);
+ });
+
+ it("filters out invalid slides from buttons", () => {
+ mockRegionSlides = {
+ TOUCH01: [
+ { executionId: "EXE-VALID", title: "Valid Slide" },
+ { executionId: "EXE-INVALID", title: "Invalid Slide", invalid: true },
+ ],
+ };
+ const { container } = render();
+
+ expect(within(container).getByText("Valid Slide")).toBeInTheDocument();
+ expect(within(container).queryByText("Invalid Slide")).not.toBeInTheDocument();
+ });
+
+ it("opens slide when Enter is pressed on a button", () => {
+ const { container } = renderWithSlides();
+
+ act(() => {
+ fireEvent.keyDown(within(container).getByText("Slide 1"), { key: "Enter" });
+ });
+
+ expect(within(container).getByTestId("slide-EXE-1")).toBeInTheDocument();
+ });
+
+ it("opens slide when Space is pressed on a button", () => {
+ const { container } = renderWithSlides();
+
+ act(() => {
+ fireEvent.keyDown(within(container).getByText("Slide 1"), { key: " " });
+ });
+
+ expect(within(container).getByTestId("slide-EXE-1")).toBeInTheDocument();
+ });
+
});
From 2e4f9a349f0da7715db13ff9d342e4f078011e13 Mon Sep 17 00:00:00 2001
From: Troels Ugilt Jensen <6103205+tuj@users.noreply.github.com>
Date: Wed, 22 Apr 2026 06:20:29 +0200
Subject: [PATCH 71/73] 7228: Added documentation
---
assets/tests/client/api-query.test.js | 12 ++++++++++++
assets/tests/client/app.test.jsx | 19 ++++++++++++++++++-
assets/tests/client/slide.test.jsx | 11 ++++-------
3 files changed, 34 insertions(+), 8 deletions(-)
diff --git a/assets/tests/client/api-query.test.js b/assets/tests/client/api-query.test.js
index 3748f2785..a89e6cf1c 100644
--- a/assets/tests/client/api-query.test.js
+++ b/assets/tests/client/api-query.test.js
@@ -1,6 +1,18 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
// --- Hoisted mocks ---
+//
+// RTK Query's dispatch(endpoint.initiate(args)) returns a "request" object
+// with { unwrap(), abort(), unsubscribe() }. We simulate that here so the
+// source's Promise.race / cleanup logic can be tested in isolation.
+//
+// dispatchDefaults.unwrapResult controls what unwrap() resolves/rejects with.
+// Set it in each test before calling query(). Tests that need per-call control
+// (e.g. pagination) override mockDispatch.mockImplementation() directly.
+//
+// mockSelect simulates clientApi.endpoints[name].select(args) which returns
+// a selector function (state => cacheEntry). The double-arrow mirrors the real
+// RTK Query API: select(args) returns (state) => ({ data, ... }).
const { mockDispatch, endpoints, mockSelect, dispatchDefaults } = vi.hoisted(() => {
const mockSelect = vi.fn(() => () => undefined);
const mockAbort = vi.fn();
diff --git a/assets/tests/client/app.test.jsx b/assets/tests/client/app.test.jsx
index b5dc77f9c..d98d295ec 100644
--- a/assets/tests/client/app.test.jsx
+++ b/assets/tests/client/app.test.jsx
@@ -2,6 +2,13 @@ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { render, cleanup, act, fireEvent } from "@testing-library/react";
// --- Hoisted mocks ---
+//
+// App.jsx orchestrates 12+ services/modules. Each is mocked below so we can
+// verify wiring without running real API calls or timers.
+//
+// mockScreen / mockIsContentEmpty use a { value } wrapper so tests can mutate
+// the value returned by the useClientState mock (plain primitives can't be
+// reassigned from outside the hoisted closure).
const {
mockContentService,
mockTokenService,
@@ -15,6 +22,7 @@ const {
mockScreen,
mockIsContentEmpty,
} = vi.hoisted(() => {
+ // --- Service mocks ---
const mockContentService = {
start: vi.fn(),
stop: vi.fn(),
@@ -43,6 +51,8 @@ const {
setStatusInUrl: vi.fn(),
error: null,
};
+
+ // --- Storage / config mocks ---
const mockAppStorage = {
getToken: vi.fn().mockReturnValue(null),
getScreenId: vi.fn().mockReturnValue(null),
@@ -58,6 +68,8 @@ const {
const mockConfigLoader = {
loadConfig: vi.fn().mockResolvedValue({ debug: false }),
};
+
+ // --- React bridge mocks ---
const mockReauthRef = { current: vi.fn() };
const mockCallbacks = {
current: {
@@ -69,6 +81,8 @@ const {
onReauthenticate: vi.fn(),
},
};
+
+ // Mutable wrappers so tests can change the value returned by useClientState.
const mockScreen = { value: null };
const mockIsContentEmpty = { value: false };
@@ -228,6 +242,7 @@ describe("App", () => {
await act(async () => {
render();
+ // Flush the promise chain: releaseService.checkForNewRelease().finally(checkLogin).
await vi.advanceTimersByTimeAsync(0);
});
@@ -268,12 +283,14 @@ describe("App", () => {
});
describe("reauthenticateHandler", () => {
+ // Mount the App in normal mode, which wires reauthenticateRef.current to
+ // the internal reauthenticateHandler. Returns that handler so tests can
+ // invoke it directly (simulating a 401 from base-query).
async function mountAndGetReauthHandler() {
await act(async () => {
render();
await vi.advanceTimersByTimeAsync(0);
});
- // reauthenticateRef.current is set during mount effect
return mockReauthRef.current;
}
diff --git a/assets/tests/client/slide.test.jsx b/assets/tests/client/slide.test.jsx
index 6f56e81c2..c5c6cc754 100644
--- a/assets/tests/client/slide.test.jsx
+++ b/assets/tests/client/slide.test.jsx
@@ -124,7 +124,7 @@ describe("Slide", () => {
expect(el.getAttribute("data-execution-id")).toBeNull();
});
- it("does not call slideError if component unmounts before error timeout fires", () => {
+ it("still fires slideError after unmount because the timeout is not cleaned up", () => {
vi.spyOn(console, "error").mockImplementation(() => {});
function ThrowingTemplate() {
@@ -146,12 +146,9 @@ describe("Slide", () => {
unmount();
vi.advanceTimersByTime(5000);
- // Note: The current implementation does NOT clean up the timeout on unmount,
- // so slideError will still fire. This test documents that behavior.
- // If this assertion passes, it means the timeout was cleaned up (ideal).
- // If it fails, it reveals a latent issue worth fixing.
- // expect(slideError).not.toHaveBeenCalled();
- // For now, just verify it was called (documenting current behavior):
+ // The error handler's setTimeout is never cleared on unmount, so
+ // slideError fires even after the component is gone. This documents
+ // current behavior — cleaning up the timeout would be an improvement.
expect(slideError).toHaveBeenCalledWith(slide);
});
From 98ac9149146a08b98906c3cd5e494d00ebd4c731 Mon Sep 17 00:00:00 2001
From: Troels Ugilt Jensen <6103205+tuj@users.noreply.github.com>
Date: Wed, 22 Apr 2026 06:23:20 +0200
Subject: [PATCH 72/73] 7228: Fixed slide error timeout cleanup
---
assets/client/components/slide.jsx | 13 ++++++++++++-
assets/tests/client/slide.test.jsx | 7 ++-----
2 files changed, 14 insertions(+), 6 deletions(-)
diff --git a/assets/client/components/slide.jsx b/assets/client/components/slide.jsx
index 5f767fc9f..ecd7b3db6 100644
--- a/assets/client/components/slide.jsx
+++ b/assets/client/components/slide.jsx
@@ -1,3 +1,4 @@
+import { useEffect, useRef } from "react";
import ErrorBoundary from "./error-boundary.jsx";
import logger from "../core/logger.js";
import { renderSlide } from "../../shared/slide-utils/templates.js";
@@ -16,6 +17,16 @@ import "./slide.scss";
* @returns {object} - The component.
*/
function Slide({ slide, id, run, slideDone, slideError, forwardRef }) {
+ const errorTimeoutRef = useRef(null);
+
+ useEffect(() => {
+ return () => {
+ if (errorTimeoutRef.current !== null) {
+ clearTimeout(errorTimeoutRef.current);
+ }
+ };
+ }, []);
+
/**
* Handle errors in ErrorBoundary.
*
@@ -24,7 +35,7 @@ function Slide({ slide, id, run, slideDone, slideError, forwardRef }) {
const handleError = () => {
logger.warn("Slide error boundary triggered.");
- setTimeout(() => {
+ errorTimeoutRef.current = setTimeout(() => {
slideError(slide);
}, constants.SLIDE_ERROR_RECOVERY_TIMEOUT);
};
diff --git a/assets/tests/client/slide.test.jsx b/assets/tests/client/slide.test.jsx
index c5c6cc754..aee7406e1 100644
--- a/assets/tests/client/slide.test.jsx
+++ b/assets/tests/client/slide.test.jsx
@@ -124,7 +124,7 @@ describe("Slide", () => {
expect(el.getAttribute("data-execution-id")).toBeNull();
});
- it("still fires slideError after unmount because the timeout is not cleaned up", () => {
+ it("does not call slideError if component unmounts before error timeout fires", () => {
vi.spyOn(console, "error").mockImplementation(() => {});
function ThrowingTemplate() {
@@ -146,10 +146,7 @@ describe("Slide", () => {
unmount();
vi.advanceTimersByTime(5000);
- // The error handler's setTimeout is never cleared on unmount, so
- // slideError fires even after the component is gone. This documents
- // current behavior — cleaning up the timeout would be an improvement.
- expect(slideError).toHaveBeenCalledWith(slide);
+ expect(slideError).not.toHaveBeenCalled();
});
it("attaches forwardRef to the slide div", () => {
From a9838c0b13d7001e679145d95f3bd03770352541 Mon Sep 17 00:00:00 2001
From: Troels Ugilt Jensen <6103205+tuj@users.noreply.github.com>
Date: Fri, 24 Apr 2026 07:36:40 +0200
Subject: [PATCH 73/73] 7228: Cleanup
---
broken1.md | 17 -----------------
broken2.md | 24 ------------------------
2 files changed, 41 deletions(-)
delete mode 100644 broken1.md
delete mode 100644 broken2.md
diff --git a/broken1.md b/broken1.md
deleted file mode 100644
index 174c732d0..000000000
--- a/broken1.md
+++ /dev/null
@@ -1,17 +0,0 @@
-# Fix: Missing `currentSlide` dependency in region.jsx useEffect
-
-## Context
-In `region.jsx`, the "Make sure current slide is set" effect (line 137) reads `currentSlide` but only lists `[slides]` as a dependency. If `currentSlide` becomes `null` via `slideDone` while `slides` hasn't changed, the effect won't re-fire and the region gets stuck with no visible slide.
-
-## Change
-**File:** `assets/client/components/region.jsx:155`
-
-Change dependency array from `[slides]` to `[slides, currentSlide]`.
-
-This is safe because:
-- The `setCurrentSlide` branch is guarded by `!currentSlide`, so it can't loop
-- The `setNodeRefs` call is idempotent — rebuilds the same ref map from `slides`
-
-## Verification
-- Confirm no render loop by checking that `currentSlide` being set doesn't re-trigger the `!currentSlide` branch
-- Run `task test:frontend-built` if available
diff --git a/broken2.md b/broken2.md
deleted file mode 100644
index c69b2e07d..000000000
--- a/broken2.md
+++ /dev/null
@@ -1,24 +0,0 @@
-# Fix: `newSlides` not cleared when consumed by the "no current slide" path
-
-## Context
-In `region.jsx`, the effect at line 130–134 consumes `newSlides` by calling `setSlides(newSlides)` when `currentSlide` is null, but never clears `newSlides` afterwards. This means `newSlides` stays non-null. Later, when `slideDone` wraps at `nextIndex === 0`, it checks `Array.isArray(latestNewSlides)` — which is still true — and re-applies the same stale slides unnecessarily, causing a redundant re-render cycle.
-
-## Change
-**File:** `assets/client/components/region.jsx:132`
-
-Add `setNewSlides(null)` after `setSlides(newSlides)`:
-
-```js
-useEffect(() => {
- if (newSlides !== null && !currentSlide) {
- setSlides(newSlides);
- setNewSlides(null);
- }
-}, [newSlides, currentSlide]);
-```
-
-This mirrors the same pattern used in `slideDone` (line 81–82) where `setSlides` and `setNewSlides(null)` are always paired.
-
-## Verification
-- Run `task test:unit`
-- The existing "wraps around to first slide after last" test exercises the `slideDone` wrap path and should still pass