From fa4b9cd64912db28112a7e8c7144071367a57a1e Mon Sep 17 00:00:00 2001 From: Troels Ugilt Jensen <6103205+tuj@users.noreply.github.com> Date: Sun, 12 Apr 2026 12:11:13 +0200 Subject: [PATCH 01/73] 6871: Started cleanup --- assets/client/app.css | 3 --- assets/client/app.js | 3 --- assets/client/index.js | 12 ------------ assets/client/service/content-service.js | 1 + assets/client/service/schedule-service.js | 2 +- 5 files changed, 2 insertions(+), 19 deletions(-) delete mode 100644 assets/client/app.css delete mode 100644 assets/client/app.js delete mode 100644 assets/client/index.js diff --git a/assets/client/app.css b/assets/client/app.css deleted file mode 100644 index 837191701..000000000 --- a/assets/client/app.css +++ /dev/null @@ -1,3 +0,0 @@ -body { - background-color: blue; -} diff --git a/assets/client/app.js b/assets/client/app.js deleted file mode 100644 index 620d7555d..000000000 --- a/assets/client/app.js +++ /dev/null @@ -1,3 +0,0 @@ -import "./app.css"; - -console.log("Hello client"); diff --git a/assets/client/index.js b/assets/client/index.js deleted file mode 100644 index 121022c65..000000000 --- a/assets/client/index.js +++ /dev/null @@ -1,12 +0,0 @@ -import React from "react"; -import { createRoot } from "react-dom/client"; -import App from "./app"; - -const url = new URL(window.location.href); -const preview = url.searchParams.get("preview"); -const previewId = url.searchParams.get("preview-id"); - -const container = document.getElementById("root"); -const root = createRoot(container); - -root.render(); diff --git a/assets/client/service/content-service.js b/assets/client/service/content-service.js index 512f46e6a..c2032177f 100644 --- a/assets/client/service/content-service.js +++ b/assets/client/service/content-service.js @@ -37,6 +37,7 @@ class ContentService { this.regionReadyHandler = this.regionReadyHandler.bind(this); this.regionRemovedHandler = this.regionRemovedHandler.bind(this); this.contentHandler = this.contentHandler.bind(this); + this.startPreview = this.startPreview.bind(this); this.start = this.start.bind(this); } diff --git a/assets/client/service/schedule-service.js b/assets/client/service/schedule-service.js index 23b56757e..ecc748674 100644 --- a/assets/client/service/schedule-service.js +++ b/assets/client/service/schedule-service.js @@ -138,7 +138,7 @@ class ScheduleService { // Update region. this.regions[regionId].hash = hash; - this.regions[regionId].slide = slides; + this.regions[regionId].slides = slides; if (newContent) { // Send slides to region. From 3bea5516daf39fcfd70c54884f946454683d764a Mon Sep 17 00:00:00 2001 From: Troels Ugilt Jensen <6103205+tuj@users.noreply.github.com> Date: Sun, 12 Apr 2026 13:01:43 +0200 Subject: [PATCH 02/73] 6871: Fixed issues regarding error handling --- assets/client/components/region.jsx | 9 +- assets/client/components/touch-region.jsx | 9 +- assets/client/data-sync/api-helper.js | 3 +- assets/client/data-sync/pull-strategy.js | 28 +++--- assets/client/service/content-service.js | 106 +++++++++++----------- assets/client/service/tenant-service.js | 13 ++- 6 files changed, 95 insertions(+), 73 deletions(-) diff --git a/assets/client/components/region.jsx b/assets/client/components/region.jsx index 878350ba7..fdfbb8ff2 100644 --- a/assets/client/components/region.jsx +++ b/assets/client/components/region.jsx @@ -88,10 +88,13 @@ function Region({ region }) { */ const slideError = (slideWithError) => { // Set error timestamp to force reload. - const slide = slides.find( - (slideElement) => slideElement.executionId === slideWithError.executionId, + setSlides((prev) => + prev.map((s) => + s.executionId === slideWithError.executionId + ? { ...s, errorTimestamp: new Date().getTime() } + : s, + ), ); - slide.errorTimestamp = new Date().getTime(); slideDone(slideWithError); }; diff --git a/assets/client/components/touch-region.jsx b/assets/client/components/touch-region.jsx index 52b84824a..19630a2d2 100644 --- a/assets/client/components/touch-region.jsx +++ b/assets/client/components/touch-region.jsx @@ -55,10 +55,13 @@ function TouchRegion({ region }) { */ const slideError = (slideWithError) => { // Set error timestamp to force reload. - const slide = slides.find( - (slideElement) => slideElement.executionId === slideWithError.executionId, + setSlides((prev) => + prev.map((s) => + s.executionId === slideWithError.executionId + ? { ...s, errorTimestamp: new Date().getTime() } + : s, + ), ); - slide.errorTimestamp = new Date().getTime(); slideDone(slideWithError); }; diff --git a/assets/client/data-sync/api-helper.js b/assets/client/data-sync/api-helper.js index eb94e5aef..20e400068 100644 --- a/assets/client/data-sync/api-helper.js +++ b/assets/client/data-sync/api-helper.js @@ -98,7 +98,8 @@ class ApiHelper { continueLoop = false; } } catch (err) { - return {}; + logger.error(`Failed to fetch all results from ${path}: ${err.message}`); + return { path, results, keys }; } } while (continueLoop); diff --git a/assets/client/data-sync/pull-strategy.js b/assets/client/data-sync/pull-strategy.js index 5b4ea7eed..2cff69f1e 100644 --- a/assets/client/data-sync/pull-strategy.js +++ b/assets/client/data-sync/pull-strategy.js @@ -10,7 +10,7 @@ import ClientConfigLoader from "../util/client-config-loader.js"; * Handles pull strategy. */ class PullStrategy { - lastestScreenData; + latestScreenData; // Helper for all api calls. apiHelper; @@ -210,7 +210,7 @@ class PullStrategy { const newScreenChecksums = newScreen?.relationsChecksum ?? []; const oldScreenChecksums = - this.lastestScreenData?.relationsChecksum ?? null; + this.latestScreenData?.relationsChecksum ?? null; if ( relationChecksumEnabled === false || @@ -222,7 +222,7 @@ class PullStrategy { newScreen.campaignsData = await this.getCampaignsData(newScreen); } else { logger.info(`Campaigns data loaded from cache.`); - newScreen.campaignsData = this.lastestScreenData.campaignsData; + newScreen.campaignsData = this.latestScreenData.campaignsData; } if (newScreen.campaignsData.length > 0) { @@ -268,7 +268,7 @@ class PullStrategy { // Get layout: Defines layout and regions. if ( relationChecksumEnabled === false || - this.lastestScreenData?.hasActiveCampaign || + this.latestScreenData?.hasActiveCampaign || oldScreenChecksums === null || oldScreenChecksums?.layout !== newScreenChecksums?.layout ) { @@ -277,13 +277,13 @@ class PullStrategy { } else { // Get layout: Defines layout and regions. logger.info(`Layout loaded from cache.`); - newScreen.layoutData = this.lastestScreenData.layoutData; + newScreen.layoutData = this.latestScreenData.layoutData; } // Fetch regions playlists: Yields playlists of slides for the regions if ( relationChecksumEnabled === false || - this.lastestScreenData?.hasActiveCampaign || + this.latestScreenData?.hasActiveCampaign || oldScreenChecksums === null || oldScreenChecksums?.regions !== newScreenChecksums?.regions ) { @@ -292,7 +292,7 @@ class PullStrategy { newScreen.regionData = await this.getSlidesForRegions(regions); } else { logger.info(`Regions and slides for regions loaded from cache.`); - newScreen.regionData = this.lastestScreenData.regionData; + newScreen.regionData = this.latestScreenData.regionData; } } @@ -317,13 +317,13 @@ class PullStrategy { // Find the slide in previous data for comparing relationsChecksum values. if ( - this.lastestScreenData?.regionData[regionKey] && - this.lastestScreenData.regionData[regionKey][playlistKey] && - this.lastestScreenData.regionData[regionKey][playlistKey] + this.latestScreenData?.regionData[regionKey] && + this.latestScreenData.regionData[regionKey][playlistKey] && + this.latestScreenData.regionData[regionKey][playlistKey] .slidesData[slideKey] ) { previousSlide = cloneDeep( - this.lastestScreenData.regionData[regionKey][playlistKey] + this.latestScreenData.regionData[regionKey][playlistKey] .slidesData[slideKey], ); } else { @@ -411,7 +411,7 @@ class PullStrategy { } /* eslint-enable no-restricted-syntax,no-await-in-loop */ - this.lastestScreenData = newScreen; + this.latestScreenData = newScreen; // Deliver result to rendering const event = new CustomEvent("content", { @@ -460,9 +460,11 @@ class PullStrategy { * Start the data synchronization. */ start() { + // Make sure nothing is running. + this.stop(); + // Pull now. this.getScreen(this.entryPoint).then(() => { - // Make sure nothing is running. this.stop(); // Start interval for pull periodically. diff --git a/assets/client/service/content-service.js b/assets/client/service/content-service.js index c2032177f..69ef113c2 100644 --- a/assets/client/service/content-service.js +++ b/assets/client/service/content-service.js @@ -117,9 +117,7 @@ class ContentService { const screenData = { ...this.currentScreen }; // Remove regionData to only emit screen when it has changed. - for (let i = 0; i < screenData.regions.length; i += 1) { - delete screenData.regionData; - } + delete screenData.regionData; const newHash = Base64.stringify(sha256(JSON.stringify(screenData))); @@ -213,59 +211,63 @@ class ContentService { const { mode, id } = data; logger.log("info", `Starting preview. Mode: ${mode}, ID: ${id}`); - if (mode === "screen") { - this.startSyncing(`/v2/screen/${id}`); - } else if (mode === "playlist") { - const pullStrategy = new PullStrategy({ - endpoint: "", - }); - - const playlist = await pullStrategy.getPath(`/v2/playlists/${id}`); - - const playlistSlidesResponse = await pullStrategy.getPath( - playlist.slides, - ); - - playlist.slidesData = playlistSlidesResponse["hydra:member"].map( - (playlistSlide) => playlistSlide.slide, - ); + try { + if (mode === "screen") { + this.startSyncing(`/v2/screen/${id}`); + } else if (mode === "playlist") { + const pullStrategy = new PullStrategy({ + endpoint: "", + }); + + const playlist = await pullStrategy.getPath(`/v2/playlists/${id}`); + + const playlistSlidesResponse = await pullStrategy.getPath( + playlist.slides, + ); + + playlist.slidesData = playlistSlidesResponse["hydra:member"].map( + (playlistSlide) => playlistSlide.slide, + ); + + // eslint-disable-next-line no-restricted-syntax + for (const slide of playlist.slidesData) { + // eslint-disable-next-line no-await-in-loop + await ContentService.attachReferencesToSlide(pullStrategy, slide); + } + + const screen = screenForPlaylistPreview(playlist); + + document.dispatchEvent( + new CustomEvent("content", { + detail: { + screen, + }, + }), + ); + } else if (mode === "slide") { + const pullStrategy = new PullStrategy({ + endpoint: "", + }); + + const slide = await pullStrategy.getPath(`/v2/slides/${id}`); - // eslint-disable-next-line no-restricted-syntax - for (const slide of playlist.slidesData) { // eslint-disable-next-line no-await-in-loop await ContentService.attachReferencesToSlide(pullStrategy, slide); - } - const screen = screenForPlaylistPreview(playlist); - - document.dispatchEvent( - new CustomEvent("content", { - detail: { - screen, - }, - }), - ); - } else if (mode === "slide") { - const pullStrategy = new PullStrategy({ - endpoint: "", - }); - - const slide = await pullStrategy.getPath(`/v2/slides/${id}`); - - // eslint-disable-next-line no-await-in-loop - await ContentService.attachReferencesToSlide(pullStrategy, slide); - - const screen = screenForSlidePreview(slide); - - document.dispatchEvent( - new CustomEvent("content", { - detail: { - screen, - }, - }), - ); - } else { - logger.error(`Unsupported preview mode: ${mode}.`); + const screen = screenForSlidePreview(slide); + + document.dispatchEvent( + new CustomEvent("content", { + detail: { + screen, + }, + }), + ); + } else { + logger.error(`Unsupported preview mode: ${mode}.`); + } + } catch (err) { + logger.error(`Preview failed (mode: ${mode}, id: ${id}): ${err.message}`); } } diff --git a/assets/client/service/tenant-service.js b/assets/client/service/tenant-service.js index 66512e769..3565da418 100644 --- a/assets/client/service/tenant-service.js +++ b/assets/client/service/tenant-service.js @@ -1,4 +1,5 @@ import appStorage from "../util/app-storage"; +import logger from "../logger/logger"; class TenantService { loadTenantConfig = () => { @@ -14,11 +15,21 @@ class TenantService { "Authorization-Tenant-Key": tenantKey, }, }) - .then((response) => response.json()) + .then((response) => { + if (!response.ok) { + throw new Error( + `Failed to fetch tenant (status: ${response.status})`, + ); + } + return response.json(); + }) .then((tenantData) => { if (tenantData?.fallbackImageUrl) { appStorage.setFallbackImageUrl(tenantData.fallbackImageUrl); } + }) + .catch((err) => { + logger.error(`Failed to load tenant config: ${err.message}`); }); } }; From da6cbc95de1fc4980b4c6b0a8125c6793eaa0d5d Mon Sep 17 00:00:00 2001 From: Troels Ugilt Jensen <6103205+tuj@users.noreply.github.com> Date: Mon, 13 Apr 2026 07:00:28 +0200 Subject: [PATCH 03/73] 6871: Added null guards --- assets/client/data-sync/api-helper.js | 6 ++++++ assets/client/data-sync/pull-strategy.js | 18 ++++++++++++++---- 2 files changed, 20 insertions(+), 4 deletions(-) diff --git a/assets/client/data-sync/api-helper.js b/assets/client/data-sync/api-helper.js index 20e400068..dee9ae542 100644 --- a/assets/client/data-sync/api-helper.js +++ b/assets/client/data-sync/api-helper.js @@ -89,6 +89,12 @@ class ApiHelper { try { // eslint-disable-next-line no-await-in-loop const responseData = await this.getPath(nextPath); + + if (responseData === null) { + logger.error(`Failed to fetch: ${nextPath}`); + return { path, results, keys }; + } + results = results.concat(responseData["hydra:member"]); if (results.length < responseData["hydra:totalItems"]) { page += 1; diff --git a/assets/client/data-sync/pull-strategy.js b/assets/client/data-sync/pull-strategy.js index 2cff69f1e..f3c37707a 100644 --- a/assets/client/data-sync/pull-strategy.js +++ b/assets/client/data-sync/pull-strategy.js @@ -50,7 +50,10 @@ class PullStrategy { try { const response = await this.apiHelper.getPath(screen.inScreenGroups); - if (Object.prototype.hasOwnProperty.call(response, "hydra:member")) { + if ( + response !== null && + Object.prototype.hasOwnProperty.call(response, "hydra:member") + ) { const promises = []; response["hydra:member"].forEach((group) => { @@ -78,9 +81,11 @@ class PullStrategy { screen.campaigns, ); - screenCampaigns = screenCampaignsResponse["hydra:member"].map( - ({ campaign }) => campaign, - ); + if (screenCampaignsResponse !== null) { + screenCampaigns = screenCampaignsResponse["hydra:member"].map( + ({ campaign }) => campaign, + ); + } } catch (err) { logger.error(err); } @@ -274,6 +279,11 @@ class PullStrategy { ) { logger.info(`Fetching layout.`); newScreen.layoutData = await this.apiHelper.getPath(newScreen.layout); + + if (newScreen.layoutData === null) { + logger.warn(`Layout (${newScreen.layout}) not loaded. Aborting content update.`); + return; + } } else { // Get layout: Defines layout and regions. logger.info(`Layout loaded from cache.`); From 2c081da3a869cb2e54b47ec91fb2ee32c6e081ae Mon Sep 17 00:00:00 2001 From: Troels Ugilt Jensen <6103205+tuj@users.noreply.github.com> Date: Mon, 13 Apr 2026 07:02:37 +0200 Subject: [PATCH 04/73] 6871: Cleaned up promises --- .gitignore | 2 + assets/client/data-sync/pull-strategy.js | 56 ++++++++++-------------- 2 files changed, 24 insertions(+), 34 deletions(-) diff --git a/.gitignore b/.gitignore index 6a75d6466..ecee3e2fe 100644 --- a/.gitignore +++ b/.gitignore @@ -66,3 +66,5 @@ phpstan.neon ###> vincentlanglet/twig-cs-fixer ### /.twig-cs-fixer.cache ###< vincentlanglet/twig-cs-fixer ### + +.claude/ diff --git a/assets/client/data-sync/pull-strategy.js b/assets/client/data-sync/pull-strategy.js index f3c37707a..0a5f0c8a7 100644 --- a/assets/client/data-sync/pull-strategy.js +++ b/assets/client/data-sync/pull-strategy.js @@ -90,9 +90,7 @@ class PullStrategy { logger.error(err); } - return new Promise((resolve) => { - resolve([...screenCampaigns, ...screenGroupCampaigns]); - }); + return [...screenCampaigns, ...screenGroupCampaigns]; } /** @@ -437,33 +435,19 @@ class PullStrategy { } async getTemplateData(slide) { - return new Promise((resolve) => { - const templatePath = slide.templateInfo["@id"]; - - this.apiHelper.getPath(templatePath).then((data) => { - resolve(data); - }); - }); + const templatePath = slide.templateInfo["@id"]; + return this.apiHelper.getPath(templatePath); } async getFeedData(slide) { - return new Promise((resolve) => { - if (!slide?.feed?.feedUrl) { - resolve([]); - } else { - this.apiHelper.getPath(slide.feed.feedUrl).then((data) => { - resolve(data); - }); - } - }); + if (!slide?.feed?.feedUrl) { + return []; + } + return this.apiHelper.getPath(slide.feed.feedUrl); } async getMediaData(media) { - return new Promise((resolve) => { - this.apiHelper.getPath(media).then((data) => { - resolve(data); - }); - }); + return this.apiHelper.getPath(media); } /** @@ -474,15 +458,19 @@ class PullStrategy { this.stop(); // Pull now. - this.getScreen(this.entryPoint).then(() => { - this.stop(); - - // Start interval for pull periodically. - this.activeInterval = setInterval( - () => this.getScreen(this.entryPoint), - this.interval, - ); - }); + this.getScreen(this.entryPoint) + .then(() => { + this.stop(); + + // Start interval for pull periodically. + this.activeInterval = setInterval( + () => this.getScreen(this.entryPoint), + this.interval, + ); + }) + .catch((err) => { + logger.error(`Failed to start data sync: ${err.message}`); + }); } /** @@ -491,7 +479,7 @@ class PullStrategy { stop() { if (this.activeInterval !== undefined) { clearInterval(this.activeInterval); - delete this.activeInterval; + this.activeInterval = undefined; } } } From c27265a8a1da7cd8786e3d05561b58f092296491 Mon Sep 17 00:00:00 2001 From: Troels Ugilt Jensen <6103205+tuj@users.noreply.github.com> Date: Mon, 13 Apr 2026 07:04:05 +0200 Subject: [PATCH 05/73] 6871: Minor fixes --- assets/client/app.scss | 4 ---- assets/client/components/region.jsx | 2 +- assets/client/data-sync/pull-strategy.js | 7 +++++-- 3 files changed, 6 insertions(+), 7 deletions(-) diff --git a/assets/client/app.scss b/assets/client/app.scss index 2f68c5a8e..162b7a6a3 100644 --- a/assets/client/app.scss +++ b/assets/client/app.scss @@ -52,10 +52,6 @@ body { } } -.ql-editor { - min-height: 200px; -} - .fallback { height: 100%; width: 100%; diff --git a/assets/client/components/region.jsx b/assets/client/components/region.jsx index fdfbb8ff2..aadcd3be2 100644 --- a/assets/client/components/region.jsx +++ b/assets/client/components/region.jsx @@ -154,7 +154,7 @@ function Region({ region }) { if (newSlides !== null && !currentSlide) { setSlides(newSlides); } - }, [newSlides]); + }, [newSlides, currentSlide]); // Make sure current slide is set. useEffect(() => { diff --git a/assets/client/data-sync/pull-strategy.js b/assets/client/data-sync/pull-strategy.js index 0a5f0c8a7..0c6c62d48 100644 --- a/assets/client/data-sync/pull-strategy.js +++ b/assets/client/data-sync/pull-strategy.js @@ -4,6 +4,9 @@ import ApiHelper from "./api-helper"; import { cloneDeep } from "lodash"; import ClientConfigLoader from "../util/client-config-loader.js"; +// Static ID used as synthetic region ID when campaigns override the screen layout. +const CAMPAIGN_REGION_ID = "01G112XBWFPY029RYFB8X2H4KD"; + /** * PullStrategy. * @@ -240,8 +243,8 @@ class PullStrategy { if (newScreen.hasActiveCampaign) { logger.info(`Has active campaign.`); - // Create ulid to connect the campaign with the regions/playlists. - const campaignRegionId = "01G112XBWFPY029RYFB8X2H4KD"; + // Use a static ID to connect the campaign with the regions/playlists. + const campaignRegionId = CAMPAIGN_REGION_ID; // Campaigns are always in full screen layout, for simplicity. newScreen.layoutData = { From 7582a4b706d89d8cfb1422af5044296848137c89 Mon Sep 17 00:00:00 2001 From: Troels Ugilt Jensen <6103205+tuj@users.noreply.github.com> Date: Mon, 13 Apr 2026 07:06:04 +0200 Subject: [PATCH 06/73] 6871: Cleaned up logger use --- assets/client/data-sync/api-helper.js | 2 +- assets/client/util/client-config-loader.js | 4 ++-- assets/client/util/release-loader.js | 5 +++-- 3 files changed, 6 insertions(+), 5 deletions(-) diff --git a/assets/client/data-sync/api-helper.js b/assets/client/data-sync/api-helper.js index dee9ae542..08405fb76 100644 --- a/assets/client/data-sync/api-helper.js +++ b/assets/client/data-sync/api-helper.js @@ -31,7 +31,7 @@ class ApiHelper { const previewToken = url.searchParams.get("preview-token"); const previewTenant = url.searchParams.get("preview-tenant"); - logger.log("info", `Fetching: ${this.endpoint + path}`); + logger.info(`Fetching: ${this.endpoint + path}`); const token = appStorage.getToken(); const tenantKey = appStorage.getTenantKey(); diff --git a/assets/client/util/client-config-loader.js b/assets/client/util/client-config-loader.js index f940bb779..61b02cf9c 100644 --- a/assets/client/util/client-config-loader.js +++ b/assets/client/util/client-config-loader.js @@ -1,5 +1,6 @@ // Only fetch new config if more than 15 minutes have passed. import appStorage from "./app-storage.js"; +import logger from "../logger/logger"; const configFetchIntervalDefault = 15 * 60 * 1000; @@ -42,8 +43,7 @@ const ClientConfigLoader = { if (configData !== null) { resolve(configData); } else { - // eslint-disable-next-line no-console - console.error("Could not load config. Will use default config."); + logger.error("Could not load config. Will use default config."); // Default config. resolve({ diff --git a/assets/client/util/release-loader.js b/assets/client/util/release-loader.js index e6ec48bbc..c30730a3a 100644 --- a/assets/client/util/release-loader.js +++ b/assets/client/util/release-loader.js @@ -1,3 +1,5 @@ +import logger from "../logger/logger"; + /** * Release loader. */ @@ -7,8 +9,7 @@ export default class ReleaseLoader { return fetch(`/release.json?ts=${nowTimestamp}`) .then((response) => response.json()) .catch((err) => { - /* eslint-disable-next-line no-console */ - console.warn("Could not find release.json. Returning defaults.", err); + logger.warn("Could not find release.json. Returning defaults.", err); return { releaseTimestamp: null, From f41f43dbf2052ab39e0944c830625c13cbed1bc3 Mon Sep 17 00:00:00 2001 From: Troels Ugilt Jensen <6103205+tuj@users.noreply.github.com> Date: Mon, 13 Apr 2026 07:08:23 +0200 Subject: [PATCH 07/73] 6871: Removed todos --- assets/client/data-sync/api-helper.js | 1 - assets/client/data-sync/pull-strategy.js | 1 - assets/client/service/content-service.js | 2 -- 3 files changed, 4 deletions(-) diff --git a/assets/client/data-sync/api-helper.js b/assets/client/data-sync/api-helper.js index 08405fb76..77c201c3f 100644 --- a/assets/client/data-sync/api-helper.js +++ b/assets/client/data-sync/api-helper.js @@ -50,7 +50,6 @@ class ApiHelper { }); if (response.ok === false) { - // TODO: Change to a better strategy for triggering reauthenticate. if (response.status === 401) { document.dispatchEvent(new Event("reauthenticate")); } diff --git a/assets/client/data-sync/pull-strategy.js b/assets/client/data-sync/pull-strategy.js index 0c6c62d48..777d21182 100644 --- a/assets/client/data-sync/pull-strategy.js +++ b/assets/client/data-sync/pull-strategy.js @@ -145,7 +145,6 @@ class PullStrategy { const promises = []; const regionData = cloneDeep(regions); - // @TODO: Fix eslint-raised issues. // eslint-disable-next-line guard-for-in,no-restricted-syntax for (const regionKey in regionData) { const playlists = regionData[regionKey]; diff --git a/assets/client/service/content-service.js b/assets/client/service/content-service.js index 69ef113c2..0ae56d00a 100644 --- a/assets/client/service/content-service.js +++ b/assets/client/service/content-service.js @@ -121,8 +121,6 @@ class ContentService { const newHash = Base64.stringify(sha256(JSON.stringify(screenData))); - // TODO: Handle issue where region data is not present for a given region. Remove given region content. - if (newHash !== this.screenHash) { logger.info("Screen has changed. Emitting screen."); this.screenHash = newHash; From 95b1869132a706582c3670de5d805d5350c88a9a Mon Sep 17 00:00:00 2001 From: Troels Ugilt Jensen <6103205+tuj@users.noreply.github.com> Date: Mon, 13 Apr 2026 07:15:17 +0200 Subject: [PATCH 08/73] 6871: Clean up --- assets/client/app.jsx | 25 +++++----- assets/client/service/content-service.js | 6 +-- assets/client/service/release-service.js | 63 +++++++++++++----------- assets/client/service/token-service.js | 2 + 4 files changed, 50 insertions(+), 46 deletions(-) diff --git a/assets/client/app.jsx b/assets/client/app.jsx index 779f62bd3..ea3398500 100644 --- a/assets/client/app.jsx +++ b/assets/client/app.jsx @@ -197,19 +197,19 @@ function App({ preview, previewId }) { if (preview === "screen") { startContent(previewId); - return; + } else { + setRunning(true); + contentServiceRef.current = new ContentService(); + contentServiceRef.current.start(); + document.dispatchEvent( + new CustomEvent("startPreview", { + detail: { + mode: preview, + id: previewId, + }, + }), + ); } - setRunning(true); - contentServiceRef.current = new ContentService(); - contentServiceRef.current.start(); - document.dispatchEvent( - new CustomEvent("startPreview", { - detail: { - mode: preview, - id: previewId, - }, - }), - ); } else { document.addEventListener("keypress", handleKeyboard); document.addEventListener("screen", screenHandler); @@ -238,7 +238,6 @@ function App({ preview, previewId }) { statusService.setStatusInUrl(); } - /* eslint-disable-next-line consistent-return */ return function cleanup() { logger.info("Unmounting App."); diff --git a/assets/client/service/content-service.js b/assets/client/service/content-service.js index 0ae56d00a..a6af78125 100644 --- a/assets/client/service/content-service.js +++ b/assets/client/service/content-service.js @@ -88,17 +88,13 @@ class ContentService { this.stopSyncHandler(); - logger.log( - "info", - `Event received: Start data synchronization from ${data?.screenPath}`, - ); if (data?.screenPath) { logger.info( `Event received: Start data synchronization from ${data.screenPath}`, ); this.startSyncing(data.screenPath); } else { - logger.log("error", "Error: screenPath not set."); + logger.error("Error: screenPath not set."); } } diff --git a/assets/client/service/release-service.js b/assets/client/service/release-service.js index ba2f7a175..a2fe588e4 100644 --- a/assets/client/service/release-service.js +++ b/assets/client/service/release-service.js @@ -17,40 +17,45 @@ class ReleaseService { const url = new URL(window.location.href); const currentTimestamp = url.searchParams.get("releaseTimestamp"); - ReleaseLoader.loadRelease().then((release) => { - if (release.releaseTimestamp === null) { - statusService.setError(constants.ERROR_RELEASE_FILE_NOT_LOADED); - } else if ( - statusService.error === constants.ERROR_RELEASE_FILE_NOT_LOADED - ) { - statusService.setError(null); - } - - if ( - release.releaseTimestamp !== null && - (!currentTimestamp || - currentTimestamp !== release.releaseTimestamp.toString()) - ) { - const redirectUrl = url; + ReleaseLoader.loadRelease() + .then((release) => { + if (release.releaseTimestamp === null) { + statusService.setError(constants.ERROR_RELEASE_FILE_NOT_LOADED); + } else if ( + statusService.error === constants.ERROR_RELEASE_FILE_NOT_LOADED + ) { + statusService.setError(null); + } - redirectUrl.searchParams.set( - "releaseTimestamp", - release.releaseTimestamp, - ); + if ( + release.releaseTimestamp !== null && + (!currentTimestamp || + currentTimestamp !== release.releaseTimestamp.toString()) + ) { + const redirectUrl = url; - if (release.releaseVersion !== null) { redirectUrl.searchParams.set( - "releaseVersion", - release.releaseVersion, + "releaseTimestamp", + release.releaseTimestamp, ); - } - window.location.replace(redirectUrl); - reject(); - } else { + if (release.releaseVersion !== null) { + redirectUrl.searchParams.set( + "releaseVersion", + release.releaseVersion, + ); + } + + window.location.replace(redirectUrl); + reject(); + } else { + resolve(); + } + }) + .catch((err) => { + logger.error(`Failed to load release: ${err}`); resolve(); - } - }); + }); }); }; @@ -70,6 +75,8 @@ class ReleaseService { }; startReleaseCheck = () => { + this.stopReleaseCheck(); + ClientConfigLoader.loadConfig().then((config) => { this.releaseCheckInterval = setInterval( this.checkForNewRelease, diff --git a/assets/client/service/token-service.js b/assets/client/service/token-service.js index 478758cdb..87c02da95 100644 --- a/assets/client/service/token-service.js +++ b/assets/client/service/token-service.js @@ -219,6 +219,8 @@ class TokenService { }; startRefreshing = () => { + this.stopRefreshing(); + ClientConfigLoader.loadConfig().then((config) => { // Start refresh token interval. this.refreshInterval = setInterval( From 2daeeeb97d7a98a7de75590c02f0d582952b4b9a Mon Sep 17 00:00:00 2001 From: Troels Ugilt Jensen <6103205+tuj@users.noreply.github.com> Date: Mon, 13 Apr 2026 10:34:35 +0200 Subject: [PATCH 09/73] 6871: Changed to also use rtx in client --- Taskfile.yml | 5 +- .../activation-code-activate.jsx | 2 +- .../activation-code-create.jsx | 2 +- .../activation-code/activation-code-list.jsx | 2 +- .../feed-sources/feed-source-edit.jsx | 2 +- .../feed-sources/feed-source-manager.jsx | 2 +- .../feed-sources/feed-sources-list.jsx | 2 +- .../admin/components/groups/group-create.jsx | 2 +- assets/admin/components/groups/group-edit.jsx | 2 +- .../admin/components/groups/groups-list.jsx | 2 +- .../admin/components/media/media-create.jsx | 2 +- assets/admin/components/media/media-list.jsx | 2 +- .../playlist-drag-and-drop.jsx | 2 +- .../components/playlist/campaign-form.jsx | 2 +- .../playlist/playlist-campaign-edit.jsx | 2 +- .../playlist/playlist-campaign-list.jsx | 2 +- .../playlist/playlist-campaign-manager.jsx | 2 +- .../components/playlist/playlist-form.jsx | 2 +- .../components/playlist/shared-playlists.jsx | 2 +- .../admin/components/screen/screen-edit.jsx | 2 +- .../admin/components/screen/screen-form.jsx | 2 +- .../admin/components/screen/screen-list.jsx | 2 +- .../components/screen/screen-manager.jsx | 2 +- .../admin/components/screen/screen-status.jsx | 2 +- .../components/screen/util/campaign-icon.jsx | 2 +- .../util/grid-generation-and-select.jsx | 2 +- .../slide/content/feed-selector.jsx | 2 +- .../slide/content/media-selector-list.jsx | 2 +- assets/admin/components/slide/slide-edit.jsx | 2 +- assets/admin/components/slide/slide-form.jsx | 2 +- .../admin/components/slide/slide-manager.jsx | 2 +- assets/admin/components/slide/slides-list.jsx | 2 +- assets/admin/components/themes/theme-edit.jsx | 2 +- .../admin/components/themes/theme-manager.jsx | 2 +- .../admin/components/themes/themes-list.jsx | 2 +- assets/admin/components/user/login.jsx | 2 +- assets/admin/components/users/users-list.jsx | 2 +- .../multi-and-table/select-groups-table.jsx | 2 +- .../select-playlists-table.jsx | 2 +- .../multi-and-table/select-screens-table.jsx | 2 +- .../multi-and-table/select-slides-table.jsx | 2 +- .../util/template-label-in-list.jsx | 2 +- assets/admin/index.jsx | 2 +- .../redux/base-query.js} | 2 +- assets/{shared => admin}/redux/empty-api.ts | 2 +- .../{shared => admin}/redux/enhanced-api.ts | 0 .../{shared => admin}/redux/generated-api.ts | 0 .../{shared => admin}/redux/openapi-config.js | 0 assets/{shared => admin}/redux/store.js | 0 assets/client/data-sync/api-helper.js | 115 - assets/client/data-sync/pull-strategy.js | 228 +- assets/client/index.jsx | 8 +- assets/client/redux/base-query.js | 54 + assets/client/redux/empty-api.ts | 8 + assets/client/redux/enhanced-api.ts | 26 + assets/client/redux/generated-api.ts | 2672 +++++++++++++++++ assets/client/redux/openapi-config.js | 25 + assets/client/redux/store.js | 11 + assets/client/service/content-service.js | 58 +- assets/client/service/tenant-service.js | 22 +- assets/client/service/token-service.js | 168 +- 61 files changed, 3126 insertions(+), 362 deletions(-) rename assets/{shared/redux/extended-base-query.js => admin/redux/base-query.js} (97%) rename assets/{shared => admin}/redux/empty-api.ts (74%) rename assets/{shared => admin}/redux/enhanced-api.ts (100%) rename assets/{shared => admin}/redux/generated-api.ts (100%) rename assets/{shared => admin}/redux/openapi-config.js (100%) rename assets/{shared => admin}/redux/store.js (100%) delete mode 100644 assets/client/data-sync/api-helper.js create mode 100644 assets/client/redux/base-query.js create mode 100644 assets/client/redux/empty-api.ts create mode 100644 assets/client/redux/enhanced-api.ts create mode 100644 assets/client/redux/generated-api.ts create mode 100644 assets/client/redux/openapi-config.js create mode 100644 assets/client/redux/store.js diff --git a/Taskfile.yml b/Taskfile.yml index d0bd361af..f91e44315 100644 --- a/Taskfile.yml +++ b/Taskfile.yml @@ -215,9 +215,10 @@ tasks: - task compose -- exec phpfpm composer update-api-spec generate:redux-toolkit-api: - desc: "Generate the RTK Query api slices (in assets/shared/redux/)." + desc: "Generate the RTK Query api slices." cmds: - - task compose -- exec node npx @rtk-query/codegen-openapi /app/assets/shared/redux/openapi-config.js + - task compose -- exec node npx @rtk-query/codegen-openapi /app/assets/admin/redux/openapi-config.js + - task compose -- exec node npx @rtk-query/codegen-openapi /app/assets/client/redux/openapi-config.js app:update: desc: "Migrate to latest database schema and update installed templates" diff --git a/assets/admin/components/activation-code/activation-code-activate.jsx b/assets/admin/components/activation-code/activation-code-activate.jsx index 711aabbbc..f6589b533 100644 --- a/assets/admin/components/activation-code/activation-code-activate.jsx +++ b/assets/admin/components/activation-code/activation-code-activate.jsx @@ -3,7 +3,7 @@ import { useTranslation } from "react-i18next"; import { useNavigate } from "react-router-dom"; import Form from "react-bootstrap/Form"; import { Button } from "react-bootstrap"; -import { usePostV2UserActivationCodesActivateMutation } from "../../../shared/redux/enhanced-api.ts"; +import { usePostV2UserActivationCodesActivateMutation } from "../../redux/enhanced-api.ts"; import { displaySuccess, displayError, diff --git a/assets/admin/components/activation-code/activation-code-create.jsx b/assets/admin/components/activation-code/activation-code-create.jsx index 5a8faf35b..adb822cfe 100644 --- a/assets/admin/components/activation-code/activation-code-create.jsx +++ b/assets/admin/components/activation-code/activation-code-create.jsx @@ -1,7 +1,7 @@ import { useState, useEffect } from "react"; import { useTranslation } from "react-i18next"; import { useNavigate } from "react-router-dom"; -import { usePostV2UserActivationCodesMutation } from "../../../shared/redux/enhanced-api.ts"; +import { usePostV2UserActivationCodesMutation } from "../../redux/enhanced-api.ts"; import ActivationCodeForm from "./activation-code-form"; import { displaySuccess, diff --git a/assets/admin/components/activation-code/activation-code-list.jsx b/assets/admin/components/activation-code/activation-code-list.jsx index 8925119d1..5e9fd833f 100644 --- a/assets/admin/components/activation-code/activation-code-list.jsx +++ b/assets/admin/components/activation-code/activation-code-list.jsx @@ -18,7 +18,7 @@ import { enhancedApi, useDeleteV2UserActivationCodesByIdMutation, useGetV2UserActivationCodesQuery, -} from "../../../shared/redux/enhanced-api.ts"; +} from "../../redux/enhanced-api.ts"; /** * The Activation Code list component. diff --git a/assets/admin/components/feed-sources/feed-source-edit.jsx b/assets/admin/components/feed-sources/feed-source-edit.jsx index 602af8bcd..b57d74696 100644 --- a/assets/admin/components/feed-sources/feed-source-edit.jsx +++ b/assets/admin/components/feed-sources/feed-source-edit.jsx @@ -1,5 +1,5 @@ import { useParams } from "react-router-dom"; -import { useGetV2FeedSourcesByIdQuery } from "../../../shared/redux/enhanced-api.ts"; +import { useGetV2FeedSourcesByIdQuery } from "../../redux/enhanced-api.ts"; import FeedSourceManager from "./feed-source-manager"; /** diff --git a/assets/admin/components/feed-sources/feed-source-manager.jsx b/assets/admin/components/feed-sources/feed-source-manager.jsx index 6dcfe0e7b..e42eafcc9 100644 --- a/assets/admin/components/feed-sources/feed-source-manager.jsx +++ b/assets/admin/components/feed-sources/feed-source-manager.jsx @@ -5,7 +5,7 @@ import FeedSourceForm from "./feed-source-form"; import { usePostV2FeedSourcesMutation, usePutV2FeedSourcesByIdMutation, -} from "../../../shared/redux/enhanced-api.ts"; +} from "../../redux/enhanced-api.ts"; import { displayError, displaySuccess, diff --git a/assets/admin/components/feed-sources/feed-sources-list.jsx b/assets/admin/components/feed-sources/feed-sources-list.jsx index 90d4146de..054dd0fcb 100644 --- a/assets/admin/components/feed-sources/feed-sources-list.jsx +++ b/assets/admin/components/feed-sources/feed-sources-list.jsx @@ -5,7 +5,7 @@ import { useGetV2FeedSourcesQuery, useDeleteV2FeedSourcesByIdMutation, useGetV2FeedSourcesByIdSlidesQuery, -} from "../../../shared/redux/enhanced-api.ts"; +} from "../../redux/enhanced-api.ts"; import ListContext from "../../context/list-context"; import ContentBody from "../util/content-body/content-body"; import List from "../util/list/list"; diff --git a/assets/admin/components/groups/group-create.jsx b/assets/admin/components/groups/group-create.jsx index bc64a70bc..9830608b6 100644 --- a/assets/admin/components/groups/group-create.jsx +++ b/assets/admin/components/groups/group-create.jsx @@ -1,7 +1,7 @@ import { useState, useEffect } from "react"; import { useTranslation } from "react-i18next"; import { useNavigate } from "react-router-dom"; -import { usePostV2ScreenGroupsMutation } from "../../../shared/redux/enhanced-api.ts"; +import { usePostV2ScreenGroupsMutation } from "../../redux/enhanced-api.ts"; import GroupForm from "./group-form"; import { displaySuccess, diff --git a/assets/admin/components/groups/group-edit.jsx b/assets/admin/components/groups/group-edit.jsx index fca125590..a5d6c3466 100644 --- a/assets/admin/components/groups/group-edit.jsx +++ b/assets/admin/components/groups/group-edit.jsx @@ -4,7 +4,7 @@ import { useTranslation } from "react-i18next"; import { useGetV2ScreenGroupsByIdQuery, usePutV2ScreenGroupsByIdMutation, -} from "../../../shared/redux/enhanced-api.ts"; +} from "../../redux/enhanced-api.ts"; import { displaySuccess, displayError, diff --git a/assets/admin/components/groups/groups-list.jsx b/assets/admin/components/groups/groups-list.jsx index 2b75c358a..a92d9cdfc 100644 --- a/assets/admin/components/groups/groups-list.jsx +++ b/assets/admin/components/groups/groups-list.jsx @@ -16,7 +16,7 @@ import { useGetV2ScreenGroupsQuery, useGetV2ScreenGroupsByIdScreensQuery, useDeleteV2ScreenGroupsByIdMutation, -} from "../../../shared/redux/enhanced-api.ts"; +} from "../../redux/enhanced-api.ts"; /** * The groups list component. diff --git a/assets/admin/components/media/media-create.jsx b/assets/admin/components/media/media-create.jsx index 051274acf..0db61a579 100644 --- a/assets/admin/components/media/media-create.jsx +++ b/assets/admin/components/media/media-create.jsx @@ -1,6 +1,6 @@ import { useState, useEffect } from "react"; import { useTranslation } from "react-i18next"; -import { usePostMediaCollectionMutation } from "../../../shared/redux/enhanced-api.ts"; +import { usePostMediaCollectionMutation } from "../../redux/enhanced-api.ts"; import MediaForm from "./media-form"; import { displayError, diff --git a/assets/admin/components/media/media-list.jsx b/assets/admin/components/media/media-list.jsx index 5cd8ed320..3897a71c1 100644 --- a/assets/admin/components/media/media-list.jsx +++ b/assets/admin/components/media/media-list.jsx @@ -16,7 +16,7 @@ import { import { useGetV2MediaQuery, useDeleteV2MediaByIdMutation, -} from "../../../shared/redux/enhanced-api.ts"; +} from "../../redux/enhanced-api.ts"; import FormCheckbox from "../util/forms/form-checkbox"; import "./media-list.scss"; diff --git a/assets/admin/components/playlist-drag-and-drop/playlist-drag-and-drop.jsx b/assets/admin/components/playlist-drag-and-drop/playlist-drag-and-drop.jsx index 1735e1661..5dd5d7849 100644 --- a/assets/admin/components/playlist-drag-and-drop/playlist-drag-and-drop.jsx +++ b/assets/admin/components/playlist-drag-and-drop/playlist-drag-and-drop.jsx @@ -7,7 +7,7 @@ import FormCheckbox from "../util/forms/form-checkbox"; import { useGetV2PlaylistsByIdSlidesQuery, useGetV2PlaylistsQuery, -} from "../../../shared/redux/enhanced-api.ts"; +} from "../../redux/enhanced-api.ts"; import ScreenGanttChart from "../screen/util/screen-gantt-chart"; /** diff --git a/assets/admin/components/playlist/campaign-form.jsx b/assets/admin/components/playlist/campaign-form.jsx index 4e2fe5fdc..ee993c694 100644 --- a/assets/admin/components/playlist/campaign-form.jsx +++ b/assets/admin/components/playlist/campaign-form.jsx @@ -1,6 +1,6 @@ import { useTranslation } from "react-i18next"; import idFromUrl from "../util/helpers/id-from-url"; -import { enhancedApi } from "../../../shared/redux/enhanced-api.ts"; +import { enhancedApi } from "../../redux/enhanced-api.ts"; import ContentBody from "../util/content-body/content-body"; import SelectScreensTable from "../util/multi-and-table/select-screens-table"; import SelectGroupsTable from "../util/multi-and-table/select-groups-table"; diff --git a/assets/admin/components/playlist/playlist-campaign-edit.jsx b/assets/admin/components/playlist/playlist-campaign-edit.jsx index 8a7af904e..7ffd955f9 100644 --- a/assets/admin/components/playlist/playlist-campaign-edit.jsx +++ b/assets/admin/components/playlist/playlist-campaign-edit.jsx @@ -2,7 +2,7 @@ import { useEffect, useState } from "react"; import { useParams } from "react-router-dom"; import PlaylistCampaignManager from "./playlist-campaign-manager"; import idFromUrl from "../util/helpers/id-from-url"; -import { useGetV2PlaylistsByIdQuery } from "../../../shared/redux/enhanced-api.ts"; +import { useGetV2PlaylistsByIdQuery } from "../../redux/enhanced-api.ts"; /** * The playlist/campaign edit component. diff --git a/assets/admin/components/playlist/playlist-campaign-list.jsx b/assets/admin/components/playlist/playlist-campaign-list.jsx index ddbcfddc0..59b593111 100644 --- a/assets/admin/components/playlist/playlist-campaign-list.jsx +++ b/assets/admin/components/playlist/playlist-campaign-list.jsx @@ -16,7 +16,7 @@ import { useDeleteV2PlaylistsByIdMutation, useGetV2PlaylistsByIdSlidesQuery, useGetV2PlaylistsQuery, -} from "../../../shared/redux/enhanced-api.ts"; +} from "../../redux/enhanced-api.ts"; /** * The shared list component. diff --git a/assets/admin/components/playlist/playlist-campaign-manager.jsx b/assets/admin/components/playlist/playlist-campaign-manager.jsx index b8cb387a3..2f65182ef 100644 --- a/assets/admin/components/playlist/playlist-campaign-manager.jsx +++ b/assets/admin/components/playlist/playlist-campaign-manager.jsx @@ -15,7 +15,7 @@ import { enhancedApi, usePutV2PlaylistsByIdMutation, usePostV2PlaylistsMutation, -} from "../../../shared/redux/enhanced-api.ts"; +} from "../../redux/enhanced-api.ts"; import { set } from "lodash/object"; /** diff --git a/assets/admin/components/playlist/playlist-form.jsx b/assets/admin/components/playlist/playlist-form.jsx index fd894738b..b58c663d2 100644 --- a/assets/admin/components/playlist/playlist-form.jsx +++ b/assets/admin/components/playlist/playlist-form.jsx @@ -3,7 +3,7 @@ import { useTranslation } from "react-i18next"; import { Alert } from "react-bootstrap"; import UserContext from "../../context/user-context"; import Schedule from "../util/schedule/schedule"; -import { useGetV2TenantsQuery } from "../../../shared/redux/enhanced-api.ts"; +import { useGetV2TenantsQuery } from "../../redux/enhanced-api.ts"; import ContentBody from "../util/content-body/content-body"; import TenantsDropdown from "../util/forms/multiselect-dropdown/tenants/tenants-dropdown"; diff --git a/assets/admin/components/playlist/shared-playlists.jsx b/assets/admin/components/playlist/shared-playlists.jsx index 7b89e0fec..8523ca840 100644 --- a/assets/admin/components/playlist/shared-playlists.jsx +++ b/assets/admin/components/playlist/shared-playlists.jsx @@ -7,7 +7,7 @@ import ListContext from "../../context/list-context"; import ContentBody from "../util/content-body/content-body"; import { displayError } from "../util/list/toast-component/display-toast"; import getSharedPlaylistColumns from "./shared-playlists-column"; -import { useGetV2PlaylistsQuery } from "../../../shared/redux/enhanced-api.ts"; +import { useGetV2PlaylistsQuery } from "../../redux/enhanced-api.ts"; /** * The list component for shared playlists. diff --git a/assets/admin/components/screen/screen-edit.jsx b/assets/admin/components/screen/screen-edit.jsx index 7fd26e5a5..fd533fd62 100644 --- a/assets/admin/components/screen/screen-edit.jsx +++ b/assets/admin/components/screen/screen-edit.jsx @@ -2,7 +2,7 @@ import { useEffect, useState } from "react"; import { useParams } from "react-router-dom"; import idFromUrl from "../util/helpers/id-from-url"; import ScreenManager from "./screen-manager"; -import { useGetV2ScreensByIdQuery } from "../../../shared/redux/enhanced-api.ts"; +import { useGetV2ScreensByIdQuery } from "../../redux/enhanced-api.ts"; /** * The screen edit component. diff --git a/assets/admin/components/screen/screen-form.jsx b/assets/admin/components/screen/screen-form.jsx index 0f141cf33..4d2aa5457 100644 --- a/assets/admin/components/screen/screen-form.jsx +++ b/assets/admin/components/screen/screen-form.jsx @@ -14,7 +14,7 @@ import idFromUrl from "../util/helpers/id-from-url"; import { useGetV2LayoutsQuery, enhancedApi, -} from "../../../shared/redux/enhanced-api.ts"; +} from "../../redux/enhanced-api.ts"; import FormCheckbox from "../util/forms/form-checkbox"; import Preview from "../preview/preview"; import StickyFooter from "../util/sticky-footer"; diff --git a/assets/admin/components/screen/screen-list.jsx b/assets/admin/components/screen/screen-list.jsx index 48e82ae9d..d8ea43ed0 100644 --- a/assets/admin/components/screen/screen-list.jsx +++ b/assets/admin/components/screen/screen-list.jsx @@ -12,7 +12,7 @@ import { useGetV2ScreensQuery, useDeleteV2ScreensByIdMutation, useGetV2ScreensByIdScreenGroupsQuery, -} from "../../../shared/redux/enhanced-api.ts"; +} from "../../redux/enhanced-api.ts"; import { displaySuccess, displayError, diff --git a/assets/admin/components/screen/screen-manager.jsx b/assets/admin/components/screen/screen-manager.jsx index 0c141b493..930b8ad7f 100644 --- a/assets/admin/components/screen/screen-manager.jsx +++ b/assets/admin/components/screen/screen-manager.jsx @@ -4,7 +4,7 @@ import { useNavigate } from "react-router-dom"; import { usePostV2ScreensMutation, usePutV2ScreensByIdMutation, -} from "../../../shared/redux/enhanced-api.ts"; +} from "../../redux/enhanced-api.ts"; import ScreenForm from "./screen-form"; import { displaySuccess, diff --git a/assets/admin/components/screen/screen-status.jsx b/assets/admin/components/screen/screen-status.jsx index 972c39cf2..ca660a54a 100644 --- a/assets/admin/components/screen/screen-status.jsx +++ b/assets/admin/components/screen/screen-status.jsx @@ -14,7 +14,7 @@ import { import { useNavigate } from "react-router-dom"; import { useDispatch } from "react-redux"; import idFromUrl from "../util/helpers/id-from-url"; -import { enhancedApi } from "../../../shared/redux/enhanced-api.ts"; +import { enhancedApi } from "../../redux/enhanced-api.ts"; import { displayError } from "../util/list/toast-component/display-toast"; import FormInput from "../util/forms/form-input"; import AdminConfigLoader from "../util/admin-config-loader.js"; diff --git a/assets/admin/components/screen/util/campaign-icon.jsx b/assets/admin/components/screen/util/campaign-icon.jsx index b61f13598..8003d2a26 100644 --- a/assets/admin/components/screen/util/campaign-icon.jsx +++ b/assets/admin/components/screen/util/campaign-icon.jsx @@ -8,7 +8,7 @@ import { enhancedApi, useGetV2ScreensByIdCampaignsQuery, useGetV2ScreensByIdScreenGroupsQuery, -} from "../../../../shared/redux/enhanced-api.ts"; +} from "../../../redux/enhanced-api.ts"; /** * An icon to show if the screen has an active campaign. diff --git a/assets/admin/components/screen/util/grid-generation-and-select.jsx b/assets/admin/components/screen/util/grid-generation-and-select.jsx index f9e9b6140..1faa64c40 100644 --- a/assets/admin/components/screen/util/grid-generation-and-select.jsx +++ b/assets/admin/components/screen/util/grid-generation-and-select.jsx @@ -4,7 +4,7 @@ import Grid from "./grid"; import { useTranslation } from "react-i18next"; import idFromUrl from "../../util/helpers/id-from-url"; import PlaylistDragAndDrop from "../../playlist-drag-and-drop/playlist-drag-and-drop"; -import { enhancedApi } from "../../../../shared/redux/enhanced-api.ts"; +import { enhancedApi } from "../../../redux/enhanced-api.ts"; import useFetchDataHook from "../../util/fetch-data-hook.js"; import mapToIds from "../../util/helpers/map-to-ids.js"; import "./grid.scss"; diff --git a/assets/admin/components/slide/content/feed-selector.jsx b/assets/admin/components/slide/content/feed-selector.jsx index 15ea1bfdc..2a05f419a 100644 --- a/assets/admin/components/slide/content/feed-selector.jsx +++ b/assets/admin/components/slide/content/feed-selector.jsx @@ -5,7 +5,7 @@ import { useDispatch } from "react-redux"; import { enhancedApi, useGetV2FeedSourcesQuery, -} from "../../../../shared/redux/enhanced-api.ts"; +} from "../../../redux/enhanced-api.ts"; import MultiSelectComponent from "../../util/forms/multiselect-dropdown/multi-dropdown"; import idFromUrl from "../../util/helpers/id-from-url"; import ContentForm from "./content-form"; diff --git a/assets/admin/components/slide/content/media-selector-list.jsx b/assets/admin/components/slide/content/media-selector-list.jsx index e247ab231..8e0c4d8ac 100644 --- a/assets/admin/components/slide/content/media-selector-list.jsx +++ b/assets/admin/components/slide/content/media-selector-list.jsx @@ -3,7 +3,7 @@ import { Col, Form, Row, Spinner } from "react-bootstrap"; import { useTranslation } from "react-i18next"; import SearchBox from "../../util/search-box/search-box"; import ContentBody from "../../util/content-body/content-body"; -import { useGetV2MediaQuery } from "../../../../shared/redux/enhanced-api.ts"; +import { useGetV2MediaQuery } from "../../../redux/enhanced-api.ts"; import "../../media/media-list.scss"; import Pagination from "../../util/paginate/pagination"; import FilePreview from "./file-preview"; diff --git a/assets/admin/components/slide/slide-edit.jsx b/assets/admin/components/slide/slide-edit.jsx index bea68346c..0e20dfa0a 100644 --- a/assets/admin/components/slide/slide-edit.jsx +++ b/assets/admin/components/slide/slide-edit.jsx @@ -1,5 +1,5 @@ import { useParams } from "react-router-dom"; -import { useGetV2SlidesByIdQuery } from "../../../shared/redux/enhanced-api.ts"; +import { useGetV2SlidesByIdQuery } from "../../redux/enhanced-api.ts"; import SlideManager from "./slide-manager"; /** diff --git a/assets/admin/components/slide/slide-form.jsx b/assets/admin/components/slide/slide-form.jsx index 902d86c51..5be7068ba 100644 --- a/assets/admin/components/slide/slide-form.jsx +++ b/assets/admin/components/slide/slide-form.jsx @@ -10,7 +10,7 @@ import MultiSelectComponent from "../util/forms/multiselect-dropdown/multi-dropd import { useGetV2TemplatesQuery, useGetV2ThemesQuery, -} from "../../../shared/redux/enhanced-api.ts"; +} from "../../redux/enhanced-api.ts"; import idFromUrl from "../util/helpers/id-from-url"; import FormInput from "../util/forms/form-input"; import ContentForm from "./content/content-form"; diff --git a/assets/admin/components/slide/slide-manager.jsx b/assets/admin/components/slide/slide-manager.jsx index eeee9320b..7ff5c1501 100644 --- a/assets/admin/components/slide/slide-manager.jsx +++ b/assets/admin/components/slide/slide-manager.jsx @@ -12,7 +12,7 @@ import { usePostV2SlidesMutation, usePutV2SlidesByIdPlaylistsMutation, usePutV2SlidesByIdMutation, -} from "../../../shared/redux/enhanced-api.ts"; +} from "../../redux/enhanced-api.ts"; import SlideForm from "./slide-form"; import { displaySuccess, diff --git a/assets/admin/components/slide/slides-list.jsx b/assets/admin/components/slide/slides-list.jsx index 56d385c52..f2908fb8e 100644 --- a/assets/admin/components/slide/slides-list.jsx +++ b/assets/admin/components/slide/slides-list.jsx @@ -16,7 +16,7 @@ import { useGetV2SlidesQuery, useDeleteV2SlidesByIdMutation, useGetV2PlaylistsByIdQuery, -} from "../../../shared/redux/enhanced-api.ts"; +} from "../../redux/enhanced-api.ts"; /** * The slides list component. diff --git a/assets/admin/components/themes/theme-edit.jsx b/assets/admin/components/themes/theme-edit.jsx index 15870a906..ce533156d 100644 --- a/assets/admin/components/themes/theme-edit.jsx +++ b/assets/admin/components/themes/theme-edit.jsx @@ -1,5 +1,5 @@ import { useParams } from "react-router-dom"; -import { useGetV2ThemesByIdQuery } from "../../../shared/redux/enhanced-api.ts"; +import { useGetV2ThemesByIdQuery } from "../../redux/enhanced-api.ts"; import ThemeManager from "./theme-manager"; /** diff --git a/assets/admin/components/themes/theme-manager.jsx b/assets/admin/components/themes/theme-manager.jsx index 63ea6bef1..c2f19ea42 100644 --- a/assets/admin/components/themes/theme-manager.jsx +++ b/assets/admin/components/themes/theme-manager.jsx @@ -6,7 +6,7 @@ import { usePostV2ThemesMutation, usePutV2ThemesByIdMutation, usePostMediaCollectionMutation, -} from "../../../shared/redux/enhanced-api.ts"; +} from "../../redux/enhanced-api.ts"; import { displaySuccess, displayError, diff --git a/assets/admin/components/themes/themes-list.jsx b/assets/admin/components/themes/themes-list.jsx index b5a78e5d3..886cd7cd9 100644 --- a/assets/admin/components/themes/themes-list.jsx +++ b/assets/admin/components/themes/themes-list.jsx @@ -15,7 +15,7 @@ import { import { useGetV2ThemesQuery, useDeleteV2ThemesByIdMutation, -} from "../../../shared/redux/enhanced-api.ts"; +} from "../../redux/enhanced-api.ts"; /** * The themes list component. diff --git a/assets/admin/components/user/login.jsx b/assets/admin/components/user/login.jsx index 26b802c78..25d04c87c 100644 --- a/assets/admin/components/user/login.jsx +++ b/assets/admin/components/user/login.jsx @@ -8,7 +8,7 @@ import Col from "react-bootstrap/Col"; import { MultiSelect } from "react-multi-select-component"; import UserContext from "../../context/user-context"; import FormInput from "../util/forms/form-input"; -import { enhancedApi } from "../../../shared/redux/enhanced-api.ts"; +import { enhancedApi } from "../../redux/enhanced-api.ts"; import AdminConfigLoader from "../util/admin-config-loader.js"; import { displayError } from "../util/list/toast-component/display-toast"; import localStorageKeys from "../util/local-storage-keys"; diff --git a/assets/admin/components/users/users-list.jsx b/assets/admin/components/users/users-list.jsx index 95ad37d99..40311dac2 100644 --- a/assets/admin/components/users/users-list.jsx +++ b/assets/admin/components/users/users-list.jsx @@ -16,7 +16,7 @@ import { import { useDeleteV2UsersByIdRemoveFromTenantMutation, useGetV2UsersQuery, -} from "../../../shared/redux/enhanced-api.ts"; +} from "../../redux/enhanced-api.ts"; /** * The users list component. diff --git a/assets/admin/components/util/multi-and-table/select-groups-table.jsx b/assets/admin/components/util/multi-and-table/select-groups-table.jsx index 316c591a7..472ea1d3f 100644 --- a/assets/admin/components/util/multi-and-table/select-groups-table.jsx +++ b/assets/admin/components/util/multi-and-table/select-groups-table.jsx @@ -5,7 +5,7 @@ import { SelectGroupColumns } from "../../groups/groups-columns"; import { useGetV2ScreenGroupsQuery, useGetV2ScreenGroupsByIdScreensQuery, -} from "../../../../shared/redux/enhanced-api.ts"; +} from "../../../redux/enhanced-api.ts"; import GroupsDropdown from "../forms/multiselect-dropdown/groups/groups-dropdown"; import useFetchDataHook from "../fetch-data-hook.js"; diff --git a/assets/admin/components/util/multi-and-table/select-playlists-table.jsx b/assets/admin/components/util/multi-and-table/select-playlists-table.jsx index 89e9235fb..853a1e738 100644 --- a/assets/admin/components/util/multi-and-table/select-playlists-table.jsx +++ b/assets/admin/components/util/multi-and-table/select-playlists-table.jsx @@ -5,7 +5,7 @@ import { enhancedApi, useGetV2PlaylistsQuery, useGetV2PlaylistsByIdSlidesQuery, -} from "../../../../shared/redux/enhanced-api.ts"; +} from "../../../redux/enhanced-api.ts"; import PlaylistsDropdown from "../forms/multiselect-dropdown/playlists/playlists-dropdown"; import { SelectPlaylistColumns } from "../../playlist/playlists-columns"; import useFetchDataHook from "../fetch-data-hook.js"; diff --git a/assets/admin/components/util/multi-and-table/select-screens-table.jsx b/assets/admin/components/util/multi-and-table/select-screens-table.jsx index 1b743c588..9503ebcfd 100644 --- a/assets/admin/components/util/multi-and-table/select-screens-table.jsx +++ b/assets/admin/components/util/multi-and-table/select-screens-table.jsx @@ -7,7 +7,7 @@ import { enhancedApi, useGetV2ScreensQuery, useGetV2ScreensByIdScreenGroupsQuery, -} from "../../../../shared/redux/enhanced-api.ts"; +} from "../../../redux/enhanced-api.ts"; import useFetchDataHook from "../fetch-data-hook.js"; /** diff --git a/assets/admin/components/util/multi-and-table/select-slides-table.jsx b/assets/admin/components/util/multi-and-table/select-slides-table.jsx index 43e1f894b..5e711a529 100644 --- a/assets/admin/components/util/multi-and-table/select-slides-table.jsx +++ b/assets/admin/components/util/multi-and-table/select-slides-table.jsx @@ -8,7 +8,7 @@ import { useGetV2SlidesQuery, useGetV2PlaylistsByIdQuery, enhancedApi, -} from "../../../../shared/redux/enhanced-api.ts"; +} from "../../../redux/enhanced-api.ts"; import PlaylistGanttChart from "../../playlist/playlist-gantt-chart"; import { displayWarning } from "../list/toast-component/display-toast"; import useFetchDataHook from "../fetch-data-hook.js"; diff --git a/assets/admin/components/util/template-label-in-list.jsx b/assets/admin/components/util/template-label-in-list.jsx index a2975eb04..00602e91d 100644 --- a/assets/admin/components/util/template-label-in-list.jsx +++ b/assets/admin/components/util/template-label-in-list.jsx @@ -1,5 +1,5 @@ import Spinner from "react-bootstrap/Spinner"; -import { useGetV2TemplatesByIdQuery } from "../../../shared/redux/enhanced-api.ts"; +import { useGetV2TemplatesByIdQuery } from "../../redux/enhanced-api.ts"; import idFromUrl from "./helpers/id-from-url"; /** * @param {object} props The props. diff --git a/assets/admin/index.jsx b/assets/admin/index.jsx index 824959ecf..397b42ead 100644 --- a/assets/admin/index.jsx +++ b/assets/admin/index.jsx @@ -1,7 +1,7 @@ import { BrowserRouter } from "react-router-dom"; import { createRoot } from "react-dom/client"; import { Provider } from "react-redux"; -import { store } from "../shared/redux/store.js"; +import { store } from "./redux/store.js"; import App from "./app"; const container = document.getElementById("root"); diff --git a/assets/shared/redux/extended-base-query.js b/assets/admin/redux/base-query.js similarity index 97% rename from assets/shared/redux/extended-base-query.js rename to assets/admin/redux/base-query.js index 999d3e3e8..f1fa1a77c 100644 --- a/assets/shared/redux/extended-base-query.js +++ b/assets/admin/redux/base-query.js @@ -1,5 +1,5 @@ import { fetchBaseQuery } from "@reduxjs/toolkit/query/react"; -import localStorageKeys from "../../admin/components/util/local-storage-keys.jsx"; +import localStorageKeys from "../components/util/local-storage-keys.jsx"; const extendedBaseQuery = async (args, api, extraOptions) => { const baseUrl = "/"; diff --git a/assets/shared/redux/empty-api.ts b/assets/admin/redux/empty-api.ts similarity index 74% rename from assets/shared/redux/empty-api.ts rename to assets/admin/redux/empty-api.ts index 7e72803f7..f6076162f 100644 --- a/assets/shared/redux/empty-api.ts +++ b/assets/admin/redux/empty-api.ts @@ -1,5 +1,5 @@ import { createApi } from '@reduxjs/toolkit/query/react'; -import extendedBaseQuery from "./extended-base-query"; +import extendedBaseQuery from "./base-query"; export const emptySplitApi = createApi({ baseQuery: extendedBaseQuery, diff --git a/assets/shared/redux/enhanced-api.ts b/assets/admin/redux/enhanced-api.ts similarity index 100% rename from assets/shared/redux/enhanced-api.ts rename to assets/admin/redux/enhanced-api.ts diff --git a/assets/shared/redux/generated-api.ts b/assets/admin/redux/generated-api.ts similarity index 100% rename from assets/shared/redux/generated-api.ts rename to assets/admin/redux/generated-api.ts diff --git a/assets/shared/redux/openapi-config.js b/assets/admin/redux/openapi-config.js similarity index 100% rename from assets/shared/redux/openapi-config.js rename to assets/admin/redux/openapi-config.js diff --git a/assets/shared/redux/store.js b/assets/admin/redux/store.js similarity index 100% rename from assets/shared/redux/store.js rename to assets/admin/redux/store.js diff --git a/assets/client/data-sync/api-helper.js b/assets/client/data-sync/api-helper.js deleted file mode 100644 index 77c201c3f..000000000 --- a/assets/client/data-sync/api-helper.js +++ /dev/null @@ -1,115 +0,0 @@ -import logger from "../logger/logger"; -import appStorage from "../util/app-storage"; - -class ApiHelper { - endpoint = ""; - - /** - * Constructor. - * - * @param {string} endpoint The API endpoint. - */ - constructor(endpoint) { - this.endpoint = endpoint; - } - - /** - * Get result from path. - * - * @param {string} path Path to the resource. - * @returns {Promise} Promise with data. - */ - async getPath(path) { - if (!path) { - throw new Error("No path"); - } - - let response; - - try { - const url = new URL(window.location.href); - const previewToken = url.searchParams.get("preview-token"); - const previewTenant = url.searchParams.get("preview-tenant"); - - logger.info(`Fetching: ${this.endpoint + path}`); - - const token = appStorage.getToken(); - const tenantKey = appStorage.getTenantKey(); - - if ((!token || !tenantKey) && (!previewToken || !previewTenant)) { - logger.error("Token or tenantKey not set."); - - return null; - } - - response = await fetch(this.endpoint + path, { - headers: { - authorization: `Bearer ${previewToken ?? token}`, - "Authorization-Tenant-Key": previewTenant ?? tenantKey, - }, - }); - - if (response.ok === false) { - if (response.status === 401) { - document.dispatchEvent(new Event("reauthenticate")); - } - - logger.error( - `Failed to fetch (status: ${response.status}): ${ - this.endpoint + path - }`, - ); - - return null; - } - - return response.json(); - } catch (err) { - logger.error(`Failed to fetch: ${this.endpoint + path}`); - - return null; - } - } - - /** - * Gets all resources from the given path. - * - * @param {string} path Path to the resources. - * @param {object} keys Keys that should be passed along with the result. - * @returns {Promise<*>} Promise with all resources from a path. - */ - async getAllResultsFromPath(path, keys = {}) { - let results = []; - let nextPath = `${path}`; - let continueLoop = false; - let page = 1; - - do { - try { - // eslint-disable-next-line no-await-in-loop - const responseData = await this.getPath(nextPath); - - if (responseData === null) { - logger.error(`Failed to fetch: ${nextPath}`); - return { path, results, keys }; - } - - results = results.concat(responseData["hydra:member"]); - if (results.length < responseData["hydra:totalItems"]) { - page += 1; - continueLoop = true; - nextPath = `${path}?page=${page}`; - } else { - continueLoop = false; - } - } catch (err) { - logger.error(`Failed to fetch all results from ${path}: ${err.message}`); - return { path, results, keys }; - } - } while (continueLoop); - - return { path, results, keys }; - } -} - -export default ApiHelper; diff --git a/assets/client/data-sync/pull-strategy.js b/assets/client/data-sync/pull-strategy.js index 777d21182..2a1b73cb9 100644 --- a/assets/client/data-sync/pull-strategy.js +++ b/assets/client/data-sync/pull-strategy.js @@ -1,12 +1,70 @@ import isPublished from "../util/isPublished"; import logger from "../logger/logger"; -import ApiHelper from "./api-helper"; +import idFromPath from "../util/id-from-path"; import { cloneDeep } from "lodash"; import ClientConfigLoader from "../util/client-config-loader.js"; +import { clientStore } from "../redux/store.js"; +import { clientApi } from "../redux/generated-api.ts"; // Static ID used as synthetic region ID when campaigns override the screen layout. const CAMPAIGN_REGION_ID = "01G112XBWFPY029RYFB8X2H4KD"; +// Regex to extract regionId from region playlist paths. +const REGION_PATH_REGEX = + /\/v2\/screens\/([^/]+)\/regions\/([^/]+)\/playlists/; + +/** + * Dispatch an RTK Query endpoint and return the unwrapped result. + * + * @param {string} endpoint The endpoint name. + * @param {object} args The endpoint args. + * @returns {Promise} The result data. + */ +function query(endpoint, args) { + return clientStore + .dispatch(clientApi.endpoints[endpoint].initiate(args)) + .unwrap(); +} + +/** + * Fetch all pages from a paginated endpoint. + * + * @param {string} endpoint The endpoint name. + * @param {object} args The endpoint args (page will be added). + * @returns {Promise} All hydra:member results concatenated. + */ +async function queryAllPages(endpoint, args) { + let results = []; + let page = 1; + let continueLoop = false; + + do { + try { + const responseData = await query(endpoint, { ...args, page }); + + if (responseData === null || responseData === undefined) { + logger.error(`Failed to fetch page ${page} for ${endpoint}`); + return results; + } + + results = results.concat(responseData["hydra:member"]); + if (results.length < responseData["hydra:totalItems"]) { + page += 1; + continueLoop = true; + } else { + continueLoop = false; + } + } catch (err) { + logger.error( + `Failed to fetch all pages for ${endpoint}: ${err.message}`, + ); + return results; + } + } while (continueLoop); + + return results; +} + /** * PullStrategy. * @@ -15,9 +73,6 @@ const CAMPAIGN_REGION_ID = "01G112XBWFPY029RYFB8X2H4KD"; class PullStrategy { latestScreenData; - // Helper for all api calls. - apiHelper; - // Fetch-interval in ms. interval; @@ -37,8 +92,6 @@ class PullStrategy { this.interval = config?.interval ?? 60000 * 5; this.entryPoint = config.entryPoint; - - this.apiHelper = new ApiHelper(config.endpoint ?? ""); } /** @@ -49,9 +102,12 @@ class PullStrategy { */ async getCampaignsData(screen) { const screenGroupCampaigns = []; + const screenId = idFromPath(screen["@id"]); try { - const response = await this.apiHelper.getPath(screen.inScreenGroups); + const response = await query("getV2ScreensByIdScreenGroups", { + id: screenId, + }); if ( response !== null && @@ -60,13 +116,16 @@ class PullStrategy { const promises = []; response["hydra:member"].forEach((group) => { - promises.push(this.apiHelper.getAllResultsFromPath(group.campaigns)); + const groupId = idFromPath(group["@id"]); + promises.push( + queryAllPages("getV2ScreenGroupsByIdCampaigns", { id: groupId }), + ); }); await Promise.allSettled(promises).then((results) => { results.forEach((result) => { if (result.status === "fulfilled") { - result.value.results.forEach(({ campaign }) => { + result.value.forEach(({ campaign }) => { screenGroupCampaigns.push(campaign); }); } @@ -80,8 +139,9 @@ class PullStrategy { let screenCampaigns = []; try { - const screenCampaignsResponse = await this.apiHelper.getPath( - screen.campaigns, + const screenCampaignsResponse = await query( + "getV2ScreensByIdCampaigns", + { id: screenId }, ); if (screenCampaignsResponse !== null) { @@ -103,28 +163,32 @@ class PullStrategy { * @returns {Promise} Regions data. */ async getRegions(regions) { - const reg = /\/v2\/screens\/.*\/regions\/(?.*)\/playlists/; - return new Promise((resolve, reject) => { const promises = []; const regionData = {}; regions.forEach((regionPath) => { - promises.push(this.apiHelper.getAllResultsFromPath(regionPath)); + const matches = regionPath.match(REGION_PATH_REGEX); + if (matches) { + promises.push( + queryAllPages("getV2ScreensByIdRegionsAndRegionIdPlaylists", { + id: matches[1], + regionId: matches[2], + }).then((results) => ({ + regionId: matches[2], + results, + })), + ); + } }); Promise.allSettled(promises) .then((results) => { results.forEach((result) => { if (result.status === "fulfilled") { - const members = result?.value?.results ?? []; - const matches = result?.value?.path?.match(reg) ?? []; - - if (matches?.groups?.regionId) { - regionData[matches.groups.regionId] = members.map( - ({ playlist }) => playlist, - ); - } + regionData[result.value.regionId] = result.value.results.map( + ({ playlist }) => playlist, + ); } }); @@ -150,14 +214,17 @@ class PullStrategy { const playlists = regionData[regionKey]; // eslint-disable-next-line guard-for-in,no-restricted-syntax for (const playlistKey in playlists) { + const playlistId = idFromPath( + regionData[regionKey][playlistKey]["@id"], + ); promises.push( - this.apiHelper.getAllResultsFromPath( - regionData[regionKey][playlistKey].slides, - { - regionKey, - playlistKey, - }, - ), + queryAllPages("getV2PlaylistsByIdSlides", { + id: playlistId, + }).then((results) => ({ + regionKey, + playlistKey, + results, + })), ); } } @@ -165,12 +232,9 @@ class PullStrategy { Promise.allSettled(promises) .then((results) => { results.forEach((result) => { - if ( - result.status === "fulfilled" && - Object.prototype.hasOwnProperty.call(result.value, "keys") - ) { - regionData[result.value.keys.regionKey][ - result.value.keys.playlistKey + if (result.status === "fulfilled") { + regionData[result.value.regionKey][ + result.value.playlistKey ].slidesData = result.value.results.map( (playlistSlide) => playlistSlide.slide, ); @@ -192,7 +256,9 @@ class PullStrategy { // Fetch screen try { - screen = await this.apiHelper.getPath(screenPath); + screen = await query("getV2ScreensById", { + id: idFromPath(screenPath), + }); } catch (err) { logger.warn( `Screen (${screenPath}) not loaded. Aborting content update.`, @@ -278,10 +344,21 @@ class PullStrategy { oldScreenChecksums?.layout !== newScreenChecksums?.layout ) { logger.info(`Fetching layout.`); - newScreen.layoutData = await this.apiHelper.getPath(newScreen.layout); + try { + newScreen.layoutData = await query("getV2LayoutsById", { + id: idFromPath(newScreen.layout), + }); + } catch (err) { + logger.warn( + `Layout (${newScreen.layout}) not loaded. Aborting content update.`, + ); + return; + } if (newScreen.layoutData === null) { - logger.warn(`Layout (${newScreen.layout}) not loaded. Aborting content update.`); + logger.warn( + `Layout (${newScreen.layout}) not loaded. Aborting content update.`, + ); return; } } else { @@ -349,23 +426,29 @@ class PullStrategy { oldSlideChecksums === null || newSlideChecksums.templateInfo !== oldSlideChecksums.templateInfo ) { - const templatePath = slide.templateInfo["@id"]; + const templateId = idFromPath(slide.templateInfo["@id"]); // Load template into slide.templateData. if ( Object.prototype.hasOwnProperty.call( fetchedTemplates, - templatePath, + templateId, ) ) { - slide.templateData = fetchedTemplates[templatePath]; + slide.templateData = fetchedTemplates[templateId]; } else { logger.info(`Fetching template data.`); - const templateData = await this.apiHelper.getPath(templatePath); - slide.templateData = templateData; - - if (templateData !== null) { - fetchedTemplates[templatePath] = templateData; + try { + const templateData = await query("getV2TemplatesById", { + id: templateId, + }); + slide.templateData = templateData; + + if (templateData !== null) { + fetchedTemplates[templateId] = templateData; + } + } catch (err) { + slide.templateData = null; } } } else { @@ -389,16 +472,25 @@ class PullStrategy { ) { const nextMediaData = {}; - for (const mediaId of slide.media) { - if (Object.prototype.hasOwnProperty.call(fetchedMedia, mediaId)) { - nextMediaData[mediaId] = fetchedMedia[mediaId]; + for (const mediaPath of slide.media) { + const mediaId = idFromPath(mediaPath); + if ( + Object.prototype.hasOwnProperty.call(fetchedMedia, mediaId) + ) { + nextMediaData[mediaPath] = fetchedMedia[mediaId]; } else { logger.info(`Fetching media data.`); - const mediaData = await this.apiHelper.getPath(mediaId); - nextMediaData[mediaId] = mediaData; - - if (mediaData !== null) { - fetchedMedia[mediaId] = mediaData; + try { + const mediaData = await query("getv2MediaById", { + id: mediaId, + }); + nextMediaData[mediaPath] = mediaData; + + if (mediaData !== null) { + fetchedMedia[mediaId] = mediaData; + } + } catch (err) { + nextMediaData[mediaPath] = null; } } } @@ -412,7 +504,13 @@ class PullStrategy { // Fetch feed. if (slide?.feed?.feedUrl !== undefined) { logger.info(`Fetching feed data.`); - slide.feedData = await this.apiHelper.getPath(slide.feed.feedUrl); + try { + slide.feedData = await query("getV2FeedsByIdData", { + id: idFromPath(slide.feed.feedUrl), + }); + } catch (err) { + slide.feedData = null; + } } dataEntrySlidesData[slideKey] = slide; @@ -432,26 +530,6 @@ class PullStrategy { document.dispatchEvent(event); } - getPath(id) { - return this.apiHelper.getPath(id); - } - - async getTemplateData(slide) { - const templatePath = slide.templateInfo["@id"]; - return this.apiHelper.getPath(templatePath); - } - - async getFeedData(slide) { - if (!slide?.feed?.feedUrl) { - return []; - } - return this.apiHelper.getPath(slide.feed.feedUrl); - } - - async getMediaData(media) { - return this.apiHelper.getPath(media); - } - /** * Start the data synchronization. */ diff --git a/assets/client/index.jsx b/assets/client/index.jsx index dc7e35ef9..f8304bd11 100644 --- a/assets/client/index.jsx +++ b/assets/client/index.jsx @@ -1,4 +1,6 @@ import { createRoot } from "react-dom/client"; +import { Provider } from "react-redux"; +import { clientStore } from "./redux/store.js"; import App from "./app.jsx"; const url = new URL(window.location.href); @@ -8,4 +10,8 @@ const previewId = url.searchParams.get("preview-id"); const container = document.getElementById("root"); const root = createRoot(container); -root.render(); +root.render( + + + , +); diff --git a/assets/client/redux/base-query.js b/assets/client/redux/base-query.js new file mode 100644 index 000000000..4c1eefcf1 --- /dev/null +++ b/assets/client/redux/base-query.js @@ -0,0 +1,54 @@ +import { fetchBaseQuery } from "@reduxjs/toolkit/query/react"; +import localStorageKeys from "../util/local-storage-keys"; + +const clientBaseQuery = async (args, api, extraOptions) => { + const baseUrl = "/"; + + const newArgs = { ...args }; + + if (!Object.prototype.hasOwnProperty.call(newArgs, "headers")) { + newArgs.headers = {}; + } + + if (!Object.prototype.hasOwnProperty.call(newArgs.headers, "accept")) { + newArgs.headers.accept = "application/ld+json"; + } + + // Support preview mode via URL params. + const url = new URL(window.location.href); + const previewToken = url.searchParams.get("preview-token"); + const previewTenant = url.searchParams.get("preview-tenant"); + + // Attach api token. + const apiToken = localStorage.getItem(localStorageKeys.API_TOKEN); + if (previewToken) { + newArgs.headers.authorization = `Bearer ${previewToken}`; + } else if (apiToken) { + newArgs.headers.authorization = `Bearer ${apiToken}`; + } + + // Attach tenant key. + const tenantKey = localStorage.getItem(localStorageKeys.TENANT_KEY); + if (previewTenant) { + newArgs.headers["Authorization-Tenant-Key"] = previewTenant; + } else if (tenantKey) { + newArgs.headers["Authorization-Tenant-Key"] = tenantKey; + } + + const baseResult = await fetchBaseQuery({ baseUrl, credentials: "include" })( + newArgs, + api, + extraOptions, + ); + + // Handle authentication errors. + if (baseResult?.error?.status === 401) { + document.dispatchEvent(new Event("reauthenticate")); + } + + return { + ...baseResult, + }; +}; + +export default clientBaseQuery; diff --git a/assets/client/redux/empty-api.ts b/assets/client/redux/empty-api.ts new file mode 100644 index 000000000..1c1070440 --- /dev/null +++ b/assets/client/redux/empty-api.ts @@ -0,0 +1,8 @@ +import { createApi } from "@reduxjs/toolkit/query/react"; +import clientBaseQuery from "./base-query"; + +export const clientEmptySplitApi = createApi({ + reducerPath: "clientApi", + baseQuery: clientBaseQuery, + endpoints: () => ({}), +}); diff --git a/assets/client/redux/enhanced-api.ts b/assets/client/redux/enhanced-api.ts new file mode 100644 index 000000000..706d5e62d --- /dev/null +++ b/assets/client/redux/enhanced-api.ts @@ -0,0 +1,26 @@ +import { clientApi as generatedApi } from "./generated-api"; + +// Invalidate the following tags for the given endpoints. +// The client uses very few mutations, so this is minimal. +const invalidatesTagsForEndpoints = { + postLoginInfoScreen: ["Authentication"], + postRefreshTokenItem: ["Authentication"], +}; + +export const enhancedApi = generatedApi.enhanceEndpoints({ + // @ts-ignore + endpoints: Object.fromEntries( + // @ts-ignore + Object.entries(generatedApi.endpoints).map(([key, endpoint]) => { + const enhancedEndpoint = { + ...endpoint, + }; + + if (invalidatesTagsForEndpoints.hasOwnProperty(key)) { + enhancedEndpoint.invalidatesTags = invalidatesTagsForEndpoints[key]; + } + + return [key, enhancedEndpoint]; + }) + ), +}); diff --git a/assets/client/redux/generated-api.ts b/assets/client/redux/generated-api.ts new file mode 100644 index 000000000..59d8c88ca --- /dev/null +++ b/assets/client/redux/generated-api.ts @@ -0,0 +1,2672 @@ +import { clientEmptySplitApi as api } from "./empty-api"; +export const addTagTypes = [ + "Authentication", + "FeedSources", + "Feeds", + "Layouts", + "Login Check", + "Media", + "Playlists", + "ScreenGroupCampaign", + "ScreenGroups", + "Screens", + "Slides", + "Templates", + "Tenants", + "Themes", + "User", + "UserActivationCode", +] as const; +const injectedRtkApi = api + .enhanceEndpoints({ + addTagTypes, + }) + .injectEndpoints({ + endpoints: (build) => ({ + getOidcAuthTokenItem: build.query< + GetOidcAuthTokenItemApiResponse, + GetOidcAuthTokenItemApiArg + >({ + query: (queryArg) => ({ + url: `/v2/authentication/oidc/token`, + params: { + state: queryArg.state, + code: queryArg.code, + }, + }), + providesTags: ["Authentication"], + }), + getOidcAuthUrlsItem: build.query< + GetOidcAuthUrlsItemApiResponse, + GetOidcAuthUrlsItemApiArg + >({ + query: (queryArg) => ({ + url: `/v2/authentication/oidc/urls`, + params: { + providerKey: queryArg.providerKey, + }, + }), + providesTags: ["Authentication"], + }), + postLoginInfoScreen: build.mutation< + PostLoginInfoScreenApiResponse, + PostLoginInfoScreenApiArg + >({ + query: (queryArg) => ({ + url: `/v2/authentication/screen`, + method: "POST", + body: queryArg.screenLoginInput, + }), + invalidatesTags: ["Authentication"], + }), + postRefreshTokenItem: build.mutation< + PostRefreshTokenItemApiResponse, + PostRefreshTokenItemApiArg + >({ + query: (queryArg) => ({ + url: `/v2/authentication/token/refresh`, + method: "POST", + body: queryArg.refreshTokenRequest, + }), + invalidatesTags: ["Authentication"], + }), + getV2FeedSources: build.query< + GetV2FeedSourcesApiResponse, + GetV2FeedSourcesApiArg + >({ + query: (queryArg) => ({ + url: `/v2/feed-sources`, + params: { + page: queryArg.page, + itemsPerPage: queryArg.itemsPerPage, + supportedFeedOutputType: queryArg.supportedFeedOutputType, + title: queryArg.title, + description: queryArg.description, + createdBy: queryArg.createdBy, + modifiedBy: queryArg.modifiedBy, + order: queryArg.order, + }, + }), + providesTags: ["FeedSources"], + }), + postV2FeedSources: build.mutation< + PostV2FeedSourcesApiResponse, + PostV2FeedSourcesApiArg + >({ + query: (queryArg) => ({ + url: `/v2/feed-sources`, + method: "POST", + body: queryArg.feedSourceFeedSourceInputJsonld, + }), + invalidatesTags: ["FeedSources"], + }), + getV2FeedSourcesById: build.query< + GetV2FeedSourcesByIdApiResponse, + GetV2FeedSourcesByIdApiArg + >({ + query: (queryArg) => ({ url: `/v2/feed-sources/${queryArg.id}` }), + providesTags: ["FeedSources"], + }), + putV2FeedSourcesById: build.mutation< + PutV2FeedSourcesByIdApiResponse, + PutV2FeedSourcesByIdApiArg + >({ + query: (queryArg) => ({ + url: `/v2/feed-sources/${queryArg.id}`, + method: "PUT", + body: queryArg.feedSourceFeedSourceInputJsonld, + }), + invalidatesTags: ["FeedSources"], + }), + deleteV2FeedSourcesById: build.mutation< + DeleteV2FeedSourcesByIdApiResponse, + DeleteV2FeedSourcesByIdApiArg + >({ + query: (queryArg) => ({ + url: `/v2/feed-sources/${queryArg.id}`, + method: "DELETE", + }), + invalidatesTags: ["FeedSources"], + }), + getV2FeedSourcesByIdConfigAndName: build.query< + GetV2FeedSourcesByIdConfigAndNameApiResponse, + GetV2FeedSourcesByIdConfigAndNameApiArg + >({ + query: (queryArg) => ({ + url: `/v2/feed-sources/${queryArg.id}/config/${queryArg.name}`, + }), + providesTags: ["FeedSources"], + }), + getV2FeedSourcesByIdSlides: build.query< + GetV2FeedSourcesByIdSlidesApiResponse, + GetV2FeedSourcesByIdSlidesApiArg + >({ + query: (queryArg) => ({ + url: `/v2/feed-sources/${queryArg.id}/slides`, + params: { + page: queryArg.page, + itemsPerPage: queryArg.itemsPerPage, + title: queryArg.title, + description: queryArg.description, + createdBy: queryArg.createdBy, + modifiedBy: queryArg.modifiedBy, + published: queryArg.published, + order: queryArg.order, + }, + }), + providesTags: ["FeedSources"], + }), + getV2Feeds: build.query({ + query: (queryArg) => ({ + url: `/v2/feeds`, + params: { + page: queryArg.page, + itemsPerPage: queryArg.itemsPerPage, + createdBy: queryArg.createdBy, + modifiedBy: queryArg.modifiedBy, + order: queryArg.order, + }, + }), + providesTags: ["Feeds"], + }), + getV2FeedsById: build.query< + GetV2FeedsByIdApiResponse, + GetV2FeedsByIdApiArg + >({ + query: (queryArg) => ({ url: `/v2/feeds/${queryArg.id}` }), + providesTags: ["Feeds"], + }), + getV2FeedsByIdData: build.query< + GetV2FeedsByIdDataApiResponse, + GetV2FeedsByIdDataApiArg + >({ + query: (queryArg) => ({ url: `/v2/feeds/${queryArg.id}/data` }), + providesTags: ["Feeds"], + }), + getV2Layouts: build.query({ + query: (queryArg) => ({ + url: `/v2/layouts`, + params: { + page: queryArg.page, + itemsPerPage: queryArg.itemsPerPage, + }, + }), + providesTags: ["Layouts"], + }), + getV2LayoutsById: build.query< + GetV2LayoutsByIdApiResponse, + GetV2LayoutsByIdApiArg + >({ + query: (queryArg) => ({ url: `/v2/layouts/${queryArg.id}` }), + providesTags: ["Layouts"], + }), + loginCheckPost: build.mutation< + LoginCheckPostApiResponse, + LoginCheckPostApiArg + >({ + query: (queryArg) => ({ + url: `/v2/authentication/token`, + method: "POST", + body: queryArg.body, + }), + invalidatesTags: ["Login Check"], + }), + getV2Media: build.query({ + query: (queryArg) => ({ + url: `/v2/media`, + params: { + page: queryArg.page, + itemsPerPage: queryArg.itemsPerPage, + title: queryArg.title, + description: queryArg.description, + createdBy: queryArg.createdBy, + modifiedBy: queryArg.modifiedBy, + order: queryArg.order, + }, + }), + providesTags: ["Media"], + }), + postMediaCollection: build.mutation< + PostMediaCollectionApiResponse, + PostMediaCollectionApiArg + >({ + query: (queryArg) => ({ + url: `/v2/media`, + method: "POST", + body: queryArg.body, + }), + invalidatesTags: ["Media"], + }), + getv2MediaById: build.query< + Getv2MediaByIdApiResponse, + Getv2MediaByIdApiArg + >({ + query: (queryArg) => ({ url: `/v2/media/${queryArg.id}` }), + providesTags: ["Media"], + }), + deleteV2MediaById: build.mutation< + DeleteV2MediaByIdApiResponse, + DeleteV2MediaByIdApiArg + >({ + query: (queryArg) => ({ + url: `/v2/media/${queryArg.id}`, + method: "DELETE", + }), + invalidatesTags: ["Media"], + }), + getV2CampaignsByIdScreenGroups: build.query< + GetV2CampaignsByIdScreenGroupsApiResponse, + GetV2CampaignsByIdScreenGroupsApiArg + >({ + query: (queryArg) => ({ + url: `/v2/campaigns/${queryArg.id}/screen-groups`, + params: { + page: queryArg.page, + itemsPerPage: queryArg.itemsPerPage, + }, + }), + providesTags: ["Playlists"], + }), + getV2CampaignsByIdScreens: build.query< + GetV2CampaignsByIdScreensApiResponse, + GetV2CampaignsByIdScreensApiArg + >({ + query: (queryArg) => ({ + url: `/v2/campaigns/${queryArg.id}/screens`, + params: { + page: queryArg.page, + itemsPerPage: queryArg.itemsPerPage, + }, + }), + providesTags: ["Playlists"], + }), + getV2Playlists: build.query< + GetV2PlaylistsApiResponse, + GetV2PlaylistsApiArg + >({ + query: (queryArg) => ({ + url: `/v2/playlists`, + params: { + page: queryArg.page, + itemsPerPage: queryArg.itemsPerPage, + title: queryArg.title, + description: queryArg.description, + createdBy: queryArg.createdBy, + modifiedBy: queryArg.modifiedBy, + published: queryArg.published, + isCampaign: queryArg.isCampaign, + order: queryArg.order, + sharedWithMe: queryArg.sharedWithMe, + }, + }), + providesTags: ["Playlists"], + }), + postV2Playlists: build.mutation< + PostV2PlaylistsApiResponse, + PostV2PlaylistsApiArg + >({ + query: (queryArg) => ({ + url: `/v2/playlists`, + method: "POST", + body: queryArg.playlistPlaylistInputJsonld, + }), + invalidatesTags: ["Playlists"], + }), + getV2PlaylistsById: build.query< + GetV2PlaylistsByIdApiResponse, + GetV2PlaylistsByIdApiArg + >({ + query: (queryArg) => ({ url: `/v2/playlists/${queryArg.id}` }), + providesTags: ["Playlists"], + }), + putV2PlaylistsById: build.mutation< + PutV2PlaylistsByIdApiResponse, + PutV2PlaylistsByIdApiArg + >({ + query: (queryArg) => ({ + url: `/v2/playlists/${queryArg.id}`, + method: "PUT", + body: queryArg.playlistPlaylistInputJsonld, + }), + invalidatesTags: ["Playlists"], + }), + deleteV2PlaylistsById: build.mutation< + DeleteV2PlaylistsByIdApiResponse, + DeleteV2PlaylistsByIdApiArg + >({ + query: (queryArg) => ({ + url: `/v2/playlists/${queryArg.id}`, + method: "DELETE", + }), + invalidatesTags: ["Playlists"], + }), + getV2PlaylistsByIdSlides: build.query< + GetV2PlaylistsByIdSlidesApiResponse, + GetV2PlaylistsByIdSlidesApiArg + >({ + query: (queryArg) => ({ + url: `/v2/playlists/${queryArg.id}/slides`, + params: { + page: queryArg.page, + itemsPerPage: queryArg.itemsPerPage, + published: queryArg.published, + }, + }), + providesTags: ["Playlists"], + }), + putV2PlaylistsByIdSlides: build.mutation< + PutV2PlaylistsByIdSlidesApiResponse, + PutV2PlaylistsByIdSlidesApiArg + >({ + query: (queryArg) => ({ + url: `/v2/playlists/${queryArg.id}/slides`, + method: "PUT", + body: queryArg.body, + }), + invalidatesTags: ["Playlists"], + }), + deleteV2PlaylistsByIdSlidesAndSlideId: build.mutation< + DeleteV2PlaylistsByIdSlidesAndSlideIdApiResponse, + DeleteV2PlaylistsByIdSlidesAndSlideIdApiArg + >({ + query: (queryArg) => ({ + url: `/v2/playlists/${queryArg.id}/slides/${queryArg.slideId}`, + method: "DELETE", + }), + invalidatesTags: ["Playlists"], + }), + getV2SlidesByIdPlaylists: build.query< + GetV2SlidesByIdPlaylistsApiResponse, + GetV2SlidesByIdPlaylistsApiArg + >({ + query: (queryArg) => ({ + url: `/v2/slides/${queryArg.id}/playlists`, + params: { + page: queryArg.page, + itemsPerPage: queryArg.itemsPerPage, + published: queryArg.published, + }, + }), + providesTags: ["Playlists"], + }), + putV2SlidesByIdPlaylists: build.mutation< + PutV2SlidesByIdPlaylistsApiResponse, + PutV2SlidesByIdPlaylistsApiArg + >({ + query: (queryArg) => ({ + url: `/v2/slides/${queryArg.id}/playlists`, + method: "PUT", + body: queryArg.body, + }), + invalidatesTags: ["Playlists"], + }), + getScreenGroupCampaignItem: build.query< + GetScreenGroupCampaignItemApiResponse, + GetScreenGroupCampaignItemApiArg + >({ + query: (queryArg) => ({ + url: `/v2/screen-groups-campaigns/${queryArg.id}`, + }), + providesTags: ["ScreenGroupCampaign"], + }), + getV2ScreenGroups: build.query< + GetV2ScreenGroupsApiResponse, + GetV2ScreenGroupsApiArg + >({ + query: (queryArg) => ({ + url: `/v2/screen-groups`, + params: { + page: queryArg.page, + itemsPerPage: queryArg.itemsPerPage, + title: queryArg.title, + description: queryArg.description, + createdBy: queryArg.createdBy, + modifiedBy: queryArg.modifiedBy, + order: queryArg.order, + }, + }), + providesTags: ["ScreenGroups"], + }), + postV2ScreenGroups: build.mutation< + PostV2ScreenGroupsApiResponse, + PostV2ScreenGroupsApiArg + >({ + query: (queryArg) => ({ + url: `/v2/screen-groups`, + method: "POST", + body: queryArg.screenGroupScreenGroupInputJsonld, + }), + invalidatesTags: ["ScreenGroups"], + }), + getV2ScreenGroupsById: build.query< + GetV2ScreenGroupsByIdApiResponse, + GetV2ScreenGroupsByIdApiArg + >({ + query: (queryArg) => ({ url: `/v2/screen-groups/${queryArg.id}` }), + providesTags: ["ScreenGroups"], + }), + putV2ScreenGroupsById: build.mutation< + PutV2ScreenGroupsByIdApiResponse, + PutV2ScreenGroupsByIdApiArg + >({ + query: (queryArg) => ({ + url: `/v2/screen-groups/${queryArg.id}`, + method: "PUT", + body: queryArg.screenGroupScreenGroupInputJsonld, + }), + invalidatesTags: ["ScreenGroups"], + }), + deleteV2ScreenGroupsById: build.mutation< + DeleteV2ScreenGroupsByIdApiResponse, + DeleteV2ScreenGroupsByIdApiArg + >({ + query: (queryArg) => ({ + url: `/v2/screen-groups/${queryArg.id}`, + method: "DELETE", + }), + invalidatesTags: ["ScreenGroups"], + }), + getV2ScreenGroupsByIdCampaigns: build.query< + GetV2ScreenGroupsByIdCampaignsApiResponse, + GetV2ScreenGroupsByIdCampaignsApiArg + >({ + query: (queryArg) => ({ + url: `/v2/screen-groups/${queryArg.id}/campaigns`, + params: { + page: queryArg.page, + itemsPerPage: queryArg.itemsPerPage, + published: queryArg.published, + }, + }), + providesTags: ["ScreenGroups"], + }), + putV2ScreenGroupsByIdCampaigns: build.mutation< + PutV2ScreenGroupsByIdCampaignsApiResponse, + PutV2ScreenGroupsByIdCampaignsApiArg + >({ + query: (queryArg) => ({ + url: `/v2/screen-groups/${queryArg.id}/campaigns`, + method: "PUT", + body: queryArg.body, + }), + invalidatesTags: ["ScreenGroups"], + }), + deleteV2ScreenGroupsByIdCampaignsAndCampaignId: build.mutation< + DeleteV2ScreenGroupsByIdCampaignsAndCampaignIdApiResponse, + DeleteV2ScreenGroupsByIdCampaignsAndCampaignIdApiArg + >({ + query: (queryArg) => ({ + url: `/v2/screen-groups/${queryArg.id}/campaigns/${queryArg.campaignId}`, + method: "DELETE", + }), + invalidatesTags: ["ScreenGroups"], + }), + getV2ScreenGroupsByIdScreens: build.query< + GetV2ScreenGroupsByIdScreensApiResponse, + GetV2ScreenGroupsByIdScreensApiArg + >({ + query: (queryArg) => ({ + url: `/v2/screen-groups/${queryArg.id}/screens`, + params: { + page: queryArg.page, + itemsPerPage: queryArg.itemsPerPage, + }, + }), + providesTags: ["ScreenGroups"], + }), + getV2Screens: build.query({ + query: (queryArg) => ({ + url: `/v2/screens`, + params: { + page: queryArg.page, + itemsPerPage: queryArg.itemsPerPage, + search: queryArg.search, + exists: queryArg.exists, + "screenUser.latestRequest": queryArg["screenUser.latestRequest"], + createdBy: queryArg.createdBy, + modifiedBy: queryArg.modifiedBy, + order: queryArg.order, + }, + }), + providesTags: ["Screens"], + }), + postV2Screens: build.mutation< + PostV2ScreensApiResponse, + PostV2ScreensApiArg + >({ + query: (queryArg) => ({ + url: `/v2/screens`, + method: "POST", + body: queryArg.screenScreenInputJsonld, + }), + invalidatesTags: ["Screens"], + }), + getV2ScreensById: build.query< + GetV2ScreensByIdApiResponse, + GetV2ScreensByIdApiArg + >({ + query: (queryArg) => ({ url: `/v2/screens/${queryArg.id}` }), + providesTags: ["Screens"], + }), + putV2ScreensById: build.mutation< + PutV2ScreensByIdApiResponse, + PutV2ScreensByIdApiArg + >({ + query: (queryArg) => ({ + url: `/v2/screens/${queryArg.id}`, + method: "PUT", + body: queryArg.screenScreenInputJsonld, + }), + invalidatesTags: ["Screens"], + }), + deleteV2ScreensById: build.mutation< + DeleteV2ScreensByIdApiResponse, + DeleteV2ScreensByIdApiArg + >({ + query: (queryArg) => ({ + url: `/v2/screens/${queryArg.id}`, + method: "DELETE", + }), + invalidatesTags: ["Screens"], + }), + postScreenBindKey: build.mutation< + PostScreenBindKeyApiResponse, + PostScreenBindKeyApiArg + >({ + query: (queryArg) => ({ + url: `/v2/screens/${queryArg.id}/bind`, + method: "POST", + body: queryArg.screenBindObject, + }), + invalidatesTags: ["Screens"], + }), + getV2ScreensByIdCampaigns: build.query< + GetV2ScreensByIdCampaignsApiResponse, + GetV2ScreensByIdCampaignsApiArg + >({ + query: (queryArg) => ({ + url: `/v2/screens/${queryArg.id}/campaigns`, + params: { + page: queryArg.page, + itemsPerPage: queryArg.itemsPerPage, + published: queryArg.published, + }, + }), + providesTags: ["Screens"], + }), + putV2ScreensByIdCampaigns: build.mutation< + PutV2ScreensByIdCampaignsApiResponse, + PutV2ScreensByIdCampaignsApiArg + >({ + query: (queryArg) => ({ + url: `/v2/screens/${queryArg.id}/campaigns`, + method: "PUT", + body: queryArg.body, + }), + invalidatesTags: ["Screens"], + }), + deleteV2ScreensByIdCampaignsAndCampaignId: build.mutation< + DeleteV2ScreensByIdCampaignsAndCampaignIdApiResponse, + DeleteV2ScreensByIdCampaignsAndCampaignIdApiArg + >({ + query: (queryArg) => ({ + url: `/v2/screens/${queryArg.id}/campaigns/${queryArg.campaignId}`, + method: "DELETE", + }), + invalidatesTags: ["Screens"], + }), + getV2ScreensByIdRegionsAndRegionIdPlaylists: build.query< + GetV2ScreensByIdRegionsAndRegionIdPlaylistsApiResponse, + GetV2ScreensByIdRegionsAndRegionIdPlaylistsApiArg + >({ + query: (queryArg) => ({ + url: `/v2/screens/${queryArg.id}/regions/${queryArg.regionId}/playlists`, + params: { + page: queryArg.page, + itemsPerPage: queryArg.itemsPerPage, + sharedWithMe: queryArg.sharedWithMe, + }, + }), + providesTags: ["Screens"], + }), + putPlaylistScreenRegionItem: build.mutation< + PutPlaylistScreenRegionItemApiResponse, + PutPlaylistScreenRegionItemApiArg + >({ + query: (queryArg) => ({ + url: `/v2/screens/${queryArg.id}/regions/${queryArg.regionId}/playlists`, + method: "PUT", + body: queryArg.body, + }), + invalidatesTags: ["Screens"], + }), + deletePlaylistScreenRegionItem: build.mutation< + DeletePlaylistScreenRegionItemApiResponse, + DeletePlaylistScreenRegionItemApiArg + >({ + query: (queryArg) => ({ + url: `/v2/screens/${queryArg.id}/regions/${queryArg.regionId}/playlists/${queryArg.playlistId}`, + method: "DELETE", + }), + invalidatesTags: ["Screens"], + }), + getV2ScreensByIdScreenGroups: build.query< + GetV2ScreensByIdScreenGroupsApiResponse, + GetV2ScreensByIdScreenGroupsApiArg + >({ + query: (queryArg) => ({ + url: `/v2/screens/${queryArg.id}/screen-groups`, + params: { + page: queryArg.page, + itemsPerPage: queryArg.itemsPerPage, + order: queryArg.order, + }, + }), + providesTags: ["Screens"], + }), + putV2ScreensByIdScreenGroups: build.mutation< + PutV2ScreensByIdScreenGroupsApiResponse, + PutV2ScreensByIdScreenGroupsApiArg + >({ + query: (queryArg) => ({ + url: `/v2/screens/${queryArg.id}/screen-groups`, + method: "PUT", + body: queryArg.body, + }), + invalidatesTags: ["Screens"], + }), + deleteV2ScreensByIdScreenGroupsAndScreenGroupId: build.mutation< + DeleteV2ScreensByIdScreenGroupsAndScreenGroupIdApiResponse, + DeleteV2ScreensByIdScreenGroupsAndScreenGroupIdApiArg + >({ + query: (queryArg) => ({ + url: `/v2/screens/${queryArg.id}/screen-groups/${queryArg.screenGroupId}`, + method: "DELETE", + }), + invalidatesTags: ["Screens"], + }), + postScreenUnbind: build.mutation< + PostScreenUnbindApiResponse, + PostScreenUnbindApiArg + >({ + query: (queryArg) => ({ + url: `/v2/screens/${queryArg.id}/unbind`, + method: "POST", + body: queryArg.body, + }), + invalidatesTags: ["Screens"], + }), + getV2Slides: build.query({ + query: (queryArg) => ({ + url: `/v2/slides`, + params: { + page: queryArg.page, + itemsPerPage: queryArg.itemsPerPage, + title: queryArg.title, + description: queryArg.description, + createdBy: queryArg.createdBy, + modifiedBy: queryArg.modifiedBy, + published: queryArg.published, + order: queryArg.order, + }, + }), + providesTags: ["Slides"], + }), + postV2Slides: build.mutation( + { + query: (queryArg) => ({ + url: `/v2/slides`, + method: "POST", + body: queryArg.slideSlideInputJsonld, + }), + invalidatesTags: ["Slides"], + }, + ), + getV2SlidesById: build.query< + GetV2SlidesByIdApiResponse, + GetV2SlidesByIdApiArg + >({ + query: (queryArg) => ({ url: `/v2/slides/${queryArg.id}` }), + providesTags: ["Slides"], + }), + putV2SlidesById: build.mutation< + PutV2SlidesByIdApiResponse, + PutV2SlidesByIdApiArg + >({ + query: (queryArg) => ({ + url: `/v2/slides/${queryArg.id}`, + method: "PUT", + body: queryArg.slideSlideInputJsonld, + }), + invalidatesTags: ["Slides"], + }), + deleteV2SlidesById: build.mutation< + DeleteV2SlidesByIdApiResponse, + DeleteV2SlidesByIdApiArg + >({ + query: (queryArg) => ({ + url: `/v2/slides/${queryArg.id}`, + method: "DELETE", + }), + invalidatesTags: ["Slides"], + }), + apiSlidePerformAction: build.mutation< + ApiSlidePerformActionApiResponse, + ApiSlidePerformActionApiArg + >({ + query: (queryArg) => ({ + url: `/v2/slides/${queryArg.id}/action`, + method: "POST", + body: queryArg.slideInteractiveSlideActionInputJsonld, + }), + invalidatesTags: ["Slides"], + }), + getV2Templates: build.query< + GetV2TemplatesApiResponse, + GetV2TemplatesApiArg + >({ + query: (queryArg) => ({ + url: `/v2/templates`, + params: { + page: queryArg.page, + itemsPerPage: queryArg.itemsPerPage, + title: queryArg.title, + createdBy: queryArg.createdBy, + modifiedBy: queryArg.modifiedBy, + order: queryArg.order, + }, + }), + providesTags: ["Templates"], + }), + getV2TemplatesById: build.query< + GetV2TemplatesByIdApiResponse, + GetV2TemplatesByIdApiArg + >({ + query: (queryArg) => ({ url: `/v2/templates/${queryArg.id}` }), + providesTags: ["Templates"], + }), + getV2Tenants: build.query({ + query: (queryArg) => ({ + url: `/v2/tenants`, + params: { + page: queryArg.page, + itemsPerPage: queryArg.itemsPerPage, + title: queryArg.title, + description: queryArg.description, + createdBy: queryArg.createdBy, + modifiedBy: queryArg.modifiedBy, + }, + }), + providesTags: ["Tenants"], + }), + getV2TenantsById: build.query< + GetV2TenantsByIdApiResponse, + GetV2TenantsByIdApiArg + >({ + query: (queryArg) => ({ url: `/v2/tenants/${queryArg.id}` }), + providesTags: ["Tenants"], + }), + getV2Themes: build.query({ + query: (queryArg) => ({ + url: `/v2/themes`, + params: { + page: queryArg.page, + itemsPerPage: queryArg.itemsPerPage, + title: queryArg.title, + description: queryArg.description, + createdBy: queryArg.createdBy, + modifiedBy: queryArg.modifiedBy, + order: queryArg.order, + }, + }), + providesTags: ["Themes"], + }), + postV2Themes: build.mutation( + { + query: (queryArg) => ({ + url: `/v2/themes`, + method: "POST", + body: queryArg.themeThemeInputJsonld, + }), + invalidatesTags: ["Themes"], + }, + ), + getV2ThemesById: build.query< + GetV2ThemesByIdApiResponse, + GetV2ThemesByIdApiArg + >({ + query: (queryArg) => ({ url: `/v2/themes/${queryArg.id}` }), + providesTags: ["Themes"], + }), + putV2ThemesById: build.mutation< + PutV2ThemesByIdApiResponse, + PutV2ThemesByIdApiArg + >({ + query: (queryArg) => ({ + url: `/v2/themes/${queryArg.id}`, + method: "PUT", + body: queryArg.themeThemeInputJsonld, + }), + invalidatesTags: ["Themes"], + }), + deleteV2ThemesById: build.mutation< + DeleteV2ThemesByIdApiResponse, + DeleteV2ThemesByIdApiArg + >({ + query: (queryArg) => ({ + url: `/v2/themes/${queryArg.id}`, + method: "DELETE", + }), + invalidatesTags: ["Themes"], + }), + getV2Users: build.query({ + query: (queryArg) => ({ + url: `/v2/users`, + params: { + page: queryArg.page, + itemsPerPage: queryArg.itemsPerPage, + fullName: queryArg.fullName, + email: queryArg.email, + createdBy: queryArg.createdBy, + modifiedBy: queryArg.modifiedBy, + order: queryArg.order, + }, + }), + providesTags: ["User"], + }), + postV2Users: build.mutation({ + query: (queryArg) => ({ + url: `/v2/users`, + method: "POST", + body: queryArg.userUserInputJsonld, + }), + invalidatesTags: ["User"], + }), + getV2UsersById: build.query< + GetV2UsersByIdApiResponse, + GetV2UsersByIdApiArg + >({ + query: (queryArg) => ({ url: `/v2/users/${queryArg.id}` }), + providesTags: ["User"], + }), + putV2UsersById: build.mutation< + PutV2UsersByIdApiResponse, + PutV2UsersByIdApiArg + >({ + query: (queryArg) => ({ + url: `/v2/users/${queryArg.id}`, + method: "PUT", + body: queryArg.userUserInputJsonld, + }), + invalidatesTags: ["User"], + }), + deleteV2UsersById: build.mutation< + DeleteV2UsersByIdApiResponse, + DeleteV2UsersByIdApiArg + >({ + query: (queryArg) => ({ + url: `/v2/users/${queryArg.id}`, + method: "DELETE", + }), + invalidatesTags: ["User"], + }), + deleteV2UsersByIdRemoveFromTenant: build.mutation< + DeleteV2UsersByIdRemoveFromTenantApiResponse, + DeleteV2UsersByIdRemoveFromTenantApiArg + >({ + query: (queryArg) => ({ + url: `/v2/users/${queryArg.id}/remove-from-tenant`, + method: "DELETE", + }), + invalidatesTags: ["User"], + }), + getV2UserActivationCodes: build.query< + GetV2UserActivationCodesApiResponse, + GetV2UserActivationCodesApiArg + >({ + query: (queryArg) => ({ + url: `/v2/user-activation-codes`, + params: { + page: queryArg.page, + itemsPerPage: queryArg.itemsPerPage, + }, + }), + providesTags: ["UserActivationCode"], + }), + postV2UserActivationCodes: build.mutation< + PostV2UserActivationCodesApiResponse, + PostV2UserActivationCodesApiArg + >({ + query: (queryArg) => ({ + url: `/v2/user-activation-codes`, + method: "POST", + body: queryArg.userActivationCodeUserActivationCodeInputJsonld, + }), + invalidatesTags: ["UserActivationCode"], + }), + postV2UserActivationCodesActivate: build.mutation< + PostV2UserActivationCodesActivateApiResponse, + PostV2UserActivationCodesActivateApiArg + >({ + query: (queryArg) => ({ + url: `/v2/user-activation-codes/activate`, + method: "POST", + body: queryArg.userActivationCodeActivationCodeJsonld, + }), + invalidatesTags: ["UserActivationCode"], + }), + postV2UserActivationCodesRefresh: build.mutation< + PostV2UserActivationCodesRefreshApiResponse, + PostV2UserActivationCodesRefreshApiArg + >({ + query: (queryArg) => ({ + url: `/v2/user-activation-codes/refresh`, + method: "POST", + body: queryArg.userActivationCodeActivationCodeJsonld, + }), + invalidatesTags: ["UserActivationCode"], + }), + getV2UserActivationCodesById: build.query< + GetV2UserActivationCodesByIdApiResponse, + GetV2UserActivationCodesByIdApiArg + >({ + query: (queryArg) => ({ + url: `/v2/user-activation-codes/${queryArg.id}`, + }), + providesTags: ["UserActivationCode"], + }), + deleteV2UserActivationCodesById: build.mutation< + DeleteV2UserActivationCodesByIdApiResponse, + DeleteV2UserActivationCodesByIdApiArg + >({ + query: (queryArg) => ({ + url: `/v2/user-activation-codes/${queryArg.id}`, + method: "DELETE", + }), + invalidatesTags: ["UserActivationCode"], + }), + }), + overrideExisting: false, + }); +export { injectedRtkApi as clientApi }; +export type GetOidcAuthTokenItemApiResponse = + /** status 200 Get JWT token from OIDC code */ TokenRead; +export type GetOidcAuthTokenItemApiArg = { + /** OIDC state */ + state?: string; + /** OIDC code */ + code?: string; +}; +export type GetOidcAuthUrlsItemApiResponse = + /** status 200 Get authentication and end session endpoints */ OidcEndpoints; +export type GetOidcAuthUrlsItemApiArg = { + /** The key for the provider to use. Leave out to use the default provider */ + providerKey?: string; +}; +export type PostLoginInfoScreenApiResponse = + /** status 200 Login with bindKey to get JWT token for screen */ ScreenLoginOutputRead; +export type PostLoginInfoScreenApiArg = { + /** Get login info with JWT token for given nonce */ + screenLoginInput: ScreenLoginInput; +}; +export type PostRefreshTokenItemApiResponse = + /** status 200 Refresh JWT token */ RefreshTokenResponseRead; +export type PostRefreshTokenItemApiArg = { + /** Refresh JWT Token */ + refreshTokenRequest: RefreshTokenRequest; +}; +export type GetV2FeedSourcesApiResponse = /** status 200 OK */ Blob; +export type GetV2FeedSourcesApiArg = { + page?: number; + /** The number of items per page */ + itemsPerPage?: string; + supportedFeedOutputType?: string; + title?: string; + description?: string; + createdBy?: string; + modifiedBy?: string; + order?: { + title?: "asc" | "desc"; + description?: "asc" | "desc"; + createdAt?: "asc" | "desc"; + modifiedAt?: "asc" | "desc"; + }; +}; +export type PostV2FeedSourcesApiResponse = + /** status 201 FeedSource resource created */ FeedSourceFeedSourceJsonldRead; +export type PostV2FeedSourcesApiArg = { + /** The new FeedSource resource */ + feedSourceFeedSourceInputJsonld: FeedSourceFeedSourceInputJsonld; +}; +export type GetV2FeedSourcesByIdApiResponse = /** status 200 OK */ Blob; +export type GetV2FeedSourcesByIdApiArg = { + id: string; +}; +export type PutV2FeedSourcesByIdApiResponse = + /** status 200 FeedSource resource updated */ FeedSourceFeedSourceJsonldRead; +export type PutV2FeedSourcesByIdApiArg = { + id: string; + /** The updated FeedSource resource */ + feedSourceFeedSourceInputJsonld: FeedSourceFeedSourceInputJsonld; +}; +export type DeleteV2FeedSourcesByIdApiResponse = unknown; +export type DeleteV2FeedSourcesByIdApiArg = { + id: string; +}; +export type GetV2FeedSourcesByIdConfigAndNameApiResponse = + /** status 200 undefined */ Blob; +export type GetV2FeedSourcesByIdConfigAndNameApiArg = { + id: string; + name: string; +}; +export type GetV2FeedSourcesByIdSlidesApiResponse = /** status 200 OK */ Blob; +export type GetV2FeedSourcesByIdSlidesApiArg = { + id: string; + page: number; + /** The number of items per page */ + itemsPerPage?: string; + title?: string; + description?: string; + createdBy?: string; + modifiedBy?: string; + /** If true only published content will be shown */ + published?: boolean; + order?: { + title?: "asc" | "desc"; + description?: "asc" | "desc"; + createdAt?: "asc" | "desc"; + modifiedAt?: "asc" | "desc"; + }; +}; +export type GetV2FeedsApiResponse = /** status 200 OK */ Blob; +export type GetV2FeedsApiArg = { + page?: number; + /** The number of items per page */ + itemsPerPage?: string; + createdBy?: string; + modifiedBy?: string; + order?: { + createdAt?: "asc" | "desc"; + modifiedAt?: "asc" | "desc"; + }; +}; +export type GetV2FeedsByIdApiResponse = /** status 200 OK */ Blob; +export type GetV2FeedsByIdApiArg = { + id: string; +}; +export type GetV2FeedsByIdDataApiResponse = /** status 200 undefined */ Blob; +export type GetV2FeedsByIdDataApiArg = { + id: string; +}; +export type GetV2LayoutsApiResponse = /** status 200 OK */ Blob; +export type GetV2LayoutsApiArg = { + page?: number; + /** The number of items per page */ + itemsPerPage?: number; +}; +export type GetV2LayoutsByIdApiResponse = /** status 200 OK */ Blob; +export type GetV2LayoutsByIdApiArg = { + id: string; +}; +export type LoginCheckPostApiResponse = /** status 200 User token created */ { + token: string; +}; +export type LoginCheckPostApiArg = { + /** The login data */ + body: { + providerId: string; + password: string; + }; +}; +export type GetV2MediaApiResponse = /** status 200 OK */ Blob; +export type GetV2MediaApiArg = { + page?: number; + /** The number of items per page */ + itemsPerPage?: string; + title?: string; + description?: string; + createdBy?: string; + modifiedBy?: string; + order?: { + title?: "asc" | "desc"; + description?: "asc" | "desc"; + createdAt?: "asc" | "desc"; + modifiedAt?: "asc" | "desc"; + }; +}; +export type PostMediaCollectionApiResponse = + /** status 201 Media resource created */ MediaMediaJsonldRead; +export type PostMediaCollectionApiArg = { + body: { + title: string; + description: string; + license: string; + file: Blob; + }; +}; +export type Getv2MediaByIdApiResponse = /** status 200 OK */ Blob; +export type Getv2MediaByIdApiArg = { + id: string; +}; +export type DeleteV2MediaByIdApiResponse = unknown; +export type DeleteV2MediaByIdApiArg = { + id: string; +}; +export type GetV2CampaignsByIdScreenGroupsApiResponse = + /** status 200 ScreenGroupCampaign collection */ { + "hydra:member": ScreenGroupCampaignJsonldCampaignsScreenGroupsReadRead[]; + "hydra:totalItems"?: number; + "hydra:view"?: { + "@id"?: string; + "@type"?: string; + "hydra:first"?: string; + "hydra:last"?: string; + "hydra:previous"?: string; + "hydra:next"?: string; + }; + "hydra:search"?: { + "@type"?: string; + "hydra:template"?: string; + "hydra:variableRepresentation"?: string; + "hydra:mapping"?: { + "@type"?: string; + variable?: string; + property?: string | null; + required?: boolean; + }[]; + }; + }; +export type GetV2CampaignsByIdScreenGroupsApiArg = { + id: string; + page?: number; + /** The number of items per page */ + itemsPerPage?: string; +}; +export type GetV2CampaignsByIdScreensApiResponse = + /** status 200 ScreenCampaign collection */ { + "hydra:member": ScreenCampaignJsonldCampaignsScreensReadRead[]; + "hydra:totalItems"?: number; + "hydra:view"?: { + "@id"?: string; + "@type"?: string; + "hydra:first"?: string; + "hydra:last"?: string; + "hydra:previous"?: string; + "hydra:next"?: string; + }; + "hydra:search"?: { + "@type"?: string; + "hydra:template"?: string; + "hydra:variableRepresentation"?: string; + "hydra:mapping"?: { + "@type"?: string; + variable?: string; + property?: string | null; + required?: boolean; + }[]; + }; + }; +export type GetV2CampaignsByIdScreensApiArg = { + id: string; + page?: number; + /** The number of items per page */ + itemsPerPage?: string; +}; +export type GetV2PlaylistsApiResponse = /** status 200 OK */ Blob; +export type GetV2PlaylistsApiArg = { + page: number; + /** The number of items per page */ + itemsPerPage?: number; + title?: string; + description?: string; + createdBy?: string; + modifiedBy?: string; + /** If true only published content will be shown */ + published?: boolean; + /** If true only campaigns will be shown */ + isCampaign?: boolean; + order?: { + title?: "asc" | "desc"; + description?: "asc" | "desc"; + createdAt?: "asc" | "desc"; + modifiedAt?: "asc" | "desc"; + }; + /** If true only entities that are shared with me will be shown */ + sharedWithMe?: boolean; +}; +export type PostV2PlaylistsApiResponse = + /** status 201 Playlist resource created */ PlaylistPlaylistJsonldRead; +export type PostV2PlaylistsApiArg = { + /** The new Playlist resource */ + playlistPlaylistInputJsonld: PlaylistPlaylistInputJsonld; +}; +export type GetV2PlaylistsByIdApiResponse = /** status 200 OK */ Blob; +export type GetV2PlaylistsByIdApiArg = { + id: string; +}; +export type PutV2PlaylistsByIdApiResponse = + /** status 200 Playlist resource updated */ PlaylistPlaylistJsonldRead; +export type PutV2PlaylistsByIdApiArg = { + id: string; + /** The updated Playlist resource */ + playlistPlaylistInputJsonld: PlaylistPlaylistInputJsonld; +}; +export type DeleteV2PlaylistsByIdApiResponse = unknown; +export type DeleteV2PlaylistsByIdApiArg = { + id: string; +}; +export type GetV2PlaylistsByIdSlidesApiResponse = /** status 200 OK */ Blob; +export type GetV2PlaylistsByIdSlidesApiArg = { + id: string; + page: number; + /** The number of items per page */ + itemsPerPage?: string; + /** If true only published content will be shown */ + published?: boolean; +}; +export type PutV2PlaylistsByIdSlidesApiResponse = /** status 201 Created */ { + slide?: string; + playlist?: string; + weight?: number; +}[]; +export type PutV2PlaylistsByIdSlidesApiArg = { + /** PlaylistSlide identifier */ + id: string; + body: { + /** Slide ULID */ + slide?: string; + weight?: number; + }[]; +}; +export type DeleteV2PlaylistsByIdSlidesAndSlideIdApiResponse = unknown; +export type DeleteV2PlaylistsByIdSlidesAndSlideIdApiArg = { + id: string; + slideId: string; +}; +export type GetV2SlidesByIdPlaylistsApiResponse = /** status 200 OK */ Blob; +export type GetV2SlidesByIdPlaylistsApiArg = { + id: string; + page?: number; + /** The number of items per page */ + itemsPerPage?: string; + /** If true only published content will be shown */ + published?: boolean; +}; +export type PutV2SlidesByIdPlaylistsApiResponse = + /** status 200 PlaylistSlide resource updated */ PlaylistSlidePlaylistSlideJsonldRead; +export type PutV2SlidesByIdPlaylistsApiArg = { + id: string; + body: { + /** Playlist ULID */ + playlist?: string; + }[]; +}; +export type GetScreenGroupCampaignItemApiResponse = + /** status 200 ScreenGroupCampaign resource */ ScreenGroupCampaignJsonldRead; +export type GetScreenGroupCampaignItemApiArg = { + /** ScreenGroupCampaign identifier */ + id: string; +}; +export type GetV2ScreenGroupsApiResponse = /** status 200 OK */ Blob; +export type GetV2ScreenGroupsApiArg = { + page?: number; + /** The number of items per page */ + itemsPerPage?: string; + title?: string; + description?: string; + createdBy?: string; + modifiedBy?: string; + order?: { + title?: "asc" | "desc"; + description?: "asc" | "desc"; + createdAt?: "asc" | "desc"; + }; +}; +export type PostV2ScreenGroupsApiResponse = + /** status 201 ScreenGroup resource created */ ScreenGroupScreenGroupJsonldRead; +export type PostV2ScreenGroupsApiArg = { + /** The new ScreenGroup resource */ + screenGroupScreenGroupInputJsonld: ScreenGroupScreenGroupInputJsonld; +}; +export type GetV2ScreenGroupsByIdApiResponse = /** status 200 OK */ Blob; +export type GetV2ScreenGroupsByIdApiArg = { + id: string; +}; +export type PutV2ScreenGroupsByIdApiResponse = + /** status 200 ScreenGroup resource updated */ ScreenGroupScreenGroupJsonldRead; +export type PutV2ScreenGroupsByIdApiArg = { + id: string; + /** The updated ScreenGroup resource */ + screenGroupScreenGroupInputJsonld: ScreenGroupScreenGroupInputJsonld; +}; +export type DeleteV2ScreenGroupsByIdApiResponse = unknown; +export type DeleteV2ScreenGroupsByIdApiArg = { + id: string; +}; +export type GetV2ScreenGroupsByIdCampaignsApiResponse = + /** status 200 OK */ Blob; +export type GetV2ScreenGroupsByIdCampaignsApiArg = { + id: string; + page: number; + /** The number of items per page */ + itemsPerPage?: string; + /** If true only published content will be shown */ + published?: boolean; +}; +export type PutV2ScreenGroupsByIdCampaignsApiResponse = + /** status 201 Created */ { + playlist?: string; + "screen-group"?: string; + }[]; +export type PutV2ScreenGroupsByIdCampaignsApiArg = { + /** ScreenGroupCampaign identifier */ + id: string; + body: { + /** Screen group ULID */ + screenGroup?: string; + }[]; +}; +export type DeleteV2ScreenGroupsByIdCampaignsAndCampaignIdApiResponse = unknown; +export type DeleteV2ScreenGroupsByIdCampaignsAndCampaignIdApiArg = { + id: string; + campaignId: string; +}; +export type GetV2ScreenGroupsByIdScreensApiResponse = + /** status 200 ScreenGroup collection */ { + "hydra:member": ScreenGroupJsonldScreenGroupsScreensReadRead[]; + "hydra:totalItems"?: number; + "hydra:view"?: { + "@id"?: string; + "@type"?: string; + "hydra:first"?: string; + "hydra:last"?: string; + "hydra:previous"?: string; + "hydra:next"?: string; + }; + "hydra:search"?: { + "@type"?: string; + "hydra:template"?: string; + "hydra:variableRepresentation"?: string; + "hydra:mapping"?: { + "@type"?: string; + variable?: string; + property?: string | null; + required?: boolean; + }[]; + }; + }; +export type GetV2ScreenGroupsByIdScreensApiArg = { + id: string; + page?: number; + /** The number of items per page */ + itemsPerPage?: string; +}; +export type GetV2ScreensApiResponse = /** status 200 OK */ Blob; +export type GetV2ScreensApiArg = { + page?: number; + /** The number of items per page */ + itemsPerPage?: string; + /** Search on both location and title */ + search?: string; + exists?: { + screenUser?: boolean; + }; + "screenUser.latestRequest"?: { + before?: string; + strictly_before?: string; + after?: string; + strictly_after?: string; + }; + createdBy?: string; + modifiedBy?: string; + order?: { + title?: "asc" | "desc"; + description?: "asc" | "desc"; + createdAt?: "asc" | "desc"; + modifiedAt?: "asc" | "desc"; + }; +}; +export type PostV2ScreensApiResponse = + /** status 201 Screen resource created */ ScreenScreenJsonldRead; +export type PostV2ScreensApiArg = { + /** The new Screen resource */ + screenScreenInputJsonld: ScreenScreenInputJsonld; +}; +export type GetV2ScreensByIdApiResponse = /** status 200 OK */ Blob; +export type GetV2ScreensByIdApiArg = { + id: string; +}; +export type PutV2ScreensByIdApiResponse = + /** status 200 Screen resource updated */ ScreenScreenJsonldRead; +export type PutV2ScreensByIdApiArg = { + id: string; + /** The updated Screen resource */ + screenScreenInputJsonld: ScreenScreenInputJsonld; +}; +export type DeleteV2ScreensByIdApiResponse = unknown; +export type DeleteV2ScreensByIdApiArg = { + id: string; +}; +export type PostScreenBindKeyApiResponse = unknown; +export type PostScreenBindKeyApiArg = { + /** The screen id */ + id: string; + /** Bind the screen with the bind key */ + screenBindObject: ScreenBindObject; +}; +export type GetV2ScreensByIdCampaignsApiResponse = /** status 200 OK */ Blob; +export type GetV2ScreensByIdCampaignsApiArg = { + id: string; + page: number; + /** The number of items per page */ + itemsPerPage?: string; + /** If true only published content will be shown */ + published?: boolean; +}; +export type PutV2ScreensByIdCampaignsApiResponse = /** status 201 Created */ { + playlist?: string; + screen?: string; +}[]; +export type PutV2ScreensByIdCampaignsApiArg = { + /** ScreenCampaign identifier */ + id: string; + body: { + /** Screen ULID */ + screen?: string; + }[]; +}; +export type DeleteV2ScreensByIdCampaignsAndCampaignIdApiResponse = unknown; +export type DeleteV2ScreensByIdCampaignsAndCampaignIdApiArg = { + id: string; + campaignId: string; +}; +export type GetV2ScreensByIdRegionsAndRegionIdPlaylistsApiResponse = + /** status 200 PlaylistScreenRegion collection */ { + "hydra:member": PlaylistScreenRegionJsonldPlaylistScreenRegionReadRead[]; + "hydra:totalItems"?: number; + "hydra:view"?: { + "@id"?: string; + "@type"?: string; + "hydra:first"?: string; + "hydra:last"?: string; + "hydra:previous"?: string; + "hydra:next"?: string; + }; + "hydra:search"?: { + "@type"?: string; + "hydra:template"?: string; + "hydra:variableRepresentation"?: string; + "hydra:mapping"?: { + "@type"?: string; + variable?: string; + property?: string | null; + required?: boolean; + }[]; + }; + }; +export type GetV2ScreensByIdRegionsAndRegionIdPlaylistsApiArg = { + id: string; + regionId: string; + page: number; + /** The number of items per page */ + itemsPerPage?: string; + /** If true only entities that are shared with me will be shown */ + sharedWithMe?: boolean; +}; +export type PutPlaylistScreenRegionItemApiResponse = unknown; +export type PutPlaylistScreenRegionItemApiArg = { + id: string; + regionId: string; + body: { + /** Playlist ULID */ + playlist?: string; + weight?: number; + }[]; +}; +export type DeletePlaylistScreenRegionItemApiResponse = unknown; +export type DeletePlaylistScreenRegionItemApiArg = { + id: string; + regionId: string; + playlistId: string; +}; +export type GetV2ScreensByIdScreenGroupsApiResponse = + /** status 200 ScreenGroup collection */ { + "hydra:member": ScreenGroupScreenGroupJsonldScreensScreenGroupsReadRead[]; + "hydra:totalItems"?: number; + "hydra:view"?: { + "@id"?: string; + "@type"?: string; + "hydra:first"?: string; + "hydra:last"?: string; + "hydra:previous"?: string; + "hydra:next"?: string; + }; + "hydra:search"?: { + "@type"?: string; + "hydra:template"?: string; + "hydra:variableRepresentation"?: string; + "hydra:mapping"?: { + "@type"?: string; + variable?: string; + property?: string | null; + required?: boolean; + }[]; + }; + }; +export type GetV2ScreensByIdScreenGroupsApiArg = { + id: string; + page: number; + /** The number of items per page */ + itemsPerPage?: string; + order?: { + title?: "asc" | "desc"; + description?: "asc" | "desc"; + }; +}; +export type PutV2ScreensByIdScreenGroupsApiResponse = /** status 200 OK */ Blob; +export type PutV2ScreensByIdScreenGroupsApiArg = { + id: string; + body: string[]; +}; +export type DeleteV2ScreensByIdScreenGroupsAndScreenGroupIdApiResponse = + unknown; +export type DeleteV2ScreensByIdScreenGroupsAndScreenGroupIdApiArg = { + id: string; + screenGroupId: string; +}; +export type PostScreenUnbindApiResponse = unknown; +export type PostScreenUnbindApiArg = { + /** The screen id */ + id: string; + /** Unbind from machine */ + body: string; +}; +export type GetV2SlidesApiResponse = /** status 200 OK */ Blob; +export type GetV2SlidesApiArg = { + page?: number; + /** The number of items per page */ + itemsPerPage?: string; + title?: string; + description?: string; + createdBy?: string; + modifiedBy?: string; + /** If true only published content will be shown */ + published?: boolean; + order?: { + title?: "asc" | "desc"; + description?: "asc" | "desc"; + createdAt?: "asc" | "desc"; + modifiedAt?: "asc" | "desc"; + }; +}; +export type PostV2SlidesApiResponse = + /** status 201 Slide resource created */ SlideSlideJsonldRead; +export type PostV2SlidesApiArg = { + /** The new Slide resource */ + slideSlideInputJsonld: SlideSlideInputJsonld; +}; +export type GetV2SlidesByIdApiResponse = /** status 200 OK */ Blob; +export type GetV2SlidesByIdApiArg = { + id: string; +}; +export type PutV2SlidesByIdApiResponse = + /** status 200 Slide resource updated */ SlideSlideJsonldRead; +export type PutV2SlidesByIdApiArg = { + id: string; + /** The updated Slide resource */ + slideSlideInputJsonld: SlideSlideInputJsonld; +}; +export type DeleteV2SlidesByIdApiResponse = unknown; +export type DeleteV2SlidesByIdApiArg = { + id: string; +}; +export type ApiSlidePerformActionApiResponse = + /** status 201 Slide resource created */ SlideSlideJsonldRead; +export type ApiSlidePerformActionApiArg = { + id: string; + /** The new Slide resource */ + slideInteractiveSlideActionInputJsonld: SlideInteractiveSlideActionInputJsonld; +}; +export type GetV2TemplatesApiResponse = /** status 200 OK */ Blob; +export type GetV2TemplatesApiArg = { + page?: number; + /** The number of items per page */ + itemsPerPage?: string; + title?: string; + createdBy?: string; + modifiedBy?: string; + order?: { + createdAt?: "asc" | "desc"; + modifiedAt?: "asc" | "desc"; + }; +}; +export type GetV2TemplatesByIdApiResponse = /** status 200 OK */ Blob; +export type GetV2TemplatesByIdApiArg = { + id: string; +}; +export type GetV2TenantsApiResponse = /** status 200 OK */ Blob; +export type GetV2TenantsApiArg = { + page?: number; + /** The number of items per page */ + itemsPerPage?: string; + title?: string; + description?: string; + createdBy?: string; + modifiedBy?: string; +}; +export type GetV2TenantsByIdApiResponse = /** status 200 OK */ Blob; +export type GetV2TenantsByIdApiArg = { + id: string; +}; +export type GetV2ThemesApiResponse = /** status 200 OK */ Blob; +export type GetV2ThemesApiArg = { + page?: number; + /** The number of items per page */ + itemsPerPage?: string; + title?: string; + description?: string; + createdBy?: string; + modifiedBy?: string; + order?: { + title?: "asc" | "desc"; + description?: "asc" | "desc"; + createdAt?: "asc" | "desc"; + modifiedAt?: "asc" | "desc"; + }; +}; +export type PostV2ThemesApiResponse = + /** status 201 Theme resource created */ ThemeThemeJsonldRead; +export type PostV2ThemesApiArg = { + /** The new Theme resource */ + themeThemeInputJsonld: ThemeThemeInputJsonld; +}; +export type GetV2ThemesByIdApiResponse = /** status 200 OK */ Blob; +export type GetV2ThemesByIdApiArg = { + id: string; +}; +export type PutV2ThemesByIdApiResponse = + /** status 200 Theme resource updated */ ThemeThemeJsonldRead; +export type PutV2ThemesByIdApiArg = { + id: string; + /** The updated Theme resource */ + themeThemeInputJsonld: ThemeThemeInputJsonld; +}; +export type DeleteV2ThemesByIdApiResponse = unknown; +export type DeleteV2ThemesByIdApiArg = { + id: string; +}; +export type GetV2UsersApiResponse = /** status 200 OK */ Blob; +export type GetV2UsersApiArg = { + page?: number; + /** The number of items per page */ + itemsPerPage?: string; + fullName?: string; + email?: string; + createdBy?: string; + modifiedBy?: string; + order?: { + createdAt?: "asc" | "desc"; + }; +}; +export type PostV2UsersApiResponse = + /** status 201 User resource created */ UserUserJsonldRead; +export type PostV2UsersApiArg = { + id: string; + /** The new User resource */ + userUserInputJsonld: UserUserInputJsonld; +}; +export type GetV2UsersByIdApiResponse = /** status 200 OK */ Blob; +export type GetV2UsersByIdApiArg = { + id: string; +}; +export type PutV2UsersByIdApiResponse = + /** status 200 User resource updated */ UserUserJsonldRead; +export type PutV2UsersByIdApiArg = { + id: string; + /** The updated User resource */ + userUserInputJsonld: UserUserInputJsonld; +}; +export type DeleteV2UsersByIdApiResponse = unknown; +export type DeleteV2UsersByIdApiArg = { + id: string; +}; +export type DeleteV2UsersByIdRemoveFromTenantApiResponse = unknown; +export type DeleteV2UsersByIdRemoveFromTenantApiArg = { + id: string; +}; +export type GetV2UserActivationCodesApiResponse = + /** status 200 UserActivationCode collection */ { + "hydra:member": UserActivationCodeUserActivationCodeJsonldRead[]; + "hydra:totalItems"?: number; + "hydra:view"?: { + "@id"?: string; + "@type"?: string; + "hydra:first"?: string; + "hydra:last"?: string; + "hydra:previous"?: string; + "hydra:next"?: string; + }; + "hydra:search"?: { + "@type"?: string; + "hydra:template"?: string; + "hydra:variableRepresentation"?: string; + "hydra:mapping"?: { + "@type"?: string; + variable?: string; + property?: string | null; + required?: boolean; + }[]; + }; + }; +export type GetV2UserActivationCodesApiArg = { + /** The collection page number */ + page?: number; + /** The number of items per page */ + itemsPerPage?: number; +}; +export type PostV2UserActivationCodesApiResponse = + /** status 201 UserActivationCode resource created */ UserActivationCodeUserActivationCodeJsonldRead; +export type PostV2UserActivationCodesApiArg = { + /** The new UserActivationCode resource */ + userActivationCodeUserActivationCodeInputJsonld: UserActivationCodeUserActivationCodeInputJsonld; +}; +export type PostV2UserActivationCodesActivateApiResponse = + /** status 201 UserActivationCode resource created */ UserActivationCodeUserActivationCodeJsonldRead; +export type PostV2UserActivationCodesActivateApiArg = { + /** The new UserActivationCode resource */ + userActivationCodeActivationCodeJsonld: UserActivationCodeActivationCodeJsonld; +}; +export type PostV2UserActivationCodesRefreshApiResponse = + /** status 201 UserActivationCode resource created */ UserActivationCodeUserActivationCodeJsonldRead; +export type PostV2UserActivationCodesRefreshApiArg = { + /** The new UserActivationCode resource */ + userActivationCodeActivationCodeJsonld: UserActivationCodeActivationCodeJsonld; +}; +export type GetV2UserActivationCodesByIdApiResponse = + /** status 200 UserActivationCode resource */ UserActivationCodeJsonldRead; +export type GetV2UserActivationCodesByIdApiArg = { + /** UserActivationCode identifier */ + id: string; +}; +export type DeleteV2UserActivationCodesByIdApiResponse = unknown; +export type DeleteV2UserActivationCodesByIdApiArg = { + /** UserActivationCode identifier */ + id: string; +}; +export type Token = {}; +export type TokenRead = { + token?: string; + refresh_token?: string; + refresh_token_expiration?: any; + tenants?: { + tenantKey?: string; + title?: string; + description?: string; + roles?: string[]; + }[]; + user?: { + fullname?: string; + email?: string; + }; +}; +export type OidcEndpoints = { + authorizationUrl?: string; + endSessionUrl?: string; +}; +export type ScreenLoginOutput = {}; +export type ScreenLoginOutputRead = { + bindKey?: string; + token?: string; +}; +export type ScreenLoginInput = object; +export type RefreshTokenResponse = {}; +export type RefreshTokenResponseRead = { + token?: string; + refresh_token?: string; +}; +export type RefreshTokenRequest = { + refresh_token?: string; +}; +export type FeedSourceFeedSourceJsonld = { + title?: string; + description?: string; + outputType?: string; + feedType?: string; + secrets?: string[]; + feeds?: string[]; + admin?: string[]; + supportedFeedOutputType?: string; + modifiedBy?: string; + createdBy?: string; + id?: string; + created?: string; + modified?: string; +}; +export type FeedSourceFeedSourceJsonldRead = { + "@context"?: + | string + | { + "@vocab": string; + hydra: "http://www.w3.org/ns/hydra/core#"; + [key: string]: any; + }; + "@id"?: string; + "@type"?: string; + title?: string; + description?: string; + outputType?: string; + feedType?: string; + secrets?: string[]; + feeds?: string[]; + admin?: string[]; + supportedFeedOutputType?: string; + modifiedBy?: string; + createdBy?: string; + id?: string; + created?: string; + modified?: string; +}; +export type FeedSourceFeedSourceInputJsonld = { + title?: string; + description?: string; + outputType?: string; + feedType?: string; + secrets?: string[]; + feeds?: string[]; + supportedFeedOutputType?: string; +}; +export type CollectionJsonld = {}; +export type CollectionJsonldRead = { + "@context"?: + | string + | { + "@vocab": string; + hydra: "http://www.w3.org/ns/hydra/core#"; + [key: string]: any; + }; + "@id"?: string; + "@type"?: string; + /** Checks whether the collection is empty (contains no elements). */ + empty?: boolean; + /** Gets all keys/indices of the collection. */ + keys?: number[] | string[]; + /** Gets all values of the collection. */ + values?: string[]; + iterator?: any; +}; +export type MediaMediaJsonld = { + title?: string; + description?: string; + license?: string; + media?: CollectionJsonld; + assets?: string[]; + thumbnail?: string | null; + modifiedBy?: string; + createdBy?: string; + id?: string; + created?: string; + modified?: string; +}; +export type MediaMediaJsonldRead = { + "@context"?: + | string + | { + "@vocab": string; + hydra: "http://www.w3.org/ns/hydra/core#"; + [key: string]: any; + }; + "@id"?: string; + "@type"?: string; + title?: string; + description?: string; + license?: string; + media?: CollectionJsonldRead; + assets?: string[]; + thumbnail?: string | null; + modifiedBy?: string; + createdBy?: string; + id?: string; + created?: string; + modified?: string; +}; +export type CollectionJsonldCampaignsScreenGroupsRead = {}; +export type CollectionJsonldCampaignsScreenGroupsReadRead = { + "@context"?: + | string + | { + "@vocab": string; + hydra: "http://www.w3.org/ns/hydra/core#"; + [key: string]: any; + }; + "@id"?: string; + "@type"?: string; +}; +export type PlaylistJsonldCampaignsScreenGroupsRead = { + title?: string; + description?: string; + schedules?: string[] | null; + slides?: string; + campaignScreens?: CollectionJsonldCampaignsScreenGroupsRead | null; + campaignScreenGroups?: CollectionJsonldCampaignsScreenGroupsRead | null; + tenants?: CollectionJsonldCampaignsScreenGroupsRead | null; + isCampaign?: boolean; + published?: string[]; + relationsChecksum?: object; +}; +export type PlaylistJsonldCampaignsScreenGroupsReadRead = { + "@context"?: + | string + | { + "@vocab": string; + hydra: "http://www.w3.org/ns/hydra/core#"; + [key: string]: any; + }; + "@id"?: string; + "@type"?: string; + title?: string; + description?: string; + schedules?: string[] | null; + slides?: string; + campaignScreens?: CollectionJsonldCampaignsScreenGroupsReadRead | null; + campaignScreenGroups?: CollectionJsonldCampaignsScreenGroupsReadRead | null; + tenants?: CollectionJsonldCampaignsScreenGroupsReadRead | null; + isCampaign?: boolean; + published?: string[]; + relationsChecksum?: object; +}; +export type ScreenGroupJsonldCampaignsScreenGroupsRead = { + title?: string; + description?: string; + campaigns?: string; + screens?: string; + relationsChecksum?: object; +}; +export type ScreenGroupJsonldCampaignsScreenGroupsReadRead = { + "@context"?: + | string + | { + "@vocab": string; + hydra: "http://www.w3.org/ns/hydra/core#"; + [key: string]: any; + }; + "@id"?: string; + "@type"?: string; + title?: string; + description?: string; + campaigns?: string; + screens?: string; + relationsChecksum?: object; +}; +export type ScreenGroupCampaignJsonldCampaignsScreenGroupsRead = { + campaign?: PlaylistJsonldCampaignsScreenGroupsRead; + screenGroup?: ScreenGroupJsonldCampaignsScreenGroupsRead; + relationsChecksum?: object; +}; +export type ScreenGroupCampaignJsonldCampaignsScreenGroupsReadRead = { + "@id"?: string; + "@type"?: string; + campaign?: PlaylistJsonldCampaignsScreenGroupsReadRead; + screenGroup?: ScreenGroupJsonldCampaignsScreenGroupsReadRead; + relationsChecksum?: object; +}; +export type CollectionJsonldCampaignsScreensRead = {}; +export type CollectionJsonldCampaignsScreensReadRead = { + "@context"?: + | string + | { + "@vocab": string; + hydra: "http://www.w3.org/ns/hydra/core#"; + [key: string]: any; + }; + "@id"?: string; + "@type"?: string; +}; +export type PlaylistJsonldCampaignsScreensRead = { + title?: string; + description?: string; + schedules?: string[] | null; + slides?: string; + campaignScreens?: CollectionJsonldCampaignsScreensRead | null; + campaignScreenGroups?: CollectionJsonldCampaignsScreensRead | null; + tenants?: CollectionJsonldCampaignsScreensRead | null; + isCampaign?: boolean; + published?: string[]; + relationsChecksum?: object; +}; +export type PlaylistJsonldCampaignsScreensReadRead = { + "@context"?: + | string + | { + "@vocab": string; + hydra: "http://www.w3.org/ns/hydra/core#"; + [key: string]: any; + }; + "@id"?: string; + "@type"?: string; + title?: string; + description?: string; + schedules?: string[] | null; + slides?: string; + campaignScreens?: CollectionJsonldCampaignsScreensReadRead | null; + campaignScreenGroups?: CollectionJsonldCampaignsScreensReadRead | null; + tenants?: CollectionJsonldCampaignsScreensReadRead | null; + isCampaign?: boolean; + published?: string[]; + relationsChecksum?: object; +}; +export type ScreenJsonldCampaignsScreensRead = { + title?: string; + description?: string; + size?: string; + campaigns?: string; + layout?: string; + orientation?: string; + resolution?: string; + location?: string; + regions?: string[]; + inScreenGroups?: string; + screenUser?: string | null; + enableColorSchemeChange?: boolean | null; + status?: string[] | null; + relationsChecksum?: object; +}; +export type ScreenJsonldCampaignsScreensReadRead = { + "@context"?: + | string + | { + "@vocab": string; + hydra: "http://www.w3.org/ns/hydra/core#"; + [key: string]: any; + }; + "@id"?: string; + "@type"?: string; + title?: string; + description?: string; + size?: string; + campaigns?: string; + layout?: string; + orientation?: string; + resolution?: string; + location?: string; + regions?: string[]; + inScreenGroups?: string; + screenUser?: string | null; + enableColorSchemeChange?: boolean | null; + status?: string[] | null; + relationsChecksum?: object; +}; +export type ScreenCampaignJsonldCampaignsScreensRead = { + campaign?: PlaylistJsonldCampaignsScreensRead; + screen?: ScreenJsonldCampaignsScreensRead; + relationsChecksum?: object; +}; +export type ScreenCampaignJsonldCampaignsScreensReadRead = { + "@id"?: string; + "@type"?: string; + campaign?: PlaylistJsonldCampaignsScreensReadRead; + screen?: ScreenJsonldCampaignsScreensReadRead; + relationsChecksum?: object; +}; +export type PlaylistPlaylistJsonld = { + title?: string; + description?: string; + schedules?: string[] | null; + slides?: string; + campaignScreens?: CollectionJsonld | null; + campaignScreenGroups?: CollectionJsonld | null; + tenants?: CollectionJsonld | null; + isCampaign?: boolean; + published?: string[]; + modifiedBy?: string; + createdBy?: string; + id?: string; + created?: string; + modified?: string; + relationsChecksum?: object; +}; +export type PlaylistPlaylistJsonldRead = { + "@context"?: + | string + | { + "@vocab": string; + hydra: "http://www.w3.org/ns/hydra/core#"; + [key: string]: any; + }; + "@id"?: string; + "@type"?: string; + title?: string; + description?: string; + schedules?: string[] | null; + slides?: string; + campaignScreens?: CollectionJsonldRead | null; + campaignScreenGroups?: CollectionJsonldRead | null; + tenants?: CollectionJsonldRead | null; + isCampaign?: boolean; + published?: string[]; + modifiedBy?: string; + createdBy?: string; + id?: string; + created?: string; + modified?: string; + relationsChecksum?: object; +}; +export type PlaylistPlaylistInputJsonld = { + title?: string; + description?: string; + schedules?: string[]; + tenants?: string[]; + isCampaign?: boolean; + published?: string[]; +}; +export type PlaylistSlidePlaylistSlideJsonld = { + slide?: string; + playlist?: string; + weight?: number; + id?: string; + relationsChecksum?: object; +}; +export type PlaylistSlidePlaylistSlideJsonldRead = { + "@context"?: + | string + | { + "@vocab": string; + hydra: "http://www.w3.org/ns/hydra/core#"; + [key: string]: any; + }; + "@id"?: string; + "@type"?: string; + slide?: string; + playlist?: string; + weight?: number; + id?: string; + relationsChecksum?: object; +}; +export type ScreenGroupCampaignJsonld = { + campaign?: string; + screenGroup?: string; + id?: string; + relationsChecksum?: object; +}; +export type ScreenGroupCampaignJsonldRead = { + "@context"?: + | string + | { + "@vocab": string; + hydra: "http://www.w3.org/ns/hydra/core#"; + [key: string]: any; + }; + "@id"?: string; + "@type"?: string; + campaign?: string; + screenGroup?: string; + id?: string; + relationsChecksum?: object; +}; +export type ScreenGroupScreenGroupJsonld = { + title?: string; + description?: string; + campaigns?: string; + screens?: string; + modifiedBy?: string; + createdBy?: string; + id?: string; + created?: string; + modified?: string; + relationsChecksum?: object; +}; +export type ScreenGroupScreenGroupJsonldRead = { + "@context"?: + | string + | { + "@vocab": string; + hydra: "http://www.w3.org/ns/hydra/core#"; + [key: string]: any; + }; + "@id"?: string; + "@type"?: string; + title?: string; + description?: string; + campaigns?: string; + screens?: string; + modifiedBy?: string; + createdBy?: string; + id?: string; + created?: string; + modified?: string; + relationsChecksum?: object; +}; +export type ScreenGroupScreenGroupInputJsonld = { + title?: string; + description?: string; +}; +export type ScreenGroupJsonldScreenGroupsScreensRead = { + relationsChecksum?: object; +}; +export type ScreenGroupJsonldScreenGroupsScreensReadRead = { + "@id"?: string; + "@type"?: string; + relationsChecksum?: object; +}; +export type ScreenScreenJsonld = { + title?: string; + description?: string; + size?: string; + campaigns?: string; + layout?: string; + orientation?: string; + resolution?: string; + location?: string; + regions?: string[]; + inScreenGroups?: string; + screenUser?: string | null; + enableColorSchemeChange?: boolean | null; + status?: string[] | null; + modifiedBy?: string; + createdBy?: string; + id?: string; + created?: string; + modified?: string; + relationsChecksum?: object; +}; +export type ScreenScreenJsonldRead = { + "@context"?: + | string + | { + "@vocab": string; + hydra: "http://www.w3.org/ns/hydra/core#"; + [key: string]: any; + }; + "@id"?: string; + "@type"?: string; + title?: string; + description?: string; + size?: string; + campaigns?: string; + layout?: string; + orientation?: string; + resolution?: string; + location?: string; + regions?: string[]; + inScreenGroups?: string; + screenUser?: string | null; + enableColorSchemeChange?: boolean | null; + status?: string[] | null; + modifiedBy?: string; + createdBy?: string; + id?: string; + created?: string; + modified?: string; + relationsChecksum?: object; +}; +export type ScreenScreenInputJsonld = { + title?: string; + description?: string; + size?: string; + layout?: string; + location?: string; + resolution?: string; + orientation?: string; + enableColorSchemeChange?: boolean | null; + regions?: string[] | null; + groups?: string[] | null; +}; +export type ScreenBindObject = { + bindKey?: string; +}; +export type CollectionJsonldPlaylistScreenRegionRead = {}; +export type CollectionJsonldPlaylistScreenRegionReadRead = { + "@context"?: + | string + | { + "@vocab": string; + hydra: "http://www.w3.org/ns/hydra/core#"; + [key: string]: any; + }; + "@id"?: string; + "@type"?: string; +}; +export type PlaylistJsonldPlaylistScreenRegionRead = { + title?: string; + description?: string; + schedules?: string[] | null; + slides?: string; + campaignScreens?: CollectionJsonldPlaylistScreenRegionRead | null; + campaignScreenGroups?: CollectionJsonldPlaylistScreenRegionRead | null; + tenants?: CollectionJsonldPlaylistScreenRegionRead | null; + isCampaign?: boolean; + published?: string[]; + relationsChecksum?: object; +}; +export type PlaylistJsonldPlaylistScreenRegionReadRead = { + "@context"?: + | string + | { + "@vocab": string; + hydra: "http://www.w3.org/ns/hydra/core#"; + [key: string]: any; + }; + "@id"?: string; + "@type"?: string; + title?: string; + description?: string; + schedules?: string[] | null; + slides?: string; + campaignScreens?: CollectionJsonldPlaylistScreenRegionReadRead | null; + campaignScreenGroups?: CollectionJsonldPlaylistScreenRegionReadRead | null; + tenants?: CollectionJsonldPlaylistScreenRegionReadRead | null; + isCampaign?: boolean; + published?: string[]; + relationsChecksum?: object; +}; +export type PlaylistScreenRegionJsonldPlaylistScreenRegionRead = { + playlist?: PlaylistJsonldPlaylistScreenRegionRead; + weight?: number; + relationsChecksum?: object; +}; +export type PlaylistScreenRegionJsonldPlaylistScreenRegionReadRead = { + "@id"?: string; + "@type"?: string; + playlist?: PlaylistJsonldPlaylistScreenRegionReadRead; + weight?: number; + relationsChecksum?: object; +}; +export type ScreenGroupScreenGroupJsonldScreensScreenGroupsRead = { + title?: string; + description?: string; + campaigns?: string; + screens?: string; + relationsChecksum?: object; +}; +export type ScreenGroupScreenGroupJsonldScreensScreenGroupsReadRead = { + "@id"?: string; + "@type"?: string; + title?: string; + description?: string; + campaigns?: string; + screens?: string; + relationsChecksum?: object; +}; +export type SlideSlideJsonld = { + title?: string; + description?: string; + templateInfo?: string[]; + theme?: string; + onPlaylists?: CollectionJsonld; + duration?: number | null; + published?: string[]; + media?: CollectionJsonld; + content?: string[]; + feed?: string[] | null; + modifiedBy?: string; + createdBy?: string; + id?: string; + created?: string; + modified?: string; + relationsChecksum?: object; +}; +export type SlideSlideJsonldRead = { + "@context"?: + | string + | { + "@vocab": string; + hydra: "http://www.w3.org/ns/hydra/core#"; + [key: string]: any; + }; + "@id"?: string; + "@type"?: string; + title?: string; + description?: string; + templateInfo?: string[]; + theme?: string; + onPlaylists?: CollectionJsonldRead; + duration?: number | null; + published?: string[]; + media?: CollectionJsonldRead; + content?: string[]; + feed?: string[] | null; + modifiedBy?: string; + createdBy?: string; + id?: string; + created?: string; + modified?: string; + relationsChecksum?: object; +}; +export type SlideSlideInputJsonld = { + title?: string; + description?: string; + templateInfo?: string[]; + theme?: string; + duration?: number | null; + published?: string[]; + feed?: string[] | null; + media?: string[]; + content?: string[]; +}; +export type SlideInteractiveSlideActionInputJsonld = { + action?: string | null; + data?: string[]; +}; +export type ThemeThemeJsonld = { + title?: string; + description?: string; + logo?: string | null; + cssStyles?: string; + modifiedBy?: string; + createdBy?: string; + id?: string; + created?: string; + modified?: string; +}; +export type ThemeThemeJsonldRead = { + "@context"?: + | string + | { + "@vocab": string; + hydra: "http://www.w3.org/ns/hydra/core#"; + [key: string]: any; + }; + "@id"?: string; + "@type"?: string; + title?: string; + description?: string; + logo?: string | null; + cssStyles?: string; + modifiedBy?: string; + createdBy?: string; + id?: string; + created?: string; + modified?: string; +}; +export type ThemeThemeInputJsonld = { + title?: string; + description?: string; + logo?: string; + css?: string; +}; +export type UserUserJsonld = { + fullName?: string | null; + userType?: + | ("OIDC_EXTERNAL" | "OIDC_INTERNAL" | "USERNAME_PASSWORD" | null) + | ("OIDC_EXTERNAL" | "OIDC_INTERNAL" | "USERNAME_PASSWORD" | null); + roles?: string[]; + createdAt?: string; + id?: string; +}; +export type UserUserJsonldRead = { + "@context"?: + | string + | { + "@vocab": string; + hydra: "http://www.w3.org/ns/hydra/core#"; + [key: string]: any; + }; + "@id"?: string; + "@type"?: string; + fullName?: string | null; + userType?: + | ("OIDC_EXTERNAL" | "OIDC_INTERNAL" | "USERNAME_PASSWORD" | null) + | ("OIDC_EXTERNAL" | "OIDC_INTERNAL" | "USERNAME_PASSWORD" | null); + roles?: string[]; + createdAt?: string; + id?: string; +}; +export type UserUserInputJsonld = { + fullName?: string | null; +}; +export type UserActivationCodeUserActivationCodeJsonld = { + code?: string | null; + codeExpire?: string | null; + username?: string | null; + roles?: string[] | null; + modifiedBy?: string; + createdBy?: string; + id?: string; + created?: string; + modified?: string; +}; +export type UserActivationCodeUserActivationCodeJsonldRead = { + "@context"?: + | string + | { + "@vocab": string; + hydra: "http://www.w3.org/ns/hydra/core#"; + [key: string]: any; + }; + "@id"?: string; + "@type"?: string; + code?: string | null; + codeExpire?: string | null; + username?: string | null; + roles?: string[] | null; + modifiedBy?: string; + createdBy?: string; + id?: string; + created?: string; + modified?: string; +}; +export type UserActivationCodeUserActivationCodeInputJsonld = { + displayName?: string; + roles?: string[]; +}; +export type UserActivationCodeActivationCodeJsonld = { + activationCode?: string; +}; +export type UserActivationCodeJsonld = { + code?: string | null; + codeExpire?: string | null; + username?: string | null; + roles?: string[] | null; + modifiedBy?: string; + createdBy?: string; + id?: string; + created?: string; + modified?: string; +}; +export type UserActivationCodeJsonldRead = { + "@context"?: + | string + | { + "@vocab": string; + hydra: "http://www.w3.org/ns/hydra/core#"; + [key: string]: any; + }; + "@id"?: string; + "@type"?: string; + code?: string | null; + codeExpire?: string | null; + username?: string | null; + roles?: string[] | null; + modifiedBy?: string; + createdBy?: string; + id?: string; + created?: string; + modified?: string; +}; +export const { + useGetOidcAuthTokenItemQuery, + useGetOidcAuthUrlsItemQuery, + usePostLoginInfoScreenMutation, + usePostRefreshTokenItemMutation, + useGetV2FeedSourcesQuery, + usePostV2FeedSourcesMutation, + useGetV2FeedSourcesByIdQuery, + usePutV2FeedSourcesByIdMutation, + useDeleteV2FeedSourcesByIdMutation, + useGetV2FeedSourcesByIdConfigAndNameQuery, + useGetV2FeedSourcesByIdSlidesQuery, + useGetV2FeedsQuery, + useGetV2FeedsByIdQuery, + useGetV2FeedsByIdDataQuery, + useGetV2LayoutsQuery, + useGetV2LayoutsByIdQuery, + useLoginCheckPostMutation, + useGetV2MediaQuery, + usePostMediaCollectionMutation, + useGetv2MediaByIdQuery, + useDeleteV2MediaByIdMutation, + useGetV2CampaignsByIdScreenGroupsQuery, + useGetV2CampaignsByIdScreensQuery, + useGetV2PlaylistsQuery, + usePostV2PlaylistsMutation, + useGetV2PlaylistsByIdQuery, + usePutV2PlaylistsByIdMutation, + useDeleteV2PlaylistsByIdMutation, + useGetV2PlaylistsByIdSlidesQuery, + usePutV2PlaylistsByIdSlidesMutation, + useDeleteV2PlaylistsByIdSlidesAndSlideIdMutation, + useGetV2SlidesByIdPlaylistsQuery, + usePutV2SlidesByIdPlaylistsMutation, + useGetScreenGroupCampaignItemQuery, + useGetV2ScreenGroupsQuery, + usePostV2ScreenGroupsMutation, + useGetV2ScreenGroupsByIdQuery, + usePutV2ScreenGroupsByIdMutation, + useDeleteV2ScreenGroupsByIdMutation, + useGetV2ScreenGroupsByIdCampaignsQuery, + usePutV2ScreenGroupsByIdCampaignsMutation, + useDeleteV2ScreenGroupsByIdCampaignsAndCampaignIdMutation, + useGetV2ScreenGroupsByIdScreensQuery, + useGetV2ScreensQuery, + usePostV2ScreensMutation, + useGetV2ScreensByIdQuery, + usePutV2ScreensByIdMutation, + useDeleteV2ScreensByIdMutation, + usePostScreenBindKeyMutation, + useGetV2ScreensByIdCampaignsQuery, + usePutV2ScreensByIdCampaignsMutation, + useDeleteV2ScreensByIdCampaignsAndCampaignIdMutation, + useGetV2ScreensByIdRegionsAndRegionIdPlaylistsQuery, + usePutPlaylistScreenRegionItemMutation, + useDeletePlaylistScreenRegionItemMutation, + useGetV2ScreensByIdScreenGroupsQuery, + usePutV2ScreensByIdScreenGroupsMutation, + useDeleteV2ScreensByIdScreenGroupsAndScreenGroupIdMutation, + usePostScreenUnbindMutation, + useGetV2SlidesQuery, + usePostV2SlidesMutation, + useGetV2SlidesByIdQuery, + usePutV2SlidesByIdMutation, + useDeleteV2SlidesByIdMutation, + useApiSlidePerformActionMutation, + useGetV2TemplatesQuery, + useGetV2TemplatesByIdQuery, + useGetV2TenantsQuery, + useGetV2TenantsByIdQuery, + useGetV2ThemesQuery, + usePostV2ThemesMutation, + useGetV2ThemesByIdQuery, + usePutV2ThemesByIdMutation, + useDeleteV2ThemesByIdMutation, + useGetV2UsersQuery, + usePostV2UsersMutation, + useGetV2UsersByIdQuery, + usePutV2UsersByIdMutation, + useDeleteV2UsersByIdMutation, + useDeleteV2UsersByIdRemoveFromTenantMutation, + useGetV2UserActivationCodesQuery, + usePostV2UserActivationCodesMutation, + usePostV2UserActivationCodesActivateMutation, + usePostV2UserActivationCodesRefreshMutation, + useGetV2UserActivationCodesByIdQuery, + useDeleteV2UserActivationCodesByIdMutation, +} = injectedRtkApi; diff --git a/assets/client/redux/openapi-config.js b/assets/client/redux/openapi-config.js new file mode 100644 index 000000000..000b16335 --- /dev/null +++ b/assets/client/redux/openapi-config.js @@ -0,0 +1,25 @@ +const config = { + schemaFile: "../../../public/api-spec-v2.json", + apiFile: "./empty-api.ts", + apiImport: "clientEmptySplitApi", + outputFile: "./generated-api.ts", + exportName: "clientApi", + hooks: true, + tag: true, + endpointOverrides: [ + { + pattern: /.*/, + parameterFilter: (_name, parameter) => { + // Filter out parameters from OpenAPI specification that results in + // invalid javascript with duplicate query parameters. + return !( + ["createdBy", "modifiedBy", "supportedFeedOutputType"].includes( + _name, + ) && parameter.style === "deepObject" + ); + }, + }, + ], +}; + +export default config; diff --git a/assets/client/redux/store.js b/assets/client/redux/store.js new file mode 100644 index 000000000..5f3d12c73 --- /dev/null +++ b/assets/client/redux/store.js @@ -0,0 +1,11 @@ +import { configureStore } from "@reduxjs/toolkit"; +import { clientApi } from "./generated-api.ts"; + +/* eslint-disable-next-line import/prefer-default-export */ +export const clientStore = configureStore({ + reducer: { + [clientApi.reducerPath]: clientApi.reducer, + }, + middleware: (getDefaultMiddleware) => + getDefaultMiddleware().concat(clientApi.middleware), +}); diff --git a/assets/client/service/content-service.js b/assets/client/service/content-service.js index a6af78125..6855241e1 100644 --- a/assets/client/service/content-service.js +++ b/assets/client/service/content-service.js @@ -1,14 +1,16 @@ import sha256 from "crypto-js/sha256"; import Base64 from "crypto-js/enc-base64"; -import PullStrategy from "../data-sync/pull-strategy"; import { screenForPlaylistPreview, screenForSlidePreview, } from "../util/preview"; import logger from "../logger/logger"; +import idFromPath from "../util/id-from-path"; import DataSync from "../data-sync/data-sync"; import ScheduleService from "./schedule-service"; import ClientConfigLoader from "../util/client-config-loader.js"; +import { clientStore } from "../redux/store.js"; +import { clientApi } from "../redux/generated-api.ts"; /** * ContentService. @@ -209,14 +211,13 @@ class ContentService { if (mode === "screen") { this.startSyncing(`/v2/screen/${id}`); } else if (mode === "playlist") { - const pullStrategy = new PullStrategy({ - endpoint: "", + const playlist = await ContentService.query("getV2PlaylistsById", { + id, }); - const playlist = await pullStrategy.getPath(`/v2/playlists/${id}`); - - const playlistSlidesResponse = await pullStrategy.getPath( - playlist.slides, + const playlistSlidesResponse = await ContentService.query( + "getV2PlaylistsByIdSlides", + { id: idFromPath(playlist.slides) }, ); playlist.slidesData = playlistSlidesResponse["hydra:member"].map( @@ -226,7 +227,7 @@ class ContentService { // eslint-disable-next-line no-restricted-syntax for (const slide of playlist.slidesData) { // eslint-disable-next-line no-await-in-loop - await ContentService.attachReferencesToSlide(pullStrategy, slide); + await ContentService.attachReferencesToSlide(slide); } const screen = screenForPlaylistPreview(playlist); @@ -239,14 +240,10 @@ class ContentService { }), ); } else if (mode === "slide") { - const pullStrategy = new PullStrategy({ - endpoint: "", - }); - - const slide = await pullStrategy.getPath(`/v2/slides/${id}`); + const slide = await ContentService.query("getV2SlidesById", { id }); // eslint-disable-next-line no-await-in-loop - await ContentService.attachReferencesToSlide(pullStrategy, slide); + await ContentService.attachReferencesToSlide(slide); const screen = screenForSlidePreview(slide); @@ -261,24 +258,45 @@ class ContentService { logger.error(`Unsupported preview mode: ${mode}.`); } } catch (err) { - logger.error(`Preview failed (mode: ${mode}, id: ${id}): ${err.message}`); + logger.error( + `Preview failed (mode: ${mode}, id: ${id}): ${err.message}`, + ); } } - static async attachReferencesToSlide(strategy, slide) { + static query(endpoint, args) { + return clientStore + .dispatch(clientApi.endpoints[endpoint].initiate(args)) + .unwrap(); + } + + static async attachReferencesToSlide(slide) { /* eslint-disable no-param-reassign */ - slide.templateData = await strategy.getTemplateData(slide); - slide.feedData = await strategy.getFeedData(slide); + slide.templateData = await ContentService.query("getV2TemplatesById", { + id: idFromPath(slide.templateInfo["@id"]), + }); + + if (slide?.feed?.feedUrl) { + slide.feedData = await ContentService.query("getV2FeedsByIdData", { + id: idFromPath(slide.feed.feedUrl), + }); + } else { + slide.feedData = []; + } slide.mediaData = {}; // eslint-disable-next-line no-restricted-syntax for (const media of slide.media) { // eslint-disable-next-line no-await-in-loop - slide.mediaData[media] = await strategy.getMediaData(media); + slide.mediaData[media] = await ContentService.query("getv2MediaById", { + id: idFromPath(media), + }); } if (typeof slide.theme === "string" || slide.theme instanceof String) { - slide.theme = await strategy.getPath(slide.theme); + slide.theme = await ContentService.query("getV2ThemesById", { + id: idFromPath(slide.theme), + }); } /* eslint-enable no-param-reassign */ } diff --git a/assets/client/service/tenant-service.js b/assets/client/service/tenant-service.js index 3565da418..fbb4301bd 100644 --- a/assets/client/service/tenant-service.js +++ b/assets/client/service/tenant-service.js @@ -1,5 +1,7 @@ import appStorage from "../util/app-storage"; import logger from "../logger/logger"; +import { clientStore } from "../redux/store.js"; +import { clientApi } from "../redux/generated-api.ts"; class TenantService { loadTenantConfig = () => { @@ -8,21 +10,11 @@ class TenantService { const tenantId = appStorage.getTenantId(); if (token && tenantKey && tenantId) { - // Get fallback image. - fetch(`/v2/tenants/${tenantId}`, { - headers: { - authorization: `Bearer ${token}`, - "Authorization-Tenant-Key": tenantKey, - }, - }) - .then((response) => { - if (!response.ok) { - throw new Error( - `Failed to fetch tenant (status: ${response.status})`, - ); - } - return response.json(); - }) + clientStore + .dispatch( + clientApi.endpoints.getV2TenantsById.initiate({ id: tenantId }), + ) + .unwrap() .then((tenantData) => { if (tenantData?.fallbackImageUrl) { appStorage.setFallbackImageUrl(tenantData.fallbackImageUrl); diff --git a/assets/client/service/token-service.js b/assets/client/service/token-service.js index 87c02da95..cb05ee27e 100644 --- a/assets/client/service/token-service.js +++ b/assets/client/service/token-service.js @@ -4,6 +4,8 @@ import ClientConfigLoader from "../util/client-config-loader.js"; 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"; class TokenService { refreshingToken = false; @@ -81,47 +83,40 @@ class TokenService { logger.info("Refresh token invoked."); if (this.refreshPromise === null) { - this.refreshPromise = new Promise((resolve, reject) => { - const refreshToken = appStorage.getRefreshToken(); - this.refreshingToken = true; - - fetch(`/v2/authentication/token/refresh`, { - method: "POST", - headers: { - "Content-Type": "application/json", - }, - body: JSON.stringify({ - refresh_token: refreshToken, + this.refreshingToken = true; + const refreshToken = appStorage.getRefreshToken(); + + this.refreshPromise = clientStore + .dispatch( + clientApi.endpoints.postRefreshTokenItem.initiate({ + refreshTokenRequest: { refresh_token: refreshToken }, }), + ) + .unwrap() + .then((data) => { + logger.info("Token refreshed."); + + appStorage.setToken(data.token); + appStorage.setRefreshToken(data.refresh_token); + + // Remove token expired error codes. + if ( + [ + constants.ERROR_TOKEN_EXPIRED, + constants.ERROR_TOKEN_VALID_SHOULD_HAVE_BEEN_REFRESHED, + ].includes(statusService.error) + ) { + statusService.setError(null); + } }) - .then((response) => response.json()) - .then((data) => { - logger.info("Token refreshed."); - - appStorage.setToken(data.token); - appStorage.setRefreshToken(data.refresh_token); - - // Remove token expired error codes. - if ( - [ - constants.ERROR_TOKEN_EXPIRED, - constants.ERROR_TOKEN_VALID_SHOULD_HAVE_BEEN_REFRESHED, - ].includes(statusService.error) - ) { - statusService.setError(null); - } - - resolve(); - }) - .catch((err) => { - logger.error("Token refresh error."); - reject(err); - }) - .finally(() => { - this.refreshingToken = false; - this.refreshPromise = null; - }); - }); + .catch((err) => { + logger.error("Token refresh error."); + throw err; + }) + .finally(() => { + this.refreshingToken = false; + this.refreshPromise = null; + }); } return this.refreshPromise; @@ -162,60 +157,53 @@ class TokenService { }; checkLogin = () => { - return new Promise((resolve, reject) => { - fetch(`/v2/authentication/screen`, { - method: "POST", - mode: "cors", - credentials: "include", - }) - .then((response) => response.json()) - .then((data) => { + return clientStore + .dispatch( + clientApi.endpoints.postLoginInfoScreen.initiate({ + screenLoginInput: {}, + }), + ) + .unwrap() + .then((data) => { + if ( + data?.status === constants.LOGIN_STATUS_READY && + data?.token && + data?.screenId && + data?.tenantKey && + data?.refresh_token + ) { + appStorage.setToken(data.token); + appStorage.setRefreshToken(data.refresh_token); + appStorage.setScreenId(data.screenId); + appStorage.setTenant(data.tenantKey, data.tenantId); + + // Remove token expired error codes. if ( - data?.status === constants.LOGIN_STATUS_READY && - data?.token && - data?.screenId && - data?.tenantKey && - data?.refresh_token - ) { - appStorage.setToken(data.token); - appStorage.setRefreshToken(data.refresh_token); - appStorage.setScreenId(data.screenId); - appStorage.setTenant(data.tenantKey, data.tenantId); - - // Remove token expired error codes. - if ( - [ - constants.ERROR_TOKEN_REFRESH_FAILED, - constants.ERROR_TOKEN_REFRESH_LOOP_FAILED, - constants.ERROR_TOKEN_EXP_IAT_NOT_SET, - constants.ERROR_TOKEN_EXPIRED, - constants.ERROR_TOKEN_VALID_SHOULD_HAVE_BEEN_REFRESHED, - ].includes(statusService.error) - ) { - statusService.setError(null); - } - - resolve({ - status: constants.LOGIN_STATUS_READY, - screenId: data.screenId, - }); - } else if ( - data?.status === constants.LOGIN_STATUS_AWAITING_BIND_KEY + [ + constants.ERROR_TOKEN_REFRESH_FAILED, + constants.ERROR_TOKEN_REFRESH_LOOP_FAILED, + constants.ERROR_TOKEN_EXP_IAT_NOT_SET, + constants.ERROR_TOKEN_EXPIRED, + constants.ERROR_TOKEN_VALID_SHOULD_HAVE_BEEN_REFRESHED, + ].includes(statusService.error) ) { - resolve({ - status: constants.LOGIN_STATUS_AWAITING_BIND_KEY, - bindKey: data.bindKey, - }); - } else { - resolve({ - status: constants.LOGIN_STATUS_UNKNOWN, - }); + statusService.setError(null); } - }) - .catch((err) => { - reject(err); - }); - }); + + return { + status: constants.LOGIN_STATUS_READY, + screenId: data.screenId, + }; + } else if (data?.status === constants.LOGIN_STATUS_AWAITING_BIND_KEY) { + return { + status: constants.LOGIN_STATUS_AWAITING_BIND_KEY, + bindKey: data.bindKey, + }; + } + return { + status: constants.LOGIN_STATUS_UNKNOWN, + }; + }); }; startRefreshing = () => { From f285ea20ed9eb48fb80b6ffff10c16d578855121 Mon Sep 17 00:00:00 2001 From: Troels Ugilt Jensen <6103205+tuj@users.noreply.github.com> Date: Mon, 13 Apr 2026 12:11:59 +0200 Subject: [PATCH 10/73] 6871: Added claude plans --- rtx-migration-review.md | 106 ++++++++++++++++++ rtx-migration.md | 233 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 339 insertions(+) create mode 100644 rtx-migration-review.md create mode 100644 rtx-migration.md diff --git a/rtx-migration-review.md b/rtx-migration-review.md new file mode 100644 index 000000000..6c246e46b --- /dev/null +++ b/rtx-migration-review.md @@ -0,0 +1,106 @@ +# Evaluation of rtx-migration.md + +## Overall Assessment + +The plan is **well-researched and largely correct** in its assumptions. All 16 endpoint names were verified in `generated-api.ts`, the authentication flow is accurately described, and the architectural decisions are sound. However, there are several issues and risks worth addressing before execution. + +--- + +## Verified Correct + +- All RTK Query endpoint names exist exactly as specified (including lowercase `getv2MediaById`) +- Pagination support exists on the correct collection endpoints (`page`, `itemsPerPage` params) +- `extended-base-query.js` does import from `assets/admin/components/util/local-storage-keys.jsx` (confirming it's admin-specific) +- Client uses `apiToken` / `tenantKey` localStorage keys (plain strings, not JSON) +- 401 handling dispatches `document.dispatchEvent(new Event("reauthenticate"))` +- `id-from-path.js` exists and uses regex `/[A-Za-z0-9]{26}/` +- `content-service.js` preview methods use `pullStrategy.getPath()` and `attachReferencesToSlide()` as described + +--- + +## Issues and Concerns + +### 1. Phase 0 is large and risky — should be a separate PR + +Moving shared Redux to `assets/admin/redux/` touches **42+ admin component files** plus the entry point. This is a purely mechanical refactor that has nothing to do with the client migration. Bundling it into the same work: +- Makes the PR enormous and hard to review +- Risks introducing import breakage in admin while the actual goal is client changes +- **Recommendation**: Do Phase 0 as a standalone PR first. Or skip it entirely for now — the "shared" directory being admin-specific is a naming issue, not a blocking problem for the client migration. + +### 2. RTK Query caching may interfere with the pull-based sync + +RTK Query caches responses by endpoint + args. When `PullStrategy` polls on an interval, subsequent calls to the same endpoint will hit RTK Query's cache instead of making a network request. This is a **fundamental behavioral change** from raw `fetch()`. + +Options: +- Use `forceRefetch: true` on every `initiate()` call — but this defeats RTK Query's purpose +- Use `initiate(args, { forceRefetch: true })` only for the polling cycle, not for preview +- Set a very short `keepUnusedDataFor` on the client API +- **Recommendation**: This needs explicit design. The plan should specify the caching strategy. For a pull-based sync pipeline that expects fresh data each cycle, `forceRefetch: true` or `{ subscribe: false, forceRefetch: true }` is likely needed. + +### 3. Error handling semantics change + +Current `ApiHelper.getPath()` returns `null` on failure (line ~50 of api-helper.js). RTK Query's `.unwrap()` **throws** on error. Every call site that checks `if (result === null)` will need to be converted to try/catch. The plan doesn't mention this behavioral difference. + +**Affected areas**: PullStrategy checks for null results in multiple places to mark slides as invalid or skip processing. + +### 4. Bundle size impact on display devices + +Adding Redux + RTK Query + 26K lines of generated endpoints to the client bundle is non-trivial. The client runs on display devices (screens, kiosks) that may have limited resources. The current raw-fetch approach is intentionally lightweight. + +**Recommendation**: Measure the bundle size delta before and after. Consider whether the client actually needs the full generated API or just a subset of endpoints. + +### 5. Duplicated generated-api.ts (26K+ lines x 2) + +The plan generates a second copy of `generated-api.ts` for the client. This means: +- Two 26K+ line files to maintain +- Two codegen commands to run +- Potential drift if one is regenerated and the other isn't + +**Alternative**: Keep one `generated-api.ts` in shared, but have each app provide its own `empty-api.ts` with its own base query. The codegen output is just endpoint definitions — the base query is injected via the empty API. This would require the codegen to support a shared output with pluggable base queries, which RTK Query codegen doesn't natively support. So the duplication may be unavoidable, but the Taskfile should run both codegen commands together. + +### 6. `content-service.js` preview creates PullStrategy for `getPath()` convenience + +In preview mode (lines 212-246), `content-service.js` creates a `PullStrategy` instance solely to use its `getPath()`, `getTemplateData()`, `getFeedData()`, and `getMediaData()` helper methods. After migration, these methods won't exist on PullStrategy anymore. + +The plan addresses this (Phase 3.2) but the replacement pattern needs the `query()` helper to be importable from outside PullStrategy — it should be a shared utility in `assets/client/redux/` or `assets/client/util/`, not local to PullStrategy. + +### 7. `credentials: "include"` for screen auth + +The plan correctly notes this (Phase 2.2) but buries it as a caveat. This is a **must-have** configuration in `fetchBaseQuery`. If forgotten, screen binding will silently fail. It should be a top-level action item in Phase 1.1. + +### 8. Missing: `getPath()` on PullStrategy used by content-service + +`PullStrategy.getPath(path)` is a public method called externally by content-service.js (lines 216, 218, 246, 281). The plan says "Remove ApiHelper entirely" but doesn't explicitly address that `getPath` is a PullStrategy public API method, not just an internal one. It's used as `pullStrategy.getPath(url)` where `url` is a full path like `/v2/playlists/{id}`. This is handled in Phase 3.2, but it's worth noting that PullStrategy's public interface changes. + +--- + +## Design Decisions — Agree/Disagree + +| Decision | Verdict | Comment | +|---|---|---| +| Each app owns its Redux | **Agree** | Different auth, different needs | +| Preserve event-driven architecture | **Agree** | Rewriting to React state is out of scope | +| Preserve checksum optimization | **Agree** | RTK Query tags don't replace server-side checksums | +| `store.dispatch(initiate())` vs hooks | **Agree** | Class-based services can't use hooks | +| Own codegen config | **Agree, with caveat** | Ensure Taskfile runs both together | + +--- + +## Suggested Execution Order + +1. **Phase 0 as separate PR** (or defer entirely) +2. **Phase 1** — Client Redux infrastructure (with explicit `credentials: "include"` and cache policy) +3. **Phase 2** — Simple services (tenant + token), with error handling changes +4. **Phase 3** — Data sync pipeline (largest, most risk — could be its own PR) + +Each phase should be independently testable with `task assets:build` and `task test:frontend-built`. + +--- + +## Verification Additions + +The plan's verification section is good. Add: +- Bundle size comparison (before/after) +- Verify RTK Query doesn't serve stale cached data during sync cycles +- Test 401 → reauthenticate → token refresh → resume flow specifically +- Test screen binding flow with `credentials: "include"` diff --git a/rtx-migration.md b/rtx-migration.md new file mode 100644 index 000000000..9c962e566 --- /dev/null +++ b/rtx-migration.md @@ -0,0 +1,233 @@ +# Plan: Replace Custom Fetches in Client with RTK Query + +## Context + +The client app (`assets/client/`) uses raw `fetch()` calls in service classes and a custom pull-based data sync pipeline. The shared RTK Query infrastructure (`enhanced-api.ts`, `generated-api.ts`, `store.js`) already has typed endpoints for every API call the client makes, but: +- The client has **no Redux store or Provider** (`index.jsx` renders `` directly) +- The shared `extended-base-query.js` is **admin-specific** (reads `admin-api-token` and JSON-parsed `admin-selected-tenant` from localStorage) +- The client uses different localStorage keys (`apiToken`, `tenantKey`) via its own `local-storage-keys.js` + +**Goal:** Replace custom `fetch()` calls in the client with RTK Query endpoints from the enhanced API. + +**Out of scope:** `client-config-loader.js` (`GET /config/client`) and `release-loader.js` (`GET /release.json`) — these are infrastructure endpoints, not API data endpoints. + +--- + +## Phase 0: Reorganize Redux — Move Admin-Specific Code to `assets/admin/redux/` + +Currently all Redux files live in `assets/shared/redux/`. The admin-specific files should move to `assets/admin/redux/`: + +### 0.0 Move admin Redux files + +| From (`assets/shared/redux/`) | To (`assets/admin/redux/`) | +|------|------| +| `extended-base-query.js` | `base-query.js` | +| `empty-api.ts` | `empty-api.ts` | +| `generated-api.ts` | `generated-api.ts` | +| `enhanced-api.ts` | `enhanced-api.ts` | +| `store.js` | `store.js` | +| `openapi-config.js` | `openapi-config.js` | + +**What stays in `assets/shared/redux/`:** Nothing — the shared directory can be removed once both admin and client have their own Redux setups. (Or keep it if there's genuinely shared Redux code later.) + +**Update imports:** All admin files that import from `../../shared/redux/` need import paths updated to `../redux/` (or similar relative path). Key files: +- `assets/admin/index.jsx` (imports `store`) +- All admin components that import hooks from `enhanced-api.ts` +- `extended-base-query.js` → `base-query.js` imports `local-storage-keys.jsx` from admin (already does) + +**Update codegen:** `openapi-config.js` paths (`schemaFile`, `apiFile`, `outputFile`) need adjusting for the new location. + +--- + +## Phase 1: Client-Specific Redux Infrastructure + +### 1.1 Create `assets/client/redux/base-query.js` + +A client-specific base query (similar to `assets/shared/redux/extended-base-query.js`) that: +- Reads token from `localStorage.getItem("apiToken")` (client key) +- Reads tenant key from `localStorage.getItem("tenantKey")` (client key, plain string — not JSON like admin) +- Checks URL params `preview-token` and `preview-tenant` and uses those when present (currently in `api-helper.js:31-37`) +- Sets headers: `authorization: Bearer {token}`, `Authorization-Tenant-Key: {tenantKey}`, `accept: application/ld+json` +- On 401: dispatches `document.dispatchEvent(new Event("reauthenticate"))` (same as current `api-helper.js:54`) +- Does NOT include admin-specific param rewriting (order, exists, etc.) + +Reference: `assets/shared/redux/extended-base-query.js` + +### 1.2 Create `assets/client/redux/empty-api.ts` + +```ts +import { createApi } from '@reduxjs/toolkit/query/react'; +import clientBaseQuery from "./base-query"; + +export const clientEmptySplitApi = createApi({ + reducerPath: 'clientApi', + baseQuery: clientBaseQuery, + endpoints: () => ({}), +}); +``` + +### 1.3 Generate client endpoint definitions + +Add a codegen config `assets/client/redux/openapi-config.js` pointing to the client empty API: +```js +const config = { + schemaFile: "../../../public/api-spec-v2.json", + apiFile: "./empty-api.ts", + apiImport: "clientEmptySplitApi", + outputFile: "./generated-api.ts", + exportName: "clientApi", + hooks: true, + tag: true, + endpointOverrides: [ /* same as shared/redux/openapi-config.js */ ], +}; +``` + +Run `npx @rtk-query/codegen-openapi openapi-config.js` to generate `assets/client/redux/generated-api.ts`. Add this as a task in Taskfile or as a companion step to `task generate:redux-toolkit-api`. + +### 1.4 Create `assets/client/redux/enhanced-api.ts` + +Mirror of `assets/shared/redux/enhanced-api.ts` but importing from the client's `generated-api.ts`. Only needs the invalidation rules relevant to client (most mutations aren't used by client, so this can be minimal). + +### 1.5 Create `assets/client/redux/store.js` + +```js +import { configureStore } from "@reduxjs/toolkit"; +import { clientApi } from "./generated-api.ts"; + +export const clientStore = configureStore({ + reducer: { [clientApi.reducerPath]: clientApi.reducer }, + middleware: (gDM) => gDM().concat(clientApi.middleware), +}); +``` + +### 1.6 Add Redux Provider in `assets/client/index.jsx` + +Wrap `` with ``. + +**Files created/modified:** +- `assets/client/redux/base-query.js` (new) +- `assets/client/redux/empty-api.ts` (new) +- `assets/client/redux/openapi-config.js` (new) +- `assets/client/redux/generated-api.ts` (new, auto-generated) +- `assets/client/redux/enhanced-api.ts` (new) +- `assets/client/redux/store.js` (new) +- `assets/client/index.jsx` (modified) + +--- + +## Phase 2: Migrate Simple Services + +### 2.1 Migrate `assets/client/service/tenant-service.js` + +**Current:** `fetch('/v2/tenants/${tenantId}', ...)` with manual auth headers (line 12) +**Replace with:** `clientStore.dispatch(clientApi.endpoints.getV2TenantsById.initiate({ id: tenantId })).unwrap()` (import from `../redux/store.js` and `../redux/generated-api.ts`) + +The response shape is the same. Error handling maps directly (`.catch()`). + +### 2.2 Migrate `assets/client/service/token-service.js` + +Two fetch calls: + +1. **`refreshToken()`** (line 88): `POST /v2/authentication/token/refresh` + - Replace with: `clientStore.dispatch(clientApi.endpoints.postRefreshTokenItem.initiate({ ... })).unwrap()` + - Note: verify the generated endpoint's expected body shape matches `{ refresh_token }`. + +2. **`checkLogin()`** (line 166): `POST /v2/authentication/screen` + - Replace with: `clientStore.dispatch(clientApi.endpoints.postLoginInfoScreen.initiate({ ... })).unwrap()` + - **Caveat:** Current code uses `credentials: "include"` (cookie-based screen auth). The `client-base-query.js` must set `credentials: "include"` in its `fetchBaseQuery` config to support this. + +**Files modified:** +- `assets/client/service/tenant-service.js` +- `assets/client/service/token-service.js` + +--- + +## Phase 3: Migrate Data Sync Pipeline + +This is the largest change. The strategy: replace the transport layer (`ApiHelper`) while preserving the orchestration logic (`PullStrategy`). + +### 3.1 Refactor `assets/client/data-sync/pull-strategy.js` — use named endpoints directly + +Remove `ApiHelper` entirely. Each fetch call in PullStrategy becomes a direct RTK Query endpoint dispatch. Use `idFromPath()` (existing in `assets/client/util/id-from-path.js`) to extract IDs from Hydra paths. + +Helper for dispatching (local to file or small util): +```js +async function query(endpoint, args) { + return clientStore.dispatch(clientApi.endpoints[endpoint].initiate(args)).unwrap(); +} +``` + +Call-by-call migration in `getScreen()` and helpers: + +| Current code | Becomes | +|---|---| +| `this.apiHelper.getPath(screenPath)` | `query("getV2ScreensById", { id: idFromPath(screenPath) })` | +| `this.apiHelper.getPath(screen.inScreenGroups)` | `query("getV2ScreensByIdScreenGroups", { id: idFromPath(screen["@id"]) })` | +| `this.apiHelper.getAllResultsFromPath(group.campaigns)` | `query("getV2ScreenGroupsByIdCampaigns", { id: idFromPath(group["@id"]) })` — handle pagination with `page` param | +| `this.apiHelper.getPath(screen.campaigns)` | `query("getV2ScreensByIdCampaigns", { id: idFromPath(screen["@id"]) })` | +| `this.apiHelper.getPath(newScreen.layout)` | `query("getV2LayoutsById", { id: idFromPath(newScreen.layout) })` | +| `this.apiHelper.getAllResultsFromPath(regionPath)` | `query("getV2ScreensByIdRegionsAndRegionIdPlaylists", { id: screenId, regionId: extractRegionId(regionPath) })` — extract both IDs with regex | +| `this.apiHelper.getAllResultsFromPath(playlist.slides, keys)` | `query("getV2PlaylistsByIdSlides", { id: idFromPath(playlist["@id"]) })` — handle pagination | +| `this.apiHelper.getPath(templatePath)` | `query("getV2TemplatesById", { id: idFromPath(templatePath) })` | +| `this.apiHelper.getPath(mediaId)` | `query("getv2MediaById", { id: idFromPath(mediaId) })` | +| `this.apiHelper.getPath(slide.feed.feedUrl)` | `query("getV2FeedsByIdData", { id: idFromPath(slide.feed.feedUrl) })` | + +For the two-ID regions path (`/v2/screens/{id}/regions/{regionId}/playlists`), add a small `extractRegionId(path)` helper using the existing regex in `getRegions()` at line 106. + +Pagination: Current `getAllResultsFromPath` loops through `?page=N`. Replace with a local `queryAllPages(endpoint, args)` helper that calls the endpoint with incrementing `page` param, collecting `hydra:member` results. + +The checksum comparison logic, caching, and event dispatch all stay unchanged — only the transport calls change. + +### 3.2 Refactor `assets/client/service/content-service.js` preview methods + +`startPreview()` and `attachReferencesToSlide()` currently create ad-hoc `PullStrategy` instances for one-off fetches via `pullStrategy.getPath(...)`. Replace with direct endpoint dispatches: + +| Current | Becomes | +|---|---| +| `pullStrategy.getPath("/v2/playlists/" + id)` | `query("getV2PlaylistsById", { id })` | +| `pullStrategy.getPath("/v2/slides/" + id)` | `query("getV2SlidesById", { id })` | +| `strategy.getTemplateData(slide)` | `query("getV2TemplatesById", { id: idFromPath(slide.templateInfo["@id"]) })` | +| `strategy.getFeedData(slide)` | `query("getV2FeedsByIdData", { id: idFromPath(slide.feed.feedUrl) })` | +| `strategy.getMediaData(media)` | `query("getv2MediaById", { id: idFromPath(media) })` | +| `strategy.getPath(slide.theme)` | `query("getV2ThemesById", { id: idFromPath(slide.theme) })` | + +### 3.3 Delete `assets/client/data-sync/api-helper.js` + +No longer needed. + +### 3.4 Clean up `assets/client/data-sync/data-sync.js` + +Update if needed — it wraps PullStrategy construction. May simplify since `ApiHelper` endpoint config is no longer passed. + +**Files modified/deleted:** +- `assets/client/data-sync/pull-strategy.js` (major refactor — replace all ApiHelper calls with named endpoints) +- `assets/client/service/content-service.js` (refactor preview methods) +- `assets/client/data-sync/api-helper.js` (deleted) +- `assets/client/data-sync/data-sync.js` (minor update) + +--- + +## Design Decisions + +| Decision | Choice | Rationale | +|----------|--------|-----------| +| Each app owns its Redux in `assets/{app}/redux/` | Yes | Admin and client use different auth. No shared Redux folder needed. | +| Preserve event-driven architecture | Yes | ContentService/ScheduleService/Region event pipeline works well. Rewriting to React state is a separate concern. | +| Preserve checksum optimization | Yes | RTK Query's tag-based invalidation doesn't replace server-side relationsChecksum polling. Keep application-level caching logic. | +| Use `store.dispatch(initiate())` vs hooks | `dispatch(initiate())` | Services are class-based singletons outside React. Hooks only work in components. | +| How to generate client endpoints | Own codegen config in `assets/client/redux/` | Avoids manual duplication. Same OpenAPI spec, different base query. One extra codegen step. | + +--- + +## Verification + +1. **Build:** `task assets:build` — ensure no TypeScript/bundling errors +2. **E2E tests:** `task test:frontend-built` — existing Playwright tests cover screen display flow +3. **Manual testing:** + - Start dev server, open client URL + - Verify screen binding flow works (login → bind key → content display) + - Verify content polling loads and updates slides + - Verify token refresh happens silently + - Verify preview mode works (screen, playlist, slide previews from admin) + - Check browser DevTools Network tab: requests should still go to same endpoints with same headers +4. **Regression:** Verify admin app still works (its API slice is untouched) From 3cb998722e2efe8ef8006229c1db614082eeaeed Mon Sep 17 00:00:00 2001 From: Troels Ugilt Jensen <6103205+tuj@users.noreply.github.com> Date: Fri, 17 Apr 2026 14:11:55 +0200 Subject: [PATCH 11/73] 7203: Added vitest for javascript unit tests. Converted playwright unit tests to vitests --- CHANGELOG.md | 1 + PLAN1.md | 4839 +++++++++++++++++ Taskfile.yml | 5 + ...g.spec.js => admin-content-string.test.js} | 8 +- ...spec.js => admin-slide-media-sync.test.js} | 2 +- ...=> campaigns-button-promise-chain.test.js} | 2 +- ...ll-pages.spec.js => get-all-pages.test.js} | 2 +- assets/tests/client/id-from-path.test.js | 36 + assets/tests/setup.js | 1 + ...-loader.spec.js => release-loader.test.js} | 2 +- package-lock.json | 1295 ++++- package.json | 10 +- vitest.config.js | 9 + 13 files changed, 6119 insertions(+), 93 deletions(-) create mode 100644 PLAN1.md rename assets/tests/admin/{admin-content-string.spec.js => admin-content-string.test.js} (65%) rename assets/tests/admin/{admin-slide-media-sync.spec.js => admin-slide-media-sync.test.js} (98%) rename assets/tests/admin/{campaigns-button-promise-chain.spec.js => campaigns-button-promise-chain.test.js} (98%) rename assets/tests/admin/{get-all-pages.spec.js => get-all-pages.test.js} (98%) create mode 100644 assets/tests/client/id-from-path.test.js create mode 100644 assets/tests/setup.js rename assets/tests/shared/{release-loader.spec.js => release-loader.test.js} (98%) create mode 100644 vitest.config.js diff --git a/CHANGELOG.md b/CHANGELOG.md index c3a3b0151..02e22fdbb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -34,6 +34,7 @@ All notable changes to this project will be documented in this file. - Optimized release data fetching. - Optimized list loading. - Removed fixture length check from test. +- Added vitest for frontend unit tests. ### NB! Prior to 3.x the project was split into separate repositories diff --git a/PLAN1.md b/PLAN1.md new file mode 100644 index 000000000..b589f9b5b --- /dev/null +++ b/PLAN1.md @@ -0,0 +1,4839 @@ +# Implementation Plan: OS2Display Black Screen & Auth Recovery Fixes + +**Target branch:** `release/3.0.0` +**Methodology:** TDD — each step writes a failing test first, then the minimal fix +**Execution:** Each step is one PR, optimized for `claude code /loop` + +## Docker compose services + +All commands run inside the project's docker compose containers: + +| Service | Container | Used for | +|---------|-----------|----------| +| `phpfpm` | `itkdev/php8.3-fpm` | PHP: composer, phpunit, psalm, php-cs-fixer | +| `node` | `node:24` | npm, vitest | +| `playwright` | `mcr.microsoft.com/playwright` | E2E tests | +| `mariadb` | `itkdev/mariadb` | Test database | +| `redis` | `redis:6` | Cache, sessions | + +## Command reference + +```bash +# PHP — tests +docker compose run --rm phpfpm composer test + +# PHP — static analysis +docker compose run --rm phpfpm composer code-analysis + +# PHP — coding standards check +docker compose run --rm phpfpm composer coding-standards-check + +# PHP — auto-fix coding standards +docker compose run --rm phpfpm composer coding-standards-apply + +# PHP — test setup (DB reset, migrations) +docker compose run --rm phpfpm composer test-setup + +# PHP — clear cache +docker compose run --rm phpfpm bin/console cache:clear + +# JS — install dependencies +docker compose run --rm node npm install + +# JS — unit tests (after Step 0) +docker compose run --rm node npx vitest run + +# JS — E2E tests +docker compose run --rm playwright npx playwright test + +# JS — coding standards (if configured) +docker compose run --rm node npx prettier --check assets/ +``` + +## Verification checklist (run before every PR) + +```bash +# Build (after Step 15 adds the SW build plugin) +docker compose run --rm node npm run build + +# PHP steps +docker compose run --rm phpfpm composer coding-standards-check +docker compose run --rm phpfpm composer code-analysis +docker compose run --rm phpfpm composer test + +# JS steps (after Step 0) +docker compose run --rm node npx vitest run +docker compose run --rm playwright npx playwright test +``` + +--- + +## Phase 0: Test infrastructure + +### Step 0 — Add Vitest for client unit tests + +**Branch:** `fix/add-client-unit-test-framework` + +**Motivation:** The client codebase has no unit test framework. The +only automated coverage is Playwright E2E, which runs against a full +browser and requires the entire stack (backend, database, Redis, the +React app) to be up. E2E tests are valuable but slow, brittle under +timing variance, and incapable of isolating the kind of state-machine +bugs we're about to fix. + +Consider the specific bugs in this plan: + +- `reauthenticateHandler` not resetting `displayFallback` (Step 6) — a + bug in a state transition triggered by a 401 response. To hit it + with E2E, we'd need to set up a screen, fake a 401, and inspect the + DOM at the exact moment the handler runs. To hit it in a unit test, + we mount `` in jsdom and dispatch a `reauthenticate` event. +- `checkLogin` dead-end on unknown status (Step 7) — requires injecting + a malformed backend response. Trivial in a unit test, practically + impossible in E2E without backend mocking. +- Crash-loop guard (Step 11c) — a state machine around localStorage + with time-sensitive windows. Unit tests with fake timers cover it in + seconds; E2E would need ~4 minutes of real wall-clock time to exercise. + +Vitest integrates directly with the existing Vite config, uses the same +JSX transform, and is zero-config. The `fake-indexeddb` polyfill +(installed in this step) lets us test Step 14's IndexedDB auth-bridge +without a real browser. Every subsequent TDD step depends on this +infrastructure. + +This step also establishes the convention: tests live next to the code +as `__tests__/*.test.{js,jsx}`, imports match production imports, +`jsdom` provides the DOM environment. Consistent structure makes +`/loop` runs predictable. + +**Tasks:** + +1. Install vitest + testing libraries: + ```bash + docker compose run --rm node npm install -D vitest @testing-library/react @testing-library/jest-dom jsdom fake-indexeddb + ``` + +2. Add vitest config to `vite.config.js`: + ```js + test: { + environment: 'jsdom', + include: ['assets/**/*.test.{js,jsx}'], + setupFiles: ['./assets/tests/setup.js'], + } + ``` + +3. Create `assets/tests/setup.js`: + ```js + import '@testing-library/jest-dom'; + ``` + +4. Add npm script to `package.json`: + ```json + "test:unit": "vitest run", + "test:unit:watch": "vitest" + ``` + +5. Write a smoke test `assets/client/util/__tests__/id-from-path.test.js` + that tests the existing `idFromPath` utility to prove the framework works. + +6. Verify: + ```bash + docker compose run --rm node npx vitest run + ``` + +**Changelog:** Added Vitest for client-side unit testing. + +--- + +## Phase 1: Backend bug fixes + +### Step 1 — Fix `ScreenUserRequestSubscriber` entityManager->clear() + +**Branch:** `fix/screen-subscriber-entity-clear` + +**Bug:** When `TRACK_SCREEN_INFO=true`, this subscriber updates the +screen's `lastSeen` timestamp and client metadata on every API request, +then calls `$this->entityManager->flush()` followed by +`$this->entityManager->clear()`. + +`EntityManager::clear()` with no argument detaches ALL entities from +Doctrine's identity map — not just the one that was flushed. This means +any other managed entity loaded earlier in the request lifecycle, +including the authenticated ScreenUser, its associated Screen, and the +Tenant, becomes detached. + +The subscriber runs on `kernel.request` after authentication but before +the controller. When the controller later accesses `$user->getScreen()` +or TenantExtension runs its query filter, Doctrine can no longer +lazy-load from these detached entities. The request either 500s +(`EntityNotFoundException`) or returns incorrect data (tenant filter +silently inactive). Under concurrent load across a shared PHP-FPM +worker, the corruption cascades to adjacent requests. + +Only triggers when `TRACK_SCREEN_INFO=true` (defaults to false). In +deployments that have opted in to screen tracking, every screen's 90-second +poll cycle runs through this subscriber, making the failure rate +proportional to screen count. + +**RED — write test:** + +File: `tests/EventSubscriber/ScreenUserRequestSubscriberTest.php` + +Test that after `ScreenUserRequestSubscriber::onKernelRequest` runs for a +screen endpoint, the authenticated ScreenUser entity is still managed by +the entity manager (not detached). Simulate a request to +`/v2/screens/{ULID}` with `trackScreenInfo=true`, then assert: +- `$entityManager->contains($screenUser)` is true after the subscriber runs +- The screen's tenant is still accessible via the user security token + +```bash +docker compose run --rm phpfpm composer test -- --filter=ScreenUserRequestSubscriberTest +``` + +**GREEN — fix:** + +In `src/EventSubscriber/ScreenUserRequestSubscriber.php`, replace: +```php +$this->entityManager->flush(); +$this->entityManager->clear(); +``` +With: +```php +$this->entityManager->flush(); +$this->entityManager->detach($screenUser); +``` + +Only detach the specific entity that was modified, not the entire identity map. + +**Verify:** +```bash +docker compose run --rm phpfpm composer test +docker compose run --rm phpfpm composer code-analysis +docker compose run --rm phpfpm composer coding-standards-check +``` + +**Changelog:** Fixed: `entityManager->clear()` in ScreenUserRequestSubscriber no longer detaches authenticated user entities. + +--- + +### Step 2 — Fix `getStatus()` one-shot cache delete + +**Branch:** `fix/screen-auth-cache-grace-period` + +**Bug:** `ScreenAuthenticator::getStatus()` is the polling endpoint the +client hits to check whether an admin has claimed its bind key. When the +admin binds the screen, a JWT + refresh token pair is written to the +cache against the session's bind key. The next time `getStatus()` is +called, it retrieves the tokens AND immediately calls +`$this->authScreenCache->deleteItem($cacheKey)` before returning them to +the client. + +This treats the cache as a one-shot retrieval. If anything between the +cache read and the client's successful token storage fails — network +blip, proxy timeout, slow PHP-FPM response, browser retry, JS parse +error, kernel panic on a cheap kiosk box — the client polls again but +the cache is already empty. The response reverts to `awaitingBindKey`, +even though the screen IS bound on the backend. + +From the admin's perspective: they click bind, the bind key entry UI +confirms success, but the screen keeps showing the bind key. The admin +concludes the bind failed and re-binds. Each additional bind attempt +creates new refresh tokens (Step 4 issue), each adds a ScreenUser +conflict (Step 3 issue). A cascade of downstream problems can trace +back to this single one-shot retrieval. + +The fix replaces the immediate delete with a 60-second grace period. +The client has time to retry; stale tokens still clean up on their own. + +**RED — write test:** + +File: `tests/Security/ScreenAuthenticatorTest.php` + +Test that after `getStatus()` returns a `ready` response with a token, a +second call to `getStatus()` within 60 seconds (same session) still returns +`ready` with the same token (not `awaitingBindKey`). + +Currently fails because `deleteItem()` destroys the entry on first read. + +```bash +docker compose run --rm phpfpm composer test -- --filter=ScreenAuthenticatorTest +``` + +**GREEN — fix:** + +In `src/Security/ScreenAuthenticator.php` `getStatus()`, replace: +```php +$this->authScreenCache->deleteItem($cacheKey); +``` +With: +```php +$cacheItem->expiresAfter(60); +$this->authScreenCache->save($cacheItem); +``` + +**Verify:** +```bash +docker compose run --rm phpfpm composer test +docker compose run --rm phpfpm composer code-analysis +docker compose run --rm phpfpm composer coding-standards-check +``` + +**Changelog:** Fixed: Screen auth token cache entry now has a 60-second grace period instead of immediate deletion. + +--- + +### Step 3 — Fix `bindScreen` for already-bound screens + +**Branch:** `fix/screen-rebind-already-bound` + +**Bug:** `ScreenAuthenticator::bindScreen()` is called when an admin +enters a bind key in the UI. It resolves the Screen and checks whether a +ScreenUser already exists. If yes, it throws an exception with the +message "Screen already bound." `AuthScreenBindController` catches every +exception as "Key not accepted" and returns that to the admin. + +This is the exact failure mode blocking every client-side auth recovery +scenario. Consider the path: a screen loses its tokens (localStorage +cleared by browser memory pressure, IndexedDB corrupted, explicit +`reauthenticate` wiping credentials, hardware-induced browser crash +clearing storage). The screen boots, has no tokens, requests a new bind +key, and displays it. The admin sees the screen showing a key, enters +it, clicks bind. Backend: ScreenUser already exists → "Screen already +bound" → "Key not accepted." + +The admin has no indication that unbinding is required first, and even +if they knew, unbinding can lose configuration depending on other +cleanup paths. The documented workaround is "delete the Screen and +create a new one" — losing history, schedules, and playlist assignments. + +On the field: every screen that loses client-side auth becomes a support +ticket. The fix accepts rebind requests for already-bound screens: the +old ScreenUser and its refresh tokens are cleaned up (same cleanup as +Step 4's unbind fix), a new ScreenUser is created, the screen transparently +rebinds. Admins see the same success flow as first-time binding. + +**RED — write test:** + +File: `tests/Security/ScreenAuthenticatorRebindTest.php` + +Test the full flow: bind a screen, then simulate the screen losing auth +and requesting a new bind key. Call `bindScreen` again with the same +screen. Assert that: +- The call succeeds (does not throw) +- The old ScreenUser is removed +- A new ScreenUser is created with a new JWT and refresh token +- The old refresh token is deleted from the `refresh_tokens` table + +Currently fails because `bindScreen` throws "Screen already bound". + +```bash +docker compose run --rm phpfpm composer test -- --filter=ScreenAuthenticatorRebindTest +``` + +**GREEN — fix:** + +In `src/Security/ScreenAuthenticator.php` `bindScreen()`, replace: +```php +if ($screenUser) { + throw new \Exception('Screen already bound'); +} +``` +With: +```php +if ($screenUser) { + $this->cleanupScreenUser($screenUser); +} +``` + +Add private method: +```php +private function cleanupScreenUser(ScreenUser $screenUser): void +{ + $refreshTokens = $this->entityManager + ->getRepository(RefreshToken::class) + ->findBy(['username' => $screenUser->getUserIdentifier()]); + + foreach ($refreshTokens as $token) { + $this->entityManager->remove($token); + } + + $this->entityManager->remove($screenUser); + $this->entityManager->flush(); +} +``` + +Also update `src/Controller/Api/AuthScreenBindController.php` to return a +descriptive error on remaining failure cases instead of generic "Key not +accepted". + +**Verify:** +```bash +docker compose run --rm phpfpm composer test +docker compose run --rm phpfpm composer code-analysis +docker compose run --rm phpfpm composer coding-standards-check +``` + +**Changelog:** Fixed: Screens that lost client-side auth can now rebind without manual unbind. Old refresh tokens are cleaned up. + +--- + +### Step 4 — Fix `unbindScreen` refresh token cleanup + +**Branch:** `fix/unbind-cleanup-refresh-tokens` + +**Bug:** `ScreenAuthenticator::unbindScreen()` removes the ScreenUser +entity and flushes. The associated rows in the `refresh_tokens` table — +created by the Gesdinet JWT refresh bundle with the ScreenUser's +identifier in the `username` column — are not touched. + +Every screen accumulates multiple refresh tokens over its lifetime: one +from the initial bind, one from each `/v2/authentication/token/refresh` +call (every 7.5 days by default with the 15-day JWT TTL). Over a year, +one screen generates ~50 refresh token rows. A 200-screen deployment +produces ~10,000 token rows per year. + +When that screen is unbound and rebound (which Step 3 now allows to +succeed), the ScreenUser is recreated with a different identifier, so +the new screen doesn't inherit old tokens. The old tokens remain +queryable: `SELECT * FROM refresh_tokens WHERE username = '...'` +returns data for an entity that no longer exists. Over months of +churn, the `refresh_tokens` table grows without bound. Every token +refresh request scans this growing table; response time degrades. + +A subtler failure mode: if token identifier collisions ever occur (e.g., +a deployment bug reuses IDs, or a Ulid collision, or a deliberate ID +set for testing), the new ScreenUser inherits stale refresh tokens from +a previous life. + +The fix wraps ScreenUser deletion in a transaction that first removes +all `refresh_tokens` rows matching the username. Unbinding leaves no +orphans. + +**RED — write test:** + +File: `tests/Security/ScreenAuthenticatorUnbindTest.php` + +Test that after `unbindScreen()`: +- The ScreenUser is deleted +- All refresh tokens for that ScreenUser are deleted from `refresh_tokens` +- No orphaned rows remain + +Currently fails because `unbindScreen` only deletes the ScreenUser. + +```bash +docker compose run --rm phpfpm composer test -- --filter=ScreenAuthenticatorUnbindTest +``` + +**GREEN — fix:** + +In `src/Security/ScreenAuthenticator.php` `unbindScreen()`, add refresh +token cleanup before removing the ScreenUser. Reuse the +`cleanupScreenUser` helper from Step 3 or inline the logic. + +**Verify:** +```bash +docker compose run --rm phpfpm composer test +docker compose run --rm phpfpm composer code-analysis +docker compose run --rm phpfpm composer coding-standards-check +``` + +**Changelog:** Fixed: Unbinding a screen now removes associated refresh tokens. + +--- + +### Step 5 — Harden `JwtTokenRefreshedSubscriber` TTL correction + +**Branch:** `fix/refresh-token-ttl-resilience` + +**Bug:** When a screen refreshes its JWT via +`/v2/authentication/token/refresh`, the Gesdinet bundle creates a NEW +refresh token with the bundle's default TTL of 2 hours. OS2Display needs +a 30-day TTL to match its operational model. The +`JwtTokenRefreshedSubscriber` listens for Gesdinet's +`RefreshTokenCreated` event, retrieves the freshly-created token via +`$refreshTokenManager->get($tokenString)`, modifies the `valid` field, +and persists it via `$refreshTokenManager->save()`. + +Any exception in this flow silently breaks the refresh token's TTL: + +- `get()` returns null because the token hasn't been committed to the + DB yet (transaction race with the subscriber's read) +- DB connection exception during `get()` or `save()` under load +- Schema mismatch if the `valid` field migration hasn't run in the + current deployment (known deployment hazard when running out of sequence) +- Datetime field edge cases on `valid` + +When any of these triggers, the exception bubbles up — but the HTTP +response to the client is still 200 OK, because the NEW access token +was generated before the subscriber ran. The client happily stores the +new tokens and continues. + +Two hours later, the refresh token silently expires. The next refresh +attempt fails with 401 "Invalid refresh token." The access token +(15-day TTL) is still valid but cannot be renewed. The client enters +the `reauthenticateHandler` flow (Step 6's bug), which wipes tokens +and dead-ends. + +From the operator perspective: "the screen worked yesterday, this morning +it's black." The log shows a 401 from two hours before it went black. +No obvious connection to the earlier successful refresh. + +The fix wraps the TTL upgrade in try/catch. Failure logs a critical +warning (for alerting) but allows the refresh response to succeed. A +2-hour refresh token is degraded but not broken — the next refresh +cycle gets another chance to correct the TTL. Ops has visibility; the +screen keeps running. + +**RED — write test:** + +File: `tests/Security/EventSubscriber/JwtTokenRefreshedSubscriberTest.php` + +Unit test the subscriber directly: +1. Test that when `refreshTokenManager->get()` returns null, the subscriber + does NOT throw — it logs a warning and the response still contains the + token. +2. Test that when `refreshTokenManager->save()` throws a DB error, the + subscriber catches it and the response still succeeds. + +Currently fails because both cases throw uncaught exceptions. + +```bash +docker compose run --rm phpfpm composer test -- --filter=JwtTokenRefreshedSubscriberTest +``` + +**GREEN — fix:** + +In `src/Security/EventSubscriber/JwtTokenRefreshedSubscriber.php`, wrap +the TTL correction in a try/catch. On failure, log a critical warning but +allow the refresh response to succeed with the gesdinet-default TTL: + +```php +try { + $refreshToken = $this->refreshTokenManager->get($refreshTokenString); + if (null === $refreshToken) { + return; + } + // ... existing TTL correction ... + $this->refreshTokenManager->save($refreshToken); +} catch (\Throwable $e) { + // Log critical — the refresh token has the wrong TTL. + // But the JWT refresh itself succeeded, so don't break it. +} +``` + +**Verify:** +```bash +docker compose run --rm phpfpm composer test +docker compose run --rm phpfpm composer code-analysis +docker compose run --rm phpfpm composer coding-standards-check +``` + +**Changelog:** Fixed: Refresh token TTL correction failure no longer crashes the token refresh endpoint. + +--- + +### Step 5b — Read tenant from JWT payload instead of DB in TenantScopedAuthenticator + +**Branch:** `fix/authenticator-tenant-from-jwt` + +**Bug:** `TenantScopedAuthenticator::doAuthenticate()` runs on every +authenticated request. For ScreenUser it calls `$user->getTenants()`, +which returns `new ArrayCollection([$this->getScreen()->getTenant()])`. +Both `getScreen()` and `getTenant()` are lazy-loaded Doctrine proxies; +this single method call triggers two SELECT queries (one against +`screen`, one against `tenant`). The authenticator then iterates the +collection, calls `$user->setActiveTenant()` (which internally calls +`getScreen()->getTenant()` again for validation), and the request +proceeds. + +Downstream, `TenantExtension` — a Doctrine query filter — calls +`$user->getActiveTenant()->getId()` on every query to scope results to +the tenant. ScreenUser's `getActiveTenant()` also chains through +`getScreen()->getTenant()`. For a typical API call that loads 5 entities +with tenant filtering, the request makes 3–5 extra queries just for +tenant resolution, all redundant. + +Under burst load this becomes a connection-pool problem. Common bursts: + +- Mass reboot: 200 screens power-cycle simultaneously (power event, + maintenance window, timed reboot script) +- Morning startup: school or office where screens come online within a + 10-minute window +- Network recovery: screens that were offline all come back at once + +200 screens × 3 queries × 2 seconds to retry on failure = connection +pool saturation. MariaDB's default `max_connections` is 100–200. +Connection waits exceed PHP's 30-second request timeout, requests 500, +clients retry, the cascade amplifies. Screens appear to "fail to boot" +but are actually hitting auth timeouts. + +The JWT payload already contains `tenantKey`, `title`, `description`, +and `roles` per tenant (set by `JWTCreatedListener`). Adding `tenantId` +means the full tenant identity is in the signed token. The authenticator +can validate the tenant key against the JWT claims (no DB), then use +Doctrine's `getReference(Tenant::class, $ulid)` — which returns a proxy +with the ID set but **never initializes it**. Downstream code calls +`$tenant->getId()->toBinary()`, which works directly on the uninitialized +proxy because Doctrine stores the identifier on the proxy object itself. + +Result: zero DB queries during screen authentication. The bottleneck +moves to the actual data queries the kiosk is making, which is the +actual intended workload. + +**RED — write tests:** + +File: `tests/Security/TenantScopedAuthenticatorJwtTest.php` + +1. Bind a screen, get a JWT. Make an API request with the JWT. Assert + 200. Use Doctrine SQL logger to assert **no query touches the + `screen` table** during the request. +2. Assert that the `tenant` table is also NOT queried (only + `getReference` is used). +3. JWT with a `tenantKey` not matching the `Authorization-Tenant-Key` + header returns 401. +4. JWT without a `tenants` claim falls back to the DB path (backward + compatibility with tokens issued before this change). +5. JWT with `tenants` that include `tenantId` — verify `getReference` + is used and tenant `getId()` returns the correct ULID. + +File: `tests/Security/EventListener/JWTCreatedListenerTest.php` + +1. Create a JWT for a ScreenUser. Decode the payload. Assert each + tenant object contains `tenantKey`, `title`, `description`, `roles`, + AND `tenantId`. +2. Create a JWT for a regular User. Assert `tenantId` is present in + each tenant entry. + +```bash +docker compose run --rm phpfpm composer test -- --filter=TenantScopedAuthenticatorJwtTest +docker compose run --rm phpfpm composer test -- --filter=JWTCreatedListenerTest +``` + +**GREEN — implement:** + +**Step A — Add `tenantId` to JWT payload:** + +In `src/Entity/ScreenUser.php` `getUserRoleTenants()`, add the ID: + +```php +public function getUserRoleTenants(): Collection +{ + $tenant = $this->getScreen()->getTenant(); + + $userRoleTenant = new \stdClass(); + $userRoleTenant->tenantKey = $tenant->getTenantKey(); + $userRoleTenant->tenantId = $tenant->getId()?->jsonSerialize(); + $userRoleTenant->title = $tenant->getTitle(); + $userRoleTenant->description = $tenant->getDescription(); + $userRoleTenant->roles = $this->getRoles(); + + return new ArrayCollection([$userRoleTenant]); +} +``` + +In `src/Entity/UserRoleTenant.php` `jsonSerialize()`, add the ID: + +```php +public function jsonSerialize(): array +{ + return [ + 'tenantKey' => $this->getTenant()?->getTenantKey(), + 'tenantId' => $this->getTenant()?->getId()?->jsonSerialize(), + 'title' => $this->getTenant()?->getTitle(), + 'description' => $this->getTenant()?->getDescription(), + 'roles' => $this->getRoles(), + ]; +} +``` + +Now every JWT contains both `tenantKey` and `tenantId` per tenant. + +**Step B — Add `preloadTenant` to ScreenUser:** + +In `src/Entity/ScreenUser.php`: + +```php +private ?Tenant $preloadedTenant = null; + +public function preloadTenant(Tenant $tenant): void +{ + $this->preloadedTenant = $tenant; +} + +public function getActiveTenant(): Tenant +{ + return $this->preloadedTenant ?? $this->getScreen()->getTenant(); +} + +public function setActiveTenant(Tenant $activeTenant): self +{ + if ($this->preloadedTenant !== null) { + // Tenant was resolved from JWT — skip DB validation. + return $this; + } + + if ($this->getScreen()->getTenant() !== $activeTenant) { + throw new \InvalidArgumentException( + 'Active Tenant cannot be set. User does not have access to Tenant: ' + .$activeTenant->getTenantKey() + ); + } + + return $this; +} + +public function getTenants(): Collection +{ + if ($this->preloadedTenant !== null) { + return new ArrayCollection([$this->preloadedTenant]); + } + + return new ArrayCollection([$this->getScreen()->getTenant()]); +} +``` + +**Step C — Update TenantScopedAuthenticator:** + +Update service definition in `config/services.yaml`: + +```yaml +app.tenant_scoped_authenticator: + class: App\Security\TenantScopedAuthenticator + parent: lexik_jwt_authentication.security.jwt_authenticator + calls: + - [setTenantDependencies, ['@doctrine.orm.entity_manager', '@lexik_jwt_authentication.jwt_manager']] +``` + +In `src/Security/TenantScopedAuthenticator.php`: + +```php +use App\Entity\ScreenUser; +use App\Entity\Tenant; +use Doctrine\ORM\EntityManagerInterface; +use Lexik\Bundle\JWTAuthenticationBundle\Services\JWTTokenManagerInterface; +use Symfony\Component\Uid\Ulid; + +class TenantScopedAuthenticator extends JWTAuthenticator +{ + final public const string AUTH_TENANT_ID_HEADER = 'Authorization-Tenant-Key'; + + private ?EntityManagerInterface $entityManager = null; + private ?JWTTokenManagerInterface $jwtManager = null; + + public function setTenantDependencies( + EntityManagerInterface $entityManager, + JWTTokenManagerInterface $jwtManager, + ): void { + $this->entityManager = $entityManager; + $this->jwtManager = $jwtManager; + } + + final public function doAuthenticate(Request $request): Passport + { + $passport = parent::doAuthenticate($request); + $user = $passport->getUser(); + + if (!$user instanceof TenantScopedUserInterface) { + throw new AuthenticationException('User class is not tenant scoped'); + } + + // Fast path for ScreenUser: validate tenant from JWT, use + // getReference() for the entity. Zero DB queries. + if ($user instanceof ScreenUser) { + $resolved = $this->resolveScreenTenantFromJwt($request, $user); + if ($resolved) { + return $passport; + } + } + + // Fallback: original DB path for regular Users and legacy JWTs + // that predate the tenantId claim. + if ($user->getTenants()->isEmpty()) { + throw new AuthenticationException('User has no tenant access'); + } + + if ($request->headers->has(self::AUTH_TENANT_ID_HEADER)) { + $requestTenantKey = $request->headers->get(self::AUTH_TENANT_ID_HEADER); + $hasTenantAccess = false; + + foreach ($user->getTenants() as $tenant) { + if ($tenant->getTenantKey() === $requestTenantKey) { + $user->setActiveTenant($tenant); + $hasTenantAccess = true; + break; + } + } + + if (!$hasTenantAccess) { + throw new AuthenticationException( + 'Unknown tenant key or user has no access to tenant: '.$requestTenantKey + ); + } + } else { + $defaultTenant = $user->getTenants()->first(); + $user->setActiveTenant($defaultTenant); + } + + return $passport; + } + + private function resolveScreenTenantFromJwt(Request $request, ScreenUser $user): bool + { + if (null === $this->jwtManager || null === $this->entityManager) { + return false; + } + + $authHeader = $request->headers->get('Authorization', ''); + $rawToken = str_replace('Bearer ', '', $authHeader); + + if ('' === $rawToken) { + return false; + } + + try { + $payload = $this->jwtManager->parse($rawToken); + } catch (\Throwable) { + return false; + } + + $jwtTenants = $payload['tenants'] ?? []; + if (empty($jwtTenants)) { + return false; + } + + // Find the matching tenant from JWT claims. + $requestTenantKey = $request->headers->get(self::AUTH_TENANT_ID_HEADER); + $matchedTenant = null; + + foreach ($jwtTenants as $jwtTenant) { + $key = is_object($jwtTenant) + ? ($jwtTenant->tenantKey ?? null) + : ($jwtTenant['tenantKey'] ?? null); + $id = is_object($jwtTenant) + ? ($jwtTenant->tenantId ?? null) + : ($jwtTenant['tenantId'] ?? null); + + if (null === $key || null === $id) { + // Legacy JWT without tenantId — fall back to DB path. + return false; + } + + if (null === $requestTenantKey || $key === $requestTenantKey) { + $matchedTenant = ['key' => $key, 'id' => $id]; + break; + } + } + + if (null === $matchedTenant) { + throw new AuthenticationException( + 'Unknown tenant key or user has no access to tenant: '.$requestTenantKey + ); + } + + // getReference() returns a Doctrine proxy with the ID set but + // NEVER queries the database. Downstream code that only calls + // $tenant->getId() (like TenantExtension) works without any + // proxy initialization. + $tenantUlid = Ulid::fromString($matchedTenant['id']); + $tenantRef = $this->entityManager->getReference(Tenant::class, $tenantUlid); + + $user->preloadTenant($tenantRef); + + return true; + } +} +``` + +**Verify:** +```bash +docker compose run --rm phpfpm composer test +docker compose run --rm phpfpm composer code-analysis +docker compose run --rm phpfpm composer coding-standards-check +``` + +**Changelog:** Fixed: Screen authentication validates tenant entirely from the JWT payload using Doctrine `getReference()`. Zero DB queries during authentication. Legacy JWTs without tenant IDs fall back to the original DB path. + +--- + +## Phase 2: Frontend bug fixes + +### Step 6 — Fix `displayFallback` not reset in reauthenticateHandler + +**Branch:** `fix/display-fallback-reset-on-reauth` + +**Bug:** `App.jsx` has three mutually exclusive render conditions, in +priority order: + +1. `displayFallback === true` → render the fallback image (OS2Display logo) +2. `screen != null` (after displayFallback is false) → render the screen content +3. Otherwise → render nothing + +The `` background is black by default in the client CSS. When all +three conditions miss, the DOM has no visible children and the user +sees a pure black screen — the exact failure mode operators report most +often. + +`reauthenticateHandler` fires when the data-sync layer receives a 401. +It calls `tokenService.stopRefreshing()`, `appStorage.clearAuth()` (wipes +localStorage tokens), `setScreen(null)` (clears current content), and +`checkLogin()` (restarts the login polling loop). Nowhere does it +set `displayFallback` back to `true`. + +At the moment reauth begins: `displayFallback` is `false` (set earlier +when content loaded successfully) AND `screen` is `null` (just cleared). +Both conditions miss. Black screen. If `checkLogin` eventually resolves +and new content loads, the state self-corrects. But if login fails — +refresh token expired (Step 5's TTL bug makes this common), backend +briefly unavailable, network issue — the screen stays black indefinitely +with no visible indication that recovery is in progress. + +This bug is the proximate cause of most "screen went black and never +came back" support tickets. The hardware is working, the browser is +running, but the render tree is empty. The fix adds one line — +`setDisplayFallback(true)` at the start of the handler — so users see +the known-good fallback image during reauth, not an ambiguous black +screen. + +**RED — write test:** + +File: `assets/client/components/__tests__/app-reauth.test.jsx` + +Mount `` in test, simulate: +1. Set `displayFallback` to false (via dispatching `contentNotEmpty`) +2. Trigger `reauthenticate` event +3. Mock `tokenService.refreshToken` to reject +4. Assert that the fallback div is rendered after reauth failure + +Currently fails — fallback div is absent after reauth failure. + +```bash +docker compose run --rm node npx vitest run --reporter=verbose +``` + +**GREEN — fix:** + +In `assets/client/app.jsx` `reauthenticateHandler` catch block, add +before `checkLogin()`: +```js +setDisplayFallback(true); +``` + +**Verify:** +```bash +docker compose run --rm node npx vitest run +docker compose run --rm playwright npx playwright test +``` + +**Changelog:** Fixed: Fallback image now shows during auth recovery instead of black screen. + +--- + +### Step 6a — Fix `checkToken` clear-error typo + +**Branch:** `fix/check-token-clear-typo` + +**Bug:** `tokenService.checkToken()` sets error codes based on the JWT's +expire state, then tries to clear them when the token transitions back +to valid: + +```js +} else { + const err = statusService.error; + if ( + err !== null && + [ + constants.TOKEN_EXPIRED, + constants.TOKEN_VALID_SHOULD_HAVE_BEEN_REFRESHED, + ].includes(err) + ) { + statusService.setError(null); + } +} +``` + +`statusService.error` contains the error CODES: `"ER105"` or `"ER106"`. +But the `includes` check compares against the expire STATE names: +`constants.TOKEN_EXPIRED = "Expired"` and +`constants.TOKEN_VALID_SHOULD_HAVE_BEEN_REFRESHED = "ValidShouldHaveBeenRefreshed"`. + +The comparison is `["ER105", "ER106"]` vs `["Expired", +"ValidShouldHaveBeenRefreshed"]`. These never match. The `setError(null)` +line never executes. Once `checkToken` sets ER105 or ER106, those codes +stay in the URL until something else clears them (only `checkLogin` +during a rebind does this reliably). + +Visible symptom: screens that briefly experience token lateness show +`?error=ER106` in the URL permanently, even after the next successful +refresh. Ops teams see the error code and investigate — but the screen +is actually fine and has been for hours. + +This bug predates every other change in the plan and should be fixed +first because: + +1. It's a one-line typo, trivial to fix +2. It affects production NOW, independent of the SW work +3. Step 18's error taxonomy audit assumes clear paths actually work — + this needs to be true before Step 18 runs +4. `token-service.js` is deleted in Step 15, so the fix needs to land + and be validated before Step 15 rewrites the logic in the SW + +**RED — write test:** + +File: `assets/client/service/__tests__/token-service-clear-error.test.js` + +1. Manually set `statusService.error = constants.ERROR_TOKEN_EXPIRED` + (ER105). +2. Set up a valid, non-expired token in `appStorage`. +3. Call `tokenService.checkToken()`. +4. Assert `statusService.error === null` after the call. + +This test currently fails — the bug keeps the error set. + +Add a second test for ER106 symmetry: + +1. Set `statusService.error = constants.ERROR_TOKEN_VALID_SHOULD_HAVE_BEEN_REFRESHED` +2. Valid fresh token in appStorage. +3. Call `checkToken()`. +4. Assert `statusService.error === null`. + +```bash +docker compose run --rm node npx vitest run --reporter=verbose +``` + +**GREEN — fix:** + +In `assets/client/service/token-service.js` `checkToken`, change: + +```diff +- [ +- constants.TOKEN_EXPIRED, +- constants.TOKEN_VALID_SHOULD_HAVE_BEEN_REFRESHED, +- ].includes(err) ++ [ ++ constants.ERROR_TOKEN_EXPIRED, ++ constants.ERROR_TOKEN_VALID_SHOULD_HAVE_BEEN_REFRESHED, ++ ].includes(err) +``` + +**Verify:** +```bash +docker compose run --rm node npx vitest run +``` + +**Changelog:** Fixed: Token error codes ER105 and ER106 now clear from the URL when the token state returns to valid. Previous comparison used state names instead of error codes, silently never matching. + +--- + +### Step 7 — Fix `checkLogin` dead-end on unknown status + +**Branch:** `fix/check-login-unknown-status-retry` + +**Bug:** `checkLogin()` is the boot-time polling loop that asks the +backend "has an admin claimed my bind key yet?" It calls +`tokenService.checkLogin()` which returns an object with a `status` +field. The current implementation handles two cases explicitly: + +- `'ready'` — admin has bound the screen; store tokens, start content +- `'awaitingBindKey'` — not yet; schedule another `checkLogin` in 5s + +Any other value falls through with no action. The Promise resolves, no +retry is scheduled, the polling loop stops. The screen sits waiting for +an event that never fires. + +Unknown statuses can arise from a surprising number of sources: +- Backend returning a new status value after a protocol update (version + skew between client and API during rolling deployment) +- Malformed JSON from a misconfigured reverse proxy or cache layer +- Response body truncation from network middleware +- HTTP 200 with empty body from a Redis cache edge case +- Typo in a status string on either side + +When it happens, the screen shows the bind key but never transitions to +content — even after the admin successfully binds. From the admin side, +the bind appears to fail (no visible state change on the screen). They +re-bind, which now works because Step 3 allows rebinding, but the new +bind cycle also dead-ends on the same unknown status. + +The fix adds an `else` branch that retries `checkLogin` on any +unexpected status. Unknown becomes transient rather than permanent. +When the backend resolves (protocol mismatch fixed, cache flushed, +etc.), the screen self-heals on the next poll. + +**RED — write test:** + +File: `assets/client/components/__tests__/app-login.test.jsx` + +Mock `tokenService.checkLogin` to resolve with +`{ status: 'somethingUnexpected' }`. Assert that `restartLoginTimeout` is +called (login polling continues). + +Currently fails — no action taken, no retry scheduled. + +```bash +docker compose run --rm node npx vitest run --reporter=verbose +``` + +**GREEN — fix:** + +In `assets/client/app.jsx` `checkLogin`, add an else clause: +```js +} else { + restartLoginTimeout(); +} +``` + +**Verify:** +```bash +docker compose run --rm node npx vitest run +``` + +**Changelog:** Fixed: Login polling no longer stops on unexpected status responses. + +--- + +### Step 8 — Fix `screenHandler` undefined guard + +**Branch:** `fix/screen-handler-undefined-guard` + +**Bug:** `screenHandler` listens for the custom `screen` event +dispatched by the data-sync layer. Events are created with +`new CustomEvent('screen', { detail: { screen: screenData } })` and the +handler reads `event.detail.screen`. The existing guard: + +```js +if (screenData !== null) { + setScreen(screenData); +} +``` + +This catches explicit `null` but not `undefined`. When an event is +dispatched without the `screen` key (or with `detail: {}`), +`event.detail.screen` is `undefined`. `undefined !== null` evaluates to +`true`. `setScreen(undefined)` executes. The `screen` state becomes +`undefined`, which is falsy in the app's render check. + +Combined with `displayFallback = false` (from an earlier successful +render), both render conditions fail — we're back at the Step 6 black +screen, from a different entry point. + +Dispatch sites that omit the screen key include certain error paths in +the data-sync layer where a screen-switch cycle begins but the new +screen data hasn't finished loading. The old screen is cleared, the +new screen event fires without data, then the fetch completes and fires +another event with the real data — but between those two events, the +screen is black. + +The fix changes `!==` to `!=`. The loose comparison catches both `null` +and `undefined` because `null == undefined` is true by spec. This is +one of the few cases where `!=` is preferred over `!==`: it's +specifically designed as a nullish check. The linter should be +configured to allow it here. + +**RED — write test:** + +File: `assets/client/components/__tests__/app-screen-handler.test.jsx` + +Dispatch a `screen` event with `detail: {}` (no `screen` property). +Assert that `screen` state is NOT set to `undefined`. + +Currently fails — `undefined !== null` passes the guard and `setScreen(undefined)` is called. + +```bash +docker compose run --rm node npx vitest run --reporter=verbose +``` + +**GREEN — fix:** + +In `assets/client/app.jsx` `screenHandler`, change: +```js +if (screenData !== null) { +``` +To: +```js +if (screenData != null) { +``` + +This catches both `null` and `undefined`. + +**Verify:** +```bash +docker compose run --rm node npx vitest run +``` + +**Changelog:** Fixed: Screen handler no longer accepts undefined screen data. + +--- + +### Step 9 — Fix `PullStrategy.start()` missing error handling + +**Branch:** `fix/pull-strategy-start-catch` + +**Bug:** `PullStrategy.start()` is the entry point for the client's +content polling loop. It fetches the screen's data, then registers a +`setInterval` to re-fetch every 90 seconds: + +```js +start() { + this.getScreen(this.entryPoint).then(() => { + this.activeInterval = setInterval( + () => this.getScreen(this.entryPoint), + this.interval, + ); + }); +} +``` + +The interval registration lives inside `.then()`. If the initial +`getScreen` rejects — for any reason, including transient ones — +`.then()` never fires. The interval is never created. The promise +rejection bubbles up as an unhandled rejection. + +From that moment, the screen has no content polling. No amount of time +passing, no backend recovery, no network restoration will trigger a +retry. `start()` was called once at boot and will not be called again +without a full page reload. + +The most common trigger is boot-time timing collisions: the screen +boots while the backend is warming up after a deployment, the initial +fetch times out, the polling loop never starts. Another common cause is +a screen booting during a brief network outage — content loads fine +moments later, but the client doesn't know to try again. + +The fix uses `.catch()` to log the error and `.finally()` to +unconditionally register the interval. Initial fetch failure is +survivable: the interval still runs, and the next tick (90 seconds +later) retries. A screen that boots during a backend restart recovers +within two minutes with no manual intervention. + +**RED — write test:** + +File: `assets/client/data-sync/__tests__/pull-strategy.test.js` + +Create a PullStrategy with a mock `apiHelper` that throws on `getPath`. +Call `start()`. Assert that: +- The error is caught (no unhandled rejection) +- The periodic interval is created (recovery continues) + +Currently fails — `.then()` never fires, interval never starts. + +```bash +docker compose run --rm node npx vitest run --reporter=verbose +``` + +**GREEN — fix:** + +In `assets/client/data-sync/pull-strategy.js` `start()`: +```js +start() { + this.getScreen(this.entryPoint) + .catch((err) => { + logger.error("Initial getScreen failed:", err); + }) + .finally(() => { + this.stop(); + this.activeInterval = setInterval( + () => this.getScreen(this.entryPoint), + this.interval, + ); + }); +} +``` + +**Verify:** +```bash +docker compose run --rm node npx vitest run +``` + +**Changelog:** Fixed: Pull strategy interval now starts even if the initial fetch fails. + +--- + +### Step 10 — Fix `findNextSlide` with empty array + +**Branch:** `fix/find-next-slide-empty-array` + +**Bug:** `region.jsx` advances through its slides using modular +arithmetic. When a slide completes, `slideDone` computes the next +index: + +```js +const nextIndex = (currentSlideIndex + 1) % slides.length; +const nextSlide = slides[nextIndex]; +setCurrentSlide(nextSlide); +``` + +This works as long as `slides.length > 0`. With an empty array, +JavaScript evaluates `0 % 0` as `NaN`. Array indexing with `NaN` +returns `undefined`. `setCurrentSlide(undefined)` runs. The Slide +component receives undefined props and throws on the next render +(typically "Cannot read properties of undefined (reading 'type')" or +similar, depending on the slide template). + +The region-level ErrorBoundary catches the throw. Before Step 11b, +the region stays in the error state forever — one dead region on an +otherwise working screen. After Step 11b, the region auto-reloads +after 30 seconds, but that's treating the symptom. + +Empty slide arrays can occur in legitimate scenarios: + +- Schedule gap: a region's playlist has slides scheduled for specific + time windows; between windows, the effective slide list is empty +- All scheduled slides have already passed (admin configuration error, + or NTP drift on the screen pushing "now" past the schedule end) +- Backend returns empty slides during a transitional state + +The fix adds an early return in `slideDone` when `slides` is empty or +undefined. Log a warning (so ops can investigate). The region keeps +its current state (last slide, or blank) until new slides arrive from +the next data-sync cycle. No ErrorBoundary trigger, no auto-reload, +no visible error to the end user. + +**RED — write test:** + +File: `assets/client/components/__tests__/region-empty-slides.test.jsx` + +Render a `` with an initial slide, then dispatch +`regionContent-{id}` with an empty slides array. After the current slide +calls `slideDone`, assert that no exception is thrown. + +Currently produces NaN from `0 % 0`. + +```bash +docker compose run --rm node npx vitest run --reporter=verbose +``` + +**GREEN — fix:** + +In `assets/client/components/region.jsx` `slideDone`, add an early return: +```js +const slideDone = (slide) => { + if (!slides || slides.length === 0) { + logger.warn("slideDone called with empty slides array"); + return; + } + // ... rest of existing logic +}; +``` + +**Verify:** +```bash +docker compose run --rm node npx vitest run +``` + +**Changelog:** Fixed: Slide progression no longer crashes when the slide array is empty. + +--- + +### Step 11 — Add top-level ErrorBoundary + +**Branch:** `fix/top-level-error-boundary` + +**Bug:** `index.jsx` renders `` directly with no error boundary +above it. The existing ErrorBoundary component is applied inside App +at the region and slide levels, but nothing catches errors in App +itself. + +React's error handling semantics: if a component throws during render +and no ancestor ErrorBoundary exists, React unmounts the entire +component tree. The root div becomes empty. With the `` CSS +background set to black, the user sees a black screen — the same +visible symptom as the unprotected cases in Steps 6 and 8, but from a +different cause and with worse consequences: the whole React app is +gone, not just one region. + +App-level crashes originate from a narrow set of places — state +initialization, context provider setup, the top-level useEffect hooks +handling auth and boot — but the blast radius when they happen is +total. A single uncaught throw in any of App's initialization paths +kills the page. + +Observed triggers: + +- Corrupted state in localStorage causing App's initial read to throw +- A context provider throwing during construction (upstream library + bugs) +- Race conditions during fast screen-switch events unmounting App + mid-lifecycle +- Third-party library errors (Sentry, logger) during App init + +Without a top-level boundary, the SW watchdog (Step 15) is the only +recovery mechanism, but that requires a 30-second timeout. 30 seconds +of black screen is a long time on a public display. + +The fix wraps `` in an ErrorBoundary with `reloadTimeout={15000}`. +React catches the error, renders the fallback UI briefly, then reloads +the page. The hardcoded 15000 is a boot-time fallback (config isn't +loaded yet at this outer boundary — Step 11a's config applies only to +inner boundaries). Recovery happens automatically in under 15 seconds, +well before the SW watchdog would need to intervene. + +**RED — write test:** + +File: `assets/client/__tests__/index-error-boundary.test.jsx` + +Render `` wrapped in ``. +Make App throw during render. Assert: +- The error is caught (ErrorBoundary fallback UI renders) +- After timeout, `window.location.reload` is called + +Currently fails — App crash unmounts the entire React tree. + +```bash +docker compose run --rm node npx vitest run --reporter=verbose +``` + +**GREEN — fix:** + +In `assets/client/index.jsx`, wrap `` with ``: +```jsx +import ErrorBoundary from "./components/error-boundary.jsx"; + +root.render( + + + +); +``` + +Note: the hardcoded 15000 is a boot-time fallback. Once `App` mounts +and loads config, the actual `appErrorReloadTimeout` from the config +is used for the region and inner ErrorBoundaries. The top-level +boundary wraps `App` itself so config isn't available yet — the fallback +is acceptable here. + +Update `assets/client/components/error-boundary.jsx` to support +`reloadTimeout` prop — schedule `window.location.reload()` after +timeout, clear timer in `componentWillUnmount`. + +**Verify:** +```bash +docker compose run --rm node npx vitest run +docker compose run --rm playwright npx playwright test +``` + +**Changelog:** Added: Top-level error boundary with auto-reload recovers from unhandled React crashes. + +--- + +### Step 11a — Add error recovery timeout to client config + +**Branch:** `feat/error-recovery-config` + +**Motivation:** Steps 11, 11b, 11c, and 15 all introduce timeouts: +watchdog, ErrorBoundary reload, crash-loop window, backoff duration, +heartbeat interval, global error reload delay. Each has a sensible +default, but "sensible" depends on hardware and content. + +On modern kiosk hardware with lightweight templates, a 30-second +watchdog is generous — the main thread shouldn't block for more than a +few hundred milliseconds. On a 2016-vintage ARM kiosk running a heavy +canvas-based weather template at 4K resolution, a single frame render +can exceed 5 seconds, and paint under memory pressure can stall for +15 seconds. The same 30-second watchdog that's generous on new +hardware is aggressive on old hardware, causing false watchdog kills +during legitimate slow renders. + +Exposing 8 separate timeouts to operators (watchdog, heartbeat, +region-reload, app-reload, global-error-delay, crash-max, crash-window, +crash-backoff) is too much surface area. Operators don't have the +information to tune each one independently — they think in terms of +"how long before recovery kicks in." One value that everything derives +from matches that mental model: + +``` +CLIENT_ERROR_RECOVERY_TIMEOUT=30000 # default + ↓ +heartbeatInterval: t / 6 (5s) +globalErrorReloadDelay: t / 3 (10s) +appErrorReloadTimeout: t / 2 (15s) +regionErrorReloadTimeout: t (30s) +watchdogTimeout: t (30s) +crashGuardWindow: t * 2 (60s) +crashGuardBackoff: t * 4 (120s) +``` + +A heavy-kiosk operator sets `CLIENT_ERROR_RECOVERY_TIMEOUT=120000` and +every timeout scales proportionally: 20s heartbeats, 60s app reload, +120s watchdog, 240s crash window, 8-minute backoff. No false triggers +from slow renders; if something genuinely breaks, recovery still +happens within a few minutes. + +The compiler pass (Step 12) hashes this env var into the +`X-OS2Display-Config-Hash` response header, so the SW detects when it +changes and re-fetches config without a deploy. + +**RED — write test:** + +File: `tests/Controller/Client/ClientConfigControllerTest.php` + +1. GET `/config/client`. Assert response contains `errorRecoveryTimeout` + with default value `30000`. +2. Override env var `CLIENT_ERROR_RECOVERY_TIMEOUT=120000`, GET + `/config/client`, assert the value is reflected. + +File: `assets/client/util/__tests__/error-recovery-timeouts.test.js` + +1. `deriveTimeouts(30000)` returns the expected ratios (heartbeat=5000, + appReload=15000, watchdog=30000, crashWindow=60000, etc.). +2. `deriveTimeouts(120000)` scales everything proportionally. +3. `deriveTimeouts(undefined)` uses the default 30000. + +```bash +docker compose run --rm phpfpm composer test -- --filter=ClientConfigControllerTest +docker compose run --rm node npx vitest run +``` + +**GREEN — implement:** + +Add env var to `.env`: + +```env +CLIENT_ERROR_RECOVERY_TIMEOUT=30000 +``` + +Update `src/Controller/Client/ClientConfigController.php` — add one +constructor argument and one response key: + +```php +public function __construct( + // ... existing params ... + private readonly int $errorRecoveryTimeout, +) {} + +public function __invoke(): Response +{ + return new JsonResponse([ + // ... existing keys ... + 'errorRecoveryTimeout' => $this->errorRecoveryTimeout, + ]); +} +``` + +Update `config/services.yaml` bindings: + +```yaml +App\Controller\Client\ClientConfigController: + arguments: + # ... existing args ... + $errorRecoveryTimeout: '%env(int:CLIENT_ERROR_RECOVERY_TIMEOUT)%' +``` + +Create `assets/client/util/error-recovery-timeouts.js` — the single +function subsequent steps use: + +```js +/** + * Derive all error recovery timeouts from one operator-facing value. + * + * Mental model: "how long before recovery kicks in" = t. + * All downstream timeouts scale proportionally: + * - heartbeat 6x faster than t (granularity) + * - app reload at t/2 (fast for whole-app crashes) + * - region reload at t (slower — other regions still work) + * - watchdog at t (matches region) + * - crash window 2t, backoff 4t (loop prevention scale) + */ +export function deriveTimeouts(errorRecoveryTimeout = 30000) { + const t = errorRecoveryTimeout; + return { + heartbeatInterval: Math.round(t / 6), + globalErrorReloadDelay: Math.round(t / 3), + appErrorReloadTimeout: Math.round(t / 2), + regionErrorReloadTimeout: t, + watchdogTimeout: t, + crashGuardWindow: t * 2, + crashGuardBackoff: t * 4, + crashGuardMaxCrashes: 5, // fixed — not time-based + }; +} +``` + +Update `src/DependencyInjection/Compiler/ApiResponseHeaderPass.php` to +include `CLIENT_ERROR_RECOVERY_TIMEOUT` in the config hash so the SW +detects when it changes. + +**Verify:** +```bash +docker compose run --rm phpfpm composer test +docker compose run --rm phpfpm composer code-analysis +docker compose run --rm phpfpm composer coding-standards-check +``` + +**Changelog:** Added: `CLIENT_ERROR_RECOVERY_TIMEOUT` env var controls all error recovery timeouts. Downstream timeouts (heartbeat, watchdog, ErrorBoundary reload, crash-guard window/backoff) derived from this single value as ratios. Operators scale the whole recovery system with one number. + +--- + +### Step 11b — Region ErrorBoundary with auto-recovery + +**Branch:** `fix/region-error-boundary-reload` + +**Motivation:** The existing ErrorBoundary component catches render +errors in the region tree and displays a fallback error UI. This is the +right default for a desktop app where a developer sees the error +boundary output and fixes the code. On an unattended 24/7 kiosk, a +permanent error UI is a dead region — the rest of the screen continues +running, but that portion is lost until a human intervenes. + +For a customer deployment where screens show information to the +public (transit signs, wayfinding, cafeteria menus), a region +permanently stuck in "Something went wrong" is worse than useless — +it's actively misleading. Worse, region errors usually indicate +transient issues: a malformed slide payload, a CDN timeout for a +media asset, a template JS error triggered by an edge-case prop. The +next data-sync cycle (90 seconds later) usually has the correct data, +but ErrorBoundaries don't retry rendering — they stay in the error +state until something forces React to remount. + +Adding a `reloadTimeout` prop to ErrorBoundary gives operators a simple +recovery primitive. Region-level boundaries get a 30-second timeout +(matches `errorRecoveryTimeout`). If a region's render fails, the error +UI shows briefly, then the page reloads. A fresh boot pulls fresh data; +if the error was transient, the screen self-heals. If the error is +persistent, it re-triggers on the next boot — and the crash-loop guard +(Step 11c) catches that pattern and stops the reload cycle. + +This step preserves backward compatibility: `` without +`reloadTimeout` keeps the original "display the error forever" behavior. +Only region.jsx passes the timeout. Callers opting into auto-recovery +do so explicitly. + +**RED — write test:** + +File: `assets/client/components/__tests__/error-boundary-reload.test.jsx` + +1. Render `` around a component that + throws. Assert `window.location.reload` is called after ~1000ms. +2. Render `` (no reloadTimeout) around a throwing component. + Assert `window.location.reload` is NOT called (existing behavior preserved). +3. Render ``, trigger error, then unmount + before timeout. Assert `window.location.reload` is NOT called (timer + cleared on unmount). + +```bash +docker compose run --rm node npx vitest run --reporter=verbose +``` + +**GREEN — fix:** + +In `assets/client/components/error-boundary.jsx`: + +```js +componentDidCatch(error, errorInfo) { + // ... existing logging and errorHandler call ... + + const { reloadTimeout = null } = this.props; + if (reloadTimeout !== null) { + this.reloadTimer = setTimeout(() => { + window.location.reload(); + }, reloadTimeout); + } +} + +componentWillUnmount() { + if (this.reloadTimer) { + clearTimeout(this.reloadTimer); + } +} +``` + +In `assets/client/components/region.jsx`, pass the timeout from config +(loaded in `App` and passed via context or prop, or read from the config +loader directly): + +```diff +- ++ +``` + +The default of 30000 matches the `.env` default. Operators can increase +it for deployments with heavy templates that might trigger longer renders. + +**Verify:** +```bash +docker compose run --rm node npx vitest run +docker compose run --rm playwright npx playwright test +``` + +**Changelog:** Added: Region ErrorBoundary auto-reloads the page after 30 seconds if a region crashes, preventing permanent broken regions on kiosk screens. + +--- + +### Step 11c — Global error handlers with crash-loop guard + +**Branch:** `fix/global-error-handlers-crash-guard` + +**Motivation:** React's ErrorBoundary catches errors during render, +commitPhase, and constructors. It does NOT catch: + +- Errors in event handlers (onClick, onChange, etc.) +- Errors in setTimeout/setInterval callbacks +- Errors in requestAnimationFrame callbacks +- Unhandled Promise rejections +- Errors in the data-sync layer (which lives outside the React tree) +- Errors in fetch callbacks +- Errors thrown by addEventListener callbacks the app attached + +In the OS2Display client, most runtime work happens in these excluded +zones: the pull-strategy runs setTimeout chains, tokenService does fetch +callbacks, the logger subscribes to event dispatches, template slides +use animation frames for transitions. An error in any of these is +completely invisible to ErrorBoundaries. It logs to console (maybe) and +the app silently degrades or hangs. + +`window.onerror` and `window.addEventListener('unhandledrejection')` +catch everything the React boundaries miss. They're the last-resort +safety net. Without them, a kiosk that hits an async error just... +stops working, with no recovery path short of a SW watchdog timeout +(30+ seconds at best). + +But global handlers alone aren't enough. They need a crash-loop guard. +Consider the failure mode: a persistent bug — corrupted localStorage +state, a bad backend response the client can't handle, a template that +always throws — crashes the app immediately on every boot. Without a +guard, the app starts → crashes → reloads → crashes → reloads in a +tight loop. Hundreds of reloads per minute. Battery drain on battery- +backed hardware. Backend flood from the reboot bursts. Log file +saturation. Eventually hardware thermal throttling. + +The guard tracks crash count in a rolling window. N crashes within +window = crash loop detected. Suppress reloads for a backoff period +(long enough that whatever's wrong has time to recover — backend +restart finishing, network clearing, cache expiring). After backoff, +try again. If still broken, extended backoff. If recovered, the counter +resets on the next successful boot. + +Using localStorage for the guard state is acceptable here because: +1. The guard runs before the SW is registered (boot-time), so IndexedDB + async access is awkward +2. Its state is tiny (3 numbers) +3. The consequences of storage loss are benign (counter resets, guard + lets the next crash through) + +Step 15's SW-side crash guard supersedes this one — moving state to +IndexedDB where the SW can also read it, enabling cross-thread +coordination (SW watchdog reloads also count against the guard). + +**RED — write test:** + +File: `assets/client/util/__tests__/crash-guard.test.js` + +1. Call `crashGuard.recordCrash()` once. Assert `shouldReload()` returns true. +2. Call `recordCrash()` 5 times within 60 seconds. Assert `shouldReload()` + returns false (crash-loop detected — back off). +3. Call `recordCrash()` 5 times, wait for the backoff window to expire, call + again. Assert `shouldReload()` returns true (loop broken). +4. Call `crashGuard.reset()`. Assert crash count is zero. + +File: `assets/client/util/__tests__/global-error-handlers.test.js` + +1. Dispatch a `window.onerror` event. Assert the crash guard is consulted. +2. Dispatch an `unhandledrejection` event. Assert the crash guard is consulted. +3. When `shouldReload()` returns true, assert `window.location.reload` is called + after a short delay. +4. When `shouldReload()` returns false (crash loop), assert reload is NOT called. + +```bash +docker compose run --rm node npx vitest run --reporter=verbose +``` + +**GREEN — implement:** + +Create `assets/client/util/crash-guard.js`: + +```js +const STORAGE_KEY = 'os2display_crash_guard'; + +// Defaults match .env — overridden after config loads. +let maxCrashes = 5; +let crashWindow = 60_000; +let backoffDuration = 120_000; + +function getState() { + try { + const raw = localStorage.getItem(STORAGE_KEY); + return raw ? JSON.parse(raw) : { count: 0, firstCrash: null, backoffUntil: null }; + } catch { + return { count: 0, firstCrash: null, backoffUntil: null }; + } +} + +function saveState(state) { + try { localStorage.setItem(STORAGE_KEY, JSON.stringify(state)); } catch {} +} + +const crashGuard = { + /** Call after config loads to override defaults. */ + configure({ crashGuardMaxCrashes, crashGuardWindow, crashGuardBackoff }) { + if (crashGuardMaxCrashes) maxCrashes = crashGuardMaxCrashes; + if (crashGuardWindow) crashWindow = crashGuardWindow; + if (crashGuardBackoff) backoffDuration = crashGuardBackoff; + }, + + recordCrash() { + const now = Date.now(); + const state = getState(); + + if (!state.firstCrash || now - state.firstCrash > crashWindow) { + state.count = 1; + state.firstCrash = now; + } else { + state.count++; + } + + if (state.count >= maxCrashes) { + state.backoffUntil = now + backoffDuration; + } + + saveState(state); + }, + + shouldReload() { + const state = getState(); + if (state.backoffUntil && Date.now() < state.backoffUntil) { + return false; + } + return true; + }, + + reset() { + try { localStorage.removeItem(STORAGE_KEY); } catch {} + }, +}; + +export default crashGuard; +``` + +Create `assets/client/util/global-error-handlers.js`: + +```js +import logger from '../logger/logger'; +import crashGuard from './crash-guard'; + +let reloadDelay = 10_000; // Default — overridden by configure(). + +export function installGlobalErrorHandlers() { + window.addEventListener('error', (event) => { + logger.error('[global] Uncaught error:', event.error); + handleFatalError(); + }); + + window.addEventListener('unhandledrejection', (event) => { + logger.error('[global] Unhandled rejection:', event.reason); + handleFatalError(); + }); +} + +/** Call after config loads to override the default reload delay. */ +export function configureGlobalErrorHandlers({ globalErrorReloadDelay }) { + if (globalErrorReloadDelay) reloadDelay = globalErrorReloadDelay; +} + +function handleFatalError() { + crashGuard.recordCrash(); + + if (crashGuard.shouldReload()) { + setTimeout(() => window.location.reload(), reloadDelay); + } else { + logger.warn('[global] Crash loop detected — suppressing reload'); + } +} +``` + +Wire into `assets/client/app.jsx` useEffect: + +```js +import { installGlobalErrorHandlers, configureGlobalErrorHandlers } from './util/global-error-handlers'; +import crashGuard from './util/crash-guard'; + +useEffect(() => { + installGlobalErrorHandlers(); + crashGuard.reset(); // Successful boot — reset the counter. + + ClientConfigLoader.loadConfig().then((config) => { + // Push config values to error recovery modules. + crashGuard.configure(config); + configureGlobalErrorHandlers(config); + // ... existing config handling ... + }); +}, []); +``` + +The `reset()` on successful boot breaks the crash-loop: if the app +managed to mount and start running, the previous crashes are forgiven. + +Later in Step 15, the crash-loop guard will be enhanced to live in the +SW (survives main-thread death) using IndexedDB instead of localStorage. + +**Verify:** +```bash +docker compose run --rm node npx vitest run +docker compose run --rm playwright npx playwright test +``` + +**Changelog:** Added: Global `window.onerror` and `unhandledrejection` handlers with crash-loop guard. Catches non-React errors and auto-reloads with backoff protection. + +--- + +## Phase 3: Backend improvements + +### Step 12 — Add API response headers via compiler pass + +**Branch:** `feat/api-response-headers` + +**Motivation:** The current client has two polling loops that have +nothing to do with actual content: + +- `release.json` every 10 minutes — checks whether a new client build + has been deployed so the browser can reload +- `/config/client` every 15 minutes — checks whether the backend's + client config has changed + +For a 200-screen deployment, that's 200 × 6 requests/hour for release +checking + 200 × 4 requests/hour for config checking = 2000 wasted +requests per hour. Each request costs a TCP handshake, TLS negotiation, +HTTP round trip, PHP-FPM process time, and a DB query (in the config +endpoint's case). All to discover "nothing changed" 99.9% of the time. + +Worse, the release-check loop has a race condition. When it detects a +new build, it calls `window.location.replace()` to reload — but the +detection sits inside a `.finally()` that also triggers `checkLogin()` +as part of an unrelated path. The race: `.finally()` fires, `checkLogin()` +starts API calls, those API calls 401 because the deploy rotated some +keys, `reauthenticateHandler` wipes localStorage — AFTER the browser +has initiated the redirect but BEFORE navigation completes. The page +reloads with no auth, the client goes to the bind screen. + +The cleaner model: the client is already making API calls for content +(every 90 seconds via pull-strategy). Those responses can carry +lightweight metadata telling the client when release or config +changes. Three headers: + +- `X-OS2Display-Release-Timestamp` — unix timestamp of the current + build, set at deploy time via env var +- `X-OS2Display-Release-Version` — the version string, for logging +- `X-OS2Display-Config-Hash` — 8-char hex hash of all client config + env vars, computed at `cache:warmup` + +Headers ride on every existing `/v2/` response. Zero new requests. +Detection happens at the cadence of existing traffic (90 seconds), +which is faster than the old 10-minute release check anyway. + +Computing these values at runtime would mean reading env vars on every +response — cheap but not free, and the config hash would need +re-computation. The compiler pass computes all three values ONCE during +`cache:warmup` and bakes them into the compiled container as plain +string parameters. The subscriber injects the headers with a simple +`$response->headers->set()` call. Zero computation overhead at runtime. + +Step 16 wires the SW to read these headers and notify the main thread. +Step 17 removes the old polling loops entirely. + +**RED — write tests:** + +File: `tests/EventSubscriber/ApiResponseHeaderSubscriberTest.php` + +1. GET request to any `/v2/` endpoint returns all three `X-OS2Display-*` headers. +2. Request to `/admin` does NOT contain the headers. +3. `X-OS2Display-Config-Hash` is a valid 8-char hex string. +4. Hash changes when a config env var changes (boot two kernels, compare). + +File: `tests/DependencyInjection/Compiler/ApiResponseHeaderPassTest.php` + +1. Compiler pass sets three `app.api_header.*` container parameters. +2. Config hash is deterministic (same input → same output). + +```bash +docker compose run --rm phpfpm composer test -- --filter=ApiResponseHeader +``` + +**GREEN — implement:** + +1. Create `src/DependencyInjection/Compiler/ApiResponseHeaderPass.php` +2. Create `src/EventSubscriber/ApiResponseHeaderSubscriber.php` +3. Register pass in `src/Kernel.php` via `build()` method +4. Add service definition in `config/services.yaml` +5. Add `APP_RELEASE_TIMESTAMP=0` and `APP_RELEASE_VERSION=""` to `.env` + and `.env.test` +6. Update `expose_headers` in `config/packages/nelmio_cors.yaml` + +**Verify:** +```bash +docker compose run --rm phpfpm composer test +docker compose run --rm phpfpm composer code-analysis +docker compose run --rm phpfpm composer coding-standards-check +``` + +**Changelog:** Added: API responses include `X-OS2Display-Release-Timestamp`, `X-OS2Display-Release-Version`, and `X-OS2Display-Config-Hash` headers. Values are computed once at cache warmup via a compiler pass. + +--- + +### Step 13 — Move PHP sessions to Redis + +**Branch:** `feat/redis-sessions` + +**Bug:** Symfony uses the default PHP session handler, which stores +session data as files in the container's filesystem (typically under +`/tmp/sess_*`). Session files are created on first use, read on +subsequent requests bearing the session cookie, and garbage-collected +after their TTL. + +Container filesystems are ephemeral in every modern deployment strategy. +Any of the following events deletes the session files: + +- Container restart (new deployment, OOM kill, failed health check, + node drain) +- Container replacement (horizontal scaling event, rolling update) +- Manual recreation for any reason +- Kubernetes pod eviction + +Sessions are critical to the screen binding flow. The sequence: + +1. Unbound screen POSTs to `/v2/authentication/screen` +2. Backend creates a session, stores a bind key in it, returns the key + and sets a session cookie on the response +3. Screen displays the bind key, polls `GET /v2/authentication/screen/status` + every few seconds using the session cookie +4. Admin enters the bind key in the UI +5. Backend writes tokens to the cache against the session's bind key +6. Screen's next poll retrieves the tokens (Step 2's cache issue is a + separate concern) + +If the container restarts between steps 1 and 6 — common during any +deployment — the session file is gone. The screen's polling requests +arrive with a cookie for a session that no longer exists. Backend +treats this as "new session" and creates a fresh one with no bind key. +The screen's polling receives a state that doesn't match what it was +doing moments ago. + +In multi-replica deployments (behind a load balancer), the problem is +worse: session files are local to one container, but different requests +from the same screen may hit different containers. Without session +affinity or shared storage, binding is essentially random. + +The fix routes sessions through Redis via +`handler_id: '%env(REDIS_CACHE_DSN)%'`. Sessions survive container +restarts, work correctly across multiple backend replicas, and require +no new infrastructure — Redis is already in the stack for caching. + +**RED — write test:** + +File: `tests/Security/SessionPersistenceTest.php` + +1. Create screen client, call `POST /v2/authentication/screen`, get bind key. +2. Clear filesystem session storage directory. +3. Same request with same session cookie. +4. Assert bind key is unchanged. + +Currently fails because filesystem sessions are lost. + +Note: the test environment uses `session.storage.factory.mock_file` per +`config/packages/test/framework.yaml` — the test may need an override +or a custom test kernel to exercise Redis sessions. + +```bash +docker compose run --rm phpfpm composer test -- --filter=SessionPersistenceTest +``` + +**GREEN — fix:** + +In `config/packages/framework.yaml`, change: +```yaml +session: + handler_id: '%env(REDIS_CACHE_DSN)%' + cookie_secure: auto + cookie_samesite: lax +``` + +Ensure the `redis` service is in `docker-compose.override.yml` (already +present). + +**Verify:** +```bash +docker compose run --rm phpfpm composer test +docker compose run --rm phpfpm composer code-analysis +docker compose run --rm phpfpm composer coding-standards-check +``` + +**Changelog:** Changed: PHP sessions now stored in Redis to survive container restarts. + +--- + +## Phase 4: Frontend improvements + +### Step 14 — Add IndexedDB auth store (auth-bridge) + +**Branch:** `feat/auth-bridge-indexeddb` + +**Motivation:** The client currently stores all auth state — JWT, +refresh token, screen ID, tenant key — in localStorage. localStorage is +convenient but has properties that hurt kiosk deployments: + +- **Main-thread only.** Service workers cannot access localStorage. + Step 15's SW takes over token refresh and auth injection, which + requires the SW to read and write auth state directly. With + localStorage, the SW would need to postMessage the main thread for + every operation — slow, race-prone, and impossible during main-thread + crashes (the exact scenario the SW watchdog is designed for). + +- **Evictable under memory pressure.** Browsers with tight memory + budgets (cheap ARM kiosks, Chrome OS devices, Android tablets) can + evict localStorage during the "reset" phase of aggressive memory + reclamation. The exact moment the client is fighting to stay alive + is when it's most likely to lose its credentials. + +- **Synchronous API blocks the main thread.** Every read/write pauses + rendering. On slow storage (eMMC on old hardware), this can stall + animations. + +- **5MB limit shared with everything.** Template-generated data, logger + buffers, and auth state all compete for the same quota. Auth can + silently fail writes. + +IndexedDB solves all four: SW-accessible, survives memory pressure +better than localStorage, async API doesn't block, quotas are measured +in gigabytes. Moving auth to IndexedDB isn't just a storage upgrade — +it's a prerequisite for the SW-based auth architecture. + +The auth-bridge pattern keeps backward compatibility and eases the +migration: + +1. On first load after the upgrade, `load()` checks IndexedDB first. +2. If IndexedDB is empty, it reads localStorage and migrates + (write to IDB, keep in localStorage as mirror). +3. Subsequent loads find IDB populated and return from there. +4. Writes go to both stores during a transition period, so rolling back + to the old client works. + +Step 15's SW reads from IndexedDB on activation to have credentials +ready for the first fetch. Step 15 can also remove the localStorage +mirror once the migration period is done (next major version). + +**RED — write test:** + +File: `assets/client/util/__tests__/auth-bridge.test.js` + +Uses `fake-indexeddb` (installed in Step 0). + +1. `setCredentials()` writes to both IndexedDB and localStorage. +2. `load()` reads from IndexedDB first. +3. `load()` migrates from localStorage when IndexedDB is empty. +4. `clear()` removes from both stores. +5. `load()` after `clear()` returns empty credentials. +6. `hasCredentials()` reflects current state correctly. + +```bash +docker compose run --rm node npx vitest run --reporter=verbose +``` + +**GREEN — implement:** + +1. Create `assets/client/util/auth-bridge.js` +2. Wire into `assets/client/app.jsx` — replace `appStorage.getToken()` / + `appStorage.getScreenId()` reads in `checkLogin` with + `authBridge.hasCredentials()` / `authBridge.getScreenId()`. +3. Replace `appStorage.setToken()` / etc. writes in the login flow with + `authBridge.setCredentials()`. +4. Keep `appStorage` for non-auth data (fallback image, previous boot). + +**Verify:** +```bash +docker compose run --rm node npx vitest run +docker compose run --rm playwright npx playwright test +``` + +**Changelog:** Added: Auth credentials stored in IndexedDB (persistent across browser restarts) with localStorage fallback and migration. + +--- + +### Step 15 — Add service worker (auth + watchdog + fetch interception) + +**Branch:** `feat/service-worker` + +**Motivation:** The main thread has always been a single point of +failure. When it dies — stuck in a JavaScript deadlock, paused by a +blocking synchronous operation, crashed by a memory-corruption bug in a +template, deadlocked on a race between React and a template library, +or just running slowly enough to miss polling ticks — nothing in the +client can detect or recover from it. The main thread is both the +patient and the doctor. + +A service worker runs in a separate thread with its own event loop. +It keeps running when the main thread is stuck. This changes the +recovery architecture fundamentally: + +**Watchdog pattern.** Main thread sends a heartbeat to the SW every +`heartbeatInterval` ms. If the SW doesn't hear from the main thread +for `watchdogTimeout` ms, the main thread is considered dead. The SW +calls `client.navigate(client.url)` on the controlled window, forcing +a navigation (equivalent to a reload). This recovers from conditions +that would otherwise permanently hang a kiosk. Without a SW, the only +way to detect main-thread death is a human walking up to the screen. + +**Centralized auth.** The current auth lifecycle spans `app.jsx`, +`tokenService.js`, `api-helper.js`, and `appStorage.js`, with implicit +contracts between them: +- `tokenService.startRefreshing()` polls `/token/refresh` every 7.5 days +- `api-helper.js` catches 401s, dispatches `reauthenticate` events +- `app.jsx` listens for `reauthenticate`, wipes storage, restarts login +- Every API call reads the token from `appStorage` and sets the header + +This architecture has 5 bugs that this plan fixes individually (Steps 5, +6, 9, etc.). But the architecture itself keeps inviting bugs because +auth is a cross-cutting concern scattered across modules. Moving it +into the SW: + +- SW intercepts every `/v2/` fetch, injects the auth header +- SW sees 401 responses, refreshes the token transparently, retries + the original request with the new token +- SW manages the refresh cycle internally (no polling in the main + thread) +- Main thread never sees 401s except for genuinely unrecoverable auth + failures, in which case the SW posts `tokenRefreshFailed` and the + main thread shows the fallback image and starts the re-bind flow + +Five files become one module (plus a thin `sw-registration.js`). + +**Response header detection (Step 16).** The SW is also the natural +place to observe response headers. Step 16 wires it to watch +`X-OS2Display-Release-Timestamp` and `X-OS2Display-Config-Hash` headers +from existing API responses; on change, it posts to the main thread. +This replaces the polling loops removed in Step 17. + +**Crash-loop upgrade.** The SW's crash-guard module upgrades Step 11c's +localStorage-based guard to IndexedDB. The SW can observe reload +cycles directly (by counting `activate` events, or by tracking +`client.navigate()` calls) and participate in the cross-thread crash +loop detection — the main-thread handler records crashes, the SW +watchdog also records navigation reloads, and the guard sees the +combined picture. + +**Modular source, single output.** The SW is conceptually five small +responsibilities — auth state, fetch interception, watchdog, crash +guard, and message routing. Source code lives in six files under +`assets/client/sw/`, organized by responsibility. esbuild bundles them +into one IIFE at `public/client/sw.js` for the browser. Maintainability +and runtime performance both win. + +**RED — write test:** + +File: `assets/client/service/__tests__/sw-registration.test.js` (JS) + +1. `registerSW()` calls `navigator.serviceWorker.register` with + URL `/client/sw.js` and `updateViaCache: 'none'`. +2. `onTokenRefreshed` callback fires when SW posts `tokenRefreshed`. +3. `onTokenRefreshFailed` callback fires on failure message. +4. Heartbeat interval is started. + +```bash +docker compose run --rm node npx vitest run --reporter=verbose +``` + +File: `assets/client/sw/__tests__/lifecycle.test.js` + +1. Mock `self.skipWaiting` and `self.clients.claim`. Import sw/index.js + (which installs the listeners at module load). Dispatch an `install` + event. Assert `self.skipWaiting` was called. +2. Mock `loadAuth` to resolve after a short delay. Dispatch an `activate` + event. Assert: + - `loadAuth` was called + - `self.clients.claim` was called AFTER `loadAuth` resolved (not before) + - `event.waitUntil` received the combined promise +3. `loadAuth` rejects (IndexedDB corrupted or unavailable). Assert: + - `self.clients.claim` IS still called — the SW must take control + so the main thread receives a signal rather than silently running + without SW support + - A `tokenRefreshFailed` message is posted to controlled clients + so the main thread enters the re-bind flow via existing error + handling + - The `activate` event's promise resolves rather than rejecting + (a rejected waitUntil terminates the SW) + +```bash +docker compose run --rm node npx vitest run --reporter=verbose +``` + +File: `assets/tests/client/client-sw.spec.js` (Playwright) + +1. After login, API calls succeed (SW injects auth headers). +2. Simulated 401 → 200 retry is transparent. + +**GREEN — implement:** + +Build pipeline: + +1. Create modular SW source under `assets/client/sw/`: + ``` + assets/client/sw/ + index.js ← entry point, imports all modules + auth.js ← token state, IndexedDB persistence, refresh loop + fetch.js ← fetch interception, 401 retry, response header detection + watchdog.js ← heartbeat monitoring, client.navigate() recovery + crash-guard.js ← crash-loop tracking (upgrades Step 11c from localStorage to IndexedDB) + idb.js ← shared IndexedDB open/read/write helpers + messages.js ← message type constants shared with main thread + ``` + esbuild bundles all modules into one IIFE output — the SW runtime is + a single file, but the source is maintainable. + +2. Create `vite-plugin-sw.js` — Vite plugin that builds the SW via esbuild + in the `closeBundle` hook. Entry point: `assets/client/sw/index.js`. + Outputs to `public/client/sw.js`. + Scope defaults to `/client/` which covers the client page — all fetch + requests from controlled pages (including `/v2/` API calls) go through + the SW. Nginx serves as a static file. + In dev mode, serves a dev build via Vite's dev server middleware. +3. Update `vite.config.js` — import and register `serviceWorkerPlugin()`. +4. Add `public/client/sw.js` to `.gitignore` (build output). +5. Build to verify: + ```bash + docker compose run --rm node npm run build + ls -la public/client/sw.js # should exist, minified + ``` + +SW lifecycle handlers (required — determines how fast updates take +effect): + +6. `assets/client/sw/index.js` must register `install` and `activate` + handlers so updated SW code (including watchdog logic) takes effect + immediately without waiting for a navigation cycle: + + ```js + self.addEventListener("install", () => { + // Skip the "waiting" state — don't wait for the old SW to release + // its clients. Without this, an updated SW sits inactive until + // the kiosk navigates away, which for a 24/7 signage screen may + // never happen. + self.skipWaiting(); + }); + + self.addEventListener("activate", (event) => { + // Two things happen in the activate handler: + // + // 1. loadAuth() restores credentials from IndexedDB into the SW's + // in-memory state. This SHOULD complete before clients.claim() + // because claim() starts routing fetches through the SW, and + // the fetch interceptor needs auth state available. + // + // 2. clients.claim() takes control of all in-scope tabs/clients + // that loaded before this SW version existed. Without it, + // already-open clients continue using the old SW (or no SW + // at all for a fresh install) until they navigate. + // + // If loadAuth() rejects (IndexedDB corrupted), claim anyway and + // signal the main thread to re-bind. Silently not claiming would + // leave the kiosk running without SW support and no signal that + // something went wrong. + // + // waitUntil() keeps the activate event alive until the promise + // resolves, preventing the browser from terminating the SW mid- + // initialization. We catch loadAuth failures so the outer + // promise always resolves; a rejected waitUntil terminates the SW. + event.waitUntil( + loadAuth() + .catch((err) => { + // Signal clients to re-bind — they'll reach STATUS_LOGIN and + // restart the bind flow via existing error paths. + notifyAllClients({ type: messageTypes.TOKEN_REFRESH_FAILED }); + }) + .then(() => self.clients.claim()) + ); + }); + ``` + + The ordering matters: on a fresh install, `clients.claim()` fires the + `controllerchange` event on the main thread, which the main thread + uses as the signal that the SW is ready to receive `postMessage`. + If we claimed before loading auth, the main thread could send the + first `setVitals` / `setBootSession` message, have the SW try to + inject auth headers on its next fetch, and find no token because + `loadAuth()` hasn't completed yet. + +Client integration: + +6. Create `assets/client/service/sw-registration.js` — registers the SW + at `/client/sw.js` with `updateViaCache: 'none'` (bypasses HTTP cache for + update checks — browsers cap SW cache at 24h anyway, this makes it + immediate). Starts heartbeat. Handles messages from SW. +7. Wire into `assets/client/app.jsx` — call `registerSW()` after + `authBridge.load()`. +8. Remove `tokenService.startRefreshing()` / `stopRefreshing()`. +9. Remove `reauthenticateHandler` event listener. Add + `handleRefreshFailure` for the `onTokenRefreshFailed` callback. +10. Remove auth header injection from `assets/client/data-sync/api-helper.js`. +11. Remove `reauthenticate` event dispatch from `api-helper.js`. + +**Verify:** +```bash +docker compose run --rm node npm run build +docker compose run --rm node npx vitest run +docker compose run --rm playwright npx playwright test +``` + +**Changelog:** Added: Service worker handles token refresh, 401 retry, auth header injection, and main-thread watchdog. Built by Vite plugin, served as static file by nginx. + +--- + +### Step 16 — Integrate response header change detection + +**Branch:** `feat/sw-response-header-detection` + +**Depends on:** Step 12 (backend headers) and Step 15 (service worker). + +**Motivation:** Step 12 added the headers. Step 15 built the SW. This +step wires them together. + +The SW's `fetch` handler already intercepts every `/v2/` response to +inject auth. It's the perfect place to also inspect the response +headers for change markers. On every response: + +1. Read `X-OS2Display-Release-Timestamp`, `X-OS2Display-Release-Version`, + `X-OS2Display-Config-Hash`. +2. Compare with the last-seen values (stored in SW memory + IndexedDB). +3. If release timestamp changed → post `onReleaseChanged` to main thread. +4. If config hash changed → post `onConfigChanged` to main thread. + +The main thread reacts: + +- `onReleaseChanged` → `window.location.replace()` to pick up the new + build. Auth persists (IndexedDB survives navigation). No race + condition because the SW is authoritative — no separate redirect + path fights with auth handling. +- `onConfigChanged` → `ClientConfigLoader.invalidateCache()` so the + next config read fetches fresh values from `/config/client`. + +Header inspection is essentially free — the SW is already reading every +response. Comparing 3 strings adds microseconds per response. No new +network traffic, no new polling loops, no extra PHP overhead. + +Contrast with the current polling model: 2 separate intervals, 2 +separate endpoints, 2 separate race conditions, runs on every screen +regardless of whether anything changed. This step replaces both with +observation of existing traffic. + +**RED — write test:** + +File: `assets/tests/client/client-release-detection.spec.js` (Playwright) + +1. Mock API to return changed `X-OS2Display-Release-Timestamp` on second + response. Assert the client reloads. +2. Mock changed `X-OS2Display-Config-Hash`. Assert config is re-fetched. + +```bash +docker compose run --rm playwright npx playwright test +``` + +**GREEN — implement:** + +1. Add `checkResponseHeaders()` to `public/client/sw.js` fetch handler. +2. Add `onReleaseChanged` / `onConfigChanged` to `sw-registration.js`. +3. Wire `onReleaseChanged` in `app.jsx` to `window.location.replace()`. +4. Wire `onConfigChanged` in `app.jsx` to `ClientConfigLoader.invalidateCache()`. +5. Add `invalidateCache()` method to `assets/client/util/client-config-loader.js`. + +**Verify:** +```bash +docker compose run --rm node npx vitest run +docker compose run --rm playwright npx playwright test +``` + +**Changelog:** Added: Release and config changes detected via API response headers in the service worker. + +--- + +### Step 17 — Remove old polling loops + +**Branch:** `feat/remove-polling-loops` + +**Depends on:** Step 16. + +**Motivation:** With Step 16 in place, the old release and config +polling mechanisms are redundant — the SW now detects changes faster, +cheaper, and without race conditions. Time to remove them. + +The removals are surgical: + +- `releaseService.startReleaseCheck()` / `stopReleaseCheck()` — the + 10-minute release polling interval. Delete. +- `releaseService.checkForNewRelease()` — the function that runs on + boot, fetches `release.json`, and conditionally redirects. Delete. + This also eliminates the race condition documented in Step 12: the + redirect no longer fires during boot, so it can't fight with + `checkLogin()`'s 401 handling. +- `releaseService.setPreviousBootInUrl()` — the hack that set a query + parameter on the redirect target to prevent redirect loops. No + redirect → no loop → no query parameter. +- `ClientConfigLoader`'s internal cache timer — the 15-minute interval + that marked the config as stale. Cache invalidation now happens + externally via `invalidateCache()` called by the SW message handler. + +The boot sequence simplifies from: + +```js +// Before +appStorage.load() + → if (shouldRedirect) window.location.replace() + → .finally(() => { checkLogin(); startReleaseCheck(); ... }) +``` + +To: + +```js +// After +authBridge.load() + .then(() => { + registerSW({ onTokenRefreshed, onReleaseChanged, onConfigChanged }); + checkLogin(); + }); +``` + +Four fewer files involved, one fewer race condition, two fewer polling +loops, no redirect-on-boot timing problems. Every concern that used to +be split between a polling loop and an event handler is now unified +under the SW's fetch interception. + +**RED — write test:** + +File: `assets/client/service/__tests__/boot-sequence.test.js` + +1. Assert `releaseService.startReleaseCheck` is NOT called during boot. +2. Assert `checkForNewRelease()` redirect is NOT called during boot. +3. Assert boot goes: `authBridge.load()` → `registerSW()` → `checkLogin()` + with no redirect. + +```bash +docker compose run --rm node npx vitest run --reporter=verbose +``` + +**GREEN — implement:** + +1. Remove `releaseService.checkForNewRelease()` from `app.jsx` useEffect. +2. Remove `releaseService.startReleaseCheck()` / `stopReleaseCheck()` calls. +3. Remove `releaseService.setPreviousBootInUrl()`. +4. Simplify boot: + ```js + authBridge.load().then(() => { + registerSW({ ... }); + checkLogin(); + appStorage.setPreviousBoot(new Date().getTime()); + }); + ``` +5. Remove internal cache timer from `ClientConfigLoader`. + +**Verify:** +```bash +docker compose run --rm node npx vitest run +docker compose run --rm playwright npx playwright test +``` + +**Changelog:** Removed: Dedicated polling loops for release.json and config.json replaced by API response header detection via service worker. + +--- + +### Step 17a — Preserve TRACK_SCREEN_INFO via request headers + +**Branch:** `feat/track-screen-info-headers` + +**Depends on:** Steps 12, 15, 17. + +**Motivation:** `TRACK_SCREEN_INFO` is a backend feature that captures +per-screen diagnostics — release version, latest request timestamp, +client IP, user agent, token expiry state — on every API call a screen +makes. Admins query this data via `GET /v2/screens/{id}` to answer "which +build is each screen running?" and "when was this screen last seen?" +It's disabled by default (`TRACK_SCREEN_INFO=false`) but is actively +used in deployments where fleet monitoring matters. + +The release info specifically (`releaseVersion`, `releaseTimestamp`) is +captured via an indirect path: + +1. `releaseService.checkForNewRelease()` fetches `release.json` +2. On version change, it redirects to the same URL with + `?releaseTimestamp=X&releaseVersion=Y` appended +3. The browser remembers this URL. Every subsequent API call includes + it as the `Referer` header +4. `ScreenUserRequestSubscriber` reads the Referer, parses its query + string, extracts the two values, writes them to the ScreenUser entity + +Step 17 deletes `releaseService` entirely. The URL never gets those +params. The Referer stops carrying them. The subscriber still runs on +every `/v2/screens/{ulid}` request (gated by `trackScreenInfo && path +match`), still parses the Referer, but now always finds empty values. +ScreenUser's `releaseVersion` and `releaseTimestamp` fields get +overwritten with `null` on every request. TRACK_SCREEN_INFO silently +stops working — ops teams see "no data" in the admin UI and assume +screens are offline, when they're actually running normally. + +The data flow was always fragile: passing structured telemetry through +a URL query parameter that exists only because of a redirect side +effect, extracted via Referer parsing, is indirect at best. The fix +replaces it with explicit request headers: + +- Client knows its own build info (injected by Vite at build time) +- SW adds `X-OS2Display-Client-Release-Version` and + `X-OS2Display-Client-Release-Timestamp` to every `/v2/` request +- Subscriber reads from these headers, ignores Referer entirely + +Direct, unambiguous, robust to URL changes. + +**RED — write tests:** + +File: `tests/EventSubscriber/ScreenUserRequestSubscriberHeadersTest.php` + +1. Send a request to `/v2/screens/{ulid}` with + `X-OS2Display-Client-Release-Version: v3.0.0` and + `X-OS2Display-Client-Release-Timestamp: 1234567890` headers (no + Referer). Assert ScreenUser's fields are updated. +2. Send a request with ONLY the Referer-style params (legacy client + before this step deployed). Assert the fields are still updated — + backward compatibility during the upgrade window. +3. Send a request with BOTH headers and Referer params. Assert the + header values win (newer client). +4. Assert subscriber still gates on + `$trackScreenInfo && preg_match('/^\/v2\/screens\/.../...)` — + disabled deployments still skip entirely. + +File: `assets/client/sw/__tests__/request-headers.test.js` + +1. Call the SW's fetch handler with a request to `/v2/screens/abc`. +2. Assert the outgoing request has + `X-OS2Display-Client-Release-Version` and + `X-OS2Display-Client-Release-Timestamp` headers. +3. Assert the values match the build-time constants. + +```bash +docker compose run --rm phpfpm composer test -- --filter=ScreenUserRequestSubscriberHeadersTest +docker compose run --rm node npx vitest run +``` + +**GREEN — implement:** + +**Step A — Vite build-time injection:** + +In `vite.config.js`, inject release info as build-time constants: + +```diff + export default defineConfig(() => { + return { + base: "/build", ++ define: { ++ __RELEASE_TIMESTAMP__: JSON.stringify( ++ process.env.APP_RELEASE_TIMESTAMP || "0" ++ ), ++ __RELEASE_VERSION__: JSON.stringify( ++ process.env.APP_RELEASE_VERSION || "dev" ++ ), ++ }, + // ... existing config ... + }; + }); +``` + +These match the backend env vars introduced in Step 12 so client and +backend stay in sync when deployed together. + +**Step B — SW injects headers:** + +In `assets/client/sw/fetch.js`, add the two headers to every outgoing +`/v2/` request alongside the auth header: + +```js +// Build-time constants — replaced by Vite at bundle time. +const CLIENT_RELEASE_VERSION = __RELEASE_VERSION__; +const CLIENT_RELEASE_TIMESTAMP = __RELEASE_TIMESTAMP__; + +function injectHeaders(request, token) { + const headers = new Headers(request.headers); + if (token) { + headers.set("Authorization", `Bearer ${token}`); + } + headers.set("X-OS2Display-Client-Release-Version", CLIENT_RELEASE_VERSION); + headers.set("X-OS2Display-Client-Release-Timestamp", CLIENT_RELEASE_TIMESTAMP); + + return new Request(request, { headers }); +} +``` + +**Step C — Subscriber reads from headers, falls back to Referer:** + +In `src/EventSubscriber/ScreenUserRequestSubscriber.php` +`createCacheEntry`: + +```php +$request = $event->getRequest(); + +// Prefer explicit headers (post-Step-17a clients). +$releaseVersion = $request->headers->get('X-OS2Display-Client-Release-Version'); +$releaseTimestamp = $request->headers->get('X-OS2Display-Client-Release-Timestamp'); + +// Fall back to Referer parsing for legacy clients that haven't +// upgraded yet. Remove this block in a later release once all +// clients in the deployment are on the new protocol. +if (null === $releaseVersion || null === $releaseTimestamp) { + $referer = $request->headers->get('referer') ?? ''; + $url = parse_url($referer); + $queryString = $url['query'] ?? ''; + $queryArray = []; + + if (!empty($queryString)) { + parse_str($queryString, $queryArray); + } + + $releaseVersion ??= $queryArray['releaseVersion'] ?? null; + $releaseTimestamp ??= $queryArray['releaseTimestamp'] ?? null; +} + +$screenUser->setReleaseTimestamp((int) $releaseTimestamp); +$screenUser->setReleaseVersion($releaseVersion); +// ... rest unchanged ... +``` + +**Step D — CORS allowlist:** + +Update `config/packages/nelmio_cors.yaml` `allow_headers` to include the +two new request headers (alongside any existing auth headers): + +```yaml +allow_headers: + - 'Content-Type' + - 'Authorization' + - 'Authorization-Tenant-Key' + - 'X-OS2Display-Client-Release-Version' + - 'X-OS2Display-Client-Release-Timestamp' +``` + +Without this, the browser's preflight will reject requests carrying the +custom headers. + +**Verify:** +```bash +docker compose run --rm node npm run build +docker compose run --rm phpfpm composer test +docker compose run --rm phpfpm composer code-analysis +docker compose run --rm phpfpm composer coding-standards-check +docker compose run --rm node npx vitest run +docker compose run --rm playwright npx playwright test +``` + +**Changelog:** Changed: `TRACK_SCREEN_INFO` now captures release info via dedicated `X-OS2Display-Client-Release-*` request headers instead of parsing the Referer URL. Preserves telemetry functionality after the release-polling loop was removed. Referer-based fallback kept for one release cycle to support clients upgrading mid-deploy. + +--- + +### Step 17b — Real-time screen last-seen via Redis + +**Branch:** `feat/realtime-screen-last-seen` + +**Depends on:** Step 1, Step 17a. + +**Motivation:** `ScreenProvider` reads `latestRequest` from the +`ScreenUser` entity in the database. That field is updated by +`ScreenUserRequestSubscriber` — but only when the subscriber's Redis +cache entry misses, which is throttled to once per +`TRACK_SCREEN_INFO_UPDATE_INTERVAL_SECONDS` (default 300). Between +misses, no code path updates the timestamp anywhere: not the DB, not +the cache entry itself. `GET /v2/screens/{id}` therefore returns a +`latestRequestDateTime` that can be up to 5 minutes stale. + +For fleet monitoring this is a meaningful gap. A screen that crashed 3 +minutes ago still shows as "recently active." An admin investigating +"which screens are alive?" can't distinguish a working screen from one +that just crashed within the tracking interval. The DB throttling is +correct for heavy data (`clientMeta`, `releaseVersion`, IP, user +agent) — those rarely change and don't need per-request writes — but +`latestRequestDateTime` is specifically the field where freshness +matters most. + +The fix separates two concerns: + +- **Liveness heartbeat** — `latestRequestDateTime` updated on every + tracked request via a lightweight Redis SET. No DB write. Essentially + free (Redis handles millions of SETs/sec). +- **Durable metadata** — the existing 5-minute DB write pattern for + `clientMeta`, `releaseVersion`, `releaseTimestamp`. Unchanged. + +`ScreenProvider` reads the liveness key first; falls back to the +entity's `latestRequest` field if Redis is unavailable or the key +expired. Admins see request-level granularity (≈90s, matching the +pull-strategy poll interval) instead of 5-minute granularity. + +Redis write volume estimate: 200 screens × 40 polls/hour = 8000 +liveness writes/hour ≈ 2.2/sec. Negligible. + +**RED — write tests:** + +File: `tests/EventSubscriber/ScreenLastSeenCacheTest.php` + +1. Send a request to `/v2/screens/{ulid}` with `TRACK_SCREEN_INFO=true`. + Assert the Redis key `screen:last-seen:{ulid}` is set with a + timestamp close to `now()`. +2. Send two requests 30 seconds apart. Assert both update the Redis + key. Assert the DB's `latestRequest` field is unchanged between + them (still throttled by the existing 5-minute interval). +3. Send a request with `TRACK_SCREEN_INFO=false`. Assert no Redis key + is written. +4. Send a request to a non-screen endpoint (e.g., `/v2/playlists`). + Assert no Redis key is written (path check still gates). + +File: `tests/State/ScreenProviderLastSeenTest.php` + +1. Populate Redis with `screen:last-seen:{ulid}` set to `now()`. + Populate ScreenUser's `latestRequest` with a value 10 minutes old. + Call `ScreenProvider::provide()` via `GET /v2/screens/{ulid}`. + Assert response `status.latestRequestDateTime` matches Redis, not DB. +2. Delete the Redis key (simulate TTL expiry or Redis eviction). + Call provide again. Assert response falls back to the DB value. +3. `TRACK_SCREEN_INFO=false`: assert the `status` field is absent + from the response (existing behavior preserved). + +```bash +docker compose run --rm phpfpm composer test -- --filter=ScreenLastSeen +docker compose run --rm phpfpm composer test -- --filter=ScreenProviderLastSeen +``` + +**GREEN — implement:** + +**Step A — Update the subscriber to write the liveness key:** + +In `src/EventSubscriber/ScreenUserRequestSubscriber.php`: + +```php +public function onKernelRequest(RequestEvent $event): void +{ + $pathInfo = $event->getRequest()->getPathInfo(); + + if (!$this->trackScreenInfo + || !preg_match("/^\/v2\/screens\/[A-Za-z0-9]{26}$/i", $pathInfo)) { + return; + } + + $user = $this->security->getUser(); + if (!$user instanceof ScreenUser) { + return; + } + + $key = $user->getId()?->jsonSerialize(); + if (null === $key) { + return; + } + + // Liveness heartbeat: update on EVERY request. Cheap Redis SET. + $liveItem = $this->screenStatusCache->getItem($this->liveSeenKey($key)); + $liveItem->set((new \DateTime())->format('c')); + // TTL slightly longer than one full tracking interval so the key + // survives brief Redis maintenance windows and serves as a soft + // TTL rather than an authoritative cutoff. + $liveItem->expiresAfter($this->trackScreenInfoUpdateIntervalSeconds * 4); + $this->screenStatusCache->save($liveItem); + + // Durable snapshot: unchanged 5-minute-throttled DB write. + $this->screenStatusCache->get( + $key, + fn (CacheItemInterface $item) => $this->createCacheEntry($item, $event, $user) + ); +} + +private function liveSeenKey(string $id): string +{ + return "last-seen.$id"; +} +``` + +The existing `createCacheEntry` method keeps writing the DB on its +usual interval. No changes to that method's body (Step 1's +`detach($screenUser)` fix stays in place). + +**Step B — ScreenProvider reads Redis first:** + +In `src/State/ScreenProvider.php`, inject the cache and override the +timestamp: + +```php +public function __construct( + // ... existing deps ... + private readonly CacheInterface $screenStatusCache, + private readonly bool $trackScreenInfo = false, +) {} + +// In provide() where status is computed: +if ($this->trackScreenInfo) { + $screenUser = $object->getScreenUser(); + $status = null; + + if (null != $screenUser) { + $status = $this->getStatus($screenUser); + + // Override with real-time liveness if available. + $screenUserId = $screenUser->getId()?->jsonSerialize(); + if (null !== $screenUserId) { + $liveItem = $this->screenStatusCache->getItem("last-seen.$screenUserId"); + if ($liveItem->isHit()) { + $status['latestRequestDateTime'] = $liveItem->get(); + } + } + } + + $output->status = $status; +} +``` + +The `isHit()` check handles the fallback cleanly: Redis down → no hit → +DB value is used (current behavior preserved). Redis up → fresh +timestamp. + +**Step C — Update service config:** + +In `config/services.yaml`, inject the cache into ScreenProvider: + +```yaml +App\State\ScreenProvider: + arguments: + # ... existing args ... + $screenStatusCache: '@screen.status.cache' +``` + +**Verify:** +```bash +docker compose run --rm phpfpm composer test +docker compose run --rm phpfpm composer code-analysis +docker compose run --rm phpfpm composer coding-standards-check +``` + +**Changelog:** Changed: Screen `latestRequestDateTime` now updates in Redis on every tracked request, giving admins poll-interval freshness (~90s) instead of the previous 5-minute granularity. Durable metadata fields (clientMeta, releaseVersion) still throttled to the configured DB write interval. Falls back to DB value if Redis is unavailable. + +--- + +### Step 17c — Skip relations checksum recomputation on irrelevant flushes + +**Branch:** `fix/checksum-skip-irrelevant-flush` + +**Bug:** `RelationsChecksumListener` subscribes to Doctrine's `postFlush` +event and, when `RELATIONS_CHECKSUM_ENABLED=true`, unconditionally runs +`$this->calculator->execute(withWhereClause: true)` on every flush. + +The calculator executes ~30 SQL statements in a transaction: a set of +`UPDATE parent JOIN child ... WHERE p.changed = 1 OR temp.changed = 1` +propagation queries across 15 checksum-tracked tables, plus +`UPDATE table SET changed = 0 WHERE changed = 1` reset queries. + +The listener itself only marks entities as `changed = 1` when those +entities implement `RelationsChecksumInterface` (Screen, Playlist, Slide, +Media, etc.). `ScreenUser` does NOT implement that interface. When +`ScreenUserRequestSubscriber` flushes (every 5 minutes per tracked +screen), only ScreenUser's own fields change — nothing marks any +checksum-tracked entity as changed. + +But `postFlush` runs anyway. The calculator executes all ~30 UPDATE +statements. Every one matches zero rows because nothing has +`changed = 1`. The DB parses each query, plans it, scans the +`changed` index, commits a transaction — for zero data changes. + +At 200 screens × 12 flushes/hour × 30 queries = **72 000 no-op +queries/hour**. MariaDB handles them, but it's measurable load for zero +benefit. On a loaded instance, these pointless transactions compete +with real queries for the connection pool and binlog. + +The same pattern applies to any flush that doesn't touch a checksum +entity: user profile updates, refresh token saves, session writes +through Doctrine, any subscriber modifying non-tracked entities. Every +one incurs the full recomputation pass. + +The fix: track within the listener's lifecycle whether any +`RelationsChecksumInterface` entity was actually flagged `changed` +during the current unit of work. If nothing was flagged, `postFlush` +has nothing to propagate — skip the calculator entirely. + +**RED — write test:** + +File: `tests/EventListener/RelationsChecksumListenerSkipTest.php` + +1. Flush a ScreenUser update (no relations-tracked entity modified). + Use a DB query counter (Doctrine SQLLogger or middleware) to assert + the calculator's SQL was NOT executed — 0 UPDATE queries on the + checksum tables. +2. Flush a Screen update (entity implements `RelationsChecksumInterface`). + Assert the calculator DID run — UPDATE queries on the checksum tables + were issued. +3. Flush a mixed batch (ScreenUser + Screen in the same unit of work). + Assert the calculator ran (one changed entity is enough). +4. Flush with `RELATIONS_CHECKSUM_ENABLED=false` and a Screen update. + Assert the calculator did NOT run (existing behavior preserved). + +```bash +docker compose run --rm phpfpm composer test -- --filter=RelationsChecksumListenerSkipTest +``` + +**GREEN — fix:** + +In `src/EventListener/RelationsChecksumListener.php`, add a flag +that tracks whether any tracked entity was modified during the +current unit of work: + +```php +class RelationsChecksumListener +{ + private bool $shouldRecompute = false; + + public function __construct( + private readonly RelationsChecksumCalculator $calculator, + private readonly bool $enabled = false, + ) {} + + final public function prePersist(PrePersistEventArgs $args): void + { + if (!$this->enabled) return; + + $entity = $args->getObject(); + // ... existing switch statement setting initial checksums ... + + // If the switch matched a tracked entity type, a new row will + // be inserted → checksum propagation needed. + if ($entity instanceof RelationsChecksumInterface) { + $this->shouldRecompute = true; + } + } + + final public function preUpdate(PreUpdateEventArgs $args): void + { + if (!$this->enabled) return; + + $entity = $args->getObject(); + if ($entity instanceof RelationsChecksumInterface) { + $entity->setChanged(true); + $this->shouldRecompute = true; + } + } + + final public function preRemove(PreRemoveEventArgs $args): void + { + if (!$this->enabled) return; + + // ... existing switch statement marking parents as changed ... + // Every setChanged(true) path also sets $this->shouldRecompute. + + switch ($entity::class) { + case ScreenLayoutRegions::class: + $entity->getScreenLayout()?->setChanged(true); + $this->shouldRecompute = true; + break; + // ... other cases, same pattern ... + } + } + + final public function onFlush(OnFlushEventArgs $args): void + { + if (!$this->enabled) return; + + $em = $args->getObjectManager(); + $uow = $em->getUnitOfWork(); + + foreach ($uow->getScheduledCollectionUpdates() as $collection) { + $owner = $collection->getOwner(); + if ($owner instanceof RelationsChecksumInterface) { + $owner->setChanged(true); + $uow->recomputeSingleEntityChangeSet( + $em->getClassMetadata($owner::class), + $owner + ); + $this->shouldRecompute = true; + } + } + + foreach ($uow->getScheduledCollectionDeletions() as $collection) { + $owner = $collection->getOwner(); + if ($owner instanceof RelationsChecksumInterface) { + $owner->setChanged(true); + $uow->recomputeSingleEntityChangeSet( + $em->getClassMetadata($owner::class), + $owner + ); + $this->shouldRecompute = true; + } + } + } + + final public function postFlush(PostFlushEventArgs $args): void + { + if (!$this->enabled) return; + + // Skip the expensive recomputation pass if nothing relevant + // actually changed during this unit of work. + if (!$this->shouldRecompute) { + return; + } + + try { + $this->calculator->execute(withWhereClause: true); + } finally { + $this->shouldRecompute = false; + } + } +} +``` + +The flag is reset inside a `finally` so a calculator exception doesn't +leave the listener in a stuck `shouldRecompute=true` state that would +force the next (unrelated) flush to recompute too. + +**Verify:** +```bash +docker compose run --rm phpfpm composer test +docker compose run --rm phpfpm composer code-analysis +docker compose run --rm phpfpm composer coding-standards-check +``` + +**Changelog:** Fixed: `RelationsChecksumListener` no longer runs the ~30-query propagation pass on flushes that didn't modify any checksum-tracked entity. Previously fired on every Doctrine flush regardless of relevance, producing tens of thousands of no-op queries per hour under TRACK_SCREEN_INFO load. + +--- + +### Step 17d — Only write DB on heavy metadata change + +**Branch:** `feat/tracking-diff-aware-writes` + +**Depends on:** Step 1, Step 17a, Step 17b. + +**Motivation:** After Step 17b moved `latestRequestDateTime` to Redis, +the DB write in `ScreenUserRequestSubscriber::createCacheEntry` only +needs to persist the heavy metadata: `releaseVersion`, `releaseTimestamp`, +`clientMeta` (host, userAgent, ip, tokenExpired). + +Those values rarely change for a steady-state screen: + +- `releaseVersion` / `releaseTimestamp` — only on deploy +- `userAgent` — only on browser upgrade (essentially never on kiosk hardware) +- `host` — the client's URL; static +- `ip` — occasional (DHCP lease change, VLAN reassignment); rare +- `tokenExpired` — computed as `$exp < $now`; for authenticated requests + the token is always valid, so this is always false. The only way to + reach the subscriber with an expired token would bypass the JWT + authenticator, which isn't possible + +The current subscriber still does a DB flush on every 5-minute cache +miss, writing identical values for every field other than +`latestRequest` — which is no longer needed in the DB post-17b. + +Even with Doctrine's change tracking detecting no-op UPDATEs at the SQL +level, the flush path still: + +- Computes the UoW change set (scans all loaded entities) +- Fires postFlush events (Step 17c helps but pre-17c is significant) +- Acquires a connection from the pool +- Starts/commits a transaction even if empty + +The fix compares each incoming value against the entity's current +state and skips the flush entirely if nothing differs. For a stable +screen between deploys, the subscriber does ZERO DB writes per day. + +Additional discovery: `$screenStatusCache` is never read outside the +subscriber — grep confirms only one call site. The cache exists purely +as a throttle (cache-miss pattern rate-limits the callback), and the +callback's return value is discarded. This means we can freely change +the callback's internal logic without affecting any reader. + +**Write volume estimates** (200 screens, weekly deploy cycle): + +| Stage | DB writes / week | +|---|---| +| Before any fixes | ~1,680,000 (5-min × 200 screens × 168 hours × 12) | +| After Step 1 (clear→detach) | same write count, less entity damage | +| After Step 17c (skip checksum) | same write count, fewer post-flush queries | +| After Step 17d (diff-aware) | **~200–500** (only on deploys + rare IP/UA changes) | + +**RED — write tests:** + +File: `tests/EventSubscriber/ScreenUserDiffAwareWritesTest.php` + +1. Populate a ScreenUser with fixed releaseVersion/clientMeta values. + Make a tracked request with headers/Referer carrying the SAME values. + Use Doctrine SQLLogger to assert NO `UPDATE screen_user` query fired. +2. Make a request with a different releaseVersion. Assert UPDATE fires. + Assert new value persisted. +3. Make a request with a different IP (everything else same). Assert + UPDATE fires with new clientMeta. +4. Make a request with nothing changed, but wait for the cache TTL to + expire, make another identical request. Assert still no UPDATE + (throttle doesn't matter; comparison does). +5. First tracked request after a screen's creation (ScreenUser has + null clientMeta and null release fields). Assert UPDATE fires and + populates the fields. + +```bash +docker compose run --rm phpfpm composer test -- --filter=ScreenUserDiffAwareWritesTest +``` + +**GREEN — implement:** + +Replace the body of +`src/EventSubscriber/ScreenUserRequestSubscriber::createCacheEntry`: + +```php +private function createCacheEntry( + CacheItemInterface $item, + RequestEvent $event, + ScreenUser $screenUser +): array { + $item->expiresAfter($this->trackScreenInfoUpdateIntervalSeconds); + + $request = $event->getRequest(); + + // Extract new metadata — headers first (Step 17a), Referer fallback. + $releaseVersion = $request->headers->get('X-OS2Display-Client-Release-Version'); + $releaseTimestamp = $request->headers->get('X-OS2Display-Client-Release-Timestamp'); + + if (null === $releaseVersion || null === $releaseTimestamp) { + $referer = $request->headers->get('referer') ?? ''; + $parsed = parse_url($referer); + $queryString = $parsed['query'] ?? ''; + $queryArray = []; + if (!empty($queryString)) { + parse_str($queryString, $queryArray); + } + $releaseVersion ??= $queryArray['releaseVersion'] ?? null; + $releaseTimestamp ??= $queryArray['releaseTimestamp'] ?? null; + } + + $userAgent = $request->headers->get('user-agent') ?? ''; + $ip = $request->getClientIp(); + $host = preg_replace("/\?.*$/i", '', $request->headers->get('referer') ?? ''); + + $tokenExpired = false; + $token = $this->security->getToken(); + if (null !== $token) { + $decodedToken = $this->tokenManager->decode($token); + $expire = $decodedToken['exp'] ?? 0; + $tokenExpired = (new \DateTime())->setTimestamp($expire) < new \DateTime(); + } + + $newClientMeta = [ + 'host' => $host, + 'userAgent' => $userAgent, + 'ip' => $ip, + 'tokenExpired' => $tokenExpired, + ]; + + // Diff-aware write: only flush if anything actually differs from + // the current entity state. For a stable screen between deploys + // this means zero DB writes. + $hasChanges = + $screenUser->getReleaseVersion() !== $releaseVersion + || $screenUser->getReleaseTimestamp() !== (int) $releaseTimestamp + || $screenUser->getClientMeta() !== $newClientMeta; + + if ($hasChanges) { + $screenUser->setReleaseVersion($releaseVersion); + $screenUser->setReleaseTimestamp((int) $releaseTimestamp); + $screenUser->setClientMeta($newClientMeta); + $screenUser->setLatestRequest(new \DateTime()); + + $this->entityManager->flush(); + $this->entityManager->detach($screenUser); // Step 1's fix + } + + // Return value is unused (cache is write-only for throttling). + return ['throttled' => true]; +} +``` + +Note the change in semantics for `ScreenUser::latestRequest` in the DB: +it now means "time of last metadata change" rather than "time of last +request." That's correct: + +- Real-time "last seen" is served from Redis (Step 17b) +- The DB field becomes "when this screen last booted / deployed / moved" +- If Redis is down, the fallback is stale but still useful + ("this screen was last seen in its current state at X") + +Update the admin UI documentation to clarify the two timestamps' +meanings. + +**Verify:** +```bash +docker compose run --rm phpfpm composer test +docker compose run --rm phpfpm composer code-analysis +docker compose run --rm phpfpm composer coding-standards-check +``` + +**Changelog:** Changed: TRACK_SCREEN_INFO DB writes now only occur when heavy metadata (releaseVersion, clientMeta, IP, user-agent) actually differs from the stored value. Stable screens between deploys produce zero DB writes from tracking. Reduces write volume by ~99% for typical deployments. DB `latestRequest` field now reflects "last metadata change" rather than "last request" — real-time last-seen served from Redis (Step 17b). + +--- + +### Step 17e — Screen uptime tracking via boot session + +**Branch:** `feat/screen-uptime-tracking` + +**Depends on:** Step 17a, Step 17d. + +**Motivation:** Fleet operators repeatedly ask the same diagnostic +question: "is this screen stable, or has it been rebooting?" Two +kiosks that both pinged the API 30 seconds ago look identical from +`latestRequest` alone — but one has been running for 3 weeks and the +other has reloaded 40 times in the last hour. That distinction matters +for triage: the first is healthy, the second needs investigation. + +Uptime is the natural metric. Formally: how long has the current page +lifecycle been running? A reload (any cause — user, SW watchdog, +ErrorBoundary, release redirect, crash-guard, watchdog navigate) +resets it to zero. + +The client knows exactly when it booted (the moment `index.jsx` runs). +Sending that to the backend gives operators a uptime number they can +glance at. But a literal timestamp has a clock-skew problem: if the +kiosk's system clock is wrong (common — kiosk hardware often lacks +reliable NTP), the computed uptime would be wrong too. + +The fix is a **boot session ID** — a random UUID generated once per +page load, sent on every request. The backend doesn't care about the +client's wall-clock time; it cares whether the session ID matches the +previous one: + +- Session ID unchanged from last tracked request → same boot → no change +- Session ID differs → client has rebooted → record `bootTimestamp = now()` + using the server's authoritative clock + +Uptime on read is simply `now - bootTimestamp`, computed with the +server's clock end-to-end. Client clock drift is irrelevant. + +The check plugs directly into Step 17d's diff-aware logic: the boot +session ID comparison becomes one more field in the `$hasChanges` +evaluation. No separate write path, no additional DB query for +freshness checks. And like Step 17d, the DB write only fires when the +session ID actually changes — at most once per page lifecycle. + +**Schema:** + +Two nullable columns on `screen_user`: + +- `boot_session_id VARCHAR(36) NULL` — UUID from the most recent boot +- `boot_timestamp DATETIME NULL` — server time when that session was + first observed + +Nullable so existing rows (for screens that haven't made a request +since this migration ran) don't need backfilling. The next tracked +request from each screen populates them naturally. + +**RED — write tests:** + +File: `tests/EventSubscriber/ScreenUptimeTrackingTest.php` + +1. Send a tracked request with a new `X-OS2Display-Client-Boot-Session` + value. Assert `ScreenUser.bootSessionId` is set to the value and + `ScreenUser.bootTimestamp` is set to `now()` (within 1s tolerance). +2. Send a second request with the SAME boot session ID (and same + heavy metadata — nothing else to write). Assert NO DB UPDATE + (diff-aware path confirms no change). +3. Send a third request with a DIFFERENT boot session ID. Assert + `bootTimestamp` is updated to the new `now()` (reboot detected). +4. Request with no `X-OS2Display-Client-Boot-Session` header (legacy + client). Assert no change to bootSessionId / bootTimestamp — + the existing values remain untouched. + +File: `tests/State/ScreenProviderUptimeTest.php` + +1. Populate ScreenUser with `bootTimestamp` set to 2 hours ago. Call + `GET /v2/screens/{ulid}`. Assert the response's + `status.uptime.seconds` is ~7200 (within a reasonable tolerance). +2. ScreenUser with null `bootTimestamp` (never seen since migration). + Assert `status.uptime` is null or absent, not an error. +3. `TRACK_SCREEN_INFO=false`. Assert `status` is absent entirely + (existing behavior preserved). + +File: `assets/client/__tests__/boot-session.test.jsx` + +1. Import the boot session module twice; assert it returns the same + ID both times (initialized once, singleton). +2. After a simulated page reload (fresh module registry), assert a + different ID is returned. + +```bash +docker compose run --rm phpfpm composer test -- --filter=ScreenUptime +docker compose run --rm node npx vitest run +``` + +**GREEN — implement:** + +**Step A — Schema migration:** + +Create `migrations/VersionYYYYMMDDHHMMSS.php`: + +```php +public function up(Schema $schema): void +{ + $this->addSql( + 'ALTER TABLE screen_user ' + .'ADD boot_session_id VARCHAR(36) DEFAULT NULL, ' + .'ADD boot_timestamp DATETIME DEFAULT NULL ' + ."COMMENT '(DC2Type:datetime)'" + ); +} + +public function down(Schema $schema): void +{ + $this->addSql('ALTER TABLE screen_user DROP boot_session_id, DROP boot_timestamp'); +} +``` + +**Step B — Entity fields:** + +In `src/Entity/ScreenUser.php`: + +```php +#[ORM\Column(type: 'string', length: 36, nullable: true)] +private ?string $bootSessionId = null; + +#[ORM\Column(type: 'datetime', nullable: true)] +private ?\DateTime $bootTimestamp = null; + +public function getBootSessionId(): ?string { return $this->bootSessionId; } +public function setBootSessionId(?string $id): void { $this->bootSessionId = $id; } + +public function getBootTimestamp(): ?\DateTime { return $this->bootTimestamp; } +public function setBootTimestamp(?\DateTime $ts): void { $this->bootTimestamp = $ts; } +``` + +**Step C — Client generates session ID:** + +Create `assets/client/util/boot-session.js`: + +```js +/** + * A random identifier generated once per page lifecycle. + * Survives across component remounts, resets on every page reload. + * The backend uses this to detect when a screen has rebooted + * (session ID change) and to compute uptime. + */ +const BOOT_SESSION_ID = typeof crypto !== "undefined" && crypto.randomUUID + ? crypto.randomUUID() + : `${Date.now()}-${Math.random().toString(36).slice(2)}`; + +export default BOOT_SESSION_ID; +``` + +The `crypto.randomUUID()` check + fallback handles older browsers +without the WebCrypto API. The fallback isn't cryptographically +secure but doesn't need to be — collisions would require the same +kiosk hardware to boot twice within the same millisecond AND produce +the same random suffix. + +**Step D — SW attaches header:** + +In `assets/client/sw/index.js`, the main thread passes the session ID +at registration: + +```js +// assets/client/service/sw-registration.js +import bootSessionId from "../util/boot-session.js"; + +await navigator.serviceWorker.register("/client/sw.js", { + updateViaCache: "none", +}); + +// Post the boot session to the SW so it can attach it to requests. +navigator.serviceWorker.controller?.postMessage({ + type: "setBootSession", + bootSessionId, +}); +``` + +SW stores and attaches: + +```js +// assets/client/sw/fetch.js +let bootSessionId = null; + +self.addEventListener("message", (event) => { + if (event.data?.type === "setBootSession") { + bootSessionId = event.data.bootSessionId; + } +}); + +function injectHeaders(request, token) { + const headers = new Headers(request.headers); + if (token) headers.set("Authorization", `Bearer ${token}`); + headers.set("X-OS2Display-Client-Release-Version", CLIENT_RELEASE_VERSION); + headers.set("X-OS2Display-Client-Release-Timestamp", CLIENT_RELEASE_TIMESTAMP); + if (bootSessionId) { + headers.set("X-OS2Display-Client-Boot-Session", bootSessionId); + } + return new Request(request, { headers }); +} +``` + +**Step E — Subscriber tracks session change (extends Step 17d):** + +Extend `ScreenUserRequestSubscriber::createCacheEntry` from Step 17d: + +```php +$bootSessionId = $request->headers->get('X-OS2Display-Client-Boot-Session'); + +// ... extract other metadata as in Step 17d ... + +$hasChanges = + $screenUser->getReleaseVersion() !== $releaseVersion + || $screenUser->getReleaseTimestamp() !== (int) $releaseTimestamp + || $screenUser->getClientMeta() !== $newClientMeta + || ($bootSessionId !== null + && $screenUser->getBootSessionId() !== $bootSessionId); + +if ($hasChanges) { + $screenUser->setReleaseVersion($releaseVersion); + $screenUser->setReleaseTimestamp((int) $releaseTimestamp); + $screenUser->setClientMeta($newClientMeta); + $screenUser->setLatestRequest(new \DateTime()); + + // Boot session change → record new boot time using server clock. + if ($bootSessionId !== null + && $screenUser->getBootSessionId() !== $bootSessionId) { + $screenUser->setBootSessionId($bootSessionId); + $screenUser->setBootTimestamp(new \DateTime()); + } + + $this->entityManager->flush(); + $this->entityManager->detach($screenUser); +} +``` + +Note the `$bootSessionId !== null` guard — a legacy client (pre-17e) +without the header doesn't trigger a session change detection. This +preserves backward compatibility during the rollout window. + +**Step F — ScreenProvider exposes uptime:** + +In `src/State/ScreenProvider.php`, extend the status payload with +computed uptime: + +```php +if ($this->trackScreenInfo) { + $screenUser = $object->getScreenUser(); + $status = null; + + if (null != $screenUser) { + $status = $this->getStatus($screenUser); + + // Real-time last-seen from Redis (Step 17b). + $screenUserId = $screenUser->getId()?->jsonSerialize(); + if (null !== $screenUserId) { + $liveItem = $this->screenStatusCache->getItem("last-seen.$screenUserId"); + if ($liveItem->isHit()) { + $status['latestRequestDateTime'] = $liveItem->get(); + } + } + + // Uptime: time since last observed boot session change. + $bootTimestamp = $screenUser->getBootTimestamp(); + if (null !== $bootTimestamp) { + $uptimeSeconds = (new \DateTime())->getTimestamp() - $bootTimestamp->getTimestamp(); + $status['uptime'] = [ + 'bootTimestamp' => $bootTimestamp->format('c'), + 'seconds' => max(0, $uptimeSeconds), + ]; + } + } + + $output->status = $status; +} +``` + +**Step G — CORS allowlist:** + +Add to `config/packages/nelmio_cors.yaml` `allow_headers`: + +```yaml +allow_headers: + # ... existing headers ... + - 'X-OS2Display-Client-Boot-Session' +``` + +**Verify:** +```bash +docker compose run --rm phpfpm composer test +docker compose run --rm phpfpm composer code-analysis +docker compose run --rm phpfpm composer coding-standards-check +docker compose run --rm node npx vitest run +docker compose run --rm playwright npx playwright test +``` + +**Changelog:** Added: Screen uptime tracking. Client sends a per-boot session UUID in the `X-OS2Display-Client-Boot-Session` header; backend records server-side boot timestamp when the session ID changes. `GET /v2/screens/{id}.status` now includes `uptime.seconds` and `uptime.bootTimestamp`. Uses server clock end-to-end — no dependency on client clock accuracy. Integrates with Step 17d's diff-aware write path: a session ID change is a metadata change, so a reboot triggers the one DB write needed to capture it; subsequent requests in the same session produce no writes. + +--- + +### Step 17f — Client vitals telemetry + +**Branch:** `feat/client-vitals` + +**Depends on:** Step 15, Step 17a, Step 17b. + +**Motivation:** Even with Step 17e's uptime metric, the admin view +still can't distinguish "kiosk hardware is struggling" from "kiosk is +fine." A screen can have days of uptime and great last-seen freshness +while simultaneously dropping frames, leaking memory, and throttled on +2G network. Operators need to see those failure modes before they +become reboots. + +Five browser-maintained metrics cover the diagnostic surface for +kiosks, each answering a distinct question: + +| Metric | Source | Detects | +|---|---|---| +| `heap` | `performance.memory.usedJSHeapSize` | Memory leak (growth over time) | +| `longTasks` | `PerformanceObserver('longtask')` | Main-thread blocking from heavy templates | +| `fps` | `requestAnimationFrame` sampling | Render degradation | +| `storage` | `navigator.storage.estimate()` | Cache/IDB bloat | +| `net` | `navigator.connection` | Intermittent connectivity | + +All five are already maintained by the browser — reading them costs a +few microseconds. The rAF FPS sampler runs continuously but is just +one counter per frame. No COOP/COEP changes needed: we use +`performance.memory` (Chrome's non-standard API) rather than +`measureUserAgentSpecificMemory`. On browsers without +`performance.memory` (Firefox, Safari), the `heap` field is simply +absent from the payload — every vital is independently guarded. + +Vitals are volatile by nature — they change on every sample. If +routed through Step 17d's diff-aware write path, every request +would trigger a DB write and defeat the whole optimization. +The right architecture is to mirror Step 17b's Redis pattern: vitals +live in a per-screen Redis key, written on every receipt, read by +`ScreenProvider` when the admin API is called. Zero DB writes from +vitals ever. + +Sampling rhythm: compute once every 30s in the main thread (matches +the heartbeat cadence roughly), post to SW, SW attaches the latest +value to every outgoing `/v2/` request. Backend updates the Redis +key on every receipt. Admin API reads the latest from Redis on +demand. + +**Payload shape:** + +```json +{ + "sampledAt": "2026-04-17T10:23:45Z", + "heap": 45000000, + "longTasks": { "count": 3, "totalMs": 240 }, + "fps": 58, + "storage": 15_000_000, + "net": { "type": "4g", "rtt": 50 } +} +``` + +Size: ~150 bytes stringified. Fits comfortably in a single HTTP header. + +**RED — write tests:** + +File: `assets/client/util/__tests__/vitals.test.js` + +1. Mock `performance.memory` with `{ usedJSHeapSize: 50_000_000 }`. + Call `collectVitals()`. Assert `result.heap === 50_000_000`. +2. Browser without `performance.memory` (unset in mock). Call + `collectVitals()`. Assert `result.heap` is absent (not null, + not 0 — absent). +3. Simulate `longtask` `PerformanceObserver` entries. Call + `collectVitals()`. Assert `result.longTasks.count` and + `totalMs` match; call again, assert counters reset (each sample + reports since-last-sample, not cumulative). +4. Browser without `navigator.storage.estimate`. Assert `storage` + is absent. +5. Browser without `navigator.connection`. Assert `net` is absent. +6. All APIs missing. Assert `collectVitals()` returns + `{ sampledAt: ... }` — always includes the timestamp, other + fields absent. + +File: `tests/EventSubscriber/ScreenVitalsHeaderTest.php` + +1. Send request with `X-OS2Display-Client-Vitals: {json}` header. + Assert the Redis key `vitals.{ulid}` is set to the JSON string. +2. Send request without the header. Assert no Redis write for + vitals (existing key unchanged if present). +3. Send two requests in sequence with different vitals payloads. + Assert the Redis value reflects the most recent payload. + +File: `tests/State/ScreenProviderVitalsTest.php` + +1. Populate `vitals.{ulid}` in Redis with a known payload. Call + `GET /v2/screens/{ulid}`. Assert response `status.vitals` is + the parsed JSON. +2. No Redis key present. Assert `status.vitals` is absent from + the response (not null — absent). +3. `TRACK_SCREEN_INFO=false`. Assert `status` is absent entirely. + +```bash +docker compose run --rm node npx vitest run +docker compose run --rm phpfpm composer test -- --filter=ScreenVitals +``` + +**GREEN — implement:** + +**Step A — Vitals collector module:** + +Create `assets/client/util/vitals.js`: + +```js +// Module-scope long-task counters, drained on each sample. +let longTaskCount = 0; +let longTaskTotalMs = 0; + +if (typeof PerformanceObserver !== "undefined") { + try { + const observer = new PerformanceObserver((list) => { + for (const entry of list.getEntries()) { + longTaskCount++; + longTaskTotalMs += entry.duration; + } + }); + observer.observe({ entryTypes: ["longtask"] }); + } catch { + // longtask entry type not supported — skip silently. + } +} + +// rAF FPS sampler — maintains a 10-second rolling window of frame times. +let frameTimes = []; +function sampleFrame(ts) { + frameTimes.push(ts); + const cutoff = ts - 10_000; + frameTimes = frameTimes.filter((t) => t > cutoff); + requestAnimationFrame(sampleFrame); +} +if (typeof requestAnimationFrame !== "undefined") { + requestAnimationFrame(sampleFrame); +} + +function currentFps() { + if (frameTimes.length < 2) return null; + const span = frameTimes[frameTimes.length - 1] - frameTimes[0]; + if (span <= 0) return null; + return Math.round(((frameTimes.length - 1) / span) * 1000); +} + +export async function collectVitals() { + const vitals = { sampledAt: new Date().toISOString() }; + + // Heap (Chrome non-standard). + if (typeof performance !== "undefined" && performance.memory) { + vitals.heap = performance.memory.usedJSHeapSize; + } + + // Long tasks (drain counters so each sample is "since last sample"). + if (longTaskCount > 0) { + vitals.longTasks = { + count: longTaskCount, + totalMs: Math.round(longTaskTotalMs), + }; + longTaskCount = 0; + longTaskTotalMs = 0; + } + + // Effective FPS. + const fps = currentFps(); + if (fps !== null) vitals.fps = fps; + + // Storage usage. + if (navigator.storage?.estimate) { + try { + const est = await navigator.storage.estimate(); + if (est.usage !== undefined) vitals.storage = est.usage; + } catch { + // Ignore — storage quota API refused. + } + } + + // Network info. + if (navigator.connection) { + vitals.net = { + type: navigator.connection.effectiveType, + rtt: navigator.connection.rtt, + }; + } + + return vitals; +} +``` + +**Step B — Main thread posts vitals to SW periodically:** + +In `assets/client/app.jsx` (or the boot sequence near SW registration): + +```js +import { collectVitals } from "./util/vitals"; + +// After SW registration and after config load: +const VITALS_INTERVAL = 30_000; // Fixed — not tied to errorRecoveryTimeout. + +setInterval(async () => { + const vitals = await collectVitals(); + navigator.serviceWorker.controller?.postMessage({ + type: "setVitals", + vitals: JSON.stringify(vitals), + }); +}, VITALS_INTERVAL); +``` + +The `setInterval` is a module-scope timer. Cleared on page unload +automatically; no explicit cleanup needed for the kiosk's +never-navigate lifecycle. + +**Step C — SW stores and attaches the header:** + +In `assets/client/sw/fetch.js`: + +```js +let vitalsPayload = null; + +// In the SW's message handler: +self.addEventListener("message", (event) => { + if (event.data?.type === "setVitals") { + vitalsPayload = event.data.vitals; + } + // ... other message types ... +}); + +// Extend injectHeaders: +function injectHeaders(request, token) { + const headers = new Headers(request.headers); + if (token) headers.set("Authorization", `Bearer ${token}`); + headers.set("X-OS2Display-Client-Release-Version", CLIENT_RELEASE_VERSION); + headers.set("X-OS2Display-Client-Release-Timestamp", CLIENT_RELEASE_TIMESTAMP); + if (bootSessionId) { + headers.set("X-OS2Display-Client-Boot-Session", bootSessionId); + } + if (vitalsPayload) { + headers.set("X-OS2Display-Client-Vitals", vitalsPayload); + } + return new Request(request, { headers }); +} +``` + +**Step D — Subscriber writes to Redis:** + +Extend `ScreenUserRequestSubscriber::onKernelRequest`: + +```php +$vitalsJson = $request->headers->get('X-OS2Display-Client-Vitals'); +if (null !== $vitalsJson) { + $vitalsItem = $this->screenStatusCache->getItem("vitals.$key"); + $vitalsItem->set($vitalsJson); + $vitalsItem->expiresAfter($this->trackScreenInfoUpdateIntervalSeconds * 4); + $this->screenStatusCache->save($vitalsItem); +} +``` + +Note: this is outside the existing `->get($key, $callback)` cache +contract — vitals update on every request, not on a throttled +schedule. The main throttled path (DB write for heavy metadata) is +unaffected. No DB write happens from vitals ever. + +**Step E — ScreenProvider exposes vitals:** + +In `src/State/ScreenProvider.php`, alongside the uptime lookup: + +```php +$vitalsItem = $this->screenStatusCache->getItem("vitals.$screenUserId"); +if ($vitalsItem->isHit()) { + $status['vitals'] = json_decode($vitalsItem->get(), true); +} +``` + +**Step F — CORS allowlist:** + +Add to `config/packages/nelmio_cors.yaml` `allow_headers`: + +```yaml +allow_headers: + # ... existing headers ... + - 'X-OS2Display-Client-Vitals' +``` + +**Verify:** +```bash +docker compose run --rm node npm run build +docker compose run --rm phpfpm composer test +docker compose run --rm phpfpm composer code-analysis +docker compose run --rm phpfpm composer coding-standards-check +docker compose run --rm node npx vitest run +docker compose run --rm playwright npx playwright test +``` + +**Changelog:** Added: Client vitals telemetry. Main thread samples heap size, long-task count/duration, effective FPS, storage usage, and network info every 30s; SW attaches the latest sample to every `/v2/` request as `X-OS2Display-Client-Vitals` header. Backend stores in Redis (no DB writes). `GET /v2/screens/{id}.status.vitals` exposes the current sample. Browser APIs independently guarded — missing fields simply absent rather than causing errors. No COOP/COEP changes required. + +--- + +### Step 17g — Boot environment capture + +**Branch:** `feat/boot-environment` + +**Depends on:** Step 17a, Step 17d, Step 17e. + +**Motivation:** When a kiosk misbehaves, the first questions an +operator asks are rarely about runtime state — they're about the +environment the client is actually running in. "Is the browser +actually fullscreen?" (`viewport` vs `screen`). "Is this really a +4K display or a 1080p rendering to a 4K panel?" (`screen.width` + +`devicePixelRatio`). "Is the timezone set correctly?" +(`Intl.resolvedOptions().timeZone`). "Is this the weak hardware we +have on the backorder list or the new gen?" (`hardwareConcurrency`, +`deviceMemory`). + +None of this data changes during a session — it's determined at boot +and remains constant until reboot. That's different from Step 17f's +vitals (continuously fluctuating runtime state) and different from +Step 17b's last-seen (timestamp of last observed activity). Boot +environment is pure static profiling: "what kind of thing is this?" + +Step 17e already detects the moment of interest — when the boot +session ID changes, the screen just rebooted and a fresh environment +is worth capturing. The subscriber can read an `X-OS2Display-Client-Boot-Environment` +header on the same request that carries the new session ID, store it +alongside `bootTimestamp`, and expose it via `GET /v2/screens/{id}` +under `status.environment`. + +Since this data is static per boot, it belongs in the DB (unlike vitals +which belong in Redis). It joins `boot_session_id` and `boot_timestamp` +as boot-scoped fields — they always change together when the session +changes, and never change otherwise. + +**Captured fields:** + +| Field | Source | Example | Why | +|---|---|---|---| +| `screen.width` × `.height` | `window.screen.width/height` | `3840 × 2160` | Display physical resolution | +| `pixelRatio` | `window.devicePixelRatio` | `1` or `2` | DPI scaling — reveals upscaling | +| `viewport.width` × `.height` | `window.innerWidth/Height` | `3840 × 2160` | If ≠ screen, browser isn't fullscreen | +| `cores` | `navigator.hardwareConcurrency` | `4` | Hardware capacity | +| `memory` | `navigator.deviceMemory` | `2` (GB) | Rough RAM size | +| `timezone` | `Intl.DateTimeFormat().resolvedOptions().timeZone` | `Europe/Copenhagen` | Schedule correctness | +| `language` | `navigator.language` | `da-DK` | Locale verification | + +Total payload: ~200 bytes JSON. Sent as a request header on every +`/v2/` call (the SW adds it once at activation time, then attaches it +unchanged forever until the page reloads). Backend writes the DB only +when the boot session also changed — so at most one write per kiosk +boot captures the environment. + +**Schema:** + +One additional nullable column on `screen_user`: + +- `boot_environment JSON DEFAULT NULL` — parsed and returned as-is in + the admin API. Using JSON rather than discrete columns because the + shape might evolve over time without migration churn. + +**RED — write tests:** + +File: `assets/client/util/__tests__/boot-environment.test.js` + +1. Call `collectBootEnvironment()` in a jsdom environment with known + screen/viewport/navigator mocks. Assert all fields are present + with the mocked values. +2. `navigator.deviceMemory` absent (Safari). Assert `memory` field is + absent from the result, other fields present. +3. `navigator.hardwareConcurrency` absent. Assert `cores` is absent. +4. Call twice. Assert the same object is returned (memoized — not + re-computed on every call). + +File: `tests/EventSubscriber/ScreenBootEnvironmentTest.php` + +1. Send request with a new boot session ID AND an + `X-OS2Display-Client-Boot-Environment` header. Assert + `ScreenUser.bootEnvironment` is populated with the parsed JSON. +2. Send a subsequent request with the SAME boot session ID but a + DIFFERENT environment header (shouldn't happen in practice, but + defensive). Assert `bootEnvironment` is NOT overwritten — + environment only updates on session change. +3. Send a request with a new boot session ID but NO environment + header (legacy client). Assert `bootSessionId` updates but + `bootEnvironment` is left untouched. +4. Malformed JSON in the header. Assert the subscriber logs a + warning and skips the environment update; the session ID still + updates correctly. + +File: `tests/State/ScreenProviderBootEnvironmentTest.php` + +1. Populate `ScreenUser.bootEnvironment` with a known JSON structure. + Call `GET /v2/screens/{ulid}`. Assert response + `status.environment` is the parsed JSON object. +2. Null `bootEnvironment`. Assert `status.environment` is absent + from the response. + +```bash +docker compose run --rm node npx vitest run +docker compose run --rm phpfpm composer test -- --filter=ScreenBootEnvironment +``` + +**GREEN — implement:** + +**Step A — Schema migration:** + +```php +public function up(Schema $schema): void +{ + $this->addSql( + 'ALTER TABLE screen_user ' + ."ADD boot_environment JSON DEFAULT NULL COMMENT '(DC2Type:json)'" + ); +} + +public function down(Schema $schema): void +{ + $this->addSql('ALTER TABLE screen_user DROP boot_environment'); +} +``` + +**Step B — Entity field:** + +In `src/Entity/ScreenUser.php`: + +```php +#[ORM\Column(type: Types::JSON, nullable: true)] +private ?array $bootEnvironment = null; + +public function getBootEnvironment(): ?array { return $this->bootEnvironment; } +public function setBootEnvironment(?array $env): void { $this->bootEnvironment = $env; } +``` + +**Step C — Client collector (memoized):** + +Create `assets/client/util/boot-environment.js`: + +```js +/** + * Capture static properties of the environment the client is booting into. + * + * Computed once per module load (i.e., once per boot) and memoized. + * Every field is independently guarded so missing browser APIs produce + * an absent field rather than an error. + */ +let cached = null; + +export default function collectBootEnvironment() { + if (cached !== null) return cached; + + const env = {}; + + if (typeof window !== "undefined") { + if (window.screen) { + env.screen = { width: window.screen.width, height: window.screen.height }; + } + if (typeof window.devicePixelRatio === "number") { + env.pixelRatio = window.devicePixelRatio; + } + env.viewport = { width: window.innerWidth, height: window.innerHeight }; + } + + if (typeof navigator !== "undefined") { + if (typeof navigator.hardwareConcurrency === "number") { + env.cores = navigator.hardwareConcurrency; + } + if (typeof navigator.deviceMemory === "number") { + env.memory = navigator.deviceMemory; + } + if (navigator.language) { + env.language = navigator.language; + } + } + + try { + env.timezone = Intl.DateTimeFormat().resolvedOptions().timeZone; + } catch { + // Intl not available — skip. + } + + cached = env; + return env; +} +``` + +**Step D — Pass to SW at registration:** + +In `assets/client/service/sw-registration.js`: + +```js +import bootSessionId from "../util/boot-session.js"; +import collectBootEnvironment from "../util/boot-environment.js"; + +await navigator.serviceWorker.register("/client/sw.js", { + updateViaCache: "none", +}); + +navigator.serviceWorker.controller?.postMessage({ + type: "setBootSession", + bootSessionId, + bootEnvironment: JSON.stringify(collectBootEnvironment()), +}); +``` + +**Step E — SW attaches header:** + +In `assets/client/sw/fetch.js`, extend the state: + +```js +let bootSessionId = null; +let bootEnvironment = null; + +self.addEventListener("message", (event) => { + if (event.data?.type === "setBootSession") { + bootSessionId = event.data.bootSessionId; + bootEnvironment = event.data.bootEnvironment ?? null; + } + // ... other handlers ... +}); + +function injectHeaders(request, token) { + const headers = new Headers(request.headers); + if (token) headers.set("Authorization", `Bearer ${token}`); + headers.set("X-OS2Display-Client-Release-Version", CLIENT_RELEASE_VERSION); + headers.set("X-OS2Display-Client-Release-Timestamp", CLIENT_RELEASE_TIMESTAMP); + if (bootSessionId) { + headers.set("X-OS2Display-Client-Boot-Session", bootSessionId); + } + if (bootEnvironment) { + headers.set("X-OS2Display-Client-Boot-Environment", bootEnvironment); + } + if (vitalsPayload) { + headers.set("X-OS2Display-Client-Vitals", vitalsPayload); + } + return new Request(request, { headers }); +} +``` + +**Step F — Subscriber captures on session change:** + +Extend `ScreenUserRequestSubscriber::createCacheEntry` from Step 17e: + +```php +if ($bootSessionId !== null + && $screenUser->getBootSessionId() !== $bootSessionId) { + $screenUser->setBootSessionId($bootSessionId); + $screenUser->setBootTimestamp(new \DateTime()); + + // Capture the environment the new boot is running in. + $envJson = $request->headers->get('X-OS2Display-Client-Boot-Environment'); + if (null !== $envJson) { + try { + $env = json_decode($envJson, true, 5, JSON_THROW_ON_ERROR); + if (is_array($env)) { + $screenUser->setBootEnvironment($env); + } + } catch (\JsonException $e) { + // Malformed header — log and skip, don't fail the request. + // Session update still proceeds. + } + } +} +``` + +The environment update is nested inside the session-change branch — +environment never updates without a session change. A kiosk whose +screen was resized mid-session (rare but possible) won't trigger a +write; the next reboot will capture the new dimensions. + +**Step G — ScreenProvider exposes the field:** + +In `src/State/ScreenProvider.php`, alongside uptime and vitals: + +```php +$env = $screenUser->getBootEnvironment(); +if (null !== $env) { + $status['environment'] = $env; +} +``` + +**Step H — CORS allowlist:** + +Add to `config/packages/nelmio_cors.yaml` `allow_headers`: + +```yaml +allow_headers: + # ... existing headers ... + - 'X-OS2Display-Client-Boot-Environment' +``` + +**Verify:** +```bash +docker compose run --rm node npm run build +docker compose run --rm phpfpm composer test +docker compose run --rm phpfpm composer code-analysis +docker compose run --rm phpfpm composer coding-standards-check +docker compose run --rm node npx vitest run +docker compose run --rm playwright npx playwright test +``` + +**Changelog:** Added: Boot environment capture. Client collects static display/device info (screen resolution, DPI, viewport, CPU cores, RAM, timezone, language) on boot and sends it as `X-OS2Display-Client-Boot-Environment` header. Backend captures into a new nullable JSON column `screen_user.boot_environment` only when the boot session ID changes — typically one DB write per reboot. Surfaced via `GET /v2/screens/{id}.status.environment`. Helps operators distinguish hardware generations, verify fullscreen operation, and confirm timezone correctness. + +--- + +### Step 17h — Clock skew tracking + +**Branch:** `feat/clock-skew-tracking` + +**Depends on:** Step 17a, Step 17b. + +**Motivation:** Kiosk hardware is notorious for bad clocks. Cheap +boards skip the RTC battery; NTP configuration is inconsistent across +deployments; firmware bugs cause time zone tables to be out of date. +The symptom on OS2Display is silent: a screen with a 5-minute clock +skew still shows content, still refreshes, still authenticates — but +its 9:00 AM schedule runs at 8:55. Ops teams discover the skew by +chance, usually after a stakeholder complains that "the cafeteria menu +showed tomorrow's lunch during today's service." + +The fix isn't to synchronize the kiosk's clock (we can't; that's an OS-level +concern). The fix is to **surface** the skew so operators can see it and +investigate at the OS/NTP layer. + +Measurement is trivial: client sends its current time in a header on +every `/v2/` request; backend computes `skew = clientTime - serverTime` +on receipt. The server's clock is the authority — we care about whether +the kiosk matches the server, not absolute correctness. One-way network +latency adds noise (request-travel time means the client's timestamp +is always slightly in the past by the time the server reads it) but +that's sub-second on any functioning network. For kiosk skew (measured +in tens of seconds to minutes), the noise is irrelevant. + +Redis-backed like vitals — skew changes continuously as clocks drift +independently, and admins only need the most recent value. No DB writes. + +The admin API exposes raw seconds; the admin UI decides how to render +severity (green under 10s, yellow under 60s, red beyond). Keeping the +threshold on the UI side avoids another env var and lets deployments +customize rendering without backend changes. + +**RED — write tests:** + +File: `tests/EventSubscriber/ScreenClockSkewTest.php` + +1. Send a request with `X-OS2Display-Client-Time` set to `now + 45s`. + Mock server time accordingly. Assert the Redis key + `clock-skew.{ulid}` is set with seconds value `45`. +2. Send a request with client time set to `now - 120s`. Assert + stored value is `-120`. +3. Send a request without the header. Assert no Redis key write + (existing value preserved if any). +4. Send a request with a malformed time string. Assert the + subscriber logs a warning and skips the skew update — doesn't + throw, doesn't break the request flow. +5. Send multiple requests in sequence with varying skew values. + Assert the Redis value reflects the most recent measurement. + +File: `tests/State/ScreenProviderClockSkewTest.php` + +1. Populate `clock-skew.{ulid}` with `{ "seconds": 45, "sampledAt": "..." }`. + Call `GET /v2/screens/{ulid}`. Assert response + `status.clockSkew` matches. +2. No Redis key present. Assert `status.clockSkew` is absent + from the response (not null — absent). +3. `TRACK_SCREEN_INFO=false`. Assert `status` is absent entirely. + +```bash +docker compose run --rm phpfpm composer test -- --filter=ScreenClockSkew +``` + +**GREEN — implement:** + +**Step A — SW attaches the client time header:** + +In `assets/client/sw/fetch.js`, extend `injectHeaders`: + +```js +function injectHeaders(request, token) { + const headers = new Headers(request.headers); + if (token) headers.set("Authorization", `Bearer ${token}`); + headers.set("X-OS2Display-Client-Release-Version", CLIENT_RELEASE_VERSION); + headers.set("X-OS2Display-Client-Release-Timestamp", CLIENT_RELEASE_TIMESTAMP); + headers.set("X-OS2Display-Client-Time", new Date().toISOString()); + if (bootSessionId) { + headers.set("X-OS2Display-Client-Boot-Session", bootSessionId); + } + if (bootEnvironment) { + headers.set("X-OS2Display-Client-Boot-Environment", bootEnvironment); + } + if (vitalsPayload) { + headers.set("X-OS2Display-Client-Vitals", vitalsPayload); + } + return new Request(request, { headers }); +} +``` + +Generated at header-injection time, not request-construction time, +so the timestamp reflects when the request actually goes out over +the wire rather than when the main thread initiated the fetch. + +**Step B — Subscriber computes and stores skew:** + +Extend `ScreenUserRequestSubscriber::onKernelRequest` (alongside the +vitals Redis write from Step 17f): + +```php +$clientTimeHeader = $request->headers->get('X-OS2Display-Client-Time'); +if (null !== $clientTimeHeader) { + try { + $clientTime = new \DateTimeImmutable($clientTimeHeader); + $serverTime = new \DateTimeImmutable(); + $skewSeconds = $clientTime->getTimestamp() - $serverTime->getTimestamp(); + + $skewItem = $this->screenStatusCache->getItem("clock-skew.$key"); + $skewItem->set(json_encode([ + 'seconds' => $skewSeconds, + 'sampledAt' => $serverTime->format('c'), + ])); + $skewItem->expiresAfter($this->trackScreenInfoUpdateIntervalSeconds * 4); + $this->screenStatusCache->save($skewItem); + } catch (\Exception $e) { + // Malformed client time — log at debug, skip the measurement. + // Don't fail the request. + } +} +``` + +The server time is captured at message-receipt (before any PHP work), +giving a consistent reference point. Sub-second precision isn't +meaningful — kiosk skew is measured in seconds, not milliseconds. + +**Step C — ScreenProvider surfaces the measurement:** + +In `src/State/ScreenProvider.php`, alongside the vitals lookup: + +```php +$skewItem = $this->screenStatusCache->getItem("clock-skew.$screenUserId"); +if ($skewItem->isHit()) { + $status['clockSkew'] = json_decode($skewItem->get(), true); +} +``` + +The payload structure is fixed from Step B — `{ seconds, sampledAt }` +— so no shape transformation needed here. + +**Step D — CORS allowlist:** + +Add to `config/packages/nelmio_cors.yaml` `allow_headers`: + +```yaml +allow_headers: + # ... existing headers ... + - 'X-OS2Display-Client-Time' +``` + +**Verify:** +```bash +docker compose run --rm node npm run build +docker compose run --rm phpfpm composer test +docker compose run --rm phpfpm composer code-analysis +docker compose run --rm phpfpm composer coding-standards-check +docker compose run --rm node npx vitest run +``` + +**Changelog:** Added: Client/server clock skew tracking. SW attaches current client time as `X-OS2Display-Client-Time` to every `/v2/` request; backend computes `skew = client - server` and stores in Redis. `GET /v2/screens/{id}.status.clockSkew` exposes `{ seconds, sampledAt }`. Surfaces cheap-hardware RTC drift and missing NTP setup — the class of silent bugs that cause schedules to fire at the wrong wall time. Admin UI renders severity (raw seconds exposed; threshold decisions made on the UI side). + +--- + +### Step 18 — Update error code taxonomy + +**Branch:** `feat/error-code-taxonomy` + +**Depends on:** Steps 15, 17. + +**Motivation:** The client writes `?status=...&error=...` to the URL via +`statusService.setStatusInUrl()` so operations staff can read the +current state directly from a kiosk's browser URL bar. This is an +invaluable debugging aid — no developer tools, no backend logs, just +look at the screen. The contract is: + +``` +https://kiosk.example.com/client/?status=running&error=ER105 +``` + +The plan's earlier steps invalidate parts of the current taxonomy and +introduce new failure modes that aren't represented. This step reconciles +the codes with the new architecture AND fixes structural bugs in how +errors are cleared. + +**Sticky-error bugs in the current architecture:** + +Error clearing today is scattered across individual call sites. Each +site that can successfully complete an operation has to remember to +clear the specific errors its failure would have set. This pattern has +failed in three ways: + +1. **ER101 has no clear path outside rebind.** ER101 + (`ERROR_TOKEN_REFRESH_FAILED`) is set in `app.jsx` + `reauthenticateHandler` and in `tokenService`. The only place that + clears it is `tokenService.checkLogin()`, which only runs during the + bind flow. A screen that hits ER101 once, then recovers via a + successful refresh, carries `?error=ER101` in the URL until rebound. + Step 6a fixed a separate typo — this is a different bug: the clear + path simply doesn't exist for routine recovery. + +2. **URL params persist across reloads.** `history.replaceState` writes + `?error=XXX` to the URL, which survives `window.location.reload()`. + After a reload that successfully boots the app, the URL still carries + the pre-reload error. An ER202 watchdog-triggered reload that + successfully recovers shows `?error=ER202` forever. Ops teams waste + cycles investigating errors that resolved themselves. + +3. **No central clearing on STATUS_RUNNING.** When the app reaches + `STATUS_RUNNING`, by definition every prerequisite (auth, config, + content fetch) succeeded. Any previous error is, by construction, + resolved. But `setStatus(STATUS_RUNNING)` doesn't touch the error + field. Errors that set up a path `init → login → running` can + linger visible on a working screen. + +**Clearing strategy:** + +- On boot, read the old `?error=` from the URL into a memory-only + `previousError` field (for logs/diagnostics), then clear the URL. + New boot starts with a clean URL — any error that re-fires will + overwrite. +- `statusService.setStatus()` clears the error on the `STATUS_RUNNING` + transition. Reaching running is proof of resolution. +- Add `statusService.clearError(code)` as the unambiguous clear API. + Replaces the scattered `setError(null)` calls. Clear is no-op if the + current error doesn't match `code` — prevents races where a newer + error is accidentally cleared by a delayed success handler from an + older operation. +- ER301 (crash-loop) is the one error that must NOT clear on boot — + it's meaningful precisely because the screen is cycling. It clears + explicitly when `crashGuard.reset()` fires on successful boot (the + `STATUS_RUNNING` transition handler calls `reset()`, which in turn + clears ER301). + +**Dead codes after earlier steps:** + +- **ER102** (`ERROR_TOKEN_REFRESH_LOOP_FAILED`) — set inside + `tokenService.startRefreshing()`. That function is deleted in Step 15. + The SW now owns the refresh cycle and any failure collapses to ER101. +- **ER104** (`ERROR_RELEASE_FILE_NOT_LOADED`) — set inside + `releaseService.checkForNewRelease()`. The entire `release-service.js` + is deleted in Step 17. Release detection is now header-based with no + failure mode worth surfacing (a missing header on an API response is + equivalent to "no change" — benign). + +**Relocated codes:** + +- **ER101** (`ERROR_TOKEN_REFRESH_FAILED`) — currently set by + `reauthenticateHandler` in `app.jsx` and by `tokenService`. Both + callers are deleted in Step 15. The SW now detects refresh failure + and posts `tokenRefreshFailed` to the main thread; the message + handler sets ER101. +- **ER103** (`ERROR_TOKEN_EXP_IAT_NOT_SET`) — JWT payload validation + still occurs (now in the SW's `auth.js` module). Set via the same + message-passing path. +- **ER105, ER106** — same pattern. + +**New codes for new failure modes:** + +| Code | Constant | When | +|---|---|---| +| ER201 | `ERROR_SW_REGISTRATION_FAILED` | `registerSW()` rejected — browser blocked or SW file missing. Main-thread auth falls back to pre-Step-15 behavior temporarily. | +| ER202 | `ERROR_WATCHDOG_RELOAD` | SW's watchdog navigated the page because heartbeats stopped. Set by the SW via message on activation-after-reload. | +| ER301 | `ERROR_CRASH_LOOP_DETECTED` | Crash-guard is in backoff — recent crashes exceeded threshold, reloads suppressed. Lets ops see "this screen has been cycling" without reading logs. | +| ER302 | `ERROR_UNCAUGHT_EXCEPTION` | `window.onerror` or `unhandledrejection` fired. Set briefly before the subsequent reload. | + +**RED — write test:** + +File: `assets/client/service/__tests__/status-service-clearing.test.js` + +These tests target the structural clearing bugs: + +1. Set URL to `?error=ER101` before constructing StatusService. Construct + the service. Assert `statusService.error === null` (URL is cleared) + and `statusService.previousError === "ER101"` (captured for diagnostics). +2. Set URL to `?error=ER301` (crash-loop) before constructing. Construct. + Assert the URL is cleared (new boot shouldn't inherit stale state) — + but crashGuard.recordCrash() will re-set ER301 if still in backoff. +3. Set `statusService.error = "ER101"`, then call + `setStatus(STATUS_RUNNING)`. Assert error is cleared. +4. Set `statusService.error = "ER301"`, then call + `setStatus(STATUS_RUNNING)`. Assert error is NOT cleared (ER301 is + persistent). +5. Set `statusService.error = "ER105"`, call `clearError("ER101")`. + Assert error is still "ER105" (code mismatch, no-op). +6. Set `statusService.error = "ER105"`, call `clearError("ER105")`. + Assert error is null. +7. Set `statusService.error = "ER105"`, call `clearError()` with no arg. + Assert error is null (force clear). + +File: `assets/client/util/__tests__/constants.test.js` + +1. Assert `constants.ERROR_TOKEN_REFRESH_LOOP_FAILED` is undefined + (removed). +2. Assert `constants.ERROR_RELEASE_FILE_NOT_LOADED` is undefined + (removed). +3. Assert `constants.ERROR_SW_REGISTRATION_FAILED` equals `"ER201"`. +4. Assert `constants.ERROR_WATCHDOG_RELOAD` equals `"ER202"`. +5. Assert `constants.ERROR_CRASH_LOOP_DETECTED` equals `"ER301"`. +6. Assert `constants.ERROR_UNCAUGHT_EXCEPTION` equals `"ER302"`. +7. Assert ER101, ER103, ER105, ER106 are unchanged. + +File: `assets/client/service/__tests__/sw-registration-error.test.js` + +1. Mock `navigator.serviceWorker.register` to reject. +2. Call `registerSW({ onError })`. +3. Assert the caller receives `ERROR_SW_REGISTRATION_FAILED`. +4. Assert `statusService.error === "ER201"` after the callback fires. + +File: `assets/client/util/__tests__/crash-guard-error.test.js` + +1. Call `recordCrash()` enough times to enter backoff. +2. Assert `statusService.error === "ER301"` was set. +3. Call `reset()`. Assert error is cleared from the URL. + +File: `assets/client/util/__tests__/global-error-handlers-error.test.js` + +1. Dispatch a `window.onerror` event. +2. Assert `statusService.error === "ER302"` was set before the + scheduled reload fires. + +```bash +docker compose run --rm node npx vitest run +``` + +**GREEN — implement:** + +Refactor `assets/client/service/status-service.js` to own the clearing +strategy: + +```js +import constants from '../util/constants'; + +class StatusService { + status = constants.STATUS_INIT; + error = null; + + /** + * The error that was in the URL when this boot started. + * Read-only after boot, for logs and diagnostics. + * Never written back to the URL — keeps the URL reflective of the + * current boot's state, not a previous life's. + */ + previousError = null; + + /** + * Errors that should survive a STATUS_RUNNING transition. + * ER301 means the screen is actively in a crash loop — clearing it + * just because we briefly reached running would defeat the whole + * purpose of the guard. + */ + static PERSISTENT_ERRORS = new Set([ + constants.ERROR_CRASH_LOOP_DETECTED, + ]); + + constructor() { + // On boot: capture the previous URL error for diagnostics, then + // clear it from the URL so the new boot starts clean. + const url = new URL(window.location.href); + const inheritedError = url.searchParams.get("error"); + if (inheritedError) { + this.previousError = inheritedError; + url.searchParams.delete("error"); + url.searchParams.delete("status"); + window.history.replaceState(null, "", url); + } + } + + setStatus = (newStatus) => { + this.status = newStatus; + + // Reaching STATUS_RUNNING is proof that all prerequisites resolved. + // Clear any transient error automatically. + if (newStatus === constants.STATUS_RUNNING && this.error) { + if (!StatusService.PERSISTENT_ERRORS.has(this.error)) { + this.error = null; + } + } + + this.setStatusInUrl(); + }; + + setError = (newError) => { + this.error = newError; + this.setStatusInUrl(); + }; + + /** + * Clear error only if it matches the given code. Prevents a + * delayed success handler from clearing a newer, unrelated error. + * Pass no argument to force-clear regardless. + */ + clearError = (code = null) => { + if (code === null || this.error === code) { + this.error = null; + this.setStatusInUrl(); + } + }; + + setStatusInUrl = () => { + const url = new URL(window.location.href); + + if (this.status) { + url.searchParams.set("status", this.status); + } else { + url.searchParams.delete("status"); + } + + if (this.error) { + url.searchParams.set("error", this.error); + } else { + url.searchParams.delete("error"); + } + + window.history.replaceState(null, "", url); + }; +} + +const statusService = new StatusService(); +export default statusService; +``` + +Update every call site currently using `setError(null)` to use +`clearError(code)` with the specific code they're clearing. This +prevents the race where a slow success handler from a previous +operation clears a newer error that was set in the meantime. + +Update `assets/client/util/constants.js`: + +```js +const constants = { + // ... status values unchanged ... + + // Token / auth errors — still relevant, now set by SW message handlers. + ERROR_TOKEN_REFRESH_FAILED: "ER101", + ERROR_TOKEN_EXP_IAT_NOT_SET: "ER103", + ERROR_TOKEN_EXPIRED: "ER105", + ERROR_TOKEN_VALID_SHOULD_HAVE_BEEN_REFRESHED: "ER106", + + // NOTE: ER102 ERROR_TOKEN_REFRESH_LOOP_FAILED removed — refresh loop + // replaced by SW in Step 15. Failures now surface as ER101. + // NOTE: ER104 ERROR_RELEASE_FILE_NOT_LOADED removed — release polling + // replaced by header-based detection in Step 17. + + // Service worker lifecycle errors. + ERROR_SW_REGISTRATION_FAILED: "ER201", + ERROR_WATCHDOG_RELOAD: "ER202", + + // Crash handling errors. + ERROR_CRASH_LOOP_DETECTED: "ER301", + ERROR_UNCAUGHT_EXCEPTION: "ER302", + + NO_TOKEN: "NO_TOKEN", + NO_EXPIRE: "NO_EXPIRE", + NO_ISSUED_AT: "NO_ISSUED_AT", +}; +``` + +Wire ER201 in `assets/client/service/sw-registration.js`: + +```js +import statusService from './status-service'; +import constants from '../util/constants'; + +export async function registerSW(callbacks) { + try { + const reg = await navigator.serviceWorker.register( + "/client/sw.js", + { updateViaCache: "none" } + ); + // ... existing setup ... + } catch (err) { + logger.error("SW registration failed:", err); + statusService.setError(constants.ERROR_SW_REGISTRATION_FAILED); + callbacks?.onError?.(err); + } +} +``` + +Wire ER301 in `assets/client/util/crash-guard.js`: + +```js +import statusService from '../service/status-service'; +import constants from './constants'; + +const crashGuard = { + recordCrash() { + // ... existing logic ... + if (state.count >= maxCrashes) { + state.backoffUntil = now + backoffDuration; + statusService.setError(constants.ERROR_CRASH_LOOP_DETECTED); + } + saveState(state); + }, + + reset() { + try { localStorage.removeItem(STORAGE_KEY); } catch {} + if (statusService.error === constants.ERROR_CRASH_LOOP_DETECTED) { + statusService.setError(null); + } + }, + // ... +}; +``` + +Wire ER302 in `assets/client/util/global-error-handlers.js`: + +```js +function handleFatalError() { + statusService.setError(constants.ERROR_UNCAUGHT_EXCEPTION); + crashGuard.recordCrash(); + + if (crashGuard.shouldReload()) { + setTimeout(() => window.location.reload(), reloadDelay); + } +} +``` + +Wire ER202 in the SW → main thread message handler (in +`assets/client/service/sw-registration.js`): + +```js +navigator.serviceWorker.addEventListener('message', (event) => { + switch (event.data?.type) { + case 'watchdogReloadPending': + // SW is about to call client.navigate() due to missed heartbeats. + statusService.setError(constants.ERROR_WATCHDOG_RELOAD); + break; + case 'tokenRefreshFailed': + statusService.setError(constants.ERROR_TOKEN_REFRESH_FAILED); + callbacks.onTokenRefreshFailed?.(); + break; + // ... other message types + } +}); +``` + +Note that ER202 is set on the main thread in the *current* boot — just +before the SW navigates. After the reload, the new main thread reads +the previous value from the URL (if still present) and can keep the +error in the URL bar for ops visibility. Alternatively, the SW can +stash the error in IndexedDB before navigating and the new main thread +reads it on boot. + +Delete all usages of the removed codes: + +```bash +# Verify removed codes are no longer referenced: +docker compose run --rm node sh -c "grep -r 'ERROR_TOKEN_REFRESH_LOOP_FAILED\|ERROR_RELEASE_FILE_NOT_LOADED' assets/ || echo 'Clean'" +``` + +Update `docs/client/error-codes.md` (create if it doesn't exist): + +```markdown +# Client Error Codes + +Errors appear in the URL as `?error=ERxxx`. The status parameter +indicates the app's current phase (`init`, `login`, `running`). + +## Token / auth (ER1xx) +- ER101 Token refresh failed (SW couldn't refresh, reauth required) +- ER103 Token payload missing iat/exp +- ER105 Token expired +- ER106 Token should have been refreshed by now (diagnostic) + +## Service worker (ER2xx) +- ER201 SW registration failed (browser blocked or SW file unreachable) +- ER202 Watchdog triggered a reload (main thread was unresponsive) + +## Crash handling (ER3xx) +- ER301 Crash loop detected (reloads suppressed during backoff) +- ER302 Uncaught exception (reload scheduled) +``` + +**Verify:** +```bash +docker compose run --rm node npx vitest run +docker compose run --rm playwright npx playwright test +``` + +**Changelog:** Changed: Error code taxonomy updated for the SW-based architecture. Removed ER102 and ER104 (dead code after refresh loop and release service removal). Added ER201 (SW registration failed), ER202 (watchdog reload), ER301 (crash loop detected), ER302 (uncaught exception). Fixed: URL error codes now clear on boot and on STATUS_RUNNING transition; persistent errors (ER301) retain their semantics. New `clearError(code)` API prevents races between delayed success handlers and newer errors. + +--- + +## PR Dependency Graph + +``` +Step 0 (vitest) + │ + ├──── Steps 1–5b (backend fixes, independent of each other) + │ │ + │ └──── Step 12 (response headers) + │ │ + │ └──── Step 13 (redis sessions) + │ + ├──── Steps 6–10 (frontend fixes, independent of each other) + │ 6a depends on existence of token-service.js and must land + │ before Step 15 deletes that file. + │ + ├──── Step 11 (top-level ErrorBoundary) + │ │ + │ └──── Step 11a (config: error recovery timeouts) + │ │ + │ ├──── Step 11b (region ErrorBoundary — reads config) + │ │ + │ └──── Step 11c (global handlers + crash guard — reads config) + │ + ├──── Step 14 (auth-bridge) + │ │ + │ └──── Step 15 (service worker — reads config for watchdog/heartbeat) + │ │ + │ └──── Step 16 (response header detection) + │ │ + │ └──── Step 17 (remove polling) + │ │ + │ └──── Step 17a (TRACK_SCREEN_INFO via headers) + │ │ + │ ├──── Step 17b (real-time last-seen via Redis) + │ │ + │ ├──── Step 17c (skip checksum on irrelevant flush) + │ │ + │ ├──── Step 17d (diff-aware DB writes) + │ │ │ + │ │ ├──── Step 17e (uptime via boot session) + │ │ │ │ + │ │ │ └──── Step 17g (boot environment) + │ │ + │ ├──── Step 17f (vitals telemetry via Redis) + │ │ + │ ├──── Step 17h (clock skew tracking via Redis) + │ │ + │ └──── Step 18 (error code taxonomy) +``` + +Steps 1–5b and 6–10 can proceed in parallel. Step 11a must land before +11b, 11c, and 15 since they all read timeout values from the config. +Step 15's SW crash-guard module upgrades 11c's localStorage-based guard +to IndexedDB. Step 17a bridges TRACK_SCREEN_INFO from Referer-parsing +to request headers — required because Step 17 removes the URL params +the Referer approach relied on. Step 17c is technically independent +of 17/17a/17b (it fixes a general backend performance issue, not a +TRACK_SCREEN_INFO-specific one) but grouped nearby because the issue +surfaces most visibly under TRACK_SCREEN_INFO load. Step 18 is the +final cleanup — after 17 removes dead code, 18 removes the dead error +codes and adds codes for new failure modes. + +--- + +## Claude Code /loop instructions + +Each step is designed to be executed as a single `/loop` session. +Copy the template below, replace `N` with the step number: + +``` +/loop "Implement Step N from PLAN.md against release/3.0.0. + 1. Create branch from release/3.0.0 + 2. Write the failing test(s) described in the RED section + 3. Run tests inside docker: + - PHP: docker compose run --rm phpfpm composer test -- --filter= + - JS: docker compose run --rm node npx vitest run + Confirm the new test FAILS + 4. Implement the fix described in the GREEN section + 5. Build assets if changed: + - docker compose run --rm node npm run build + 6. Run tests — confirm all tests PASS: + - PHP: docker compose run --rm phpfpm composer test + - JS: docker compose run --rm node npx vitest run + 7. Run quality checks: + - PHP: docker compose run --rm phpfpm composer coding-standards-check + - PHP: docker compose run --rm phpfpm composer code-analysis + If coding standards fail: docker compose run --rm phpfpm composer coding-standards-apply + 8. Run E2E if applicable: + - docker compose run --rm playwright npx playwright test + 9. Update CHANGELOG.md with the entry from the step + 10. Commit with message matching the changelog entry + 11. Push and create PR against release/3.0.0" +``` + +Each `/loop` session should take 5–15 minutes depending on scope. diff --git a/Taskfile.yml b/Taskfile.yml index 2b7ff4b09..0dbc4dc90 100644 --- a/Taskfile.yml +++ b/Taskfile.yml @@ -229,6 +229,11 @@ tasks: cmds: - task compose -- exec phpfpm cp -r fixtures/public/fixtures public/ + test:unit: + desc: "Runs client unit tests (Vitest)." + cmds: + - task compose -- run --rm node npm run test:unit {{.CLI_ARGS}} + assets:build: desc: "Build the assets." cmds: diff --git a/assets/tests/admin/admin-content-string.spec.js b/assets/tests/admin/admin-content-string.test.js similarity index 65% rename from assets/tests/admin/admin-content-string.spec.js rename to assets/tests/admin/admin-content-string.test.js index 15c122db2..8430a2f25 100644 --- a/assets/tests/admin/admin-content-string.spec.js +++ b/assets/tests/admin/admin-content-string.test.js @@ -1,14 +1,14 @@ -import { test, expect } from "@playwright/test"; +import { test, expect } from "vitest"; import contentString from "../../admin/components/util/helpers/content-string.jsx"; test.describe("Content string", () => { - test("It creates a string: 'test and test'", async ({ page }) => { + test("It creates a string: 'test and test'", () => { expect(contentString([{ name: "test" }, { name: "test" }], "and")).toBe( "test and test", ); }); - test("It creates a string: 'test, hest or test'", async ({ page }) => { + test("It creates a string: 'test, hest or test'", () => { expect( contentString( [{ name: "test" }, { label: "hest" }, { name: "test" }], @@ -17,7 +17,7 @@ test.describe("Content string", () => { ).toBe("test, hest or test"); }); - test("It creates a string: 'test'", async ({ page }) => { + test("It creates a string: 'test'", () => { expect(contentString([{ name: "test" }], "or")).toBe("test"); }); }); diff --git a/assets/tests/admin/admin-slide-media-sync.spec.js b/assets/tests/admin/admin-slide-media-sync.test.js similarity index 98% rename from assets/tests/admin/admin-slide-media-sync.spec.js rename to assets/tests/admin/admin-slide-media-sync.test.js index 6f8164888..5fff3df78 100644 --- a/assets/tests/admin/admin-slide-media-sync.spec.js +++ b/assets/tests/admin/admin-slide-media-sync.test.js @@ -1,4 +1,4 @@ -import { test, expect } from "@playwright/test"; +import { test, expect } from "vitest"; import rebuildMediaFromContent from "../../admin/components/slide/slide-media-utils"; test.describe("Slide media sync", () => { diff --git a/assets/tests/admin/campaigns-button-promise-chain.spec.js b/assets/tests/admin/campaigns-button-promise-chain.test.js similarity index 98% rename from assets/tests/admin/campaigns-button-promise-chain.spec.js rename to assets/tests/admin/campaigns-button-promise-chain.test.js index 1ec17d343..e6240c3dd 100644 --- a/assets/tests/admin/campaigns-button-promise-chain.spec.js +++ b/assets/tests/admin/campaigns-button-promise-chain.test.js @@ -1,4 +1,4 @@ -import { test, expect } from "@playwright/test"; +import { test, expect } from "vitest"; /** * Regression tests for the CampaignsButton.onClick promise chain. diff --git a/assets/tests/admin/get-all-pages.spec.js b/assets/tests/admin/get-all-pages.test.js similarity index 98% rename from assets/tests/admin/get-all-pages.spec.js rename to assets/tests/admin/get-all-pages.test.js index 25b715887..311b0e5cc 100644 --- a/assets/tests/admin/get-all-pages.spec.js +++ b/assets/tests/admin/get-all-pages.test.js @@ -1,4 +1,4 @@ -import { test, expect } from "@playwright/test"; +import { test, expect } from "vitest"; import getAllPages from "../../admin/components/util/helpers/get-all-pages.js"; function createHydraResponse(members, hasNext = false) { diff --git a/assets/tests/client/id-from-path.test.js b/assets/tests/client/id-from-path.test.js new file mode 100644 index 000000000..8f8b84490 --- /dev/null +++ b/assets/tests/client/id-from-path.test.js @@ -0,0 +1,36 @@ +import { describe, it, expect } from "vitest"; +import idFromPath from "../../client/util/id-from-path"; + +describe("idFromPath", () => { + it("extracts a ULID from a path", () => { + expect(idFromPath("/v2/screens/01ARZ3NDEKTSV4RRFFQ69G5FAV")).toBe( + "01ARZ3NDEKTSV4RRFFQ69G5FAV" + ); + }); + + it("returns the first 26-char match when multiple exist", () => { + expect( + idFromPath("/v2/screens/01ARZ3NDEKTSV4RRFFQ69G5FAV/playlists/01BRZ3NDEKTSV4RRFFQ69G5FAV") + ).toBe("01ARZ3NDEKTSV4RRFFQ69G5FAV"); + }); + + it("returns false for a string with no 26-char alphanumeric match", () => { + expect(idFromPath("/v2/screens/short")).toBe(false); + }); + + it("returns false for an empty string", () => { + expect(idFromPath("")).toBe(false); + }); + + it("returns false for null", () => { + expect(idFromPath(null)).toBe(false); + }); + + it("returns false for undefined", () => { + expect(idFromPath(undefined)).toBe(false); + }); + + it("returns false for a number", () => { + expect(idFromPath(123)).toBe(false); + }); +}); diff --git a/assets/tests/setup.js b/assets/tests/setup.js new file mode 100644 index 000000000..f149f27ae --- /dev/null +++ b/assets/tests/setup.js @@ -0,0 +1 @@ +import "@testing-library/jest-dom/vitest"; diff --git a/assets/tests/shared/release-loader.spec.js b/assets/tests/shared/release-loader.test.js similarity index 98% rename from assets/tests/shared/release-loader.spec.js rename to assets/tests/shared/release-loader.test.js index 98adb7475..442131502 100644 --- a/assets/tests/shared/release-loader.spec.js +++ b/assets/tests/shared/release-loader.test.js @@ -1,4 +1,4 @@ -import { test, expect } from "@playwright/test"; +import { test, expect } from "vitest"; import { ReleaseLoader } from "../../shared/release-loader.js"; const RELEASE_DATA = { diff --git a/package-lock.json b/package-lock.json index 1d5c4ee9f..58e741494 100644 --- a/package-lock.json +++ b/package-lock.json @@ -58,16 +58,27 @@ "devDependencies": { "@playwright/test": "1.57.0", "@rtk-query/codegen-openapi": "^2.0.0", + "@testing-library/jest-dom": "^6.9.1", + "@testing-library/react": "^16.3.2", "@vitejs/plugin-react": "^4.6.0", "esbuild": "^0.25.8", "esbuild-runner": "^2.2.2", + "jsdom": "^29.0.2", "sass": "^1.88.9", "typescript": "^4.4.2", "vite": "npm:rolldown-vite@^7.0.3", "vite-plugin-svgr": "^4.3.0", - "vite-plugin-symfony": "^8.2.0" + "vite-plugin-symfony": "^8.2.0", + "vitest": "^4.1.4" } }, + "node_modules/@adobe/css-tools": { + "version": "4.4.4", + "resolved": "https://registry.npmjs.org/@adobe/css-tools/-/css-tools-4.4.4.tgz", + "integrity": "sha512-Elp+iwUx5rN5+Y8xLt5/GRoG20WGoDCQ/1Fb+1LiGtvwbDavuSk0jhD/eZdckHAuzcDzccnkv+rEjyWfRx18gg==", + "dev": true, + "license": "MIT" + }, "node_modules/@amcharts/amcharts4": { "version": "4.10.40", "resolved": "https://registry.npmjs.org/@amcharts/amcharts4/-/amcharts4-4.10.40.tgz", @@ -157,6 +168,57 @@ "openapi-types": ">=7" } }, + "node_modules/@asamuzakjp/css-color": { + "version": "5.1.11", + "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-5.1.11.tgz", + "integrity": "sha512-KVw6qIiCTUQhByfTd78h2yD1/00waTmm9uy/R7Ck/ctUyAPj+AEDLkQIdJW0T8+qGgj3j5bpNKK7Q3G+LedJWg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/generational-cache": "^1.0.1", + "@csstools/css-calc": "^3.2.0", + "@csstools/css-color-parser": "^4.1.0", + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/@asamuzakjp/dom-selector": { + "version": "7.0.10", + "resolved": "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-7.0.10.tgz", + "integrity": "sha512-KyOb19eytNSELkmdqzZZUXWCU25byIlOld5qVFg0RYdS0T3tt7jeDByxk9hIAC73frclD8GKrHttr0SUjKCCdQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/generational-cache": "^1.0.1", + "@asamuzakjp/nwsapi": "^2.3.9", + "bidi-js": "^1.0.3", + "css-tree": "^3.2.1", + "is-potential-custom-element-name": "^1.0.1" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/@asamuzakjp/generational-cache": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@asamuzakjp/generational-cache/-/generational-cache-1.0.1.tgz", + "integrity": "sha512-wajfB8KqzMCN2KGNFdLkReeHncd0AslUSrvHVvvYWuU8ghncRJoA50kT3zP9MVL0+9g4/67H+cdvBskj9THPzg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/@asamuzakjp/nwsapi": { + "version": "2.3.9", + "resolved": "https://registry.npmjs.org/@asamuzakjp/nwsapi/-/nwsapi-2.3.9.tgz", + "integrity": "sha512-n8GuYSrI9bF7FFZ/SjhwevlHc8xaVlb/7HmHelnc/PZXBD2ZR49NnN9sMMuDdEGPeeRQ5d0hqlSlEpgCX3Wl0Q==", + "dev": true, + "license": "MIT" + }, "node_modules/@babel/code-frame": { "version": "7.27.1", "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.27.1.tgz", @@ -185,7 +247,6 @@ "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.28.0.tgz", "integrity": "sha512-UlLAnTPrFdNGoFtbSXwcGFQBtQZJCNjaN6hQNP3UPvuNXT1i82N26KL3dZeIpNalWywr9IuQuncaAfUaS1g6sQ==", "license": "MIT", - "peer": true, "dependencies": { "@ampproject/remapping": "^2.2.0", "@babel/code-frame": "^7.27.1", @@ -459,6 +520,159 @@ "node": ">=6.9.0" } }, + "node_modules/@bramus/specificity": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/@bramus/specificity/-/specificity-2.4.2.tgz", + "integrity": "sha512-ctxtJ/eA+t+6q2++vj5j7FYX3nRu311q1wfYH3xjlLOsczhlhxAg2FWNUXhpGvAw3BWo1xBcvOV6/YLc2r5FJw==", + "dev": true, + "license": "MIT", + "dependencies": { + "css-tree": "^3.0.0" + }, + "bin": { + "specificity": "bin/cli.js" + } + }, + "node_modules/@csstools/color-helpers": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-6.0.2.tgz", + "integrity": "sha512-LMGQLS9EuADloEFkcTBR3BwV/CGHV7zyDxVRtVDTwdI2Ca4it0CCVTT9wCkxSgokjE5Ho41hEPgb8OEUwoXr6Q==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "engines": { + "node": ">=20.19.0" + } + }, + "node_modules/@csstools/css-calc": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-3.2.0.tgz", + "integrity": "sha512-bR9e6o2BDB12jzN/gIbjHa5wLJ4UjD1CB9pM7ehlc0ddk6EBz+yYS1EV2MF55/HUxrHcB/hehAyt5vhsA3hx7w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-color-parser": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-4.1.0.tgz", + "integrity": "sha512-U0KhLYmy2GVj6q4T3WaAe6NPuFYCPQoE3b0dRGxejWDgcPp8TP7S5rVdM5ZrFaqu4N67X8YaPBw14dQSYx3IyQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "dependencies": { + "@csstools/color-helpers": "^6.0.2", + "@csstools/css-calc": "^3.2.0" + }, + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-parser-algorithms": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-4.0.0.tgz", + "integrity": "sha512-+B87qS7fIG3L5h3qwJ/IFbjoVoOe/bpOdh9hAjXbvx0o8ImEmUsGXN0inFOnk2ChCFgqkkGFQ+TpM5rbhkKe4w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-syntax-patches-for-csstree": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.1.3.tgz", + "integrity": "sha512-SH60bMfrRCJF3morcdk57WklujF4Jr/EsQUzqkarfHXEFcAR1gg7fS/chAE922Sehgzc1/+Tz5H3Ypa1HiEKrg==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "peerDependencies": { + "css-tree": "^3.2.1" + }, + "peerDependenciesMeta": { + "css-tree": { + "optional": true + } + } + }, + "node_modules/@csstools/css-tokenizer": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-4.0.0.tgz", + "integrity": "sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + } + }, "node_modules/@emnapi/core": { "version": "1.4.5", "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.4.5.tgz", @@ -1041,6 +1255,24 @@ "node": ">=18" } }, + "node_modules/@exodus/bytes": { + "version": "1.15.0", + "resolved": "https://registry.npmjs.org/@exodus/bytes/-/bytes-1.15.0.tgz", + "integrity": "sha512-UY0nlA+feH81UGSHv92sLEPLCeZFjXOuHhrIo0HQydScuQc8s0A7kL/UdgwgDq8g8ilksmuoF35YVTNphV2aBQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + }, + "peerDependencies": { + "@noble/hashes": "^1.8.0 || ^2.0.0" + }, + "peerDependenciesMeta": { + "@noble/hashes": { + "optional": true + } + } + }, "node_modules/@exodus/schemasafe": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/@exodus/schemasafe/-/schemasafe-1.3.0.tgz", @@ -1062,7 +1294,6 @@ "resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.7.3.tgz", "integrity": "sha512-uZA413QEpNuhtb3/iIKoYMSK07keHPYeXF02Zhd6e213j+d1NamLix/mCLxBUDW/Gx52sPH2m+chlUsyaBs/Ag==", "license": "MIT", - "peer": true, "dependencies": { "@floating-ui/core": "^1.7.3", "@floating-ui/utils": "^0.2.10" @@ -1229,7 +1460,6 @@ "integrity": "sha512-YUcsLQKYb6DmaJjIHdDWpBIGCcyE/W+p/LMGvjQem55Mm2XWVAP5kWTMKWLv9lwpCVjpLxPyOMOyUocP1GxrtA==", "hasInstallScript": true, "license": "MIT", - "peer": true, "dependencies": { "@fortawesome/fontawesome-common-types": "^0.2.36" }, @@ -1300,9 +1530,9 @@ } }, "node_modules/@jridgewell/sourcemap-codec": { - "version": "1.5.4", - "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.4.tgz", - "integrity": "sha512-VT2+G1VQs/9oz078bLrYbecdZKs912zQlkelYpuf+SXF+QvZDYJlbx/LSx+meSAwdDFnF8FVXW92AVjjkVmgFw==", + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", "license": "MIT" }, "node_modules/@jridgewell/trace-mapping": { @@ -1722,7 +1952,6 @@ "resolved": "https://registry.npmjs.org/@popperjs/core/-/core-2.11.8.tgz", "integrity": "sha512-P1st0aksCrn9sGZhp8GMYwBnQsbvAWsZAX44oXNNvLHGqAOcoVxmjZiohstwQ7SqKnbR47akdNi+uleWD8+g6A==", "license": "MIT", - "peer": true, "funding": { "type": "opencollective", "url": "https://opencollective.com/popperjs" @@ -2084,9 +2313,9 @@ } }, "node_modules/@standard-schema/spec": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.0.0.tgz", - "integrity": "sha512-m2bOd0f2RT9k8QJx1JN85cZYyH1RqFBdlwtkSlf4tBDYLCiiZnv1fIIwacK6cqwXavOydf0NPToMQgpKq+dVlA==", + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", "license": "MIT" }, "node_modules/@standard-schema/utils": { @@ -2360,12 +2589,87 @@ "@svgr/core": "*" } }, + "node_modules/@testing-library/dom": { + "version": "10.4.1", + "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz", + "integrity": "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/code-frame": "^7.10.4", + "@babel/runtime": "^7.12.5", + "@types/aria-query": "^5.0.1", + "aria-query": "5.3.0", + "dom-accessibility-api": "^0.5.9", + "lz-string": "^1.5.0", + "picocolors": "1.1.1", + "pretty-format": "^27.0.2" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@testing-library/jest-dom": { + "version": "6.9.1", + "resolved": "https://registry.npmjs.org/@testing-library/jest-dom/-/jest-dom-6.9.1.tgz", + "integrity": "sha512-zIcONa+hVtVSSep9UT3jZ5rizo2BsxgyDYU7WFD5eICBE7no3881HGeb/QkGfsJs6JTkY1aQhT7rIPC7e+0nnA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@adobe/css-tools": "^4.4.0", + "aria-query": "^5.0.0", + "css.escape": "^1.5.1", + "dom-accessibility-api": "^0.6.3", + "picocolors": "^1.1.1", + "redent": "^3.0.0" + }, + "engines": { + "node": ">=14", + "npm": ">=6", + "yarn": ">=1" + } + }, + "node_modules/@testing-library/jest-dom/node_modules/dom-accessibility-api": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.6.3.tgz", + "integrity": "sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@testing-library/react": { + "version": "16.3.2", + "resolved": "https://registry.npmjs.org/@testing-library/react/-/react-16.3.2.tgz", + "integrity": "sha512-XU5/SytQM+ykqMnAnvB2umaJNIOsLF3PVv//1Ew4CTcpz0/BRyy/af40qqrt7SjKpDdT1saBMc42CUok5gaw+g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.12.5" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@testing-library/dom": "^10.0.0", + "@types/react": "^18.0.0 || ^19.0.0", + "@types/react-dom": "^18.0.0 || ^19.0.0", + "react": "^18.0.0 || ^19.0.0", + "react-dom": "^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, "node_modules/@tiptap/core": { "version": "3.14.0", "resolved": "https://registry.npmjs.org/@tiptap/core/-/core-3.14.0.tgz", "integrity": "sha512-nm0VWVA1Vq/jaKY3wyRXViL/kf78yMdH7qETpv4qZXDQLU+pdWV3IGoRTQTKESc7d8L1wL/2uCeByLNUJfrSIw==", "license": "MIT", - "peer": true, "funding": { "type": "github", "url": "https://github.com/sponsors/ueberdosis" @@ -2588,7 +2892,6 @@ "resolved": "https://registry.npmjs.org/@tiptap/extension-list/-/extension-list-3.14.0.tgz", "integrity": "sha512-rsjFH0Vd/4UbDsjwMLay7oz72VVu1r35t8ofAzy5587jn5JAjflaZs05XbRRMD2imUTK41dyajVSh8CqSnDEJw==", "license": "MIT", - "peer": true, "funding": { "type": "github", "url": "https://github.com/sponsors/ueberdosis" @@ -2694,7 +2997,6 @@ "resolved": "https://registry.npmjs.org/@tiptap/extensions/-/extensions-3.14.0.tgz", "integrity": "sha512-qQBVKqzU4ZVjRn8W0UbdfE4LaaIgcIWHOMrNnJ+PutrRzQ6ZzhmD/kRONvRWBfG9z3DU7pSKGwVYSR2hztsGuQ==", "license": "MIT", - "peer": true, "funding": { "type": "github", "url": "https://github.com/sponsors/ueberdosis" @@ -2709,7 +3011,6 @@ "resolved": "https://registry.npmjs.org/@tiptap/pm/-/pm-3.14.0.tgz", "integrity": "sha512-xrZmqI5jl4yMeAsu8p8gVP9S3An5h2MBi8BQHNnZmpyzkUrlpd40vlT6u13SWIqVi5ZWhBZ6U3rL7mkVLZuRKg==", "license": "MIT", - "peer": true, "dependencies": { "prosemirror-changeset": "^2.3.0", "prosemirror-collab": "^1.3.1", @@ -2808,6 +3109,14 @@ "tslib": "^2.4.0" } }, + "node_modules/@types/aria-query": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz", + "integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==", + "dev": true, + "license": "MIT", + "peer": true + }, "node_modules/@types/babel__core": { "version": "7.20.5", "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", @@ -2853,6 +3162,24 @@ "@babel/types": "^7.28.2" } }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/estree": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", @@ -2924,7 +3251,6 @@ "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.7.tgz", "integrity": "sha512-MWtvHrGZLFttgeEj28VXHxpmwYbor/ATPYbBfSFZEIRK0ecCFLl2Qo55z52Hss+UV9CRN7trSeq1zbgx7YDWWg==", "license": "MIT", - "peer": true, "dependencies": { "csstype": "^3.2.2" } @@ -3034,69 +3360,192 @@ "vite": "^6.3.0 || ^7.0.0" } }, - "node_modules/ajv": { - "version": "8.17.1", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.17.1.tgz", - "integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==", + "node_modules/@vitest/expect": { + "version": "4.1.4", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.4.tgz", + "integrity": "sha512-iPBpra+VDuXmBFI3FMKHSFXp3Gx5HfmSCE8X67Dn+bwephCnQCaB7qWK2ldHa+8ncN8hJU8VTMcxjPpyMkUjww==", "dev": true, "license": "MIT", "dependencies": { - "fast-deep-equal": "^3.1.3", - "fast-uri": "^3.0.1", - "json-schema-traverse": "^1.0.0", - "require-from-string": "^2.0.2" + "@standard-schema/spec": "^1.1.0", + "@types/chai": "^5.2.2", + "@vitest/spy": "4.1.4", + "@vitest/utils": "4.1.4", + "chai": "^6.2.2", + "tinyrainbow": "^3.1.0" }, "funding": { - "type": "github", - "url": "https://github.com/sponsors/epoberezkin" + "url": "https://opencollective.com/vitest" } }, - "node_modules/ajv-draft-04": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/ajv-draft-04/-/ajv-draft-04-1.0.0.tgz", - "integrity": "sha512-mv00Te6nmYbRp5DCwclxtt7yV/joXJPGS7nM+97GdxvuttCOfgI3K4U25zboyeX0O+myI8ERluxQe5wljMmVIw==", + "node_modules/@vitest/mocker": { + "version": "4.1.4", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.4.tgz", + "integrity": "sha512-R9HTZBhW6yCSGbGQnDnH3QHfJxokKN4KB+Yvk9Q1le7eQNYwiCyKxmLmurSpFy6BzJanSLuEUDrD+j97Q+ZLPg==", "dev": true, "license": "MIT", + "dependencies": { + "@vitest/spy": "4.1.4", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.21" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, "peerDependencies": { - "ajv": "^8.5.0" + "msw": "^2.4.9", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" }, "peerDependenciesMeta": { - "ajv": { + "msw": { + "optional": true + }, + "vite": { "optional": true } } }, - "node_modules/ansi-regex": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", - "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "node_modules/@vitest/mocker/node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, "license": "MIT", - "engines": { - "node": ">=8" + "dependencies": { + "@types/estree": "^1.0.0" } }, - "node_modules/ansi-styles": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", - "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "node_modules/@vitest/pretty-format": { + "version": "4.1.4", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.4.tgz", + "integrity": "sha512-ddmDHU0gjEUyEVLxtZa7xamrpIefdEETu3nZjWtHeZX4QxqJ7tRxSteHVXJOcr8jhiLoGAhkK4WJ3WqBpjx42A==", + "dev": true, "license": "MIT", "dependencies": { - "color-convert": "^2.0.1" - }, - "engines": { - "node": ">=8" + "tinyrainbow": "^3.1.0" }, "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" + "url": "https://opencollective.com/vitest" } }, - "node_modules/ansis": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/ansis/-/ansis-4.1.0.tgz", - "integrity": "sha512-BGcItUBWSMRgOCe+SVZJ+S7yTRG0eGt9cXAHev72yuGcY23hnLA7Bky5L/xLyPINoSN95geovfBkqoTlNZYa7w==", - "license": "ISC", - "engines": { - "node": ">=14" + "node_modules/@vitest/runner": { + "version": "4.1.4", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.4.tgz", + "integrity": "sha512-xTp7VZ5aXP5ZJrn15UtJUWlx6qXLnGtF6jNxHepdPHpMfz/aVPx+htHtgcAL2mDXJgKhpoo2e9/hVJsIeFbytQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "4.1.4", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "4.1.4", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.4.tgz", + "integrity": "sha512-MCjCFgaS8aZz+m5nTcEcgk/xhWv0rEH4Yl53PPlMXOZ1/Ka2VcZU6CJ+MgYCZbcJvzGhQRjVrGQNZqkGPttIKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.4", + "@vitest/utils": "4.1.4", + "magic-string": "^0.30.21", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "4.1.4", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.4.tgz", + "integrity": "sha512-XxNdAsKW7C+FLydqFJLb5KhJtl3PGCMmYwFRfhvIgxJvLSXhhVI1zM8f1qD3Zg7RCjTSzDVyct6sghs9UEgBEQ==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "4.1.4", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.4.tgz", + "integrity": "sha512-13QMT+eysM5uVGa1rG4kegGYNp6cnQcsTc67ELFbhNLQO+vgsygtYJx2khvdt4gVQqSSpC/KT5FZZxUpP3Oatw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.4", + "convert-source-map": "^2.0.0", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/ajv": { + "version": "8.17.1", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.17.1.tgz", + "integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ajv-draft-04": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/ajv-draft-04/-/ajv-draft-04-1.0.0.tgz", + "integrity": "sha512-mv00Te6nmYbRp5DCwclxtt7yV/joXJPGS7nM+97GdxvuttCOfgI3K4U25zboyeX0O+myI8ERluxQe5wljMmVIw==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "ajv": "^8.5.0" + }, + "peerDependenciesMeta": { + "ajv": { + "optional": true + } + } + }, + "node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/ansis": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/ansis/-/ansis-4.1.0.tgz", + "integrity": "sha512-BGcItUBWSMRgOCe+SVZJ+S7yTRG0eGt9cXAHev72yuGcY23hnLA7Bky5L/xLyPINoSN95geovfBkqoTlNZYa7w==", + "license": "ISC", + "engines": { + "node": ">=14" } }, "node_modules/argparse": { @@ -3105,6 +3554,26 @@ "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", "license": "Python-2.0" }, + "node_modules/aria-query": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.0.tgz", + "integrity": "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "dequal": "^2.0.3" + } + }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, "node_modules/atomic-sleep": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/atomic-sleep/-/atomic-sleep-1.0.0.tgz", @@ -3160,6 +3629,16 @@ "integrity": "sha512-mLQ4i2QO1ytvGWFWmcngKO//JXAQueZvwEKtjgQFM4jIK0kU+ytMfplL8j+n5mspOfjHwoAg+9yhb7BwAHm36g==", "license": "MIT" }, + "node_modules/bidi-js": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.0.3.tgz", + "integrity": "sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==", + "dev": true, + "license": "MIT", + "dependencies": { + "require-from-string": "^2.0.2" + } + }, "node_modules/bootstrap": { "version": "5.3.7", "resolved": "https://registry.npmjs.org/bootstrap/-/bootstrap-5.3.7.tgz", @@ -3220,7 +3699,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "caniuse-lite": "^1.0.30001726", "electron-to-chromium": "^1.5.173", @@ -3346,6 +3824,16 @@ ], "license": "CC-BY-4.0" }, + "node_modules/chai": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", + "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/chokidar": { "version": "4.0.3", "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz", @@ -3507,6 +3995,27 @@ "postcss-value-parser": "^4.0.2" } }, + "node_modules/css-tree": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.2.1.tgz", + "integrity": "sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==", + "dev": true, + "license": "MIT", + "dependencies": { + "mdn-data": "2.27.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0" + } + }, + "node_modules/css.escape": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/css.escape/-/css.escape-1.5.1.tgz", + "integrity": "sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg==", + "dev": true, + "license": "MIT" + }, "node_modules/csstype": { "version": "3.2.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", @@ -3625,7 +4134,6 @@ "resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz", "integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==", "license": "ISC", - "peer": true, "engines": { "node": ">=12" } @@ -3658,6 +4166,58 @@ "d3-selection": "2 - 3" } }, + "node_modules/data-urls": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-7.0.0.tgz", + "integrity": "sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA==", + "dev": true, + "license": "MIT", + "dependencies": { + "whatwg-mimetype": "^5.0.0", + "whatwg-url": "^16.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/data-urls/node_modules/tr46": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-6.0.0.tgz", + "integrity": "sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==", + "dev": true, + "license": "MIT", + "dependencies": { + "punycode": "^2.3.1" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/data-urls/node_modules/webidl-conversions": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-8.0.1.tgz", + "integrity": "sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=20" + } + }, + "node_modules/data-urls/node_modules/whatwg-url": { + "version": "16.0.1", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-16.0.1.tgz", + "integrity": "sha512-1to4zXBxmXHV3IiSSEInrreIlu02vUOvrhxJJH5vcxYTBDAx51cqZiKdyTxlecdKNSjj8EcxGBxNf6Vg+945gw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@exodus/bytes": "^1.11.0", + "tr46": "^6.0.0", + "webidl-conversions": "^8.0.1" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, "node_modules/dayjs": { "version": "1.11.13", "resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.13.tgz", @@ -3690,6 +4250,13 @@ "node": ">=0.10.0" } }, + "node_modules/decimal.js": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz", + "integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==", + "dev": true, + "license": "MIT" + }, "node_modules/decode-uri-component": { "version": "0.2.2", "resolved": "https://registry.npmjs.org/decode-uri-component/-/decode-uri-component-0.2.2.tgz", @@ -3787,6 +4354,14 @@ "integrity": "sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA==", "license": "MIT" }, + "node_modules/dom-accessibility-api": { + "version": "0.5.16", + "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz", + "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==", + "dev": true, + "license": "MIT", + "peer": true + }, "node_modules/dom-helpers": { "version": "5.2.1", "resolved": "https://registry.npmjs.org/dom-helpers/-/dom-helpers-5.2.1.tgz", @@ -3946,6 +4521,13 @@ "node": ">= 0.4" } }, + "node_modules/es-module-lexer": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.0.0.tgz", + "integrity": "sha512-5POEcUuZybH7IdmGsD8wlf0AI55wMecM9rVBTI/qEAy2c1kTOm3DjFYjrBdI2K3BaJjJYfYFeRtM0t9ssnRuxw==", + "dev": true, + "license": "MIT" + }, "node_modules/es-object-atoms": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", @@ -3972,7 +4554,6 @@ "devOptional": true, "hasInstallScript": true, "license": "MIT", - "peer": true, "bin": { "esbuild": "bin/esbuild" }, @@ -4060,6 +4641,16 @@ "dev": true, "license": "MIT" }, + "node_modules/expect-type": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz", + "integrity": "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/fast-deep-equal": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", @@ -4425,6 +5016,19 @@ "htmlparser2": "7.2.0" } }, + "node_modules/html-encoding-sniffer": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-6.0.0.tgz", + "integrity": "sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@exodus/bytes": "^1.6.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, "node_modules/html-parse-stringify": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/html-parse-stringify/-/html-parse-stringify-3.0.1.tgz", @@ -4494,7 +5098,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "@babel/runtime": "^7.17.2" } @@ -4544,6 +5147,16 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/indent-string": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/indent-string/-/indent-string-4.0.0.tgz", + "integrity": "sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/inline-style-parser": { "version": "0.1.1", "resolved": "https://registry.npmjs.org/inline-style-parser/-/inline-style-parser-0.1.1.tgz", @@ -4675,6 +5288,13 @@ "node": ">=0.12.0" } }, + "node_modules/is-potential-custom-element-name": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", + "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==", + "dev": true, + "license": "MIT" + }, "node_modules/is-regex": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.2.1.tgz", @@ -4724,6 +5344,95 @@ "js-yaml": "bin/js-yaml.js" } }, + "node_modules/jsdom": { + "version": "29.0.2", + "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-29.0.2.tgz", + "integrity": "sha512-9VnGEBosc/ZpwyOsJBCQ/3I5p7Q5ngOY14a9bf5btenAORmZfDse1ZEheMiWcJ3h81+Fv7HmJFdS0szo/waF2w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/css-color": "^5.1.5", + "@asamuzakjp/dom-selector": "^7.0.6", + "@bramus/specificity": "^2.4.2", + "@csstools/css-syntax-patches-for-csstree": "^1.1.1", + "@exodus/bytes": "^1.15.0", + "css-tree": "^3.2.1", + "data-urls": "^7.0.0", + "decimal.js": "^10.6.0", + "html-encoding-sniffer": "^6.0.0", + "is-potential-custom-element-name": "^1.0.1", + "lru-cache": "^11.2.7", + "parse5": "^8.0.0", + "saxes": "^6.0.0", + "symbol-tree": "^3.2.4", + "tough-cookie": "^6.0.1", + "undici": "^7.24.5", + "w3c-xmlserializer": "^5.0.0", + "webidl-conversions": "^8.0.1", + "whatwg-mimetype": "^5.0.0", + "whatwg-url": "^16.0.1", + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24.0.0" + }, + "peerDependencies": { + "canvas": "^3.0.0" + }, + "peerDependenciesMeta": { + "canvas": { + "optional": true + } + } + }, + "node_modules/jsdom/node_modules/lru-cache": { + "version": "11.3.5", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.3.5.tgz", + "integrity": "sha512-NxVFwLAnrd9i7KUBxC4DrUhmgjzOs+1Qm50D3oF1/oL+r1NpZ4gA7xvG0/zJ8evR7zIKn4vLf7qTNduWFtCrRw==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/jsdom/node_modules/tr46": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-6.0.0.tgz", + "integrity": "sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==", + "dev": true, + "license": "MIT", + "dependencies": { + "punycode": "^2.3.1" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/jsdom/node_modules/webidl-conversions": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-8.0.1.tgz", + "integrity": "sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=20" + } + }, + "node_modules/jsdom/node_modules/whatwg-url": { + "version": "16.0.1", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-16.0.1.tgz", + "integrity": "sha512-1to4zXBxmXHV3IiSSEInrreIlu02vUOvrhxJJH5vcxYTBDAx51cqZiKdyTxlecdKNSjj8EcxGBxNf6Vg+945gw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@exodus/bytes": "^1.11.0", + "tr46": "^6.0.0", + "webidl-conversions": "^8.0.1" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, "node_modules/jsesc": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", @@ -5081,6 +5790,27 @@ "yallist": "^3.0.2" } }, + "node_modules/lz-string": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz", + "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==", + "dev": true, + "license": "MIT", + "peer": true, + "bin": { + "lz-string": "bin/bin.js" + } + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, "node_modules/markdown-it": { "version": "14.1.0", "resolved": "https://registry.npmjs.org/markdown-it/-/markdown-it-14.1.0.tgz", @@ -5119,6 +5849,13 @@ "node": ">= 0.4" } }, + "node_modules/mdn-data": { + "version": "2.27.1", + "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.27.1.tgz", + "integrity": "sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==", + "dev": true, + "license": "CC0-1.0" + }, "node_modules/mdurl": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/mdurl/-/mdurl-2.0.0.tgz", @@ -5155,6 +5892,16 @@ "node": ">=8.6" } }, + "node_modules/min-indent": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/min-indent/-/min-indent-1.0.1.tgz", + "integrity": "sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, "node_modules/minimist": { "version": "1.2.8", "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", @@ -5554,6 +6301,17 @@ "node": ">= 0.4" } }, + "node_modules/obug": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.1.tgz", + "integrity": "sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==", + "dev": true, + "funding": [ + "https://github.com/sponsors/sxzz", + "https://opencollective.com/debug" + ], + "license": "MIT" + }, "node_modules/on-exit-leak-free": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/on-exit-leak-free/-/on-exit-leak-free-2.1.2.tgz", @@ -5649,6 +6407,32 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/parse5": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-8.0.0.tgz", + "integrity": "sha512-9m4m5GSgXjL4AjumKzq1Fgfp3Z8rsvjRNbnkVwfu2ImRqE5D0LnY2QfDen18FSY9C573YU5XxSapdHZTZ2WolA==", + "dev": true, + "license": "MIT", + "dependencies": { + "entities": "^6.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, + "node_modules/parse5/node_modules/entities": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz", + "integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, "node_modules/path-exists": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", @@ -5682,6 +6466,13 @@ "node": ">=8" } }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, "node_modules/pdfmake": { "version": "0.2.20", "resolved": "https://registry.npmjs.org/pdfmake/-/pdfmake-0.2.20.tgz", @@ -5863,6 +6654,44 @@ "url": "https://github.com/prettier/prettier?sponsor=1" } }, + "node_modules/pretty-format": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.5.1.tgz", + "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "ansi-regex": "^5.0.1", + "ansi-styles": "^5.0.0", + "react-is": "^17.0.1" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + } + }, + "node_modules/pretty-format/node_modules/ansi-styles": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", + "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/pretty-format/node_modules/react-is": { + "version": "17.0.2", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", + "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==", + "dev": true, + "license": "MIT", + "peer": true + }, "node_modules/process-warning": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/process-warning/-/process-warning-5.0.0.tgz", @@ -5884,7 +6713,6 @@ "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", "integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==", "license": "MIT", - "peer": true, "dependencies": { "loose-envify": "^1.4.0", "object-assign": "^4.1.1", @@ -6028,7 +6856,6 @@ "resolved": "https://registry.npmjs.org/prosemirror-model/-/prosemirror-model-1.25.4.tgz", "integrity": "sha512-PIM7E43PBxKce8OQeezAs9j4TP+5yDpZVbuurd1h5phUxEKIu+G2a+EUZzIC5nS1mJktDJWzbqS23n1tsAf5QA==", "license": "MIT", - "peer": true, "dependencies": { "orderedmap": "^2.0.0" } @@ -6058,7 +6885,6 @@ "resolved": "https://registry.npmjs.org/prosemirror-state/-/prosemirror-state-1.4.4.tgz", "integrity": "sha512-6jiYHH2CIGbCfnxdHbXZ12gySFY/fz/ulZE333G6bPqIZ4F+TXo9ifiR86nAHpWnfoNjOb3o5ESi7J8Uz1jXHw==", "license": "MIT", - "peer": true, "dependencies": { "prosemirror-model": "^1.0.0", "prosemirror-transform": "^1.0.0", @@ -6107,13 +6933,22 @@ "resolved": "https://registry.npmjs.org/prosemirror-view/-/prosemirror-view-1.41.4.tgz", "integrity": "sha512-WkKgnyjNncri03Gjaz3IFWvCAE94XoiEgvtr0/r2Xw7R8/IjK3sKLSiDoCHWcsXSAinVaKlGRZDvMCsF1kbzjA==", "license": "MIT", - "peer": true, "dependencies": { "prosemirror-model": "^1.20.0", "prosemirror-state": "^1.0.0", "prosemirror-transform": "^1.1.0" } }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/punycode.js": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/punycode.js/-/punycode.js-2.3.1.tgz", @@ -6205,7 +7040,6 @@ "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", "license": "MIT", - "peer": true, "dependencies": { "loose-envify": "^1.1.0" }, @@ -6259,7 +7093,6 @@ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", "license": "MIT", - "peer": true, "dependencies": { "loose-envify": "^1.1.0", "scheduler": "^0.23.2" @@ -6362,7 +7195,8 @@ "version": "19.1.1", "resolved": "https://registry.npmjs.org/react-is/-/react-is-19.1.1.tgz", "integrity": "sha512-tr41fA15Vn8p4X9ntI+yCyeGSf1TlYaY5vlTZfQmeLBrFo3psOPX6HhTDnFNL9uj3EhP0KAQ80cugCl4b4BERA==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/react-lifecycles-compat": { "version": "3.0.4", @@ -6609,12 +7443,25 @@ "node": ">= 12.13.0" } }, + "node_modules/redent": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/redent/-/redent-3.0.0.tgz", + "integrity": "sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==", + "dev": true, + "license": "MIT", + "dependencies": { + "indent-string": "^4.0.0", + "strip-indent": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/redux": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz", "integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/reftools": { "version": "1.1.9", @@ -6835,7 +7682,6 @@ "integrity": "sha512-9GUyuksjw70uNpb1MTYWsH9MQHOHY6kwfnkafC24+7aOMZn9+rVMBxRbLvw756mrBFbIsFg6Xw9IkR2Fnn3k+Q==", "devOptional": true, "license": "MIT", - "peer": true, "dependencies": { "chokidar": "^4.0.0", "immutable": "^5.0.2", @@ -6857,6 +7703,19 @@ "integrity": "sha512-+aWOz7yVScEGoKNd4PA10LZ8sk0A/z5+nXQG5giUO5rprX9jgYsTdov9qCchZiPIZezbZH+jRut8nPodFAX4Jg==", "license": "ISC" }, + "node_modules/saxes": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz", + "integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==", + "dev": true, + "license": "ISC", + "dependencies": { + "xmlchars": "^2.2.0" + }, + "engines": { + "node": ">=v12.22.7" + } + }, "node_modules/scheduler": { "version": "0.23.2", "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz", @@ -6979,6 +7838,13 @@ "dev": true, "license": "MIT" }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, "node_modules/sirv": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/sirv/-/sirv-3.0.1.tgz", @@ -7071,6 +7937,13 @@ "node": ">= 10.x" } }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, "node_modules/stackblur-canvas": { "version": "2.7.0", "resolved": "https://registry.npmjs.org/stackblur-canvas/-/stackblur-canvas-2.7.0.tgz", @@ -7080,6 +7953,13 @@ "node": ">=0.1.14" } }, + "node_modules/std-env": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.1.0.tgz", + "integrity": "sha512-Rq7ybcX2RuC55r9oaPVEW7/xu3tj8u4GeBYHBWCychFtzMIr86A7e3PPEBPT37sHStKX3+TiX/Fr/ACmJLVlLQ==", + "dev": true, + "license": "MIT" + }, "node_modules/strict-uri-encode": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/strict-uri-encode/-/strict-uri-encode-2.0.0.tgz", @@ -7115,6 +7995,19 @@ "node": ">=8" } }, + "node_modules/strip-indent": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/strip-indent/-/strip-indent-3.0.0.tgz", + "integrity": "sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "min-indent": "^1.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/style-to-js": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/style-to-js/-/style-to-js-1.1.1.tgz", @@ -7321,6 +8214,13 @@ "node": ">=12" } }, + "node_modules/symbol-tree": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz", + "integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==", + "dev": true, + "license": "MIT" + }, "node_modules/tabbable": { "version": "6.2.0", "resolved": "https://registry.npmjs.org/tabbable/-/tabbable-6.2.0.tgz", @@ -7368,14 +8268,31 @@ "integrity": "sha512-lBN9zLN/oAf68o3zNXYrdCt1kP8WsiGW8Oo2ka41b2IM5JL/S1CTyX1rW0mb/zSuJun0ZUrDxx4sqvYS2FWzPA==", "license": "MIT" }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.1.1.tgz", + "integrity": "sha512-VKS/ZaQhhkKFMANmAOhhXVoIfBXblQxGX1myCQ2faQrfmobMftXeJPcZGp0gS07ocvGJWDLZGyOZDadDBqYIJg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/tinyglobby": { - "version": "0.2.14", - "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.14.tgz", - "integrity": "sha512-tX5e7OM1HnYr2+a2C/4V0htOcSQcoSTH9KgJnVvNm5zm/cyEWKJ7j7YutsH9CxMdtOkkLFy2AHrMci9IM8IPZQ==", + "version": "0.2.16", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.16.tgz", + "integrity": "sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==", "license": "MIT", "dependencies": { - "fdir": "^6.4.4", - "picomatch": "^4.0.2" + "fdir": "^6.5.0", + "picomatch": "^4.0.4" }, "engines": { "node": ">=12.0.0" @@ -7385,10 +8302,13 @@ } }, "node_modules/tinyglobby/node_modules/fdir": { - "version": "6.4.6", - "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.4.6.tgz", - "integrity": "sha512-hiFoqpyZcfNm1yc4u8oWCf9A2c4D3QjCrks3zmoVKVxpQRzmPNar1hUJcBG2RQHvEVGDN+Jm81ZheVLAQMK6+w==", + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, "peerDependencies": { "picomatch": "^3 || ^4" }, @@ -7399,11 +8319,10 @@ } }, "node_modules/tinyglobby/node_modules/picomatch": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", - "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -7417,6 +8336,36 @@ "integrity": "sha512-ppJZNDuKGgxzkHihX8v9v9G5f+18gzaTfrukGrq6ueg0lmH4nqVnA2IPG0AEH3jKEk2GRJCUhDoqpoiw3PHLBA==", "license": "ISC" }, + "node_modules/tinyrainbow": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.0.tgz", + "integrity": "sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tldts": { + "version": "7.0.28", + "resolved": "https://registry.npmjs.org/tldts/-/tldts-7.0.28.tgz", + "integrity": "sha512-+Zg3vWhRUv8B1maGSTFdev9mjoo8Etn2Ayfs4cnjlD3CsGkxXX4QyW3j2WJ0wdjYcYmy7Lx2RDsZMhgCWafKIw==", + "dev": true, + "license": "MIT", + "dependencies": { + "tldts-core": "^7.0.28" + }, + "bin": { + "tldts": "bin/cli.js" + } + }, + "node_modules/tldts-core": { + "version": "7.0.28", + "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.0.28.tgz", + "integrity": "sha512-7W5Efjhsc3chVdFhqtaU0KtK32J37Zcr9RKtID54nG+tIpcY79CQK/veYPODxtD/LJ4Lue66jvrQzIX2Z2/pUQ==", + "dev": true, + "license": "MIT" + }, "node_modules/to-regex-range": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", @@ -7440,6 +8389,19 @@ "node": ">=6" } }, + "node_modules/tough-cookie": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-6.0.1.tgz", + "integrity": "sha512-LktZQb3IeoUWB9lqR5EWTHgW/VTITCXg4D21M+lvybRVdylLrRMnqaIONLVb5mav8vM19m44HIcGq4qASeu2Qw==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "tldts": "^7.0.5" + }, + "engines": { + "node": ">=16" + } + }, "node_modules/tr46": { "version": "0.0.3", "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", @@ -7459,7 +8421,6 @@ "integrity": "sha512-1FXk9E2Hm+QzZQ7z+McJiHL4NW1F2EzMu9Nq9i3zAaGqibafqYwCVU6WyWAuyQRRzOlxou8xZSyXLEN8oKj24g==", "devOptional": true, "license": "Apache-2.0", - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -7498,6 +8459,16 @@ "react": ">=15.0.0" } }, + "node_modules/undici": { + "version": "7.25.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-7.25.0.tgz", + "integrity": "sha512-xXnp4kTyor2Zq+J1FfPI6Eq3ew5h6Vl0F/8d9XU5zZQf1tX9s2Su1/3PiMmUANFULpmksxkClamIZcaUqryHsQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20.18.1" + } + }, "node_modules/unicode-properties": { "version": "1.4.1", "resolved": "https://registry.npmjs.org/unicode-properties/-/unicode-properties-1.4.1.tgz", @@ -7583,7 +8554,6 @@ "resolved": "https://registry.npmjs.org/rolldown-vite/-/rolldown-vite-7.0.12.tgz", "integrity": "sha512-Gr40FRnE98FwPJcMwcJgBwP6U7Qxw/VEtDsFdFjvGUTdgI/tTmF7z7dbVo/ajItM54G+Zo9w5BIrUmat6MbuWQ==", "license": "MIT", - "peer": true, "dependencies": { "fdir": "^6.4.6", "lightningcss": "^1.30.1", @@ -7717,7 +8687,109 @@ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "license": "MIT", - "peer": true, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/vitest": { + "version": "4.1.4", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.4.tgz", + "integrity": "sha512-tFuJqTxKb8AvfyqMfnavXdzfy3h3sWZRWwfluGbkeR7n0HUev+FmNgZ8SDrRBTVrVCjgH5cA21qGbCffMNtWvg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/expect": "4.1.4", + "@vitest/mocker": "4.1.4", + "@vitest/pretty-format": "4.1.4", + "@vitest/runner": "4.1.4", + "@vitest/snapshot": "4.1.4", + "@vitest/spy": "4.1.4", + "@vitest/utils": "4.1.4", + "es-module-lexer": "^2.0.0", + "expect-type": "^1.3.0", + "magic-string": "^0.30.21", + "obug": "^2.1.1", + "pathe": "^2.0.3", + "picomatch": "^4.0.3", + "std-env": "^4.0.0-rc.1", + "tinybench": "^2.9.0", + "tinyexec": "^1.0.2", + "tinyglobby": "^0.2.15", + "tinyrainbow": "^3.1.0", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@opentelemetry/api": "^1.9.0", + "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", + "@vitest/browser-playwright": "4.1.4", + "@vitest/browser-preview": "4.1.4", + "@vitest/browser-webdriverio": "4.1.4", + "@vitest/coverage-istanbul": "4.1.4", + "@vitest/coverage-v8": "4.1.4", + "@vitest/ui": "4.1.4", + "happy-dom": "*", + "jsdom": "*", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@opentelemetry/api": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser-playwright": { + "optional": true + }, + "@vitest/browser-preview": { + "optional": true + }, + "@vitest/browser-webdriverio": { + "optional": true + }, + "@vitest/coverage-istanbul": { + "optional": true + }, + "@vitest/coverage-v8": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + }, + "vite": { + "optional": false + } + } + }, + "node_modules/vitest/node_modules/picomatch": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "dev": true, + "license": "MIT", "engines": { "node": ">=12" }, @@ -7740,6 +8812,19 @@ "integrity": "sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==", "license": "MIT" }, + "node_modules/w3c-xmlserializer": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz", + "integrity": "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==", + "dev": true, + "license": "MIT", + "dependencies": { + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/warning": { "version": "4.0.3", "resolved": "https://registry.npmjs.org/warning/-/warning-4.0.3.tgz", @@ -7765,6 +8850,16 @@ "dev": true, "license": "BSD-2-Clause" }, + "node_modules/whatwg-mimetype": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-5.0.0.tgz", + "integrity": "sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20" + } + }, "node_modules/whatwg-url": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", @@ -7782,6 +8877,23 @@ "integrity": "sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ==", "license": "ISC" }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/wrap-ansi": { "version": "6.2.0", "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz", @@ -7796,6 +8908,23 @@ "node": ">=8" } }, + "node_modules/xml-name-validator": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz", + "integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/xmlchars": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz", + "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==", + "dev": true, + "license": "MIT" + }, "node_modules/xmldoc": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/xmldoc/-/xmldoc-2.0.2.tgz", diff --git a/package.json b/package.json index 7602e6fc3..c7cc22fa1 100644 --- a/package.json +++ b/package.json @@ -5,19 +5,25 @@ "private": true, "scripts": { "dev": "vite", - "build": "vite build" + "build": "vite build", + "test:unit": "vitest run", + "test:unit:watch": "vitest" }, "devDependencies": { "@playwright/test": "1.57.0", "@rtk-query/codegen-openapi": "^2.0.0", + "@testing-library/jest-dom": "^6.9.1", + "@testing-library/react": "^16.3.2", "@vitejs/plugin-react": "^4.6.0", "esbuild": "^0.25.8", "esbuild-runner": "^2.2.2", + "jsdom": "^29.0.2", "sass": "^1.88.9", "typescript": "^4.4.2", "vite": "npm:rolldown-vite@^7.0.3", "vite-plugin-svgr": "^4.3.0", - "vite-plugin-symfony": "^8.2.0" + "vite-plugin-symfony": "^8.2.0", + "vitest": "^4.1.4" }, "dependencies": { "@amcharts/amcharts4": "^4.10.21", diff --git a/vitest.config.js b/vitest.config.js new file mode 100644 index 000000000..9ec63ed2b --- /dev/null +++ b/vitest.config.js @@ -0,0 +1,9 @@ +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { + environment: "jsdom", + include: ["assets/**/*.test.{js,jsx}"], + setupFiles: ["./assets/tests/setup.js"], + }, +}); From 014ebd680ba6bb97f5f52fd7829f3388b8f0d384 Mon Sep 17 00:00:00 2001 From: Troels Ugilt Jensen <6103205+tuj@users.noreply.github.com> Date: Fri, 17 Apr 2026 14:14:21 +0200 Subject: [PATCH 12/73] 7203: Added github actions for vitest --- .github/workflows/vitest.yaml | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) create mode 100644 .github/workflows/vitest.yaml diff --git a/.github/workflows/vitest.yaml b/.github/workflows/vitest.yaml new file mode 100644 index 000000000..7203fe02f --- /dev/null +++ b/.github/workflows/vitest.yaml @@ -0,0 +1,23 @@ +on: pull_request + +name: Unit tests + +env: + COMPOSE_USER: runner + +jobs: + vitest: + name: Vitest + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup network + run: docker network create frontend + + - name: Install dependencies + run: docker compose run --rm node npm install + + - name: Run unit tests + run: docker compose run --rm node npm run test:unit From 6937ca862daec029696abf14accfb8831a58fc9d Mon Sep 17 00:00:00 2001 From: Troels Ugilt Jensen <6103205+tuj@users.noreply.github.com> Date: Fri, 17 Apr 2026 14:17:49 +0200 Subject: [PATCH 13/73] Removed plan --- PLAN1.md | 4839 ------------------------------------------------------ 1 file changed, 4839 deletions(-) delete mode 100644 PLAN1.md diff --git a/PLAN1.md b/PLAN1.md deleted file mode 100644 index b589f9b5b..000000000 --- a/PLAN1.md +++ /dev/null @@ -1,4839 +0,0 @@ -# Implementation Plan: OS2Display Black Screen & Auth Recovery Fixes - -**Target branch:** `release/3.0.0` -**Methodology:** TDD — each step writes a failing test first, then the minimal fix -**Execution:** Each step is one PR, optimized for `claude code /loop` - -## Docker compose services - -All commands run inside the project's docker compose containers: - -| Service | Container | Used for | -|---------|-----------|----------| -| `phpfpm` | `itkdev/php8.3-fpm` | PHP: composer, phpunit, psalm, php-cs-fixer | -| `node` | `node:24` | npm, vitest | -| `playwright` | `mcr.microsoft.com/playwright` | E2E tests | -| `mariadb` | `itkdev/mariadb` | Test database | -| `redis` | `redis:6` | Cache, sessions | - -## Command reference - -```bash -# PHP — tests -docker compose run --rm phpfpm composer test - -# PHP — static analysis -docker compose run --rm phpfpm composer code-analysis - -# PHP — coding standards check -docker compose run --rm phpfpm composer coding-standards-check - -# PHP — auto-fix coding standards -docker compose run --rm phpfpm composer coding-standards-apply - -# PHP — test setup (DB reset, migrations) -docker compose run --rm phpfpm composer test-setup - -# PHP — clear cache -docker compose run --rm phpfpm bin/console cache:clear - -# JS — install dependencies -docker compose run --rm node npm install - -# JS — unit tests (after Step 0) -docker compose run --rm node npx vitest run - -# JS — E2E tests -docker compose run --rm playwright npx playwright test - -# JS — coding standards (if configured) -docker compose run --rm node npx prettier --check assets/ -``` - -## Verification checklist (run before every PR) - -```bash -# Build (after Step 15 adds the SW build plugin) -docker compose run --rm node npm run build - -# PHP steps -docker compose run --rm phpfpm composer coding-standards-check -docker compose run --rm phpfpm composer code-analysis -docker compose run --rm phpfpm composer test - -# JS steps (after Step 0) -docker compose run --rm node npx vitest run -docker compose run --rm playwright npx playwright test -``` - ---- - -## Phase 0: Test infrastructure - -### Step 0 — Add Vitest for client unit tests - -**Branch:** `fix/add-client-unit-test-framework` - -**Motivation:** The client codebase has no unit test framework. The -only automated coverage is Playwright E2E, which runs against a full -browser and requires the entire stack (backend, database, Redis, the -React app) to be up. E2E tests are valuable but slow, brittle under -timing variance, and incapable of isolating the kind of state-machine -bugs we're about to fix. - -Consider the specific bugs in this plan: - -- `reauthenticateHandler` not resetting `displayFallback` (Step 6) — a - bug in a state transition triggered by a 401 response. To hit it - with E2E, we'd need to set up a screen, fake a 401, and inspect the - DOM at the exact moment the handler runs. To hit it in a unit test, - we mount `` in jsdom and dispatch a `reauthenticate` event. -- `checkLogin` dead-end on unknown status (Step 7) — requires injecting - a malformed backend response. Trivial in a unit test, practically - impossible in E2E without backend mocking. -- Crash-loop guard (Step 11c) — a state machine around localStorage - with time-sensitive windows. Unit tests with fake timers cover it in - seconds; E2E would need ~4 minutes of real wall-clock time to exercise. - -Vitest integrates directly with the existing Vite config, uses the same -JSX transform, and is zero-config. The `fake-indexeddb` polyfill -(installed in this step) lets us test Step 14's IndexedDB auth-bridge -without a real browser. Every subsequent TDD step depends on this -infrastructure. - -This step also establishes the convention: tests live next to the code -as `__tests__/*.test.{js,jsx}`, imports match production imports, -`jsdom` provides the DOM environment. Consistent structure makes -`/loop` runs predictable. - -**Tasks:** - -1. Install vitest + testing libraries: - ```bash - docker compose run --rm node npm install -D vitest @testing-library/react @testing-library/jest-dom jsdom fake-indexeddb - ``` - -2. Add vitest config to `vite.config.js`: - ```js - test: { - environment: 'jsdom', - include: ['assets/**/*.test.{js,jsx}'], - setupFiles: ['./assets/tests/setup.js'], - } - ``` - -3. Create `assets/tests/setup.js`: - ```js - import '@testing-library/jest-dom'; - ``` - -4. Add npm script to `package.json`: - ```json - "test:unit": "vitest run", - "test:unit:watch": "vitest" - ``` - -5. Write a smoke test `assets/client/util/__tests__/id-from-path.test.js` - that tests the existing `idFromPath` utility to prove the framework works. - -6. Verify: - ```bash - docker compose run --rm node npx vitest run - ``` - -**Changelog:** Added Vitest for client-side unit testing. - ---- - -## Phase 1: Backend bug fixes - -### Step 1 — Fix `ScreenUserRequestSubscriber` entityManager->clear() - -**Branch:** `fix/screen-subscriber-entity-clear` - -**Bug:** When `TRACK_SCREEN_INFO=true`, this subscriber updates the -screen's `lastSeen` timestamp and client metadata on every API request, -then calls `$this->entityManager->flush()` followed by -`$this->entityManager->clear()`. - -`EntityManager::clear()` with no argument detaches ALL entities from -Doctrine's identity map — not just the one that was flushed. This means -any other managed entity loaded earlier in the request lifecycle, -including the authenticated ScreenUser, its associated Screen, and the -Tenant, becomes detached. - -The subscriber runs on `kernel.request` after authentication but before -the controller. When the controller later accesses `$user->getScreen()` -or TenantExtension runs its query filter, Doctrine can no longer -lazy-load from these detached entities. The request either 500s -(`EntityNotFoundException`) or returns incorrect data (tenant filter -silently inactive). Under concurrent load across a shared PHP-FPM -worker, the corruption cascades to adjacent requests. - -Only triggers when `TRACK_SCREEN_INFO=true` (defaults to false). In -deployments that have opted in to screen tracking, every screen's 90-second -poll cycle runs through this subscriber, making the failure rate -proportional to screen count. - -**RED — write test:** - -File: `tests/EventSubscriber/ScreenUserRequestSubscriberTest.php` - -Test that after `ScreenUserRequestSubscriber::onKernelRequest` runs for a -screen endpoint, the authenticated ScreenUser entity is still managed by -the entity manager (not detached). Simulate a request to -`/v2/screens/{ULID}` with `trackScreenInfo=true`, then assert: -- `$entityManager->contains($screenUser)` is true after the subscriber runs -- The screen's tenant is still accessible via the user security token - -```bash -docker compose run --rm phpfpm composer test -- --filter=ScreenUserRequestSubscriberTest -``` - -**GREEN — fix:** - -In `src/EventSubscriber/ScreenUserRequestSubscriber.php`, replace: -```php -$this->entityManager->flush(); -$this->entityManager->clear(); -``` -With: -```php -$this->entityManager->flush(); -$this->entityManager->detach($screenUser); -``` - -Only detach the specific entity that was modified, not the entire identity map. - -**Verify:** -```bash -docker compose run --rm phpfpm composer test -docker compose run --rm phpfpm composer code-analysis -docker compose run --rm phpfpm composer coding-standards-check -``` - -**Changelog:** Fixed: `entityManager->clear()` in ScreenUserRequestSubscriber no longer detaches authenticated user entities. - ---- - -### Step 2 — Fix `getStatus()` one-shot cache delete - -**Branch:** `fix/screen-auth-cache-grace-period` - -**Bug:** `ScreenAuthenticator::getStatus()` is the polling endpoint the -client hits to check whether an admin has claimed its bind key. When the -admin binds the screen, a JWT + refresh token pair is written to the -cache against the session's bind key. The next time `getStatus()` is -called, it retrieves the tokens AND immediately calls -`$this->authScreenCache->deleteItem($cacheKey)` before returning them to -the client. - -This treats the cache as a one-shot retrieval. If anything between the -cache read and the client's successful token storage fails — network -blip, proxy timeout, slow PHP-FPM response, browser retry, JS parse -error, kernel panic on a cheap kiosk box — the client polls again but -the cache is already empty. The response reverts to `awaitingBindKey`, -even though the screen IS bound on the backend. - -From the admin's perspective: they click bind, the bind key entry UI -confirms success, but the screen keeps showing the bind key. The admin -concludes the bind failed and re-binds. Each additional bind attempt -creates new refresh tokens (Step 4 issue), each adds a ScreenUser -conflict (Step 3 issue). A cascade of downstream problems can trace -back to this single one-shot retrieval. - -The fix replaces the immediate delete with a 60-second grace period. -The client has time to retry; stale tokens still clean up on their own. - -**RED — write test:** - -File: `tests/Security/ScreenAuthenticatorTest.php` - -Test that after `getStatus()` returns a `ready` response with a token, a -second call to `getStatus()` within 60 seconds (same session) still returns -`ready` with the same token (not `awaitingBindKey`). - -Currently fails because `deleteItem()` destroys the entry on first read. - -```bash -docker compose run --rm phpfpm composer test -- --filter=ScreenAuthenticatorTest -``` - -**GREEN — fix:** - -In `src/Security/ScreenAuthenticator.php` `getStatus()`, replace: -```php -$this->authScreenCache->deleteItem($cacheKey); -``` -With: -```php -$cacheItem->expiresAfter(60); -$this->authScreenCache->save($cacheItem); -``` - -**Verify:** -```bash -docker compose run --rm phpfpm composer test -docker compose run --rm phpfpm composer code-analysis -docker compose run --rm phpfpm composer coding-standards-check -``` - -**Changelog:** Fixed: Screen auth token cache entry now has a 60-second grace period instead of immediate deletion. - ---- - -### Step 3 — Fix `bindScreen` for already-bound screens - -**Branch:** `fix/screen-rebind-already-bound` - -**Bug:** `ScreenAuthenticator::bindScreen()` is called when an admin -enters a bind key in the UI. It resolves the Screen and checks whether a -ScreenUser already exists. If yes, it throws an exception with the -message "Screen already bound." `AuthScreenBindController` catches every -exception as "Key not accepted" and returns that to the admin. - -This is the exact failure mode blocking every client-side auth recovery -scenario. Consider the path: a screen loses its tokens (localStorage -cleared by browser memory pressure, IndexedDB corrupted, explicit -`reauthenticate` wiping credentials, hardware-induced browser crash -clearing storage). The screen boots, has no tokens, requests a new bind -key, and displays it. The admin sees the screen showing a key, enters -it, clicks bind. Backend: ScreenUser already exists → "Screen already -bound" → "Key not accepted." - -The admin has no indication that unbinding is required first, and even -if they knew, unbinding can lose configuration depending on other -cleanup paths. The documented workaround is "delete the Screen and -create a new one" — losing history, schedules, and playlist assignments. - -On the field: every screen that loses client-side auth becomes a support -ticket. The fix accepts rebind requests for already-bound screens: the -old ScreenUser and its refresh tokens are cleaned up (same cleanup as -Step 4's unbind fix), a new ScreenUser is created, the screen transparently -rebinds. Admins see the same success flow as first-time binding. - -**RED — write test:** - -File: `tests/Security/ScreenAuthenticatorRebindTest.php` - -Test the full flow: bind a screen, then simulate the screen losing auth -and requesting a new bind key. Call `bindScreen` again with the same -screen. Assert that: -- The call succeeds (does not throw) -- The old ScreenUser is removed -- A new ScreenUser is created with a new JWT and refresh token -- The old refresh token is deleted from the `refresh_tokens` table - -Currently fails because `bindScreen` throws "Screen already bound". - -```bash -docker compose run --rm phpfpm composer test -- --filter=ScreenAuthenticatorRebindTest -``` - -**GREEN — fix:** - -In `src/Security/ScreenAuthenticator.php` `bindScreen()`, replace: -```php -if ($screenUser) { - throw new \Exception('Screen already bound'); -} -``` -With: -```php -if ($screenUser) { - $this->cleanupScreenUser($screenUser); -} -``` - -Add private method: -```php -private function cleanupScreenUser(ScreenUser $screenUser): void -{ - $refreshTokens = $this->entityManager - ->getRepository(RefreshToken::class) - ->findBy(['username' => $screenUser->getUserIdentifier()]); - - foreach ($refreshTokens as $token) { - $this->entityManager->remove($token); - } - - $this->entityManager->remove($screenUser); - $this->entityManager->flush(); -} -``` - -Also update `src/Controller/Api/AuthScreenBindController.php` to return a -descriptive error on remaining failure cases instead of generic "Key not -accepted". - -**Verify:** -```bash -docker compose run --rm phpfpm composer test -docker compose run --rm phpfpm composer code-analysis -docker compose run --rm phpfpm composer coding-standards-check -``` - -**Changelog:** Fixed: Screens that lost client-side auth can now rebind without manual unbind. Old refresh tokens are cleaned up. - ---- - -### Step 4 — Fix `unbindScreen` refresh token cleanup - -**Branch:** `fix/unbind-cleanup-refresh-tokens` - -**Bug:** `ScreenAuthenticator::unbindScreen()` removes the ScreenUser -entity and flushes. The associated rows in the `refresh_tokens` table — -created by the Gesdinet JWT refresh bundle with the ScreenUser's -identifier in the `username` column — are not touched. - -Every screen accumulates multiple refresh tokens over its lifetime: one -from the initial bind, one from each `/v2/authentication/token/refresh` -call (every 7.5 days by default with the 15-day JWT TTL). Over a year, -one screen generates ~50 refresh token rows. A 200-screen deployment -produces ~10,000 token rows per year. - -When that screen is unbound and rebound (which Step 3 now allows to -succeed), the ScreenUser is recreated with a different identifier, so -the new screen doesn't inherit old tokens. The old tokens remain -queryable: `SELECT * FROM refresh_tokens WHERE username = '...'` -returns data for an entity that no longer exists. Over months of -churn, the `refresh_tokens` table grows without bound. Every token -refresh request scans this growing table; response time degrades. - -A subtler failure mode: if token identifier collisions ever occur (e.g., -a deployment bug reuses IDs, or a Ulid collision, or a deliberate ID -set for testing), the new ScreenUser inherits stale refresh tokens from -a previous life. - -The fix wraps ScreenUser deletion in a transaction that first removes -all `refresh_tokens` rows matching the username. Unbinding leaves no -orphans. - -**RED — write test:** - -File: `tests/Security/ScreenAuthenticatorUnbindTest.php` - -Test that after `unbindScreen()`: -- The ScreenUser is deleted -- All refresh tokens for that ScreenUser are deleted from `refresh_tokens` -- No orphaned rows remain - -Currently fails because `unbindScreen` only deletes the ScreenUser. - -```bash -docker compose run --rm phpfpm composer test -- --filter=ScreenAuthenticatorUnbindTest -``` - -**GREEN — fix:** - -In `src/Security/ScreenAuthenticator.php` `unbindScreen()`, add refresh -token cleanup before removing the ScreenUser. Reuse the -`cleanupScreenUser` helper from Step 3 or inline the logic. - -**Verify:** -```bash -docker compose run --rm phpfpm composer test -docker compose run --rm phpfpm composer code-analysis -docker compose run --rm phpfpm composer coding-standards-check -``` - -**Changelog:** Fixed: Unbinding a screen now removes associated refresh tokens. - ---- - -### Step 5 — Harden `JwtTokenRefreshedSubscriber` TTL correction - -**Branch:** `fix/refresh-token-ttl-resilience` - -**Bug:** When a screen refreshes its JWT via -`/v2/authentication/token/refresh`, the Gesdinet bundle creates a NEW -refresh token with the bundle's default TTL of 2 hours. OS2Display needs -a 30-day TTL to match its operational model. The -`JwtTokenRefreshedSubscriber` listens for Gesdinet's -`RefreshTokenCreated` event, retrieves the freshly-created token via -`$refreshTokenManager->get($tokenString)`, modifies the `valid` field, -and persists it via `$refreshTokenManager->save()`. - -Any exception in this flow silently breaks the refresh token's TTL: - -- `get()` returns null because the token hasn't been committed to the - DB yet (transaction race with the subscriber's read) -- DB connection exception during `get()` or `save()` under load -- Schema mismatch if the `valid` field migration hasn't run in the - current deployment (known deployment hazard when running out of sequence) -- Datetime field edge cases on `valid` - -When any of these triggers, the exception bubbles up — but the HTTP -response to the client is still 200 OK, because the NEW access token -was generated before the subscriber ran. The client happily stores the -new tokens and continues. - -Two hours later, the refresh token silently expires. The next refresh -attempt fails with 401 "Invalid refresh token." The access token -(15-day TTL) is still valid but cannot be renewed. The client enters -the `reauthenticateHandler` flow (Step 6's bug), which wipes tokens -and dead-ends. - -From the operator perspective: "the screen worked yesterday, this morning -it's black." The log shows a 401 from two hours before it went black. -No obvious connection to the earlier successful refresh. - -The fix wraps the TTL upgrade in try/catch. Failure logs a critical -warning (for alerting) but allows the refresh response to succeed. A -2-hour refresh token is degraded but not broken — the next refresh -cycle gets another chance to correct the TTL. Ops has visibility; the -screen keeps running. - -**RED — write test:** - -File: `tests/Security/EventSubscriber/JwtTokenRefreshedSubscriberTest.php` - -Unit test the subscriber directly: -1. Test that when `refreshTokenManager->get()` returns null, the subscriber - does NOT throw — it logs a warning and the response still contains the - token. -2. Test that when `refreshTokenManager->save()` throws a DB error, the - subscriber catches it and the response still succeeds. - -Currently fails because both cases throw uncaught exceptions. - -```bash -docker compose run --rm phpfpm composer test -- --filter=JwtTokenRefreshedSubscriberTest -``` - -**GREEN — fix:** - -In `src/Security/EventSubscriber/JwtTokenRefreshedSubscriber.php`, wrap -the TTL correction in a try/catch. On failure, log a critical warning but -allow the refresh response to succeed with the gesdinet-default TTL: - -```php -try { - $refreshToken = $this->refreshTokenManager->get($refreshTokenString); - if (null === $refreshToken) { - return; - } - // ... existing TTL correction ... - $this->refreshTokenManager->save($refreshToken); -} catch (\Throwable $e) { - // Log critical — the refresh token has the wrong TTL. - // But the JWT refresh itself succeeded, so don't break it. -} -``` - -**Verify:** -```bash -docker compose run --rm phpfpm composer test -docker compose run --rm phpfpm composer code-analysis -docker compose run --rm phpfpm composer coding-standards-check -``` - -**Changelog:** Fixed: Refresh token TTL correction failure no longer crashes the token refresh endpoint. - ---- - -### Step 5b — Read tenant from JWT payload instead of DB in TenantScopedAuthenticator - -**Branch:** `fix/authenticator-tenant-from-jwt` - -**Bug:** `TenantScopedAuthenticator::doAuthenticate()` runs on every -authenticated request. For ScreenUser it calls `$user->getTenants()`, -which returns `new ArrayCollection([$this->getScreen()->getTenant()])`. -Both `getScreen()` and `getTenant()` are lazy-loaded Doctrine proxies; -this single method call triggers two SELECT queries (one against -`screen`, one against `tenant`). The authenticator then iterates the -collection, calls `$user->setActiveTenant()` (which internally calls -`getScreen()->getTenant()` again for validation), and the request -proceeds. - -Downstream, `TenantExtension` — a Doctrine query filter — calls -`$user->getActiveTenant()->getId()` on every query to scope results to -the tenant. ScreenUser's `getActiveTenant()` also chains through -`getScreen()->getTenant()`. For a typical API call that loads 5 entities -with tenant filtering, the request makes 3–5 extra queries just for -tenant resolution, all redundant. - -Under burst load this becomes a connection-pool problem. Common bursts: - -- Mass reboot: 200 screens power-cycle simultaneously (power event, - maintenance window, timed reboot script) -- Morning startup: school or office where screens come online within a - 10-minute window -- Network recovery: screens that were offline all come back at once - -200 screens × 3 queries × 2 seconds to retry on failure = connection -pool saturation. MariaDB's default `max_connections` is 100–200. -Connection waits exceed PHP's 30-second request timeout, requests 500, -clients retry, the cascade amplifies. Screens appear to "fail to boot" -but are actually hitting auth timeouts. - -The JWT payload already contains `tenantKey`, `title`, `description`, -and `roles` per tenant (set by `JWTCreatedListener`). Adding `tenantId` -means the full tenant identity is in the signed token. The authenticator -can validate the tenant key against the JWT claims (no DB), then use -Doctrine's `getReference(Tenant::class, $ulid)` — which returns a proxy -with the ID set but **never initializes it**. Downstream code calls -`$tenant->getId()->toBinary()`, which works directly on the uninitialized -proxy because Doctrine stores the identifier on the proxy object itself. - -Result: zero DB queries during screen authentication. The bottleneck -moves to the actual data queries the kiosk is making, which is the -actual intended workload. - -**RED — write tests:** - -File: `tests/Security/TenantScopedAuthenticatorJwtTest.php` - -1. Bind a screen, get a JWT. Make an API request with the JWT. Assert - 200. Use Doctrine SQL logger to assert **no query touches the - `screen` table** during the request. -2. Assert that the `tenant` table is also NOT queried (only - `getReference` is used). -3. JWT with a `tenantKey` not matching the `Authorization-Tenant-Key` - header returns 401. -4. JWT without a `tenants` claim falls back to the DB path (backward - compatibility with tokens issued before this change). -5. JWT with `tenants` that include `tenantId` — verify `getReference` - is used and tenant `getId()` returns the correct ULID. - -File: `tests/Security/EventListener/JWTCreatedListenerTest.php` - -1. Create a JWT for a ScreenUser. Decode the payload. Assert each - tenant object contains `tenantKey`, `title`, `description`, `roles`, - AND `tenantId`. -2. Create a JWT for a regular User. Assert `tenantId` is present in - each tenant entry. - -```bash -docker compose run --rm phpfpm composer test -- --filter=TenantScopedAuthenticatorJwtTest -docker compose run --rm phpfpm composer test -- --filter=JWTCreatedListenerTest -``` - -**GREEN — implement:** - -**Step A — Add `tenantId` to JWT payload:** - -In `src/Entity/ScreenUser.php` `getUserRoleTenants()`, add the ID: - -```php -public function getUserRoleTenants(): Collection -{ - $tenant = $this->getScreen()->getTenant(); - - $userRoleTenant = new \stdClass(); - $userRoleTenant->tenantKey = $tenant->getTenantKey(); - $userRoleTenant->tenantId = $tenant->getId()?->jsonSerialize(); - $userRoleTenant->title = $tenant->getTitle(); - $userRoleTenant->description = $tenant->getDescription(); - $userRoleTenant->roles = $this->getRoles(); - - return new ArrayCollection([$userRoleTenant]); -} -``` - -In `src/Entity/UserRoleTenant.php` `jsonSerialize()`, add the ID: - -```php -public function jsonSerialize(): array -{ - return [ - 'tenantKey' => $this->getTenant()?->getTenantKey(), - 'tenantId' => $this->getTenant()?->getId()?->jsonSerialize(), - 'title' => $this->getTenant()?->getTitle(), - 'description' => $this->getTenant()?->getDescription(), - 'roles' => $this->getRoles(), - ]; -} -``` - -Now every JWT contains both `tenantKey` and `tenantId` per tenant. - -**Step B — Add `preloadTenant` to ScreenUser:** - -In `src/Entity/ScreenUser.php`: - -```php -private ?Tenant $preloadedTenant = null; - -public function preloadTenant(Tenant $tenant): void -{ - $this->preloadedTenant = $tenant; -} - -public function getActiveTenant(): Tenant -{ - return $this->preloadedTenant ?? $this->getScreen()->getTenant(); -} - -public function setActiveTenant(Tenant $activeTenant): self -{ - if ($this->preloadedTenant !== null) { - // Tenant was resolved from JWT — skip DB validation. - return $this; - } - - if ($this->getScreen()->getTenant() !== $activeTenant) { - throw new \InvalidArgumentException( - 'Active Tenant cannot be set. User does not have access to Tenant: ' - .$activeTenant->getTenantKey() - ); - } - - return $this; -} - -public function getTenants(): Collection -{ - if ($this->preloadedTenant !== null) { - return new ArrayCollection([$this->preloadedTenant]); - } - - return new ArrayCollection([$this->getScreen()->getTenant()]); -} -``` - -**Step C — Update TenantScopedAuthenticator:** - -Update service definition in `config/services.yaml`: - -```yaml -app.tenant_scoped_authenticator: - class: App\Security\TenantScopedAuthenticator - parent: lexik_jwt_authentication.security.jwt_authenticator - calls: - - [setTenantDependencies, ['@doctrine.orm.entity_manager', '@lexik_jwt_authentication.jwt_manager']] -``` - -In `src/Security/TenantScopedAuthenticator.php`: - -```php -use App\Entity\ScreenUser; -use App\Entity\Tenant; -use Doctrine\ORM\EntityManagerInterface; -use Lexik\Bundle\JWTAuthenticationBundle\Services\JWTTokenManagerInterface; -use Symfony\Component\Uid\Ulid; - -class TenantScopedAuthenticator extends JWTAuthenticator -{ - final public const string AUTH_TENANT_ID_HEADER = 'Authorization-Tenant-Key'; - - private ?EntityManagerInterface $entityManager = null; - private ?JWTTokenManagerInterface $jwtManager = null; - - public function setTenantDependencies( - EntityManagerInterface $entityManager, - JWTTokenManagerInterface $jwtManager, - ): void { - $this->entityManager = $entityManager; - $this->jwtManager = $jwtManager; - } - - final public function doAuthenticate(Request $request): Passport - { - $passport = parent::doAuthenticate($request); - $user = $passport->getUser(); - - if (!$user instanceof TenantScopedUserInterface) { - throw new AuthenticationException('User class is not tenant scoped'); - } - - // Fast path for ScreenUser: validate tenant from JWT, use - // getReference() for the entity. Zero DB queries. - if ($user instanceof ScreenUser) { - $resolved = $this->resolveScreenTenantFromJwt($request, $user); - if ($resolved) { - return $passport; - } - } - - // Fallback: original DB path for regular Users and legacy JWTs - // that predate the tenantId claim. - if ($user->getTenants()->isEmpty()) { - throw new AuthenticationException('User has no tenant access'); - } - - if ($request->headers->has(self::AUTH_TENANT_ID_HEADER)) { - $requestTenantKey = $request->headers->get(self::AUTH_TENANT_ID_HEADER); - $hasTenantAccess = false; - - foreach ($user->getTenants() as $tenant) { - if ($tenant->getTenantKey() === $requestTenantKey) { - $user->setActiveTenant($tenant); - $hasTenantAccess = true; - break; - } - } - - if (!$hasTenantAccess) { - throw new AuthenticationException( - 'Unknown tenant key or user has no access to tenant: '.$requestTenantKey - ); - } - } else { - $defaultTenant = $user->getTenants()->first(); - $user->setActiveTenant($defaultTenant); - } - - return $passport; - } - - private function resolveScreenTenantFromJwt(Request $request, ScreenUser $user): bool - { - if (null === $this->jwtManager || null === $this->entityManager) { - return false; - } - - $authHeader = $request->headers->get('Authorization', ''); - $rawToken = str_replace('Bearer ', '', $authHeader); - - if ('' === $rawToken) { - return false; - } - - try { - $payload = $this->jwtManager->parse($rawToken); - } catch (\Throwable) { - return false; - } - - $jwtTenants = $payload['tenants'] ?? []; - if (empty($jwtTenants)) { - return false; - } - - // Find the matching tenant from JWT claims. - $requestTenantKey = $request->headers->get(self::AUTH_TENANT_ID_HEADER); - $matchedTenant = null; - - foreach ($jwtTenants as $jwtTenant) { - $key = is_object($jwtTenant) - ? ($jwtTenant->tenantKey ?? null) - : ($jwtTenant['tenantKey'] ?? null); - $id = is_object($jwtTenant) - ? ($jwtTenant->tenantId ?? null) - : ($jwtTenant['tenantId'] ?? null); - - if (null === $key || null === $id) { - // Legacy JWT without tenantId — fall back to DB path. - return false; - } - - if (null === $requestTenantKey || $key === $requestTenantKey) { - $matchedTenant = ['key' => $key, 'id' => $id]; - break; - } - } - - if (null === $matchedTenant) { - throw new AuthenticationException( - 'Unknown tenant key or user has no access to tenant: '.$requestTenantKey - ); - } - - // getReference() returns a Doctrine proxy with the ID set but - // NEVER queries the database. Downstream code that only calls - // $tenant->getId() (like TenantExtension) works without any - // proxy initialization. - $tenantUlid = Ulid::fromString($matchedTenant['id']); - $tenantRef = $this->entityManager->getReference(Tenant::class, $tenantUlid); - - $user->preloadTenant($tenantRef); - - return true; - } -} -``` - -**Verify:** -```bash -docker compose run --rm phpfpm composer test -docker compose run --rm phpfpm composer code-analysis -docker compose run --rm phpfpm composer coding-standards-check -``` - -**Changelog:** Fixed: Screen authentication validates tenant entirely from the JWT payload using Doctrine `getReference()`. Zero DB queries during authentication. Legacy JWTs without tenant IDs fall back to the original DB path. - ---- - -## Phase 2: Frontend bug fixes - -### Step 6 — Fix `displayFallback` not reset in reauthenticateHandler - -**Branch:** `fix/display-fallback-reset-on-reauth` - -**Bug:** `App.jsx` has three mutually exclusive render conditions, in -priority order: - -1. `displayFallback === true` → render the fallback image (OS2Display logo) -2. `screen != null` (after displayFallback is false) → render the screen content -3. Otherwise → render nothing - -The `` background is black by default in the client CSS. When all -three conditions miss, the DOM has no visible children and the user -sees a pure black screen — the exact failure mode operators report most -often. - -`reauthenticateHandler` fires when the data-sync layer receives a 401. -It calls `tokenService.stopRefreshing()`, `appStorage.clearAuth()` (wipes -localStorage tokens), `setScreen(null)` (clears current content), and -`checkLogin()` (restarts the login polling loop). Nowhere does it -set `displayFallback` back to `true`. - -At the moment reauth begins: `displayFallback` is `false` (set earlier -when content loaded successfully) AND `screen` is `null` (just cleared). -Both conditions miss. Black screen. If `checkLogin` eventually resolves -and new content loads, the state self-corrects. But if login fails — -refresh token expired (Step 5's TTL bug makes this common), backend -briefly unavailable, network issue — the screen stays black indefinitely -with no visible indication that recovery is in progress. - -This bug is the proximate cause of most "screen went black and never -came back" support tickets. The hardware is working, the browser is -running, but the render tree is empty. The fix adds one line — -`setDisplayFallback(true)` at the start of the handler — so users see -the known-good fallback image during reauth, not an ambiguous black -screen. - -**RED — write test:** - -File: `assets/client/components/__tests__/app-reauth.test.jsx` - -Mount `` in test, simulate: -1. Set `displayFallback` to false (via dispatching `contentNotEmpty`) -2. Trigger `reauthenticate` event -3. Mock `tokenService.refreshToken` to reject -4. Assert that the fallback div is rendered after reauth failure - -Currently fails — fallback div is absent after reauth failure. - -```bash -docker compose run --rm node npx vitest run --reporter=verbose -``` - -**GREEN — fix:** - -In `assets/client/app.jsx` `reauthenticateHandler` catch block, add -before `checkLogin()`: -```js -setDisplayFallback(true); -``` - -**Verify:** -```bash -docker compose run --rm node npx vitest run -docker compose run --rm playwright npx playwright test -``` - -**Changelog:** Fixed: Fallback image now shows during auth recovery instead of black screen. - ---- - -### Step 6a — Fix `checkToken` clear-error typo - -**Branch:** `fix/check-token-clear-typo` - -**Bug:** `tokenService.checkToken()` sets error codes based on the JWT's -expire state, then tries to clear them when the token transitions back -to valid: - -```js -} else { - const err = statusService.error; - if ( - err !== null && - [ - constants.TOKEN_EXPIRED, - constants.TOKEN_VALID_SHOULD_HAVE_BEEN_REFRESHED, - ].includes(err) - ) { - statusService.setError(null); - } -} -``` - -`statusService.error` contains the error CODES: `"ER105"` or `"ER106"`. -But the `includes` check compares against the expire STATE names: -`constants.TOKEN_EXPIRED = "Expired"` and -`constants.TOKEN_VALID_SHOULD_HAVE_BEEN_REFRESHED = "ValidShouldHaveBeenRefreshed"`. - -The comparison is `["ER105", "ER106"]` vs `["Expired", -"ValidShouldHaveBeenRefreshed"]`. These never match. The `setError(null)` -line never executes. Once `checkToken` sets ER105 or ER106, those codes -stay in the URL until something else clears them (only `checkLogin` -during a rebind does this reliably). - -Visible symptom: screens that briefly experience token lateness show -`?error=ER106` in the URL permanently, even after the next successful -refresh. Ops teams see the error code and investigate — but the screen -is actually fine and has been for hours. - -This bug predates every other change in the plan and should be fixed -first because: - -1. It's a one-line typo, trivial to fix -2. It affects production NOW, independent of the SW work -3. Step 18's error taxonomy audit assumes clear paths actually work — - this needs to be true before Step 18 runs -4. `token-service.js` is deleted in Step 15, so the fix needs to land - and be validated before Step 15 rewrites the logic in the SW - -**RED — write test:** - -File: `assets/client/service/__tests__/token-service-clear-error.test.js` - -1. Manually set `statusService.error = constants.ERROR_TOKEN_EXPIRED` - (ER105). -2. Set up a valid, non-expired token in `appStorage`. -3. Call `tokenService.checkToken()`. -4. Assert `statusService.error === null` after the call. - -This test currently fails — the bug keeps the error set. - -Add a second test for ER106 symmetry: - -1. Set `statusService.error = constants.ERROR_TOKEN_VALID_SHOULD_HAVE_BEEN_REFRESHED` -2. Valid fresh token in appStorage. -3. Call `checkToken()`. -4. Assert `statusService.error === null`. - -```bash -docker compose run --rm node npx vitest run --reporter=verbose -``` - -**GREEN — fix:** - -In `assets/client/service/token-service.js` `checkToken`, change: - -```diff -- [ -- constants.TOKEN_EXPIRED, -- constants.TOKEN_VALID_SHOULD_HAVE_BEEN_REFRESHED, -- ].includes(err) -+ [ -+ constants.ERROR_TOKEN_EXPIRED, -+ constants.ERROR_TOKEN_VALID_SHOULD_HAVE_BEEN_REFRESHED, -+ ].includes(err) -``` - -**Verify:** -```bash -docker compose run --rm node npx vitest run -``` - -**Changelog:** Fixed: Token error codes ER105 and ER106 now clear from the URL when the token state returns to valid. Previous comparison used state names instead of error codes, silently never matching. - ---- - -### Step 7 — Fix `checkLogin` dead-end on unknown status - -**Branch:** `fix/check-login-unknown-status-retry` - -**Bug:** `checkLogin()` is the boot-time polling loop that asks the -backend "has an admin claimed my bind key yet?" It calls -`tokenService.checkLogin()` which returns an object with a `status` -field. The current implementation handles two cases explicitly: - -- `'ready'` — admin has bound the screen; store tokens, start content -- `'awaitingBindKey'` — not yet; schedule another `checkLogin` in 5s - -Any other value falls through with no action. The Promise resolves, no -retry is scheduled, the polling loop stops. The screen sits waiting for -an event that never fires. - -Unknown statuses can arise from a surprising number of sources: -- Backend returning a new status value after a protocol update (version - skew between client and API during rolling deployment) -- Malformed JSON from a misconfigured reverse proxy or cache layer -- Response body truncation from network middleware -- HTTP 200 with empty body from a Redis cache edge case -- Typo in a status string on either side - -When it happens, the screen shows the bind key but never transitions to -content — even after the admin successfully binds. From the admin side, -the bind appears to fail (no visible state change on the screen). They -re-bind, which now works because Step 3 allows rebinding, but the new -bind cycle also dead-ends on the same unknown status. - -The fix adds an `else` branch that retries `checkLogin` on any -unexpected status. Unknown becomes transient rather than permanent. -When the backend resolves (protocol mismatch fixed, cache flushed, -etc.), the screen self-heals on the next poll. - -**RED — write test:** - -File: `assets/client/components/__tests__/app-login.test.jsx` - -Mock `tokenService.checkLogin` to resolve with -`{ status: 'somethingUnexpected' }`. Assert that `restartLoginTimeout` is -called (login polling continues). - -Currently fails — no action taken, no retry scheduled. - -```bash -docker compose run --rm node npx vitest run --reporter=verbose -``` - -**GREEN — fix:** - -In `assets/client/app.jsx` `checkLogin`, add an else clause: -```js -} else { - restartLoginTimeout(); -} -``` - -**Verify:** -```bash -docker compose run --rm node npx vitest run -``` - -**Changelog:** Fixed: Login polling no longer stops on unexpected status responses. - ---- - -### Step 8 — Fix `screenHandler` undefined guard - -**Branch:** `fix/screen-handler-undefined-guard` - -**Bug:** `screenHandler` listens for the custom `screen` event -dispatched by the data-sync layer. Events are created with -`new CustomEvent('screen', { detail: { screen: screenData } })` and the -handler reads `event.detail.screen`. The existing guard: - -```js -if (screenData !== null) { - setScreen(screenData); -} -``` - -This catches explicit `null` but not `undefined`. When an event is -dispatched without the `screen` key (or with `detail: {}`), -`event.detail.screen` is `undefined`. `undefined !== null` evaluates to -`true`. `setScreen(undefined)` executes. The `screen` state becomes -`undefined`, which is falsy in the app's render check. - -Combined with `displayFallback = false` (from an earlier successful -render), both render conditions fail — we're back at the Step 6 black -screen, from a different entry point. - -Dispatch sites that omit the screen key include certain error paths in -the data-sync layer where a screen-switch cycle begins but the new -screen data hasn't finished loading. The old screen is cleared, the -new screen event fires without data, then the fetch completes and fires -another event with the real data — but between those two events, the -screen is black. - -The fix changes `!==` to `!=`. The loose comparison catches both `null` -and `undefined` because `null == undefined` is true by spec. This is -one of the few cases where `!=` is preferred over `!==`: it's -specifically designed as a nullish check. The linter should be -configured to allow it here. - -**RED — write test:** - -File: `assets/client/components/__tests__/app-screen-handler.test.jsx` - -Dispatch a `screen` event with `detail: {}` (no `screen` property). -Assert that `screen` state is NOT set to `undefined`. - -Currently fails — `undefined !== null` passes the guard and `setScreen(undefined)` is called. - -```bash -docker compose run --rm node npx vitest run --reporter=verbose -``` - -**GREEN — fix:** - -In `assets/client/app.jsx` `screenHandler`, change: -```js -if (screenData !== null) { -``` -To: -```js -if (screenData != null) { -``` - -This catches both `null` and `undefined`. - -**Verify:** -```bash -docker compose run --rm node npx vitest run -``` - -**Changelog:** Fixed: Screen handler no longer accepts undefined screen data. - ---- - -### Step 9 — Fix `PullStrategy.start()` missing error handling - -**Branch:** `fix/pull-strategy-start-catch` - -**Bug:** `PullStrategy.start()` is the entry point for the client's -content polling loop. It fetches the screen's data, then registers a -`setInterval` to re-fetch every 90 seconds: - -```js -start() { - this.getScreen(this.entryPoint).then(() => { - this.activeInterval = setInterval( - () => this.getScreen(this.entryPoint), - this.interval, - ); - }); -} -``` - -The interval registration lives inside `.then()`. If the initial -`getScreen` rejects — for any reason, including transient ones — -`.then()` never fires. The interval is never created. The promise -rejection bubbles up as an unhandled rejection. - -From that moment, the screen has no content polling. No amount of time -passing, no backend recovery, no network restoration will trigger a -retry. `start()` was called once at boot and will not be called again -without a full page reload. - -The most common trigger is boot-time timing collisions: the screen -boots while the backend is warming up after a deployment, the initial -fetch times out, the polling loop never starts. Another common cause is -a screen booting during a brief network outage — content loads fine -moments later, but the client doesn't know to try again. - -The fix uses `.catch()` to log the error and `.finally()` to -unconditionally register the interval. Initial fetch failure is -survivable: the interval still runs, and the next tick (90 seconds -later) retries. A screen that boots during a backend restart recovers -within two minutes with no manual intervention. - -**RED — write test:** - -File: `assets/client/data-sync/__tests__/pull-strategy.test.js` - -Create a PullStrategy with a mock `apiHelper` that throws on `getPath`. -Call `start()`. Assert that: -- The error is caught (no unhandled rejection) -- The periodic interval is created (recovery continues) - -Currently fails — `.then()` never fires, interval never starts. - -```bash -docker compose run --rm node npx vitest run --reporter=verbose -``` - -**GREEN — fix:** - -In `assets/client/data-sync/pull-strategy.js` `start()`: -```js -start() { - this.getScreen(this.entryPoint) - .catch((err) => { - logger.error("Initial getScreen failed:", err); - }) - .finally(() => { - this.stop(); - this.activeInterval = setInterval( - () => this.getScreen(this.entryPoint), - this.interval, - ); - }); -} -``` - -**Verify:** -```bash -docker compose run --rm node npx vitest run -``` - -**Changelog:** Fixed: Pull strategy interval now starts even if the initial fetch fails. - ---- - -### Step 10 — Fix `findNextSlide` with empty array - -**Branch:** `fix/find-next-slide-empty-array` - -**Bug:** `region.jsx` advances through its slides using modular -arithmetic. When a slide completes, `slideDone` computes the next -index: - -```js -const nextIndex = (currentSlideIndex + 1) % slides.length; -const nextSlide = slides[nextIndex]; -setCurrentSlide(nextSlide); -``` - -This works as long as `slides.length > 0`. With an empty array, -JavaScript evaluates `0 % 0` as `NaN`. Array indexing with `NaN` -returns `undefined`. `setCurrentSlide(undefined)` runs. The Slide -component receives undefined props and throws on the next render -(typically "Cannot read properties of undefined (reading 'type')" or -similar, depending on the slide template). - -The region-level ErrorBoundary catches the throw. Before Step 11b, -the region stays in the error state forever — one dead region on an -otherwise working screen. After Step 11b, the region auto-reloads -after 30 seconds, but that's treating the symptom. - -Empty slide arrays can occur in legitimate scenarios: - -- Schedule gap: a region's playlist has slides scheduled for specific - time windows; between windows, the effective slide list is empty -- All scheduled slides have already passed (admin configuration error, - or NTP drift on the screen pushing "now" past the schedule end) -- Backend returns empty slides during a transitional state - -The fix adds an early return in `slideDone` when `slides` is empty or -undefined. Log a warning (so ops can investigate). The region keeps -its current state (last slide, or blank) until new slides arrive from -the next data-sync cycle. No ErrorBoundary trigger, no auto-reload, -no visible error to the end user. - -**RED — write test:** - -File: `assets/client/components/__tests__/region-empty-slides.test.jsx` - -Render a `` with an initial slide, then dispatch -`regionContent-{id}` with an empty slides array. After the current slide -calls `slideDone`, assert that no exception is thrown. - -Currently produces NaN from `0 % 0`. - -```bash -docker compose run --rm node npx vitest run --reporter=verbose -``` - -**GREEN — fix:** - -In `assets/client/components/region.jsx` `slideDone`, add an early return: -```js -const slideDone = (slide) => { - if (!slides || slides.length === 0) { - logger.warn("slideDone called with empty slides array"); - return; - } - // ... rest of existing logic -}; -``` - -**Verify:** -```bash -docker compose run --rm node npx vitest run -``` - -**Changelog:** Fixed: Slide progression no longer crashes when the slide array is empty. - ---- - -### Step 11 — Add top-level ErrorBoundary - -**Branch:** `fix/top-level-error-boundary` - -**Bug:** `index.jsx` renders `` directly with no error boundary -above it. The existing ErrorBoundary component is applied inside App -at the region and slide levels, but nothing catches errors in App -itself. - -React's error handling semantics: if a component throws during render -and no ancestor ErrorBoundary exists, React unmounts the entire -component tree. The root div becomes empty. With the `` CSS -background set to black, the user sees a black screen — the same -visible symptom as the unprotected cases in Steps 6 and 8, but from a -different cause and with worse consequences: the whole React app is -gone, not just one region. - -App-level crashes originate from a narrow set of places — state -initialization, context provider setup, the top-level useEffect hooks -handling auth and boot — but the blast radius when they happen is -total. A single uncaught throw in any of App's initialization paths -kills the page. - -Observed triggers: - -- Corrupted state in localStorage causing App's initial read to throw -- A context provider throwing during construction (upstream library - bugs) -- Race conditions during fast screen-switch events unmounting App - mid-lifecycle -- Third-party library errors (Sentry, logger) during App init - -Without a top-level boundary, the SW watchdog (Step 15) is the only -recovery mechanism, but that requires a 30-second timeout. 30 seconds -of black screen is a long time on a public display. - -The fix wraps `` in an ErrorBoundary with `reloadTimeout={15000}`. -React catches the error, renders the fallback UI briefly, then reloads -the page. The hardcoded 15000 is a boot-time fallback (config isn't -loaded yet at this outer boundary — Step 11a's config applies only to -inner boundaries). Recovery happens automatically in under 15 seconds, -well before the SW watchdog would need to intervene. - -**RED — write test:** - -File: `assets/client/__tests__/index-error-boundary.test.jsx` - -Render `` wrapped in ``. -Make App throw during render. Assert: -- The error is caught (ErrorBoundary fallback UI renders) -- After timeout, `window.location.reload` is called - -Currently fails — App crash unmounts the entire React tree. - -```bash -docker compose run --rm node npx vitest run --reporter=verbose -``` - -**GREEN — fix:** - -In `assets/client/index.jsx`, wrap `` with ``: -```jsx -import ErrorBoundary from "./components/error-boundary.jsx"; - -root.render( - - - -); -``` - -Note: the hardcoded 15000 is a boot-time fallback. Once `App` mounts -and loads config, the actual `appErrorReloadTimeout` from the config -is used for the region and inner ErrorBoundaries. The top-level -boundary wraps `App` itself so config isn't available yet — the fallback -is acceptable here. - -Update `assets/client/components/error-boundary.jsx` to support -`reloadTimeout` prop — schedule `window.location.reload()` after -timeout, clear timer in `componentWillUnmount`. - -**Verify:** -```bash -docker compose run --rm node npx vitest run -docker compose run --rm playwright npx playwright test -``` - -**Changelog:** Added: Top-level error boundary with auto-reload recovers from unhandled React crashes. - ---- - -### Step 11a — Add error recovery timeout to client config - -**Branch:** `feat/error-recovery-config` - -**Motivation:** Steps 11, 11b, 11c, and 15 all introduce timeouts: -watchdog, ErrorBoundary reload, crash-loop window, backoff duration, -heartbeat interval, global error reload delay. Each has a sensible -default, but "sensible" depends on hardware and content. - -On modern kiosk hardware with lightweight templates, a 30-second -watchdog is generous — the main thread shouldn't block for more than a -few hundred milliseconds. On a 2016-vintage ARM kiosk running a heavy -canvas-based weather template at 4K resolution, a single frame render -can exceed 5 seconds, and paint under memory pressure can stall for -15 seconds. The same 30-second watchdog that's generous on new -hardware is aggressive on old hardware, causing false watchdog kills -during legitimate slow renders. - -Exposing 8 separate timeouts to operators (watchdog, heartbeat, -region-reload, app-reload, global-error-delay, crash-max, crash-window, -crash-backoff) is too much surface area. Operators don't have the -information to tune each one independently — they think in terms of -"how long before recovery kicks in." One value that everything derives -from matches that mental model: - -``` -CLIENT_ERROR_RECOVERY_TIMEOUT=30000 # default - ↓ -heartbeatInterval: t / 6 (5s) -globalErrorReloadDelay: t / 3 (10s) -appErrorReloadTimeout: t / 2 (15s) -regionErrorReloadTimeout: t (30s) -watchdogTimeout: t (30s) -crashGuardWindow: t * 2 (60s) -crashGuardBackoff: t * 4 (120s) -``` - -A heavy-kiosk operator sets `CLIENT_ERROR_RECOVERY_TIMEOUT=120000` and -every timeout scales proportionally: 20s heartbeats, 60s app reload, -120s watchdog, 240s crash window, 8-minute backoff. No false triggers -from slow renders; if something genuinely breaks, recovery still -happens within a few minutes. - -The compiler pass (Step 12) hashes this env var into the -`X-OS2Display-Config-Hash` response header, so the SW detects when it -changes and re-fetches config without a deploy. - -**RED — write test:** - -File: `tests/Controller/Client/ClientConfigControllerTest.php` - -1. GET `/config/client`. Assert response contains `errorRecoveryTimeout` - with default value `30000`. -2. Override env var `CLIENT_ERROR_RECOVERY_TIMEOUT=120000`, GET - `/config/client`, assert the value is reflected. - -File: `assets/client/util/__tests__/error-recovery-timeouts.test.js` - -1. `deriveTimeouts(30000)` returns the expected ratios (heartbeat=5000, - appReload=15000, watchdog=30000, crashWindow=60000, etc.). -2. `deriveTimeouts(120000)` scales everything proportionally. -3. `deriveTimeouts(undefined)` uses the default 30000. - -```bash -docker compose run --rm phpfpm composer test -- --filter=ClientConfigControllerTest -docker compose run --rm node npx vitest run -``` - -**GREEN — implement:** - -Add env var to `.env`: - -```env -CLIENT_ERROR_RECOVERY_TIMEOUT=30000 -``` - -Update `src/Controller/Client/ClientConfigController.php` — add one -constructor argument and one response key: - -```php -public function __construct( - // ... existing params ... - private readonly int $errorRecoveryTimeout, -) {} - -public function __invoke(): Response -{ - return new JsonResponse([ - // ... existing keys ... - 'errorRecoveryTimeout' => $this->errorRecoveryTimeout, - ]); -} -``` - -Update `config/services.yaml` bindings: - -```yaml -App\Controller\Client\ClientConfigController: - arguments: - # ... existing args ... - $errorRecoveryTimeout: '%env(int:CLIENT_ERROR_RECOVERY_TIMEOUT)%' -``` - -Create `assets/client/util/error-recovery-timeouts.js` — the single -function subsequent steps use: - -```js -/** - * Derive all error recovery timeouts from one operator-facing value. - * - * Mental model: "how long before recovery kicks in" = t. - * All downstream timeouts scale proportionally: - * - heartbeat 6x faster than t (granularity) - * - app reload at t/2 (fast for whole-app crashes) - * - region reload at t (slower — other regions still work) - * - watchdog at t (matches region) - * - crash window 2t, backoff 4t (loop prevention scale) - */ -export function deriveTimeouts(errorRecoveryTimeout = 30000) { - const t = errorRecoveryTimeout; - return { - heartbeatInterval: Math.round(t / 6), - globalErrorReloadDelay: Math.round(t / 3), - appErrorReloadTimeout: Math.round(t / 2), - regionErrorReloadTimeout: t, - watchdogTimeout: t, - crashGuardWindow: t * 2, - crashGuardBackoff: t * 4, - crashGuardMaxCrashes: 5, // fixed — not time-based - }; -} -``` - -Update `src/DependencyInjection/Compiler/ApiResponseHeaderPass.php` to -include `CLIENT_ERROR_RECOVERY_TIMEOUT` in the config hash so the SW -detects when it changes. - -**Verify:** -```bash -docker compose run --rm phpfpm composer test -docker compose run --rm phpfpm composer code-analysis -docker compose run --rm phpfpm composer coding-standards-check -``` - -**Changelog:** Added: `CLIENT_ERROR_RECOVERY_TIMEOUT` env var controls all error recovery timeouts. Downstream timeouts (heartbeat, watchdog, ErrorBoundary reload, crash-guard window/backoff) derived from this single value as ratios. Operators scale the whole recovery system with one number. - ---- - -### Step 11b — Region ErrorBoundary with auto-recovery - -**Branch:** `fix/region-error-boundary-reload` - -**Motivation:** The existing ErrorBoundary component catches render -errors in the region tree and displays a fallback error UI. This is the -right default for a desktop app where a developer sees the error -boundary output and fixes the code. On an unattended 24/7 kiosk, a -permanent error UI is a dead region — the rest of the screen continues -running, but that portion is lost until a human intervenes. - -For a customer deployment where screens show information to the -public (transit signs, wayfinding, cafeteria menus), a region -permanently stuck in "Something went wrong" is worse than useless — -it's actively misleading. Worse, region errors usually indicate -transient issues: a malformed slide payload, a CDN timeout for a -media asset, a template JS error triggered by an edge-case prop. The -next data-sync cycle (90 seconds later) usually has the correct data, -but ErrorBoundaries don't retry rendering — they stay in the error -state until something forces React to remount. - -Adding a `reloadTimeout` prop to ErrorBoundary gives operators a simple -recovery primitive. Region-level boundaries get a 30-second timeout -(matches `errorRecoveryTimeout`). If a region's render fails, the error -UI shows briefly, then the page reloads. A fresh boot pulls fresh data; -if the error was transient, the screen self-heals. If the error is -persistent, it re-triggers on the next boot — and the crash-loop guard -(Step 11c) catches that pattern and stops the reload cycle. - -This step preserves backward compatibility: `` without -`reloadTimeout` keeps the original "display the error forever" behavior. -Only region.jsx passes the timeout. Callers opting into auto-recovery -do so explicitly. - -**RED — write test:** - -File: `assets/client/components/__tests__/error-boundary-reload.test.jsx` - -1. Render `` around a component that - throws. Assert `window.location.reload` is called after ~1000ms. -2. Render `` (no reloadTimeout) around a throwing component. - Assert `window.location.reload` is NOT called (existing behavior preserved). -3. Render ``, trigger error, then unmount - before timeout. Assert `window.location.reload` is NOT called (timer - cleared on unmount). - -```bash -docker compose run --rm node npx vitest run --reporter=verbose -``` - -**GREEN — fix:** - -In `assets/client/components/error-boundary.jsx`: - -```js -componentDidCatch(error, errorInfo) { - // ... existing logging and errorHandler call ... - - const { reloadTimeout = null } = this.props; - if (reloadTimeout !== null) { - this.reloadTimer = setTimeout(() => { - window.location.reload(); - }, reloadTimeout); - } -} - -componentWillUnmount() { - if (this.reloadTimer) { - clearTimeout(this.reloadTimer); - } -} -``` - -In `assets/client/components/region.jsx`, pass the timeout from config -(loaded in `App` and passed via context or prop, or read from the config -loader directly): - -```diff -- -+ -``` - -The default of 30000 matches the `.env` default. Operators can increase -it for deployments with heavy templates that might trigger longer renders. - -**Verify:** -```bash -docker compose run --rm node npx vitest run -docker compose run --rm playwright npx playwright test -``` - -**Changelog:** Added: Region ErrorBoundary auto-reloads the page after 30 seconds if a region crashes, preventing permanent broken regions on kiosk screens. - ---- - -### Step 11c — Global error handlers with crash-loop guard - -**Branch:** `fix/global-error-handlers-crash-guard` - -**Motivation:** React's ErrorBoundary catches errors during render, -commitPhase, and constructors. It does NOT catch: - -- Errors in event handlers (onClick, onChange, etc.) -- Errors in setTimeout/setInterval callbacks -- Errors in requestAnimationFrame callbacks -- Unhandled Promise rejections -- Errors in the data-sync layer (which lives outside the React tree) -- Errors in fetch callbacks -- Errors thrown by addEventListener callbacks the app attached - -In the OS2Display client, most runtime work happens in these excluded -zones: the pull-strategy runs setTimeout chains, tokenService does fetch -callbacks, the logger subscribes to event dispatches, template slides -use animation frames for transitions. An error in any of these is -completely invisible to ErrorBoundaries. It logs to console (maybe) and -the app silently degrades or hangs. - -`window.onerror` and `window.addEventListener('unhandledrejection')` -catch everything the React boundaries miss. They're the last-resort -safety net. Without them, a kiosk that hits an async error just... -stops working, with no recovery path short of a SW watchdog timeout -(30+ seconds at best). - -But global handlers alone aren't enough. They need a crash-loop guard. -Consider the failure mode: a persistent bug — corrupted localStorage -state, a bad backend response the client can't handle, a template that -always throws — crashes the app immediately on every boot. Without a -guard, the app starts → crashes → reloads → crashes → reloads in a -tight loop. Hundreds of reloads per minute. Battery drain on battery- -backed hardware. Backend flood from the reboot bursts. Log file -saturation. Eventually hardware thermal throttling. - -The guard tracks crash count in a rolling window. N crashes within -window = crash loop detected. Suppress reloads for a backoff period -(long enough that whatever's wrong has time to recover — backend -restart finishing, network clearing, cache expiring). After backoff, -try again. If still broken, extended backoff. If recovered, the counter -resets on the next successful boot. - -Using localStorage for the guard state is acceptable here because: -1. The guard runs before the SW is registered (boot-time), so IndexedDB - async access is awkward -2. Its state is tiny (3 numbers) -3. The consequences of storage loss are benign (counter resets, guard - lets the next crash through) - -Step 15's SW-side crash guard supersedes this one — moving state to -IndexedDB where the SW can also read it, enabling cross-thread -coordination (SW watchdog reloads also count against the guard). - -**RED — write test:** - -File: `assets/client/util/__tests__/crash-guard.test.js` - -1. Call `crashGuard.recordCrash()` once. Assert `shouldReload()` returns true. -2. Call `recordCrash()` 5 times within 60 seconds. Assert `shouldReload()` - returns false (crash-loop detected — back off). -3. Call `recordCrash()` 5 times, wait for the backoff window to expire, call - again. Assert `shouldReload()` returns true (loop broken). -4. Call `crashGuard.reset()`. Assert crash count is zero. - -File: `assets/client/util/__tests__/global-error-handlers.test.js` - -1. Dispatch a `window.onerror` event. Assert the crash guard is consulted. -2. Dispatch an `unhandledrejection` event. Assert the crash guard is consulted. -3. When `shouldReload()` returns true, assert `window.location.reload` is called - after a short delay. -4. When `shouldReload()` returns false (crash loop), assert reload is NOT called. - -```bash -docker compose run --rm node npx vitest run --reporter=verbose -``` - -**GREEN — implement:** - -Create `assets/client/util/crash-guard.js`: - -```js -const STORAGE_KEY = 'os2display_crash_guard'; - -// Defaults match .env — overridden after config loads. -let maxCrashes = 5; -let crashWindow = 60_000; -let backoffDuration = 120_000; - -function getState() { - try { - const raw = localStorage.getItem(STORAGE_KEY); - return raw ? JSON.parse(raw) : { count: 0, firstCrash: null, backoffUntil: null }; - } catch { - return { count: 0, firstCrash: null, backoffUntil: null }; - } -} - -function saveState(state) { - try { localStorage.setItem(STORAGE_KEY, JSON.stringify(state)); } catch {} -} - -const crashGuard = { - /** Call after config loads to override defaults. */ - configure({ crashGuardMaxCrashes, crashGuardWindow, crashGuardBackoff }) { - if (crashGuardMaxCrashes) maxCrashes = crashGuardMaxCrashes; - if (crashGuardWindow) crashWindow = crashGuardWindow; - if (crashGuardBackoff) backoffDuration = crashGuardBackoff; - }, - - recordCrash() { - const now = Date.now(); - const state = getState(); - - if (!state.firstCrash || now - state.firstCrash > crashWindow) { - state.count = 1; - state.firstCrash = now; - } else { - state.count++; - } - - if (state.count >= maxCrashes) { - state.backoffUntil = now + backoffDuration; - } - - saveState(state); - }, - - shouldReload() { - const state = getState(); - if (state.backoffUntil && Date.now() < state.backoffUntil) { - return false; - } - return true; - }, - - reset() { - try { localStorage.removeItem(STORAGE_KEY); } catch {} - }, -}; - -export default crashGuard; -``` - -Create `assets/client/util/global-error-handlers.js`: - -```js -import logger from '../logger/logger'; -import crashGuard from './crash-guard'; - -let reloadDelay = 10_000; // Default — overridden by configure(). - -export function installGlobalErrorHandlers() { - window.addEventListener('error', (event) => { - logger.error('[global] Uncaught error:', event.error); - handleFatalError(); - }); - - window.addEventListener('unhandledrejection', (event) => { - logger.error('[global] Unhandled rejection:', event.reason); - handleFatalError(); - }); -} - -/** Call after config loads to override the default reload delay. */ -export function configureGlobalErrorHandlers({ globalErrorReloadDelay }) { - if (globalErrorReloadDelay) reloadDelay = globalErrorReloadDelay; -} - -function handleFatalError() { - crashGuard.recordCrash(); - - if (crashGuard.shouldReload()) { - setTimeout(() => window.location.reload(), reloadDelay); - } else { - logger.warn('[global] Crash loop detected — suppressing reload'); - } -} -``` - -Wire into `assets/client/app.jsx` useEffect: - -```js -import { installGlobalErrorHandlers, configureGlobalErrorHandlers } from './util/global-error-handlers'; -import crashGuard from './util/crash-guard'; - -useEffect(() => { - installGlobalErrorHandlers(); - crashGuard.reset(); // Successful boot — reset the counter. - - ClientConfigLoader.loadConfig().then((config) => { - // Push config values to error recovery modules. - crashGuard.configure(config); - configureGlobalErrorHandlers(config); - // ... existing config handling ... - }); -}, []); -``` - -The `reset()` on successful boot breaks the crash-loop: if the app -managed to mount and start running, the previous crashes are forgiven. - -Later in Step 15, the crash-loop guard will be enhanced to live in the -SW (survives main-thread death) using IndexedDB instead of localStorage. - -**Verify:** -```bash -docker compose run --rm node npx vitest run -docker compose run --rm playwright npx playwright test -``` - -**Changelog:** Added: Global `window.onerror` and `unhandledrejection` handlers with crash-loop guard. Catches non-React errors and auto-reloads with backoff protection. - ---- - -## Phase 3: Backend improvements - -### Step 12 — Add API response headers via compiler pass - -**Branch:** `feat/api-response-headers` - -**Motivation:** The current client has two polling loops that have -nothing to do with actual content: - -- `release.json` every 10 minutes — checks whether a new client build - has been deployed so the browser can reload -- `/config/client` every 15 minutes — checks whether the backend's - client config has changed - -For a 200-screen deployment, that's 200 × 6 requests/hour for release -checking + 200 × 4 requests/hour for config checking = 2000 wasted -requests per hour. Each request costs a TCP handshake, TLS negotiation, -HTTP round trip, PHP-FPM process time, and a DB query (in the config -endpoint's case). All to discover "nothing changed" 99.9% of the time. - -Worse, the release-check loop has a race condition. When it detects a -new build, it calls `window.location.replace()` to reload — but the -detection sits inside a `.finally()` that also triggers `checkLogin()` -as part of an unrelated path. The race: `.finally()` fires, `checkLogin()` -starts API calls, those API calls 401 because the deploy rotated some -keys, `reauthenticateHandler` wipes localStorage — AFTER the browser -has initiated the redirect but BEFORE navigation completes. The page -reloads with no auth, the client goes to the bind screen. - -The cleaner model: the client is already making API calls for content -(every 90 seconds via pull-strategy). Those responses can carry -lightweight metadata telling the client when release or config -changes. Three headers: - -- `X-OS2Display-Release-Timestamp` — unix timestamp of the current - build, set at deploy time via env var -- `X-OS2Display-Release-Version` — the version string, for logging -- `X-OS2Display-Config-Hash` — 8-char hex hash of all client config - env vars, computed at `cache:warmup` - -Headers ride on every existing `/v2/` response. Zero new requests. -Detection happens at the cadence of existing traffic (90 seconds), -which is faster than the old 10-minute release check anyway. - -Computing these values at runtime would mean reading env vars on every -response — cheap but not free, and the config hash would need -re-computation. The compiler pass computes all three values ONCE during -`cache:warmup` and bakes them into the compiled container as plain -string parameters. The subscriber injects the headers with a simple -`$response->headers->set()` call. Zero computation overhead at runtime. - -Step 16 wires the SW to read these headers and notify the main thread. -Step 17 removes the old polling loops entirely. - -**RED — write tests:** - -File: `tests/EventSubscriber/ApiResponseHeaderSubscriberTest.php` - -1. GET request to any `/v2/` endpoint returns all three `X-OS2Display-*` headers. -2. Request to `/admin` does NOT contain the headers. -3. `X-OS2Display-Config-Hash` is a valid 8-char hex string. -4. Hash changes when a config env var changes (boot two kernels, compare). - -File: `tests/DependencyInjection/Compiler/ApiResponseHeaderPassTest.php` - -1. Compiler pass sets three `app.api_header.*` container parameters. -2. Config hash is deterministic (same input → same output). - -```bash -docker compose run --rm phpfpm composer test -- --filter=ApiResponseHeader -``` - -**GREEN — implement:** - -1. Create `src/DependencyInjection/Compiler/ApiResponseHeaderPass.php` -2. Create `src/EventSubscriber/ApiResponseHeaderSubscriber.php` -3. Register pass in `src/Kernel.php` via `build()` method -4. Add service definition in `config/services.yaml` -5. Add `APP_RELEASE_TIMESTAMP=0` and `APP_RELEASE_VERSION=""` to `.env` - and `.env.test` -6. Update `expose_headers` in `config/packages/nelmio_cors.yaml` - -**Verify:** -```bash -docker compose run --rm phpfpm composer test -docker compose run --rm phpfpm composer code-analysis -docker compose run --rm phpfpm composer coding-standards-check -``` - -**Changelog:** Added: API responses include `X-OS2Display-Release-Timestamp`, `X-OS2Display-Release-Version`, and `X-OS2Display-Config-Hash` headers. Values are computed once at cache warmup via a compiler pass. - ---- - -### Step 13 — Move PHP sessions to Redis - -**Branch:** `feat/redis-sessions` - -**Bug:** Symfony uses the default PHP session handler, which stores -session data as files in the container's filesystem (typically under -`/tmp/sess_*`). Session files are created on first use, read on -subsequent requests bearing the session cookie, and garbage-collected -after their TTL. - -Container filesystems are ephemeral in every modern deployment strategy. -Any of the following events deletes the session files: - -- Container restart (new deployment, OOM kill, failed health check, - node drain) -- Container replacement (horizontal scaling event, rolling update) -- Manual recreation for any reason -- Kubernetes pod eviction - -Sessions are critical to the screen binding flow. The sequence: - -1. Unbound screen POSTs to `/v2/authentication/screen` -2. Backend creates a session, stores a bind key in it, returns the key - and sets a session cookie on the response -3. Screen displays the bind key, polls `GET /v2/authentication/screen/status` - every few seconds using the session cookie -4. Admin enters the bind key in the UI -5. Backend writes tokens to the cache against the session's bind key -6. Screen's next poll retrieves the tokens (Step 2's cache issue is a - separate concern) - -If the container restarts between steps 1 and 6 — common during any -deployment — the session file is gone. The screen's polling requests -arrive with a cookie for a session that no longer exists. Backend -treats this as "new session" and creates a fresh one with no bind key. -The screen's polling receives a state that doesn't match what it was -doing moments ago. - -In multi-replica deployments (behind a load balancer), the problem is -worse: session files are local to one container, but different requests -from the same screen may hit different containers. Without session -affinity or shared storage, binding is essentially random. - -The fix routes sessions through Redis via -`handler_id: '%env(REDIS_CACHE_DSN)%'`. Sessions survive container -restarts, work correctly across multiple backend replicas, and require -no new infrastructure — Redis is already in the stack for caching. - -**RED — write test:** - -File: `tests/Security/SessionPersistenceTest.php` - -1. Create screen client, call `POST /v2/authentication/screen`, get bind key. -2. Clear filesystem session storage directory. -3. Same request with same session cookie. -4. Assert bind key is unchanged. - -Currently fails because filesystem sessions are lost. - -Note: the test environment uses `session.storage.factory.mock_file` per -`config/packages/test/framework.yaml` — the test may need an override -or a custom test kernel to exercise Redis sessions. - -```bash -docker compose run --rm phpfpm composer test -- --filter=SessionPersistenceTest -``` - -**GREEN — fix:** - -In `config/packages/framework.yaml`, change: -```yaml -session: - handler_id: '%env(REDIS_CACHE_DSN)%' - cookie_secure: auto - cookie_samesite: lax -``` - -Ensure the `redis` service is in `docker-compose.override.yml` (already -present). - -**Verify:** -```bash -docker compose run --rm phpfpm composer test -docker compose run --rm phpfpm composer code-analysis -docker compose run --rm phpfpm composer coding-standards-check -``` - -**Changelog:** Changed: PHP sessions now stored in Redis to survive container restarts. - ---- - -## Phase 4: Frontend improvements - -### Step 14 — Add IndexedDB auth store (auth-bridge) - -**Branch:** `feat/auth-bridge-indexeddb` - -**Motivation:** The client currently stores all auth state — JWT, -refresh token, screen ID, tenant key — in localStorage. localStorage is -convenient but has properties that hurt kiosk deployments: - -- **Main-thread only.** Service workers cannot access localStorage. - Step 15's SW takes over token refresh and auth injection, which - requires the SW to read and write auth state directly. With - localStorage, the SW would need to postMessage the main thread for - every operation — slow, race-prone, and impossible during main-thread - crashes (the exact scenario the SW watchdog is designed for). - -- **Evictable under memory pressure.** Browsers with tight memory - budgets (cheap ARM kiosks, Chrome OS devices, Android tablets) can - evict localStorage during the "reset" phase of aggressive memory - reclamation. The exact moment the client is fighting to stay alive - is when it's most likely to lose its credentials. - -- **Synchronous API blocks the main thread.** Every read/write pauses - rendering. On slow storage (eMMC on old hardware), this can stall - animations. - -- **5MB limit shared with everything.** Template-generated data, logger - buffers, and auth state all compete for the same quota. Auth can - silently fail writes. - -IndexedDB solves all four: SW-accessible, survives memory pressure -better than localStorage, async API doesn't block, quotas are measured -in gigabytes. Moving auth to IndexedDB isn't just a storage upgrade — -it's a prerequisite for the SW-based auth architecture. - -The auth-bridge pattern keeps backward compatibility and eases the -migration: - -1. On first load after the upgrade, `load()` checks IndexedDB first. -2. If IndexedDB is empty, it reads localStorage and migrates - (write to IDB, keep in localStorage as mirror). -3. Subsequent loads find IDB populated and return from there. -4. Writes go to both stores during a transition period, so rolling back - to the old client works. - -Step 15's SW reads from IndexedDB on activation to have credentials -ready for the first fetch. Step 15 can also remove the localStorage -mirror once the migration period is done (next major version). - -**RED — write test:** - -File: `assets/client/util/__tests__/auth-bridge.test.js` - -Uses `fake-indexeddb` (installed in Step 0). - -1. `setCredentials()` writes to both IndexedDB and localStorage. -2. `load()` reads from IndexedDB first. -3. `load()` migrates from localStorage when IndexedDB is empty. -4. `clear()` removes from both stores. -5. `load()` after `clear()` returns empty credentials. -6. `hasCredentials()` reflects current state correctly. - -```bash -docker compose run --rm node npx vitest run --reporter=verbose -``` - -**GREEN — implement:** - -1. Create `assets/client/util/auth-bridge.js` -2. Wire into `assets/client/app.jsx` — replace `appStorage.getToken()` / - `appStorage.getScreenId()` reads in `checkLogin` with - `authBridge.hasCredentials()` / `authBridge.getScreenId()`. -3. Replace `appStorage.setToken()` / etc. writes in the login flow with - `authBridge.setCredentials()`. -4. Keep `appStorage` for non-auth data (fallback image, previous boot). - -**Verify:** -```bash -docker compose run --rm node npx vitest run -docker compose run --rm playwright npx playwright test -``` - -**Changelog:** Added: Auth credentials stored in IndexedDB (persistent across browser restarts) with localStorage fallback and migration. - ---- - -### Step 15 — Add service worker (auth + watchdog + fetch interception) - -**Branch:** `feat/service-worker` - -**Motivation:** The main thread has always been a single point of -failure. When it dies — stuck in a JavaScript deadlock, paused by a -blocking synchronous operation, crashed by a memory-corruption bug in a -template, deadlocked on a race between React and a template library, -or just running slowly enough to miss polling ticks — nothing in the -client can detect or recover from it. The main thread is both the -patient and the doctor. - -A service worker runs in a separate thread with its own event loop. -It keeps running when the main thread is stuck. This changes the -recovery architecture fundamentally: - -**Watchdog pattern.** Main thread sends a heartbeat to the SW every -`heartbeatInterval` ms. If the SW doesn't hear from the main thread -for `watchdogTimeout` ms, the main thread is considered dead. The SW -calls `client.navigate(client.url)` on the controlled window, forcing -a navigation (equivalent to a reload). This recovers from conditions -that would otherwise permanently hang a kiosk. Without a SW, the only -way to detect main-thread death is a human walking up to the screen. - -**Centralized auth.** The current auth lifecycle spans `app.jsx`, -`tokenService.js`, `api-helper.js`, and `appStorage.js`, with implicit -contracts between them: -- `tokenService.startRefreshing()` polls `/token/refresh` every 7.5 days -- `api-helper.js` catches 401s, dispatches `reauthenticate` events -- `app.jsx` listens for `reauthenticate`, wipes storage, restarts login -- Every API call reads the token from `appStorage` and sets the header - -This architecture has 5 bugs that this plan fixes individually (Steps 5, -6, 9, etc.). But the architecture itself keeps inviting bugs because -auth is a cross-cutting concern scattered across modules. Moving it -into the SW: - -- SW intercepts every `/v2/` fetch, injects the auth header -- SW sees 401 responses, refreshes the token transparently, retries - the original request with the new token -- SW manages the refresh cycle internally (no polling in the main - thread) -- Main thread never sees 401s except for genuinely unrecoverable auth - failures, in which case the SW posts `tokenRefreshFailed` and the - main thread shows the fallback image and starts the re-bind flow - -Five files become one module (plus a thin `sw-registration.js`). - -**Response header detection (Step 16).** The SW is also the natural -place to observe response headers. Step 16 wires it to watch -`X-OS2Display-Release-Timestamp` and `X-OS2Display-Config-Hash` headers -from existing API responses; on change, it posts to the main thread. -This replaces the polling loops removed in Step 17. - -**Crash-loop upgrade.** The SW's crash-guard module upgrades Step 11c's -localStorage-based guard to IndexedDB. The SW can observe reload -cycles directly (by counting `activate` events, or by tracking -`client.navigate()` calls) and participate in the cross-thread crash -loop detection — the main-thread handler records crashes, the SW -watchdog also records navigation reloads, and the guard sees the -combined picture. - -**Modular source, single output.** The SW is conceptually five small -responsibilities — auth state, fetch interception, watchdog, crash -guard, and message routing. Source code lives in six files under -`assets/client/sw/`, organized by responsibility. esbuild bundles them -into one IIFE at `public/client/sw.js` for the browser. Maintainability -and runtime performance both win. - -**RED — write test:** - -File: `assets/client/service/__tests__/sw-registration.test.js` (JS) - -1. `registerSW()` calls `navigator.serviceWorker.register` with - URL `/client/sw.js` and `updateViaCache: 'none'`. -2. `onTokenRefreshed` callback fires when SW posts `tokenRefreshed`. -3. `onTokenRefreshFailed` callback fires on failure message. -4. Heartbeat interval is started. - -```bash -docker compose run --rm node npx vitest run --reporter=verbose -``` - -File: `assets/client/sw/__tests__/lifecycle.test.js` - -1. Mock `self.skipWaiting` and `self.clients.claim`. Import sw/index.js - (which installs the listeners at module load). Dispatch an `install` - event. Assert `self.skipWaiting` was called. -2. Mock `loadAuth` to resolve after a short delay. Dispatch an `activate` - event. Assert: - - `loadAuth` was called - - `self.clients.claim` was called AFTER `loadAuth` resolved (not before) - - `event.waitUntil` received the combined promise -3. `loadAuth` rejects (IndexedDB corrupted or unavailable). Assert: - - `self.clients.claim` IS still called — the SW must take control - so the main thread receives a signal rather than silently running - without SW support - - A `tokenRefreshFailed` message is posted to controlled clients - so the main thread enters the re-bind flow via existing error - handling - - The `activate` event's promise resolves rather than rejecting - (a rejected waitUntil terminates the SW) - -```bash -docker compose run --rm node npx vitest run --reporter=verbose -``` - -File: `assets/tests/client/client-sw.spec.js` (Playwright) - -1. After login, API calls succeed (SW injects auth headers). -2. Simulated 401 → 200 retry is transparent. - -**GREEN — implement:** - -Build pipeline: - -1. Create modular SW source under `assets/client/sw/`: - ``` - assets/client/sw/ - index.js ← entry point, imports all modules - auth.js ← token state, IndexedDB persistence, refresh loop - fetch.js ← fetch interception, 401 retry, response header detection - watchdog.js ← heartbeat monitoring, client.navigate() recovery - crash-guard.js ← crash-loop tracking (upgrades Step 11c from localStorage to IndexedDB) - idb.js ← shared IndexedDB open/read/write helpers - messages.js ← message type constants shared with main thread - ``` - esbuild bundles all modules into one IIFE output — the SW runtime is - a single file, but the source is maintainable. - -2. Create `vite-plugin-sw.js` — Vite plugin that builds the SW via esbuild - in the `closeBundle` hook. Entry point: `assets/client/sw/index.js`. - Outputs to `public/client/sw.js`. - Scope defaults to `/client/` which covers the client page — all fetch - requests from controlled pages (including `/v2/` API calls) go through - the SW. Nginx serves as a static file. - In dev mode, serves a dev build via Vite's dev server middleware. -3. Update `vite.config.js` — import and register `serviceWorkerPlugin()`. -4. Add `public/client/sw.js` to `.gitignore` (build output). -5. Build to verify: - ```bash - docker compose run --rm node npm run build - ls -la public/client/sw.js # should exist, minified - ``` - -SW lifecycle handlers (required — determines how fast updates take -effect): - -6. `assets/client/sw/index.js` must register `install` and `activate` - handlers so updated SW code (including watchdog logic) takes effect - immediately without waiting for a navigation cycle: - - ```js - self.addEventListener("install", () => { - // Skip the "waiting" state — don't wait for the old SW to release - // its clients. Without this, an updated SW sits inactive until - // the kiosk navigates away, which for a 24/7 signage screen may - // never happen. - self.skipWaiting(); - }); - - self.addEventListener("activate", (event) => { - // Two things happen in the activate handler: - // - // 1. loadAuth() restores credentials from IndexedDB into the SW's - // in-memory state. This SHOULD complete before clients.claim() - // because claim() starts routing fetches through the SW, and - // the fetch interceptor needs auth state available. - // - // 2. clients.claim() takes control of all in-scope tabs/clients - // that loaded before this SW version existed. Without it, - // already-open clients continue using the old SW (or no SW - // at all for a fresh install) until they navigate. - // - // If loadAuth() rejects (IndexedDB corrupted), claim anyway and - // signal the main thread to re-bind. Silently not claiming would - // leave the kiosk running without SW support and no signal that - // something went wrong. - // - // waitUntil() keeps the activate event alive until the promise - // resolves, preventing the browser from terminating the SW mid- - // initialization. We catch loadAuth failures so the outer - // promise always resolves; a rejected waitUntil terminates the SW. - event.waitUntil( - loadAuth() - .catch((err) => { - // Signal clients to re-bind — they'll reach STATUS_LOGIN and - // restart the bind flow via existing error paths. - notifyAllClients({ type: messageTypes.TOKEN_REFRESH_FAILED }); - }) - .then(() => self.clients.claim()) - ); - }); - ``` - - The ordering matters: on a fresh install, `clients.claim()` fires the - `controllerchange` event on the main thread, which the main thread - uses as the signal that the SW is ready to receive `postMessage`. - If we claimed before loading auth, the main thread could send the - first `setVitals` / `setBootSession` message, have the SW try to - inject auth headers on its next fetch, and find no token because - `loadAuth()` hasn't completed yet. - -Client integration: - -6. Create `assets/client/service/sw-registration.js` — registers the SW - at `/client/sw.js` with `updateViaCache: 'none'` (bypasses HTTP cache for - update checks — browsers cap SW cache at 24h anyway, this makes it - immediate). Starts heartbeat. Handles messages from SW. -7. Wire into `assets/client/app.jsx` — call `registerSW()` after - `authBridge.load()`. -8. Remove `tokenService.startRefreshing()` / `stopRefreshing()`. -9. Remove `reauthenticateHandler` event listener. Add - `handleRefreshFailure` for the `onTokenRefreshFailed` callback. -10. Remove auth header injection from `assets/client/data-sync/api-helper.js`. -11. Remove `reauthenticate` event dispatch from `api-helper.js`. - -**Verify:** -```bash -docker compose run --rm node npm run build -docker compose run --rm node npx vitest run -docker compose run --rm playwright npx playwright test -``` - -**Changelog:** Added: Service worker handles token refresh, 401 retry, auth header injection, and main-thread watchdog. Built by Vite plugin, served as static file by nginx. - ---- - -### Step 16 — Integrate response header change detection - -**Branch:** `feat/sw-response-header-detection` - -**Depends on:** Step 12 (backend headers) and Step 15 (service worker). - -**Motivation:** Step 12 added the headers. Step 15 built the SW. This -step wires them together. - -The SW's `fetch` handler already intercepts every `/v2/` response to -inject auth. It's the perfect place to also inspect the response -headers for change markers. On every response: - -1. Read `X-OS2Display-Release-Timestamp`, `X-OS2Display-Release-Version`, - `X-OS2Display-Config-Hash`. -2. Compare with the last-seen values (stored in SW memory + IndexedDB). -3. If release timestamp changed → post `onReleaseChanged` to main thread. -4. If config hash changed → post `onConfigChanged` to main thread. - -The main thread reacts: - -- `onReleaseChanged` → `window.location.replace()` to pick up the new - build. Auth persists (IndexedDB survives navigation). No race - condition because the SW is authoritative — no separate redirect - path fights with auth handling. -- `onConfigChanged` → `ClientConfigLoader.invalidateCache()` so the - next config read fetches fresh values from `/config/client`. - -Header inspection is essentially free — the SW is already reading every -response. Comparing 3 strings adds microseconds per response. No new -network traffic, no new polling loops, no extra PHP overhead. - -Contrast with the current polling model: 2 separate intervals, 2 -separate endpoints, 2 separate race conditions, runs on every screen -regardless of whether anything changed. This step replaces both with -observation of existing traffic. - -**RED — write test:** - -File: `assets/tests/client/client-release-detection.spec.js` (Playwright) - -1. Mock API to return changed `X-OS2Display-Release-Timestamp` on second - response. Assert the client reloads. -2. Mock changed `X-OS2Display-Config-Hash`. Assert config is re-fetched. - -```bash -docker compose run --rm playwright npx playwright test -``` - -**GREEN — implement:** - -1. Add `checkResponseHeaders()` to `public/client/sw.js` fetch handler. -2. Add `onReleaseChanged` / `onConfigChanged` to `sw-registration.js`. -3. Wire `onReleaseChanged` in `app.jsx` to `window.location.replace()`. -4. Wire `onConfigChanged` in `app.jsx` to `ClientConfigLoader.invalidateCache()`. -5. Add `invalidateCache()` method to `assets/client/util/client-config-loader.js`. - -**Verify:** -```bash -docker compose run --rm node npx vitest run -docker compose run --rm playwright npx playwright test -``` - -**Changelog:** Added: Release and config changes detected via API response headers in the service worker. - ---- - -### Step 17 — Remove old polling loops - -**Branch:** `feat/remove-polling-loops` - -**Depends on:** Step 16. - -**Motivation:** With Step 16 in place, the old release and config -polling mechanisms are redundant — the SW now detects changes faster, -cheaper, and without race conditions. Time to remove them. - -The removals are surgical: - -- `releaseService.startReleaseCheck()` / `stopReleaseCheck()` — the - 10-minute release polling interval. Delete. -- `releaseService.checkForNewRelease()` — the function that runs on - boot, fetches `release.json`, and conditionally redirects. Delete. - This also eliminates the race condition documented in Step 12: the - redirect no longer fires during boot, so it can't fight with - `checkLogin()`'s 401 handling. -- `releaseService.setPreviousBootInUrl()` — the hack that set a query - parameter on the redirect target to prevent redirect loops. No - redirect → no loop → no query parameter. -- `ClientConfigLoader`'s internal cache timer — the 15-minute interval - that marked the config as stale. Cache invalidation now happens - externally via `invalidateCache()` called by the SW message handler. - -The boot sequence simplifies from: - -```js -// Before -appStorage.load() - → if (shouldRedirect) window.location.replace() - → .finally(() => { checkLogin(); startReleaseCheck(); ... }) -``` - -To: - -```js -// After -authBridge.load() - .then(() => { - registerSW({ onTokenRefreshed, onReleaseChanged, onConfigChanged }); - checkLogin(); - }); -``` - -Four fewer files involved, one fewer race condition, two fewer polling -loops, no redirect-on-boot timing problems. Every concern that used to -be split between a polling loop and an event handler is now unified -under the SW's fetch interception. - -**RED — write test:** - -File: `assets/client/service/__tests__/boot-sequence.test.js` - -1. Assert `releaseService.startReleaseCheck` is NOT called during boot. -2. Assert `checkForNewRelease()` redirect is NOT called during boot. -3. Assert boot goes: `authBridge.load()` → `registerSW()` → `checkLogin()` - with no redirect. - -```bash -docker compose run --rm node npx vitest run --reporter=verbose -``` - -**GREEN — implement:** - -1. Remove `releaseService.checkForNewRelease()` from `app.jsx` useEffect. -2. Remove `releaseService.startReleaseCheck()` / `stopReleaseCheck()` calls. -3. Remove `releaseService.setPreviousBootInUrl()`. -4. Simplify boot: - ```js - authBridge.load().then(() => { - registerSW({ ... }); - checkLogin(); - appStorage.setPreviousBoot(new Date().getTime()); - }); - ``` -5. Remove internal cache timer from `ClientConfigLoader`. - -**Verify:** -```bash -docker compose run --rm node npx vitest run -docker compose run --rm playwright npx playwright test -``` - -**Changelog:** Removed: Dedicated polling loops for release.json and config.json replaced by API response header detection via service worker. - ---- - -### Step 17a — Preserve TRACK_SCREEN_INFO via request headers - -**Branch:** `feat/track-screen-info-headers` - -**Depends on:** Steps 12, 15, 17. - -**Motivation:** `TRACK_SCREEN_INFO` is a backend feature that captures -per-screen diagnostics — release version, latest request timestamp, -client IP, user agent, token expiry state — on every API call a screen -makes. Admins query this data via `GET /v2/screens/{id}` to answer "which -build is each screen running?" and "when was this screen last seen?" -It's disabled by default (`TRACK_SCREEN_INFO=false`) but is actively -used in deployments where fleet monitoring matters. - -The release info specifically (`releaseVersion`, `releaseTimestamp`) is -captured via an indirect path: - -1. `releaseService.checkForNewRelease()` fetches `release.json` -2. On version change, it redirects to the same URL with - `?releaseTimestamp=X&releaseVersion=Y` appended -3. The browser remembers this URL. Every subsequent API call includes - it as the `Referer` header -4. `ScreenUserRequestSubscriber` reads the Referer, parses its query - string, extracts the two values, writes them to the ScreenUser entity - -Step 17 deletes `releaseService` entirely. The URL never gets those -params. The Referer stops carrying them. The subscriber still runs on -every `/v2/screens/{ulid}` request (gated by `trackScreenInfo && path -match`), still parses the Referer, but now always finds empty values. -ScreenUser's `releaseVersion` and `releaseTimestamp` fields get -overwritten with `null` on every request. TRACK_SCREEN_INFO silently -stops working — ops teams see "no data" in the admin UI and assume -screens are offline, when they're actually running normally. - -The data flow was always fragile: passing structured telemetry through -a URL query parameter that exists only because of a redirect side -effect, extracted via Referer parsing, is indirect at best. The fix -replaces it with explicit request headers: - -- Client knows its own build info (injected by Vite at build time) -- SW adds `X-OS2Display-Client-Release-Version` and - `X-OS2Display-Client-Release-Timestamp` to every `/v2/` request -- Subscriber reads from these headers, ignores Referer entirely - -Direct, unambiguous, robust to URL changes. - -**RED — write tests:** - -File: `tests/EventSubscriber/ScreenUserRequestSubscriberHeadersTest.php` - -1. Send a request to `/v2/screens/{ulid}` with - `X-OS2Display-Client-Release-Version: v3.0.0` and - `X-OS2Display-Client-Release-Timestamp: 1234567890` headers (no - Referer). Assert ScreenUser's fields are updated. -2. Send a request with ONLY the Referer-style params (legacy client - before this step deployed). Assert the fields are still updated — - backward compatibility during the upgrade window. -3. Send a request with BOTH headers and Referer params. Assert the - header values win (newer client). -4. Assert subscriber still gates on - `$trackScreenInfo && preg_match('/^\/v2\/screens\/.../...)` — - disabled deployments still skip entirely. - -File: `assets/client/sw/__tests__/request-headers.test.js` - -1. Call the SW's fetch handler with a request to `/v2/screens/abc`. -2. Assert the outgoing request has - `X-OS2Display-Client-Release-Version` and - `X-OS2Display-Client-Release-Timestamp` headers. -3. Assert the values match the build-time constants. - -```bash -docker compose run --rm phpfpm composer test -- --filter=ScreenUserRequestSubscriberHeadersTest -docker compose run --rm node npx vitest run -``` - -**GREEN — implement:** - -**Step A — Vite build-time injection:** - -In `vite.config.js`, inject release info as build-time constants: - -```diff - export default defineConfig(() => { - return { - base: "/build", -+ define: { -+ __RELEASE_TIMESTAMP__: JSON.stringify( -+ process.env.APP_RELEASE_TIMESTAMP || "0" -+ ), -+ __RELEASE_VERSION__: JSON.stringify( -+ process.env.APP_RELEASE_VERSION || "dev" -+ ), -+ }, - // ... existing config ... - }; - }); -``` - -These match the backend env vars introduced in Step 12 so client and -backend stay in sync when deployed together. - -**Step B — SW injects headers:** - -In `assets/client/sw/fetch.js`, add the two headers to every outgoing -`/v2/` request alongside the auth header: - -```js -// Build-time constants — replaced by Vite at bundle time. -const CLIENT_RELEASE_VERSION = __RELEASE_VERSION__; -const CLIENT_RELEASE_TIMESTAMP = __RELEASE_TIMESTAMP__; - -function injectHeaders(request, token) { - const headers = new Headers(request.headers); - if (token) { - headers.set("Authorization", `Bearer ${token}`); - } - headers.set("X-OS2Display-Client-Release-Version", CLIENT_RELEASE_VERSION); - headers.set("X-OS2Display-Client-Release-Timestamp", CLIENT_RELEASE_TIMESTAMP); - - return new Request(request, { headers }); -} -``` - -**Step C — Subscriber reads from headers, falls back to Referer:** - -In `src/EventSubscriber/ScreenUserRequestSubscriber.php` -`createCacheEntry`: - -```php -$request = $event->getRequest(); - -// Prefer explicit headers (post-Step-17a clients). -$releaseVersion = $request->headers->get('X-OS2Display-Client-Release-Version'); -$releaseTimestamp = $request->headers->get('X-OS2Display-Client-Release-Timestamp'); - -// Fall back to Referer parsing for legacy clients that haven't -// upgraded yet. Remove this block in a later release once all -// clients in the deployment are on the new protocol. -if (null === $releaseVersion || null === $releaseTimestamp) { - $referer = $request->headers->get('referer') ?? ''; - $url = parse_url($referer); - $queryString = $url['query'] ?? ''; - $queryArray = []; - - if (!empty($queryString)) { - parse_str($queryString, $queryArray); - } - - $releaseVersion ??= $queryArray['releaseVersion'] ?? null; - $releaseTimestamp ??= $queryArray['releaseTimestamp'] ?? null; -} - -$screenUser->setReleaseTimestamp((int) $releaseTimestamp); -$screenUser->setReleaseVersion($releaseVersion); -// ... rest unchanged ... -``` - -**Step D — CORS allowlist:** - -Update `config/packages/nelmio_cors.yaml` `allow_headers` to include the -two new request headers (alongside any existing auth headers): - -```yaml -allow_headers: - - 'Content-Type' - - 'Authorization' - - 'Authorization-Tenant-Key' - - 'X-OS2Display-Client-Release-Version' - - 'X-OS2Display-Client-Release-Timestamp' -``` - -Without this, the browser's preflight will reject requests carrying the -custom headers. - -**Verify:** -```bash -docker compose run --rm node npm run build -docker compose run --rm phpfpm composer test -docker compose run --rm phpfpm composer code-analysis -docker compose run --rm phpfpm composer coding-standards-check -docker compose run --rm node npx vitest run -docker compose run --rm playwright npx playwright test -``` - -**Changelog:** Changed: `TRACK_SCREEN_INFO` now captures release info via dedicated `X-OS2Display-Client-Release-*` request headers instead of parsing the Referer URL. Preserves telemetry functionality after the release-polling loop was removed. Referer-based fallback kept for one release cycle to support clients upgrading mid-deploy. - ---- - -### Step 17b — Real-time screen last-seen via Redis - -**Branch:** `feat/realtime-screen-last-seen` - -**Depends on:** Step 1, Step 17a. - -**Motivation:** `ScreenProvider` reads `latestRequest` from the -`ScreenUser` entity in the database. That field is updated by -`ScreenUserRequestSubscriber` — but only when the subscriber's Redis -cache entry misses, which is throttled to once per -`TRACK_SCREEN_INFO_UPDATE_INTERVAL_SECONDS` (default 300). Between -misses, no code path updates the timestamp anywhere: not the DB, not -the cache entry itself. `GET /v2/screens/{id}` therefore returns a -`latestRequestDateTime` that can be up to 5 minutes stale. - -For fleet monitoring this is a meaningful gap. A screen that crashed 3 -minutes ago still shows as "recently active." An admin investigating -"which screens are alive?" can't distinguish a working screen from one -that just crashed within the tracking interval. The DB throttling is -correct for heavy data (`clientMeta`, `releaseVersion`, IP, user -agent) — those rarely change and don't need per-request writes — but -`latestRequestDateTime` is specifically the field where freshness -matters most. - -The fix separates two concerns: - -- **Liveness heartbeat** — `latestRequestDateTime` updated on every - tracked request via a lightweight Redis SET. No DB write. Essentially - free (Redis handles millions of SETs/sec). -- **Durable metadata** — the existing 5-minute DB write pattern for - `clientMeta`, `releaseVersion`, `releaseTimestamp`. Unchanged. - -`ScreenProvider` reads the liveness key first; falls back to the -entity's `latestRequest` field if Redis is unavailable or the key -expired. Admins see request-level granularity (≈90s, matching the -pull-strategy poll interval) instead of 5-minute granularity. - -Redis write volume estimate: 200 screens × 40 polls/hour = 8000 -liveness writes/hour ≈ 2.2/sec. Negligible. - -**RED — write tests:** - -File: `tests/EventSubscriber/ScreenLastSeenCacheTest.php` - -1. Send a request to `/v2/screens/{ulid}` with `TRACK_SCREEN_INFO=true`. - Assert the Redis key `screen:last-seen:{ulid}` is set with a - timestamp close to `now()`. -2. Send two requests 30 seconds apart. Assert both update the Redis - key. Assert the DB's `latestRequest` field is unchanged between - them (still throttled by the existing 5-minute interval). -3. Send a request with `TRACK_SCREEN_INFO=false`. Assert no Redis key - is written. -4. Send a request to a non-screen endpoint (e.g., `/v2/playlists`). - Assert no Redis key is written (path check still gates). - -File: `tests/State/ScreenProviderLastSeenTest.php` - -1. Populate Redis with `screen:last-seen:{ulid}` set to `now()`. - Populate ScreenUser's `latestRequest` with a value 10 minutes old. - Call `ScreenProvider::provide()` via `GET /v2/screens/{ulid}`. - Assert response `status.latestRequestDateTime` matches Redis, not DB. -2. Delete the Redis key (simulate TTL expiry or Redis eviction). - Call provide again. Assert response falls back to the DB value. -3. `TRACK_SCREEN_INFO=false`: assert the `status` field is absent - from the response (existing behavior preserved). - -```bash -docker compose run --rm phpfpm composer test -- --filter=ScreenLastSeen -docker compose run --rm phpfpm composer test -- --filter=ScreenProviderLastSeen -``` - -**GREEN — implement:** - -**Step A — Update the subscriber to write the liveness key:** - -In `src/EventSubscriber/ScreenUserRequestSubscriber.php`: - -```php -public function onKernelRequest(RequestEvent $event): void -{ - $pathInfo = $event->getRequest()->getPathInfo(); - - if (!$this->trackScreenInfo - || !preg_match("/^\/v2\/screens\/[A-Za-z0-9]{26}$/i", $pathInfo)) { - return; - } - - $user = $this->security->getUser(); - if (!$user instanceof ScreenUser) { - return; - } - - $key = $user->getId()?->jsonSerialize(); - if (null === $key) { - return; - } - - // Liveness heartbeat: update on EVERY request. Cheap Redis SET. - $liveItem = $this->screenStatusCache->getItem($this->liveSeenKey($key)); - $liveItem->set((new \DateTime())->format('c')); - // TTL slightly longer than one full tracking interval so the key - // survives brief Redis maintenance windows and serves as a soft - // TTL rather than an authoritative cutoff. - $liveItem->expiresAfter($this->trackScreenInfoUpdateIntervalSeconds * 4); - $this->screenStatusCache->save($liveItem); - - // Durable snapshot: unchanged 5-minute-throttled DB write. - $this->screenStatusCache->get( - $key, - fn (CacheItemInterface $item) => $this->createCacheEntry($item, $event, $user) - ); -} - -private function liveSeenKey(string $id): string -{ - return "last-seen.$id"; -} -``` - -The existing `createCacheEntry` method keeps writing the DB on its -usual interval. No changes to that method's body (Step 1's -`detach($screenUser)` fix stays in place). - -**Step B — ScreenProvider reads Redis first:** - -In `src/State/ScreenProvider.php`, inject the cache and override the -timestamp: - -```php -public function __construct( - // ... existing deps ... - private readonly CacheInterface $screenStatusCache, - private readonly bool $trackScreenInfo = false, -) {} - -// In provide() where status is computed: -if ($this->trackScreenInfo) { - $screenUser = $object->getScreenUser(); - $status = null; - - if (null != $screenUser) { - $status = $this->getStatus($screenUser); - - // Override with real-time liveness if available. - $screenUserId = $screenUser->getId()?->jsonSerialize(); - if (null !== $screenUserId) { - $liveItem = $this->screenStatusCache->getItem("last-seen.$screenUserId"); - if ($liveItem->isHit()) { - $status['latestRequestDateTime'] = $liveItem->get(); - } - } - } - - $output->status = $status; -} -``` - -The `isHit()` check handles the fallback cleanly: Redis down → no hit → -DB value is used (current behavior preserved). Redis up → fresh -timestamp. - -**Step C — Update service config:** - -In `config/services.yaml`, inject the cache into ScreenProvider: - -```yaml -App\State\ScreenProvider: - arguments: - # ... existing args ... - $screenStatusCache: '@screen.status.cache' -``` - -**Verify:** -```bash -docker compose run --rm phpfpm composer test -docker compose run --rm phpfpm composer code-analysis -docker compose run --rm phpfpm composer coding-standards-check -``` - -**Changelog:** Changed: Screen `latestRequestDateTime` now updates in Redis on every tracked request, giving admins poll-interval freshness (~90s) instead of the previous 5-minute granularity. Durable metadata fields (clientMeta, releaseVersion) still throttled to the configured DB write interval. Falls back to DB value if Redis is unavailable. - ---- - -### Step 17c — Skip relations checksum recomputation on irrelevant flushes - -**Branch:** `fix/checksum-skip-irrelevant-flush` - -**Bug:** `RelationsChecksumListener` subscribes to Doctrine's `postFlush` -event and, when `RELATIONS_CHECKSUM_ENABLED=true`, unconditionally runs -`$this->calculator->execute(withWhereClause: true)` on every flush. - -The calculator executes ~30 SQL statements in a transaction: a set of -`UPDATE parent JOIN child ... WHERE p.changed = 1 OR temp.changed = 1` -propagation queries across 15 checksum-tracked tables, plus -`UPDATE table SET changed = 0 WHERE changed = 1` reset queries. - -The listener itself only marks entities as `changed = 1` when those -entities implement `RelationsChecksumInterface` (Screen, Playlist, Slide, -Media, etc.). `ScreenUser` does NOT implement that interface. When -`ScreenUserRequestSubscriber` flushes (every 5 minutes per tracked -screen), only ScreenUser's own fields change — nothing marks any -checksum-tracked entity as changed. - -But `postFlush` runs anyway. The calculator executes all ~30 UPDATE -statements. Every one matches zero rows because nothing has -`changed = 1`. The DB parses each query, plans it, scans the -`changed` index, commits a transaction — for zero data changes. - -At 200 screens × 12 flushes/hour × 30 queries = **72 000 no-op -queries/hour**. MariaDB handles them, but it's measurable load for zero -benefit. On a loaded instance, these pointless transactions compete -with real queries for the connection pool and binlog. - -The same pattern applies to any flush that doesn't touch a checksum -entity: user profile updates, refresh token saves, session writes -through Doctrine, any subscriber modifying non-tracked entities. Every -one incurs the full recomputation pass. - -The fix: track within the listener's lifecycle whether any -`RelationsChecksumInterface` entity was actually flagged `changed` -during the current unit of work. If nothing was flagged, `postFlush` -has nothing to propagate — skip the calculator entirely. - -**RED — write test:** - -File: `tests/EventListener/RelationsChecksumListenerSkipTest.php` - -1. Flush a ScreenUser update (no relations-tracked entity modified). - Use a DB query counter (Doctrine SQLLogger or middleware) to assert - the calculator's SQL was NOT executed — 0 UPDATE queries on the - checksum tables. -2. Flush a Screen update (entity implements `RelationsChecksumInterface`). - Assert the calculator DID run — UPDATE queries on the checksum tables - were issued. -3. Flush a mixed batch (ScreenUser + Screen in the same unit of work). - Assert the calculator ran (one changed entity is enough). -4. Flush with `RELATIONS_CHECKSUM_ENABLED=false` and a Screen update. - Assert the calculator did NOT run (existing behavior preserved). - -```bash -docker compose run --rm phpfpm composer test -- --filter=RelationsChecksumListenerSkipTest -``` - -**GREEN — fix:** - -In `src/EventListener/RelationsChecksumListener.php`, add a flag -that tracks whether any tracked entity was modified during the -current unit of work: - -```php -class RelationsChecksumListener -{ - private bool $shouldRecompute = false; - - public function __construct( - private readonly RelationsChecksumCalculator $calculator, - private readonly bool $enabled = false, - ) {} - - final public function prePersist(PrePersistEventArgs $args): void - { - if (!$this->enabled) return; - - $entity = $args->getObject(); - // ... existing switch statement setting initial checksums ... - - // If the switch matched a tracked entity type, a new row will - // be inserted → checksum propagation needed. - if ($entity instanceof RelationsChecksumInterface) { - $this->shouldRecompute = true; - } - } - - final public function preUpdate(PreUpdateEventArgs $args): void - { - if (!$this->enabled) return; - - $entity = $args->getObject(); - if ($entity instanceof RelationsChecksumInterface) { - $entity->setChanged(true); - $this->shouldRecompute = true; - } - } - - final public function preRemove(PreRemoveEventArgs $args): void - { - if (!$this->enabled) return; - - // ... existing switch statement marking parents as changed ... - // Every setChanged(true) path also sets $this->shouldRecompute. - - switch ($entity::class) { - case ScreenLayoutRegions::class: - $entity->getScreenLayout()?->setChanged(true); - $this->shouldRecompute = true; - break; - // ... other cases, same pattern ... - } - } - - final public function onFlush(OnFlushEventArgs $args): void - { - if (!$this->enabled) return; - - $em = $args->getObjectManager(); - $uow = $em->getUnitOfWork(); - - foreach ($uow->getScheduledCollectionUpdates() as $collection) { - $owner = $collection->getOwner(); - if ($owner instanceof RelationsChecksumInterface) { - $owner->setChanged(true); - $uow->recomputeSingleEntityChangeSet( - $em->getClassMetadata($owner::class), - $owner - ); - $this->shouldRecompute = true; - } - } - - foreach ($uow->getScheduledCollectionDeletions() as $collection) { - $owner = $collection->getOwner(); - if ($owner instanceof RelationsChecksumInterface) { - $owner->setChanged(true); - $uow->recomputeSingleEntityChangeSet( - $em->getClassMetadata($owner::class), - $owner - ); - $this->shouldRecompute = true; - } - } - } - - final public function postFlush(PostFlushEventArgs $args): void - { - if (!$this->enabled) return; - - // Skip the expensive recomputation pass if nothing relevant - // actually changed during this unit of work. - if (!$this->shouldRecompute) { - return; - } - - try { - $this->calculator->execute(withWhereClause: true); - } finally { - $this->shouldRecompute = false; - } - } -} -``` - -The flag is reset inside a `finally` so a calculator exception doesn't -leave the listener in a stuck `shouldRecompute=true` state that would -force the next (unrelated) flush to recompute too. - -**Verify:** -```bash -docker compose run --rm phpfpm composer test -docker compose run --rm phpfpm composer code-analysis -docker compose run --rm phpfpm composer coding-standards-check -``` - -**Changelog:** Fixed: `RelationsChecksumListener` no longer runs the ~30-query propagation pass on flushes that didn't modify any checksum-tracked entity. Previously fired on every Doctrine flush regardless of relevance, producing tens of thousands of no-op queries per hour under TRACK_SCREEN_INFO load. - ---- - -### Step 17d — Only write DB on heavy metadata change - -**Branch:** `feat/tracking-diff-aware-writes` - -**Depends on:** Step 1, Step 17a, Step 17b. - -**Motivation:** After Step 17b moved `latestRequestDateTime` to Redis, -the DB write in `ScreenUserRequestSubscriber::createCacheEntry` only -needs to persist the heavy metadata: `releaseVersion`, `releaseTimestamp`, -`clientMeta` (host, userAgent, ip, tokenExpired). - -Those values rarely change for a steady-state screen: - -- `releaseVersion` / `releaseTimestamp` — only on deploy -- `userAgent` — only on browser upgrade (essentially never on kiosk hardware) -- `host` — the client's URL; static -- `ip` — occasional (DHCP lease change, VLAN reassignment); rare -- `tokenExpired` — computed as `$exp < $now`; for authenticated requests - the token is always valid, so this is always false. The only way to - reach the subscriber with an expired token would bypass the JWT - authenticator, which isn't possible - -The current subscriber still does a DB flush on every 5-minute cache -miss, writing identical values for every field other than -`latestRequest` — which is no longer needed in the DB post-17b. - -Even with Doctrine's change tracking detecting no-op UPDATEs at the SQL -level, the flush path still: - -- Computes the UoW change set (scans all loaded entities) -- Fires postFlush events (Step 17c helps but pre-17c is significant) -- Acquires a connection from the pool -- Starts/commits a transaction even if empty - -The fix compares each incoming value against the entity's current -state and skips the flush entirely if nothing differs. For a stable -screen between deploys, the subscriber does ZERO DB writes per day. - -Additional discovery: `$screenStatusCache` is never read outside the -subscriber — grep confirms only one call site. The cache exists purely -as a throttle (cache-miss pattern rate-limits the callback), and the -callback's return value is discarded. This means we can freely change -the callback's internal logic without affecting any reader. - -**Write volume estimates** (200 screens, weekly deploy cycle): - -| Stage | DB writes / week | -|---|---| -| Before any fixes | ~1,680,000 (5-min × 200 screens × 168 hours × 12) | -| After Step 1 (clear→detach) | same write count, less entity damage | -| After Step 17c (skip checksum) | same write count, fewer post-flush queries | -| After Step 17d (diff-aware) | **~200–500** (only on deploys + rare IP/UA changes) | - -**RED — write tests:** - -File: `tests/EventSubscriber/ScreenUserDiffAwareWritesTest.php` - -1. Populate a ScreenUser with fixed releaseVersion/clientMeta values. - Make a tracked request with headers/Referer carrying the SAME values. - Use Doctrine SQLLogger to assert NO `UPDATE screen_user` query fired. -2. Make a request with a different releaseVersion. Assert UPDATE fires. - Assert new value persisted. -3. Make a request with a different IP (everything else same). Assert - UPDATE fires with new clientMeta. -4. Make a request with nothing changed, but wait for the cache TTL to - expire, make another identical request. Assert still no UPDATE - (throttle doesn't matter; comparison does). -5. First tracked request after a screen's creation (ScreenUser has - null clientMeta and null release fields). Assert UPDATE fires and - populates the fields. - -```bash -docker compose run --rm phpfpm composer test -- --filter=ScreenUserDiffAwareWritesTest -``` - -**GREEN — implement:** - -Replace the body of -`src/EventSubscriber/ScreenUserRequestSubscriber::createCacheEntry`: - -```php -private function createCacheEntry( - CacheItemInterface $item, - RequestEvent $event, - ScreenUser $screenUser -): array { - $item->expiresAfter($this->trackScreenInfoUpdateIntervalSeconds); - - $request = $event->getRequest(); - - // Extract new metadata — headers first (Step 17a), Referer fallback. - $releaseVersion = $request->headers->get('X-OS2Display-Client-Release-Version'); - $releaseTimestamp = $request->headers->get('X-OS2Display-Client-Release-Timestamp'); - - if (null === $releaseVersion || null === $releaseTimestamp) { - $referer = $request->headers->get('referer') ?? ''; - $parsed = parse_url($referer); - $queryString = $parsed['query'] ?? ''; - $queryArray = []; - if (!empty($queryString)) { - parse_str($queryString, $queryArray); - } - $releaseVersion ??= $queryArray['releaseVersion'] ?? null; - $releaseTimestamp ??= $queryArray['releaseTimestamp'] ?? null; - } - - $userAgent = $request->headers->get('user-agent') ?? ''; - $ip = $request->getClientIp(); - $host = preg_replace("/\?.*$/i", '', $request->headers->get('referer') ?? ''); - - $tokenExpired = false; - $token = $this->security->getToken(); - if (null !== $token) { - $decodedToken = $this->tokenManager->decode($token); - $expire = $decodedToken['exp'] ?? 0; - $tokenExpired = (new \DateTime())->setTimestamp($expire) < new \DateTime(); - } - - $newClientMeta = [ - 'host' => $host, - 'userAgent' => $userAgent, - 'ip' => $ip, - 'tokenExpired' => $tokenExpired, - ]; - - // Diff-aware write: only flush if anything actually differs from - // the current entity state. For a stable screen between deploys - // this means zero DB writes. - $hasChanges = - $screenUser->getReleaseVersion() !== $releaseVersion - || $screenUser->getReleaseTimestamp() !== (int) $releaseTimestamp - || $screenUser->getClientMeta() !== $newClientMeta; - - if ($hasChanges) { - $screenUser->setReleaseVersion($releaseVersion); - $screenUser->setReleaseTimestamp((int) $releaseTimestamp); - $screenUser->setClientMeta($newClientMeta); - $screenUser->setLatestRequest(new \DateTime()); - - $this->entityManager->flush(); - $this->entityManager->detach($screenUser); // Step 1's fix - } - - // Return value is unused (cache is write-only for throttling). - return ['throttled' => true]; -} -``` - -Note the change in semantics for `ScreenUser::latestRequest` in the DB: -it now means "time of last metadata change" rather than "time of last -request." That's correct: - -- Real-time "last seen" is served from Redis (Step 17b) -- The DB field becomes "when this screen last booted / deployed / moved" -- If Redis is down, the fallback is stale but still useful - ("this screen was last seen in its current state at X") - -Update the admin UI documentation to clarify the two timestamps' -meanings. - -**Verify:** -```bash -docker compose run --rm phpfpm composer test -docker compose run --rm phpfpm composer code-analysis -docker compose run --rm phpfpm composer coding-standards-check -``` - -**Changelog:** Changed: TRACK_SCREEN_INFO DB writes now only occur when heavy metadata (releaseVersion, clientMeta, IP, user-agent) actually differs from the stored value. Stable screens between deploys produce zero DB writes from tracking. Reduces write volume by ~99% for typical deployments. DB `latestRequest` field now reflects "last metadata change" rather than "last request" — real-time last-seen served from Redis (Step 17b). - ---- - -### Step 17e — Screen uptime tracking via boot session - -**Branch:** `feat/screen-uptime-tracking` - -**Depends on:** Step 17a, Step 17d. - -**Motivation:** Fleet operators repeatedly ask the same diagnostic -question: "is this screen stable, or has it been rebooting?" Two -kiosks that both pinged the API 30 seconds ago look identical from -`latestRequest` alone — but one has been running for 3 weeks and the -other has reloaded 40 times in the last hour. That distinction matters -for triage: the first is healthy, the second needs investigation. - -Uptime is the natural metric. Formally: how long has the current page -lifecycle been running? A reload (any cause — user, SW watchdog, -ErrorBoundary, release redirect, crash-guard, watchdog navigate) -resets it to zero. - -The client knows exactly when it booted (the moment `index.jsx` runs). -Sending that to the backend gives operators a uptime number they can -glance at. But a literal timestamp has a clock-skew problem: if the -kiosk's system clock is wrong (common — kiosk hardware often lacks -reliable NTP), the computed uptime would be wrong too. - -The fix is a **boot session ID** — a random UUID generated once per -page load, sent on every request. The backend doesn't care about the -client's wall-clock time; it cares whether the session ID matches the -previous one: - -- Session ID unchanged from last tracked request → same boot → no change -- Session ID differs → client has rebooted → record `bootTimestamp = now()` - using the server's authoritative clock - -Uptime on read is simply `now - bootTimestamp`, computed with the -server's clock end-to-end. Client clock drift is irrelevant. - -The check plugs directly into Step 17d's diff-aware logic: the boot -session ID comparison becomes one more field in the `$hasChanges` -evaluation. No separate write path, no additional DB query for -freshness checks. And like Step 17d, the DB write only fires when the -session ID actually changes — at most once per page lifecycle. - -**Schema:** - -Two nullable columns on `screen_user`: - -- `boot_session_id VARCHAR(36) NULL` — UUID from the most recent boot -- `boot_timestamp DATETIME NULL` — server time when that session was - first observed - -Nullable so existing rows (for screens that haven't made a request -since this migration ran) don't need backfilling. The next tracked -request from each screen populates them naturally. - -**RED — write tests:** - -File: `tests/EventSubscriber/ScreenUptimeTrackingTest.php` - -1. Send a tracked request with a new `X-OS2Display-Client-Boot-Session` - value. Assert `ScreenUser.bootSessionId` is set to the value and - `ScreenUser.bootTimestamp` is set to `now()` (within 1s tolerance). -2. Send a second request with the SAME boot session ID (and same - heavy metadata — nothing else to write). Assert NO DB UPDATE - (diff-aware path confirms no change). -3. Send a third request with a DIFFERENT boot session ID. Assert - `bootTimestamp` is updated to the new `now()` (reboot detected). -4. Request with no `X-OS2Display-Client-Boot-Session` header (legacy - client). Assert no change to bootSessionId / bootTimestamp — - the existing values remain untouched. - -File: `tests/State/ScreenProviderUptimeTest.php` - -1. Populate ScreenUser with `bootTimestamp` set to 2 hours ago. Call - `GET /v2/screens/{ulid}`. Assert the response's - `status.uptime.seconds` is ~7200 (within a reasonable tolerance). -2. ScreenUser with null `bootTimestamp` (never seen since migration). - Assert `status.uptime` is null or absent, not an error. -3. `TRACK_SCREEN_INFO=false`. Assert `status` is absent entirely - (existing behavior preserved). - -File: `assets/client/__tests__/boot-session.test.jsx` - -1. Import the boot session module twice; assert it returns the same - ID both times (initialized once, singleton). -2. After a simulated page reload (fresh module registry), assert a - different ID is returned. - -```bash -docker compose run --rm phpfpm composer test -- --filter=ScreenUptime -docker compose run --rm node npx vitest run -``` - -**GREEN — implement:** - -**Step A — Schema migration:** - -Create `migrations/VersionYYYYMMDDHHMMSS.php`: - -```php -public function up(Schema $schema): void -{ - $this->addSql( - 'ALTER TABLE screen_user ' - .'ADD boot_session_id VARCHAR(36) DEFAULT NULL, ' - .'ADD boot_timestamp DATETIME DEFAULT NULL ' - ."COMMENT '(DC2Type:datetime)'" - ); -} - -public function down(Schema $schema): void -{ - $this->addSql('ALTER TABLE screen_user DROP boot_session_id, DROP boot_timestamp'); -} -``` - -**Step B — Entity fields:** - -In `src/Entity/ScreenUser.php`: - -```php -#[ORM\Column(type: 'string', length: 36, nullable: true)] -private ?string $bootSessionId = null; - -#[ORM\Column(type: 'datetime', nullable: true)] -private ?\DateTime $bootTimestamp = null; - -public function getBootSessionId(): ?string { return $this->bootSessionId; } -public function setBootSessionId(?string $id): void { $this->bootSessionId = $id; } - -public function getBootTimestamp(): ?\DateTime { return $this->bootTimestamp; } -public function setBootTimestamp(?\DateTime $ts): void { $this->bootTimestamp = $ts; } -``` - -**Step C — Client generates session ID:** - -Create `assets/client/util/boot-session.js`: - -```js -/** - * A random identifier generated once per page lifecycle. - * Survives across component remounts, resets on every page reload. - * The backend uses this to detect when a screen has rebooted - * (session ID change) and to compute uptime. - */ -const BOOT_SESSION_ID = typeof crypto !== "undefined" && crypto.randomUUID - ? crypto.randomUUID() - : `${Date.now()}-${Math.random().toString(36).slice(2)}`; - -export default BOOT_SESSION_ID; -``` - -The `crypto.randomUUID()` check + fallback handles older browsers -without the WebCrypto API. The fallback isn't cryptographically -secure but doesn't need to be — collisions would require the same -kiosk hardware to boot twice within the same millisecond AND produce -the same random suffix. - -**Step D — SW attaches header:** - -In `assets/client/sw/index.js`, the main thread passes the session ID -at registration: - -```js -// assets/client/service/sw-registration.js -import bootSessionId from "../util/boot-session.js"; - -await navigator.serviceWorker.register("/client/sw.js", { - updateViaCache: "none", -}); - -// Post the boot session to the SW so it can attach it to requests. -navigator.serviceWorker.controller?.postMessage({ - type: "setBootSession", - bootSessionId, -}); -``` - -SW stores and attaches: - -```js -// assets/client/sw/fetch.js -let bootSessionId = null; - -self.addEventListener("message", (event) => { - if (event.data?.type === "setBootSession") { - bootSessionId = event.data.bootSessionId; - } -}); - -function injectHeaders(request, token) { - const headers = new Headers(request.headers); - if (token) headers.set("Authorization", `Bearer ${token}`); - headers.set("X-OS2Display-Client-Release-Version", CLIENT_RELEASE_VERSION); - headers.set("X-OS2Display-Client-Release-Timestamp", CLIENT_RELEASE_TIMESTAMP); - if (bootSessionId) { - headers.set("X-OS2Display-Client-Boot-Session", bootSessionId); - } - return new Request(request, { headers }); -} -``` - -**Step E — Subscriber tracks session change (extends Step 17d):** - -Extend `ScreenUserRequestSubscriber::createCacheEntry` from Step 17d: - -```php -$bootSessionId = $request->headers->get('X-OS2Display-Client-Boot-Session'); - -// ... extract other metadata as in Step 17d ... - -$hasChanges = - $screenUser->getReleaseVersion() !== $releaseVersion - || $screenUser->getReleaseTimestamp() !== (int) $releaseTimestamp - || $screenUser->getClientMeta() !== $newClientMeta - || ($bootSessionId !== null - && $screenUser->getBootSessionId() !== $bootSessionId); - -if ($hasChanges) { - $screenUser->setReleaseVersion($releaseVersion); - $screenUser->setReleaseTimestamp((int) $releaseTimestamp); - $screenUser->setClientMeta($newClientMeta); - $screenUser->setLatestRequest(new \DateTime()); - - // Boot session change → record new boot time using server clock. - if ($bootSessionId !== null - && $screenUser->getBootSessionId() !== $bootSessionId) { - $screenUser->setBootSessionId($bootSessionId); - $screenUser->setBootTimestamp(new \DateTime()); - } - - $this->entityManager->flush(); - $this->entityManager->detach($screenUser); -} -``` - -Note the `$bootSessionId !== null` guard — a legacy client (pre-17e) -without the header doesn't trigger a session change detection. This -preserves backward compatibility during the rollout window. - -**Step F — ScreenProvider exposes uptime:** - -In `src/State/ScreenProvider.php`, extend the status payload with -computed uptime: - -```php -if ($this->trackScreenInfo) { - $screenUser = $object->getScreenUser(); - $status = null; - - if (null != $screenUser) { - $status = $this->getStatus($screenUser); - - // Real-time last-seen from Redis (Step 17b). - $screenUserId = $screenUser->getId()?->jsonSerialize(); - if (null !== $screenUserId) { - $liveItem = $this->screenStatusCache->getItem("last-seen.$screenUserId"); - if ($liveItem->isHit()) { - $status['latestRequestDateTime'] = $liveItem->get(); - } - } - - // Uptime: time since last observed boot session change. - $bootTimestamp = $screenUser->getBootTimestamp(); - if (null !== $bootTimestamp) { - $uptimeSeconds = (new \DateTime())->getTimestamp() - $bootTimestamp->getTimestamp(); - $status['uptime'] = [ - 'bootTimestamp' => $bootTimestamp->format('c'), - 'seconds' => max(0, $uptimeSeconds), - ]; - } - } - - $output->status = $status; -} -``` - -**Step G — CORS allowlist:** - -Add to `config/packages/nelmio_cors.yaml` `allow_headers`: - -```yaml -allow_headers: - # ... existing headers ... - - 'X-OS2Display-Client-Boot-Session' -``` - -**Verify:** -```bash -docker compose run --rm phpfpm composer test -docker compose run --rm phpfpm composer code-analysis -docker compose run --rm phpfpm composer coding-standards-check -docker compose run --rm node npx vitest run -docker compose run --rm playwright npx playwright test -``` - -**Changelog:** Added: Screen uptime tracking. Client sends a per-boot session UUID in the `X-OS2Display-Client-Boot-Session` header; backend records server-side boot timestamp when the session ID changes. `GET /v2/screens/{id}.status` now includes `uptime.seconds` and `uptime.bootTimestamp`. Uses server clock end-to-end — no dependency on client clock accuracy. Integrates with Step 17d's diff-aware write path: a session ID change is a metadata change, so a reboot triggers the one DB write needed to capture it; subsequent requests in the same session produce no writes. - ---- - -### Step 17f — Client vitals telemetry - -**Branch:** `feat/client-vitals` - -**Depends on:** Step 15, Step 17a, Step 17b. - -**Motivation:** Even with Step 17e's uptime metric, the admin view -still can't distinguish "kiosk hardware is struggling" from "kiosk is -fine." A screen can have days of uptime and great last-seen freshness -while simultaneously dropping frames, leaking memory, and throttled on -2G network. Operators need to see those failure modes before they -become reboots. - -Five browser-maintained metrics cover the diagnostic surface for -kiosks, each answering a distinct question: - -| Metric | Source | Detects | -|---|---|---| -| `heap` | `performance.memory.usedJSHeapSize` | Memory leak (growth over time) | -| `longTasks` | `PerformanceObserver('longtask')` | Main-thread blocking from heavy templates | -| `fps` | `requestAnimationFrame` sampling | Render degradation | -| `storage` | `navigator.storage.estimate()` | Cache/IDB bloat | -| `net` | `navigator.connection` | Intermittent connectivity | - -All five are already maintained by the browser — reading them costs a -few microseconds. The rAF FPS sampler runs continuously but is just -one counter per frame. No COOP/COEP changes needed: we use -`performance.memory` (Chrome's non-standard API) rather than -`measureUserAgentSpecificMemory`. On browsers without -`performance.memory` (Firefox, Safari), the `heap` field is simply -absent from the payload — every vital is independently guarded. - -Vitals are volatile by nature — they change on every sample. If -routed through Step 17d's diff-aware write path, every request -would trigger a DB write and defeat the whole optimization. -The right architecture is to mirror Step 17b's Redis pattern: vitals -live in a per-screen Redis key, written on every receipt, read by -`ScreenProvider` when the admin API is called. Zero DB writes from -vitals ever. - -Sampling rhythm: compute once every 30s in the main thread (matches -the heartbeat cadence roughly), post to SW, SW attaches the latest -value to every outgoing `/v2/` request. Backend updates the Redis -key on every receipt. Admin API reads the latest from Redis on -demand. - -**Payload shape:** - -```json -{ - "sampledAt": "2026-04-17T10:23:45Z", - "heap": 45000000, - "longTasks": { "count": 3, "totalMs": 240 }, - "fps": 58, - "storage": 15_000_000, - "net": { "type": "4g", "rtt": 50 } -} -``` - -Size: ~150 bytes stringified. Fits comfortably in a single HTTP header. - -**RED — write tests:** - -File: `assets/client/util/__tests__/vitals.test.js` - -1. Mock `performance.memory` with `{ usedJSHeapSize: 50_000_000 }`. - Call `collectVitals()`. Assert `result.heap === 50_000_000`. -2. Browser without `performance.memory` (unset in mock). Call - `collectVitals()`. Assert `result.heap` is absent (not null, - not 0 — absent). -3. Simulate `longtask` `PerformanceObserver` entries. Call - `collectVitals()`. Assert `result.longTasks.count` and - `totalMs` match; call again, assert counters reset (each sample - reports since-last-sample, not cumulative). -4. Browser without `navigator.storage.estimate`. Assert `storage` - is absent. -5. Browser without `navigator.connection`. Assert `net` is absent. -6. All APIs missing. Assert `collectVitals()` returns - `{ sampledAt: ... }` — always includes the timestamp, other - fields absent. - -File: `tests/EventSubscriber/ScreenVitalsHeaderTest.php` - -1. Send request with `X-OS2Display-Client-Vitals: {json}` header. - Assert the Redis key `vitals.{ulid}` is set to the JSON string. -2. Send request without the header. Assert no Redis write for - vitals (existing key unchanged if present). -3. Send two requests in sequence with different vitals payloads. - Assert the Redis value reflects the most recent payload. - -File: `tests/State/ScreenProviderVitalsTest.php` - -1. Populate `vitals.{ulid}` in Redis with a known payload. Call - `GET /v2/screens/{ulid}`. Assert response `status.vitals` is - the parsed JSON. -2. No Redis key present. Assert `status.vitals` is absent from - the response (not null — absent). -3. `TRACK_SCREEN_INFO=false`. Assert `status` is absent entirely. - -```bash -docker compose run --rm node npx vitest run -docker compose run --rm phpfpm composer test -- --filter=ScreenVitals -``` - -**GREEN — implement:** - -**Step A — Vitals collector module:** - -Create `assets/client/util/vitals.js`: - -```js -// Module-scope long-task counters, drained on each sample. -let longTaskCount = 0; -let longTaskTotalMs = 0; - -if (typeof PerformanceObserver !== "undefined") { - try { - const observer = new PerformanceObserver((list) => { - for (const entry of list.getEntries()) { - longTaskCount++; - longTaskTotalMs += entry.duration; - } - }); - observer.observe({ entryTypes: ["longtask"] }); - } catch { - // longtask entry type not supported — skip silently. - } -} - -// rAF FPS sampler — maintains a 10-second rolling window of frame times. -let frameTimes = []; -function sampleFrame(ts) { - frameTimes.push(ts); - const cutoff = ts - 10_000; - frameTimes = frameTimes.filter((t) => t > cutoff); - requestAnimationFrame(sampleFrame); -} -if (typeof requestAnimationFrame !== "undefined") { - requestAnimationFrame(sampleFrame); -} - -function currentFps() { - if (frameTimes.length < 2) return null; - const span = frameTimes[frameTimes.length - 1] - frameTimes[0]; - if (span <= 0) return null; - return Math.round(((frameTimes.length - 1) / span) * 1000); -} - -export async function collectVitals() { - const vitals = { sampledAt: new Date().toISOString() }; - - // Heap (Chrome non-standard). - if (typeof performance !== "undefined" && performance.memory) { - vitals.heap = performance.memory.usedJSHeapSize; - } - - // Long tasks (drain counters so each sample is "since last sample"). - if (longTaskCount > 0) { - vitals.longTasks = { - count: longTaskCount, - totalMs: Math.round(longTaskTotalMs), - }; - longTaskCount = 0; - longTaskTotalMs = 0; - } - - // Effective FPS. - const fps = currentFps(); - if (fps !== null) vitals.fps = fps; - - // Storage usage. - if (navigator.storage?.estimate) { - try { - const est = await navigator.storage.estimate(); - if (est.usage !== undefined) vitals.storage = est.usage; - } catch { - // Ignore — storage quota API refused. - } - } - - // Network info. - if (navigator.connection) { - vitals.net = { - type: navigator.connection.effectiveType, - rtt: navigator.connection.rtt, - }; - } - - return vitals; -} -``` - -**Step B — Main thread posts vitals to SW periodically:** - -In `assets/client/app.jsx` (or the boot sequence near SW registration): - -```js -import { collectVitals } from "./util/vitals"; - -// After SW registration and after config load: -const VITALS_INTERVAL = 30_000; // Fixed — not tied to errorRecoveryTimeout. - -setInterval(async () => { - const vitals = await collectVitals(); - navigator.serviceWorker.controller?.postMessage({ - type: "setVitals", - vitals: JSON.stringify(vitals), - }); -}, VITALS_INTERVAL); -``` - -The `setInterval` is a module-scope timer. Cleared on page unload -automatically; no explicit cleanup needed for the kiosk's -never-navigate lifecycle. - -**Step C — SW stores and attaches the header:** - -In `assets/client/sw/fetch.js`: - -```js -let vitalsPayload = null; - -// In the SW's message handler: -self.addEventListener("message", (event) => { - if (event.data?.type === "setVitals") { - vitalsPayload = event.data.vitals; - } - // ... other message types ... -}); - -// Extend injectHeaders: -function injectHeaders(request, token) { - const headers = new Headers(request.headers); - if (token) headers.set("Authorization", `Bearer ${token}`); - headers.set("X-OS2Display-Client-Release-Version", CLIENT_RELEASE_VERSION); - headers.set("X-OS2Display-Client-Release-Timestamp", CLIENT_RELEASE_TIMESTAMP); - if (bootSessionId) { - headers.set("X-OS2Display-Client-Boot-Session", bootSessionId); - } - if (vitalsPayload) { - headers.set("X-OS2Display-Client-Vitals", vitalsPayload); - } - return new Request(request, { headers }); -} -``` - -**Step D — Subscriber writes to Redis:** - -Extend `ScreenUserRequestSubscriber::onKernelRequest`: - -```php -$vitalsJson = $request->headers->get('X-OS2Display-Client-Vitals'); -if (null !== $vitalsJson) { - $vitalsItem = $this->screenStatusCache->getItem("vitals.$key"); - $vitalsItem->set($vitalsJson); - $vitalsItem->expiresAfter($this->trackScreenInfoUpdateIntervalSeconds * 4); - $this->screenStatusCache->save($vitalsItem); -} -``` - -Note: this is outside the existing `->get($key, $callback)` cache -contract — vitals update on every request, not on a throttled -schedule. The main throttled path (DB write for heavy metadata) is -unaffected. No DB write happens from vitals ever. - -**Step E — ScreenProvider exposes vitals:** - -In `src/State/ScreenProvider.php`, alongside the uptime lookup: - -```php -$vitalsItem = $this->screenStatusCache->getItem("vitals.$screenUserId"); -if ($vitalsItem->isHit()) { - $status['vitals'] = json_decode($vitalsItem->get(), true); -} -``` - -**Step F — CORS allowlist:** - -Add to `config/packages/nelmio_cors.yaml` `allow_headers`: - -```yaml -allow_headers: - # ... existing headers ... - - 'X-OS2Display-Client-Vitals' -``` - -**Verify:** -```bash -docker compose run --rm node npm run build -docker compose run --rm phpfpm composer test -docker compose run --rm phpfpm composer code-analysis -docker compose run --rm phpfpm composer coding-standards-check -docker compose run --rm node npx vitest run -docker compose run --rm playwright npx playwright test -``` - -**Changelog:** Added: Client vitals telemetry. Main thread samples heap size, long-task count/duration, effective FPS, storage usage, and network info every 30s; SW attaches the latest sample to every `/v2/` request as `X-OS2Display-Client-Vitals` header. Backend stores in Redis (no DB writes). `GET /v2/screens/{id}.status.vitals` exposes the current sample. Browser APIs independently guarded — missing fields simply absent rather than causing errors. No COOP/COEP changes required. - ---- - -### Step 17g — Boot environment capture - -**Branch:** `feat/boot-environment` - -**Depends on:** Step 17a, Step 17d, Step 17e. - -**Motivation:** When a kiosk misbehaves, the first questions an -operator asks are rarely about runtime state — they're about the -environment the client is actually running in. "Is the browser -actually fullscreen?" (`viewport` vs `screen`). "Is this really a -4K display or a 1080p rendering to a 4K panel?" (`screen.width` + -`devicePixelRatio`). "Is the timezone set correctly?" -(`Intl.resolvedOptions().timeZone`). "Is this the weak hardware we -have on the backorder list or the new gen?" (`hardwareConcurrency`, -`deviceMemory`). - -None of this data changes during a session — it's determined at boot -and remains constant until reboot. That's different from Step 17f's -vitals (continuously fluctuating runtime state) and different from -Step 17b's last-seen (timestamp of last observed activity). Boot -environment is pure static profiling: "what kind of thing is this?" - -Step 17e already detects the moment of interest — when the boot -session ID changes, the screen just rebooted and a fresh environment -is worth capturing. The subscriber can read an `X-OS2Display-Client-Boot-Environment` -header on the same request that carries the new session ID, store it -alongside `bootTimestamp`, and expose it via `GET /v2/screens/{id}` -under `status.environment`. - -Since this data is static per boot, it belongs in the DB (unlike vitals -which belong in Redis). It joins `boot_session_id` and `boot_timestamp` -as boot-scoped fields — they always change together when the session -changes, and never change otherwise. - -**Captured fields:** - -| Field | Source | Example | Why | -|---|---|---|---| -| `screen.width` × `.height` | `window.screen.width/height` | `3840 × 2160` | Display physical resolution | -| `pixelRatio` | `window.devicePixelRatio` | `1` or `2` | DPI scaling — reveals upscaling | -| `viewport.width` × `.height` | `window.innerWidth/Height` | `3840 × 2160` | If ≠ screen, browser isn't fullscreen | -| `cores` | `navigator.hardwareConcurrency` | `4` | Hardware capacity | -| `memory` | `navigator.deviceMemory` | `2` (GB) | Rough RAM size | -| `timezone` | `Intl.DateTimeFormat().resolvedOptions().timeZone` | `Europe/Copenhagen` | Schedule correctness | -| `language` | `navigator.language` | `da-DK` | Locale verification | - -Total payload: ~200 bytes JSON. Sent as a request header on every -`/v2/` call (the SW adds it once at activation time, then attaches it -unchanged forever until the page reloads). Backend writes the DB only -when the boot session also changed — so at most one write per kiosk -boot captures the environment. - -**Schema:** - -One additional nullable column on `screen_user`: - -- `boot_environment JSON DEFAULT NULL` — parsed and returned as-is in - the admin API. Using JSON rather than discrete columns because the - shape might evolve over time without migration churn. - -**RED — write tests:** - -File: `assets/client/util/__tests__/boot-environment.test.js` - -1. Call `collectBootEnvironment()` in a jsdom environment with known - screen/viewport/navigator mocks. Assert all fields are present - with the mocked values. -2. `navigator.deviceMemory` absent (Safari). Assert `memory` field is - absent from the result, other fields present. -3. `navigator.hardwareConcurrency` absent. Assert `cores` is absent. -4. Call twice. Assert the same object is returned (memoized — not - re-computed on every call). - -File: `tests/EventSubscriber/ScreenBootEnvironmentTest.php` - -1. Send request with a new boot session ID AND an - `X-OS2Display-Client-Boot-Environment` header. Assert - `ScreenUser.bootEnvironment` is populated with the parsed JSON. -2. Send a subsequent request with the SAME boot session ID but a - DIFFERENT environment header (shouldn't happen in practice, but - defensive). Assert `bootEnvironment` is NOT overwritten — - environment only updates on session change. -3. Send a request with a new boot session ID but NO environment - header (legacy client). Assert `bootSessionId` updates but - `bootEnvironment` is left untouched. -4. Malformed JSON in the header. Assert the subscriber logs a - warning and skips the environment update; the session ID still - updates correctly. - -File: `tests/State/ScreenProviderBootEnvironmentTest.php` - -1. Populate `ScreenUser.bootEnvironment` with a known JSON structure. - Call `GET /v2/screens/{ulid}`. Assert response - `status.environment` is the parsed JSON object. -2. Null `bootEnvironment`. Assert `status.environment` is absent - from the response. - -```bash -docker compose run --rm node npx vitest run -docker compose run --rm phpfpm composer test -- --filter=ScreenBootEnvironment -``` - -**GREEN — implement:** - -**Step A — Schema migration:** - -```php -public function up(Schema $schema): void -{ - $this->addSql( - 'ALTER TABLE screen_user ' - ."ADD boot_environment JSON DEFAULT NULL COMMENT '(DC2Type:json)'" - ); -} - -public function down(Schema $schema): void -{ - $this->addSql('ALTER TABLE screen_user DROP boot_environment'); -} -``` - -**Step B — Entity field:** - -In `src/Entity/ScreenUser.php`: - -```php -#[ORM\Column(type: Types::JSON, nullable: true)] -private ?array $bootEnvironment = null; - -public function getBootEnvironment(): ?array { return $this->bootEnvironment; } -public function setBootEnvironment(?array $env): void { $this->bootEnvironment = $env; } -``` - -**Step C — Client collector (memoized):** - -Create `assets/client/util/boot-environment.js`: - -```js -/** - * Capture static properties of the environment the client is booting into. - * - * Computed once per module load (i.e., once per boot) and memoized. - * Every field is independently guarded so missing browser APIs produce - * an absent field rather than an error. - */ -let cached = null; - -export default function collectBootEnvironment() { - if (cached !== null) return cached; - - const env = {}; - - if (typeof window !== "undefined") { - if (window.screen) { - env.screen = { width: window.screen.width, height: window.screen.height }; - } - if (typeof window.devicePixelRatio === "number") { - env.pixelRatio = window.devicePixelRatio; - } - env.viewport = { width: window.innerWidth, height: window.innerHeight }; - } - - if (typeof navigator !== "undefined") { - if (typeof navigator.hardwareConcurrency === "number") { - env.cores = navigator.hardwareConcurrency; - } - if (typeof navigator.deviceMemory === "number") { - env.memory = navigator.deviceMemory; - } - if (navigator.language) { - env.language = navigator.language; - } - } - - try { - env.timezone = Intl.DateTimeFormat().resolvedOptions().timeZone; - } catch { - // Intl not available — skip. - } - - cached = env; - return env; -} -``` - -**Step D — Pass to SW at registration:** - -In `assets/client/service/sw-registration.js`: - -```js -import bootSessionId from "../util/boot-session.js"; -import collectBootEnvironment from "../util/boot-environment.js"; - -await navigator.serviceWorker.register("/client/sw.js", { - updateViaCache: "none", -}); - -navigator.serviceWorker.controller?.postMessage({ - type: "setBootSession", - bootSessionId, - bootEnvironment: JSON.stringify(collectBootEnvironment()), -}); -``` - -**Step E — SW attaches header:** - -In `assets/client/sw/fetch.js`, extend the state: - -```js -let bootSessionId = null; -let bootEnvironment = null; - -self.addEventListener("message", (event) => { - if (event.data?.type === "setBootSession") { - bootSessionId = event.data.bootSessionId; - bootEnvironment = event.data.bootEnvironment ?? null; - } - // ... other handlers ... -}); - -function injectHeaders(request, token) { - const headers = new Headers(request.headers); - if (token) headers.set("Authorization", `Bearer ${token}`); - headers.set("X-OS2Display-Client-Release-Version", CLIENT_RELEASE_VERSION); - headers.set("X-OS2Display-Client-Release-Timestamp", CLIENT_RELEASE_TIMESTAMP); - if (bootSessionId) { - headers.set("X-OS2Display-Client-Boot-Session", bootSessionId); - } - if (bootEnvironment) { - headers.set("X-OS2Display-Client-Boot-Environment", bootEnvironment); - } - if (vitalsPayload) { - headers.set("X-OS2Display-Client-Vitals", vitalsPayload); - } - return new Request(request, { headers }); -} -``` - -**Step F — Subscriber captures on session change:** - -Extend `ScreenUserRequestSubscriber::createCacheEntry` from Step 17e: - -```php -if ($bootSessionId !== null - && $screenUser->getBootSessionId() !== $bootSessionId) { - $screenUser->setBootSessionId($bootSessionId); - $screenUser->setBootTimestamp(new \DateTime()); - - // Capture the environment the new boot is running in. - $envJson = $request->headers->get('X-OS2Display-Client-Boot-Environment'); - if (null !== $envJson) { - try { - $env = json_decode($envJson, true, 5, JSON_THROW_ON_ERROR); - if (is_array($env)) { - $screenUser->setBootEnvironment($env); - } - } catch (\JsonException $e) { - // Malformed header — log and skip, don't fail the request. - // Session update still proceeds. - } - } -} -``` - -The environment update is nested inside the session-change branch — -environment never updates without a session change. A kiosk whose -screen was resized mid-session (rare but possible) won't trigger a -write; the next reboot will capture the new dimensions. - -**Step G — ScreenProvider exposes the field:** - -In `src/State/ScreenProvider.php`, alongside uptime and vitals: - -```php -$env = $screenUser->getBootEnvironment(); -if (null !== $env) { - $status['environment'] = $env; -} -``` - -**Step H — CORS allowlist:** - -Add to `config/packages/nelmio_cors.yaml` `allow_headers`: - -```yaml -allow_headers: - # ... existing headers ... - - 'X-OS2Display-Client-Boot-Environment' -``` - -**Verify:** -```bash -docker compose run --rm node npm run build -docker compose run --rm phpfpm composer test -docker compose run --rm phpfpm composer code-analysis -docker compose run --rm phpfpm composer coding-standards-check -docker compose run --rm node npx vitest run -docker compose run --rm playwright npx playwright test -``` - -**Changelog:** Added: Boot environment capture. Client collects static display/device info (screen resolution, DPI, viewport, CPU cores, RAM, timezone, language) on boot and sends it as `X-OS2Display-Client-Boot-Environment` header. Backend captures into a new nullable JSON column `screen_user.boot_environment` only when the boot session ID changes — typically one DB write per reboot. Surfaced via `GET /v2/screens/{id}.status.environment`. Helps operators distinguish hardware generations, verify fullscreen operation, and confirm timezone correctness. - ---- - -### Step 17h — Clock skew tracking - -**Branch:** `feat/clock-skew-tracking` - -**Depends on:** Step 17a, Step 17b. - -**Motivation:** Kiosk hardware is notorious for bad clocks. Cheap -boards skip the RTC battery; NTP configuration is inconsistent across -deployments; firmware bugs cause time zone tables to be out of date. -The symptom on OS2Display is silent: a screen with a 5-minute clock -skew still shows content, still refreshes, still authenticates — but -its 9:00 AM schedule runs at 8:55. Ops teams discover the skew by -chance, usually after a stakeholder complains that "the cafeteria menu -showed tomorrow's lunch during today's service." - -The fix isn't to synchronize the kiosk's clock (we can't; that's an OS-level -concern). The fix is to **surface** the skew so operators can see it and -investigate at the OS/NTP layer. - -Measurement is trivial: client sends its current time in a header on -every `/v2/` request; backend computes `skew = clientTime - serverTime` -on receipt. The server's clock is the authority — we care about whether -the kiosk matches the server, not absolute correctness. One-way network -latency adds noise (request-travel time means the client's timestamp -is always slightly in the past by the time the server reads it) but -that's sub-second on any functioning network. For kiosk skew (measured -in tens of seconds to minutes), the noise is irrelevant. - -Redis-backed like vitals — skew changes continuously as clocks drift -independently, and admins only need the most recent value. No DB writes. - -The admin API exposes raw seconds; the admin UI decides how to render -severity (green under 10s, yellow under 60s, red beyond). Keeping the -threshold on the UI side avoids another env var and lets deployments -customize rendering without backend changes. - -**RED — write tests:** - -File: `tests/EventSubscriber/ScreenClockSkewTest.php` - -1. Send a request with `X-OS2Display-Client-Time` set to `now + 45s`. - Mock server time accordingly. Assert the Redis key - `clock-skew.{ulid}` is set with seconds value `45`. -2. Send a request with client time set to `now - 120s`. Assert - stored value is `-120`. -3. Send a request without the header. Assert no Redis key write - (existing value preserved if any). -4. Send a request with a malformed time string. Assert the - subscriber logs a warning and skips the skew update — doesn't - throw, doesn't break the request flow. -5. Send multiple requests in sequence with varying skew values. - Assert the Redis value reflects the most recent measurement. - -File: `tests/State/ScreenProviderClockSkewTest.php` - -1. Populate `clock-skew.{ulid}` with `{ "seconds": 45, "sampledAt": "..." }`. - Call `GET /v2/screens/{ulid}`. Assert response - `status.clockSkew` matches. -2. No Redis key present. Assert `status.clockSkew` is absent - from the response (not null — absent). -3. `TRACK_SCREEN_INFO=false`. Assert `status` is absent entirely. - -```bash -docker compose run --rm phpfpm composer test -- --filter=ScreenClockSkew -``` - -**GREEN — implement:** - -**Step A — SW attaches the client time header:** - -In `assets/client/sw/fetch.js`, extend `injectHeaders`: - -```js -function injectHeaders(request, token) { - const headers = new Headers(request.headers); - if (token) headers.set("Authorization", `Bearer ${token}`); - headers.set("X-OS2Display-Client-Release-Version", CLIENT_RELEASE_VERSION); - headers.set("X-OS2Display-Client-Release-Timestamp", CLIENT_RELEASE_TIMESTAMP); - headers.set("X-OS2Display-Client-Time", new Date().toISOString()); - if (bootSessionId) { - headers.set("X-OS2Display-Client-Boot-Session", bootSessionId); - } - if (bootEnvironment) { - headers.set("X-OS2Display-Client-Boot-Environment", bootEnvironment); - } - if (vitalsPayload) { - headers.set("X-OS2Display-Client-Vitals", vitalsPayload); - } - return new Request(request, { headers }); -} -``` - -Generated at header-injection time, not request-construction time, -so the timestamp reflects when the request actually goes out over -the wire rather than when the main thread initiated the fetch. - -**Step B — Subscriber computes and stores skew:** - -Extend `ScreenUserRequestSubscriber::onKernelRequest` (alongside the -vitals Redis write from Step 17f): - -```php -$clientTimeHeader = $request->headers->get('X-OS2Display-Client-Time'); -if (null !== $clientTimeHeader) { - try { - $clientTime = new \DateTimeImmutable($clientTimeHeader); - $serverTime = new \DateTimeImmutable(); - $skewSeconds = $clientTime->getTimestamp() - $serverTime->getTimestamp(); - - $skewItem = $this->screenStatusCache->getItem("clock-skew.$key"); - $skewItem->set(json_encode([ - 'seconds' => $skewSeconds, - 'sampledAt' => $serverTime->format('c'), - ])); - $skewItem->expiresAfter($this->trackScreenInfoUpdateIntervalSeconds * 4); - $this->screenStatusCache->save($skewItem); - } catch (\Exception $e) { - // Malformed client time — log at debug, skip the measurement. - // Don't fail the request. - } -} -``` - -The server time is captured at message-receipt (before any PHP work), -giving a consistent reference point. Sub-second precision isn't -meaningful — kiosk skew is measured in seconds, not milliseconds. - -**Step C — ScreenProvider surfaces the measurement:** - -In `src/State/ScreenProvider.php`, alongside the vitals lookup: - -```php -$skewItem = $this->screenStatusCache->getItem("clock-skew.$screenUserId"); -if ($skewItem->isHit()) { - $status['clockSkew'] = json_decode($skewItem->get(), true); -} -``` - -The payload structure is fixed from Step B — `{ seconds, sampledAt }` -— so no shape transformation needed here. - -**Step D — CORS allowlist:** - -Add to `config/packages/nelmio_cors.yaml` `allow_headers`: - -```yaml -allow_headers: - # ... existing headers ... - - 'X-OS2Display-Client-Time' -``` - -**Verify:** -```bash -docker compose run --rm node npm run build -docker compose run --rm phpfpm composer test -docker compose run --rm phpfpm composer code-analysis -docker compose run --rm phpfpm composer coding-standards-check -docker compose run --rm node npx vitest run -``` - -**Changelog:** Added: Client/server clock skew tracking. SW attaches current client time as `X-OS2Display-Client-Time` to every `/v2/` request; backend computes `skew = client - server` and stores in Redis. `GET /v2/screens/{id}.status.clockSkew` exposes `{ seconds, sampledAt }`. Surfaces cheap-hardware RTC drift and missing NTP setup — the class of silent bugs that cause schedules to fire at the wrong wall time. Admin UI renders severity (raw seconds exposed; threshold decisions made on the UI side). - ---- - -### Step 18 — Update error code taxonomy - -**Branch:** `feat/error-code-taxonomy` - -**Depends on:** Steps 15, 17. - -**Motivation:** The client writes `?status=...&error=...` to the URL via -`statusService.setStatusInUrl()` so operations staff can read the -current state directly from a kiosk's browser URL bar. This is an -invaluable debugging aid — no developer tools, no backend logs, just -look at the screen. The contract is: - -``` -https://kiosk.example.com/client/?status=running&error=ER105 -``` - -The plan's earlier steps invalidate parts of the current taxonomy and -introduce new failure modes that aren't represented. This step reconciles -the codes with the new architecture AND fixes structural bugs in how -errors are cleared. - -**Sticky-error bugs in the current architecture:** - -Error clearing today is scattered across individual call sites. Each -site that can successfully complete an operation has to remember to -clear the specific errors its failure would have set. This pattern has -failed in three ways: - -1. **ER101 has no clear path outside rebind.** ER101 - (`ERROR_TOKEN_REFRESH_FAILED`) is set in `app.jsx` - `reauthenticateHandler` and in `tokenService`. The only place that - clears it is `tokenService.checkLogin()`, which only runs during the - bind flow. A screen that hits ER101 once, then recovers via a - successful refresh, carries `?error=ER101` in the URL until rebound. - Step 6a fixed a separate typo — this is a different bug: the clear - path simply doesn't exist for routine recovery. - -2. **URL params persist across reloads.** `history.replaceState` writes - `?error=XXX` to the URL, which survives `window.location.reload()`. - After a reload that successfully boots the app, the URL still carries - the pre-reload error. An ER202 watchdog-triggered reload that - successfully recovers shows `?error=ER202` forever. Ops teams waste - cycles investigating errors that resolved themselves. - -3. **No central clearing on STATUS_RUNNING.** When the app reaches - `STATUS_RUNNING`, by definition every prerequisite (auth, config, - content fetch) succeeded. Any previous error is, by construction, - resolved. But `setStatus(STATUS_RUNNING)` doesn't touch the error - field. Errors that set up a path `init → login → running` can - linger visible on a working screen. - -**Clearing strategy:** - -- On boot, read the old `?error=` from the URL into a memory-only - `previousError` field (for logs/diagnostics), then clear the URL. - New boot starts with a clean URL — any error that re-fires will - overwrite. -- `statusService.setStatus()` clears the error on the `STATUS_RUNNING` - transition. Reaching running is proof of resolution. -- Add `statusService.clearError(code)` as the unambiguous clear API. - Replaces the scattered `setError(null)` calls. Clear is no-op if the - current error doesn't match `code` — prevents races where a newer - error is accidentally cleared by a delayed success handler from an - older operation. -- ER301 (crash-loop) is the one error that must NOT clear on boot — - it's meaningful precisely because the screen is cycling. It clears - explicitly when `crashGuard.reset()` fires on successful boot (the - `STATUS_RUNNING` transition handler calls `reset()`, which in turn - clears ER301). - -**Dead codes after earlier steps:** - -- **ER102** (`ERROR_TOKEN_REFRESH_LOOP_FAILED`) — set inside - `tokenService.startRefreshing()`. That function is deleted in Step 15. - The SW now owns the refresh cycle and any failure collapses to ER101. -- **ER104** (`ERROR_RELEASE_FILE_NOT_LOADED`) — set inside - `releaseService.checkForNewRelease()`. The entire `release-service.js` - is deleted in Step 17. Release detection is now header-based with no - failure mode worth surfacing (a missing header on an API response is - equivalent to "no change" — benign). - -**Relocated codes:** - -- **ER101** (`ERROR_TOKEN_REFRESH_FAILED`) — currently set by - `reauthenticateHandler` in `app.jsx` and by `tokenService`. Both - callers are deleted in Step 15. The SW now detects refresh failure - and posts `tokenRefreshFailed` to the main thread; the message - handler sets ER101. -- **ER103** (`ERROR_TOKEN_EXP_IAT_NOT_SET`) — JWT payload validation - still occurs (now in the SW's `auth.js` module). Set via the same - message-passing path. -- **ER105, ER106** — same pattern. - -**New codes for new failure modes:** - -| Code | Constant | When | -|---|---|---| -| ER201 | `ERROR_SW_REGISTRATION_FAILED` | `registerSW()` rejected — browser blocked or SW file missing. Main-thread auth falls back to pre-Step-15 behavior temporarily. | -| ER202 | `ERROR_WATCHDOG_RELOAD` | SW's watchdog navigated the page because heartbeats stopped. Set by the SW via message on activation-after-reload. | -| ER301 | `ERROR_CRASH_LOOP_DETECTED` | Crash-guard is in backoff — recent crashes exceeded threshold, reloads suppressed. Lets ops see "this screen has been cycling" without reading logs. | -| ER302 | `ERROR_UNCAUGHT_EXCEPTION` | `window.onerror` or `unhandledrejection` fired. Set briefly before the subsequent reload. | - -**RED — write test:** - -File: `assets/client/service/__tests__/status-service-clearing.test.js` - -These tests target the structural clearing bugs: - -1. Set URL to `?error=ER101` before constructing StatusService. Construct - the service. Assert `statusService.error === null` (URL is cleared) - and `statusService.previousError === "ER101"` (captured for diagnostics). -2. Set URL to `?error=ER301` (crash-loop) before constructing. Construct. - Assert the URL is cleared (new boot shouldn't inherit stale state) — - but crashGuard.recordCrash() will re-set ER301 if still in backoff. -3. Set `statusService.error = "ER101"`, then call - `setStatus(STATUS_RUNNING)`. Assert error is cleared. -4. Set `statusService.error = "ER301"`, then call - `setStatus(STATUS_RUNNING)`. Assert error is NOT cleared (ER301 is - persistent). -5. Set `statusService.error = "ER105"`, call `clearError("ER101")`. - Assert error is still "ER105" (code mismatch, no-op). -6. Set `statusService.error = "ER105"`, call `clearError("ER105")`. - Assert error is null. -7. Set `statusService.error = "ER105"`, call `clearError()` with no arg. - Assert error is null (force clear). - -File: `assets/client/util/__tests__/constants.test.js` - -1. Assert `constants.ERROR_TOKEN_REFRESH_LOOP_FAILED` is undefined - (removed). -2. Assert `constants.ERROR_RELEASE_FILE_NOT_LOADED` is undefined - (removed). -3. Assert `constants.ERROR_SW_REGISTRATION_FAILED` equals `"ER201"`. -4. Assert `constants.ERROR_WATCHDOG_RELOAD` equals `"ER202"`. -5. Assert `constants.ERROR_CRASH_LOOP_DETECTED` equals `"ER301"`. -6. Assert `constants.ERROR_UNCAUGHT_EXCEPTION` equals `"ER302"`. -7. Assert ER101, ER103, ER105, ER106 are unchanged. - -File: `assets/client/service/__tests__/sw-registration-error.test.js` - -1. Mock `navigator.serviceWorker.register` to reject. -2. Call `registerSW({ onError })`. -3. Assert the caller receives `ERROR_SW_REGISTRATION_FAILED`. -4. Assert `statusService.error === "ER201"` after the callback fires. - -File: `assets/client/util/__tests__/crash-guard-error.test.js` - -1. Call `recordCrash()` enough times to enter backoff. -2. Assert `statusService.error === "ER301"` was set. -3. Call `reset()`. Assert error is cleared from the URL. - -File: `assets/client/util/__tests__/global-error-handlers-error.test.js` - -1. Dispatch a `window.onerror` event. -2. Assert `statusService.error === "ER302"` was set before the - scheduled reload fires. - -```bash -docker compose run --rm node npx vitest run -``` - -**GREEN — implement:** - -Refactor `assets/client/service/status-service.js` to own the clearing -strategy: - -```js -import constants from '../util/constants'; - -class StatusService { - status = constants.STATUS_INIT; - error = null; - - /** - * The error that was in the URL when this boot started. - * Read-only after boot, for logs and diagnostics. - * Never written back to the URL — keeps the URL reflective of the - * current boot's state, not a previous life's. - */ - previousError = null; - - /** - * Errors that should survive a STATUS_RUNNING transition. - * ER301 means the screen is actively in a crash loop — clearing it - * just because we briefly reached running would defeat the whole - * purpose of the guard. - */ - static PERSISTENT_ERRORS = new Set([ - constants.ERROR_CRASH_LOOP_DETECTED, - ]); - - constructor() { - // On boot: capture the previous URL error for diagnostics, then - // clear it from the URL so the new boot starts clean. - const url = new URL(window.location.href); - const inheritedError = url.searchParams.get("error"); - if (inheritedError) { - this.previousError = inheritedError; - url.searchParams.delete("error"); - url.searchParams.delete("status"); - window.history.replaceState(null, "", url); - } - } - - setStatus = (newStatus) => { - this.status = newStatus; - - // Reaching STATUS_RUNNING is proof that all prerequisites resolved. - // Clear any transient error automatically. - if (newStatus === constants.STATUS_RUNNING && this.error) { - if (!StatusService.PERSISTENT_ERRORS.has(this.error)) { - this.error = null; - } - } - - this.setStatusInUrl(); - }; - - setError = (newError) => { - this.error = newError; - this.setStatusInUrl(); - }; - - /** - * Clear error only if it matches the given code. Prevents a - * delayed success handler from clearing a newer, unrelated error. - * Pass no argument to force-clear regardless. - */ - clearError = (code = null) => { - if (code === null || this.error === code) { - this.error = null; - this.setStatusInUrl(); - } - }; - - setStatusInUrl = () => { - const url = new URL(window.location.href); - - if (this.status) { - url.searchParams.set("status", this.status); - } else { - url.searchParams.delete("status"); - } - - if (this.error) { - url.searchParams.set("error", this.error); - } else { - url.searchParams.delete("error"); - } - - window.history.replaceState(null, "", url); - }; -} - -const statusService = new StatusService(); -export default statusService; -``` - -Update every call site currently using `setError(null)` to use -`clearError(code)` with the specific code they're clearing. This -prevents the race where a slow success handler from a previous -operation clears a newer error that was set in the meantime. - -Update `assets/client/util/constants.js`: - -```js -const constants = { - // ... status values unchanged ... - - // Token / auth errors — still relevant, now set by SW message handlers. - ERROR_TOKEN_REFRESH_FAILED: "ER101", - ERROR_TOKEN_EXP_IAT_NOT_SET: "ER103", - ERROR_TOKEN_EXPIRED: "ER105", - ERROR_TOKEN_VALID_SHOULD_HAVE_BEEN_REFRESHED: "ER106", - - // NOTE: ER102 ERROR_TOKEN_REFRESH_LOOP_FAILED removed — refresh loop - // replaced by SW in Step 15. Failures now surface as ER101. - // NOTE: ER104 ERROR_RELEASE_FILE_NOT_LOADED removed — release polling - // replaced by header-based detection in Step 17. - - // Service worker lifecycle errors. - ERROR_SW_REGISTRATION_FAILED: "ER201", - ERROR_WATCHDOG_RELOAD: "ER202", - - // Crash handling errors. - ERROR_CRASH_LOOP_DETECTED: "ER301", - ERROR_UNCAUGHT_EXCEPTION: "ER302", - - NO_TOKEN: "NO_TOKEN", - NO_EXPIRE: "NO_EXPIRE", - NO_ISSUED_AT: "NO_ISSUED_AT", -}; -``` - -Wire ER201 in `assets/client/service/sw-registration.js`: - -```js -import statusService from './status-service'; -import constants from '../util/constants'; - -export async function registerSW(callbacks) { - try { - const reg = await navigator.serviceWorker.register( - "/client/sw.js", - { updateViaCache: "none" } - ); - // ... existing setup ... - } catch (err) { - logger.error("SW registration failed:", err); - statusService.setError(constants.ERROR_SW_REGISTRATION_FAILED); - callbacks?.onError?.(err); - } -} -``` - -Wire ER301 in `assets/client/util/crash-guard.js`: - -```js -import statusService from '../service/status-service'; -import constants from './constants'; - -const crashGuard = { - recordCrash() { - // ... existing logic ... - if (state.count >= maxCrashes) { - state.backoffUntil = now + backoffDuration; - statusService.setError(constants.ERROR_CRASH_LOOP_DETECTED); - } - saveState(state); - }, - - reset() { - try { localStorage.removeItem(STORAGE_KEY); } catch {} - if (statusService.error === constants.ERROR_CRASH_LOOP_DETECTED) { - statusService.setError(null); - } - }, - // ... -}; -``` - -Wire ER302 in `assets/client/util/global-error-handlers.js`: - -```js -function handleFatalError() { - statusService.setError(constants.ERROR_UNCAUGHT_EXCEPTION); - crashGuard.recordCrash(); - - if (crashGuard.shouldReload()) { - setTimeout(() => window.location.reload(), reloadDelay); - } -} -``` - -Wire ER202 in the SW → main thread message handler (in -`assets/client/service/sw-registration.js`): - -```js -navigator.serviceWorker.addEventListener('message', (event) => { - switch (event.data?.type) { - case 'watchdogReloadPending': - // SW is about to call client.navigate() due to missed heartbeats. - statusService.setError(constants.ERROR_WATCHDOG_RELOAD); - break; - case 'tokenRefreshFailed': - statusService.setError(constants.ERROR_TOKEN_REFRESH_FAILED); - callbacks.onTokenRefreshFailed?.(); - break; - // ... other message types - } -}); -``` - -Note that ER202 is set on the main thread in the *current* boot — just -before the SW navigates. After the reload, the new main thread reads -the previous value from the URL (if still present) and can keep the -error in the URL bar for ops visibility. Alternatively, the SW can -stash the error in IndexedDB before navigating and the new main thread -reads it on boot. - -Delete all usages of the removed codes: - -```bash -# Verify removed codes are no longer referenced: -docker compose run --rm node sh -c "grep -r 'ERROR_TOKEN_REFRESH_LOOP_FAILED\|ERROR_RELEASE_FILE_NOT_LOADED' assets/ || echo 'Clean'" -``` - -Update `docs/client/error-codes.md` (create if it doesn't exist): - -```markdown -# Client Error Codes - -Errors appear in the URL as `?error=ERxxx`. The status parameter -indicates the app's current phase (`init`, `login`, `running`). - -## Token / auth (ER1xx) -- ER101 Token refresh failed (SW couldn't refresh, reauth required) -- ER103 Token payload missing iat/exp -- ER105 Token expired -- ER106 Token should have been refreshed by now (diagnostic) - -## Service worker (ER2xx) -- ER201 SW registration failed (browser blocked or SW file unreachable) -- ER202 Watchdog triggered a reload (main thread was unresponsive) - -## Crash handling (ER3xx) -- ER301 Crash loop detected (reloads suppressed during backoff) -- ER302 Uncaught exception (reload scheduled) -``` - -**Verify:** -```bash -docker compose run --rm node npx vitest run -docker compose run --rm playwright npx playwright test -``` - -**Changelog:** Changed: Error code taxonomy updated for the SW-based architecture. Removed ER102 and ER104 (dead code after refresh loop and release service removal). Added ER201 (SW registration failed), ER202 (watchdog reload), ER301 (crash loop detected), ER302 (uncaught exception). Fixed: URL error codes now clear on boot and on STATUS_RUNNING transition; persistent errors (ER301) retain their semantics. New `clearError(code)` API prevents races between delayed success handlers and newer errors. - ---- - -## PR Dependency Graph - -``` -Step 0 (vitest) - │ - ├──── Steps 1–5b (backend fixes, independent of each other) - │ │ - │ └──── Step 12 (response headers) - │ │ - │ └──── Step 13 (redis sessions) - │ - ├──── Steps 6–10 (frontend fixes, independent of each other) - │ 6a depends on existence of token-service.js and must land - │ before Step 15 deletes that file. - │ - ├──── Step 11 (top-level ErrorBoundary) - │ │ - │ └──── Step 11a (config: error recovery timeouts) - │ │ - │ ├──── Step 11b (region ErrorBoundary — reads config) - │ │ - │ └──── Step 11c (global handlers + crash guard — reads config) - │ - ├──── Step 14 (auth-bridge) - │ │ - │ └──── Step 15 (service worker — reads config for watchdog/heartbeat) - │ │ - │ └──── Step 16 (response header detection) - │ │ - │ └──── Step 17 (remove polling) - │ │ - │ └──── Step 17a (TRACK_SCREEN_INFO via headers) - │ │ - │ ├──── Step 17b (real-time last-seen via Redis) - │ │ - │ ├──── Step 17c (skip checksum on irrelevant flush) - │ │ - │ ├──── Step 17d (diff-aware DB writes) - │ │ │ - │ │ ├──── Step 17e (uptime via boot session) - │ │ │ │ - │ │ │ └──── Step 17g (boot environment) - │ │ - │ ├──── Step 17f (vitals telemetry via Redis) - │ │ - │ ├──── Step 17h (clock skew tracking via Redis) - │ │ - │ └──── Step 18 (error code taxonomy) -``` - -Steps 1–5b and 6–10 can proceed in parallel. Step 11a must land before -11b, 11c, and 15 since they all read timeout values from the config. -Step 15's SW crash-guard module upgrades 11c's localStorage-based guard -to IndexedDB. Step 17a bridges TRACK_SCREEN_INFO from Referer-parsing -to request headers — required because Step 17 removes the URL params -the Referer approach relied on. Step 17c is technically independent -of 17/17a/17b (it fixes a general backend performance issue, not a -TRACK_SCREEN_INFO-specific one) but grouped nearby because the issue -surfaces most visibly under TRACK_SCREEN_INFO load. Step 18 is the -final cleanup — after 17 removes dead code, 18 removes the dead error -codes and adds codes for new failure modes. - ---- - -## Claude Code /loop instructions - -Each step is designed to be executed as a single `/loop` session. -Copy the template below, replace `N` with the step number: - -``` -/loop "Implement Step N from PLAN.md against release/3.0.0. - 1. Create branch from release/3.0.0 - 2. Write the failing test(s) described in the RED section - 3. Run tests inside docker: - - PHP: docker compose run --rm phpfpm composer test -- --filter= - - JS: docker compose run --rm node npx vitest run - Confirm the new test FAILS - 4. Implement the fix described in the GREEN section - 5. Build assets if changed: - - docker compose run --rm node npm run build - 6. Run tests — confirm all tests PASS: - - PHP: docker compose run --rm phpfpm composer test - - JS: docker compose run --rm node npx vitest run - 7. Run quality checks: - - PHP: docker compose run --rm phpfpm composer coding-standards-check - - PHP: docker compose run --rm phpfpm composer code-analysis - If coding standards fail: docker compose run --rm phpfpm composer coding-standards-apply - 8. Run E2E if applicable: - - docker compose run --rm playwright npx playwright test - 9. Update CHANGELOG.md with the entry from the step - 10. Commit with message matching the changelog entry - 11. Push and create PR against release/3.0.0" -``` - -Each `/loop` session should take 5–15 minutes depending on scope. From 2fc8d905c5c63d1bc1305f2c2add66e35e43d51a Mon Sep 17 00:00:00 2001 From: Troels Ugilt Jensen <6103205+tuj@users.noreply.github.com> Date: Sat, 18 Apr 2026 11:33:05 +0200 Subject: [PATCH 14/73] Fixed forceRetch issue --- assets/client/data-sync/pull-strategy.js | 272 ++++++++++------------- rtx-force-refetch.md | 138 ++++++++++++ 2 files changed, 253 insertions(+), 157 deletions(-) create mode 100644 rtx-force-refetch.md diff --git a/assets/client/data-sync/pull-strategy.js b/assets/client/data-sync/pull-strategy.js index 2a1b73cb9..6dc3c28c2 100644 --- a/assets/client/data-sync/pull-strategy.js +++ b/assets/client/data-sync/pull-strategy.js @@ -18,11 +18,12 @@ const REGION_PATH_REGEX = * * @param {string} endpoint The endpoint name. * @param {object} args The endpoint args. + * @param {boolean} forceRefetch Whether to bypass RTK Query cache. * @returns {Promise} The result data. */ -function query(endpoint, args) { +function query(endpoint, args, forceRefetch = false) { return clientStore - .dispatch(clientApi.endpoints[endpoint].initiate(args)) + .dispatch(clientApi.endpoints[endpoint].initiate(args, { forceRefetch })) .unwrap(); } @@ -31,16 +32,17 @@ function query(endpoint, args) { * * @param {string} endpoint The endpoint name. * @param {object} args The endpoint args (page will be added). + * @param {boolean} forceRefetch Whether to bypass RTK Query cache. * @returns {Promise} All hydra:member results concatenated. */ -async function queryAllPages(endpoint, args) { +async function queryAllPages(endpoint, args, forceRefetch = false) { let results = []; let page = 1; let continueLoop = false; do { try { - const responseData = await query(endpoint, { ...args, page }); + const responseData = await query(endpoint, { ...args, page }, forceRefetch); if (responseData === null || responseData === undefined) { logger.error(`Failed to fetch page ${page} for ${endpoint}`); @@ -71,7 +73,11 @@ async function queryAllPages(endpoint, args) { * Handles pull strategy. */ class PullStrategy { - latestScreenData; + previousScreenChecksums = null; + + previousSlideChecksums = {}; + + previousHadActiveCampaign = false; // Fetch-interval in ms. interval; @@ -98,16 +104,17 @@ class PullStrategy { * Gets all campaigns, both from screen and groups. * * @param {object} screen The screen object to extract campaigns from. + * @param {boolean} forceRefetch Whether to bypass RTK Query cache. * @returns {Promise} Array of campaigns (playlists). */ - async getCampaignsData(screen) { + async getCampaignsData(screen, forceRefetch) { const screenGroupCampaigns = []; const screenId = idFromPath(screen["@id"]); try { const response = await query("getV2ScreensByIdScreenGroups", { id: screenId, - }); + }, forceRefetch); if ( response !== null && @@ -118,7 +125,7 @@ class PullStrategy { response["hydra:member"].forEach((group) => { const groupId = idFromPath(group["@id"]); promises.push( - queryAllPages("getV2ScreenGroupsByIdCampaigns", { id: groupId }), + queryAllPages("getV2ScreenGroupsByIdCampaigns", { id: groupId }, forceRefetch), ); }); @@ -142,6 +149,7 @@ class PullStrategy { const screenCampaignsResponse = await query( "getV2ScreensByIdCampaigns", { id: screenId }, + forceRefetch, ); if (screenCampaignsResponse !== null) { @@ -160,9 +168,10 @@ class PullStrategy { * Get slides for regions. * * @param {Array} regions Paths to regions. + * @param {boolean} forceRefetch Whether to bypass RTK Query cache. * @returns {Promise} Regions data. */ - async getRegions(regions) { + async getRegions(regions, forceRefetch) { return new Promise((resolve, reject) => { const promises = []; const regionData = {}; @@ -174,7 +183,7 @@ class PullStrategy { queryAllPages("getV2ScreensByIdRegionsAndRegionIdPlaylists", { id: matches[1], regionId: matches[2], - }).then((results) => ({ + }, forceRefetch).then((results) => ({ regionId: matches[2], results, })), @@ -202,9 +211,10 @@ class PullStrategy { * Get slides for the given regions. * * @param {object} regions Regions to fetch slides for. + * @param {boolean} forceRefetch Whether to bypass RTK Query cache. * @returns {Promise} Promise with slides for the given regions. */ - async getSlidesForRegions(regions) { + async getSlidesForRegions(regions, forceRefetch) { return new Promise((resolve, reject) => { const promises = []; const regionData = cloneDeep(regions); @@ -220,7 +230,7 @@ class PullStrategy { promises.push( queryAllPages("getV2PlaylistsByIdSlides", { id: playlistId, - }).then((results) => ({ + }, forceRefetch).then((results) => ({ regionKey, playlistKey, results, @@ -254,11 +264,11 @@ class PullStrategy { async getScreen(screenPath) { let screen; - // Fetch screen + // Always forceRefetch the screen to get fresh checksums. try { screen = await query("getV2ScreensById", { id: idFromPath(screenPath), - }); + }, true); } catch (err) { logger.warn( `Screen (${screenPath}) not loaded. Aborting content update.`, @@ -280,21 +290,18 @@ class PullStrategy { newScreen.hasActiveCampaign = false; const newScreenChecksums = newScreen?.relationsChecksum ?? []; - const oldScreenChecksums = - this.latestScreenData?.relationsChecksum ?? null; - - if ( - relationChecksumEnabled === false || - oldScreenChecksums === null || - oldScreenChecksums?.campaigns !== newScreenChecksums?.campaigns || - oldScreenChecksums?.inScreenGroups !== newScreenChecksums?.inScreenGroups - ) { + + // Determine which resources need fresh data based on checksum changes. + const campaignsChanged = + !relationChecksumEnabled || + !this.previousScreenChecksums || + this.previousScreenChecksums?.campaigns !== newScreenChecksums?.campaigns || + this.previousScreenChecksums?.inScreenGroups !== newScreenChecksums?.inScreenGroups; + + if (campaignsChanged) { logger.info(`Fetching campaigns.`); - newScreen.campaignsData = await this.getCampaignsData(newScreen); - } else { - logger.info(`Campaigns data loaded from cache.`); - newScreen.campaignsData = this.latestScreenData.campaignsData; } + newScreen.campaignsData = await this.getCampaignsData(newScreen, campaignsChanged); if (newScreen.campaignsData.length > 0) { newScreen.campaignsData.forEach(({ published }) => { @@ -332,63 +339,57 @@ class PullStrategy { ]; newScreen.regionData = await this.getSlidesForRegions( newScreen.regionData, + true, ); } else { logger.info(`Has no active campaign.`); - // Get layout: Defines layout and regions. - if ( - relationChecksumEnabled === false || - this.latestScreenData?.hasActiveCampaign || - oldScreenChecksums === null || - oldScreenChecksums?.layout !== newScreenChecksums?.layout - ) { + const layoutChanged = + !relationChecksumEnabled || + this.previousHadActiveCampaign || + !this.previousScreenChecksums || + this.previousScreenChecksums?.layout !== newScreenChecksums?.layout; + + if (layoutChanged) { logger.info(`Fetching layout.`); - try { - newScreen.layoutData = await query("getV2LayoutsById", { - id: idFromPath(newScreen.layout), - }); - } catch (err) { - logger.warn( - `Layout (${newScreen.layout}) not loaded. Aborting content update.`, - ); - return; - } + } - if (newScreen.layoutData === null) { - logger.warn( - `Layout (${newScreen.layout}) not loaded. Aborting content update.`, - ); - return; - } - } else { - // Get layout: Defines layout and regions. - logger.info(`Layout loaded from cache.`); - newScreen.layoutData = this.latestScreenData.layoutData; + try { + newScreen.layoutData = await query("getV2LayoutsById", { + id: idFromPath(newScreen.layout), + }, layoutChanged); + } catch (err) { + logger.warn( + `Layout (${newScreen.layout}) not loaded. Aborting content update.`, + ); + return; } - // Fetch regions playlists: Yields playlists of slides for the regions - if ( - relationChecksumEnabled === false || - this.latestScreenData?.hasActiveCampaign || - oldScreenChecksums === null || - oldScreenChecksums?.regions !== newScreenChecksums?.regions - ) { + if (newScreen.layoutData === null) { + logger.warn( + `Layout (${newScreen.layout}) not loaded. Aborting content update.`, + ); + return; + } + + const regionsChanged = + !relationChecksumEnabled || + this.previousHadActiveCampaign || + !this.previousScreenChecksums || + this.previousScreenChecksums?.regions !== newScreenChecksums?.regions; + + if (regionsChanged) { logger.info(`Fetching regions and slides for regions.`); - const regions = await this.getRegions(newScreen.regions); - newScreen.regionData = await this.getSlidesForRegions(regions); - } else { - logger.info(`Regions and slides for regions loaded from cache.`); - newScreen.regionData = this.latestScreenData.regionData; } - } - // Cached data. - const fetchedTemplates = {}; - const fetchedMedia = {}; + const regions = await this.getRegions(newScreen.regions, regionsChanged); + newScreen.regionData = await this.getSlidesForRegions(regions, regionsChanged); + } // Iterate all slides and load required relations. const { regionData } = newScreen; + const nextSlideChecksums = {}; + /* eslint-disable no-restricted-syntax,no-await-in-loop */ for (const regionKey of Object.keys(regionData)) { const regionDataEntry = regionData[regionKey]; @@ -399,61 +400,29 @@ class PullStrategy { for (const slideKey of Object.keys(dataEntrySlidesData)) { const slide = cloneDeep(dataEntrySlidesData[slideKey]); - - let previousSlide = null; - - // Find the slide in previous data for comparing relationsChecksum values. - if ( - this.latestScreenData?.regionData[regionKey] && - this.latestScreenData.regionData[regionKey][playlistKey] && - this.latestScreenData.regionData[regionKey][playlistKey] - .slidesData[slideKey] - ) { - previousSlide = cloneDeep( - this.latestScreenData.regionData[regionKey][playlistKey] - .slidesData[slideKey], - ); - } else { - previousSlide = {}; - } + const slideId = slide["@id"]; const newSlideChecksums = slide.relationsChecksum ?? []; - const oldSlideChecksums = previousSlide?.relationsChecksum ?? null; + const oldSlideChecksums = this.previousSlideChecksums[slideId] ?? null; // Fetch template if it has changed. - if ( - relationChecksumEnabled === false || - oldSlideChecksums === null || - newSlideChecksums.templateInfo !== oldSlideChecksums.templateInfo - ) { - const templateId = idFromPath(slide.templateInfo["@id"]); - - // Load template into slide.templateData. - if ( - Object.prototype.hasOwnProperty.call( - fetchedTemplates, - templateId, - ) - ) { - slide.templateData = fetchedTemplates[templateId]; - } else { - logger.info(`Fetching template data.`); - try { - const templateData = await query("getV2TemplatesById", { - id: templateId, - }); - slide.templateData = templateData; - - if (templateData !== null) { - fetchedTemplates[templateId] = templateData; - } - } catch (err) { - slide.templateData = null; - } - } - } else { - logger.info(`Template data loaded from cache.`); - slide.templateData = previousSlide.templateData; + const templateChanged = + !relationChecksumEnabled || + !oldSlideChecksums || + newSlideChecksums.templateInfo !== oldSlideChecksums.templateInfo; + + const templateId = idFromPath(slide.templateInfo["@id"]); + + if (templateChanged) { + logger.info(`Fetching template data.`); + } + + try { + slide.templateData = await query("getV2TemplatesById", { + id: templateId, + }, templateChanged); + } catch (err) { + slide.templateData = null; } // A slide cannot work without templateData. Mark as invalid. @@ -465,61 +434,50 @@ class PullStrategy { } // Fetch media if it has changed. - if ( - relationChecksumEnabled === false || - oldSlideChecksums === null || - newSlideChecksums.media !== oldSlideChecksums.media - ) { - const nextMediaData = {}; - - for (const mediaPath of slide.media) { - const mediaId = idFromPath(mediaPath); - if ( - Object.prototype.hasOwnProperty.call(fetchedMedia, mediaId) - ) { - nextMediaData[mediaPath] = fetchedMedia[mediaId]; - } else { - logger.info(`Fetching media data.`); - try { - const mediaData = await query("getv2MediaById", { - id: mediaId, - }); - nextMediaData[mediaPath] = mediaData; - - if (mediaData !== null) { - fetchedMedia[mediaId] = mediaData; - } - } catch (err) { - nextMediaData[mediaPath] = null; - } - } - } + const mediaChanged = + !relationChecksumEnabled || + !oldSlideChecksums || + newSlideChecksums.media !== oldSlideChecksums.media; + + if (mediaChanged) { + logger.info(`Fetching media data.`); + } - slide.mediaData = nextMediaData; - } else { - logger.info(`Media data loaded from cache.`); - slide.mediaData = previousSlide.mediaData; + const nextMediaData = {}; + for (const mediaPath of slide.media) { + const mediaId = idFromPath(mediaPath); + try { + nextMediaData[mediaPath] = await query("getv2MediaById", { + id: mediaId, + }, mediaChanged); + } catch (err) { + nextMediaData[mediaPath] = null; + } } + slide.mediaData = nextMediaData; - // Fetch feed. + // Fetch feed — always forceRefetch (no checksum, needs fresh data). if (slide?.feed?.feedUrl !== undefined) { logger.info(`Fetching feed data.`); try { slide.feedData = await query("getV2FeedsByIdData", { id: idFromPath(slide.feed.feedUrl), - }); + }, true); } catch (err) { slide.feedData = null; } } + nextSlideChecksums[slideId] = newSlideChecksums; dataEntrySlidesData[slideKey] = slide; } } } /* eslint-enable no-restricted-syntax,no-await-in-loop */ - this.latestScreenData = newScreen; + this.previousScreenChecksums = newScreen.relationsChecksum ?? null; + this.previousSlideChecksums = nextSlideChecksums; + this.previousHadActiveCampaign = newScreen.hasActiveCampaign; // Deliver result to rendering const event = new CustomEvent("content", { diff --git a/rtx-force-refetch.md b/rtx-force-refetch.md new file mode 100644 index 000000000..1a33dac37 --- /dev/null +++ b/rtx-force-refetch.md @@ -0,0 +1,138 @@ +# Fix: Merge relationChecksum with RTK Query forceRefetch + +## Context + +`PullStrategy` manually caches composed data in `this.latestScreenData` and uses checksum comparisons to decide whether to refetch or reuse cached data. This duplicates what RTK Query already provides — a per-endpoint cache. The `query()` function also lacks `forceRefetch`, so RTK Query silently serves stale data on every poll after the first. + +## Approach + +Use checksums to control `forceRefetch` on `query()` calls. RTK Query becomes the sole cache. Remove `this.latestScreenData` as a data cache and the manual if/else branching. + +**File**: `assets/client/data-sync/pull-strategy.js` + +### 1. Update `query()` to accept `forceRefetch` + +```js +function query(endpoint, args, forceRefetch = false) { + return clientStore + .dispatch( + clientApi.endpoints[endpoint].initiate(args, { forceRefetch }) + ) + .unwrap(); +} +``` + +`queryAllPages()` also needs the parameter, passed through to `query()`. + +### 2. Store only previous checksums, not full data + +Replace `this.latestScreenData` with two lightweight stores: + +```js +this.previousScreenChecksums = null; // { campaigns, inScreenGroups, layout, regions } +this.previousSlideChecksums = {}; // { [slideId]: { templateInfo, media } } +``` + +Updated at end of `getScreen()` from the fetched data. + +### 3. Screen fetch — always forceRefetch + +```js +screen = await query("getV2ScreensById", { id: idFromPath(screenPath) }, true); +``` + +This is the root of the sync cycle — must always hit the network to get fresh checksums. + +### 4. Remove all manual cache branches, use forceRefetch instead + +For each downstream resource, compute whether the checksum changed, pass that as `forceRefetch`: + +**Campaigns** (currently lines 286-297): +```js +const campaignsChanged = + !relationChecksumEnabled || + !this.previousScreenChecksums || + this.previousScreenChecksums.campaigns !== newScreenChecksums.campaigns || + this.previousScreenChecksums.inScreenGroups !== newScreenChecksums.inScreenGroups; + +// Always call getCampaignsData — it will hit cache or network based on forceRefetch +newScreen.campaignsData = await this.getCampaignsData(newScreen, campaignsChanged); +``` + +- `getCampaignsData` passes `forceRefetch` through to `query()` / `queryAllPages()` +- Remove the `else { ... loaded from cache }` branch entirely + +**Layout** (currently lines 340-368): +```js +const layoutChanged = + !relationChecksumEnabled || + !this.previousScreenChecksums || + this.previousScreenChecksums.layout !== newScreenChecksums.layout; + +newScreen.layoutData = await query("getV2LayoutsById", { id: idFromPath(newScreen.layout) }, layoutChanged); +``` + +**Regions + slides** (currently lines 371-383): +```js +const regionsChanged = + !relationChecksumEnabled || + !this.previousScreenChecksums || + this.previousScreenChecksums.regions !== newScreenChecksums.regions; + +// getRegions and getSlidesForRegions pass forceRefetch through +const regions = await this.getRegions(newScreen.regions, regionsChanged); +newScreen.regionData = await this.getSlidesForRegions(regions, regionsChanged); +``` + +**Per-slide template/media** (currently lines 424-502): +```js +const templateChanged = + !relationChecksumEnabled || + !this.previousSlideChecksums[slideId] || + newSlideChecksums.templateInfo !== this.previousSlideChecksums[slideId]?.templateInfo; + +slide.templateData = await query("getV2TemplatesById", { id: templateId }, templateChanged); +``` + +Same pattern for media. RTK Query's cache naturally deduplicates same-template/same-media across slides within a cycle (same endpoint + args = cache hit), so `fetchedTemplates` / `fetchedMedia` maps can be removed. + +**Feed** — always forceRefetch (no checksum, needs fresh data): +```js +slide.feedData = await query("getV2FeedsByIdData", { id: idFromPath(slide.feed.feedUrl) }, true); +``` + +### 5. Update at end of cycle + +```js +this.previousScreenChecksums = newScreen.relationsChecksum ?? null; +this.previousSlideChecksums = {}; // rebuild from current slide data +// ... iterate slides and store { [slideId]: slide.relationsChecksum } +``` + +### 6. Handle `hasActiveCampaign` transition + +Current code (line 342) also forces layout refetch when `this.latestScreenData?.hasActiveCampaign` (transitioning from campaign → normal). Store `this.previousHadActiveCampaign` boolean alongside checksums. + +### 7. Methods that need signature changes + +| Method | New parameter | Passes to | +|--------|--------------|-----------| +| `query()` | `forceRefetch = false` | `initiate()` | +| `queryAllPages()` | `forceRefetch = false` | `query()` | +| `getCampaignsData()` | `forceRefetch` | `query()`, `queryAllPages()` | +| `getRegions()` | `forceRefetch` | `queryAllPages()` | +| `getSlidesForRegions()` | `forceRefetch` | `queryAllPages()` | + +### Summary of removals + +- `this.latestScreenData` property → replaced by `this.previousScreenChecksums`, `this.previousSlideChecksums`, `this.previousHadActiveCampaign` +- All `else { ... loaded from cache }` branches (6 of them) +- `fetchedTemplates` / `fetchedMedia` within-cycle maps (RTK Query deduplicates naturally) +- `previousSlide` lookup and `cloneDeep` of previous slide data + +## Verification + +- `task assets:build` succeeds +- `task test:frontend-built` passes +- Manual: content updates on screen are picked up within polling interval +- Manual: unchanged content does NOT trigger unnecessary network requests (check DevTools network tab) From 279dc38de8baeacbc4b31aa4871fb1c2c0069371 Mon Sep 17 00:00:00 2001 From: Troels Ugilt Jensen <6103205+tuj@users.noreply.github.com> Date: Sat, 18 Apr 2026 11:46:32 +0200 Subject: [PATCH 15/73] 7203: Refactored to use shared function for checksum calculation --- assets/client/data-sync/pull-strategy.js | 53 +++++++++++++++--------- 1 file changed, 34 insertions(+), 19 deletions(-) diff --git a/assets/client/data-sync/pull-strategy.js b/assets/client/data-sync/pull-strategy.js index 6dc3c28c2..8c0ab1185 100644 --- a/assets/client/data-sync/pull-strategy.js +++ b/assets/client/data-sync/pull-strategy.js @@ -13,6 +13,20 @@ const CAMPAIGN_REGION_ID = "01G112XBWFPY029RYFB8X2H4KD"; const REGION_PATH_REGEX = /\/v2\/screens\/([^/]+)\/regions\/([^/]+)\/playlists/; +/** + * Check if any of the given checksum fields have changed. + * + * @param {boolean} enabled Whether checksum comparison is enabled. + * @param {object|null} oldChecksums Previous checksums (null on first run). + * @param {object} newChecksums Current checksums. + * @param {Array} fields Checksum field names to compare. + * @returns {boolean} True if data should be refetched. + */ +function checksumChanged(enabled, oldChecksums, newChecksums, fields) { + if (!enabled || !oldChecksums) return true; + return fields.some((field) => oldChecksums[field] !== newChecksums[field]); +} + /** * Dispatch an RTK Query endpoint and return the unwrapped result. * @@ -292,11 +306,10 @@ class PullStrategy { const newScreenChecksums = newScreen?.relationsChecksum ?? []; // Determine which resources need fresh data based on checksum changes. - const campaignsChanged = - !relationChecksumEnabled || - !this.previousScreenChecksums || - this.previousScreenChecksums?.campaigns !== newScreenChecksums?.campaigns || - this.previousScreenChecksums?.inScreenGroups !== newScreenChecksums?.inScreenGroups; + const campaignsChanged = checksumChanged( + relationChecksumEnabled, this.previousScreenChecksums, newScreenChecksums, + ["campaigns", "inScreenGroups"], + ); if (campaignsChanged) { logger.info(`Fetching campaigns.`); @@ -345,10 +358,11 @@ class PullStrategy { logger.info(`Has no active campaign.`); const layoutChanged = - !relationChecksumEnabled || this.previousHadActiveCampaign || - !this.previousScreenChecksums || - this.previousScreenChecksums?.layout !== newScreenChecksums?.layout; + checksumChanged( + relationChecksumEnabled, this.previousScreenChecksums, newScreenChecksums, + ["layout"], + ); if (layoutChanged) { logger.info(`Fetching layout.`); @@ -373,10 +387,11 @@ class PullStrategy { } const regionsChanged = - !relationChecksumEnabled || this.previousHadActiveCampaign || - !this.previousScreenChecksums || - this.previousScreenChecksums?.regions !== newScreenChecksums?.regions; + checksumChanged( + relationChecksumEnabled, this.previousScreenChecksums, newScreenChecksums, + ["regions"], + ); if (regionsChanged) { logger.info(`Fetching regions and slides for regions.`); @@ -406,10 +421,10 @@ class PullStrategy { const oldSlideChecksums = this.previousSlideChecksums[slideId] ?? null; // Fetch template if it has changed. - const templateChanged = - !relationChecksumEnabled || - !oldSlideChecksums || - newSlideChecksums.templateInfo !== oldSlideChecksums.templateInfo; + const templateChanged = checksumChanged( + relationChecksumEnabled, oldSlideChecksums, newSlideChecksums, + ["templateInfo"], + ); const templateId = idFromPath(slide.templateInfo["@id"]); @@ -434,10 +449,10 @@ class PullStrategy { } // Fetch media if it has changed. - const mediaChanged = - !relationChecksumEnabled || - !oldSlideChecksums || - newSlideChecksums.media !== oldSlideChecksums.media; + const mediaChanged = checksumChanged( + relationChecksumEnabled, oldSlideChecksums, newSlideChecksums, + ["media"], + ); if (mediaChanged) { logger.info(`Fetching media data.`); From cf06082400fa8e583bc1a84865015aaadb238c45 Mon Sep 17 00:00:00 2001 From: Troels Ugilt Jensen <6103205+tuj@users.noreply.github.com> Date: Sat, 18 Apr 2026 12:07:10 +0200 Subject: [PATCH 16/73] 7203: Fixed path bugs --- assets/admin/components/groups/groups-columns.jsx | 2 +- assets/admin/components/playlist/playlists-columns.jsx | 2 +- assets/admin/components/screen/util/campaigns-button.jsx | 2 +- assets/admin/components/screen/util/screen-groups-button.jsx | 2 +- assets/client/data-sync/pull-strategy.js | 2 +- assets/client/service/schedule-service.js | 2 +- assets/client/util/{isPublished.js => is-published.js} | 0 7 files changed, 6 insertions(+), 6 deletions(-) rename assets/client/util/{isPublished.js => is-published.js} (100%) diff --git a/assets/admin/components/groups/groups-columns.jsx b/assets/admin/components/groups/groups-columns.jsx index 191df9414..0b38faa61 100644 --- a/assets/admin/components/groups/groups-columns.jsx +++ b/assets/admin/components/groups/groups-columns.jsx @@ -3,11 +3,11 @@ import ColumnHoc from "../util/column-hoc"; import SelectColumnHoc from "../util/select-column-hoc"; import useModal from "../../context/modal-context/modal-context-hook.jsx"; import { useDispatch } from "react-redux"; -import { enhancedApi } from "../../../shared/redux/enhanced-api.ts"; import idFromUrl from "../util/helpers/id-from-url.jsx"; import getAllPages from "../util/helpers/get-all-pages.js"; import { Link } from "react-router-dom"; import { Button } from "react-bootstrap"; +import { enhancedApi } from "../../redux/enhanced-api.ts"; function ScreensButton({ group }) { const { t } = useTranslation("common", { keyPrefix: "groups-columns" }); diff --git a/assets/admin/components/playlist/playlists-columns.jsx b/assets/admin/components/playlist/playlists-columns.jsx index d7c93be26..814838c80 100644 --- a/assets/admin/components/playlist/playlists-columns.jsx +++ b/assets/admin/components/playlist/playlists-columns.jsx @@ -5,11 +5,11 @@ import DateValue from "../util/date-value"; import PublishingStatus from "../util/publishingStatus"; import useModal from "../../context/modal-context/modal-context-hook.jsx"; import { useDispatch } from "react-redux"; -import { enhancedApi } from "../../../shared/redux/enhanced-api.ts"; import idFromUrl from "../util/helpers/id-from-url.jsx"; import getAllPages from "../util/helpers/get-all-pages.js"; import { Link } from "react-router-dom"; import { Button } from "react-bootstrap"; +import { enhancedApi } from "../../redux/enhanced-api.ts"; function SlidesButton({ playlist }) { const { t } = useTranslation("common", { keyPrefix: "playlists-columns" }); diff --git a/assets/admin/components/screen/util/campaigns-button.jsx b/assets/admin/components/screen/util/campaigns-button.jsx index edb5657c3..c1b5e3dd3 100644 --- a/assets/admin/components/screen/util/campaigns-button.jsx +++ b/assets/admin/components/screen/util/campaigns-button.jsx @@ -6,8 +6,8 @@ import idFromUrl from "../../util/helpers/id-from-url.jsx"; import getAllPages from "../../util/helpers/get-all-pages.js"; import { useDispatch } from "react-redux"; import useModal from "../../../context/modal-context/modal-context-hook.jsx"; -import { enhancedApi } from "../../../../shared/redux/enhanced-api.ts"; import { Button } from "react-bootstrap"; +import { enhancedApi } from "../../../redux/enhanced-api.ts"; function getAllScreenGroupCampaigns(dispatch, screenGroupIds = []) { return screenGroupIds.reduce( diff --git a/assets/admin/components/screen/util/screen-groups-button.jsx b/assets/admin/components/screen/util/screen-groups-button.jsx index dbd8d01b5..1fc696401 100644 --- a/assets/admin/components/screen/util/screen-groups-button.jsx +++ b/assets/admin/components/screen/util/screen-groups-button.jsx @@ -3,9 +3,9 @@ import idFromUrl from "../../util/helpers/id-from-url"; import getAllPages from "../../util/helpers/get-all-pages.js"; import { Button } from "react-bootstrap"; import useModal from "../../../context/modal-context/modal-context-hook.jsx"; -import { enhancedApi } from "../../../../shared/redux/enhanced-api.ts"; import { useDispatch } from "react-redux"; import { Link } from "react-router-dom"; +import { enhancedApi } from "../../../redux/enhanced-api.ts"; function ScreenGroupsButton({ screen }) { const { t } = useTranslation("common", { keyPrefix: "screen-columns" }); diff --git a/assets/client/data-sync/pull-strategy.js b/assets/client/data-sync/pull-strategy.js index 8c0ab1185..d8353f450 100644 --- a/assets/client/data-sync/pull-strategy.js +++ b/assets/client/data-sync/pull-strategy.js @@ -1,4 +1,4 @@ -import isPublished from "../util/isPublished"; +import isPublished from "../util/is-published"; import logger from "../logger/logger"; import idFromPath from "../util/id-from-path"; import { cloneDeep } from "lodash"; diff --git a/assets/client/service/schedule-service.js b/assets/client/service/schedule-service.js index ecc748674..8e1dfe940 100644 --- a/assets/client/service/schedule-service.js +++ b/assets/client/service/schedule-service.js @@ -1,7 +1,7 @@ import sha256 from "crypto-js/sha256"; import Md5 from "crypto-js/md5"; import Base64 from "crypto-js/enc-base64"; -import isPublished from "../util/isPublished"; +import isPublished from "../util/is-published"; import logger from "../logger/logger"; import ClientConfigLoader from "../util/client-config-loader.js"; import ScheduleUtils from "../util/schedule"; diff --git a/assets/client/util/isPublished.js b/assets/client/util/is-published.js similarity index 100% rename from assets/client/util/isPublished.js rename to assets/client/util/is-published.js From 14cf76adc129fd09e03d63965d0e69220747d3ee Mon Sep 17 00:00:00 2001 From: Troels Ugilt Jensen <6103205+tuj@users.noreply.github.com> Date: Sat, 18 Apr 2026 21:45:48 +0200 Subject: [PATCH 17/73] 7203: Added tests for client --- assets/tests/client/app-storage.test.js | 154 ++++++++ .../tests/client/client-config-loader.test.js | 132 +++++++ assets/tests/client/constants.test.js | 41 ++ assets/tests/client/defaults.test.js | 16 + assets/tests/client/error-boundary.test.jsx | 63 +++ assets/tests/client/is-published.test.js | 67 ++++ assets/tests/client/preview.test.js | 69 ++++ assets/tests/client/region.test.jsx | 200 ++++++++++ assets/tests/client/schedule-service.test.js | 244 ++++++++++++ assets/tests/client/schedule.test.js | 71 ++++ assets/tests/client/screen.test.jsx | 117 ++++++ assets/tests/client/slide.test.jsx | 109 +++++ assets/tests/client/status-service.test.js | 62 +++ assets/tests/client/token-service.test.js | 371 ++++++++++++++++++ assets/tests/client/touch-region.test.jsx | 167 ++++++++ 15 files changed, 1883 insertions(+) create mode 100644 assets/tests/client/app-storage.test.js create mode 100644 assets/tests/client/client-config-loader.test.js create mode 100644 assets/tests/client/constants.test.js create mode 100644 assets/tests/client/defaults.test.js create mode 100644 assets/tests/client/error-boundary.test.jsx create mode 100644 assets/tests/client/is-published.test.js create mode 100644 assets/tests/client/preview.test.js create mode 100644 assets/tests/client/region.test.jsx create mode 100644 assets/tests/client/schedule-service.test.js create mode 100644 assets/tests/client/schedule.test.js create mode 100644 assets/tests/client/screen.test.jsx create mode 100644 assets/tests/client/slide.test.jsx create mode 100644 assets/tests/client/status-service.test.js create mode 100644 assets/tests/client/token-service.test.js create mode 100644 assets/tests/client/touch-region.test.jsx diff --git a/assets/tests/client/app-storage.test.js b/assets/tests/client/app-storage.test.js new file mode 100644 index 000000000..a0086604e --- /dev/null +++ b/assets/tests/client/app-storage.test.js @@ -0,0 +1,154 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; + +vi.mock("jwt-decode", () => ({ + default: () => ({ exp: 1700000000, iat: 1699990000 }), +})); + +import appStorage from "../../client/util/app-storage"; + +describe("AppStorage", () => { + beforeEach(() => { + localStorage.clear(); + }); + + describe("token", () => { + it("setToken stores token, exp and iat", () => { + appStorage.setToken("fake.jwt.token"); + expect(localStorage.getItem("apiToken")).toBe("fake.jwt.token"); + expect(localStorage.getItem("apiTokenExpire")).toBe("1700000000"); + expect(localStorage.getItem("apiTokenIssuedAt")).toBe("1699990000"); + }); + + it("getToken returns stored token", () => { + localStorage.setItem("apiToken", "my-token"); + expect(appStorage.getToken()).toBe("my-token"); + }); + + it("getToken returns null when nothing stored", () => { + expect(appStorage.getToken()).toBeNull(); + }); + + it("getTokenExpire returns parsed integer", () => { + localStorage.setItem("apiTokenExpire", "1700000000"); + expect(appStorage.getTokenExpire()).toBe(1700000000); + }); + + it("getTokenExpire returns null when nothing stored", () => { + expect(appStorage.getTokenExpire()).toBeNull(); + }); + + it("getTokenIssueAt returns parsed integer", () => { + localStorage.setItem("apiTokenIssuedAt", "1699990000"); + expect(appStorage.getTokenIssueAt()).toBe(1699990000); + }); + + it("getTokenIssueAt returns null when nothing stored", () => { + expect(appStorage.getTokenIssueAt()).toBeNull(); + }); + + it("clearToken removes all token keys", () => { + localStorage.setItem("apiToken", "t"); + localStorage.setItem("apiTokenExpire", "1"); + localStorage.setItem("apiTokenIssuedAt", "2"); + appStorage.clearToken(); + expect(localStorage.getItem("apiToken")).toBeNull(); + expect(localStorage.getItem("apiTokenExpire")).toBeNull(); + expect(localStorage.getItem("apiTokenIssuedAt")).toBeNull(); + }); + }); + + describe("refreshToken", () => { + it("round-trips set and get", () => { + appStorage.setRefreshToken("refresh-123"); + expect(appStorage.getRefreshToken()).toBe("refresh-123"); + }); + + it("clearRefreshToken removes the value", () => { + appStorage.setRefreshToken("refresh-123"); + appStorage.clearRefreshToken(); + expect(appStorage.getRefreshToken()).toBeNull(); + }); + }); + + describe("screenId", () => { + it("round-trips set and get", () => { + appStorage.setScreenId("screen-abc"); + expect(appStorage.getScreenId()).toBe("screen-abc"); + }); + + it("clearScreenId removes the value", () => { + appStorage.setScreenId("screen-abc"); + appStorage.clearScreenId(); + expect(appStorage.getScreenId()).toBeNull(); + }); + }); + + describe("tenant", () => { + it("setTenant stores both key and id", () => { + appStorage.setTenant("key-1", "id-1"); + expect(appStorage.getTenantKey()).toBe("key-1"); + expect(appStorage.getTenantId()).toBe("id-1"); + }); + + it("clearTenant removes both key and id", () => { + appStorage.setTenant("key-1", "id-1"); + appStorage.clearTenant(); + expect(appStorage.getTenantKey()).toBeNull(); + expect(appStorage.getTenantId()).toBeNull(); + }); + }); + + describe("fallbackImageUrl", () => { + it("round-trips set and get", () => { + appStorage.setFallbackImageUrl("https://example.com/img.png"); + expect(appStorage.getFallbackImageUrl()).toBe( + "https://example.com/img.png" + ); + }); + + it("clearFallbackImageUrl removes the value", () => { + appStorage.setFallbackImageUrl("https://example.com/img.png"); + appStorage.clearFallbackImageUrl(); + expect(appStorage.getFallbackImageUrl()).toBeNull(); + }); + }); + + describe("apiUrl", () => { + it("setApiUrl stores the value", () => { + appStorage.setApiUrl("https://api.example.com"); + expect(localStorage.getItem("apiUrl")).toBe("https://api.example.com"); + }); + }); + + describe("previousBoot", () => { + it("returns 0 when nothing stored", () => { + expect(appStorage.getPreviousBoot()).toBe(0); + }); + + it("round-trips set and get", () => { + appStorage.setPreviousBoot("1700000000"); + expect(appStorage.getPreviousBoot()).toBe("1700000000"); + }); + }); + + describe("clearAppStorage", () => { + it("clears token, refreshToken, screenId, tenant, and fallbackImage", () => { + appStorage.setToken("fake.jwt.token"); + appStorage.setRefreshToken("refresh"); + appStorage.setScreenId("screen"); + appStorage.setTenant("key", "id"); + appStorage.setFallbackImageUrl("img.png"); + appStorage.setApiUrl("https://api.example.com"); + + appStorage.clearAppStorage(); + + expect(appStorage.getToken()).toBeNull(); + expect(appStorage.getRefreshToken()).toBeNull(); + expect(appStorage.getScreenId()).toBeNull(); + expect(appStorage.getTenantKey()).toBeNull(); + expect(appStorage.getFallbackImageUrl()).toBeNull(); + // apiUrl is NOT cleared by clearAppStorage + expect(localStorage.getItem("apiUrl")).toBe("https://api.example.com"); + }); + }); +}); diff --git a/assets/tests/client/client-config-loader.test.js b/assets/tests/client/client-config-loader.test.js new file mode 100644 index 000000000..73addfb42 --- /dev/null +++ b/assets/tests/client/client-config-loader.test.js @@ -0,0 +1,132 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; + +vi.mock("../../client/util/app-storage.js", () => ({ + default: { setApiUrl: vi.fn() }, +})); +vi.mock("../../client/logger/logger", () => ({ + default: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), log: vi.fn() }, +})); + +const mockConfig = { + apiEndpoint: "https://api.test.com", + schedulingInterval: 60000, + debug: false, +}; + +describe("ClientConfigLoader", () => { + let ClientConfigLoader; + let appStorage; + + beforeEach(async () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date("2025-06-15T12:00:00Z")); + vi.resetModules(); + + // Re-mock after resetModules + vi.doMock("../../client/util/app-storage.js", () => ({ + default: { setApiUrl: vi.fn() }, + })); + vi.doMock("../../client/logger/logger", () => ({ + default: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), log: vi.fn() }, + })); + + const module = await import("../../client/util/client-config-loader.js"); + ClientConfigLoader = module.default; + + const storageModule = await import("../../client/util/app-storage.js"); + appStorage = storageModule.default; + }); + + afterEach(() => { + vi.useRealTimers(); + vi.restoreAllMocks(); + }); + + it("fetches /config/client and returns the data", async () => { + vi.stubGlobal( + "fetch", + vi.fn().mockResolvedValue({ + json: () => Promise.resolve(mockConfig), + }) + ); + + const config = await ClientConfigLoader.loadConfig(); + expect(fetch).toHaveBeenCalledWith("/config/client"); + expect(config).toEqual(mockConfig); + }); + + it("calls appStorage.setApiUrl with the apiEndpoint", async () => { + vi.stubGlobal( + "fetch", + vi.fn().mockResolvedValue({ + json: () => Promise.resolve(mockConfig), + }) + ); + + await ClientConfigLoader.loadConfig(); + expect(appStorage.setApiUrl).toHaveBeenCalledWith("https://api.test.com"); + }); + + it("returns cached data on second call within cache window", async () => { + const fetchMock = vi.fn().mockResolvedValue({ + json: () => Promise.resolve(mockConfig), + }); + vi.stubGlobal("fetch", fetchMock); + + await ClientConfigLoader.loadConfig(); + + // Advance 5 minutes (within 15 min default cache) + vi.setSystemTime(new Date("2025-06-15T12:05:00Z")); + + const config = await ClientConfigLoader.loadConfig(); + expect(fetchMock).toHaveBeenCalledTimes(1); + expect(config).toEqual(mockConfig); + }); + + it("re-fetches after cache expires", async () => { + const fetchMock = vi.fn().mockResolvedValue({ + json: () => Promise.resolve(mockConfig), + }); + vi.stubGlobal("fetch", fetchMock); + + await ClientConfigLoader.loadConfig(); + + // Wait for the finally() callback to clear activePromise + await vi.advanceTimersByTimeAsync(0); + + // Advance 16 minutes (past 15 min default cache) + vi.setSystemTime(new Date("2025-06-15T12:16:00Z")); + + await ClientConfigLoader.loadConfig(); + expect(fetchMock).toHaveBeenCalledTimes(2); + }); + + it("returns default config when fetch fails and no cached data", async () => { + vi.stubGlobal("fetch", vi.fn().mockRejectedValue(new Error("network"))); + + const config = await ClientConfigLoader.loadConfig(); + expect(config.apiEndpoint).toBe("/api"); + expect(config.schedulingInterval).toBe(60000); + expect(config.debug).toBe(false); + }); + + it("returns cached data when fetch fails and cached data exists", async () => { + const fetchMock = vi + .fn() + .mockResolvedValueOnce({ + json: () => Promise.resolve(mockConfig), + }) + .mockRejectedValueOnce(new Error("network")); + vi.stubGlobal("fetch", fetchMock); + + // First call succeeds + await ClientConfigLoader.loadConfig(); + + // Advance past cache + vi.setSystemTime(new Date("2025-06-15T12:16:00Z")); + + // Second call fails — should return cached + const config = await ClientConfigLoader.loadConfig(); + expect(config).toEqual(mockConfig); + }); +}); diff --git a/assets/tests/client/constants.test.js b/assets/tests/client/constants.test.js new file mode 100644 index 000000000..648bb01e2 --- /dev/null +++ b/assets/tests/client/constants.test.js @@ -0,0 +1,41 @@ +import { describe, it, expect } from "vitest"; +import constants from "../../client/util/constants"; + +describe("constants", () => { + it("has all login status constants", () => { + expect(constants.LOGIN_STATUS_READY).toBe("ready"); + expect(constants.LOGIN_STATUS_AWAITING_BIND_KEY).toBe("awaitingBindKey"); + expect(constants.LOGIN_STATUS_UNKNOWN).toBe("unknown"); + }); + + it("has all token state constants", () => { + expect(constants.TOKEN_EXPIRED).toBe("Expired"); + expect(constants.TOKEN_VALID_SHOULD_HAVE_BEEN_REFRESHED).toBe( + "ValidShouldHaveBeenRefreshed" + ); + expect(constants.TOKEN_VALID).toBe("Valid"); + }); + + it("has all app status constants", () => { + expect(constants.STATUS_INIT).toBe("init"); + expect(constants.STATUS_LOGIN).toBe("login"); + expect(constants.STATUS_RUNNING).toBe("running"); + }); + + it("has all error code constants following ER1xx pattern", () => { + expect(constants.ERROR_TOKEN_REFRESH_FAILED).toBe("ER101"); + expect(constants.ERROR_TOKEN_REFRESH_LOOP_FAILED).toBe("ER102"); + expect(constants.ERROR_TOKEN_EXP_IAT_NOT_SET).toBe("ER103"); + expect(constants.ERROR_RELEASE_FILE_NOT_LOADED).toBe("ER104"); + expect(constants.ERROR_TOKEN_EXPIRED).toBe("ER105"); + expect(constants.ERROR_TOKEN_VALID_SHOULD_HAVE_BEEN_REFRESHED).toBe( + "ER106" + ); + }); + + it("has all sentinel constants", () => { + expect(constants.NO_TOKEN).toBe("NO_TOKEN"); + expect(constants.NO_EXPIRE).toBe("NO_EXPIRE"); + expect(constants.NO_ISSUED_AT).toBe("NO_ISSUED_AT"); + }); +}); diff --git a/assets/tests/client/defaults.test.js b/assets/tests/client/defaults.test.js new file mode 100644 index 000000000..3eb4e664a --- /dev/null +++ b/assets/tests/client/defaults.test.js @@ -0,0 +1,16 @@ +import { describe, it, expect } from "vitest"; +import defaults from "../../client/util/defaults"; + +describe("defaults", () => { + it("loginCheckTimeoutDefault is 20 seconds", () => { + expect(defaults.loginCheckTimeoutDefault).toBe(20000); + }); + + it("refreshTokenTimeoutDefault is 15 minutes", () => { + expect(defaults.refreshTokenTimeoutDefault).toBe(900000); + }); + + it("releaseTimestampIntervalTimeoutDefault is 10 minutes", () => { + expect(defaults.releaseTimestampIntervalTimeoutDefault).toBe(600000); + }); +}); diff --git a/assets/tests/client/error-boundary.test.jsx b/assets/tests/client/error-boundary.test.jsx new file mode 100644 index 000000000..afa0c1a74 --- /dev/null +++ b/assets/tests/client/error-boundary.test.jsx @@ -0,0 +1,63 @@ +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", () => ({ + default: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), log: vi.fn() }, +})); +vi.mock("../../client/assets/fallback.png", () => ({ + default: "fallback.png", +})); +vi.mock("../../client/components/error-boundary.scss", () => ({})); + +function ThrowingComponent({ message }) { + throw new Error(message); +} + +describe("ErrorBoundary", () => { + let consoleError; + + beforeEach(() => { + // Suppress React's console.error for caught errors + consoleError = vi.spyOn(console, "error").mockImplementation(() => {}); + }); + + it("renders children when no error", () => { + render( + +
Hello
+
+ ); + expect(screen.getByTestId("child")).toBeInTheDocument(); + }); + + it("shows fallback UI when child throws", () => { + render( + + + + ); + expect(screen.getByText("Seneste log hændelser")).toBeInTheDocument(); + expect(screen.getByText(/Test error/)).toBeInTheDocument(); + }); + + it("calls errorHandler prop when child throws", () => { + const errorHandler = vi.fn(); + render( + + + + ); + expect(errorHandler).toHaveBeenCalledTimes(1); + expect(errorHandler.mock.calls[0][0].message).toBe("Test error"); + }); + + it("works without errorHandler prop", () => { + render( + + + + ); + expect(screen.getByText(/No handler/)).toBeInTheDocument(); + }); +}); diff --git a/assets/tests/client/is-published.test.js b/assets/tests/client/is-published.test.js new file mode 100644 index 000000000..2f8fbb6ea --- /dev/null +++ b/assets/tests/client/is-published.test.js @@ -0,0 +1,67 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import isPublished from "../../client/util/is-published"; + +describe("isPublished", () => { + beforeEach(() => { + vi.useFakeTimers(); + vi.setSystemTime(new Date("2025-06-15T12:00:00Z")); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + describe("both from and to set", () => { + it("returns true when current time is within window", () => { + expect( + isPublished({ from: "2025-06-15T10:00:00Z", to: "2025-06-15T14:00:00Z" }) + ).toBe(true); + }); + + it("returns false when current time is before from", () => { + expect( + isPublished({ from: "2025-06-15T13:00:00Z", to: "2025-06-15T14:00:00Z" }) + ).toBe(false); + }); + + it("returns false when current time is after to", () => { + expect( + isPublished({ from: "2025-06-15T08:00:00Z", to: "2025-06-15T10:00:00Z" }) + ).toBe(false); + }); + }); + + describe("only from set", () => { + it("returns true when from is in the past", () => { + expect(isPublished({ from: "2025-06-15T10:00:00Z" })).toBe(true); + }); + + it("returns false when from is in the future", () => { + expect(isPublished({ from: "2025-06-15T14:00:00Z" })).toBe(false); + }); + }); + + describe("only to set", () => { + it("returns true when to is in the future", () => { + expect(isPublished({ to: "2025-06-15T14:00:00Z" })).toBe(true); + }); + + it("returns false when to is in the past", () => { + expect(isPublished({ to: "2025-06-15T10:00:00Z" })).toBe(false); + }); + }); + + describe("neither from nor to set", () => { + it("returns true for empty object", () => { + expect(isPublished({})).toBe(true); + }); + + it("returns true for null", () => { + expect(isPublished(null)).toBe(true); + }); + + it("returns true for undefined", () => { + expect(isPublished(undefined)).toBe(true); + }); + }); +}); diff --git a/assets/tests/client/preview.test.js b/assets/tests/client/preview.test.js new file mode 100644 index 000000000..bd1beda25 --- /dev/null +++ b/assets/tests/client/preview.test.js @@ -0,0 +1,69 @@ +import { describe, it, expect } from "vitest"; +import { + screenForPlaylistPreview, + screenForSlidePreview, +} from "../../client/util/preview"; + +describe("screenForPlaylistPreview", () => { + const playlist = { + "@id": "/v2/playlists/TEST01234567890123456789", + "@type": "Playlist", + title: "Test playlist", + }; + + it("returns a screen object with expected structure", () => { + const screen = screenForPlaylistPreview(playlist); + expect(screen["@id"]).toBe("/v2/screens/SCREEN01234567890123456789"); + expect(screen["@type"]).toBe("Screen"); + expect(screen.title).toBe("Preview"); + }); + + it("has a single region", () => { + const screen = screenForPlaylistPreview(playlist); + expect(screen.regions).toHaveLength(1); + }); + + it("includes the playlist in regionData", () => { + const screen = screenForPlaylistPreview(playlist); + expect(screen.regionData.REGION01234567890123456789).toEqual([playlist]); + }); + + it("has a 1x1 grid layout", () => { + const screen = screenForPlaylistPreview(playlist); + expect(screen.layoutData.grid).toEqual({ rows: 1, columns: 1 }); + }); + + it("has a single region in layout with gridArea ['a']", () => { + const screen = screenForPlaylistPreview(playlist); + expect(screen.layoutData.regions).toHaveLength(1); + expect(screen.layoutData.regions[0].gridArea).toEqual(["a"]); + }); +}); + +describe("screenForSlidePreview", () => { + const slide = { + "@id": "/v2/slides/SLIDE01234567890123456789", + "@type": "Slide", + title: "Test slide", + }; + + it("wraps the slide in a playlist", () => { + const screen = screenForSlidePreview(slide); + const playlists = screen.regionData.REGION01234567890123456789; + expect(playlists).toHaveLength(1); + expect(playlists[0]["@type"]).toBe("Playlist"); + expect(playlists[0].slidesData).toEqual([slide]); + }); + + it("the wrapper playlist has empty schedules", () => { + const screen = screenForSlidePreview(slide); + const playlists = screen.regionData.REGION01234567890123456789; + expect(playlists[0].schedules).toEqual([]); + }); + + it("produces a valid screen structure", () => { + const screen = screenForSlidePreview(slide); + expect(screen["@type"]).toBe("Screen"); + expect(screen.layoutData.grid).toEqual({ rows: 1, columns: 1 }); + }); +}); diff --git a/assets/tests/client/region.test.jsx b/assets/tests/client/region.test.jsx new file mode 100644 index 000000000..3eb3ce82b --- /dev/null +++ b/assets/tests/client/region.test.jsx @@ -0,0 +1,200 @@ +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", () => ({ + default: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), log: vi.fn() }, +})); +vi.mock("../../client/components/region.scss", () => ({})); +vi.mock("../../client/components/slide.scss", () => ({})); +vi.mock("../../client/components/error-boundary.scss", () => ({})); +vi.mock("../../client/assets/fallback.png", () => ({ + default: "fallback.png", +})); +vi.mock("../../shared/grid-generator/grid-generator", () => ({ + createGridArea: (gridArea) => gridArea.join(" / "), +})); +vi.mock("../../client/util/id-from-path", () => ({ + default: () => "REGION01", +})); + +// Mock Slide to render a simple div and expose slideDone +let capturedSlideDone = null; +vi.mock("../../client/components/slide.jsx", () => ({ + default: ({ slide, slideDone }) => { + capturedSlideDone = () => slideDone(slide); + return
{slide.title}
; + }, +})); + +// Mock react-transition-group to be pass-through +vi.mock("react-transition-group", () => ({ + TransitionGroup: ({ children }) => <>{children}, + CSSTransition: ({ children }) => <>{children}, +})); + +describe("Region", () => { + const region = { + "@id": "/v2/layouts/regions/REGION01", + gridArea: ["a"], + }; + + beforeEach(() => { + capturedSlideDone = null; + }); + + afterEach(() => { + cleanup(); + vi.restoreAllMocks(); + }); + + it("emits regionReady event on mount", () => { + const handler = vi.fn(); + document.addEventListener("regionReady", handler); + + render(); + + expect(handler).toHaveBeenCalledTimes(1); + expect(handler.mock.calls[0][0].detail.id).toBe("REGION01"); + + document.removeEventListener("regionReady", handler); + }); + + it("emits regionRemoved event on unmount", () => { + const handler = vi.fn(); + document.addEventListener("regionRemoved", handler); + + const { unmount } = render(); + unmount(); + + expect(handler).toHaveBeenCalledTimes(1); + expect(handler.mock.calls[0][0].detail.id).toBe("REGION01"); + + document.removeEventListener("regionRemoved", handler); + }); + + it("displays the first slide when regionContent event is dispatched", () => { + const { container } = render(); + + act(() => { + const event = new CustomEvent("regionContent-REGION01", { + detail: { + slides: [ + { executionId: "EXE-1", title: "Slide 1" }, + { executionId: "EXE-2", title: "Slide 2" }, + ], + }, + }); + document.dispatchEvent(event); + }); + + expect( + within(container).getByTestId("slide-EXE-1") + ).toBeInTheDocument(); + }); + + it("filters out invalid slides", () => { + const { container } = render(); + + act(() => { + const event = new CustomEvent("regionContent-REGION01", { + detail: { + slides: [ + { executionId: "EXE-1", title: "Valid", invalid: false }, + { executionId: "EXE-2", title: "Invalid", invalid: true }, + ], + }, + }); + document.dispatchEvent(event); + }); + + expect( + within(container).getByTestId("slide-EXE-1") + ).toBeInTheDocument(); + }); + + it("advances to next slide when slideDone is called", () => { + const { container } = render(); + + act(() => { + const event = new CustomEvent("regionContent-REGION01", { + detail: { + slides: [ + { executionId: "EXE-1", title: "Slide 1" }, + { executionId: "EXE-2", title: "Slide 2" }, + ], + }, + }); + document.dispatchEvent(event); + }); + + act(() => { + capturedSlideDone(); + }); + + expect( + within(container).getByTestId("slide-EXE-2") + ).toBeInTheDocument(); + }); + + it("wraps around to first slide after last", () => { + const { container } = render(); + + act(() => { + const event = new CustomEvent("regionContent-REGION01", { + detail: { + slides: [ + { executionId: "EXE-1", title: "Slide 1" }, + { executionId: "EXE-2", title: "Slide 2" }, + ], + }, + }); + document.dispatchEvent(event); + }); + + // Advance to EXE-2 + act(() => { + capturedSlideDone(); + }); + + // Advance past EXE-2 → wraps to EXE-1 + act(() => { + capturedSlideDone(); + }); + + expect( + within(container).getByTestId("slide-EXE-1") + ).toBeInTheDocument(); + }); + + it("emits slideDone event on document when slide completes", () => { + const handler = vi.fn(); + document.addEventListener("slideDone", handler); + + render(); + + act(() => { + const event = new CustomEvent("regionContent-REGION01", { + detail: { + slides: [{ executionId: "EXE-1", title: "Slide 1" }], + }, + }); + document.dispatchEvent(event); + }); + + act(() => { + capturedSlideDone(); + }); + + expect(handler).toHaveBeenCalledTimes(1); + expect(handler.mock.calls[0][0].detail.executionId).toBe("EXE-1"); + + document.removeEventListener("slideDone", handler); + }); + + it("renders with correct grid area style", () => { + const { container } = render(); + const el = container.querySelector(".region"); + expect(el.style.gridArea).toBe("a"); + }); +}); diff --git a/assets/tests/client/schedule-service.test.js b/assets/tests/client/schedule-service.test.js new file mode 100644 index 000000000..3908ac550 --- /dev/null +++ b/assets/tests/client/schedule-service.test.js @@ -0,0 +1,244 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; + +vi.mock("../../client/logger/logger", () => ({ + default: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), log: vi.fn() }, +})); +vi.mock("../../client/util/client-config-loader.js", () => ({ + default: { + loadConfig: vi.fn().mockResolvedValue({ schedulingInterval: 60000 }), + }, +})); + +import ScheduleService from "../../client/service/schedule-service"; + +describe("ScheduleService", () => { + describe("findScheduledSlides (static)", () => { + beforeEach(() => { + vi.useFakeTimers(); + vi.setSystemTime(new Date("2025-06-15T12:00:00Z")); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + const makeSlide = (id, published = {}) => ({ + "@id": `/v2/slides/${id}`, + "@type": "Slide", + title: `Slide ${id}`, + published, + }); + + const makePlaylist = (id, slides, options = {}) => ({ + "@id": `/v2/playlists/${id}`, + "@type": "Playlist", + title: `Playlist ${id}`, + schedules: [], + published: {}, + slidesData: slides, + ...options, + }); + + it("returns all slides from a published playlist with no schedules", () => { + const slides = [makeSlide("A"), makeSlide("B")]; + const playlists = [makePlaylist("P1", slides)]; + + const result = ScheduleService.findScheduledSlides(playlists, "region1"); + expect(result).toHaveLength(2); + expect(result[0].title).toBe("Slide A"); + expect(result[1].title).toBe("Slide B"); + }); + + it("excludes slides from unpublished playlists", () => { + const slides = [makeSlide("A")]; + const playlists = [ + makePlaylist("P1", slides, { + published: { from: "2025-06-16T00:00:00Z" }, + }), + ]; + + const result = ScheduleService.findScheduledSlides(playlists, "region1"); + expect(result).toHaveLength(0); + }); + + it("includes slides when playlist schedule is active", () => { + const slides = [makeSlide("A")]; + const playlists = [ + makePlaylist("P1", slides, { + schedules: [ + { + rrule: "DTSTART:20250601T120000Z\nRRULE:FREQ=DAILY", + duration: 3600, + }, + ], + }), + ]; + + const result = ScheduleService.findScheduledSlides(playlists, "region1"); + expect(result).toHaveLength(1); + }); + + it("excludes slides when playlist schedule is inactive", () => { + const slides = [makeSlide("A")]; + const playlists = [ + makePlaylist("P1", slides, { + schedules: [ + { + rrule: "DTSTART:20250601T080000Z\nRRULE:FREQ=DAILY", + duration: 3600, + }, + ], + }), + ]; + + // Schedule is 08:00-09:00, current time is 12:00 + const result = ScheduleService.findScheduledSlides(playlists, "region1"); + expect(result).toHaveLength(0); + }); + + it("filters unpublished slides within a published playlist", () => { + const slides = [ + makeSlide("A"), // published (no dates = always published) + makeSlide("B", { from: "2025-06-16T00:00:00Z" }), // not yet published + ]; + const playlists = [makePlaylist("P1", slides)]; + + const result = ScheduleService.findScheduledSlides(playlists, "region1"); + expect(result).toHaveLength(1); + expect(result[0].title).toBe("Slide A"); + }); + + it("sets executionId on each slide in EXE-ID-xxx format", () => { + const slides = [makeSlide("A")]; + const playlists = [makePlaylist("P1", slides)]; + + const result = ScheduleService.findScheduledSlides(playlists, "region1"); + expect(result[0].executionId).toMatch(/^EXE-ID-.+/); + }); + + it("produces deterministic executionIds for the same inputs", () => { + const slides = [makeSlide("A")]; + const playlists = [makePlaylist("P1", slides)]; + + const result1 = ScheduleService.findScheduledSlides(playlists, "region1"); + const result2 = ScheduleService.findScheduledSlides(playlists, "region1"); + expect(result1[0].executionId).toBe(result2[0].executionId); + }); + + it("produces different executionIds for different regions", () => { + const slides = [makeSlide("A")]; + const playlists = [makePlaylist("P1", slides)]; + + const result1 = ScheduleService.findScheduledSlides( + playlists, + "region1" + ); + const result2 = ScheduleService.findScheduledSlides( + playlists, + "region2" + ); + expect(result1[0].executionId).not.toBe(result2[0].executionId); + }); + + it("deep clones slides so modifications do not affect input", () => { + const originalSlide = makeSlide("A"); + const playlists = [makePlaylist("P1", [originalSlide])]; + + const result = ScheduleService.findScheduledSlides(playlists, "region1"); + result[0].title = "Modified"; + + expect(originalSlide.title).toBe("Slide A"); + }); + + it("handles multiple playlists with mixed published states", () => { + const playlists = [ + makePlaylist("P1", [makeSlide("A")]), // published + makePlaylist("P2", [makeSlide("B")], { + published: { to: "2025-06-14T00:00:00Z" }, + }), // expired + makePlaylist("P3", [makeSlide("C")]), // published + ]; + + const result = ScheduleService.findScheduledSlides(playlists, "region1"); + expect(result).toHaveLength(2); + expect(result[0].title).toBe("Slide A"); + expect(result[1].title).toBe("Slide C"); + }); + }); + + describe("instance methods", () => { + let service; + + beforeEach(() => { + service = new ScheduleService(); + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it("sendSlides dispatches regionContent custom event", () => { + const handler = vi.fn(); + document.addEventListener("regionContent-region1", handler); + + const slides = [{ "@id": "/v2/slides/A" }]; + service.sendSlides("region1", slides); + + expect(handler).toHaveBeenCalledTimes(1); + expect(handler.mock.calls[0][0].detail.slides).toEqual(slides); + + document.removeEventListener("regionContent-region1", handler); + }); + + it("checkForEmptyContent dispatches contentEmpty when no regions have slides", () => { + const handler = vi.fn(); + document.addEventListener("contentEmpty", handler); + + service.regions = { r1: { slides: [] } }; + service.contentEmpty = false; // force change detection + service.checkForEmptyContent(); + + expect(handler).toHaveBeenCalledTimes(1); + document.removeEventListener("contentEmpty", handler); + }); + + it("checkForEmptyContent dispatches contentNotEmpty when regions have slides", () => { + const handler = vi.fn(); + document.addEventListener("contentNotEmpty", handler); + + service.regions = { r1: { slides: [{ "@id": "s1" }] } }; + service.contentEmpty = true; // force change detection + service.checkForEmptyContent(); + + expect(handler).toHaveBeenCalledTimes(1); + document.removeEventListener("contentNotEmpty", handler); + }); + + it("regionRemoved clears interval and cached data", () => { + const intervalId = setInterval(() => {}, 1000); + service.intervals.region1 = intervalId; + service.regions.region1 = { hash: "abc", slides: [] }; + + service.regionRemoved("region1"); + + expect(service.intervals.region1).toBeUndefined(); + expect(service.regions.region1).toBeUndefined(); + }); + + it("updateRegion is a no-op when regionId is falsy", () => { + const handler = vi.fn(); + document.addEventListener("regionContent-undefined", handler); + + service.updateRegion(null, []); + + expect(handler).not.toHaveBeenCalled(); + document.removeEventListener("regionContent-undefined", handler); + }); + + it("updateRegion is a no-op when region is falsy", () => { + service.updateRegion("region1", null); + expect(service.regions.region1).toBeUndefined(); + }); + }); +}); diff --git a/assets/tests/client/schedule.test.js b/assets/tests/client/schedule.test.js new file mode 100644 index 000000000..07ab9b861 --- /dev/null +++ b/assets/tests/client/schedule.test.js @@ -0,0 +1,71 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import ScheduleUtils from "../../client/util/schedule"; + +describe("ScheduleUtils.occursNow", () => { + beforeEach(() => { + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it("returns true when within a daily occurrence + duration", () => { + // Fake local time to 10:30 on a Sunday + vi.setSystemTime(new Date(2025, 5, 15, 10, 30, 0)); + + // Daily at 10:00 with 1 hour duration + const rrule = "DTSTART:20250601T100000Z\nRRULE:FREQ=DAILY"; + expect(ScheduleUtils.occursNow(rrule, 3600)).toBe(true); + }); + + it("returns false when after the duration has ended", () => { + // Fake local time to 11:30 + vi.setSystemTime(new Date(2025, 5, 15, 11, 30, 0)); + + const rrule = "DTSTART:20250601T100000Z\nRRULE:FREQ=DAILY"; + expect(ScheduleUtils.occursNow(rrule, 3600)).toBe(false); + }); + + it("returns false when before the occurrence", () => { + // Fake local time to 09:30 + vi.setSystemTime(new Date(2025, 5, 15, 9, 30, 0)); + + const rrule = "DTSTART:20250601T100000Z\nRRULE:FREQ=DAILY"; + expect(ScheduleUtils.occursNow(rrule, 3600)).toBe(false); + }); + + it("returns true for a weekly rule on the correct day", () => { + // 2025-06-16 is a Monday. Fake local time to Monday 14:30 + vi.setSystemTime(new Date(2025, 5, 16, 14, 30, 0)); + + // Weekly on Mondays at 14:00 with 1 hour duration + const rrule = "DTSTART:20250602T140000Z\nRRULE:FREQ=WEEKLY;BYDAY=MO"; + expect(ScheduleUtils.occursNow(rrule, 3600)).toBe(true); + }); + + it("returns false for a weekly rule on the wrong day", () => { + // 2025-06-15 is a Sunday. Fake local time to Sunday 14:30 + vi.setSystemTime(new Date(2025, 5, 15, 14, 30, 0)); + + // Weekly on Mondays at 14:00 + const rrule = "DTSTART:20250602T140000Z\nRRULE:FREQ=WEEKLY;BYDAY=MO"; + expect(ScheduleUtils.occursNow(rrule, 3600)).toBe(false); + }); + + it("handles the escaped newline replacement (\\\\n to \\n)", () => { + vi.setSystemTime(new Date(2025, 5, 15, 10, 30, 0)); + + // Use literal \\n as the code replaces it with \n + const rrule = "DTSTART:20250601T100000Z\\nRRULE:FREQ=DAILY"; + expect(ScheduleUtils.occursNow(rrule, 3600)).toBe(true); + }); + + it("returns false with zero duration when not exactly at occurrence time", () => { + // Fake local time to 10:01 + vi.setSystemTime(new Date(2025, 5, 15, 10, 1, 0)); + + const rrule = "DTSTART:20250601T100000Z\nRRULE:FREQ=DAILY"; + expect(ScheduleUtils.occursNow(rrule, 0)).toBe(false); + }); +}); diff --git a/assets/tests/client/screen.test.jsx b/assets/tests/client/screen.test.jsx new file mode 100644 index 000000000..97db0400b --- /dev/null +++ b/assets/tests/client/screen.test.jsx @@ -0,0 +1,117 @@ +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", () => ({ + default: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), log: vi.fn() }, +})); +vi.mock("../../client/components/screen.scss", () => ({})); +vi.mock("../../client/components/region.scss", () => ({})); +vi.mock("../../client/components/touch-region.scss", () => ({})); +vi.mock("../../client/components/slide.scss", () => ({})); +vi.mock("../../client/components/error-boundary.scss", () => ({})); +vi.mock("../../client/assets/fallback.png", () => ({ + default: "fallback.png", +})); +vi.mock("../../client/assets/icon-close.svg", () => ({ + default: () => , +})); +vi.mock("../../client/assets/icon-pointer.svg", () => ({ + default: () => , +})); + +vi.mock("../../shared/grid-generator/grid-generator", () => ({ + createGrid: (cols, rows) => `"${"a ".repeat(cols).trim()}"${" ".repeat(rows)}`, + createGridArea: (gridArea) => gridArea.join(" / "), +})); + +vi.mock("../../client/util/client-config-loader.js", () => ({ + default: { + loadConfig: vi.fn().mockResolvedValue({ colorScheme: { type: "browser" } }), + }, +})); + +vi.mock("suncalc", () => ({ + default: { getTimes: vi.fn() }, +})); + +vi.mock("../../shared/slide-utils/templates.js", () => ({ + renderSlide: vi.fn().mockReturnValue(null), +})); + +vi.mock("../../client/util/id-from-path", () => ({ + default: (path) => path.split("/").pop(), +})); + +describe("Screen", () => { + const makeScreen = (regions = [], grid = { rows: 1, columns: 1 }) => ({ + "@id": "/v2/screens/SCREEN01", + "@type": "Screen", + layoutData: { + grid, + regions, + }, + }); + + it("renders a div with class screen and the screen id", () => { + const screen = makeScreen(); + const { container } = render(); + const el = container.querySelector(".screen"); + expect(el).toBeInTheDocument(); + expect(el.id).toBe("/v2/screens/SCREEN01"); + }); + + it("renders Region components for default regions", () => { + const screen = makeScreen([ + { + "@id": "/v2/layouts/regions/R1", + title: "Region 1", + gridArea: ["a"], + }, + ]); + const { container } = render(); + // Region renders a div with class "region" + expect(container.querySelector(".region")).toBeInTheDocument(); + }); + + it("renders TouchRegion for touch-buttons regions", () => { + const screen = makeScreen([ + { + "@id": "/v2/layouts/regions/R2", + title: "Touch Region", + gridArea: ["a"], + type: "touch-buttons", + }, + ]); + const { container } = render(); + expect(container.querySelector(".touch-region")).toBeInTheDocument(); + }); + + it("renders both Region and TouchRegion when mixed", () => { + const screen = makeScreen([ + { + "@id": "/v2/layouts/regions/R1", + title: "Default", + gridArea: ["a"], + }, + { + "@id": "/v2/layouts/regions/R2", + title: "Touch", + gridArea: ["b"], + type: "touch-buttons", + }, + ]); + const { container } = render(); + expect(container.querySelector(".region")).toBeInTheDocument(); + expect(container.querySelector(".touch-region")).toBeInTheDocument(); + }); + + it("applies grid styles 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(); + }); +}); diff --git a/assets/tests/client/slide.test.jsx b/assets/tests/client/slide.test.jsx new file mode 100644 index 000000000..c525e34b1 --- /dev/null +++ b/assets/tests/client/slide.test.jsx @@ -0,0 +1,109 @@ +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", () => ({ + default: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), log: vi.fn() }, +})); +vi.mock("../../client/assets/fallback.png", () => ({ + default: "fallback.png", +})); +vi.mock("../../client/components/slide.scss", () => ({})); +vi.mock("../../client/components/error-boundary.scss", () => ({})); + +const mockRenderSlide = vi.fn(); +vi.mock("../../shared/slide-utils/templates.js", () => ({ + renderSlide: (...args) => mockRenderSlide(...args), +})); + +describe("Slide", () => { + const slide = { + "@id": "/v2/slides/TEST01234567890123456789", + executionId: "EXE-ID-abc123", + title: "Test Slide", + }; + + beforeEach(() => { + vi.useFakeTimers(); + mockRenderSlide.mockReturnValue(
Content
); + }); + + afterEach(() => { + cleanup(); + vi.useRealTimers(); + }); + + it("renders with correct id and data-execution-id", () => { + const { container } = render( + + ); + + const el = container.querySelector("#slide-1"); + expect(el).toBeInTheDocument(); + expect(el.getAttribute("data-execution-id")).toBe("EXE-ID-abc123"); + expect(el.getAttribute("data-run")).toBe("12345"); + }); + + it("calls renderSlide with slide, run, and slideDone", () => { + const slideDone = vi.fn(); + render( + + ); + + expect(mockRenderSlide).toHaveBeenCalledWith(slide, "12345", slideDone); + }); + + it("renders the output of renderSlide", () => { + render( + + ); + + expect(screen.getByTestId("rendered")).toBeInTheDocument(); + }); + + it("calls slideError after 5 seconds when rendered template throws", () => { + vi.spyOn(console, "error").mockImplementation(() => {}); + + // Return a component that throws during its own render, + // so ErrorBoundary can catch it (unlike throwing in renderSlide itself). + function ThrowingTemplate() { + throw new Error("template crash"); + } + mockRenderSlide.mockReturnValue(); + + const slideError = vi.fn(); + render( + + ); + + expect(slideError).not.toHaveBeenCalled(); + + vi.advanceTimersByTime(5000); + + expect(slideError).toHaveBeenCalledWith(slide); + }); +}); diff --git a/assets/tests/client/status-service.test.js b/assets/tests/client/status-service.test.js new file mode 100644 index 000000000..06adbd7b6 --- /dev/null +++ b/assets/tests/client/status-service.test.js @@ -0,0 +1,62 @@ +import { describe, it, expect, beforeEach } from "vitest"; +import statusService from "../../client/service/status-service"; +import constants from "../../client/util/constants"; + +describe("StatusService", () => { + beforeEach(() => { + statusService.status = constants.STATUS_INIT; + statusService.error = null; + window.history.replaceState(null, "", "/"); + }); + + it("has correct initial state", () => { + expect(statusService.status).toBe("init"); + expect(statusService.error).toBeNull(); + }); + + describe("setStatus", () => { + it("updates status property", () => { + statusService.setStatus("running"); + expect(statusService.status).toBe("running"); + }); + + it("adds status to URL query params", () => { + statusService.setStatus("running"); + expect(window.location.search).toContain("status=running"); + }); + + it("removes status param when set to falsy", () => { + statusService.setStatus("running"); + statusService.setStatus(null); + expect(window.location.search).not.toContain("status="); + }); + }); + + describe("setError", () => { + it("updates error property", () => { + statusService.setError("ER101"); + expect(statusService.error).toBe("ER101"); + }); + + it("adds error to URL query params", () => { + statusService.setError("ER101"); + expect(window.location.search).toContain("error=ER101"); + }); + + it("removes error param when set to null", () => { + statusService.setError("ER101"); + statusService.setError(null); + expect(window.location.search).not.toContain("error="); + }); + }); + + describe("setStatusInUrl", () => { + it("includes both status and error when both are set", () => { + statusService.setStatus("running"); + statusService.setError("ER101"); + const params = new URLSearchParams(window.location.search); + expect(params.get("status")).toBe("running"); + expect(params.get("error")).toBe("ER101"); + }); + }); +}); diff --git a/assets/tests/client/token-service.test.js b/assets/tests/client/token-service.test.js new file mode 100644 index 000000000..555e18974 --- /dev/null +++ b/assets/tests/client/token-service.test.js @@ -0,0 +1,371 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; + +const { mockDispatch } = vi.hoisted(() => ({ + mockDispatch: vi.fn(), +})); + +vi.mock("../../client/logger/logger", () => ({ + default: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), log: vi.fn() }, +})); + +vi.mock("../../client/util/app-storage.js", () => ({ + default: { + getTokenExpire: vi.fn(), + getTokenIssueAt: vi.fn(), + getToken: vi.fn(), + getRefreshToken: vi.fn(), + setToken: vi.fn(), + setRefreshToken: vi.fn(), + setScreenId: vi.fn(), + setTenant: vi.fn(), + }, +})); + +vi.mock("../../client/service/status-service.js", () => ({ + default: { + error: null, + setError: vi.fn(), + setStatus: vi.fn(), + }, +})); + +vi.mock("../../client/util/client-config-loader.js", () => ({ + default: { + loadConfig: vi.fn().mockResolvedValue({ refreshTokenTimeout: 900000 }), + }, +})); + +vi.mock("../../client/redux/store.js", () => ({ + clientStore: { dispatch: mockDispatch }, +})); + +vi.mock("../../client/redux/generated-api.ts", () => ({ + clientApi: { + endpoints: { + postRefreshTokenItem: { + initiate: vi.fn().mockReturnValue("refreshAction"), + }, + postLoginInfoScreen: { + initiate: vi.fn().mockReturnValue("loginAction"), + }, + }, + reducerPath: "clientApi", + reducer: (state = {}) => state, + middleware: () => (next) => (action) => next(action), + }, +})); + +vi.mock("../../client/redux/empty-api.ts", () => ({ + clientEmptySplitApi: { + injectEndpoints: vi.fn().mockReturnValue({ + endpoints: {}, + }), + }, +})); + +import tokenService from "../../client/service/token-service"; +import constants from "../../client/util/constants"; +import appStorage from "../../client/util/app-storage.js"; +import statusService from "../../client/service/status-service.js"; + +describe("TokenService", () => { + beforeEach(() => { + vi.useFakeTimers(); + tokenService.refreshingToken = false; + tokenService.refreshInterval = null; + tokenService.refreshPromise = null; + statusService.error = null; + vi.clearAllMocks(); + }); + + afterEach(() => { + tokenService.stopRefreshing(); + vi.useRealTimers(); + }); + + describe("getExpireState", () => { + it("returns NO_EXPIRE when no expire in storage", () => { + appStorage.getTokenExpire.mockReturnValue(null); + appStorage.getTokenIssueAt.mockReturnValue(100); + appStorage.getToken.mockReturnValue("token"); + + expect(tokenService.getExpireState()).toBe(constants.NO_EXPIRE); + }); + + it("returns NO_ISSUED_AT when no issuedAt in storage", () => { + appStorage.getTokenExpire.mockReturnValue(200); + appStorage.getTokenIssueAt.mockReturnValue(null); + appStorage.getToken.mockReturnValue("token"); + + expect(tokenService.getExpireState()).toBe(constants.NO_ISSUED_AT); + }); + + it("returns NO_TOKEN when no token in storage", () => { + appStorage.getTokenExpire.mockReturnValue(200); + appStorage.getTokenIssueAt.mockReturnValue(100); + appStorage.getToken.mockReturnValue(null); + + expect(tokenService.getExpireState()).toBe(constants.NO_TOKEN); + }); + + it("returns TOKEN_EXPIRED when now > expire", () => { + vi.setSystemTime(new Date(1000 * 1000)); + appStorage.getTokenExpire.mockReturnValue(500); + appStorage.getTokenIssueAt.mockReturnValue(100); + appStorage.getToken.mockReturnValue("token"); + + expect(tokenService.getExpireState()).toBe(constants.TOKEN_EXPIRED); + }); + + it("returns TOKEN_VALID_SHOULD_HAVE_BEEN_REFRESHED when past 50% of lifetime", () => { + vi.setSystemTime(new Date(160 * 1000)); + appStorage.getTokenExpire.mockReturnValue(200); + appStorage.getTokenIssueAt.mockReturnValue(100); + appStorage.getToken.mockReturnValue("token"); + + expect(tokenService.getExpireState()).toBe( + constants.TOKEN_VALID_SHOULD_HAVE_BEEN_REFRESHED + ); + }); + + it("returns TOKEN_VALID when within first half of lifetime", () => { + vi.setSystemTime(new Date(120 * 1000)); + appStorage.getTokenExpire.mockReturnValue(200); + appStorage.getTokenIssueAt.mockReturnValue(100); + appStorage.getToken.mockReturnValue("token"); + + expect(tokenService.getExpireState()).toBe(constants.TOKEN_VALID); + }); + }); + + describe("ensureFreshToken", () => { + it("returns immediately when already refreshing", () => { + tokenService.refreshingToken = true; + appStorage.getRefreshToken.mockReturnValue("token"); + + tokenService.ensureFreshToken(); + + expect(mockDispatch).not.toHaveBeenCalled(); + }); + + it("sets error when refresh token is not set", () => { + appStorage.getRefreshToken.mockReturnValue(null); + appStorage.getTokenExpire.mockReturnValue(200); + appStorage.getTokenIssueAt.mockReturnValue(100); + + tokenService.ensureFreshToken(); + + expect(statusService.setError).toHaveBeenCalledWith( + constants.ERROR_TOKEN_EXP_IAT_NOT_SET + ); + }); + + it("sets error when expire is not set", () => { + appStorage.getRefreshToken.mockReturnValue("token"); + appStorage.getTokenExpire.mockReturnValue(null); + appStorage.getTokenIssueAt.mockReturnValue(100); + + tokenService.ensureFreshToken(); + + expect(statusService.setError).toHaveBeenCalledWith( + constants.ERROR_TOKEN_EXP_IAT_NOT_SET + ); + }); + + it("calls refreshToken when past 50% of token lifetime", () => { + vi.setSystemTime(new Date(160 * 1000)); + appStorage.getRefreshToken.mockReturnValue("refresh"); + appStorage.getTokenExpire.mockReturnValue(200); + appStorage.getTokenIssueAt.mockReturnValue(100); + + mockDispatch.mockReturnValue({ + unwrap: () => Promise.resolve({ token: "new", refresh_token: "new-r" }), + }); + + tokenService.ensureFreshToken(); + + expect(mockDispatch).toHaveBeenCalled(); + }); + + it("does not call refreshToken when before 50% of token lifetime", () => { + vi.setSystemTime(new Date(120 * 1000)); + appStorage.getRefreshToken.mockReturnValue("refresh"); + appStorage.getTokenExpire.mockReturnValue(200); + appStorage.getTokenIssueAt.mockReturnValue(100); + + tokenService.ensureFreshToken(); + + expect(mockDispatch).not.toHaveBeenCalled(); + }); + }); + + describe("checkToken", () => { + it("sets ERROR_TOKEN_EXPIRED when token is expired", () => { + vi.setSystemTime(new Date(1000 * 1000)); + appStorage.getTokenExpire.mockReturnValue(500); + appStorage.getTokenIssueAt.mockReturnValue(100); + appStorage.getToken.mockReturnValue("token"); + + tokenService.checkToken(); + + expect(statusService.setError).toHaveBeenCalledWith( + constants.ERROR_TOKEN_EXPIRED + ); + }); + + it("sets ERROR_TOKEN_VALID_SHOULD_HAVE_BEEN_REFRESHED when past 50%", () => { + vi.setSystemTime(new Date(160 * 1000)); + appStorage.getTokenExpire.mockReturnValue(200); + appStorage.getTokenIssueAt.mockReturnValue(100); + appStorage.getToken.mockReturnValue("token"); + + tokenService.checkToken(); + + expect(statusService.setError).toHaveBeenCalledWith( + constants.ERROR_TOKEN_VALID_SHOULD_HAVE_BEEN_REFRESHED + ); + }); + + it("clears token-related errors when token is valid", () => { + vi.setSystemTime(new Date(120 * 1000)); + appStorage.getTokenExpire.mockReturnValue(200); + appStorage.getTokenIssueAt.mockReturnValue(100); + appStorage.getToken.mockReturnValue("token"); + statusService.error = constants.TOKEN_EXPIRED; + + tokenService.checkToken(); + + expect(statusService.setError).toHaveBeenCalledWith(null); + }); + + it("does nothing when no token info exists", () => { + appStorage.getTokenExpire.mockReturnValue(null); + + tokenService.checkToken(); + + expect(statusService.setError).not.toHaveBeenCalled(); + }); + }); + + describe("checkLogin", () => { + it("stores credentials and returns ready when login is ready", async () => { + const loginData = { + status: constants.LOGIN_STATUS_READY, + token: "jwt-token", + screenId: "screen-1", + tenantKey: "tenant-key", + tenantId: "tenant-id", + refresh_token: "refresh-token", + }; + mockDispatch.mockReturnValue({ + unwrap: () => Promise.resolve(loginData), + }); + + const result = await tokenService.checkLogin(); + + expect(appStorage.setToken).toHaveBeenCalledWith("jwt-token"); + expect(appStorage.setRefreshToken).toHaveBeenCalledWith("refresh-token"); + expect(appStorage.setScreenId).toHaveBeenCalledWith("screen-1"); + expect(appStorage.setTenant).toHaveBeenCalledWith( + "tenant-key", + "tenant-id" + ); + expect(result).toEqual({ + status: constants.LOGIN_STATUS_READY, + screenId: "screen-1", + }); + }); + + it("returns bindKey when awaiting bind key", async () => { + mockDispatch.mockReturnValue({ + unwrap: () => + Promise.resolve({ + status: constants.LOGIN_STATUS_AWAITING_BIND_KEY, + bindKey: "ABCD-1234", + }), + }); + + const result = await tokenService.checkLogin(); + + expect(result).toEqual({ + status: constants.LOGIN_STATUS_AWAITING_BIND_KEY, + bindKey: "ABCD-1234", + }); + }); + + it("returns unknown status for unexpected response", async () => { + mockDispatch.mockReturnValue({ + unwrap: () => Promise.resolve({ status: "something-else" }), + }); + + const result = await tokenService.checkLogin(); + + expect(result).toEqual({ status: constants.LOGIN_STATUS_UNKNOWN }); + }); + }); + + describe("refreshToken", () => { + it("stores new token and refresh token on success", async () => { + appStorage.getRefreshToken.mockReturnValue("old-refresh"); + mockDispatch.mockReturnValue({ + unwrap: () => + Promise.resolve({ + token: "new-token", + refresh_token: "new-refresh", + }), + }); + + await tokenService.refreshToken(); + + expect(appStorage.setToken).toHaveBeenCalledWith("new-token"); + expect(appStorage.setRefreshToken).toHaveBeenCalledWith("new-refresh"); + }); + + it("resets refreshingToken flag after success", async () => { + appStorage.getRefreshToken.mockReturnValue("old-refresh"); + mockDispatch.mockReturnValue({ + unwrap: () => + Promise.resolve({ + token: "new-token", + refresh_token: "new-refresh", + }), + }); + + await tokenService.refreshToken(); + + expect(tokenService.refreshingToken).toBe(false); + expect(tokenService.refreshPromise).toBeNull(); + }); + + it("resets refreshingToken flag after failure", async () => { + appStorage.getRefreshToken.mockReturnValue("old-refresh"); + mockDispatch.mockReturnValue({ + unwrap: () => Promise.reject(new Error("401")), + }); + + await expect(tokenService.refreshToken()).rejects.toThrow("401"); + + expect(tokenService.refreshingToken).toBe(false); + expect(tokenService.refreshPromise).toBeNull(); + }); + + it("deduplicates concurrent refresh calls", async () => { + appStorage.getRefreshToken.mockReturnValue("old-refresh"); + mockDispatch.mockReturnValue({ + unwrap: () => + Promise.resolve({ + token: "new-token", + refresh_token: "new-refresh", + }), + }); + + const p1 = tokenService.refreshToken(); + const p2 = tokenService.refreshToken(); + + expect(p1).toBe(p2); + + await p1; + expect(mockDispatch).toHaveBeenCalledTimes(1); + }); + }); +}); diff --git a/assets/tests/client/touch-region.test.jsx b/assets/tests/client/touch-region.test.jsx new file mode 100644 index 000000000..c99644267 --- /dev/null +++ b/assets/tests/client/touch-region.test.jsx @@ -0,0 +1,167 @@ +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", () => ({ + default: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), log: vi.fn() }, +})); +vi.mock("../../client/components/touch-region.scss", () => ({})); +vi.mock("../../client/components/slide.scss", () => ({})); +vi.mock("../../client/components/error-boundary.scss", () => ({})); +vi.mock("../../client/assets/fallback.png", () => ({ + default: "fallback.png", +})); +vi.mock("../../client/assets/icon-close.svg", () => ({ + default: () => , +})); +vi.mock("../../client/assets/icon-pointer.svg", () => ({ + default: () => , +})); +vi.mock("../../shared/grid-generator/grid-generator", () => ({ + createGridArea: (gridArea) => gridArea.join(" / "), +})); +vi.mock("../../client/util/id-from-path", () => ({ + default: () => "TOUCH01", +})); + +let capturedSlideDone = null; +vi.mock("../../client/components/slide.jsx", () => ({ + default: ({ slide, slideDone }) => { + capturedSlideDone = () => slideDone(slide); + return
{slide.title}
; + }, +})); + +describe("TouchRegion", () => { + const region = { + "@id": "/v2/layouts/regions/TOUCH01", + gridArea: ["a"], + type: "touch-buttons", + }; + + const slides = [ + { executionId: "EXE-1", title: "Slide 1" }, + { + executionId: "EXE-2", + title: "Slide 2", + content: { touchRegionButtonText: "Press Me" }, + }, + ]; + + afterEach(() => { + capturedSlideDone = null; + cleanup(); + vi.restoreAllMocks(); + }); + + function renderAndDispatchSlides() { + const result = render(); + + act(() => { + const event = new CustomEvent("regionContent-TOUCH01", { + detail: { slides }, + }); + document.dispatchEvent(event); + }); + + return result; + } + + it("emits regionReady on mount", () => { + const handler = vi.fn(); + document.addEventListener("regionReady", handler); + + render(); + + expect(handler).toHaveBeenCalledTimes(1); + expect(handler.mock.calls[0][0].detail.id).toBe("TOUCH01"); + + document.removeEventListener("regionReady", handler); + }); + + it("emits regionRemoved on unmount", () => { + const handler = vi.fn(); + document.addEventListener("regionRemoved", handler); + + const { unmount } = render(); + unmount(); + + expect(handler).toHaveBeenCalledTimes(1); + document.removeEventListener("regionRemoved", handler); + }); + + it("renders buttons for each slide when regionContent arrives", () => { + const { container } = renderAndDispatchSlides(); + + const buttons = within(container).getAllByRole("button"); + expect(buttons.length).toBeGreaterThanOrEqual(2); + }); + + it("uses slide title for button text", () => { + const { container } = renderAndDispatchSlides(); + + expect(within(container).getByText("Slide 1")).toBeInTheDocument(); + }); + + it("uses touchRegionButtonText when available", () => { + const { container } = renderAndDispatchSlides(); + + expect(within(container).getByText("Press Me")).toBeInTheDocument(); + }); + + it("opens slide when button is clicked", () => { + const { container } = renderAndDispatchSlides(); + + act(() => { + fireEvent.click(within(container).getByText("Slide 1")); + }); + + expect(within(container).getByTestId("slide-EXE-1")).toBeInTheDocument(); + }); + + it("shows close button when slide is active", () => { + const { container } = renderAndDispatchSlides(); + + act(() => { + fireEvent.click(within(container).getByText("Slide 1")); + }); + + expect(within(container).getByText("LUK")).toBeInTheDocument(); + }); + + it("dismisses slide when close button is clicked", () => { + const { container } = renderAndDispatchSlides(); + + act(() => { + fireEvent.click(within(container).getByText("Slide 1")); + }); + + act(() => { + fireEvent.click(within(container).getByText("LUK")); + }); + + expect( + within(container).queryByTestId("slide-EXE-1") + ).not.toBeInTheDocument(); + }); + + it("emits slideDone event when slide completes", () => { + const handler = vi.fn(); + document.addEventListener("slideDone", handler); + + const { container } = renderAndDispatchSlides(); + + act(() => { + fireEvent.click(within(container).getByText("Slide 1")); + }); + + act(() => { + capturedSlideDone(); + }); + + expect(handler).toHaveBeenCalledTimes(1); + expect(handler.mock.calls[0][0].detail.executionId).toBe("EXE-1"); + + document.removeEventListener("slideDone", handler); + }); +}); From af2ab96e4d85df218f4c91caea0a9a366fd0352a Mon Sep 17 00:00:00 2001 From: Troels Ugilt Jensen <6103205+tuj@users.noreply.github.com> Date: Sun, 19 Apr 2026 22:31:30 +0200 Subject: [PATCH 18/73] 7203: Added fixed for error paths --- assets/client/data-sync/pull-strategy.js | 22 +++++++++++++++++++--- 1 file changed, 19 insertions(+), 3 deletions(-) diff --git a/assets/client/data-sync/pull-strategy.js b/assets/client/data-sync/pull-strategy.js index d8353f450..b60f72c65 100644 --- a/assets/client/data-sync/pull-strategy.js +++ b/assets/client/data-sync/pull-strategy.js @@ -167,7 +167,7 @@ class PullStrategy { ); if (screenCampaignsResponse !== null) { - screenCampaigns = screenCampaignsResponse["hydra:member"].map( + screenCampaigns = (screenCampaignsResponse["hydra:member"] ?? []).map( ({ campaign }) => campaign, ); } @@ -420,6 +420,19 @@ class PullStrategy { const newSlideChecksums = slide.relationsChecksum ?? []; const oldSlideChecksums = this.previousSlideChecksums[slideId] ?? null; + // A slide cannot work without templateInfo. Mark as invalid and skip. + if (!slide.templateInfo?.["@id"]) { + logger.warn( + `Slide (${slide["@id"]}) has no templateInfo. Marking as invalid.`, + ); + slide.templateData = null; + slide.invalid = true; + slide.mediaData = {}; + nextSlideChecksums[slideId] = newSlideChecksums; + dataEntrySlidesData[slideKey] = slide; + continue; + } + // Fetch template if it has changed. const templateChanged = checksumChanged( relationChecksumEnabled, oldSlideChecksums, newSlideChecksums, @@ -459,7 +472,7 @@ class PullStrategy { } const nextMediaData = {}; - for (const mediaPath of slide.media) { + for (const mediaPath of slide.media ?? []) { const mediaId = idFromPath(mediaPath); try { nextMediaData[mediaPath] = await query("getv2MediaById", { @@ -517,7 +530,10 @@ class PullStrategy { // Start interval for pull periodically. this.activeInterval = setInterval( - () => this.getScreen(this.entryPoint), + () => + this.getScreen(this.entryPoint).catch((err) => { + logger.error(`Content update failed: ${err.message}`); + }), this.interval, ); }) From 4e5206c458dc68c89587c847e1e848365bc26636 Mon Sep 17 00:00:00 2001 From: Troels Ugilt Jensen <6103205+tuj@users.noreply.github.com> Date: Sun, 19 Apr 2026 22:38:01 +0200 Subject: [PATCH 19/73] 7203: Changed from setInteval to setTimeout to avoid overlapping pulls --- assets/client/data-sync/pull-strategy.js | 38 +++++++++++++----------- 1 file changed, 21 insertions(+), 17 deletions(-) diff --git a/assets/client/data-sync/pull-strategy.js b/assets/client/data-sync/pull-strategy.js index b60f72c65..f287d1283 100644 --- a/assets/client/data-sync/pull-strategy.js +++ b/assets/client/data-sync/pull-strategy.js @@ -523,22 +523,26 @@ class PullStrategy { // Make sure nothing is running. this.stop(); - // Pull now. + // Pull now, then schedule the next pull after completion. + this.pull(); + } + + /** + * Run a single pull cycle, then schedule the next one. + */ + pull() { this.getScreen(this.entryPoint) - .then(() => { - this.stop(); - - // Start interval for pull periodically. - this.activeInterval = setInterval( - () => - this.getScreen(this.entryPoint).catch((err) => { - logger.error(`Content update failed: ${err.message}`); - }), - this.interval, - ); - }) .catch((err) => { - logger.error(`Failed to start data sync: ${err.message}`); + logger.error(`Content update failed: ${err.message}`); + }) + .finally(() => { + if (this.activeTimeout !== undefined) { + return; + } + this.activeTimeout = setTimeout(() => { + this.activeTimeout = undefined; + this.pull(); + }, this.interval); }); } @@ -546,9 +550,9 @@ class PullStrategy { * Stop the data synchronization. */ stop() { - if (this.activeInterval !== undefined) { - clearInterval(this.activeInterval); - this.activeInterval = undefined; + if (this.activeTimeout !== undefined) { + clearTimeout(this.activeTimeout); + this.activeTimeout = undefined; } } } From b53c1c980f408d6033efcdbc1bbe3a2a20f17186 Mon Sep 17 00:00:00 2001 From: Troels Ugilt Jensen <6103205+tuj@users.noreply.github.com> Date: Sun, 19 Apr 2026 22:42:10 +0200 Subject: [PATCH 20/73] 7203: Fixed timeout restart bug --- assets/client/data-sync/pull-strategy.js | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/assets/client/data-sync/pull-strategy.js b/assets/client/data-sync/pull-strategy.js index f287d1283..3607dc4e2 100644 --- a/assets/client/data-sync/pull-strategy.js +++ b/assets/client/data-sync/pull-strategy.js @@ -522,6 +522,7 @@ class PullStrategy { start() { // Make sure nothing is running. this.stop(); + this.stopped = false; // Pull now, then schedule the next pull after completion. this.pull(); @@ -536,7 +537,7 @@ class PullStrategy { logger.error(`Content update failed: ${err.message}`); }) .finally(() => { - if (this.activeTimeout !== undefined) { + if (this.stopped) { return; } this.activeTimeout = setTimeout(() => { @@ -550,6 +551,7 @@ class PullStrategy { * Stop the data synchronization. */ stop() { + this.stopped = true; if (this.activeTimeout !== undefined) { clearTimeout(this.activeTimeout); this.activeTimeout = undefined; From 787d6f0ef76d96a2470fe5a6ca294fc282476e97 Mon Sep 17 00:00:00 2001 From: Troels Ugilt Jensen <6103205+tuj@users.noreply.github.com> Date: Mon, 20 Apr 2026 09:43:37 +0200 Subject: [PATCH 21/73] 7203: Refactored getScreen --- assets/client/data-sync/pull-strategy.js | 481 ++++++++++--------- assets/tests/client/pull-strategy.test.js | 532 ++++++++++++++++++++++ 2 files changed, 794 insertions(+), 219 deletions(-) create mode 100644 assets/tests/client/pull-strategy.test.js diff --git a/assets/client/data-sync/pull-strategy.js b/assets/client/data-sync/pull-strategy.js index 3607dc4e2..783f3d0d0 100644 --- a/assets/client/data-sync/pull-strategy.js +++ b/assets/client/data-sync/pull-strategy.js @@ -63,7 +63,7 @@ async function queryAllPages(endpoint, args, forceRefetch = false) { return results; } - results = results.concat(responseData["hydra:member"]); + results = results.concat(responseData["hydra:member"] ?? []); if (results.length < responseData["hydra:totalItems"]) { page += 1; continueLoop = true; @@ -109,6 +109,10 @@ class PullStrategy { this.start = this.start.bind(this); this.stop = this.stop.bind(this); this.getScreen = this.getScreen.bind(this); + this.buildCampaignLayout = this.buildCampaignLayout.bind(this); + this.fetchLayoutAndRegions = this.fetchLayoutAndRegions.bind(this); + this.enrichSlides = this.enrichSlides.bind(this); + this.enrichSlide = this.enrichSlide.bind(this); this.interval = config?.interval ?? 60000 * 5; this.entryPoint = config.entryPoint; @@ -186,39 +190,34 @@ class PullStrategy { * @returns {Promise} Regions data. */ async getRegions(regions, forceRefetch) { - return new Promise((resolve, reject) => { - const promises = []; - const regionData = {}; - - regions.forEach((regionPath) => { - const matches = regionPath.match(REGION_PATH_REGEX); - if (matches) { - promises.push( - queryAllPages("getV2ScreensByIdRegionsAndRegionIdPlaylists", { - id: matches[1], - regionId: matches[2], - }, forceRefetch).then((results) => ({ - regionId: matches[2], - results, - })), - ); - } - }); - - Promise.allSettled(promises) - .then((results) => { - results.forEach((result) => { - if (result.status === "fulfilled") { - regionData[result.value.regionId] = result.value.results.map( - ({ playlist }) => playlist, - ); - } - }); + const promises = []; + const regionData = {}; + + regions.forEach((regionPath) => { + const matches = regionPath.match(REGION_PATH_REGEX); + if (matches) { + promises.push( + queryAllPages("getV2ScreensByIdRegionsAndRegionIdPlaylists", { + id: matches[1], + regionId: matches[2], + }, forceRefetch).then((results) => ({ + regionId: matches[2], + results, + })), + ); + } + }); - resolve(regionData); - }) - .catch((err) => reject(err)); + const results = await Promise.allSettled(promises); + results.forEach((result) => { + if (result.status === "fulfilled") { + regionData[result.value.regionId] = result.value.results.map( + ({ playlist }) => playlist, + ); + } }); + + return regionData; } /** @@ -229,45 +228,41 @@ class PullStrategy { * @returns {Promise} Promise with slides for the given regions. */ async getSlidesForRegions(regions, forceRefetch) { - return new Promise((resolve, reject) => { - const promises = []; - const regionData = cloneDeep(regions); + const promises = []; + const regionData = cloneDeep(regions); + // eslint-disable-next-line guard-for-in,no-restricted-syntax + for (const regionKey in regionData) { + const playlists = regionData[regionKey]; // eslint-disable-next-line guard-for-in,no-restricted-syntax - for (const regionKey in regionData) { - const playlists = regionData[regionKey]; - // eslint-disable-next-line guard-for-in,no-restricted-syntax - for (const playlistKey in playlists) { - const playlistId = idFromPath( - regionData[regionKey][playlistKey]["@id"], - ); - promises.push( - queryAllPages("getV2PlaylistsByIdSlides", { - id: playlistId, - }, forceRefetch).then((results) => ({ - regionKey, - playlistKey, - results, - })), - ); - } + for (const playlistKey in playlists) { + const playlistId = idFromPath( + regionData[regionKey][playlistKey]["@id"], + ); + promises.push( + queryAllPages("getV2PlaylistsByIdSlides", { + id: playlistId, + }, forceRefetch).then((results) => ({ + regionKey, + playlistKey, + results, + })), + ); } + } - Promise.allSettled(promises) - .then((results) => { - results.forEach((result) => { - if (result.status === "fulfilled") { - regionData[result.value.regionKey][ - result.value.playlistKey - ].slidesData = result.value.results.map( - (playlistSlide) => playlistSlide.slide, - ); - } - }); - resolve(regionData); - }) - .catch((err) => reject(err)); + const results = await Promise.allSettled(promises); + results.forEach((result) => { + if (result.status === "fulfilled") { + regionData[result.value.regionKey][ + result.value.playlistKey + ].slidesData = result.value.results.map( + (playlistSlide) => playlistSlide.slide, + ); + } }); + + return regionData; } /** @@ -326,83 +321,131 @@ class PullStrategy { // With active campaigns, we override region/layout values. if (newScreen.hasActiveCampaign) { - logger.info(`Has active campaign.`); + await this.buildCampaignLayout(newScreen); + } else { + const success = await this.fetchLayoutAndRegions( + newScreen, newScreenChecksums, relationChecksumEnabled, + ); + if (!success) return; + } + + const nextSlideChecksums = await this.enrichSlides( + newScreen.regionData, relationChecksumEnabled, + ); - // Use a static ID to connect the campaign with the regions/playlists. - const campaignRegionId = CAMPAIGN_REGION_ID; + this.previousScreenChecksums = newScreen.relationsChecksum ?? null; + this.previousSlideChecksums = nextSlideChecksums; + this.previousHadActiveCampaign = newScreen.hasActiveCampaign; + + // Deliver result to rendering + const event = new CustomEvent("content", { + detail: { + screen: newScreen, + }, + }); + document.dispatchEvent(event); + } - // Campaigns are always in full screen layout, for simplicity. - newScreen.layoutData = { - grid: { - rows: 1, - columns: 1, + /** + * Build a synthetic full-screen layout for active campaigns. + * + * @param {object} screen The screen object to mutate. + */ + async buildCampaignLayout(screen) { + logger.info(`Has active campaign.`); + + const campaignRegionId = CAMPAIGN_REGION_ID; + + screen.layoutData = { + grid: { + rows: 1, + columns: 1, + }, + regions: [ + { + "@id": `/v2/layouts/regions/${campaignRegionId}`, + gridArea: ["a"], }, - regions: [ - { - "@id": `/v2/layouts/regions/${campaignRegionId}`, - gridArea: ["a"], - }, - ], - }; - - newScreen.regionData = {}; - newScreen.regionData[campaignRegionId] = newScreen.campaignsData; - newScreen.regions = [ - `/v2/screens/01FV9K4K0Y0X0K1J88SQ6B64VT/regions/${campaignRegionId}/playlists`, - ]; - newScreen.regionData = await this.getSlidesForRegions( - newScreen.regionData, - true, - ); - } else { - logger.info(`Has no active campaign.`); + ], + }; - const layoutChanged = - this.previousHadActiveCampaign || - checksumChanged( - relationChecksumEnabled, this.previousScreenChecksums, newScreenChecksums, - ["layout"], - ); + screen.regionData = {}; + screen.regionData[campaignRegionId] = screen.campaignsData; + const screenId = idFromPath(screen["@id"]); + screen.regions = [ + `/v2/screens/${screenId}/regions/${campaignRegionId}/playlists`, + ]; + screen.regionData = await this.getSlidesForRegions( + screen.regionData, + true, + ); + } - if (layoutChanged) { - logger.info(`Fetching layout.`); - } + /** + * Fetch layout and regions for the normal (non-campaign) path. + * + * @param {object} screen The screen object to mutate. + * @param {object} newScreenChecksums Current screen checksums. + * @param {boolean} relationChecksumEnabled Whether checksum comparison is enabled. + * @returns {Promise} False if content update should be aborted. + */ + async fetchLayoutAndRegions(screen, newScreenChecksums, relationChecksumEnabled) { + logger.info(`Has no active campaign.`); + + const layoutChanged = + this.previousHadActiveCampaign || + checksumChanged( + relationChecksumEnabled, this.previousScreenChecksums, newScreenChecksums, + ["layout"], + ); - try { - newScreen.layoutData = await query("getV2LayoutsById", { - id: idFromPath(newScreen.layout), - }, layoutChanged); - } catch (err) { - logger.warn( - `Layout (${newScreen.layout}) not loaded. Aborting content update.`, - ); - return; - } + if (layoutChanged) { + logger.info(`Fetching layout.`); + } - if (newScreen.layoutData === null) { - logger.warn( - `Layout (${newScreen.layout}) not loaded. Aborting content update.`, - ); - return; - } + try { + screen.layoutData = await query("getV2LayoutsById", { + id: idFromPath(screen.layout), + }, layoutChanged); + } catch (err) { + logger.warn( + `Layout (${screen.layout}) not loaded. Aborting content update.`, + ); + return false; + } - const regionsChanged = - this.previousHadActiveCampaign || - checksumChanged( - relationChecksumEnabled, this.previousScreenChecksums, newScreenChecksums, - ["regions"], - ); + if (screen.layoutData === null) { + logger.warn( + `Layout (${screen.layout}) not loaded. Aborting content update.`, + ); + return false; + } - if (regionsChanged) { - logger.info(`Fetching regions and slides for regions.`); - } + const regionsChanged = + this.previousHadActiveCampaign || + checksumChanged( + relationChecksumEnabled, this.previousScreenChecksums, newScreenChecksums, + ["regions"], + ); - const regions = await this.getRegions(newScreen.regions, regionsChanged); - newScreen.regionData = await this.getSlidesForRegions(regions, regionsChanged); + if (regionsChanged) { + logger.info(`Fetching regions and slides for regions.`); } - // Iterate all slides and load required relations. - const { regionData } = newScreen; + const regions = await this.getRegions(screen.regions ?? [], regionsChanged); + screen.regionData = await this.getSlidesForRegions(regions, regionsChanged); + + return true; + } + + /** + * Enrich all slides in regionData with template, media, and feed data. + * + * @param {object} regionData Regions containing playlists with slides. + * @param {boolean} relationChecksumEnabled Whether checksum comparison is enabled. + * @returns {Promise} Updated slide checksums. + */ + async enrichSlides(regionData, relationChecksumEnabled) { const nextSlideChecksums = {}; /* eslint-disable no-restricted-syntax,no-await-in-loop */ @@ -411,90 +454,14 @@ class PullStrategy { for (const playlistKey of Object.keys(regionDataEntry)) { const dataEntryPlaylist = regionDataEntry[playlistKey]; - const dataEntrySlidesData = dataEntryPlaylist.slidesData; + const dataEntrySlidesData = dataEntryPlaylist.slidesData ?? []; for (const slideKey of Object.keys(dataEntrySlidesData)) { const slide = cloneDeep(dataEntrySlidesData[slideKey]); const slideId = slide["@id"]; - const newSlideChecksums = slide.relationsChecksum ?? []; - const oldSlideChecksums = this.previousSlideChecksums[slideId] ?? null; - - // A slide cannot work without templateInfo. Mark as invalid and skip. - if (!slide.templateInfo?.["@id"]) { - logger.warn( - `Slide (${slide["@id"]}) has no templateInfo. Marking as invalid.`, - ); - slide.templateData = null; - slide.invalid = true; - slide.mediaData = {}; - nextSlideChecksums[slideId] = newSlideChecksums; - dataEntrySlidesData[slideKey] = slide; - continue; - } - - // Fetch template if it has changed. - const templateChanged = checksumChanged( - relationChecksumEnabled, oldSlideChecksums, newSlideChecksums, - ["templateInfo"], - ); - const templateId = idFromPath(slide.templateInfo["@id"]); - - if (templateChanged) { - logger.info(`Fetching template data.`); - } - - try { - slide.templateData = await query("getV2TemplatesById", { - id: templateId, - }, templateChanged); - } catch (err) { - slide.templateData = null; - } - - // A slide cannot work without templateData. Mark as invalid. - if (slide.templateData === null) { - logger.warn( - `Template (${slide.templateInfo["@id"]}) not loaded, slideId: ${slide["@id"]}`, - ); - slide.invalid = true; - } - - // Fetch media if it has changed. - const mediaChanged = checksumChanged( - relationChecksumEnabled, oldSlideChecksums, newSlideChecksums, - ["media"], - ); - - if (mediaChanged) { - logger.info(`Fetching media data.`); - } - - const nextMediaData = {}; - for (const mediaPath of slide.media ?? []) { - const mediaId = idFromPath(mediaPath); - try { - nextMediaData[mediaPath] = await query("getv2MediaById", { - id: mediaId, - }, mediaChanged); - } catch (err) { - nextMediaData[mediaPath] = null; - } - } - slide.mediaData = nextMediaData; - - // Fetch feed — always forceRefetch (no checksum, needs fresh data). - if (slide?.feed?.feedUrl !== undefined) { - logger.info(`Fetching feed data.`); - try { - slide.feedData = await query("getV2FeedsByIdData", { - id: idFromPath(slide.feed.feedUrl), - }, true); - } catch (err) { - slide.feedData = null; - } - } + await this.enrichSlide(slide, relationChecksumEnabled); nextSlideChecksums[slideId] = newSlideChecksums; dataEntrySlidesData[slideKey] = slide; @@ -503,17 +470,93 @@ class PullStrategy { } /* eslint-enable no-restricted-syntax,no-await-in-loop */ - this.previousScreenChecksums = newScreen.relationsChecksum ?? null; - this.previousSlideChecksums = nextSlideChecksums; - this.previousHadActiveCampaign = newScreen.hasActiveCampaign; + return nextSlideChecksums; + } - // Deliver result to rendering - const event = new CustomEvent("content", { - detail: { - screen: newScreen, - }, - }); - document.dispatchEvent(event); + /** + * Enrich a single slide with template, media, and feed data. + * + * @param {object} slide The slide object to mutate. + * @param {boolean} relationChecksumEnabled Whether checksum comparison is enabled. + */ + async enrichSlide(slide, relationChecksumEnabled) { + const slideId = slide["@id"]; + const newSlideChecksums = slide.relationsChecksum ?? []; + const oldSlideChecksums = this.previousSlideChecksums[slideId] ?? null; + + // A slide cannot work without templateInfo. Mark as invalid and skip. + if (!slide.templateInfo?.["@id"]) { + logger.warn( + `Slide (${slide["@id"]}) has no templateInfo. Marking as invalid.`, + ); + slide.templateData = null; + slide.invalid = true; + slide.mediaData = {}; + return; + } + + // Fetch template if it has changed. + const templateChanged = checksumChanged( + relationChecksumEnabled, oldSlideChecksums, newSlideChecksums, + ["templateInfo"], + ); + + const templateId = idFromPath(slide.templateInfo["@id"]); + + if (templateChanged) { + logger.info(`Fetching template data.`); + } + + try { + slide.templateData = await query("getV2TemplatesById", { + id: templateId, + }, templateChanged); + } catch (err) { + slide.templateData = null; + } + + // A slide cannot work without templateData. Mark as invalid. + if (slide.templateData === null) { + logger.warn( + `Template (${slide.templateInfo["@id"]}) not loaded, slideId: ${slide["@id"]}`, + ); + slide.invalid = true; + } + + // Fetch media if it has changed. + const mediaChanged = checksumChanged( + relationChecksumEnabled, oldSlideChecksums, newSlideChecksums, + ["media"], + ); + + if (mediaChanged) { + logger.info(`Fetching media data.`); + } + + const nextMediaData = {}; + for (const mediaPath of slide.media ?? []) { + const mediaId = idFromPath(mediaPath); + try { + nextMediaData[mediaPath] = await query("getv2MediaById", { + id: mediaId, + }, mediaChanged); + } catch (err) { + nextMediaData[mediaPath] = null; + } + } + slide.mediaData = nextMediaData; + + // Fetch feed — always forceRefetch (no checksum, needs fresh data). + if (slide?.feed?.feedUrl !== undefined) { + logger.info(`Fetching feed data.`); + try { + slide.feedData = await query("getV2FeedsByIdData", { + id: idFromPath(slide.feed.feedUrl), + }, true); + } catch (err) { + slide.feedData = null; + } + } } /** diff --git a/assets/tests/client/pull-strategy.test.js b/assets/tests/client/pull-strategy.test.js new file mode 100644 index 000000000..d619b7681 --- /dev/null +++ b/assets/tests/client/pull-strategy.test.js @@ -0,0 +1,532 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; + +// --- Hoisted mocks --- +const { mockDispatch, endpoints } = vi.hoisted(() => { + const mockDispatch = vi.fn(); + const endpoints = {}; + [ + "getV2ScreensById", + "getV2ScreensByIdScreenGroups", + "getV2ScreenGroupsByIdCampaigns", + "getV2ScreensByIdCampaigns", + "getV2LayoutsById", + "getV2ScreensByIdRegionsAndRegionIdPlaylists", + "getV2PlaylistsByIdSlides", + "getV2TemplatesById", + "getv2MediaById", + "getV2FeedsByIdData", + ].forEach((name) => { + endpoints[name] = { + initiate: (args, opts) => ({ _endpoint: name, _args: args, _opts: opts }), + }; + }); + return { mockDispatch, endpoints }; +}); + +vi.mock("../../client/logger/logger", () => ({ + default: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), log: vi.fn() }, +})); + +vi.mock("../../client/util/client-config-loader.js", () => ({ + default: { loadConfig: vi.fn() }, +})); + +vi.mock("../../client/redux/store.js", () => ({ + clientStore: { dispatch: mockDispatch }, +})); + +vi.mock("../../client/redux/generated-api.ts", () => ({ + clientApi: { + endpoints, + reducerPath: "clientApi", + reducer: (state = {}) => state, + middleware: () => (next) => (action) => next(action), + }, +})); + +import PullStrategy from "../../client/data-sync/pull-strategy"; +import logger from "../../client/logger/logger"; +import ClientConfigLoader from "../../client/util/client-config-loader.js"; + +// --- Test IDs (26 alphanumeric chars each, required by idFromPath) --- +const SCREEN_ID = "SCREEN0001AAAAAAAAAAAAAAAA"; +const LAYOUT_ID = "LAYOUT0001AAAAAAAAAAAAAAAA"; +const REGION_ID = "REGION0001AAAAAAAAAAAAAAAA"; +const PLAYLIST_ID = "PLAYLS0001AAAAAAAAAAAAAAAA"; +const SLIDE_ID = "SLIDES0001AAAAAAAAAAAAAAAA"; +const TEMPLATE_ID = "TEMPLT0001AAAAAAAAAAAAAAAA"; +const MEDIA_ID_1 = "MEDIAS0001AAAAAAAAAAAAAAAA"; +const MEDIA_ID_2 = "MEDIAS0002AAAAAAAAAAAAAAAA"; +const FEED_ID = "FEEDSS0001AAAAAAAAAAAAAAAA"; +const CAMPAIGN_REGION_ID = "01G112XBWFPY029RYFB8X2H4KD"; + +const SCREEN_PATH = `/v2/screens/${SCREEN_ID}`; + +// --- Factories --- +function makeScreen(overrides = {}) { + return { + "@id": SCREEN_PATH, + layout: `/v2/layouts/${LAYOUT_ID}`, + regions: [ + `/v2/screens/${SCREEN_ID}/regions/${REGION_ID}/playlists`, + ], + relationsChecksum: { + campaigns: "aaa", + inScreenGroups: "bbb", + layout: "ccc", + regions: "ddd", + }, + ...overrides, + }; +} + +function makeSlide(id = SLIDE_ID, overrides = {}) { + return { + "@id": `/v2/slides/${id}`, + templateInfo: { "@id": `/v2/templates/${TEMPLATE_ID}` }, + media: [], + relationsChecksum: { templateInfo: "t1", media: "m1" }, + ...overrides, + }; +} + +function makePlaylist(id = PLAYLIST_ID) { + return { "@id": `/v2/playlists/${id}` }; +} + +function makeLayout() { + return { + "@id": `/v2/layouts/${LAYOUT_ID}`, + grid: { rows: 1, columns: 1 }, + regions: [ + { "@id": `/v2/layouts/regions/${REGION_ID}`, gridArea: ["a"] }, + ], + }; +} + +function makeTemplateData() { + return { "@id": `/v2/templates/${TEMPLATE_ID}`, resources: {} }; +} + +function hydra(members) { + return { "hydra:member": members, "hydra:totalItems": members.length }; +} + +// --- Response helpers --- +function setupResponses(responseMap) { + mockDispatch.mockImplementation((action) => ({ + unwrap: () => { + const handler = responseMap[action._endpoint]; + if (handler === undefined) { + return Promise.reject( + new Error(`No mock response for ${action._endpoint}`), + ); + } + if (typeof handler === "function") { + return handler(action._args, action._opts); + } + return Promise.resolve(handler); + }, + })); +} + +function setupBasicResponses(overrides = {}) { + setupResponses({ + getV2ScreensById: makeScreen(), + getV2ScreensByIdScreenGroups: { "hydra:member": [] }, + getV2ScreensByIdCampaigns: { "hydra:member": [] }, + getV2LayoutsById: makeLayout(), + getV2ScreensByIdRegionsAndRegionIdPlaylists: hydra([ + { playlist: makePlaylist() }, + ]), + getV2PlaylistsByIdSlides: hydra([{ slide: makeSlide() }]), + getV2TemplatesById: makeTemplateData(), + ...overrides, + }); +} + +// --- Event capture --- +function captureContentEvent() { + const captured = { screen: null, callCount: 0 }; + const handler = (e) => { + captured.screen = e.detail.screen; + captured.callCount += 1; + }; + document.addEventListener("content", handler); + return { + get screen() { + return captured.screen; + }, + get callCount() { + return captured.callCount; + }, + cleanup() { + document.removeEventListener("content", handler); + }, + }; +} + +// --- Dispatch call inspection --- +function getDispatchCallsFor(endpoint) { + return mockDispatch.mock.calls + .filter(([action]) => action._endpoint === endpoint) + .map(([action]) => ({ + args: action._args, + forceRefetch: action._opts?.forceRefetch, + })); +} + +// --- Tests --- +describe("PullStrategy.getScreen", () => { + let strategy; + let contentEvent; + + beforeEach(() => { + vi.useFakeTimers(); + vi.setSystemTime(new Date("2025-06-15T12:00:00Z")); + vi.clearAllMocks(); + + ClientConfigLoader.loadConfig.mockResolvedValue({ + relationsChecksumEnabled: false, + }); + + strategy = new PullStrategy({ + entryPoint: SCREEN_PATH, + interval: 60000, + }); + contentEvent = captureContentEvent(); + }); + + afterEach(() => { + contentEvent.cleanup(); + vi.useRealTimers(); + }); + + describe("early returns", () => { + it("aborts when screen fetch throws", async () => { + setupResponses({ + getV2ScreensById: () => Promise.reject(new Error("Network error")), + }); + + await strategy.getScreen(SCREEN_PATH); + + expect(contentEvent.callCount).toBe(0); + expect(logger.warn).toHaveBeenCalledWith( + expect.stringContaining("not loaded. Aborting content update"), + ); + }); + + it("aborts when screen is null", async () => { + setupResponses({ + getV2ScreensById: null, + }); + + await strategy.getScreen(SCREEN_PATH); + + expect(contentEvent.callCount).toBe(0); + expect(logger.warn).toHaveBeenCalledWith( + expect.stringContaining("not loaded"), + ); + }); + }); + + describe("active campaign path", () => { + it("builds synthetic layout when campaign is active", async () => { + const activeCampaign = { + "@id": `/v2/playlists/${PLAYLIST_ID}`, + published: {}, + }; + + setupResponses({ + getV2ScreensById: makeScreen(), + getV2ScreensByIdScreenGroups: { "hydra:member": [] }, + getV2ScreensByIdCampaigns: { + "hydra:member": [{ campaign: activeCampaign }], + }, + getV2PlaylistsByIdSlides: hydra([{ slide: makeSlide() }]), + getV2TemplatesById: makeTemplateData(), + }); + + await strategy.getScreen(SCREEN_PATH); + + expect(contentEvent.callCount).toBe(1); + const { screen } = contentEvent; + expect(screen.hasActiveCampaign).toBe(true); + expect(screen.layoutData.grid).toEqual({ rows: 1, columns: 1 }); + expect(screen.layoutData.regions[0]["@id"]).toContain( + CAMPAIGN_REGION_ID, + ); + }); + + it("falls through to normal path when campaign is expired", async () => { + const expiredCampaign = { + "@id": `/v2/playlists/${PLAYLIST_ID}`, + published: { to: "2020-01-01T00:00:00Z" }, + }; + + setupBasicResponses({ + getV2ScreensByIdCampaigns: { + "hydra:member": [{ campaign: expiredCampaign }], + }, + }); + + await strategy.getScreen(SCREEN_PATH); + + expect(contentEvent.callCount).toBe(1); + expect(contentEvent.screen.hasActiveCampaign).toBe(false); + expect(contentEvent.screen.layoutData["@id"]).toContain(LAYOUT_ID); + }); + }); + + describe("normal path", () => { + it("fetches layout, regions, and slides", async () => { + setupBasicResponses(); + + await strategy.getScreen(SCREEN_PATH); + + expect(contentEvent.callCount).toBe(1); + const { screen } = contentEvent; + expect(screen.hasActiveCampaign).toBe(false); + expect(screen.layoutData["@id"]).toContain(LAYOUT_ID); + expect(screen.regionData[REGION_ID]).toBeDefined(); + + const slide = screen.regionData[REGION_ID][0].slidesData[0]; + expect(slide.templateData).toEqual(makeTemplateData()); + expect(slide.mediaData).toEqual({}); + }); + + it("aborts when layout fetch throws", async () => { + setupResponses({ + getV2ScreensById: makeScreen(), + getV2ScreensByIdScreenGroups: { "hydra:member": [] }, + getV2ScreensByIdCampaigns: { "hydra:member": [] }, + getV2LayoutsById: () => Promise.reject(new Error("Layout error")), + }); + + await strategy.getScreen(SCREEN_PATH); + + expect(contentEvent.callCount).toBe(0); + expect(logger.warn).toHaveBeenCalledWith( + expect.stringContaining("not loaded. Aborting content update"), + ); + }); + + it("aborts when layout is null", async () => { + setupResponses({ + getV2ScreensById: makeScreen(), + getV2ScreensByIdScreenGroups: { "hydra:member": [] }, + getV2ScreensByIdCampaigns: { "hydra:member": [] }, + getV2LayoutsById: null, + }); + + await strategy.getScreen(SCREEN_PATH); + + expect(contentEvent.callCount).toBe(0); + expect(logger.warn).toHaveBeenCalledWith( + expect.stringContaining("not loaded. Aborting content update"), + ); + }); + }); + + describe("slide enrichment", () => { + it("marks slide as invalid when templateInfo is missing", async () => { + const slideNoTemplate = makeSlide(SLIDE_ID, { templateInfo: {} }); + + setupBasicResponses({ + getV2PlaylistsByIdSlides: hydra([{ slide: slideNoTemplate }]), + }); + + await strategy.getScreen(SCREEN_PATH); + + expect(contentEvent.callCount).toBe(1); + const slide = + contentEvent.screen.regionData[REGION_ID][0].slidesData[0]; + expect(slide.invalid).toBe(true); + expect(slide.templateData).toBeNull(); + expect(slide.mediaData).toEqual({}); + }); + + it("marks slide as invalid when template fetch fails", async () => { + setupBasicResponses({ + getV2TemplatesById: () => + Promise.reject(new Error("Template error")), + }); + + await strategy.getScreen(SCREEN_PATH); + + expect(contentEvent.callCount).toBe(1); + const slide = + contentEvent.screen.regionData[REGION_ID][0].slidesData[0]; + expect(slide.invalid).toBe(true); + expect(slide.templateData).toBeNull(); + }); + + it("fetches media for each media path", async () => { + const slideWithMedia = makeSlide(SLIDE_ID, { + media: [`/v2/media/${MEDIA_ID_1}`, `/v2/media/${MEDIA_ID_2}`], + }); + const media1 = { "@id": `/v2/media/${MEDIA_ID_1}`, title: "img1" }; + const media2 = { "@id": `/v2/media/${MEDIA_ID_2}`, title: "img2" }; + + setupBasicResponses({ + getV2PlaylistsByIdSlides: hydra([{ slide: slideWithMedia }]), + getv2MediaById: (args) => { + if (args.id === MEDIA_ID_1) return Promise.resolve(media1); + if (args.id === MEDIA_ID_2) return Promise.resolve(media2); + return Promise.reject(new Error("Unknown media")); + }, + }); + + await strategy.getScreen(SCREEN_PATH); + + const slide = + contentEvent.screen.regionData[REGION_ID][0].slidesData[0]; + expect(slide.mediaData[`/v2/media/${MEDIA_ID_1}`]).toEqual(media1); + expect(slide.mediaData[`/v2/media/${MEDIA_ID_2}`]).toEqual(media2); + }); + + it("sets null for failed media fetch", async () => { + const slideWithMedia = makeSlide(SLIDE_ID, { + media: [`/v2/media/${MEDIA_ID_1}`, `/v2/media/${MEDIA_ID_2}`], + }); + const media1 = { "@id": `/v2/media/${MEDIA_ID_1}`, title: "img1" }; + + setupBasicResponses({ + getV2PlaylistsByIdSlides: hydra([{ slide: slideWithMedia }]), + getv2MediaById: (args) => { + if (args.id === MEDIA_ID_1) return Promise.resolve(media1); + return Promise.reject(new Error("Not found")); + }, + }); + + await strategy.getScreen(SCREEN_PATH); + + const slide = + contentEvent.screen.regionData[REGION_ID][0].slidesData[0]; + expect(slide.mediaData[`/v2/media/${MEDIA_ID_1}`]).toEqual(media1); + expect(slide.mediaData[`/v2/media/${MEDIA_ID_2}`]).toBeNull(); + }); + + it("fetches feed data when feedUrl is present", async () => { + const slideWithFeed = makeSlide(SLIDE_ID, { + feed: { feedUrl: `/v2/feeds/${FEED_ID}` }, + }); + const feedData = { data: [{ title: "Feed item" }] }; + + setupBasicResponses({ + getV2PlaylistsByIdSlides: hydra([{ slide: slideWithFeed }]), + getV2FeedsByIdData: feedData, + }); + + await strategy.getScreen(SCREEN_PATH); + + const slide = + contentEvent.screen.regionData[REGION_ID][0].slidesData[0]; + expect(slide.feedData).toEqual(feedData); + }); + + it("sets feedData to null when feed fetch fails", async () => { + const slideWithFeed = makeSlide(SLIDE_ID, { + feed: { feedUrl: `/v2/feeds/${FEED_ID}` }, + }); + + setupBasicResponses({ + getV2FeedsByIdData: () => Promise.reject(new Error("Feed error")), + getV2PlaylistsByIdSlides: hydra([{ slide: slideWithFeed }]), + }); + + await strategy.getScreen(SCREEN_PATH); + + const slide = + contentEvent.screen.regionData[REGION_ID][0].slidesData[0]; + expect(slide.feedData).toBeNull(); + }); + }); + + describe("checksum caching", () => { + beforeEach(() => { + ClientConfigLoader.loadConfig.mockResolvedValue({ + relationsChecksumEnabled: true, + }); + }); + + it("force-fetches everything on first call", async () => { + setupBasicResponses(); + + await strategy.getScreen(SCREEN_PATH); + + const layoutCalls = getDispatchCallsFor("getV2LayoutsById"); + expect(layoutCalls[0].forceRefetch).toBe(true); + }); + + it("skips refetch on second call with unchanged checksums", async () => { + setupBasicResponses(); + await strategy.getScreen(SCREEN_PATH); + + mockDispatch.mockClear(); + setupBasicResponses(); + await strategy.getScreen(SCREEN_PATH); + + const layoutCalls = getDispatchCallsFor("getV2LayoutsById"); + expect(layoutCalls[0].forceRefetch).toBe(false); + }); + + it("refetches when checksum changes", async () => { + setupBasicResponses(); + await strategy.getScreen(SCREEN_PATH); + + mockDispatch.mockClear(); + setupBasicResponses({ + getV2ScreensById: makeScreen({ + relationsChecksum: { + campaigns: "aaa", + inScreenGroups: "bbb", + layout: "CHANGED", + regions: "ddd", + }, + }), + }); + await strategy.getScreen(SCREEN_PATH); + + const layoutCalls = getDispatchCallsFor("getV2LayoutsById"); + expect(layoutCalls[0].forceRefetch).toBe(true); + }); + }); + + describe("campaign-to-normal transition", () => { + it("force-fetches layout after campaign ends", async () => { + ClientConfigLoader.loadConfig.mockResolvedValue({ + relationsChecksumEnabled: true, + }); + + // First call: active campaign + const activeCampaign = { + "@id": `/v2/playlists/${PLAYLIST_ID}`, + published: {}, + }; + + setupResponses({ + getV2ScreensById: makeScreen(), + getV2ScreensByIdScreenGroups: { "hydra:member": [] }, + getV2ScreensByIdCampaigns: { + "hydra:member": [{ campaign: activeCampaign }], + }, + getV2PlaylistsByIdSlides: hydra([{ slide: makeSlide() }]), + getV2TemplatesById: makeTemplateData(), + }); + + await strategy.getScreen(SCREEN_PATH); + expect(contentEvent.screen.hasActiveCampaign).toBe(true); + + // Second call: no campaign, same checksums + mockDispatch.mockClear(); + setupBasicResponses(); + + await strategy.getScreen(SCREEN_PATH); + + expect(contentEvent.screen.hasActiveCampaign).toBe(false); + const layoutCalls = getDispatchCallsFor("getV2LayoutsById"); + expect(layoutCalls[0].forceRefetch).toBe(true); + }); + }); +}); From 7d04d75f72b8b0a926c43928ca54797ef8917b5e Mon Sep 17 00:00:00 2001 From: Troels Ugilt Jensen <6103205+tuj@users.noreply.github.com> Date: Mon, 20 Apr 2026 10:03:38 +0200 Subject: [PATCH 22/73] 7228: Fixed checksums default --- assets/client/data-sync/pull-strategy.js | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/assets/client/data-sync/pull-strategy.js b/assets/client/data-sync/pull-strategy.js index 783f3d0d0..989a6436c 100644 --- a/assets/client/data-sync/pull-strategy.js +++ b/assets/client/data-sync/pull-strategy.js @@ -298,7 +298,7 @@ class PullStrategy { newScreen.hasActiveCampaign = false; - const newScreenChecksums = newScreen?.relationsChecksum ?? []; + const newScreenChecksums = newScreen?.relationsChecksum ?? {}; // Determine which resources need fresh data based on checksum changes. const campaignsChanged = checksumChanged( @@ -459,7 +459,7 @@ class PullStrategy { for (const slideKey of Object.keys(dataEntrySlidesData)) { const slide = cloneDeep(dataEntrySlidesData[slideKey]); const slideId = slide["@id"]; - const newSlideChecksums = slide.relationsChecksum ?? []; + const newSlideChecksums = slide.relationsChecksum ?? {}; await this.enrichSlide(slide, relationChecksumEnabled); @@ -481,7 +481,7 @@ class PullStrategy { */ async enrichSlide(slide, relationChecksumEnabled) { const slideId = slide["@id"]; - const newSlideChecksums = slide.relationsChecksum ?? []; + const newSlideChecksums = slide.relationsChecksum ?? {}; const oldSlideChecksums = this.previousSlideChecksums[slideId] ?? null; // A slide cannot work without templateInfo. Mark as invalid and skip. From 6677de81aee590f9d2f2e72b32c576661cdcd227 Mon Sep 17 00:00:00 2001 From: Troels Ugilt Jensen <6103205+tuj@users.noreply.github.com> Date: Mon, 20 Apr 2026 10:07:12 +0200 Subject: [PATCH 23/73] 7228: Handled case where idFromPath returns false --- assets/client/data-sync/pull-strategy.js | 40 +++++++++++++++++++++--- 1 file changed, 35 insertions(+), 5 deletions(-) diff --git a/assets/client/data-sync/pull-strategy.js b/assets/client/data-sync/pull-strategy.js index 989a6436c..0ac4dd5d0 100644 --- a/assets/client/data-sync/pull-strategy.js +++ b/assets/client/data-sync/pull-strategy.js @@ -129,6 +129,11 @@ class PullStrategy { const screenGroupCampaigns = []; const screenId = idFromPath(screen["@id"]); + if (!screenId) { + logger.warn(`Could not extract screen ID from ${screen["@id"]}`); + return []; + } + try { const response = await query("getV2ScreensByIdScreenGroups", { id: screenId, @@ -142,6 +147,7 @@ class PullStrategy { response["hydra:member"].forEach((group) => { const groupId = idFromPath(group["@id"]); + if (!groupId) return; promises.push( queryAllPages("getV2ScreenGroupsByIdCampaigns", { id: groupId }, forceRefetch), ); @@ -239,6 +245,7 @@ class PullStrategy { const playlistId = idFromPath( regionData[regionKey][playlistKey]["@id"], ); + if (!playlistId) continue; promises.push( queryAllPages("getV2PlaylistsByIdSlides", { id: playlistId, @@ -273,10 +280,16 @@ class PullStrategy { async getScreen(screenPath) { let screen; + const screenId = idFromPath(screenPath); + if (!screenId) { + logger.warn(`Could not extract screen ID from ${screenPath}. Aborting content update.`); + return; + } + // Always forceRefetch the screen to get fresh checksums. try { screen = await query("getV2ScreensById", { - id: idFromPath(screenPath), + id: screenId, }, true); } catch (err) { logger.warn( @@ -371,9 +384,9 @@ class PullStrategy { screen.regionData = {}; screen.regionData[campaignRegionId] = screen.campaignsData; - const screenId = idFromPath(screen["@id"]); + const campaignScreenId = idFromPath(screen["@id"]); screen.regions = [ - `/v2/screens/${screenId}/regions/${campaignRegionId}/playlists`, + `/v2/screens/${campaignScreenId}/regions/${campaignRegionId}/playlists`, ]; screen.regionData = await this.getSlidesForRegions( screen.regionData, @@ -403,9 +416,15 @@ class PullStrategy { logger.info(`Fetching layout.`); } + const layoutId = idFromPath(screen.layout); + if (!layoutId) { + logger.warn(`Could not extract layout ID from ${screen.layout}. Aborting content update.`); + return false; + } + try { screen.layoutData = await query("getV2LayoutsById", { - id: idFromPath(screen.layout), + id: layoutId, }, layoutChanged); } catch (err) { logger.warn( @@ -503,6 +522,14 @@ class PullStrategy { const templateId = idFromPath(slide.templateInfo["@id"]); + if (!templateId) { + logger.warn(`Could not extract template ID from ${slide.templateInfo["@id"]}. Marking slide as invalid.`); + slide.templateData = null; + slide.invalid = true; + slide.mediaData = {}; + return; + } + if (templateChanged) { logger.info(`Fetching template data.`); } @@ -536,6 +563,7 @@ class PullStrategy { const nextMediaData = {}; for (const mediaPath of slide.media ?? []) { const mediaId = idFromPath(mediaPath); + if (!mediaId) continue; try { nextMediaData[mediaPath] = await query("getv2MediaById", { id: mediaId, @@ -548,10 +576,12 @@ class PullStrategy { // Fetch feed — always forceRefetch (no checksum, needs fresh data). if (slide?.feed?.feedUrl !== undefined) { + const feedId = idFromPath(slide.feed.feedUrl); + if (!feedId) return; logger.info(`Fetching feed data.`); try { slide.feedData = await query("getV2FeedsByIdData", { - id: idFromPath(slide.feed.feedUrl), + id: feedId, }, true); } catch (err) { slide.feedData = null; From 0322aac4c4b2a9ff1434e364ab25641c23feb1c8 Mon Sep 17 00:00:00 2001 From: Troels Ugilt Jensen <6103205+tuj@users.noreply.github.com> Date: Mon, 20 Apr 2026 10:10:58 +0200 Subject: [PATCH 24/73] 7228: Use campaignChanged to decide whether to refetch campaign region data --- assets/client/data-sync/pull-strategy.js | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/assets/client/data-sync/pull-strategy.js b/assets/client/data-sync/pull-strategy.js index 0ac4dd5d0..0086a4f9f 100644 --- a/assets/client/data-sync/pull-strategy.js +++ b/assets/client/data-sync/pull-strategy.js @@ -334,7 +334,8 @@ class PullStrategy { // With active campaigns, we override region/layout values. if (newScreen.hasActiveCampaign) { - await this.buildCampaignLayout(newScreen); + const forceRefetch = !this.previousHadActiveCampaign || campaignsChanged; + await this.buildCampaignLayout(newScreen, forceRefetch); } else { const success = await this.fetchLayoutAndRegions( newScreen, newScreenChecksums, relationChecksumEnabled, @@ -363,8 +364,9 @@ class PullStrategy { * Build a synthetic full-screen layout for active campaigns. * * @param {object} screen The screen object to mutate. + * @param {boolean} forceRefetch Whether to bypass RTK Query cache. */ - async buildCampaignLayout(screen) { + async buildCampaignLayout(screen, forceRefetch) { logger.info(`Has active campaign.`); const campaignRegionId = CAMPAIGN_REGION_ID; @@ -390,7 +392,7 @@ class PullStrategy { ]; screen.regionData = await this.getSlidesForRegions( screen.regionData, - true, + forceRefetch, ); } From 2fcf4ad6e60930389318ae85081de7bb045c26e5 Mon Sep 17 00:00:00 2001 From: Troels Ugilt Jensen <6103205+tuj@users.noreply.github.com> Date: Mon, 20 Apr 2026 10:12:30 +0200 Subject: [PATCH 25/73] 7228: Added early break when no template is set for slide --- assets/client/data-sync/pull-strategy.js | 2 ++ 1 file changed, 2 insertions(+) diff --git a/assets/client/data-sync/pull-strategy.js b/assets/client/data-sync/pull-strategy.js index 0086a4f9f..d3a6343c4 100644 --- a/assets/client/data-sync/pull-strategy.js +++ b/assets/client/data-sync/pull-strategy.js @@ -550,6 +550,8 @@ class PullStrategy { `Template (${slide.templateInfo["@id"]}) not loaded, slideId: ${slide["@id"]}`, ); slide.invalid = true; + slide.mediaData = {}; + return; } // Fetch media if it has changed. From b66dc396d913133bb6f1ab54ffb24770a62afebb Mon Sep 17 00:00:00 2001 From: Troels Ugilt Jensen <6103205+tuj@users.noreply.github.com> Date: Mon, 20 Apr 2026 10:20:51 +0200 Subject: [PATCH 26/73] 7228: Fixed relationsChecksum default --- assets/client/data-sync/pull-strategy.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/assets/client/data-sync/pull-strategy.js b/assets/client/data-sync/pull-strategy.js index d3a6343c4..77e4e6626 100644 --- a/assets/client/data-sync/pull-strategy.js +++ b/assets/client/data-sync/pull-strategy.js @@ -347,7 +347,7 @@ class PullStrategy { newScreen.regionData, relationChecksumEnabled, ); - this.previousScreenChecksums = newScreen.relationsChecksum ?? null; + this.previousScreenChecksums = newScreen.relationsChecksum ?? {}; this.previousSlideChecksums = nextSlideChecksums; this.previousHadActiveCampaign = newScreen.hasActiveCampaign; From 52267d508e318380a3c4a6b50106250388dacd7f Mon Sep 17 00:00:00 2001 From: Troels Ugilt Jensen <6103205+tuj@users.noreply.github.com> Date: Mon, 20 Apr 2026 10:22:17 +0200 Subject: [PATCH 27/73] 7228: Added bind to stop --- assets/client/data-sync/data-sync.js | 1 + 1 file changed, 1 insertion(+) diff --git a/assets/client/data-sync/data-sync.js b/assets/client/data-sync/data-sync.js index b8d6ea153..430bfb914 100644 --- a/assets/client/data-sync/data-sync.js +++ b/assets/client/data-sync/data-sync.js @@ -14,6 +14,7 @@ class DataSync { */ constructor(config) { this.start = this.start.bind(this); + this.stop = this.stop.bind(this); this.config = config; this.strategy = new PullStrategy(this.config); From 562646358ee4613bb2ef8b0d632149823e7c96ba Mon Sep 17 00:00:00 2001 From: Troels Ugilt Jensen <6103205+tuj@users.noreply.github.com> Date: Mon, 20 Apr 2026 10:25:12 +0200 Subject: [PATCH 28/73] 7228: Fixed parallelization of fetches --- assets/client/data-sync/pull-strategy.js | 43 ++++++++++++++---------- 1 file changed, 25 insertions(+), 18 deletions(-) diff --git a/assets/client/data-sync/pull-strategy.js b/assets/client/data-sync/pull-strategy.js index 77e4e6626..06d48d5c5 100644 --- a/assets/client/data-sync/pull-strategy.js +++ b/assets/client/data-sync/pull-strategy.js @@ -468,8 +468,8 @@ class PullStrategy { */ async enrichSlides(regionData, relationChecksumEnabled) { const nextSlideChecksums = {}; + const promises = []; - /* eslint-disable no-restricted-syntax,no-await-in-loop */ for (const regionKey of Object.keys(regionData)) { const regionDataEntry = regionData[regionKey]; @@ -479,17 +479,18 @@ class PullStrategy { for (const slideKey of Object.keys(dataEntrySlidesData)) { const slide = cloneDeep(dataEntrySlidesData[slideKey]); - const slideId = slide["@id"]; - const newSlideChecksums = slide.relationsChecksum ?? {}; - - await this.enrichSlide(slide, relationChecksumEnabled); - nextSlideChecksums[slideId] = newSlideChecksums; - dataEntrySlidesData[slideKey] = slide; + promises.push( + this.enrichSlide(slide, relationChecksumEnabled).then(() => { + nextSlideChecksums[slide["@id"]] = slide.relationsChecksum ?? {}; + dataEntrySlidesData[slideKey] = slide; + }), + ); } } } - /* eslint-enable no-restricted-syntax,no-await-in-loop */ + + await Promise.allSettled(promises); return nextSlideChecksums; } @@ -564,18 +565,24 @@ class PullStrategy { logger.info(`Fetching media data.`); } + const mediaEntries = (slide.media ?? []) + .map((mediaPath) => ({ mediaPath, mediaId: idFromPath(mediaPath) })) + .filter(({ mediaId }) => mediaId); + + const mediaResults = await Promise.allSettled( + mediaEntries.map(({ mediaPath, mediaId }) => + query("getv2MediaById", { id: mediaId }, mediaChanged) + .then((data) => ({ mediaPath, data })) + .catch(() => ({ mediaPath, data: null })), + ), + ); + const nextMediaData = {}; - for (const mediaPath of slide.media ?? []) { - const mediaId = idFromPath(mediaPath); - if (!mediaId) continue; - try { - nextMediaData[mediaPath] = await query("getv2MediaById", { - id: mediaId, - }, mediaChanged); - } catch (err) { - nextMediaData[mediaPath] = null; + mediaResults.forEach((result) => { + if (result.status === "fulfilled") { + nextMediaData[result.value.mediaPath] = result.value.data; } - } + }); slide.mediaData = nextMediaData; // Fetch feed — always forceRefetch (no checksum, needs fresh data). From acb644449ffd962cf071dc41c0a536c62b856179 Mon Sep 17 00:00:00 2001 From: Troels Ugilt Jensen <6103205+tuj@users.noreply.github.com> Date: Mon, 20 Apr 2026 10:37:00 +0200 Subject: [PATCH 29/73] 7203: Added guards against infinite loops in queryAllPages --- assets/client/data-sync/pull-strategy.js | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/assets/client/data-sync/pull-strategy.js b/assets/client/data-sync/pull-strategy.js index 06d48d5c5..1638b2185 100644 --- a/assets/client/data-sync/pull-strategy.js +++ b/assets/client/data-sync/pull-strategy.js @@ -49,10 +49,11 @@ function query(endpoint, args, forceRefetch = false) { * @param {boolean} forceRefetch Whether to bypass RTK Query cache. * @returns {Promise} All hydra:member results concatenated. */ +const MAX_PAGES = 50; + async function queryAllPages(endpoint, args, forceRefetch = false) { let results = []; let page = 1; - let continueLoop = false; do { try { @@ -64,11 +65,11 @@ async function queryAllPages(endpoint, args, forceRefetch = false) { } results = results.concat(responseData["hydra:member"] ?? []); - if (results.length < responseData["hydra:totalItems"]) { + + if (responseData["hydra:view"]?.["hydra:next"]) { page += 1; - continueLoop = true; } else { - continueLoop = false; + break; } } catch (err) { logger.error( @@ -76,7 +77,11 @@ async function queryAllPages(endpoint, args, forceRefetch = false) { ); return results; } - } while (continueLoop); + } while (page <= MAX_PAGES); + + if (page > MAX_PAGES) { + logger.warn(`Reached max page limit (${MAX_PAGES}) for ${endpoint}`); + } return results; } From 4acf8324ae4c2d4dcd3354276ad117c1bb509533 Mon Sep 17 00:00:00 2001 From: Troels Ugilt Jensen <6103205+tuj@users.noreply.github.com> Date: Mon, 20 Apr 2026 10:53:21 +0200 Subject: [PATCH 30/73] 7228: Fixed naming issue in api spec --- .../admin/components/slide/slide-manager.jsx | 2 +- assets/admin/redux/generated-api.ts | 12 ++--- assets/client/data-sync/pull-strategy.js | 2 +- assets/client/redux/generated-api.ts | 44 ++++++++++++++++--- assets/client/service/content-service.js | 2 +- assets/tests/client/pull-strategy.test.js | 6 +-- config/api_platform/media.yaml | 2 +- public/api-spec-v2.json | 2 +- public/api-spec-v2.yaml | 2 +- 9 files changed, 53 insertions(+), 21 deletions(-) diff --git a/assets/admin/components/slide/slide-manager.jsx b/assets/admin/components/slide/slide-manager.jsx index 7ff5c1501..e1bb046a8 100644 --- a/assets/admin/components/slide/slide-manager.jsx +++ b/assets/admin/components/slide/slide-manager.jsx @@ -292,7 +292,7 @@ function SlideManager({ localFormStateObject.media.forEach((media) => { promises.push( dispatch( - enhancedApi.endpoints.getv2MediaById.initiate({ + enhancedApi.endpoints.getV2MediaById.initiate({ id: idFromUrl(media), }), ), diff --git a/assets/admin/redux/generated-api.ts b/assets/admin/redux/generated-api.ts index 63959ac3e..005a2151f 100644 --- a/assets/admin/redux/generated-api.ts +++ b/assets/admin/redux/generated-api.ts @@ -237,9 +237,9 @@ const injectedRtkApi = api }), invalidatesTags: ["Media"], }), - getv2MediaById: build.query< - Getv2MediaByIdApiResponse, - Getv2MediaByIdApiArg + getV2MediaById: build.query< + GetV2MediaByIdApiResponse, + GetV2MediaByIdApiArg >({ query: (queryArg) => ({ url: `/v2/media/${queryArg.id}` }), providesTags: ["Media"], @@ -1144,8 +1144,8 @@ export type PostMediaCollectionApiArg = { file: Blob; }; }; -export type Getv2MediaByIdApiResponse = /** status 200 OK */ Blob; -export type Getv2MediaByIdApiArg = { +export type GetV2MediaByIdApiResponse = /** status 200 OK */ Blob; +export type GetV2MediaByIdApiArg = { id: string; }; export type DeleteV2MediaByIdApiResponse = unknown; @@ -2634,7 +2634,7 @@ export const { useLoginCheckPostMutation, useGetV2MediaQuery, usePostMediaCollectionMutation, - useGetv2MediaByIdQuery, + useGetV2MediaByIdQuery, useDeleteV2MediaByIdMutation, useGetV2CampaignsByIdScreenGroupsQuery, useGetV2CampaignsByIdScreensQuery, diff --git a/assets/client/data-sync/pull-strategy.js b/assets/client/data-sync/pull-strategy.js index 1638b2185..f56abc80d 100644 --- a/assets/client/data-sync/pull-strategy.js +++ b/assets/client/data-sync/pull-strategy.js @@ -576,7 +576,7 @@ class PullStrategy { const mediaResults = await Promise.allSettled( mediaEntries.map(({ mediaPath, mediaId }) => - query("getv2MediaById", { id: mediaId }, mediaChanged) + query("getV2MediaById", { id: mediaId }, mediaChanged) .then((data) => ({ mediaPath, data })) .catch(() => ({ mediaPath, data: null })), ), diff --git a/assets/client/redux/generated-api.ts b/assets/client/redux/generated-api.ts index 59d8c88ca..5f80ee8f5 100644 --- a/assets/client/redux/generated-api.ts +++ b/assets/client/redux/generated-api.ts @@ -237,9 +237,9 @@ const injectedRtkApi = api }), invalidatesTags: ["Media"], }), - getv2MediaById: build.query< - Getv2MediaByIdApiResponse, - Getv2MediaByIdApiArg + getV2MediaById: build.query< + GetV2MediaByIdApiResponse, + GetV2MediaByIdApiArg >({ query: (queryArg) => ({ url: `/v2/media/${queryArg.id}` }), providesTags: ["Media"], @@ -1144,8 +1144,8 @@ export type PostMediaCollectionApiArg = { file: Blob; }; }; -export type Getv2MediaByIdApiResponse = /** status 200 OK */ Blob; -export type Getv2MediaByIdApiArg = { +export type GetV2MediaByIdApiResponse = /** status 200 OK */ Blob; +export type GetV2MediaByIdApiArg = { id: string; }; export type DeleteV2MediaByIdApiResponse = unknown; @@ -1950,6 +1950,7 @@ export type PlaylistJsonldCampaignsScreenGroupsRead = { campaignScreenGroups?: CollectionJsonldCampaignsScreenGroupsRead | null; tenants?: CollectionJsonldCampaignsScreenGroupsRead | null; isCampaign?: boolean; + slidesLength?: number | null; published?: string[]; relationsChecksum?: object; }; @@ -1971,6 +1972,7 @@ export type PlaylistJsonldCampaignsScreenGroupsReadRead = { campaignScreenGroups?: CollectionJsonldCampaignsScreenGroupsReadRead | null; tenants?: CollectionJsonldCampaignsScreenGroupsReadRead | null; isCampaign?: boolean; + slidesLength?: number | null; published?: string[]; relationsChecksum?: object; }; @@ -1979,6 +1981,8 @@ export type ScreenGroupJsonldCampaignsScreenGroupsRead = { description?: string; campaigns?: string; screens?: string; + screensLength?: number | null; + campaignsLength?: number | null; relationsChecksum?: object; }; export type ScreenGroupJsonldCampaignsScreenGroupsReadRead = { @@ -1995,6 +1999,8 @@ export type ScreenGroupJsonldCampaignsScreenGroupsReadRead = { description?: string; campaigns?: string; screens?: string; + screensLength?: number | null; + campaignsLength?: number | null; relationsChecksum?: object; }; export type ScreenGroupCampaignJsonldCampaignsScreenGroupsRead = { @@ -2030,6 +2036,7 @@ export type PlaylistJsonldCampaignsScreensRead = { campaignScreenGroups?: CollectionJsonldCampaignsScreensRead | null; tenants?: CollectionJsonldCampaignsScreensRead | null; isCampaign?: boolean; + slidesLength?: number | null; published?: string[]; relationsChecksum?: object; }; @@ -2051,6 +2058,7 @@ export type PlaylistJsonldCampaignsScreensReadRead = { campaignScreenGroups?: CollectionJsonldCampaignsScreensReadRead | null; tenants?: CollectionJsonldCampaignsScreensReadRead | null; isCampaign?: boolean; + slidesLength?: number | null; published?: string[]; relationsChecksum?: object; }; @@ -2068,6 +2076,9 @@ export type ScreenJsonldCampaignsScreensRead = { screenUser?: string | null; enableColorSchemeChange?: boolean | null; status?: string[] | null; + activeCampaignsLength?: number | null; + campaignsLength?: number | null; + inScreenGroupsLength?: number | null; relationsChecksum?: object; }; export type ScreenJsonldCampaignsScreensReadRead = { @@ -2093,6 +2104,9 @@ export type ScreenJsonldCampaignsScreensReadRead = { screenUser?: string | null; enableColorSchemeChange?: boolean | null; status?: string[] | null; + activeCampaignsLength?: number | null; + campaignsLength?: number | null; + inScreenGroupsLength?: number | null; relationsChecksum?: object; }; export type ScreenCampaignJsonldCampaignsScreensRead = { @@ -2116,6 +2130,7 @@ export type PlaylistPlaylistJsonld = { campaignScreenGroups?: CollectionJsonld | null; tenants?: CollectionJsonld | null; isCampaign?: boolean; + slidesLength?: number | null; published?: string[]; modifiedBy?: string; createdBy?: string; @@ -2142,6 +2157,7 @@ export type PlaylistPlaylistJsonldRead = { campaignScreenGroups?: CollectionJsonldRead | null; tenants?: CollectionJsonldRead | null; isCampaign?: boolean; + slidesLength?: number | null; published?: string[]; modifiedBy?: string; createdBy?: string; @@ -2207,6 +2223,8 @@ export type ScreenGroupScreenGroupJsonld = { description?: string; campaigns?: string; screens?: string; + screensLength?: number | null; + campaignsLength?: number | null; modifiedBy?: string; createdBy?: string; id?: string; @@ -2228,6 +2246,8 @@ export type ScreenGroupScreenGroupJsonldRead = { description?: string; campaigns?: string; screens?: string; + screensLength?: number | null; + campaignsLength?: number | null; modifiedBy?: string; createdBy?: string; id?: string; @@ -2261,6 +2281,9 @@ export type ScreenScreenJsonld = { screenUser?: string | null; enableColorSchemeChange?: boolean | null; status?: string[] | null; + activeCampaignsLength?: number | null; + campaignsLength?: number | null; + inScreenGroupsLength?: number | null; modifiedBy?: string; createdBy?: string; id?: string; @@ -2291,6 +2314,9 @@ export type ScreenScreenJsonldRead = { screenUser?: string | null; enableColorSchemeChange?: boolean | null; status?: string[] | null; + activeCampaignsLength?: number | null; + campaignsLength?: number | null; + inScreenGroupsLength?: number | null; modifiedBy?: string; createdBy?: string; id?: string; @@ -2334,6 +2360,7 @@ export type PlaylistJsonldPlaylistScreenRegionRead = { campaignScreenGroups?: CollectionJsonldPlaylistScreenRegionRead | null; tenants?: CollectionJsonldPlaylistScreenRegionRead | null; isCampaign?: boolean; + slidesLength?: number | null; published?: string[]; relationsChecksum?: object; }; @@ -2355,6 +2382,7 @@ export type PlaylistJsonldPlaylistScreenRegionReadRead = { campaignScreenGroups?: CollectionJsonldPlaylistScreenRegionReadRead | null; tenants?: CollectionJsonldPlaylistScreenRegionReadRead | null; isCampaign?: boolean; + slidesLength?: number | null; published?: string[]; relationsChecksum?: object; }; @@ -2375,6 +2403,8 @@ export type ScreenGroupScreenGroupJsonldScreensScreenGroupsRead = { description?: string; campaigns?: string; screens?: string; + screensLength?: number | null; + campaignsLength?: number | null; relationsChecksum?: object; }; export type ScreenGroupScreenGroupJsonldScreensScreenGroupsReadRead = { @@ -2384,6 +2414,8 @@ export type ScreenGroupScreenGroupJsonldScreensScreenGroupsReadRead = { description?: string; campaigns?: string; screens?: string; + screensLength?: number | null; + campaignsLength?: number | null; relationsChecksum?: object; }; export type SlideSlideJsonld = { @@ -2602,7 +2634,7 @@ export const { useLoginCheckPostMutation, useGetV2MediaQuery, usePostMediaCollectionMutation, - useGetv2MediaByIdQuery, + useGetV2MediaByIdQuery, useDeleteV2MediaByIdMutation, useGetV2CampaignsByIdScreenGroupsQuery, useGetV2CampaignsByIdScreensQuery, diff --git a/assets/client/service/content-service.js b/assets/client/service/content-service.js index 6855241e1..f7b8c6978 100644 --- a/assets/client/service/content-service.js +++ b/assets/client/service/content-service.js @@ -288,7 +288,7 @@ class ContentService { // eslint-disable-next-line no-restricted-syntax for (const media of slide.media) { // eslint-disable-next-line no-await-in-loop - slide.mediaData[media] = await ContentService.query("getv2MediaById", { + slide.mediaData[media] = await ContentService.query("getV2MediaById", { id: idFromPath(media), }); } diff --git a/assets/tests/client/pull-strategy.test.js b/assets/tests/client/pull-strategy.test.js index d619b7681..7f2986f03 100644 --- a/assets/tests/client/pull-strategy.test.js +++ b/assets/tests/client/pull-strategy.test.js @@ -13,7 +13,7 @@ const { mockDispatch, endpoints } = vi.hoisted(() => { "getV2ScreensByIdRegionsAndRegionIdPlaylists", "getV2PlaylistsByIdSlides", "getV2TemplatesById", - "getv2MediaById", + "getV2MediaById", "getV2FeedsByIdData", ].forEach((name) => { endpoints[name] = { @@ -370,7 +370,7 @@ describe("PullStrategy.getScreen", () => { setupBasicResponses({ getV2PlaylistsByIdSlides: hydra([{ slide: slideWithMedia }]), - getv2MediaById: (args) => { + getV2MediaById: (args) => { if (args.id === MEDIA_ID_1) return Promise.resolve(media1); if (args.id === MEDIA_ID_2) return Promise.resolve(media2); return Promise.reject(new Error("Unknown media")); @@ -393,7 +393,7 @@ describe("PullStrategy.getScreen", () => { setupBasicResponses({ getV2PlaylistsByIdSlides: hydra([{ slide: slideWithMedia }]), - getv2MediaById: (args) => { + getV2MediaById: (args) => { if (args.id === MEDIA_ID_1) return Promise.resolve(media1); return Promise.reject(new Error("Not found")); }, diff --git a/config/api_platform/media.yaml b/config/api_platform/media.yaml index ef1c96254..d527a20ca 100644 --- a/config/api_platform/media.yaml +++ b/config/api_platform/media.yaml @@ -13,7 +13,7 @@ resources: openapiContext: description: Retrieves a Media resource. summary: Retrieve a Media resource. - operationId: getv2MediaById + operationId: get-v2-media-by-id tags: - Media parameters: diff --git a/public/api-spec-v2.json b/public/api-spec-v2.json index df8506599..1cc5f7dab 100644 --- a/public/api-spec-v2.json +++ b/public/api-spec-v2.json @@ -1651,7 +1651,7 @@ }, "/v2/media/{id}": { "get": { - "operationId": "getv2MediaById", + "operationId": "get-v2-media-by-id", "tags": [ "Media" ], diff --git a/public/api-spec-v2.yaml b/public/api-spec-v2.yaml index 9c1b21eb1..757cd8f64 100644 --- a/public/api-spec-v2.yaml +++ b/public/api-spec-v2.yaml @@ -1259,7 +1259,7 @@ paths: deprecated: false '/v2/media/{id}': get: - operationId: getv2MediaById + operationId: get-v2-media-by-id tags: - Media responses: From 68cf86d6408b5d12e87e0beefca1d1da41b7d7d0 Mon Sep 17 00:00:00 2001 From: Troels Ugilt Jensen <6103205+tuj@users.noreply.github.com> Date: Mon, 20 Apr 2026 11:06:17 +0200 Subject: [PATCH 31/73] 7228: Fallback to cached data on query errors --- assets/client/data-sync/pull-strategy.js | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/assets/client/data-sync/pull-strategy.js b/assets/client/data-sync/pull-strategy.js index f56abc80d..371d94306 100644 --- a/assets/client/data-sync/pull-strategy.js +++ b/assets/client/data-sync/pull-strategy.js @@ -38,7 +38,17 @@ function checksumChanged(enabled, oldChecksums, newChecksums, fields) { function query(endpoint, args, forceRefetch = false) { return clientStore .dispatch(clientApi.endpoints[endpoint].initiate(args, { forceRefetch })) - .unwrap(); + .unwrap() + .catch((err) => { + const cached = clientApi.endpoints[endpoint].select(args)( + clientStore.getState(), + ); + if (cached?.data) { + logger.warn(`Using cached data for ${endpoint} after fetch failure.`); + return cached.data; + } + throw err; + }); } /** From 17202b273eb3eee9adeeaa980c16926c43eb4353 Mon Sep 17 00:00:00 2001 From: Troels Ugilt Jensen <6103205+tuj@users.noreply.github.com> Date: Mon, 20 Apr 2026 11:46:53 +0200 Subject: [PATCH 32/73] 7228: Added keepUnusedDataFor to 30 days --- assets/client/redux/empty-api.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/assets/client/redux/empty-api.ts b/assets/client/redux/empty-api.ts index 1c1070440..95625c590 100644 --- a/assets/client/redux/empty-api.ts +++ b/assets/client/redux/empty-api.ts @@ -4,5 +4,6 @@ import clientBaseQuery from "./base-query"; export const clientEmptySplitApi = createApi({ reducerPath: "clientApi", baseQuery: clientBaseQuery, + keepUnusedDataFor: 2592000, // 30 days endpoints: () => ({}), }); From e982605d63f20ec3f10dd8e97e9bd771f47dd229 Mon Sep 17 00:00:00 2001 From: Troels Ugilt Jensen <6103205+tuj@users.noreply.github.com> Date: Mon, 20 Apr 2026 11:50:33 +0200 Subject: [PATCH 33/73] 7228: Added guard against idFromPath = false --- assets/client/data-sync/pull-strategy.js | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/assets/client/data-sync/pull-strategy.js b/assets/client/data-sync/pull-strategy.js index 371d94306..d94b5ab37 100644 --- a/assets/client/data-sync/pull-strategy.js +++ b/assets/client/data-sync/pull-strategy.js @@ -402,6 +402,10 @@ class PullStrategy { screen.regionData = {}; screen.regionData[campaignRegionId] = screen.campaignsData; const campaignScreenId = idFromPath(screen["@id"]); + if (!campaignScreenId) { + logger.warn(`Could not extract screen ID from ${screen["@id"]} for campaign layout.`); + return; + } screen.regions = [ `/v2/screens/${campaignScreenId}/regions/${campaignRegionId}/playlists`, ]; From e4686ad1e172afda3a86423091129cea2f6a6494 Mon Sep 17 00:00:00 2001 From: Troels Ugilt Jensen <6103205+tuj@users.noreply.github.com> Date: Mon, 20 Apr 2026 11:51:54 +0200 Subject: [PATCH 34/73] 7228: Changed to fetch all pages --- assets/client/data-sync/pull-strategy.js | 39 +++++++++++------------- 1 file changed, 17 insertions(+), 22 deletions(-) diff --git a/assets/client/data-sync/pull-strategy.js b/assets/client/data-sync/pull-strategy.js index d94b5ab37..a60b11079 100644 --- a/assets/client/data-sync/pull-strategy.js +++ b/assets/client/data-sync/pull-strategy.js @@ -150,34 +150,29 @@ class PullStrategy { } try { - const response = await query("getV2ScreensByIdScreenGroups", { + const response = await queryAllPages("getV2ScreensByIdScreenGroups", { id: screenId, }, forceRefetch); - if ( - response !== null && - Object.prototype.hasOwnProperty.call(response, "hydra:member") - ) { - const promises = []; + const promises = []; - response["hydra:member"].forEach((group) => { - const groupId = idFromPath(group["@id"]); - if (!groupId) return; - promises.push( - queryAllPages("getV2ScreenGroupsByIdCampaigns", { id: groupId }, forceRefetch), - ); - }); + response.forEach((group) => { + const groupId = idFromPath(group["@id"]); + if (!groupId) return; + promises.push( + queryAllPages("getV2ScreenGroupsByIdCampaigns", { id: groupId }, forceRefetch), + ); + }); - await Promise.allSettled(promises).then((results) => { - results.forEach((result) => { - if (result.status === "fulfilled") { - result.value.forEach(({ campaign }) => { - screenGroupCampaigns.push(campaign); - }); - } - }); + await Promise.allSettled(promises).then((results) => { + results.forEach((result) => { + if (result.status === "fulfilled") { + result.value.forEach(({ campaign }) => { + screenGroupCampaigns.push(campaign); + }); + } }); - } + }); } catch (err) { logger.error(err); } From 3c1a26a38580852ca452aa248db6bf757d99f7bc Mon Sep 17 00:00:00 2001 From: Troels Ugilt Jensen <6103205+tuj@users.noreply.github.com> Date: Mon, 20 Apr 2026 11:52:51 +0200 Subject: [PATCH 35/73] 7228: Fixed campaign deduplication --- assets/client/data-sync/pull-strategy.js | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/assets/client/data-sync/pull-strategy.js b/assets/client/data-sync/pull-strategy.js index a60b11079..6964cad42 100644 --- a/assets/client/data-sync/pull-strategy.js +++ b/assets/client/data-sync/pull-strategy.js @@ -195,7 +195,14 @@ class PullStrategy { logger.error(err); } - return [...screenCampaigns, ...screenGroupCampaigns]; + const allCampaigns = [...screenCampaigns, ...screenGroupCampaigns]; + const seen = new Set(); + return allCampaigns.filter((campaign) => { + const id = campaign["@id"]; + if (seen.has(id)) return false; + seen.add(id); + return true; + }); } /** From f81db421484777ba853c9901e7eb1fa365ec4a3b Mon Sep 17 00:00:00 2001 From: Troels Ugilt Jensen <6103205+tuj@users.noreply.github.com> Date: Mon, 20 Apr 2026 11:54:00 +0200 Subject: [PATCH 36/73] 7228: Fixed dispatch unsubscribe --- assets/client/data-sync/pull-strategy.js | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/assets/client/data-sync/pull-strategy.js b/assets/client/data-sync/pull-strategy.js index 6964cad42..2ba27d963 100644 --- a/assets/client/data-sync/pull-strategy.js +++ b/assets/client/data-sync/pull-strategy.js @@ -36,8 +36,10 @@ function checksumChanged(enabled, oldChecksums, newChecksums, fields) { * @returns {Promise} The result data. */ function query(endpoint, args, forceRefetch = false) { - return clientStore - .dispatch(clientApi.endpoints[endpoint].initiate(args, { forceRefetch })) + const request = clientStore.dispatch( + clientApi.endpoints[endpoint].initiate(args, { forceRefetch }), + ); + return request .unwrap() .catch((err) => { const cached = clientApi.endpoints[endpoint].select(args)( @@ -48,6 +50,9 @@ function query(endpoint, args, forceRefetch = false) { return cached.data; } throw err; + }) + .finally(() => { + request.unsubscribe(); }); } From f54962a2bae155bb5e740c8ce7440655e5402950 Mon Sep 17 00:00:00 2001 From: Troels Ugilt Jensen <6103205+tuj@users.noreply.github.com> Date: Mon, 20 Apr 2026 12:07:01 +0200 Subject: [PATCH 37/73] 7228: Cleaned up implementation --- assets/client/data-sync/pull-strategy.js | 22 --- assets/client/service/content-service.js | 9 +- rtx-force-refetch.md | 138 -------------- rtx-migration-review.md | 106 ----------- rtx-migration.md | 233 ----------------------- 5 files changed, 6 insertions(+), 502 deletions(-) delete mode 100644 rtx-force-refetch.md delete mode 100644 rtx-migration-review.md delete mode 100644 rtx-migration.md diff --git a/assets/client/data-sync/pull-strategy.js b/assets/client/data-sync/pull-strategy.js index 2ba27d963..891745e9a 100644 --- a/assets/client/data-sync/pull-strategy.js +++ b/assets/client/data-sync/pull-strategy.js @@ -324,11 +324,6 @@ class PullStrategy { const config = await ClientConfigLoader.loadConfig(); const relationChecksumEnabled = config.relationsChecksumEnabled; - if (screen === null) { - logger.warn(`Screen (${screenPath}) not loaded`); - return; - } - const newScreen = cloneDeep(screen); newScreen.hasActiveCampaign = false; @@ -461,13 +456,6 @@ class PullStrategy { return false; } - if (screen.layoutData === null) { - logger.warn( - `Layout (${screen.layout}) not loaded. Aborting content update.`, - ); - return false; - } - const regionsChanged = this.previousHadActiveCampaign || checksumChanged( @@ -571,16 +559,6 @@ class PullStrategy { slide.templateData = null; } - // A slide cannot work without templateData. Mark as invalid. - if (slide.templateData === null) { - logger.warn( - `Template (${slide.templateInfo["@id"]}) not loaded, slideId: ${slide["@id"]}`, - ); - slide.invalid = true; - slide.mediaData = {}; - return; - } - // Fetch media if it has changed. const mediaChanged = checksumChanged( relationChecksumEnabled, oldSlideChecksums, newSlideChecksums, diff --git a/assets/client/service/content-service.js b/assets/client/service/content-service.js index f7b8c6978..87ab9cde9 100644 --- a/assets/client/service/content-service.js +++ b/assets/client/service/content-service.js @@ -265,9 +265,12 @@ class ContentService { } static query(endpoint, args) { - return clientStore - .dispatch(clientApi.endpoints[endpoint].initiate(args)) - .unwrap(); + const request = clientStore.dispatch( + clientApi.endpoints[endpoint].initiate(args), + ); + return request.unwrap().finally(() => { + request.unsubscribe(); + }); } static async attachReferencesToSlide(slide) { diff --git a/rtx-force-refetch.md b/rtx-force-refetch.md deleted file mode 100644 index 1a33dac37..000000000 --- a/rtx-force-refetch.md +++ /dev/null @@ -1,138 +0,0 @@ -# Fix: Merge relationChecksum with RTK Query forceRefetch - -## Context - -`PullStrategy` manually caches composed data in `this.latestScreenData` and uses checksum comparisons to decide whether to refetch or reuse cached data. This duplicates what RTK Query already provides — a per-endpoint cache. The `query()` function also lacks `forceRefetch`, so RTK Query silently serves stale data on every poll after the first. - -## Approach - -Use checksums to control `forceRefetch` on `query()` calls. RTK Query becomes the sole cache. Remove `this.latestScreenData` as a data cache and the manual if/else branching. - -**File**: `assets/client/data-sync/pull-strategy.js` - -### 1. Update `query()` to accept `forceRefetch` - -```js -function query(endpoint, args, forceRefetch = false) { - return clientStore - .dispatch( - clientApi.endpoints[endpoint].initiate(args, { forceRefetch }) - ) - .unwrap(); -} -``` - -`queryAllPages()` also needs the parameter, passed through to `query()`. - -### 2. Store only previous checksums, not full data - -Replace `this.latestScreenData` with two lightweight stores: - -```js -this.previousScreenChecksums = null; // { campaigns, inScreenGroups, layout, regions } -this.previousSlideChecksums = {}; // { [slideId]: { templateInfo, media } } -``` - -Updated at end of `getScreen()` from the fetched data. - -### 3. Screen fetch — always forceRefetch - -```js -screen = await query("getV2ScreensById", { id: idFromPath(screenPath) }, true); -``` - -This is the root of the sync cycle — must always hit the network to get fresh checksums. - -### 4. Remove all manual cache branches, use forceRefetch instead - -For each downstream resource, compute whether the checksum changed, pass that as `forceRefetch`: - -**Campaigns** (currently lines 286-297): -```js -const campaignsChanged = - !relationChecksumEnabled || - !this.previousScreenChecksums || - this.previousScreenChecksums.campaigns !== newScreenChecksums.campaigns || - this.previousScreenChecksums.inScreenGroups !== newScreenChecksums.inScreenGroups; - -// Always call getCampaignsData — it will hit cache or network based on forceRefetch -newScreen.campaignsData = await this.getCampaignsData(newScreen, campaignsChanged); -``` - -- `getCampaignsData` passes `forceRefetch` through to `query()` / `queryAllPages()` -- Remove the `else { ... loaded from cache }` branch entirely - -**Layout** (currently lines 340-368): -```js -const layoutChanged = - !relationChecksumEnabled || - !this.previousScreenChecksums || - this.previousScreenChecksums.layout !== newScreenChecksums.layout; - -newScreen.layoutData = await query("getV2LayoutsById", { id: idFromPath(newScreen.layout) }, layoutChanged); -``` - -**Regions + slides** (currently lines 371-383): -```js -const regionsChanged = - !relationChecksumEnabled || - !this.previousScreenChecksums || - this.previousScreenChecksums.regions !== newScreenChecksums.regions; - -// getRegions and getSlidesForRegions pass forceRefetch through -const regions = await this.getRegions(newScreen.regions, regionsChanged); -newScreen.regionData = await this.getSlidesForRegions(regions, regionsChanged); -``` - -**Per-slide template/media** (currently lines 424-502): -```js -const templateChanged = - !relationChecksumEnabled || - !this.previousSlideChecksums[slideId] || - newSlideChecksums.templateInfo !== this.previousSlideChecksums[slideId]?.templateInfo; - -slide.templateData = await query("getV2TemplatesById", { id: templateId }, templateChanged); -``` - -Same pattern for media. RTK Query's cache naturally deduplicates same-template/same-media across slides within a cycle (same endpoint + args = cache hit), so `fetchedTemplates` / `fetchedMedia` maps can be removed. - -**Feed** — always forceRefetch (no checksum, needs fresh data): -```js -slide.feedData = await query("getV2FeedsByIdData", { id: idFromPath(slide.feed.feedUrl) }, true); -``` - -### 5. Update at end of cycle - -```js -this.previousScreenChecksums = newScreen.relationsChecksum ?? null; -this.previousSlideChecksums = {}; // rebuild from current slide data -// ... iterate slides and store { [slideId]: slide.relationsChecksum } -``` - -### 6. Handle `hasActiveCampaign` transition - -Current code (line 342) also forces layout refetch when `this.latestScreenData?.hasActiveCampaign` (transitioning from campaign → normal). Store `this.previousHadActiveCampaign` boolean alongside checksums. - -### 7. Methods that need signature changes - -| Method | New parameter | Passes to | -|--------|--------------|-----------| -| `query()` | `forceRefetch = false` | `initiate()` | -| `queryAllPages()` | `forceRefetch = false` | `query()` | -| `getCampaignsData()` | `forceRefetch` | `query()`, `queryAllPages()` | -| `getRegions()` | `forceRefetch` | `queryAllPages()` | -| `getSlidesForRegions()` | `forceRefetch` | `queryAllPages()` | - -### Summary of removals - -- `this.latestScreenData` property → replaced by `this.previousScreenChecksums`, `this.previousSlideChecksums`, `this.previousHadActiveCampaign` -- All `else { ... loaded from cache }` branches (6 of them) -- `fetchedTemplates` / `fetchedMedia` within-cycle maps (RTK Query deduplicates naturally) -- `previousSlide` lookup and `cloneDeep` of previous slide data - -## Verification - -- `task assets:build` succeeds -- `task test:frontend-built` passes -- Manual: content updates on screen are picked up within polling interval -- Manual: unchanged content does NOT trigger unnecessary network requests (check DevTools network tab) diff --git a/rtx-migration-review.md b/rtx-migration-review.md deleted file mode 100644 index 6c246e46b..000000000 --- a/rtx-migration-review.md +++ /dev/null @@ -1,106 +0,0 @@ -# Evaluation of rtx-migration.md - -## Overall Assessment - -The plan is **well-researched and largely correct** in its assumptions. All 16 endpoint names were verified in `generated-api.ts`, the authentication flow is accurately described, and the architectural decisions are sound. However, there are several issues and risks worth addressing before execution. - ---- - -## Verified Correct - -- All RTK Query endpoint names exist exactly as specified (including lowercase `getv2MediaById`) -- Pagination support exists on the correct collection endpoints (`page`, `itemsPerPage` params) -- `extended-base-query.js` does import from `assets/admin/components/util/local-storage-keys.jsx` (confirming it's admin-specific) -- Client uses `apiToken` / `tenantKey` localStorage keys (plain strings, not JSON) -- 401 handling dispatches `document.dispatchEvent(new Event("reauthenticate"))` -- `id-from-path.js` exists and uses regex `/[A-Za-z0-9]{26}/` -- `content-service.js` preview methods use `pullStrategy.getPath()` and `attachReferencesToSlide()` as described - ---- - -## Issues and Concerns - -### 1. Phase 0 is large and risky — should be a separate PR - -Moving shared Redux to `assets/admin/redux/` touches **42+ admin component files** plus the entry point. This is a purely mechanical refactor that has nothing to do with the client migration. Bundling it into the same work: -- Makes the PR enormous and hard to review -- Risks introducing import breakage in admin while the actual goal is client changes -- **Recommendation**: Do Phase 0 as a standalone PR first. Or skip it entirely for now — the "shared" directory being admin-specific is a naming issue, not a blocking problem for the client migration. - -### 2. RTK Query caching may interfere with the pull-based sync - -RTK Query caches responses by endpoint + args. When `PullStrategy` polls on an interval, subsequent calls to the same endpoint will hit RTK Query's cache instead of making a network request. This is a **fundamental behavioral change** from raw `fetch()`. - -Options: -- Use `forceRefetch: true` on every `initiate()` call — but this defeats RTK Query's purpose -- Use `initiate(args, { forceRefetch: true })` only for the polling cycle, not for preview -- Set a very short `keepUnusedDataFor` on the client API -- **Recommendation**: This needs explicit design. The plan should specify the caching strategy. For a pull-based sync pipeline that expects fresh data each cycle, `forceRefetch: true` or `{ subscribe: false, forceRefetch: true }` is likely needed. - -### 3. Error handling semantics change - -Current `ApiHelper.getPath()` returns `null` on failure (line ~50 of api-helper.js). RTK Query's `.unwrap()` **throws** on error. Every call site that checks `if (result === null)` will need to be converted to try/catch. The plan doesn't mention this behavioral difference. - -**Affected areas**: PullStrategy checks for null results in multiple places to mark slides as invalid or skip processing. - -### 4. Bundle size impact on display devices - -Adding Redux + RTK Query + 26K lines of generated endpoints to the client bundle is non-trivial. The client runs on display devices (screens, kiosks) that may have limited resources. The current raw-fetch approach is intentionally lightweight. - -**Recommendation**: Measure the bundle size delta before and after. Consider whether the client actually needs the full generated API or just a subset of endpoints. - -### 5. Duplicated generated-api.ts (26K+ lines x 2) - -The plan generates a second copy of `generated-api.ts` for the client. This means: -- Two 26K+ line files to maintain -- Two codegen commands to run -- Potential drift if one is regenerated and the other isn't - -**Alternative**: Keep one `generated-api.ts` in shared, but have each app provide its own `empty-api.ts` with its own base query. The codegen output is just endpoint definitions — the base query is injected via the empty API. This would require the codegen to support a shared output with pluggable base queries, which RTK Query codegen doesn't natively support. So the duplication may be unavoidable, but the Taskfile should run both codegen commands together. - -### 6. `content-service.js` preview creates PullStrategy for `getPath()` convenience - -In preview mode (lines 212-246), `content-service.js` creates a `PullStrategy` instance solely to use its `getPath()`, `getTemplateData()`, `getFeedData()`, and `getMediaData()` helper methods. After migration, these methods won't exist on PullStrategy anymore. - -The plan addresses this (Phase 3.2) but the replacement pattern needs the `query()` helper to be importable from outside PullStrategy — it should be a shared utility in `assets/client/redux/` or `assets/client/util/`, not local to PullStrategy. - -### 7. `credentials: "include"` for screen auth - -The plan correctly notes this (Phase 2.2) but buries it as a caveat. This is a **must-have** configuration in `fetchBaseQuery`. If forgotten, screen binding will silently fail. It should be a top-level action item in Phase 1.1. - -### 8. Missing: `getPath()` on PullStrategy used by content-service - -`PullStrategy.getPath(path)` is a public method called externally by content-service.js (lines 216, 218, 246, 281). The plan says "Remove ApiHelper entirely" but doesn't explicitly address that `getPath` is a PullStrategy public API method, not just an internal one. It's used as `pullStrategy.getPath(url)` where `url` is a full path like `/v2/playlists/{id}`. This is handled in Phase 3.2, but it's worth noting that PullStrategy's public interface changes. - ---- - -## Design Decisions — Agree/Disagree - -| Decision | Verdict | Comment | -|---|---|---| -| Each app owns its Redux | **Agree** | Different auth, different needs | -| Preserve event-driven architecture | **Agree** | Rewriting to React state is out of scope | -| Preserve checksum optimization | **Agree** | RTK Query tags don't replace server-side checksums | -| `store.dispatch(initiate())` vs hooks | **Agree** | Class-based services can't use hooks | -| Own codegen config | **Agree, with caveat** | Ensure Taskfile runs both together | - ---- - -## Suggested Execution Order - -1. **Phase 0 as separate PR** (or defer entirely) -2. **Phase 1** — Client Redux infrastructure (with explicit `credentials: "include"` and cache policy) -3. **Phase 2** — Simple services (tenant + token), with error handling changes -4. **Phase 3** — Data sync pipeline (largest, most risk — could be its own PR) - -Each phase should be independently testable with `task assets:build` and `task test:frontend-built`. - ---- - -## Verification Additions - -The plan's verification section is good. Add: -- Bundle size comparison (before/after) -- Verify RTK Query doesn't serve stale cached data during sync cycles -- Test 401 → reauthenticate → token refresh → resume flow specifically -- Test screen binding flow with `credentials: "include"` diff --git a/rtx-migration.md b/rtx-migration.md deleted file mode 100644 index 9c962e566..000000000 --- a/rtx-migration.md +++ /dev/null @@ -1,233 +0,0 @@ -# Plan: Replace Custom Fetches in Client with RTK Query - -## Context - -The client app (`assets/client/`) uses raw `fetch()` calls in service classes and a custom pull-based data sync pipeline. The shared RTK Query infrastructure (`enhanced-api.ts`, `generated-api.ts`, `store.js`) already has typed endpoints for every API call the client makes, but: -- The client has **no Redux store or Provider** (`index.jsx` renders `` directly) -- The shared `extended-base-query.js` is **admin-specific** (reads `admin-api-token` and JSON-parsed `admin-selected-tenant` from localStorage) -- The client uses different localStorage keys (`apiToken`, `tenantKey`) via its own `local-storage-keys.js` - -**Goal:** Replace custom `fetch()` calls in the client with RTK Query endpoints from the enhanced API. - -**Out of scope:** `client-config-loader.js` (`GET /config/client`) and `release-loader.js` (`GET /release.json`) — these are infrastructure endpoints, not API data endpoints. - ---- - -## Phase 0: Reorganize Redux — Move Admin-Specific Code to `assets/admin/redux/` - -Currently all Redux files live in `assets/shared/redux/`. The admin-specific files should move to `assets/admin/redux/`: - -### 0.0 Move admin Redux files - -| From (`assets/shared/redux/`) | To (`assets/admin/redux/`) | -|------|------| -| `extended-base-query.js` | `base-query.js` | -| `empty-api.ts` | `empty-api.ts` | -| `generated-api.ts` | `generated-api.ts` | -| `enhanced-api.ts` | `enhanced-api.ts` | -| `store.js` | `store.js` | -| `openapi-config.js` | `openapi-config.js` | - -**What stays in `assets/shared/redux/`:** Nothing — the shared directory can be removed once both admin and client have their own Redux setups. (Or keep it if there's genuinely shared Redux code later.) - -**Update imports:** All admin files that import from `../../shared/redux/` need import paths updated to `../redux/` (or similar relative path). Key files: -- `assets/admin/index.jsx` (imports `store`) -- All admin components that import hooks from `enhanced-api.ts` -- `extended-base-query.js` → `base-query.js` imports `local-storage-keys.jsx` from admin (already does) - -**Update codegen:** `openapi-config.js` paths (`schemaFile`, `apiFile`, `outputFile`) need adjusting for the new location. - ---- - -## Phase 1: Client-Specific Redux Infrastructure - -### 1.1 Create `assets/client/redux/base-query.js` - -A client-specific base query (similar to `assets/shared/redux/extended-base-query.js`) that: -- Reads token from `localStorage.getItem("apiToken")` (client key) -- Reads tenant key from `localStorage.getItem("tenantKey")` (client key, plain string — not JSON like admin) -- Checks URL params `preview-token` and `preview-tenant` and uses those when present (currently in `api-helper.js:31-37`) -- Sets headers: `authorization: Bearer {token}`, `Authorization-Tenant-Key: {tenantKey}`, `accept: application/ld+json` -- On 401: dispatches `document.dispatchEvent(new Event("reauthenticate"))` (same as current `api-helper.js:54`) -- Does NOT include admin-specific param rewriting (order, exists, etc.) - -Reference: `assets/shared/redux/extended-base-query.js` - -### 1.2 Create `assets/client/redux/empty-api.ts` - -```ts -import { createApi } from '@reduxjs/toolkit/query/react'; -import clientBaseQuery from "./base-query"; - -export const clientEmptySplitApi = createApi({ - reducerPath: 'clientApi', - baseQuery: clientBaseQuery, - endpoints: () => ({}), -}); -``` - -### 1.3 Generate client endpoint definitions - -Add a codegen config `assets/client/redux/openapi-config.js` pointing to the client empty API: -```js -const config = { - schemaFile: "../../../public/api-spec-v2.json", - apiFile: "./empty-api.ts", - apiImport: "clientEmptySplitApi", - outputFile: "./generated-api.ts", - exportName: "clientApi", - hooks: true, - tag: true, - endpointOverrides: [ /* same as shared/redux/openapi-config.js */ ], -}; -``` - -Run `npx @rtk-query/codegen-openapi openapi-config.js` to generate `assets/client/redux/generated-api.ts`. Add this as a task in Taskfile or as a companion step to `task generate:redux-toolkit-api`. - -### 1.4 Create `assets/client/redux/enhanced-api.ts` - -Mirror of `assets/shared/redux/enhanced-api.ts` but importing from the client's `generated-api.ts`. Only needs the invalidation rules relevant to client (most mutations aren't used by client, so this can be minimal). - -### 1.5 Create `assets/client/redux/store.js` - -```js -import { configureStore } from "@reduxjs/toolkit"; -import { clientApi } from "./generated-api.ts"; - -export const clientStore = configureStore({ - reducer: { [clientApi.reducerPath]: clientApi.reducer }, - middleware: (gDM) => gDM().concat(clientApi.middleware), -}); -``` - -### 1.6 Add Redux Provider in `assets/client/index.jsx` - -Wrap `` with ``. - -**Files created/modified:** -- `assets/client/redux/base-query.js` (new) -- `assets/client/redux/empty-api.ts` (new) -- `assets/client/redux/openapi-config.js` (new) -- `assets/client/redux/generated-api.ts` (new, auto-generated) -- `assets/client/redux/enhanced-api.ts` (new) -- `assets/client/redux/store.js` (new) -- `assets/client/index.jsx` (modified) - ---- - -## Phase 2: Migrate Simple Services - -### 2.1 Migrate `assets/client/service/tenant-service.js` - -**Current:** `fetch('/v2/tenants/${tenantId}', ...)` with manual auth headers (line 12) -**Replace with:** `clientStore.dispatch(clientApi.endpoints.getV2TenantsById.initiate({ id: tenantId })).unwrap()` (import from `../redux/store.js` and `../redux/generated-api.ts`) - -The response shape is the same. Error handling maps directly (`.catch()`). - -### 2.2 Migrate `assets/client/service/token-service.js` - -Two fetch calls: - -1. **`refreshToken()`** (line 88): `POST /v2/authentication/token/refresh` - - Replace with: `clientStore.dispatch(clientApi.endpoints.postRefreshTokenItem.initiate({ ... })).unwrap()` - - Note: verify the generated endpoint's expected body shape matches `{ refresh_token }`. - -2. **`checkLogin()`** (line 166): `POST /v2/authentication/screen` - - Replace with: `clientStore.dispatch(clientApi.endpoints.postLoginInfoScreen.initiate({ ... })).unwrap()` - - **Caveat:** Current code uses `credentials: "include"` (cookie-based screen auth). The `client-base-query.js` must set `credentials: "include"` in its `fetchBaseQuery` config to support this. - -**Files modified:** -- `assets/client/service/tenant-service.js` -- `assets/client/service/token-service.js` - ---- - -## Phase 3: Migrate Data Sync Pipeline - -This is the largest change. The strategy: replace the transport layer (`ApiHelper`) while preserving the orchestration logic (`PullStrategy`). - -### 3.1 Refactor `assets/client/data-sync/pull-strategy.js` — use named endpoints directly - -Remove `ApiHelper` entirely. Each fetch call in PullStrategy becomes a direct RTK Query endpoint dispatch. Use `idFromPath()` (existing in `assets/client/util/id-from-path.js`) to extract IDs from Hydra paths. - -Helper for dispatching (local to file or small util): -```js -async function query(endpoint, args) { - return clientStore.dispatch(clientApi.endpoints[endpoint].initiate(args)).unwrap(); -} -``` - -Call-by-call migration in `getScreen()` and helpers: - -| Current code | Becomes | -|---|---| -| `this.apiHelper.getPath(screenPath)` | `query("getV2ScreensById", { id: idFromPath(screenPath) })` | -| `this.apiHelper.getPath(screen.inScreenGroups)` | `query("getV2ScreensByIdScreenGroups", { id: idFromPath(screen["@id"]) })` | -| `this.apiHelper.getAllResultsFromPath(group.campaigns)` | `query("getV2ScreenGroupsByIdCampaigns", { id: idFromPath(group["@id"]) })` — handle pagination with `page` param | -| `this.apiHelper.getPath(screen.campaigns)` | `query("getV2ScreensByIdCampaigns", { id: idFromPath(screen["@id"]) })` | -| `this.apiHelper.getPath(newScreen.layout)` | `query("getV2LayoutsById", { id: idFromPath(newScreen.layout) })` | -| `this.apiHelper.getAllResultsFromPath(regionPath)` | `query("getV2ScreensByIdRegionsAndRegionIdPlaylists", { id: screenId, regionId: extractRegionId(regionPath) })` — extract both IDs with regex | -| `this.apiHelper.getAllResultsFromPath(playlist.slides, keys)` | `query("getV2PlaylistsByIdSlides", { id: idFromPath(playlist["@id"]) })` — handle pagination | -| `this.apiHelper.getPath(templatePath)` | `query("getV2TemplatesById", { id: idFromPath(templatePath) })` | -| `this.apiHelper.getPath(mediaId)` | `query("getv2MediaById", { id: idFromPath(mediaId) })` | -| `this.apiHelper.getPath(slide.feed.feedUrl)` | `query("getV2FeedsByIdData", { id: idFromPath(slide.feed.feedUrl) })` | - -For the two-ID regions path (`/v2/screens/{id}/regions/{regionId}/playlists`), add a small `extractRegionId(path)` helper using the existing regex in `getRegions()` at line 106. - -Pagination: Current `getAllResultsFromPath` loops through `?page=N`. Replace with a local `queryAllPages(endpoint, args)` helper that calls the endpoint with incrementing `page` param, collecting `hydra:member` results. - -The checksum comparison logic, caching, and event dispatch all stay unchanged — only the transport calls change. - -### 3.2 Refactor `assets/client/service/content-service.js` preview methods - -`startPreview()` and `attachReferencesToSlide()` currently create ad-hoc `PullStrategy` instances for one-off fetches via `pullStrategy.getPath(...)`. Replace with direct endpoint dispatches: - -| Current | Becomes | -|---|---| -| `pullStrategy.getPath("/v2/playlists/" + id)` | `query("getV2PlaylistsById", { id })` | -| `pullStrategy.getPath("/v2/slides/" + id)` | `query("getV2SlidesById", { id })` | -| `strategy.getTemplateData(slide)` | `query("getV2TemplatesById", { id: idFromPath(slide.templateInfo["@id"]) })` | -| `strategy.getFeedData(slide)` | `query("getV2FeedsByIdData", { id: idFromPath(slide.feed.feedUrl) })` | -| `strategy.getMediaData(media)` | `query("getv2MediaById", { id: idFromPath(media) })` | -| `strategy.getPath(slide.theme)` | `query("getV2ThemesById", { id: idFromPath(slide.theme) })` | - -### 3.3 Delete `assets/client/data-sync/api-helper.js` - -No longer needed. - -### 3.4 Clean up `assets/client/data-sync/data-sync.js` - -Update if needed — it wraps PullStrategy construction. May simplify since `ApiHelper` endpoint config is no longer passed. - -**Files modified/deleted:** -- `assets/client/data-sync/pull-strategy.js` (major refactor — replace all ApiHelper calls with named endpoints) -- `assets/client/service/content-service.js` (refactor preview methods) -- `assets/client/data-sync/api-helper.js` (deleted) -- `assets/client/data-sync/data-sync.js` (minor update) - ---- - -## Design Decisions - -| Decision | Choice | Rationale | -|----------|--------|-----------| -| Each app owns its Redux in `assets/{app}/redux/` | Yes | Admin and client use different auth. No shared Redux folder needed. | -| Preserve event-driven architecture | Yes | ContentService/ScheduleService/Region event pipeline works well. Rewriting to React state is a separate concern. | -| Preserve checksum optimization | Yes | RTK Query's tag-based invalidation doesn't replace server-side relationsChecksum polling. Keep application-level caching logic. | -| Use `store.dispatch(initiate())` vs hooks | `dispatch(initiate())` | Services are class-based singletons outside React. Hooks only work in components. | -| How to generate client endpoints | Own codegen config in `assets/client/redux/` | Avoids manual duplication. Same OpenAPI spec, different base query. One extra codegen step. | - ---- - -## Verification - -1. **Build:** `task assets:build` — ensure no TypeScript/bundling errors -2. **E2E tests:** `task test:frontend-built` — existing Playwright tests cover screen display flow -3. **Manual testing:** - - Start dev server, open client URL - - Verify screen binding flow works (login → bind key → content display) - - Verify content polling loads and updates slides - - Verify token refresh happens silently - - Verify preview mode works (screen, playlist, slide previews from admin) - - Check browser DevTools Network tab: requests should still go to same endpoints with same headers -4. **Regression:** Verify admin app still works (its API slice is untouched) From 46d77aacf6f6c3dbc2f44977baf3b613b4a247a3 Mon Sep 17 00:00:00 2001 From: Troels Ugilt Jensen <6103205+tuj@users.noreply.github.com> Date: Mon, 20 Apr 2026 12:10:03 +0200 Subject: [PATCH 38/73] 7228: Fixed grid column row switch --- assets/client/components/screen.jsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/assets/client/components/screen.jsx b/assets/client/components/screen.jsx index 2560f6d17..17cdce9b3 100644 --- a/assets/client/components/screen.jsx +++ b/assets/client/components/screen.jsx @@ -26,8 +26,8 @@ function Screen({ screen }) { const rootStyle = { gridTemplateAreas: createGrid(configColumns, configRows), - gridTemplateColumns: gridTemplateRows, - gridTemplateRows: gridTemplateColumns, + gridTemplateColumns, + gridTemplateRows, }; const refreshColorScheme = () => { From 345894a6dbd8cf3df128615c1ba4ff2ed58cf9ac Mon Sep 17 00:00:00 2001 From: Troels Ugilt Jensen <6103205+tuj@users.noreply.github.com> Date: Mon, 20 Apr 2026 12:11:51 +0200 Subject: [PATCH 39/73] 7228: Added guard against race condition --- assets/client/data-sync/pull-strategy.js | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/assets/client/data-sync/pull-strategy.js b/assets/client/data-sync/pull-strategy.js index 891745e9a..113da62e3 100644 --- a/assets/client/data-sync/pull-strategy.js +++ b/assets/client/data-sync/pull-strategy.js @@ -611,6 +611,7 @@ class PullStrategy { // Make sure nothing is running. this.stop(); this.stopped = false; + this.pulling = false; // Pull now, then schedule the next pull after completion. this.pull(); @@ -620,11 +621,18 @@ class PullStrategy { * Run a single pull cycle, then schedule the next one. */ pull() { + if (this.pulling) { + return; + } + this.pulling = true; + this.getScreen(this.entryPoint) .catch((err) => { logger.error(`Content update failed: ${err.message}`); }) .finally(() => { + this.pulling = false; + if (this.stopped) { return; } From f75018f375eb4664e7ab8ac8939e8a55a38379e1 Mon Sep 17 00:00:00 2001 From: Troels Ugilt Jensen <6103205+tuj@users.noreply.github.com> Date: Mon, 20 Apr 2026 12:14:28 +0200 Subject: [PATCH 40/73] 7228: Fixed listener cleanup when in preview mode --- assets/client/app.jsx | 16 +++++++++------- 1 file changed, 9 insertions(+), 7 deletions(-) diff --git a/assets/client/app.jsx b/assets/client/app.jsx index ea3398500..0bc0987e5 100644 --- a/assets/client/app.jsx +++ b/assets/client/app.jsx @@ -241,19 +241,21 @@ function App({ preview, previewId }) { return function cleanup() { logger.info("Unmounting App."); - document.removeEventListener("keypress", handleKeyboard); document.removeEventListener("screen", screenHandler); - document.removeEventListener("reauthenticate", reauthenticateHandler); document.removeEventListener("contentEmpty", contentEmpty); document.removeEventListener("contentNotEmpty", contentNotEmpty); - if (checkLoginTimeoutRef?.current) { - clearTimeout(checkLoginTimeoutRef.current); - } + if (preview === null) { + document.removeEventListener("keypress", handleKeyboard); + document.removeEventListener("reauthenticate", reauthenticateHandler); - tokenService.stopRefreshing(); + if (checkLoginTimeoutRef?.current) { + clearTimeout(checkLoginTimeoutRef.current); + } - releaseService.stopReleaseCheck(); + tokenService.stopRefreshing(); + releaseService.stopReleaseCheck(); + } }; }, []); From 7f00ab524b5223ee7a7cf348f01f6689b1a4a3e1 Mon Sep 17 00:00:00 2001 From: Troels Ugilt Jensen <6103205+tuj@users.noreply.github.com> Date: Mon, 20 Apr 2026 12:16:14 +0200 Subject: [PATCH 41/73] 7228: Fixed error message clearing --- assets/client/service/token-service.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/assets/client/service/token-service.js b/assets/client/service/token-service.js index cb05ee27e..9097c6c03 100644 --- a/assets/client/service/token-service.js +++ b/assets/client/service/token-service.js @@ -147,8 +147,8 @@ class TokenService { if ( err !== null && [ - constants.TOKEN_EXPIRED, - constants.TOKEN_VALID_SHOULD_HAVE_BEEN_REFRESHED, + constants.ERROR_TOKEN_EXPIRED, + constants.ERROR_TOKEN_VALID_SHOULD_HAVE_BEEN_REFRESHED, ].includes(err) ) { statusService.setError(null); From ed6f7fa2f0c45498400297c914889021df42dc1f Mon Sep 17 00:00:00 2001 From: Troels Ugilt Jensen <6103205+tuj@users.noreply.github.com> Date: Mon, 20 Apr 2026 12:17:16 +0200 Subject: [PATCH 42/73] 7228: Filter out invalid slides for touch-region --- assets/client/components/touch-region.jsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/assets/client/components/touch-region.jsx b/assets/client/components/touch-region.jsx index 19630a2d2..0bb5eb070 100644 --- a/assets/client/components/touch-region.jsx +++ b/assets/client/components/touch-region.jsx @@ -72,7 +72,7 @@ function TouchRegion({ region }) { * The event. The data is contained in detail. */ function regionContentListener(event) { - setSlides([...event.detail.slides]); + setSlides([...event.detail.slides].filter((slide) => !slide.invalid)); } // Setup event listener for region content. From 0e13920c5924869f03463e409e62178f87c4cce2 Mon Sep 17 00:00:00 2001 From: Troels Ugilt Jensen <6103205+tuj@users.noreply.github.com> Date: Mon, 20 Apr 2026 12:18:58 +0200 Subject: [PATCH 43/73] 7228: Fixed stale closure in region listener --- assets/client/components/region.jsx | 2 +- assets/client/components/touch-region.jsx | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/assets/client/components/region.jsx b/assets/client/components/region.jsx index aadcd3be2..952eefced 100644 --- a/assets/client/components/region.jsx +++ b/assets/client/components/region.jsx @@ -137,7 +137,7 @@ function Region({ region }) { regionContentListener, ); }; - }, []); + }, [regionId]); // Notify that region is ready. useEffect(() => { diff --git a/assets/client/components/touch-region.jsx b/assets/client/components/touch-region.jsx index 0bb5eb070..2f6fef837 100644 --- a/assets/client/components/touch-region.jsx +++ b/assets/client/components/touch-region.jsx @@ -97,7 +97,7 @@ function TouchRegion({ region }) { regionContentListener, ); }; - }, []); + }, [regionId]); // Notify that region is ready. useEffect(() => { From c875f87d6ddeb2b1d08a95f2a771f8ecc6b224bf Mon Sep 17 00:00:00 2001 From: Troels Ugilt Jensen <6103205+tuj@users.noreply.github.com> Date: Mon, 20 Apr 2026 12:24:20 +0200 Subject: [PATCH 44/73] 7228: Fixed minor issues --- assets/client/components/touch-region.jsx | 4 ++-- assets/client/data-sync/pull-strategy.js | 2 ++ 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/assets/client/components/touch-region.jsx b/assets/client/components/touch-region.jsx index 2f6fef837..15ce267f7 100644 --- a/assets/client/components/touch-region.jsx +++ b/assets/client/components/touch-region.jsx @@ -152,8 +152,8 @@ function TouchRegion({ region }) { {displayClose && (
slideDone(currentSlide)} + onKeyDown={() => slideDone(currentSlide)} role="button" tabIndex={0} > diff --git a/assets/client/data-sync/pull-strategy.js b/assets/client/data-sync/pull-strategy.js index 113da62e3..3c89365c4 100644 --- a/assets/client/data-sync/pull-strategy.js +++ b/assets/client/data-sync/pull-strategy.js @@ -528,6 +528,7 @@ class PullStrategy { slide.templateData = null; slide.invalid = true; slide.mediaData = {}; + slide.feedData = null; return; } @@ -544,6 +545,7 @@ class PullStrategy { slide.templateData = null; slide.invalid = true; slide.mediaData = {}; + slide.feedData = null; return; } From 879056be6d7d8a823cce6b41340fb6da4d8c17c0 Mon Sep 17 00:00:00 2001 From: Troels Ugilt Jensen <6103205+tuj@users.noreply.github.com> Date: Mon, 20 Apr 2026 12:27:46 +0200 Subject: [PATCH 45/73] 7228: Make sure feedData is always set to array --- assets/client/data-sync/pull-strategy.js | 2 ++ 1 file changed, 2 insertions(+) diff --git a/assets/client/data-sync/pull-strategy.js b/assets/client/data-sync/pull-strategy.js index 3c89365c4..e5f9b7091 100644 --- a/assets/client/data-sync/pull-strategy.js +++ b/assets/client/data-sync/pull-strategy.js @@ -603,6 +603,8 @@ class PullStrategy { } catch (err) { slide.feedData = null; } + } else { + slide.feedData = []; } } From 77989c5f5a06f01ddda0be2c6270951e37d102f8 Mon Sep 17 00:00:00 2001 From: Troels Ugilt Jensen <6103205+tuj@users.noreply.github.com> Date: Mon, 20 Apr 2026 12:29:52 +0200 Subject: [PATCH 46/73] 7228: Fixed keyDown for touch button --- assets/client/components/touch-region.jsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/assets/client/components/touch-region.jsx b/assets/client/components/touch-region.jsx index 15ce267f7..fbcde7a84 100644 --- a/assets/client/components/touch-region.jsx +++ b/assets/client/components/touch-region.jsx @@ -153,7 +153,7 @@ function TouchRegion({ region }) {
slideDone(currentSlide)} - onKeyDown={() => slideDone(currentSlide)} + onKeyDown={(e) => { if (e.key === "Enter" || e.key === " ") slideDone(currentSlide); }} role="button" tabIndex={0} > @@ -174,7 +174,7 @@ function TouchRegion({ region }) { className="touch-button" key={`button-${slide.executionId}`} onClick={() => startSlide(slide)} - onKeyDown={() => startSlide(slide)} + onKeyDown={(e) => { if (e.key === "Enter" || e.key === " ") startSlide(slide); }} role="button" tabIndex={0} > From e08e5c026f9b330afe45715ece3426cdebca2eb2 Mon Sep 17 00:00:00 2001 From: Troels Ugilt Jensen <6103205+tuj@users.noreply.github.com> Date: Mon, 20 Apr 2026 12:31:21 +0200 Subject: [PATCH 47/73] 7228: Fixed race conditions with setTimeout flows --- assets/client/service/release-service.js | 5 +++++ assets/client/service/token-service.js | 5 +++++ 2 files changed, 10 insertions(+) diff --git a/assets/client/service/release-service.js b/assets/client/service/release-service.js index f84b01367..37b71382d 100644 --- a/assets/client/service/release-service.js +++ b/assets/client/service/release-service.js @@ -76,8 +76,11 @@ class ReleaseService { startReleaseCheck = () => { this.stopReleaseCheck(); + this.releaseCheckStopped = false; ClientConfigLoader.loadConfig().then((config) => { + if (this.releaseCheckStopped) return; + this.releaseCheckInterval = setInterval( this.checkForNewRelease, config.releaseTimestampIntervalTimeout ?? @@ -87,8 +90,10 @@ class ReleaseService { }; stopReleaseCheck = () => { + this.releaseCheckStopped = true; if (this.releaseCheckInterval) { clearInterval(this.releaseCheckInterval); + this.releaseCheckInterval = null; } }; } diff --git a/assets/client/service/token-service.js b/assets/client/service/token-service.js index 9097c6c03..9bd34ea0a 100644 --- a/assets/client/service/token-service.js +++ b/assets/client/service/token-service.js @@ -208,8 +208,11 @@ class TokenService { startRefreshing = () => { this.stopRefreshing(); + this.refreshingStopped = false; ClientConfigLoader.loadConfig().then((config) => { + if (this.refreshingStopped) return; + // Start refresh token interval. this.refreshInterval = setInterval( this.ensureFreshToken, @@ -219,8 +222,10 @@ class TokenService { }; stopRefreshing = () => { + this.refreshingStopped = true; if (this.refreshInterval) { clearInterval(this.refreshInterval); + this.refreshInterval = null; } }; } From eb86cb9f413c2f2aa5e7bb60b44ee0b6da831b7b Mon Sep 17 00:00:00 2001 From: Troels Ugilt Jensen <6103205+tuj@users.noreply.github.com> Date: Mon, 20 Apr 2026 14:01:49 +0200 Subject: [PATCH 48/73] 7228: Fixed broken tests and bugs --- assets/client/components/screen.jsx | 23 ++--- assets/client/data-sync/pull-strategy.js | 31 ++++++- assets/client/service/schedule-service.js | 2 +- assets/client/util/client-config-loader.js | 97 +++++++++++----------- assets/tests/client/pull-strategy.test.js | 4 +- assets/tests/client/token-service.test.js | 2 +- 6 files changed, 94 insertions(+), 65 deletions(-) diff --git a/assets/client/components/screen.jsx b/assets/client/components/screen.jsx index 17cdce9b3..91335bf39 100644 --- a/assets/client/components/screen.jsx +++ b/assets/client/components/screen.jsx @@ -1,4 +1,4 @@ -import { Fragment, useEffect, useRef } from "react"; +import { useEffect, useRef } from "react"; import SunCalc from "suncalc"; import { createGrid } from "../../shared/grid-generator/grid-generator"; import Region from "./region.jsx"; @@ -82,6 +82,7 @@ function Screen({ screen }) { return () => { if (colorSchemeIntervalRef.current !== null) { clearInterval(colorSchemeIntervalRef.current); + colorSchemeIntervalRef.current = null; } // Cleanup html root classes. @@ -90,22 +91,16 @@ function Screen({ screen }) { "color-scheme-dark", ); }; - }, [screen]); + }, [screen?.enableColorSchemeChange]); return (
- {screen?.layoutData?.regions?.map((region) => ( - - {/* Default region type */} - {(!region.type || region.type === "default") && ( - - )} - {/* Special region type: touch-buttons */} - {region?.type === "touch-buttons" && ( - - )} - - ))} + {screen?.layoutData?.regions?.map((region) => { + if (region?.type === "touch-buttons") { + return ; + } + return ; + })}
); } diff --git a/assets/client/data-sync/pull-strategy.js b/assets/client/data-sync/pull-strategy.js index e5f9b7091..468e05eca 100644 --- a/assets/client/data-sync/pull-strategy.js +++ b/assets/client/data-sync/pull-strategy.js @@ -321,6 +321,14 @@ class PullStrategy { return; } + if (!screen) { + logger.warn( + `Screen (${screenPath}) not loaded. Aborting content update.`, + ); + + return; + } + const config = await ClientConfigLoader.loadConfig(); const relationChecksumEnabled = config.relationsChecksumEnabled; @@ -368,6 +376,8 @@ class PullStrategy { this.previousSlideChecksums = nextSlideChecksums; this.previousHadActiveCampaign = newScreen.hasActiveCampaign; + if (this.stopped) return; + // Deliver result to rendering const event = new CustomEvent("content", { detail: { @@ -456,6 +466,13 @@ class PullStrategy { return false; } + if (!screen.layoutData) { + logger.warn( + `Layout (${screen.layout}) not loaded. Aborting content update.`, + ); + return false; + } + const regionsChanged = this.previousHadActiveCampaign || checksumChanged( @@ -504,7 +521,12 @@ class PullStrategy { } } - await Promise.allSettled(promises); + const results = await Promise.allSettled(promises); + + const failed = results.filter((r) => r.status === "rejected"); + if (failed.length > 0) { + logger.warn(`Failed to enrich ${failed.length} slide(s).`); + } return nextSlideChecksums; } @@ -561,6 +583,13 @@ class PullStrategy { slide.templateData = null; } + if (!slide.templateData) { + slide.invalid = true; + slide.mediaData = {}; + slide.feedData = null; + return; + } + // Fetch media if it has changed. const mediaChanged = checksumChanged( relationChecksumEnabled, oldSlideChecksums, newSlideChecksums, diff --git a/assets/client/service/schedule-service.js b/assets/client/service/schedule-service.js index 8e1dfe940..7254e2b54 100644 --- a/assets/client/service/schedule-service.js +++ b/assets/client/service/schedule-service.js @@ -222,7 +222,7 @@ class ScheduleService { slides.push(newSlide); }); } else { - logger.log("info", `Playlist ${playlist["@id"]} not scheduled for now`); + logger.info(`Playlist ${playlist["@id"]} not scheduled for now`); } }); diff --git a/assets/client/util/client-config-loader.js b/assets/client/util/client-config-loader.js index 61b02cf9c..f28fe9ee2 100644 --- a/assets/client/util/client-config-loader.js +++ b/assets/client/util/client-config-loader.js @@ -18,60 +18,63 @@ const ClientConfigLoader = { return activePromise; } - activePromise = new Promise((resolve) => { - const nowTimestamp = new Date().getTime(); + const nowTimestamp = new Date().getTime(); - if ( - latestFetchTimestamp + - (configData?.configFetchInterval ?? configFetchIntervalDefault) >= + // Return cached data directly — no promise, so activePromise stays null + // and a future call after the cache interval will trigger a real fetch. + if ( + configData !== null && + latestFetchTimestamp + + (configData?.configFetchInterval ?? configFetchIntervalDefault) >= nowTimestamp - ) { - resolve(configData); - } else { - fetch(`/config/client`) - .then((response) => response.json()) - .then((data) => { - latestFetchTimestamp = nowTimestamp; - configData = data; + ) { + return configData; + } - // Make api endpoint available through localstorage. - appStorage.setApiUrl(configData.apiEndpoint); + activePromise = new Promise((resolve) => { + fetch(`/config/client`) + .then((response) => response.json()) + .then((data) => { + latestFetchTimestamp = new Date().getTime(); + configData = data; + // Make api endpoint available through localstorage. + appStorage.setApiUrl(configData.apiEndpoint); + + resolve(configData); + }) + .catch(() => { + if (configData !== null) { resolve(configData); - }) - .catch(() => { - if (configData !== null) { - resolve(configData); - } else { - logger.error("Could not load config. Will use default config."); + } else { + logger.error("Could not load config. Will use default config."); - // Default config. - resolve({ - apiEndpoint: "/api", - dataStrategy: { - type: "pull", - config: { - interval: 30000, - }, - }, - loginCheckTimeout: 20000, - configFetchInterval: 900000, - refreshTokenTimeout: 15000, - releaseTimestampIntervalTimeout: 600000, - colorScheme: { - type: "library", - lat: 56.0, - lng: 10.0, + // Default config. + resolve({ + apiEndpoint: "/api", + dataStrategy: { + type: "pull", + config: { + interval: 30000, }, - schedulingInterval: 60000, - debug: false, - }); - } - }) - .finally(() => { - activePromise = null; - }); - } + }, + loginCheckTimeout: 20000, + configFetchInterval: 900000, + refreshTokenTimeout: 15000, + releaseTimestampIntervalTimeout: 600000, + colorScheme: { + type: "library", + lat: 56.0, + lng: 10.0, + }, + schedulingInterval: 60000, + debug: false, + }); + } + }) + .finally(() => { + activePromise = null; + }); }); return activePromise; diff --git a/assets/tests/client/pull-strategy.test.js b/assets/tests/client/pull-strategy.test.js index 7f2986f03..b86960fd7 100644 --- a/assets/tests/client/pull-strategy.test.js +++ b/assets/tests/client/pull-strategy.test.js @@ -18,6 +18,7 @@ const { mockDispatch, endpoints } = vi.hoisted(() => { ].forEach((name) => { endpoints[name] = { initiate: (args, opts) => ({ _endpoint: name, _args: args, _opts: opts }), + select: () => () => undefined, }; }); return { mockDispatch, endpoints }; @@ -32,7 +33,7 @@ vi.mock("../../client/util/client-config-loader.js", () => ({ })); vi.mock("../../client/redux/store.js", () => ({ - clientStore: { dispatch: mockDispatch }, + clientStore: { dispatch: mockDispatch, getState: () => ({}) }, })); vi.mock("../../client/redux/generated-api.ts", () => ({ @@ -127,6 +128,7 @@ function setupResponses(responseMap) { } return Promise.resolve(handler); }, + unsubscribe: vi.fn(), })); } diff --git a/assets/tests/client/token-service.test.js b/assets/tests/client/token-service.test.js index 555e18974..c7e6c03fd 100644 --- a/assets/tests/client/token-service.test.js +++ b/assets/tests/client/token-service.test.js @@ -231,7 +231,7 @@ describe("TokenService", () => { appStorage.getTokenExpire.mockReturnValue(200); appStorage.getTokenIssueAt.mockReturnValue(100); appStorage.getToken.mockReturnValue("token"); - statusService.error = constants.TOKEN_EXPIRED; + statusService.error = constants.ERROR_TOKEN_EXPIRED; tokenService.checkToken(); From 0397b8cfc3c89191b75014c9c3c7586e8d1b06ea Mon Sep 17 00:00:00 2001 From: Troels Ugilt Jensen <6103205+tuj@users.noreply.github.com> Date: Mon, 20 Apr 2026 14:10:31 +0200 Subject: [PATCH 49/73] 7228: Fixed unsubscribes --- assets/client/service/content-service.js | 6 ++++- assets/client/service/tenant-service.js | 11 +++++---- assets/client/service/token-service.js | 29 +++++++++++++---------- assets/tests/client/token-service.test.js | 7 ++++++ 4 files changed, 36 insertions(+), 17 deletions(-) diff --git a/assets/client/service/content-service.js b/assets/client/service/content-service.js index 87ab9cde9..b4afde50a 100644 --- a/assets/client/service/content-service.js +++ b/assets/client/service/content-service.js @@ -50,8 +50,11 @@ class ContentService { */ startSyncing(screenPath) { logger.info("Starting data synchronization"); + this.syncingStopped = false; ClientConfigLoader.loadConfig().then((config) => { + if (this.syncingStopped) return; + const dataStrategyConfig = { interval: config.pullStrategyInterval, endpoint: "", @@ -71,6 +74,7 @@ class ContentService { */ stopSyncHandler() { logger.info("Event received: Stop data synchronization"); + this.syncingStopped = true; if (this.dataSync) { logger.info("Stopping data synchronization"); @@ -205,7 +209,7 @@ class ContentService { async startPreview(event) { const data = event.detail; const { mode, id } = data; - logger.log("info", `Starting preview. Mode: ${mode}, ID: ${id}`); + logger.info(`Starting preview. Mode: ${mode}, ID: ${id}`); try { if (mode === "screen") { diff --git a/assets/client/service/tenant-service.js b/assets/client/service/tenant-service.js index fbb4301bd..12d5c1666 100644 --- a/assets/client/service/tenant-service.js +++ b/assets/client/service/tenant-service.js @@ -10,10 +10,10 @@ class TenantService { const tenantId = appStorage.getTenantId(); if (token && tenantKey && tenantId) { - clientStore - .dispatch( - clientApi.endpoints.getV2TenantsById.initiate({ id: tenantId }), - ) + const request = clientStore.dispatch( + clientApi.endpoints.getV2TenantsById.initiate({ id: tenantId }), + ); + request .unwrap() .then((tenantData) => { if (tenantData?.fallbackImageUrl) { @@ -22,6 +22,9 @@ class TenantService { }) .catch((err) => { logger.error(`Failed to load tenant config: ${err.message}`); + }) + .finally(() => { + request.unsubscribe(); }); } }; diff --git a/assets/client/service/token-service.js b/assets/client/service/token-service.js index 9bd34ea0a..5c6d21b92 100644 --- a/assets/client/service/token-service.js +++ b/assets/client/service/token-service.js @@ -86,12 +86,13 @@ class TokenService { this.refreshingToken = true; const refreshToken = appStorage.getRefreshToken(); - this.refreshPromise = clientStore - .dispatch( - clientApi.endpoints.postRefreshTokenItem.initiate({ - refreshTokenRequest: { refresh_token: refreshToken }, - }), - ) + const request = clientStore.dispatch( + clientApi.endpoints.postRefreshTokenItem.initiate({ + refreshTokenRequest: { refresh_token: refreshToken }, + }), + ); + + this.refreshPromise = request .unwrap() .then((data) => { logger.info("Token refreshed."); @@ -116,6 +117,7 @@ class TokenService { .finally(() => { this.refreshingToken = false; this.refreshPromise = null; + request.unsubscribe(); }); } @@ -157,12 +159,12 @@ class TokenService { }; checkLogin = () => { - return clientStore - .dispatch( - clientApi.endpoints.postLoginInfoScreen.initiate({ - screenLoginInput: {}, - }), - ) + const request = clientStore.dispatch( + clientApi.endpoints.postLoginInfoScreen.initiate({ + screenLoginInput: {}, + }), + ); + return request .unwrap() .then((data) => { if ( @@ -203,6 +205,9 @@ class TokenService { return { status: constants.LOGIN_STATUS_UNKNOWN, }; + }) + .finally(() => { + request.unsubscribe(); }); }; diff --git a/assets/tests/client/token-service.test.js b/assets/tests/client/token-service.test.js index c7e6c03fd..0ce123953 100644 --- a/assets/tests/client/token-service.test.js +++ b/assets/tests/client/token-service.test.js @@ -259,6 +259,7 @@ describe("TokenService", () => { }; mockDispatch.mockReturnValue({ unwrap: () => Promise.resolve(loginData), + unsubscribe: vi.fn(), }); const result = await tokenService.checkLogin(); @@ -283,6 +284,7 @@ describe("TokenService", () => { status: constants.LOGIN_STATUS_AWAITING_BIND_KEY, bindKey: "ABCD-1234", }), + unsubscribe: vi.fn(), }); const result = await tokenService.checkLogin(); @@ -296,6 +298,7 @@ describe("TokenService", () => { it("returns unknown status for unexpected response", async () => { mockDispatch.mockReturnValue({ unwrap: () => Promise.resolve({ status: "something-else" }), + unsubscribe: vi.fn(), }); const result = await tokenService.checkLogin(); @@ -313,6 +316,7 @@ describe("TokenService", () => { token: "new-token", refresh_token: "new-refresh", }), + unsubscribe: vi.fn(), }); await tokenService.refreshToken(); @@ -329,6 +333,7 @@ describe("TokenService", () => { token: "new-token", refresh_token: "new-refresh", }), + unsubscribe: vi.fn(), }); await tokenService.refreshToken(); @@ -341,6 +346,7 @@ describe("TokenService", () => { appStorage.getRefreshToken.mockReturnValue("old-refresh"); mockDispatch.mockReturnValue({ unwrap: () => Promise.reject(new Error("401")), + unsubscribe: vi.fn(), }); await expect(tokenService.refreshToken()).rejects.toThrow("401"); @@ -357,6 +363,7 @@ describe("TokenService", () => { token: "new-token", refresh_token: "new-refresh", }), + unsubscribe: vi.fn(), }); const p1 = tokenService.refreshToken(); From e54d6f6d32f5c193b17c2d158f0ffdffc1ffbb88 Mon Sep 17 00:00:00 2001 From: Troels Ugilt Jensen <6103205+tuj@users.noreply.github.com> Date: Mon, 20 Apr 2026 14:32:33 +0200 Subject: [PATCH 50/73] 7228: Fixed bugs raised by review --- assets/client/app.jsx | 13 ++++++++----- assets/client/components/slide.jsx | 3 ++- assets/client/data-sync/pull-strategy.js | 10 ++++------ assets/client/service/release-service.js | 4 ++-- assets/client/util/id-from-path.js | 2 +- assets/client/util/release-loader.js | 20 -------------------- assets/tests/client/id-from-path.test.js | 20 ++++++++++---------- 7 files changed, 27 insertions(+), 45 deletions(-) delete mode 100644 assets/client/util/release-loader.js diff --git a/assets/client/app.jsx b/assets/client/app.jsx index 0bc0987e5..e8cdb7ac2 100644 --- a/assets/client/app.jsx +++ b/assets/client/app.jsx @@ -31,6 +31,7 @@ function App({ preview, previewId }) { const checkLoginTimeoutRef = useRef(null); const contentServiceRef = useRef(null); + const runningRef = useRef(false); const fallbackImageUrl = appStorage.getFallbackImageUrl(); const fallbackStyle = { @@ -70,6 +71,7 @@ function App({ preview, previewId }) { } setBindKey(null); + runningRef.current = true; setRunning(true); contentServiceRef.current = new ContentService(); @@ -111,7 +113,7 @@ function App({ preview, previewId }) { const localStorageToken = appStorage.getToken(); const localScreenId = appStorage.getScreenId(); - if (!running && localStorageToken && localScreenId) { + if (!runningRef.current && localStorageToken && localScreenId) { startContent(localScreenId); } else { statusService.setStatus(constants.STATUS_LOGIN); @@ -156,12 +158,13 @@ function App({ preview, previewId }) { appStorage.clearTenant(); appStorage.clearFallbackImageUrl(); - if (contentServiceRef?.current !== null) { + if (contentServiceRef.current !== null) { contentServiceRef.current.stop(); contentServiceRef.current = null; } setScreen(null); + runningRef.current = false; setRunning(false); tokenService.stopRefreshing(); @@ -211,7 +214,7 @@ function App({ preview, previewId }) { ); } } else { - document.addEventListener("keypress", handleKeyboard); + document.addEventListener("keydown", handleKeyboard); document.addEventListener("screen", screenHandler); document.addEventListener("reauthenticate", reauthenticateHandler); document.addEventListener("contentEmpty", contentEmpty); @@ -246,10 +249,10 @@ function App({ preview, previewId }) { document.removeEventListener("contentNotEmpty", contentNotEmpty); if (preview === null) { - document.removeEventListener("keypress", handleKeyboard); + document.removeEventListener("keydown", handleKeyboard); document.removeEventListener("reauthenticate", reauthenticateHandler); - if (checkLoginTimeoutRef?.current) { + if (checkLoginTimeoutRef.current) { clearTimeout(checkLoginTimeoutRef.current); } diff --git a/assets/client/components/slide.jsx b/assets/client/components/slide.jsx index bf5b76d59..544ed2122 100644 --- a/assets/client/components/slide.jsx +++ b/assets/client/components/slide.jsx @@ -14,7 +14,7 @@ import "./slide.scss"; * @param {Function} props.slideError - Callback when slide encountered an error. * @returns {object} - The component. */ -function Slide({ slide, id, run, slideDone, slideError }) { +function Slide({ slide, id, run, slideDone, slideError, forwardRef }) { /** * Handle errors in ErrorBoundary. * @@ -30,6 +30,7 @@ function Slide({ slide, id, run, slideDone, slideError }) { return (
campaign, - ); - } + screenCampaigns = screenCampaignsResults.map( + ({ campaign }) => campaign, + ); } catch (err) { logger.error(err); } diff --git a/assets/client/service/release-service.js b/assets/client/service/release-service.js index 37b71382d..b4f38ffbc 100644 --- a/assets/client/service/release-service.js +++ b/assets/client/service/release-service.js @@ -5,7 +5,7 @@ import appStorage from "../util/app-storage"; import logger from "../logger/logger"; import statusService from "./status-service"; import constants from "../util/constants"; -import ReleaseLoader from "../../shared/release-loader.js"; +import releaseLoader from "../../shared/release-loader.js"; class ReleaseService { releaseCheckInterval = null; @@ -17,7 +17,7 @@ class ReleaseService { const url = new URL(window.location.href); const currentTimestamp = url.searchParams.get("releaseTimestamp"); - ReleaseLoader.loadRelease() + releaseLoader.loadRelease() .then((release) => { if (release.releaseTimestamp === null) { statusService.setError(constants.ERROR_RELEASE_FILE_NOT_LOADED); diff --git a/assets/client/util/id-from-path.js b/assets/client/util/id-from-path.js index 24ecdb053..a80a69db5 100644 --- a/assets/client/util/id-from-path.js +++ b/assets/client/util/id-from-path.js @@ -9,7 +9,7 @@ function idFromPath(string) { return matches.shift(); } } - return false; + return null; } export default idFromPath; diff --git a/assets/client/util/release-loader.js b/assets/client/util/release-loader.js deleted file mode 100644 index c30730a3a..000000000 --- a/assets/client/util/release-loader.js +++ /dev/null @@ -1,20 +0,0 @@ -import logger from "../logger/logger"; - -/** - * Release loader. - */ -export default class ReleaseLoader { - static async loadRelease() { - const nowTimestamp = new Date().getTime(); - return fetch(`/release.json?ts=${nowTimestamp}`) - .then((response) => response.json()) - .catch((err) => { - logger.warn("Could not find release.json. Returning defaults.", err); - - return { - releaseTimestamp: null, - releaseVersion: null, - }; - }); - } -} diff --git a/assets/tests/client/id-from-path.test.js b/assets/tests/client/id-from-path.test.js index 8f8b84490..7a04877dd 100644 --- a/assets/tests/client/id-from-path.test.js +++ b/assets/tests/client/id-from-path.test.js @@ -14,23 +14,23 @@ describe("idFromPath", () => { ).toBe("01ARZ3NDEKTSV4RRFFQ69G5FAV"); }); - it("returns false for a string with no 26-char alphanumeric match", () => { - expect(idFromPath("/v2/screens/short")).toBe(false); + it("returns null for a string with no 26-char alphanumeric match", () => { + expect(idFromPath("/v2/screens/short")).toBeNull(); }); - it("returns false for an empty string", () => { - expect(idFromPath("")).toBe(false); + it("returns null for an empty string", () => { + expect(idFromPath("")).toBeNull(); }); - it("returns false for null", () => { - expect(idFromPath(null)).toBe(false); + it("returns null for null", () => { + expect(idFromPath(null)).toBeNull(); }); - it("returns false for undefined", () => { - expect(idFromPath(undefined)).toBe(false); + it("returns null for undefined", () => { + expect(idFromPath(undefined)).toBeNull(); }); - it("returns false for a number", () => { - expect(idFromPath(123)).toBe(false); + it("returns null for a number", () => { + expect(idFromPath(123)).toBeNull(); }); }); From 723c59ffa42f48b1e4bff4493b4b0579249925dc Mon Sep 17 00:00:00 2001 From: Troels Ugilt Jensen <6103205+tuj@users.noreply.github.com> Date: Mon, 20 Apr 2026 14:56:19 +0200 Subject: [PATCH 51/73] 7228: Added refs and guards --- assets/client/components/region.jsx | 21 +++++++++++++++------ assets/client/service/content-service.js | 9 +++++++++ 2 files changed, 24 insertions(+), 6 deletions(-) diff --git a/assets/client/components/region.jsx b/assets/client/components/region.jsx index 952eefced..fca9f671b 100644 --- a/assets/client/components/region.jsx +++ b/assets/client/components/region.jsx @@ -1,4 +1,4 @@ -import { useEffect, useState, createRef } from "react"; +import { useEffect, useState, createRef, useRef } from "react"; import { createGridArea } from "../../shared/grid-generator/grid-generator"; import { TransitionGroup, CSSTransition } from "react-transition-group"; import ErrorBoundary from "./error-boundary.jsx"; @@ -24,6 +24,13 @@ function Region({ region }) { const [nodeRefs, setNodeRefs] = useState({}); const [runId, setRunId] = useState(null); + // Refs to avoid stale closures in slideDone — templates capture slideDone + // in a useEffect([run]) that won't re-run when slides/newSlides change. + const slidesRef = useRef(null); + const newSlidesRef = useRef(null); + slidesRef.current = slides; + newSlidesRef.current = newSlides; + const rootStyle = {}; const regionId = idFromPath(region["@id"]); @@ -38,14 +45,15 @@ function Region({ region }) { * The slide. */ function findNextSlide(fromId) { - const slideIndex = slides.findIndex( + const currentSlides = slidesRef.current; + const slideIndex = currentSlides.findIndex( (slideElement) => slideElement.executionId === fromId, ); - const nextIndex = (slideIndex + 1) % slides.length; + const nextIndex = (slideIndex + 1) % currentSlides.length; return { - nextSlide: slides[nextIndex], + nextSlide: currentSlides[nextIndex], nextIndex, }; } @@ -57,9 +65,10 @@ function Region({ region }) { */ const slideDone = (slide) => { const nextSlideAndIndex = findNextSlide(slide.executionId); + const latestNewSlides = newSlidesRef.current; - if (nextSlideAndIndex.nextIndex === 0 && Array.isArray(newSlides)) { - const nextSlides = [...newSlides]; + if (nextSlideAndIndex.nextIndex === 0 && Array.isArray(latestNewSlides)) { + const nextSlides = [...latestNewSlides]; setSlides(nextSlides); setNewSlides(null); setCurrentSlide(nextSlides[0]); diff --git a/assets/client/service/content-service.js b/assets/client/service/content-service.js index b4afde50a..f5d0ca132 100644 --- a/assets/client/service/content-service.js +++ b/assets/client/service/content-service.js @@ -177,6 +177,12 @@ class ContentService { * Start the engine. */ start() { + if (this.started) { + logger.warn("Content service already started."); + return; + } + this.started = true; + logger.info("Content service started."); document.addEventListener("stopDataSync", this.stopSyncHandler); @@ -191,6 +197,9 @@ class ContentService { * Stop the engine. */ stop() { + if (!this.started) return; + this.started = false; + logger.info("Content service stopped."); document.removeEventListener("stopDataSync", this.stopSyncHandler); From 55a59a10555886c0c0627289b70edf49e1e2d074 Mon Sep 17 00:00:00 2001 From: Troels Ugilt Jensen <6103205+tuj@users.noreply.github.com> Date: Mon, 20 Apr 2026 15:00:43 +0200 Subject: [PATCH 52/73] 7228: Fixed issues --- assets/client/app.jsx | 14 +++++++++----- assets/client/components/region.jsx | 5 +++++ assets/client/data-sync/pull-strategy.js | 2 ++ 3 files changed, 16 insertions(+), 5 deletions(-) diff --git a/assets/client/app.jsx b/assets/client/app.jsx index e8cdb7ac2..e85ad286e 100644 --- a/assets/client/app.jsx +++ b/assets/client/app.jsx @@ -222,12 +222,16 @@ function App({ preview, previewId }) { tokenService.checkToken(); - ClientConfigLoader.loadConfig().then((config) => { - setDebug(config.debug ?? false); + ClientConfigLoader.loadConfig() + .then((config) => { + setDebug(config.debug ?? false); - const relationChecksumEnabled = config.relationsChecksumEnabled; - logger.info(`Relation checksum enabled: ${relationChecksumEnabled}`); - }); + const relationChecksumEnabled = config.relationsChecksumEnabled; + logger.info(`Relation checksum enabled: ${relationChecksumEnabled}`); + }) + .catch((err) => { + logger.error(`Failed to load config: ${err}`); + }); releaseService.checkForNewRelease().finally(() => { releaseService.setPreviousBootInUrl(); diff --git a/assets/client/components/region.jsx b/assets/client/components/region.jsx index fca9f671b..2444db466 100644 --- a/assets/client/components/region.jsx +++ b/assets/client/components/region.jsx @@ -46,6 +46,11 @@ function Region({ region }) { */ function findNextSlide(fromId) { const currentSlides = slidesRef.current; + + if (!currentSlides || currentSlides.length === 0) { + return { nextSlide: null, nextIndex: 0 }; + } + const slideIndex = currentSlides.findIndex( (slideElement) => slideElement.executionId === fromId, ); diff --git a/assets/client/data-sync/pull-strategy.js b/assets/client/data-sync/pull-strategy.js index e90257ae2..3245873ba 100644 --- a/assets/client/data-sync/pull-strategy.js +++ b/assets/client/data-sync/pull-strategy.js @@ -64,6 +64,8 @@ function query(endpoint, args, forceRefetch = false) { * @param {boolean} forceRefetch Whether to bypass RTK Query cache. * @returns {Promise} All hydra:member results concatenated. */ +// Upper bound on pagination — intentionally capped. Content types served to +// screens should never exceed this number of pages. const MAX_PAGES = 50; async function queryAllPages(endpoint, args, forceRefetch = false) { From 278e678eae19a30a10f19615e61fd905e0196219 Mon Sep 17 00:00:00 2001 From: Troels Ugilt Jensen <6103205+tuj@users.noreply.github.com> Date: Mon, 20 Apr 2026 15:09:22 +0200 Subject: [PATCH 53/73] 7228: Moved values to constants and defaults --- assets/client/components/region.jsx | 5 +++-- assets/client/components/slide.jsx | 3 ++- assets/client/components/touch-region.jsx | 2 +- assets/client/data-sync/pull-strategy.js | 9 ++++----- assets/client/service/schedule-service.js | 3 ++- assets/client/util/constants.js | 3 +++ assets/client/util/defaults.js | 4 ++++ 7 files changed, 19 insertions(+), 10 deletions(-) diff --git a/assets/client/components/region.jsx b/assets/client/components/region.jsx index 2444db466..5f417276e 100644 --- a/assets/client/components/region.jsx +++ b/assets/client/components/region.jsx @@ -5,6 +5,7 @@ import ErrorBoundary from "./error-boundary.jsx"; import idFromPath from "../util/id-from-path"; import logger from "../logger/logger"; import Slide from "./slide.jsx"; +import constants from "../util/constants"; import "./region.scss"; /** @@ -119,7 +120,7 @@ function Region({ region }) { * The event. The data is contained in detail. */ function regionContentListener(event) { - const receivedSlides = [...event.detail.slides]; + const receivedSlides = [...(event.detail?.slides ?? [])]; // Filter out invalid slides. setNewSlides(receivedSlides.filter((slide) => !slide.invalid)); @@ -199,7 +200,7 @@ function Region({ region }) { {currentSlide && ( diff --git a/assets/client/components/slide.jsx b/assets/client/components/slide.jsx index 544ed2122..99fec16b5 100644 --- a/assets/client/components/slide.jsx +++ b/assets/client/components/slide.jsx @@ -1,6 +1,7 @@ import ErrorBoundary from "./error-boundary.jsx"; import logger from "../logger/logger"; import { renderSlide } from "../../shared/slide-utils/templates.js"; +import constants from "../util/constants"; import "./slide.scss"; /** @@ -25,7 +26,7 @@ function Slide({ slide, id, run, slideDone, slideError, forwardRef }) { setTimeout(() => { slideError(slide); - }, 5000); + }, constants.SLIDE_ERROR_RECOVERY_TIMEOUT); }; return ( diff --git a/assets/client/components/touch-region.jsx b/assets/client/components/touch-region.jsx index fbcde7a84..05f4844fa 100644 --- a/assets/client/components/touch-region.jsx +++ b/assets/client/components/touch-region.jsx @@ -72,7 +72,7 @@ function TouchRegion({ region }) { * The event. The data is contained in detail. */ function regionContentListener(event) { - setSlides([...event.detail.slides].filter((slide) => !slide.invalid)); + setSlides([...(event.detail?.slides ?? [])].filter((slide) => !slide.invalid)); } // Setup event listener for region content. diff --git a/assets/client/data-sync/pull-strategy.js b/assets/client/data-sync/pull-strategy.js index 3245873ba..70baba8e6 100644 --- a/assets/client/data-sync/pull-strategy.js +++ b/assets/client/data-sync/pull-strategy.js @@ -5,9 +5,8 @@ import { cloneDeep } from "lodash"; import ClientConfigLoader from "../util/client-config-loader.js"; import { clientStore } from "../redux/store.js"; import { clientApi } from "../redux/generated-api.ts"; - -// Static ID used as synthetic region ID when campaigns override the screen layout. -const CAMPAIGN_REGION_ID = "01G112XBWFPY029RYFB8X2H4KD"; +import constants from "../util/constants.js"; +import defaults from "../util/defaults.js"; // Regex to extract regionId from region playlist paths. const REGION_PATH_REGEX = @@ -136,7 +135,7 @@ class PullStrategy { this.enrichSlides = this.enrichSlides.bind(this); this.enrichSlide = this.enrichSlide.bind(this); - this.interval = config?.interval ?? 60000 * 5; + this.interval = config?.interval ?? defaults.pullStrategyIntervalDefault; this.entryPoint = config.entryPoint; } @@ -396,7 +395,7 @@ class PullStrategy { async buildCampaignLayout(screen, forceRefetch) { logger.info(`Has active campaign.`); - const campaignRegionId = CAMPAIGN_REGION_ID; + const campaignRegionId = constants.CAMPAIGN_REGION_ID; screen.layoutData = { grid: { diff --git a/assets/client/service/schedule-service.js b/assets/client/service/schedule-service.js index 7254e2b54..3cc25341e 100644 --- a/assets/client/service/schedule-service.js +++ b/assets/client/service/schedule-service.js @@ -6,6 +6,7 @@ import logger from "../logger/logger"; import ClientConfigLoader from "../util/client-config-loader.js"; import ScheduleUtils from "../util/schedule"; import { cloneDeep } from "lodash"; +import defaults from "../util/defaults"; /** * ScheduleService. @@ -95,7 +96,7 @@ class ScheduleService { if (!Object.prototype.hasOwnProperty.call(intervals, regionId)) { ClientConfigLoader.loadConfig().then((config) => { - const schedulingInterval = config?.schedulingInterval ?? 60000; + const schedulingInterval = config?.schedulingInterval ?? defaults.schedulingIntervalDefault; // Extra check because of async. if (!Object.prototype.hasOwnProperty.call(intervals, regionId)) { diff --git a/assets/client/util/constants.js b/assets/client/util/constants.js index 1c32963f2..dc6fec772 100644 --- a/assets/client/util/constants.js +++ b/assets/client/util/constants.js @@ -17,6 +17,9 @@ const constants = { NO_TOKEN: "NO_TOKEN", NO_EXPIRE: "NO_EXPIRE", NO_ISSUED_AT: "NO_ISSUED_AT", + CAMPAIGN_REGION_ID: "01G112XBWFPY029RYFB8X2H4KD", + SLIDE_ERROR_RECOVERY_TIMEOUT: 5 * 1000, + SLIDE_TRANSITION_TIMEOUT: 1000, }; export default constants; diff --git a/assets/client/util/defaults.js b/assets/client/util/defaults.js index 4c1e61d2c..b9e989cf1 100644 --- a/assets/client/util/defaults.js +++ b/assets/client/util/defaults.js @@ -5,6 +5,10 @@ const defaults = { refreshTokenTimeoutDefault: 15 * 60 * 1000, // Every 10 minutes. releaseTimestampIntervalTimeoutDefault: 10 * 60 * 1000, + // Every 60 seconds. Fallback for scheduling interval. + schedulingIntervalDefault: 60 * 1000, + // Every 5 minutes. Fallback for pull strategy interval. + pullStrategyIntervalDefault: 5 * 60 * 1000, }; export default defaults; From 4a3495a3bf3d5906868807963eaac6db60e2c330 Mon Sep 17 00:00:00 2001 From: Troels Ugilt Jensen <6103205+tuj@users.noreply.github.com> Date: Mon, 20 Apr 2026 15:13:18 +0200 Subject: [PATCH 54/73] 7228: Cleaned up minor issues --- assets/client/components/screen.jsx | 4 ++-- assets/client/data-sync/pull-strategy.js | 21 +++++++++++++-------- assets/client/util/client-config-loader.js | 6 ++---- assets/client/util/constants.js | 1 + assets/client/util/defaults.js | 2 ++ 5 files changed, 20 insertions(+), 14 deletions(-) diff --git a/assets/client/components/screen.jsx b/assets/client/components/screen.jsx index 91335bf39..917e110a6 100644 --- a/assets/client/components/screen.jsx +++ b/assets/client/components/screen.jsx @@ -5,6 +5,7 @@ import Region from "./region.jsx"; import logger from "../logger/logger"; import TouchRegion from "./touch-region.jsx"; import ClientConfigLoader from "../util/client-config-loader.js"; +import constants from "../util/constants"; import "./screen.scss"; /** @@ -72,10 +73,9 @@ function Screen({ screen }) { if (screen?.enableColorSchemeChange) { logger.info("Enabling color scheme change."); refreshColorScheme(); - // Refresh color scheme every 5 minutes. colorSchemeIntervalRef.current = setInterval( refreshColorScheme, - 5 * 60 * 1000, + constants.COLOR_SCHEME_REFRESH_INTERVAL, ); } diff --git a/assets/client/data-sync/pull-strategy.js b/assets/client/data-sync/pull-strategy.js index 70baba8e6..d1825d133 100644 --- a/assets/client/data-sync/pull-strategy.js +++ b/assets/client/data-sync/pull-strategy.js @@ -170,14 +170,15 @@ class PullStrategy { ); }); - await Promise.allSettled(promises).then((results) => { - results.forEach((result) => { - if (result.status === "fulfilled") { - result.value.forEach(({ campaign }) => { - screenGroupCampaigns.push(campaign); - }); - } - }); + const settledResults = await Promise.allSettled(promises); + settledResults.forEach((result) => { + if (result.status === "fulfilled") { + result.value.forEach(({ campaign }) => { + screenGroupCampaigns.push(campaign); + }); + } else { + logger.warn(`Failed to fetch screen group campaigns: ${result.reason}`); + } }); } catch (err) { logger.error(err); @@ -241,6 +242,8 @@ class PullStrategy { regionData[result.value.regionId] = result.value.results.map( ({ playlist }) => playlist, ); + } else { + logger.warn(`Failed to fetch region playlists: ${result.reason}`); } }); @@ -287,6 +290,8 @@ class PullStrategy { ].slidesData = result.value.results.map( (playlistSlide) => playlistSlide.slide, ); + } else { + logger.warn(`Failed to fetch playlist slides: ${result.reason}`); } }); diff --git a/assets/client/util/client-config-loader.js b/assets/client/util/client-config-loader.js index f28fe9ee2..15c4651ca 100644 --- a/assets/client/util/client-config-loader.js +++ b/assets/client/util/client-config-loader.js @@ -1,8 +1,6 @@ -// Only fetch new config if more than 15 minutes have passed. import appStorage from "./app-storage.js"; import logger from "../logger/logger"; - -const configFetchIntervalDefault = 15 * 60 * 1000; +import defaults from "./defaults.js"; // Defaults. let configData = null; @@ -25,7 +23,7 @@ const ClientConfigLoader = { if ( configData !== null && latestFetchTimestamp + - (configData?.configFetchInterval ?? configFetchIntervalDefault) >= + (configData?.configFetchInterval ?? defaults.configFetchIntervalDefault) >= nowTimestamp ) { return configData; diff --git a/assets/client/util/constants.js b/assets/client/util/constants.js index dc6fec772..837541b6d 100644 --- a/assets/client/util/constants.js +++ b/assets/client/util/constants.js @@ -20,6 +20,7 @@ const constants = { CAMPAIGN_REGION_ID: "01G112XBWFPY029RYFB8X2H4KD", SLIDE_ERROR_RECOVERY_TIMEOUT: 5 * 1000, SLIDE_TRANSITION_TIMEOUT: 1000, + COLOR_SCHEME_REFRESH_INTERVAL: 5 * 60 * 1000, }; export default constants; diff --git a/assets/client/util/defaults.js b/assets/client/util/defaults.js index b9e989cf1..05504ba01 100644 --- a/assets/client/util/defaults.js +++ b/assets/client/util/defaults.js @@ -9,6 +9,8 @@ const defaults = { schedulingIntervalDefault: 60 * 1000, // Every 5 minutes. Fallback for pull strategy interval. pullStrategyIntervalDefault: 5 * 60 * 1000, + // Every 15 minutes. Fallback for config fetch interval. + configFetchIntervalDefault: 15 * 60 * 1000, }; export default defaults; From 3418ed2424d62fc90ac486dd55732f13a348feb4 Mon Sep 17 00:00:00 2001 From: Troels Ugilt Jensen <6103205+tuj@users.noreply.github.com> Date: Mon, 20 Apr 2026 15:51:52 +0200 Subject: [PATCH 55/73] 7223: Changed from event based to React Context based --- assets/client/app.jsx | 85 +++------- assets/client/components/region.jsx | 59 ++----- assets/client/components/touch-region.jsx | 56 ++----- .../client/context/client-state-context.jsx | 52 +++++++ assets/client/data-sync/data-sync.js | 2 +- assets/client/data-sync/pull-strategy.js | 16 +- assets/client/index.jsx | 5 +- assets/client/redux/base-query.js | 3 +- assets/client/redux/reauthenticate-ref.js | 8 + assets/client/service/content-service.js | 144 +++++------------ assets/client/service/schedule-service.js | 21 +-- assets/tests/client/constants.test.js | 10 ++ assets/tests/client/defaults.test.js | 12 ++ assets/tests/client/pull-strategy.test.js | 62 ++++---- assets/tests/client/region.test.jsx | 145 +++++++----------- assets/tests/client/schedule-service.test.js | 45 +++--- assets/tests/client/screen.test.jsx | 15 ++ assets/tests/client/touch-region.test.jsx | 87 +++++------ 18 files changed, 344 insertions(+), 483 deletions(-) create mode 100644 assets/client/context/client-state-context.jsx create mode 100644 assets/client/redux/reauthenticate-ref.js diff --git a/assets/client/app.jsx b/assets/client/app.jsx index e85ad286e..134cabe86 100644 --- a/assets/client/app.jsx +++ b/assets/client/app.jsx @@ -11,6 +11,8 @@ import releaseService from "./service/release-service"; 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 "./app.scss"; /** @@ -24,11 +26,11 @@ import "./app.scss"; */ function App({ preview, previewId }) { const [running, setRunning] = useState(false); - const [screen, setScreen] = useState(""); const [bindKey, setBindKey] = useState(null); - const [displayFallback, setDisplayFallback] = useState(true); const [debug, setDebug] = useState(false); + const { screen, isContentEmpty, callbacks } = useClientState(); + const checkLoginTimeoutRef = useRef(null); const contentServiceRef = useRef(null); const runningRef = useRef(false); @@ -46,20 +48,6 @@ function App({ preview, previewId }) { appStyle.cursor = "none"; } - /** - * Handles "screen" events. - * - * @param {CustomEvent} event - * The event. - */ - function screenHandler(event) { - const screenData = event.detail?.screen; - - if (screenData !== null) { - setScreen(screenData); - } - } - const startContent = (localScreenId) => { logger.info("Starting content."); @@ -74,20 +62,14 @@ function App({ preview, previewId }) { runningRef.current = true; setRunning(true); - contentServiceRef.current = new ContentService(); + contentServiceRef.current = new ContentService(callbacks); // Start the content service. contentServiceRef.current.start(); const entrypoint = `/v2/screens/${localScreenId}`; - - document.dispatchEvent( - new CustomEvent("startDataSync", { - detail: { - screenPath: entrypoint, - }, - }), - ); + contentServiceRef.current.stopSync(); + contentServiceRef.current.startSyncing(entrypoint); tokenService.startRefreshing(); }; @@ -150,7 +132,11 @@ function App({ preview, previewId }) { statusService.setError(constants.ERROR_TOKEN_REFRESH_FAILED); - document.dispatchEvent(new Event("stopDataSync")); + if (contentServiceRef.current !== null) { + contentServiceRef.current.stopSync(); + contentServiceRef.current.stop(); + contentServiceRef.current = null; + } appStorage.clearToken(); appStorage.clearRefreshToken(); @@ -158,12 +144,7 @@ function App({ preview, previewId }) { appStorage.clearTenant(); appStorage.clearFallbackImageUrl(); - if (contentServiceRef.current !== null) { - contentServiceRef.current.stop(); - contentServiceRef.current = null; - } - - setScreen(null); + callbacks.current.setScreen(null); runningRef.current = false; setRunning(false); @@ -173,16 +154,6 @@ function App({ preview, previewId }) { }); }; - const contentEmpty = () => { - logger.info("Content empty. Displaying fallback."); - setDisplayFallback(true); - }; - - const contentNotEmpty = () => { - logger.info("Content not empty. Displaying content."); - setDisplayFallback(false); - }; - // ctrl/cmd i will log screen out and refresh const handleKeyboard = ({ repeat, metaKey, ctrlKey, code }) => { if (!repeat && (metaKey || ctrlKey) && code === "KeyI") { @@ -194,31 +165,19 @@ function App({ preview, previewId }) { useEffect(() => { logger.info("Mounting App."); if (preview !== null) { - document.addEventListener("screen", screenHandler); - document.addEventListener("contentEmpty", contentEmpty); - document.addEventListener("contentNotEmpty", contentNotEmpty); - if (preview === "screen") { startContent(previewId); } else { setRunning(true); - contentServiceRef.current = new ContentService(); + contentServiceRef.current = new ContentService(callbacks); contentServiceRef.current.start(); - document.dispatchEvent( - new CustomEvent("startPreview", { - detail: { - mode: preview, - id: previewId, - }, - }), - ); + contentServiceRef.current.startPreview(preview, previewId); } } else { document.addEventListener("keydown", handleKeyboard); - document.addEventListener("screen", screenHandler); - document.addEventListener("reauthenticate", reauthenticateHandler); - document.addEventListener("contentEmpty", contentEmpty); - document.addEventListener("contentNotEmpty", contentNotEmpty); + + // Wire up reauthenticate callback for base-query (outside React tree). + reauthenticateRef.current = reauthenticateHandler; tokenService.checkToken(); @@ -248,13 +207,9 @@ function App({ preview, previewId }) { return function cleanup() { logger.info("Unmounting App."); - document.removeEventListener("screen", screenHandler); - document.removeEventListener("contentEmpty", contentEmpty); - document.removeEventListener("contentNotEmpty", contentNotEmpty); - if (preview === null) { document.removeEventListener("keydown", handleKeyboard); - document.removeEventListener("reauthenticate", reauthenticateHandler); + reauthenticateRef.current = () => {}; if (checkLoginTimeoutRef.current) { clearTimeout(checkLoginTimeoutRef.current); @@ -290,7 +245,7 @@ function App({ preview, previewId }) { )} - {displayFallback && !bindKey && ( + {isContentEmpty && !bindKey && (
)}
diff --git a/assets/client/components/region.jsx b/assets/client/components/region.jsx index 5f417276e..1db82f8c5 100644 --- a/assets/client/components/region.jsx +++ b/assets/client/components/region.jsx @@ -6,6 +6,7 @@ import idFromPath from "../util/id-from-path"; import logger from "../logger/logger"; import Slide from "./slide.jsx"; import constants from "../util/constants"; +import { useClientState } from "../context/client-state-context.jsx"; import "./region.scss"; /** @@ -32,6 +33,7 @@ function Region({ region }) { slidesRef.current = slides; newSlidesRef.current = newSlides; + const { regionSlides, callbacks } = useClientState(); const rootStyle = {}; const regionId = idFromPath(region["@id"]); @@ -85,15 +87,6 @@ function Region({ region }) { setRunId(new Date().toISOString()); logger.info(`Slide done with executionId: ${slide?.executionId}`); - - // Emit slideDone event. - const slideDoneEvent = new CustomEvent("slideDone", { - detail: { - regionId, - executionId: slide.executionId, - }, - }); - document.dispatchEvent(slideDoneEvent); }; /** @@ -113,55 +106,29 @@ function Region({ region }) { slideDone(slideWithError); }; - /** - * Handle region content event. - * - * @param {CustomEvent} event - * The event. The data is contained in detail. - */ - function regionContentListener(event) { - const receivedSlides = [...(event.detail?.slides ?? [])]; + // Receive region slides from context. + useEffect(() => { + const incoming = regionSlides[regionId]; + if (!incoming) return; - // Filter out invalid slides. + const receivedSlides = [...(incoming ?? [])]; setNewSlides(receivedSlides.filter((slide) => !slide.invalid)); - } + }, [regionSlides[regionId]]); - // Setup event listener for region content. + // Notify lifecycle on mount/unmount. useEffect(() => { logger.info(`Mounting region ${regionId}`); - - document.addEventListener( - `regionContent-${regionId}`, - regionContentListener, - ); + callbacks.current.onRegionReady(regionId); return function cleanup() { logger.info(`Unmounting region ${regionId}`); - - // Emit event that region has been removed. - const event = new CustomEvent("regionRemoved", { - detail: { - id: regionId, - }, - }); - document.dispatchEvent(event); - - // Cleanup event listener. - document.removeEventListener( - `regionContent-${regionId}`, - regionContentListener, - ); + callbacks.current.onRegionRemoved(regionId); }; }, [regionId]); - // Notify that region is ready. + // Notify that region is ready when region prop changes. useEffect(() => { - const event = new CustomEvent("regionReady", { - detail: { - id: regionId, - }, - }); - document.dispatchEvent(event); + callbacks.current.onRegionReady(regionId); }, [region]); // Start the progress if no slide is currently playing. diff --git a/assets/client/components/touch-region.jsx b/assets/client/components/touch-region.jsx index 05f4844fa..68ad3a28d 100644 --- a/assets/client/components/touch-region.jsx +++ b/assets/client/components/touch-region.jsx @@ -5,6 +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 "./touch-region.scss"; /** @@ -24,6 +25,7 @@ function TouchRegion({ region }) { const [nodeRefs, setNodeRefs] = useState({}); const [runId, setRunId] = useState(null); + const { regionSlides, callbacks } = useClientState(); const rootStyle = {}; const regionId = idFromPath(region["@id"]); @@ -38,14 +40,6 @@ function TouchRegion({ region }) { setDisplayClose(false); setCurrentSlide(null); - // Emit slideDone event. - const slideDoneEvent = new CustomEvent("slideDone", { - detail: { - regionId, - executionId: slide.executionId, - }, - }); - document.dispatchEvent(slideDoneEvent); }; /** @@ -65,48 +59,26 @@ function TouchRegion({ region }) { slideDone(slideWithError); }; - /** - * Handle region content event. - * - * @param {CustomEvent} event - * The event. The data is contained in detail. - */ - function regionContentListener(event) { - setSlides([...(event.detail?.slides ?? [])].filter((slide) => !slide.invalid)); - } + // Receive region slides from context. + useEffect(() => { + const incoming = regionSlides[regionId]; + if (!incoming) return; + + setSlides([...(incoming ?? [])].filter((slide) => !slide.invalid)); + }, [regionSlides[regionId]]); - // Setup event listener for region content. + // Notify lifecycle on mount/unmount. useEffect(() => { - document.addEventListener( - `regionContent-${regionId}`, - regionContentListener, - ); + callbacks.current.onRegionReady(regionId); return function cleanup() { - // Emit event that region has been removed. - const event = new CustomEvent("regionRemoved", { - detail: { - id: regionId, - }, - }); - document.dispatchEvent(event); - - // Cleanup event listener. - document.removeEventListener( - `regionContent-${regionId}`, - regionContentListener, - ); + callbacks.current.onRegionRemoved(regionId); }; }, [regionId]); - // Notify that region is ready. + // Notify that region is ready when region prop changes. useEffect(() => { - const event = new CustomEvent("regionReady", { - detail: { - id: regionId, - }, - }); - document.dispatchEvent(event); + callbacks.current.onRegionReady(regionId); }, [region]); // Make sure current slide is set. diff --git a/assets/client/context/client-state-context.jsx b/assets/client/context/client-state-context.jsx new file mode 100644 index 000000000..a40b4b555 --- /dev/null +++ b/assets/client/context/client-state-context.jsx @@ -0,0 +1,52 @@ +import { createContext, useState, useRef, useContext, useCallback } from "react"; + +const ClientStateContext = createContext(); + +/** + * Provider that holds client state previously communicated via document events. + * Services receive the callbacks ref at construction and call them directly. + */ +function ClientStateProvider({ children }) { + const [screen, setScreen] = useState(null); + const [isContentEmpty, setIsContentEmpty] = useState(true); + const [regionSlides, setRegionSlides] = useState({}); + + const updateRegionSlides = useCallback((regionId, slides) => { + setRegionSlides((prev) => ({ ...prev, [regionId]: slides })); + }, []); + + // Stable callbacks ref — services hold a reference to this object + // and call its methods instead of dispatching document events. + const callbacks = useRef({ + setScreen, + setIsContentEmpty, + updateRegionSlides, + onRegionReady: () => {}, + onRegionRemoved: () => {}, + onReauthenticate: () => {}, + }); + + // Keep setters in sync (they are stable, but updateRegionSlides is too via useCallback). + callbacks.current.setScreen = setScreen; + callbacks.current.setIsContentEmpty = setIsContentEmpty; + callbacks.current.updateRegionSlides = updateRegionSlides; + + const value = { + screen, + isContentEmpty, + regionSlides, + callbacks, + }; + + return ( + + {children} + + ); +} + +function useClientState() { + return useContext(ClientStateContext); +} + +export { ClientStateProvider, useClientState }; diff --git a/assets/client/data-sync/data-sync.js b/assets/client/data-sync/data-sync.js index 430bfb914..b40b5ad95 100644 --- a/assets/client/data-sync/data-sync.js +++ b/assets/client/data-sync/data-sync.js @@ -17,7 +17,7 @@ class DataSync { this.stop = this.stop.bind(this); this.config = config; - this.strategy = new PullStrategy(this.config); + this.strategy = new PullStrategy(this.config, this.config.onContent); } /** diff --git a/assets/client/data-sync/pull-strategy.js b/assets/client/data-sync/pull-strategy.js index d1825d133..ff8e03cc6 100644 --- a/assets/client/data-sync/pull-strategy.js +++ b/assets/client/data-sync/pull-strategy.js @@ -126,7 +126,11 @@ class PullStrategy { * @param {object} config * The config object. */ - constructor(config) { + /** + * @param {object} config The config object. + * @param {Function} onContent Callback invoked with the screen object when content is ready. + */ + constructor(config, onContent) { this.start = this.start.bind(this); this.stop = this.stop.bind(this); this.getScreen = this.getScreen.bind(this); @@ -137,6 +141,7 @@ class PullStrategy { this.interval = config?.interval ?? defaults.pullStrategyIntervalDefault; this.entryPoint = config.entryPoint; + this.onContent = onContent; } /** @@ -382,13 +387,8 @@ class PullStrategy { if (this.stopped) return; - // Deliver result to rendering - const event = new CustomEvent("content", { - detail: { - screen: newScreen, - }, - }); - document.dispatchEvent(event); + // Deliver result to rendering. + this.onContent(newScreen); } /** diff --git a/assets/client/index.jsx b/assets/client/index.jsx index f8304bd11..e5e819c11 100644 --- a/assets/client/index.jsx +++ b/assets/client/index.jsx @@ -1,6 +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 App from "./app.jsx"; const url = new URL(window.location.href); @@ -12,6 +13,8 @@ const root = createRoot(container); root.render( - + + + , ); diff --git a/assets/client/redux/base-query.js b/assets/client/redux/base-query.js index 4c1eefcf1..1c925d9c6 100644 --- a/assets/client/redux/base-query.js +++ b/assets/client/redux/base-query.js @@ -1,5 +1,6 @@ import { fetchBaseQuery } from "@reduxjs/toolkit/query/react"; import localStorageKeys from "../util/local-storage-keys"; +import reauthenticateRef from "./reauthenticate-ref"; const clientBaseQuery = async (args, api, extraOptions) => { const baseUrl = "/"; @@ -43,7 +44,7 @@ const clientBaseQuery = async (args, api, extraOptions) => { // Handle authentication errors. if (baseResult?.error?.status === 401) { - document.dispatchEvent(new Event("reauthenticate")); + reauthenticateRef.current(); } return { diff --git a/assets/client/redux/reauthenticate-ref.js b/assets/client/redux/reauthenticate-ref.js new file mode 100644 index 000000000..5873130f9 --- /dev/null +++ b/assets/client/redux/reauthenticate-ref.js @@ -0,0 +1,8 @@ +/** + * Mutable ref for the reauthenticate callback. + * Set by App component, called by base-query when a 401 is received. + * This bridges the Redux middleware layer (outside React) with the React tree. + */ +const reauthenticateRef = { current: () => {} }; + +export default reauthenticateRef; diff --git a/assets/client/service/content-service.js b/assets/client/service/content-service.js index f5d0ca132..9cec51b16 100644 --- a/assets/client/service/content-service.js +++ b/assets/client/service/content-service.js @@ -28,19 +28,23 @@ class ContentService { /** * Constructor. + * + * @param {object} callbacks - Ref object whose .current holds callback functions + * (setScreen, setIsContentEmpty, updateRegionSlides, onRegionReady, onRegionRemoved). */ - constructor() { + constructor(callbacks) { + this.callbacks = callbacks; + // Setup schedule service. - this.scheduleService = new ScheduleService(); + this.scheduleService = new ScheduleService(callbacks); this.startSyncing = this.startSyncing.bind(this); - this.stopSyncHandler = this.stopSyncHandler.bind(this); - this.startDataSyncHandler = this.startDataSyncHandler.bind(this); - this.regionReadyHandler = this.regionReadyHandler.bind(this); - this.regionRemovedHandler = this.regionRemovedHandler.bind(this); + this.stopSync = this.stopSync.bind(this); this.contentHandler = this.contentHandler.bind(this); this.startPreview = this.startPreview.bind(this); this.start = this.start.bind(this); + this.regionReady = this.regionReady.bind(this); + this.regionRemoved = this.regionRemoved.bind(this); } /** @@ -58,6 +62,7 @@ class ContentService { const dataStrategyConfig = { interval: config.pullStrategyInterval, endpoint: "", + onContent: this.contentHandler, }; if (screenPath) { @@ -70,51 +75,27 @@ class ContentService { } /** - * Stop sync event handler. + * Stop data synchronization. */ - stopSyncHandler() { - logger.info("Event received: Stop data synchronization"); + stopSync() { + logger.info("Stopping data synchronization"); this.syncingStopped = true; if (this.dataSync) { - logger.info("Stopping data synchronization"); this.dataSync.stop(); this.dataSync = null; } } /** - * Start data event handler. + * New content handler. * - * @param {CustomEvent} event - * The event. + * @param {object} screen - The screen data. */ - startDataSyncHandler(event) { - const data = event.detail; + contentHandler(screen) { + logger.info("Content received"); - this.stopSyncHandler(); - - if (data?.screenPath) { - logger.info( - `Event received: Start data synchronization from ${data.screenPath}`, - ); - this.startSyncing(data.screenPath); - } else { - logger.error("Error: screenPath not set."); - } - } - - /** - * New content event handler. - * - * @param {CustomEvent} event - * The event. - */ - contentHandler(event) { - logger.info("Event received: content"); - - const data = event.detail; - this.currentScreen = data.screen; + this.currentScreen = screen; const screenData = { ...this.currentScreen }; @@ -124,14 +105,14 @@ class ContentService { const newHash = Base64.stringify(sha256(JSON.stringify(screenData))); if (newHash !== this.screenHash) { - logger.info("Screen has changed. Emitting screen."); + logger.info("Screen has changed. Updating screen."); this.screenHash = newHash; - ContentService.emitScreen(screenData); + this.callbacks.current.setScreen(screenData); } else { - logger.info("Screen has not changed. Not emitting screen."); + logger.info("Screen has not changed. Not updating screen."); // eslint-disable-next-line guard-for-in,no-restricted-syntax - for (const regionKey in data.screen.regionData) { + for (const regionKey in screen.regionData) { const region = this.currentScreen.regionData[regionKey]; this.scheduleService.updateRegion(regionKey, region); } @@ -141,14 +122,10 @@ class ContentService { /** * Region ready handler. * - * @param {CustomEvent} event - * The event. + * @param {string} regionId - The region id. */ - regionReadyHandler(event) { - const data = event.detail; - const regionId = data.id; - - logger.info(`Event received: regionReady for ${regionId}`); + regionReady(regionId) { + logger.info(`Region ready: ${regionId}`); if (this.currentScreen) { this.scheduleService.updateRegion( @@ -161,14 +138,10 @@ class ContentService { /** * Region removed handler. * - * @param {CustomEvent} event - * The event. + * @param {string} regionId - The region id. */ - regionRemovedHandler(event) { - const data = event.detail; - const regionId = data.id; - - logger.info(`Event received: regionRemoved for ${regionId}`); + regionRemoved(regionId) { + logger.info(`Region removed: ${regionId}`); this.scheduleService.regionRemoved(regionId); } @@ -185,12 +158,9 @@ class ContentService { logger.info("Content service started."); - document.addEventListener("stopDataSync", this.stopSyncHandler); - document.addEventListener("startDataSync", this.startDataSyncHandler); - document.addEventListener("content", this.contentHandler); - document.addEventListener("regionReady", this.regionReadyHandler); - document.addEventListener("regionRemoved", this.regionRemovedHandler); - document.addEventListener("startPreview", this.startPreview); + // Wire up region lifecycle callbacks so components can notify us directly. + this.callbacks.current.onRegionReady = this.regionReady; + this.callbacks.current.onRegionRemoved = this.regionRemoved; } /** @@ -202,22 +172,17 @@ class ContentService { logger.info("Content service stopped."); - document.removeEventListener("stopDataSync", this.stopSyncHandler); - document.removeEventListener("startDataSync", this.startDataSyncHandler); - document.removeEventListener("content", this.contentHandler); - document.removeEventListener("regionReady", this.regionReadyHandler); - document.removeEventListener("regionRemoved", this.regionRemovedHandler); - document.removeEventListener("startPreview", this.startPreview); + this.callbacks.current.onRegionReady = () => {}; + this.callbacks.current.onRegionRemoved = () => {}; } /** * Start preview. * - * @param {CustomEvent} event The event. + * @param {string} mode - Preview mode (screen, playlist, slide). + * @param {string} id - Entity ID to preview. */ - async startPreview(event) { - const data = event.detail; - const { mode, id } = data; + async startPreview(mode, id) { logger.info(`Starting preview. Mode: ${mode}, ID: ${id}`); try { @@ -244,14 +209,7 @@ class ContentService { } const screen = screenForPlaylistPreview(playlist); - - document.dispatchEvent( - new CustomEvent("content", { - detail: { - screen, - }, - }), - ); + this.contentHandler(screen); } else if (mode === "slide") { const slide = await ContentService.query("getV2SlidesById", { id }); @@ -259,14 +217,7 @@ class ContentService { await ContentService.attachReferencesToSlide(slide); const screen = screenForSlidePreview(slide); - - document.dispatchEvent( - new CustomEvent("content", { - detail: { - screen, - }, - }), - ); + this.contentHandler(screen); } else { logger.error(`Unsupported preview mode: ${mode}.`); } @@ -316,23 +267,6 @@ class ContentService { } /* eslint-enable no-param-reassign */ } - - /** - * Emit screen. - * - * @param {object} screen - * Screen data. - */ - static emitScreen(screen) { - logger.info("Emitting screen"); - - const event = new CustomEvent("screen", { - detail: { - screen, - }, - }); - document.dispatchEvent(event); - } } export default ContentService; diff --git a/assets/client/service/schedule-service.js b/assets/client/service/schedule-service.js index 3cc25341e..ec38e80f3 100644 --- a/assets/client/service/schedule-service.js +++ b/assets/client/service/schedule-service.js @@ -21,7 +21,11 @@ class ScheduleService { contentEmpty = true; - constructor() { + /** + * @param {object} callbacks - Ref object with setIsContentEmpty and updateRegionSlides. + */ + constructor(callbacks) { + this.callbacks = callbacks; this.updateRegion = this.updateRegion.bind(this); this.checkForEmptyContent = this.checkForEmptyContent.bind(this); this.sendSlides = this.sendSlides.bind(this); @@ -38,12 +42,7 @@ class ScheduleService { if (contentEmpty !== this.contentEmpty) { this.contentEmpty = contentEmpty; - - // Deliver result to rendering - const event = new Event( - contentEmpty ? "contentEmpty" : "contentNotEmpty", - ); - document.dispatchEvent(event); + this.callbacks.current.setIsContentEmpty(contentEmpty); } } @@ -157,13 +156,7 @@ class ScheduleService { */ sendSlides(regionId, slides) { logger.info(`sendSlides regionContent-${regionId}`); - const event = new CustomEvent(`regionContent-${regionId}`, { - detail: { - slides, - }, - }); - document.dispatchEvent(event); - + this.callbacks.current.updateRegionSlides(regionId, slides); this.checkForEmptyContent(); } diff --git a/assets/tests/client/constants.test.js b/assets/tests/client/constants.test.js index 648bb01e2..87c20badb 100644 --- a/assets/tests/client/constants.test.js +++ b/assets/tests/client/constants.test.js @@ -38,4 +38,14 @@ describe("constants", () => { expect(constants.NO_EXPIRE).toBe("NO_EXPIRE"); expect(constants.NO_ISSUED_AT).toBe("NO_ISSUED_AT"); }); + + it("has campaign region ID", () => { + expect(constants.CAMPAIGN_REGION_ID).toBe("01G112XBWFPY029RYFB8X2H4KD"); + }); + + it("has timing constants", () => { + expect(constants.SLIDE_ERROR_RECOVERY_TIMEOUT).toBe(5000); + expect(constants.SLIDE_TRANSITION_TIMEOUT).toBe(1000); + expect(constants.COLOR_SCHEME_REFRESH_INTERVAL).toBe(300000); + }); }); diff --git a/assets/tests/client/defaults.test.js b/assets/tests/client/defaults.test.js index 3eb4e664a..1a50464db 100644 --- a/assets/tests/client/defaults.test.js +++ b/assets/tests/client/defaults.test.js @@ -13,4 +13,16 @@ describe("defaults", () => { it("releaseTimestampIntervalTimeoutDefault is 10 minutes", () => { expect(defaults.releaseTimestampIntervalTimeoutDefault).toBe(600000); }); + + it("schedulingIntervalDefault is 60 seconds", () => { + expect(defaults.schedulingIntervalDefault).toBe(60000); + }); + + it("pullStrategyIntervalDefault is 5 minutes", () => { + expect(defaults.pullStrategyIntervalDefault).toBe(300000); + }); + + it("configFetchIntervalDefault is 15 minutes", () => { + expect(defaults.configFetchIntervalDefault).toBe(900000); + }); }); diff --git a/assets/tests/client/pull-strategy.test.js b/assets/tests/client/pull-strategy.test.js index b86960fd7..f4af0b8b4 100644 --- a/assets/tests/client/pull-strategy.test.js +++ b/assets/tests/client/pull-strategy.test.js @@ -147,24 +147,21 @@ function setupBasicResponses(overrides = {}) { }); } -// --- Event capture --- -function captureContentEvent() { +// --- Content callback capture --- +function captureContentCallback() { const captured = { screen: null, callCount: 0 }; - const handler = (e) => { - captured.screen = e.detail.screen; + const callback = (screen) => { + captured.screen = screen; captured.callCount += 1; }; - document.addEventListener("content", handler); return { + callback, get screen() { return captured.screen; }, get callCount() { return captured.callCount; }, - cleanup() { - document.removeEventListener("content", handler); - }, }; } @@ -181,7 +178,7 @@ function getDispatchCallsFor(endpoint) { // --- Tests --- describe("PullStrategy.getScreen", () => { let strategy; - let contentEvent; + let contentCapture; beforeEach(() => { vi.useFakeTimers(); @@ -192,15 +189,14 @@ describe("PullStrategy.getScreen", () => { relationsChecksumEnabled: false, }); + contentCapture = captureContentCallback(); strategy = new PullStrategy({ entryPoint: SCREEN_PATH, interval: 60000, - }); - contentEvent = captureContentEvent(); + }, contentCapture.callback); }); afterEach(() => { - contentEvent.cleanup(); vi.useRealTimers(); }); @@ -212,7 +208,7 @@ describe("PullStrategy.getScreen", () => { await strategy.getScreen(SCREEN_PATH); - expect(contentEvent.callCount).toBe(0); + expect(contentCapture.callCount).toBe(0); expect(logger.warn).toHaveBeenCalledWith( expect.stringContaining("not loaded. Aborting content update"), ); @@ -225,7 +221,7 @@ describe("PullStrategy.getScreen", () => { await strategy.getScreen(SCREEN_PATH); - expect(contentEvent.callCount).toBe(0); + expect(contentCapture.callCount).toBe(0); expect(logger.warn).toHaveBeenCalledWith( expect.stringContaining("not loaded"), ); @@ -251,8 +247,8 @@ describe("PullStrategy.getScreen", () => { await strategy.getScreen(SCREEN_PATH); - expect(contentEvent.callCount).toBe(1); - const { screen } = contentEvent; + expect(contentCapture.callCount).toBe(1); + const { screen } = contentCapture; expect(screen.hasActiveCampaign).toBe(true); expect(screen.layoutData.grid).toEqual({ rows: 1, columns: 1 }); expect(screen.layoutData.regions[0]["@id"]).toContain( @@ -274,9 +270,9 @@ describe("PullStrategy.getScreen", () => { await strategy.getScreen(SCREEN_PATH); - expect(contentEvent.callCount).toBe(1); - expect(contentEvent.screen.hasActiveCampaign).toBe(false); - expect(contentEvent.screen.layoutData["@id"]).toContain(LAYOUT_ID); + expect(contentCapture.callCount).toBe(1); + expect(contentCapture.screen.hasActiveCampaign).toBe(false); + expect(contentCapture.screen.layoutData["@id"]).toContain(LAYOUT_ID); }); }); @@ -286,8 +282,8 @@ describe("PullStrategy.getScreen", () => { await strategy.getScreen(SCREEN_PATH); - expect(contentEvent.callCount).toBe(1); - const { screen } = contentEvent; + expect(contentCapture.callCount).toBe(1); + const { screen } = contentCapture; expect(screen.hasActiveCampaign).toBe(false); expect(screen.layoutData["@id"]).toContain(LAYOUT_ID); expect(screen.regionData[REGION_ID]).toBeDefined(); @@ -307,7 +303,7 @@ describe("PullStrategy.getScreen", () => { await strategy.getScreen(SCREEN_PATH); - expect(contentEvent.callCount).toBe(0); + expect(contentCapture.callCount).toBe(0); expect(logger.warn).toHaveBeenCalledWith( expect.stringContaining("not loaded. Aborting content update"), ); @@ -323,7 +319,7 @@ describe("PullStrategy.getScreen", () => { await strategy.getScreen(SCREEN_PATH); - expect(contentEvent.callCount).toBe(0); + expect(contentCapture.callCount).toBe(0); expect(logger.warn).toHaveBeenCalledWith( expect.stringContaining("not loaded. Aborting content update"), ); @@ -340,9 +336,9 @@ describe("PullStrategy.getScreen", () => { await strategy.getScreen(SCREEN_PATH); - expect(contentEvent.callCount).toBe(1); + expect(contentCapture.callCount).toBe(1); const slide = - contentEvent.screen.regionData[REGION_ID][0].slidesData[0]; + contentCapture.screen.regionData[REGION_ID][0].slidesData[0]; expect(slide.invalid).toBe(true); expect(slide.templateData).toBeNull(); expect(slide.mediaData).toEqual({}); @@ -356,9 +352,9 @@ describe("PullStrategy.getScreen", () => { await strategy.getScreen(SCREEN_PATH); - expect(contentEvent.callCount).toBe(1); + expect(contentCapture.callCount).toBe(1); const slide = - contentEvent.screen.regionData[REGION_ID][0].slidesData[0]; + contentCapture.screen.regionData[REGION_ID][0].slidesData[0]; expect(slide.invalid).toBe(true); expect(slide.templateData).toBeNull(); }); @@ -382,7 +378,7 @@ describe("PullStrategy.getScreen", () => { await strategy.getScreen(SCREEN_PATH); const slide = - contentEvent.screen.regionData[REGION_ID][0].slidesData[0]; + contentCapture.screen.regionData[REGION_ID][0].slidesData[0]; expect(slide.mediaData[`/v2/media/${MEDIA_ID_1}`]).toEqual(media1); expect(slide.mediaData[`/v2/media/${MEDIA_ID_2}`]).toEqual(media2); }); @@ -404,7 +400,7 @@ describe("PullStrategy.getScreen", () => { await strategy.getScreen(SCREEN_PATH); const slide = - contentEvent.screen.regionData[REGION_ID][0].slidesData[0]; + contentCapture.screen.regionData[REGION_ID][0].slidesData[0]; expect(slide.mediaData[`/v2/media/${MEDIA_ID_1}`]).toEqual(media1); expect(slide.mediaData[`/v2/media/${MEDIA_ID_2}`]).toBeNull(); }); @@ -423,7 +419,7 @@ describe("PullStrategy.getScreen", () => { await strategy.getScreen(SCREEN_PATH); const slide = - contentEvent.screen.regionData[REGION_ID][0].slidesData[0]; + contentCapture.screen.regionData[REGION_ID][0].slidesData[0]; expect(slide.feedData).toEqual(feedData); }); @@ -440,7 +436,7 @@ describe("PullStrategy.getScreen", () => { await strategy.getScreen(SCREEN_PATH); const slide = - contentEvent.screen.regionData[REGION_ID][0].slidesData[0]; + contentCapture.screen.regionData[REGION_ID][0].slidesData[0]; expect(slide.feedData).toBeNull(); }); }); @@ -518,7 +514,7 @@ describe("PullStrategy.getScreen", () => { }); await strategy.getScreen(SCREEN_PATH); - expect(contentEvent.screen.hasActiveCampaign).toBe(true); + expect(contentCapture.screen.hasActiveCampaign).toBe(true); // Second call: no campaign, same checksums mockDispatch.mockClear(); @@ -526,7 +522,7 @@ describe("PullStrategy.getScreen", () => { await strategy.getScreen(SCREEN_PATH); - expect(contentEvent.screen.hasActiveCampaign).toBe(false); + expect(contentCapture.screen.hasActiveCampaign).toBe(false); const layoutCalls = getDispatchCallsFor("getV2LayoutsById"); expect(layoutCalls[0].forceRefetch).toBe(true); }); diff --git a/assets/tests/client/region.test.jsx b/assets/tests/client/region.test.jsx index 3eb3ce82b..d4465c903 100644 --- a/assets/tests/client/region.test.jsx +++ b/assets/tests/client/region.test.jsx @@ -33,6 +33,25 @@ vi.mock("react-transition-group", () => ({ CSSTransition: ({ children }) => <>{children}, })); +// Mock context +const mockCallbacks = { + current: { + onRegionReady: vi.fn(), + onRegionRemoved: vi.fn(), + setScreen: vi.fn(), + setIsContentEmpty: vi.fn(), + updateRegionSlides: vi.fn(), + }, +}; +let mockRegionSlides = {}; + +vi.mock("../../client/context/client-state-context.jsx", () => ({ + useClientState: () => ({ + regionSlides: mockRegionSlides, + callbacks: mockCallbacks, + }), +})); + describe("Region", () => { const region = { "@id": "/v2/layouts/regions/REGION01", @@ -41,6 +60,9 @@ describe("Region", () => { beforeEach(() => { capturedSlideDone = null; + mockRegionSlides = {}; + mockCallbacks.current.onRegionReady.mockClear(); + mockCallbacks.current.onRegionRemoved.mockClear(); }); afterEach(() => { @@ -48,45 +70,28 @@ describe("Region", () => { vi.restoreAllMocks(); }); - it("emits regionReady event on mount", () => { - const handler = vi.fn(); - document.addEventListener("regionReady", handler); - + it("calls onRegionReady on mount", () => { render(); - expect(handler).toHaveBeenCalledTimes(1); - expect(handler.mock.calls[0][0].detail.id).toBe("REGION01"); - - document.removeEventListener("regionReady", handler); + expect(mockCallbacks.current.onRegionReady).toHaveBeenCalledWith("REGION01"); }); - it("emits regionRemoved event on unmount", () => { - const handler = vi.fn(); - document.addEventListener("regionRemoved", handler); - + it("calls onRegionRemoved on unmount", () => { const { unmount } = render(); unmount(); - expect(handler).toHaveBeenCalledTimes(1); - expect(handler.mock.calls[0][0].detail.id).toBe("REGION01"); - - document.removeEventListener("regionRemoved", handler); + expect(mockCallbacks.current.onRegionRemoved).toHaveBeenCalledWith("REGION01"); }); - it("displays the first slide when regionContent event is dispatched", () => { - const { container } = render(); + it("displays the first slide when regionSlides updates", () => { + mockRegionSlides = { + REGION01: [ + { executionId: "EXE-1", title: "Slide 1" }, + { executionId: "EXE-2", title: "Slide 2" }, + ], + }; - act(() => { - const event = new CustomEvent("regionContent-REGION01", { - detail: { - slides: [ - { executionId: "EXE-1", title: "Slide 1" }, - { executionId: "EXE-2", title: "Slide 2" }, - ], - }, - }); - document.dispatchEvent(event); - }); + const { container } = render(); expect( within(container).getByTestId("slide-EXE-1") @@ -94,19 +99,14 @@ describe("Region", () => { }); it("filters out invalid slides", () => { - const { container } = render(); + mockRegionSlides = { + REGION01: [ + { executionId: "EXE-1", title: "Valid", invalid: false }, + { executionId: "EXE-2", title: "Invalid", invalid: true }, + ], + }; - act(() => { - const event = new CustomEvent("regionContent-REGION01", { - detail: { - slides: [ - { executionId: "EXE-1", title: "Valid", invalid: false }, - { executionId: "EXE-2", title: "Invalid", invalid: true }, - ], - }, - }); - document.dispatchEvent(event); - }); + const { container } = render(); expect( within(container).getByTestId("slide-EXE-1") @@ -114,19 +114,14 @@ describe("Region", () => { }); it("advances to next slide when slideDone is called", () => { - const { container } = render(); + mockRegionSlides = { + REGION01: [ + { executionId: "EXE-1", title: "Slide 1" }, + { executionId: "EXE-2", title: "Slide 2" }, + ], + }; - act(() => { - const event = new CustomEvent("regionContent-REGION01", { - detail: { - slides: [ - { executionId: "EXE-1", title: "Slide 1" }, - { executionId: "EXE-2", title: "Slide 2" }, - ], - }, - }); - document.dispatchEvent(event); - }); + const { container } = render(); act(() => { capturedSlideDone(); @@ -138,19 +133,14 @@ describe("Region", () => { }); it("wraps around to first slide after last", () => { - const { container } = render(); + mockRegionSlides = { + REGION01: [ + { executionId: "EXE-1", title: "Slide 1" }, + { executionId: "EXE-2", title: "Slide 2" }, + ], + }; - act(() => { - const event = new CustomEvent("regionContent-REGION01", { - detail: { - slides: [ - { executionId: "EXE-1", title: "Slide 1" }, - { executionId: "EXE-2", title: "Slide 2" }, - ], - }, - }); - document.dispatchEvent(event); - }); + const { container } = render(); // Advance to EXE-2 act(() => { @@ -167,31 +157,6 @@ describe("Region", () => { ).toBeInTheDocument(); }); - it("emits slideDone event on document when slide completes", () => { - const handler = vi.fn(); - document.addEventListener("slideDone", handler); - - render(); - - act(() => { - const event = new CustomEvent("regionContent-REGION01", { - detail: { - slides: [{ executionId: "EXE-1", title: "Slide 1" }], - }, - }); - document.dispatchEvent(event); - }); - - act(() => { - capturedSlideDone(); - }); - - expect(handler).toHaveBeenCalledTimes(1); - expect(handler.mock.calls[0][0].detail.executionId).toBe("EXE-1"); - - document.removeEventListener("slideDone", handler); - }); - it("renders with correct grid area style", () => { const { container } = render(); const el = container.querySelector(".region"); diff --git a/assets/tests/client/schedule-service.test.js b/assets/tests/client/schedule-service.test.js index 3908ac550..63c51f5db 100644 --- a/assets/tests/client/schedule-service.test.js +++ b/assets/tests/client/schedule-service.test.js @@ -11,6 +11,15 @@ vi.mock("../../client/util/client-config-loader.js", () => ({ import ScheduleService from "../../client/service/schedule-service"; +function makeCallbacks() { + return { + current: { + setIsContentEmpty: vi.fn(), + updateRegionSlides: vi.fn(), + }, + }; +} + describe("ScheduleService", () => { describe("findScheduledSlides (static)", () => { beforeEach(() => { @@ -168,9 +177,11 @@ describe("ScheduleService", () => { describe("instance methods", () => { let service; + let callbacks; beforeEach(() => { - service = new ScheduleService(); + callbacks = makeCallbacks(); + service = new ScheduleService(callbacks); vi.useFakeTimers(); }); @@ -178,41 +189,27 @@ describe("ScheduleService", () => { vi.useRealTimers(); }); - it("sendSlides dispatches regionContent custom event", () => { - const handler = vi.fn(); - document.addEventListener("regionContent-region1", handler); - + it("sendSlides calls updateRegionSlides callback", () => { const slides = [{ "@id": "/v2/slides/A" }]; service.sendSlides("region1", slides); - expect(handler).toHaveBeenCalledTimes(1); - expect(handler.mock.calls[0][0].detail.slides).toEqual(slides); - - document.removeEventListener("regionContent-region1", handler); + expect(callbacks.current.updateRegionSlides).toHaveBeenCalledWith("region1", slides); }); - it("checkForEmptyContent dispatches contentEmpty when no regions have slides", () => { - const handler = vi.fn(); - document.addEventListener("contentEmpty", handler); - + it("checkForEmptyContent calls setIsContentEmpty(true) when no regions have slides", () => { service.regions = { r1: { slides: [] } }; service.contentEmpty = false; // force change detection service.checkForEmptyContent(); - expect(handler).toHaveBeenCalledTimes(1); - document.removeEventListener("contentEmpty", handler); + expect(callbacks.current.setIsContentEmpty).toHaveBeenCalledWith(true); }); - it("checkForEmptyContent dispatches contentNotEmpty when regions have slides", () => { - const handler = vi.fn(); - document.addEventListener("contentNotEmpty", handler); - + it("checkForEmptyContent calls setIsContentEmpty(false) when regions have slides", () => { service.regions = { r1: { slides: [{ "@id": "s1" }] } }; service.contentEmpty = true; // force change detection service.checkForEmptyContent(); - expect(handler).toHaveBeenCalledTimes(1); - document.removeEventListener("contentNotEmpty", handler); + expect(callbacks.current.setIsContentEmpty).toHaveBeenCalledWith(false); }); it("regionRemoved clears interval and cached data", () => { @@ -227,13 +224,9 @@ describe("ScheduleService", () => { }); it("updateRegion is a no-op when regionId is falsy", () => { - const handler = vi.fn(); - document.addEventListener("regionContent-undefined", handler); - service.updateRegion(null, []); - expect(handler).not.toHaveBeenCalled(); - document.removeEventListener("regionContent-undefined", handler); + expect(callbacks.current.updateRegionSlides).not.toHaveBeenCalled(); }); it("updateRegion is a no-op when region is falsy", () => { diff --git a/assets/tests/client/screen.test.jsx b/assets/tests/client/screen.test.jsx index 97db0400b..d9e9faeb2 100644 --- a/assets/tests/client/screen.test.jsx +++ b/assets/tests/client/screen.test.jsx @@ -43,6 +43,21 @@ vi.mock("../../client/util/id-from-path", () => ({ default: (path) => path.split("/").pop(), })); +vi.mock("../../client/context/client-state-context.jsx", () => ({ + useClientState: () => ({ + regionSlides: {}, + callbacks: { + current: { + onRegionReady: vi.fn(), + onRegionRemoved: vi.fn(), + setScreen: vi.fn(), + setIsContentEmpty: vi.fn(), + updateRegionSlides: vi.fn(), + }, + }, + }), +})); + describe("Screen", () => { const makeScreen = (regions = [], grid = { rows: 1, columns: 1 }) => ({ "@id": "/v2/screens/SCREEN01", diff --git a/assets/tests/client/touch-region.test.jsx b/assets/tests/client/touch-region.test.jsx index c99644267..e7573cfb4 100644 --- a/assets/tests/client/touch-region.test.jsx +++ b/assets/tests/client/touch-region.test.jsx @@ -32,6 +32,25 @@ vi.mock("../../client/components/slide.jsx", () => ({ }, })); +// Mock context +const mockCallbacks = { + current: { + onRegionReady: vi.fn(), + onRegionRemoved: vi.fn(), + setScreen: vi.fn(), + setIsContentEmpty: vi.fn(), + updateRegionSlides: vi.fn(), + }, +}; +let mockRegionSlides = {}; + +vi.mock("../../client/context/client-state-context.jsx", () => ({ + useClientState: () => ({ + regionSlides: mockRegionSlides, + callbacks: mockCallbacks, + }), +})); + describe("TouchRegion", () => { const region = { "@id": "/v2/layouts/regions/TOUCH01", @@ -50,67 +69,52 @@ describe("TouchRegion", () => { afterEach(() => { capturedSlideDone = null; + mockRegionSlides = {}; + mockCallbacks.current.onRegionReady.mockClear(); + mockCallbacks.current.onRegionRemoved.mockClear(); cleanup(); vi.restoreAllMocks(); }); - function renderAndDispatchSlides() { - const result = render(); - - act(() => { - const event = new CustomEvent("regionContent-TOUCH01", { - detail: { slides }, - }); - document.dispatchEvent(event); - }); - - return result; + function renderWithSlides() { + mockRegionSlides = { TOUCH01: slides }; + return render(); } - it("emits regionReady on mount", () => { - const handler = vi.fn(); - document.addEventListener("regionReady", handler); - + it("calls onRegionReady on mount", () => { render(); - expect(handler).toHaveBeenCalledTimes(1); - expect(handler.mock.calls[0][0].detail.id).toBe("TOUCH01"); - - document.removeEventListener("regionReady", handler); + expect(mockCallbacks.current.onRegionReady).toHaveBeenCalledWith("TOUCH01"); }); - it("emits regionRemoved on unmount", () => { - const handler = vi.fn(); - document.addEventListener("regionRemoved", handler); - + it("calls onRegionRemoved on unmount", () => { const { unmount } = render(); unmount(); - expect(handler).toHaveBeenCalledTimes(1); - document.removeEventListener("regionRemoved", handler); + expect(mockCallbacks.current.onRegionRemoved).toHaveBeenCalledWith("TOUCH01"); }); - it("renders buttons for each slide when regionContent arrives", () => { - const { container } = renderAndDispatchSlides(); + it("renders buttons for each slide when regionSlides has data", () => { + const { container } = renderWithSlides(); const buttons = within(container).getAllByRole("button"); expect(buttons.length).toBeGreaterThanOrEqual(2); }); it("uses slide title for button text", () => { - const { container } = renderAndDispatchSlides(); + const { container } = renderWithSlides(); expect(within(container).getByText("Slide 1")).toBeInTheDocument(); }); it("uses touchRegionButtonText when available", () => { - const { container } = renderAndDispatchSlides(); + const { container } = renderWithSlides(); expect(within(container).getByText("Press Me")).toBeInTheDocument(); }); it("opens slide when button is clicked", () => { - const { container } = renderAndDispatchSlides(); + const { container } = renderWithSlides(); act(() => { fireEvent.click(within(container).getByText("Slide 1")); @@ -120,7 +124,7 @@ describe("TouchRegion", () => { }); it("shows close button when slide is active", () => { - const { container } = renderAndDispatchSlides(); + const { container } = renderWithSlides(); act(() => { fireEvent.click(within(container).getByText("Slide 1")); @@ -130,7 +134,7 @@ describe("TouchRegion", () => { }); it("dismisses slide when close button is clicked", () => { - const { container } = renderAndDispatchSlides(); + const { container } = renderWithSlides(); act(() => { fireEvent.click(within(container).getByText("Slide 1")); @@ -145,23 +149,4 @@ describe("TouchRegion", () => { ).not.toBeInTheDocument(); }); - it("emits slideDone event when slide completes", () => { - const handler = vi.fn(); - document.addEventListener("slideDone", handler); - - const { container } = renderAndDispatchSlides(); - - act(() => { - fireEvent.click(within(container).getByText("Slide 1")); - }); - - act(() => { - capturedSlideDone(); - }); - - expect(handler).toHaveBeenCalledTimes(1); - expect(handler.mock.calls[0][0].detail.executionId).toBe("EXE-1"); - - document.removeEventListener("slideDone", handler); - }); }); From f3886e4bab153bf13b1f07e7858b627237141059 Mon Sep 17 00:00:00 2001 From: Troels Ugilt Jensen <6103205+tuj@users.noreply.github.com> Date: Mon, 20 Apr 2026 18:55:17 +0200 Subject: [PATCH 56/73] 7228: Fixed to use shared query --- assets/client/app.jsx | 12 +++ assets/client/components/region.jsx | 15 ++-- assets/client/components/touch-region.jsx | 13 +-- .../client/context/client-state-context.jsx | 7 +- assets/client/data-sync/pull-strategy.js | 86 ++----------------- assets/client/service/content-service.js | 81 +++++++++-------- assets/client/service/schedule-service.js | 13 ++- assets/client/util/api-query.js | 79 +++++++++++++++++ assets/client/util/schedule.js | 4 +- 9 files changed, 160 insertions(+), 150 deletions(-) create mode 100644 assets/client/util/api-query.js diff --git a/assets/client/app.jsx b/assets/client/app.jsx index 134cabe86..733e3ce56 100644 --- a/assets/client/app.jsx +++ b/assets/client/app.jsx @@ -32,6 +32,7 @@ function App({ preview, previewId }) { const { screen, isContentEmpty, callbacks } = useClientState(); const checkLoginTimeoutRef = useRef(null); + const loginTimeoutGenRef = useRef(0); const contentServiceRef = useRef(null); const runningRef = useRef(false); @@ -78,9 +79,14 @@ function App({ preview, previewId }) { const restartLoginTimeout = () => { if (checkLoginTimeoutRef.current !== null) { clearTimeout(checkLoginTimeoutRef.current); + checkLoginTimeoutRef.current = null; } + loginTimeoutGenRef.current += 1; + const gen = loginTimeoutGenRef.current; + ClientConfigLoader.loadConfig().then((config) => { + if (gen !== loginTimeoutGenRef.current) return; checkLoginTimeoutRef.current = setTimeout( checkLogin, config.loginCheckTimeout ?? defaults.loginCheckTimeoutDefault, @@ -207,6 +213,12 @@ function App({ preview, previewId }) { return function cleanup() { logger.info("Unmounting App."); + if (contentServiceRef.current !== null) { + contentServiceRef.current.stopSync(); + contentServiceRef.current.stop(); + contentServiceRef.current = null; + } + if (preview === null) { document.removeEventListener("keydown", handleKeyboard); reauthenticateRef.current = () => {}; diff --git a/assets/client/components/region.jsx b/assets/client/components/region.jsx index 1db82f8c5..ac755908b 100644 --- a/assets/client/components/region.jsx +++ b/assets/client/components/region.jsx @@ -36,6 +36,7 @@ function Region({ region }) { const { regionSlides, callbacks } = useClientState(); const rootStyle = {}; const regionId = idFromPath(region["@id"]); + const incomingSlides = regionSlides[regionId]; rootStyle.gridArea = createGridArea(region.gridArea); @@ -79,7 +80,7 @@ function Region({ region }) { const nextSlides = [...latestNewSlides]; setSlides(nextSlides); setNewSlides(null); - setCurrentSlide(nextSlides[0]); + setCurrentSlide(nextSlides.length > 0 ? nextSlides[0] : null); } else { setCurrentSlide(nextSlideAndIndex.nextSlide); } @@ -108,12 +109,11 @@ function Region({ region }) { // Receive region slides from context. useEffect(() => { - const incoming = regionSlides[regionId]; - if (!incoming) return; + if (!incomingSlides) return; - const receivedSlides = [...(incoming ?? [])]; + const receivedSlides = [...incomingSlides]; setNewSlides(receivedSlides.filter((slide) => !slide.invalid)); - }, [regionSlides[regionId]]); + }, [incomingSlides]); // Notify lifecycle on mount/unmount. useEffect(() => { @@ -126,11 +126,6 @@ function Region({ region }) { }; }, [regionId]); - // Notify that region is ready when region prop changes. - useEffect(() => { - callbacks.current.onRegionReady(regionId); - }, [region]); - // Start the progress if no slide is currently playing. useEffect(() => { if (newSlides !== null && !currentSlide) { diff --git a/assets/client/components/touch-region.jsx b/assets/client/components/touch-region.jsx index 68ad3a28d..67c4c4ec5 100644 --- a/assets/client/components/touch-region.jsx +++ b/assets/client/components/touch-region.jsx @@ -28,6 +28,7 @@ function TouchRegion({ region }) { const { regionSlides, callbacks } = useClientState(); const rootStyle = {}; const regionId = idFromPath(region["@id"]); + const incomingSlides = regionSlides[regionId]; rootStyle.gridArea = createGridArea(region.gridArea); @@ -61,11 +62,10 @@ function TouchRegion({ region }) { // Receive region slides from context. useEffect(() => { - const incoming = regionSlides[regionId]; - if (!incoming) return; + if (!incomingSlides) return; - setSlides([...(incoming ?? [])].filter((slide) => !slide.invalid)); - }, [regionSlides[regionId]]); + setSlides([...incomingSlides].filter((slide) => !slide.invalid)); + }, [incomingSlides]); // Notify lifecycle on mount/unmount. useEffect(() => { @@ -76,11 +76,6 @@ function TouchRegion({ region }) { }; }, [regionId]); - // Notify that region is ready when region prop changes. - useEffect(() => { - callbacks.current.onRegionReady(regionId); - }, [region]); - // Make sure current slide is set. useEffect(() => { if (!slides) return; diff --git a/assets/client/context/client-state-context.jsx b/assets/client/context/client-state-context.jsx index a40b4b555..b29e7e517 100644 --- a/assets/client/context/client-state-context.jsx +++ b/assets/client/context/client-state-context.jsx @@ -8,7 +8,7 @@ const ClientStateContext = createContext(); */ function ClientStateProvider({ children }) { const [screen, setScreen] = useState(null); - const [isContentEmpty, setIsContentEmpty] = useState(true); + const [isContentEmpty, setIsContentEmpty] = useState(false); const [regionSlides, setRegionSlides] = useState({}); const updateRegionSlides = useCallback((regionId, slides) => { @@ -26,11 +26,6 @@ function ClientStateProvider({ children }) { onReauthenticate: () => {}, }); - // Keep setters in sync (they are stable, but updateRegionSlides is too via useCallback). - callbacks.current.setScreen = setScreen; - callbacks.current.setIsContentEmpty = setIsContentEmpty; - callbacks.current.updateRegionSlides = updateRegionSlides; - const value = { screen, isContentEmpty, diff --git a/assets/client/data-sync/pull-strategy.js b/assets/client/data-sync/pull-strategy.js index ff8e03cc6..c38b1473a 100644 --- a/assets/client/data-sync/pull-strategy.js +++ b/assets/client/data-sync/pull-strategy.js @@ -3,8 +3,7 @@ import logger from "../logger/logger"; import idFromPath from "../util/id-from-path"; import { cloneDeep } from "lodash"; import ClientConfigLoader from "../util/client-config-loader.js"; -import { clientStore } from "../redux/store.js"; -import { clientApi } from "../redux/generated-api.ts"; +import { query, queryAllPages } from "../util/api-query.js"; import constants from "../util/constants.js"; import defaults from "../util/defaults.js"; @@ -26,82 +25,6 @@ function checksumChanged(enabled, oldChecksums, newChecksums, fields) { return fields.some((field) => oldChecksums[field] !== newChecksums[field]); } -/** - * Dispatch an RTK Query endpoint and return the unwrapped result. - * - * @param {string} endpoint The endpoint name. - * @param {object} args The endpoint args. - * @param {boolean} forceRefetch Whether to bypass RTK Query cache. - * @returns {Promise} The result data. - */ -function query(endpoint, args, forceRefetch = false) { - const request = clientStore.dispatch( - clientApi.endpoints[endpoint].initiate(args, { forceRefetch }), - ); - return request - .unwrap() - .catch((err) => { - const cached = clientApi.endpoints[endpoint].select(args)( - clientStore.getState(), - ); - if (cached?.data) { - logger.warn(`Using cached data for ${endpoint} after fetch failure.`); - return cached.data; - } - throw err; - }) - .finally(() => { - request.unsubscribe(); - }); -} - -/** - * Fetch all pages from a paginated endpoint. - * - * @param {string} endpoint The endpoint name. - * @param {object} args The endpoint args (page will be added). - * @param {boolean} forceRefetch Whether to bypass RTK Query cache. - * @returns {Promise} All hydra:member results concatenated. - */ -// Upper bound on pagination — intentionally capped. Content types served to -// screens should never exceed this number of pages. -const MAX_PAGES = 50; - -async function queryAllPages(endpoint, args, forceRefetch = false) { - let results = []; - let page = 1; - - do { - try { - const responseData = await query(endpoint, { ...args, page }, forceRefetch); - - if (responseData === null || responseData === undefined) { - logger.error(`Failed to fetch page ${page} for ${endpoint}`); - return results; - } - - results = results.concat(responseData["hydra:member"] ?? []); - - if (responseData["hydra:view"]?.["hydra:next"]) { - page += 1; - } else { - break; - } - } catch (err) { - logger.error( - `Failed to fetch all pages for ${endpoint}: ${err.message}`, - ); - return results; - } - } while (page <= MAX_PAGES); - - if (page > MAX_PAGES) { - logger.warn(`Reached max page limit (${MAX_PAGES}) for ${endpoint}`); - } - - return results; -} - /** * PullStrategy. * @@ -339,6 +262,8 @@ class PullStrategy { } const config = await ClientConfigLoader.loadConfig(); + if (this.stopped) return; + const relationChecksumEnabled = config.relationsChecksumEnabled; const newScreen = cloneDeep(screen); @@ -357,6 +282,7 @@ class PullStrategy { logger.info(`Fetching campaigns.`); } newScreen.campaignsData = await this.getCampaignsData(newScreen, campaignsChanged); + if (this.stopped) return; if (newScreen.campaignsData.length > 0) { newScreen.campaignsData.forEach(({ published }) => { @@ -376,17 +302,17 @@ class PullStrategy { ); if (!success) return; } + if (this.stopped) return; const nextSlideChecksums = await this.enrichSlides( newScreen.regionData, relationChecksumEnabled, ); + if (this.stopped) return; this.previousScreenChecksums = newScreen.relationsChecksum ?? {}; this.previousSlideChecksums = nextSlideChecksums; this.previousHadActiveCampaign = newScreen.hasActiveCampaign; - if (this.stopped) return; - // Deliver result to rendering. this.onContent(newScreen); } diff --git a/assets/client/service/content-service.js b/assets/client/service/content-service.js index 9cec51b16..5480b004b 100644 --- a/assets/client/service/content-service.js +++ b/assets/client/service/content-service.js @@ -9,8 +9,7 @@ import idFromPath from "../util/id-from-path"; import DataSync from "../data-sync/data-sync"; import ScheduleService from "./schedule-service"; import ClientConfigLoader from "../util/client-config-loader.js"; -import { clientStore } from "../redux/store.js"; -import { clientApi } from "../redux/generated-api.ts"; +import { query } from "../util/api-query.js"; /** * ContentService. @@ -110,12 +109,12 @@ class ContentService { this.callbacks.current.setScreen(screenData); } else { logger.info("Screen has not changed. Not updating screen."); + } - // eslint-disable-next-line guard-for-in,no-restricted-syntax - for (const regionKey in screen.regionData) { - const region = this.currentScreen.regionData[regionKey]; - this.scheduleService.updateRegion(regionKey, region); - } + // Always push region data so both new and existing regions get content. + // eslint-disable-next-line guard-for-in,no-restricted-syntax + for (const regionKey in screen.regionData) { + this.scheduleService.updateRegion(regionKey, screen.regionData[regionKey]); } } @@ -187,15 +186,14 @@ class ContentService { try { if (mode === "screen") { - this.startSyncing(`/v2/screen/${id}`); + this.startSyncing(`/v2/screens/${id}`); } else if (mode === "playlist") { - const playlist = await ContentService.query("getV2PlaylistsById", { - id, - }); + const playlist = await query("getV2PlaylistsById", { id }, true); - const playlistSlidesResponse = await ContentService.query( + const playlistSlidesResponse = await query( "getV2PlaylistsByIdSlides", { id: idFromPath(playlist.slides) }, + true, ); playlist.slidesData = playlistSlidesResponse["hydra:member"].map( @@ -211,7 +209,7 @@ class ContentService { const screen = screenForPlaylistPreview(playlist); this.contentHandler(screen); } else if (mode === "slide") { - const slide = await ContentService.query("getV2SlidesById", { id }); + const slide = await query("getV2SlidesById", { id }, true); // eslint-disable-next-line no-await-in-loop await ContentService.attachReferencesToSlide(slide); @@ -228,25 +226,28 @@ class ContentService { } } - static query(endpoint, args) { - const request = clientStore.dispatch( - clientApi.endpoints[endpoint].initiate(args), - ); - return request.unwrap().finally(() => { - request.unsubscribe(); - }); - } - static async attachReferencesToSlide(slide) { /* eslint-disable no-param-reassign */ - slide.templateData = await ContentService.query("getV2TemplatesById", { - id: idFromPath(slide.templateInfo["@id"]), - }); + try { + slide.templateData = await query("getV2TemplatesById", { + id: idFromPath(slide.templateInfo["@id"]), + }, true); + } catch (err) { + slide.templateData = null; + slide.invalid = true; + slide.mediaData = {}; + slide.feedData = null; + return; + } - if (slide?.feed?.feedUrl) { - slide.feedData = await ContentService.query("getV2FeedsByIdData", { - id: idFromPath(slide.feed.feedUrl), - }); + if (slide?.feed?.feedUrl !== undefined) { + try { + slide.feedData = await query("getV2FeedsByIdData", { + id: idFromPath(slide.feed.feedUrl), + }, true); + } catch (err) { + slide.feedData = null; + } } else { slide.feedData = []; } @@ -254,16 +255,24 @@ class ContentService { slide.mediaData = {}; // eslint-disable-next-line no-restricted-syntax for (const media of slide.media) { - // eslint-disable-next-line no-await-in-loop - slide.mediaData[media] = await ContentService.query("getV2MediaById", { - id: idFromPath(media), - }); + try { + // eslint-disable-next-line no-await-in-loop + slide.mediaData[media] = await query("getV2MediaById", { + id: idFromPath(media), + }, true); + } catch (err) { + slide.mediaData[media] = null; + } } if (typeof slide.theme === "string" || slide.theme instanceof String) { - slide.theme = await ContentService.query("getV2ThemesById", { - id: idFromPath(slide.theme), - }); + try { + slide.theme = await query("getV2ThemesById", { + id: idFromPath(slide.theme), + }, true); + } catch (err) { + // Keep theme as the original string path. + } } /* eslint-enable no-param-reassign */ } diff --git a/assets/client/service/schedule-service.js b/assets/client/service/schedule-service.js index ec38e80f3..17972d042 100644 --- a/assets/client/service/schedule-service.js +++ b/assets/client/service/schedule-service.js @@ -5,7 +5,6 @@ import isPublished from "../util/is-published"; import logger from "../logger/logger"; import ClientConfigLoader from "../util/client-config-loader.js"; import ScheduleUtils from "../util/schedule"; -import { cloneDeep } from "lodash"; import defaults from "../util/defaults"; /** @@ -97,8 +96,11 @@ class ScheduleService { ClientConfigLoader.loadConfig().then((config) => { const schedulingInterval = config?.schedulingInterval ?? defaults.schedulingIntervalDefault; - // Extra check because of async. - if (!Object.prototype.hasOwnProperty.call(intervals, regionId)) { + // Extra check because of async — region may have been removed. + if ( + !Object.prototype.hasOwnProperty.call(intervals, regionId) && + Object.prototype.hasOwnProperty.call(this.regions, regionId) + ) { logger.info( `registering scheduling interval for region: ${regionId}, with an update rate of ${schedulingInterval}`, ); @@ -208,12 +210,9 @@ class ScheduleService { return; } - const newSlide = cloneDeep(slide); - // Execution id is the product of region, playlist and slide id, to ensure uniqueness in the client. const executionId = Md5(regionId + playlist["@id"] + slide["@id"]); - newSlide.executionId = `EXE-ID-${executionId}`; - slides.push(newSlide); + slides.push({ ...slide, executionId: `EXE-ID-${executionId}` }); }); } else { logger.info(`Playlist ${playlist["@id"]} not scheduled for now`); diff --git a/assets/client/util/api-query.js b/assets/client/util/api-query.js new file mode 100644 index 000000000..648cddc97 --- /dev/null +++ b/assets/client/util/api-query.js @@ -0,0 +1,79 @@ +import logger from "../logger/logger"; +import { clientStore } from "../redux/store.js"; +import { clientApi } from "../redux/generated-api.ts"; + +/** + * Dispatch an RTK Query endpoint and return the unwrapped result. + * + * @param {string} endpoint The endpoint name. + * @param {object} args The endpoint args. + * @param {boolean} forceRefetch Whether to bypass RTK Query cache. + * @returns {Promise} The result data. + */ +export function query(endpoint, args, forceRefetch = false) { + const request = clientStore.dispatch( + clientApi.endpoints[endpoint].initiate(args, { forceRefetch }), + ); + return request + .unwrap() + .catch((err) => { + const cached = clientApi.endpoints[endpoint].select(args)( + clientStore.getState(), + ); + if (cached?.data) { + logger.warn(`Using cached data for ${endpoint} after fetch failure.`); + return cached.data; + } + throw err; + }) + .finally(() => { + request.unsubscribe(); + }); +} + +/** + * Fetch all pages from a paginated endpoint. + * + * @param {string} endpoint The endpoint name. + * @param {object} args The endpoint args (page will be added). + * @param {boolean} forceRefetch Whether to bypass RTK Query cache. + * @returns {Promise} All hydra:member results concatenated. + */ +// Upper bound on pagination — intentionally capped. Content types served to +// screens should never exceed this number of pages. +const MAX_PAGES = 50; + +export async function queryAllPages(endpoint, args, forceRefetch = false) { + let results = []; + let page = 1; + + do { + try { + const responseData = await query(endpoint, { ...args, page }, forceRefetch); + + if (responseData === null || responseData === undefined) { + logger.error(`Failed to fetch page ${page} for ${endpoint}`); + return results; + } + + results = results.concat(responseData["hydra:member"] ?? []); + + if (responseData["hydra:view"]?.["hydra:next"]) { + page += 1; + } else { + break; + } + } catch (err) { + logger.error( + `Failed to fetch all pages for ${endpoint}: ${err.message}`, + ); + return results; + } + } while (page <= MAX_PAGES); + + if (page > MAX_PAGES) { + logger.warn(`Reached max page limit (${MAX_PAGES}) for ${endpoint}`); + } + + return results; +} diff --git a/assets/client/util/schedule.js b/assets/client/util/schedule.js index 23f8b8661..745eca744 100644 --- a/assets/client/util/schedule.js +++ b/assets/client/util/schedule.js @@ -2,7 +2,7 @@ import { RRule } from "rrule"; class ScheduleUtils { static occursNow(rruleString, durationSeconds) { - const rrule = RRule.fromString(rruleString.replace("\\n", "\n")); + const rrule = RRule.fromString(rruleString.replaceAll("\\n", "\n")); const duration = durationSeconds * 1000; const now = new Date(); @@ -40,7 +40,7 @@ class ScheduleUtils { nowWithoutTimezone, true, function iterator(occurrenceDate) { - // The "ccurrenceDate" we are iterating over contains a "pretend UTC" datetime + // The "occurrenceDate" we are iterating over contains a "pretend UTC" datetime // object. As above, if the time for "occurrenceDate" is 09:00 UTC it should be // treated as 09:00 local time regardsless of the actual local timezone const end = new Date(occurrenceDate.getTime() + duration); From 45dcd96babdac2eb15dc0d93c70177276f1fedbb Mon Sep 17 00:00:00 2001 From: Troels Ugilt Jensen <6103205+tuj@users.noreply.github.com> Date: Mon, 20 Apr 2026 19:13:31 +0200 Subject: [PATCH 57/73] 7228: Fixed possible bug that could lead to broken slide iteration --- assets/client/components/region.jsx | 2 +- assets/tests/client/region.test.jsx | 34 +++++++++++++++++++++++++++++ broken1.md | 17 +++++++++++++++ 3 files changed, 52 insertions(+), 1 deletion(-) create mode 100644 broken1.md diff --git a/assets/client/components/region.jsx b/assets/client/components/region.jsx index ac755908b..216a980f2 100644 --- a/assets/client/components/region.jsx +++ b/assets/client/components/region.jsx @@ -152,7 +152,7 @@ function Region({ region }) { return res; }, {}), ); - }, [slides]); + }, [slides, currentSlide]); return (
diff --git a/assets/tests/client/region.test.jsx b/assets/tests/client/region.test.jsx index d4465c903..638e1bc09 100644 --- a/assets/tests/client/region.test.jsx +++ b/assets/tests/client/region.test.jsx @@ -157,6 +157,40 @@ describe("Region", () => { ).toBeInTheDocument(); }); + it("recovers playback when new slides arrive after content was cleared mid-cycle", () => { + mockRegionSlides = { + REGION01: [ + { executionId: "EXE-1", title: "Slide 1" }, + { executionId: "EXE-2", title: "Slide 2" }, + ], + }; + + const { container, rerender } = render(); + expect(within(container).getByTestId("slide-EXE-1")).toBeInTheDocument(); + + // Advance to slide 2. + act(() => { capturedSlideDone(); }); + expect(within(container).getByTestId("slide-EXE-2")).toBeInTheDocument(); + + // Content is removed while slide 2 is still playing. + mockRegionSlides = { REGION01: [] }; + rerender(); + + // Slide 2 finishes — cycle wraps (nextIndex 0) and applies the empty + // newSlides, setting currentSlide to null and slides to []. + act(() => { capturedSlideDone(); }); + expect(container.querySelector("[data-testid^='slide-']")).toBeNull(); + + // New content arrives while currentSlide is null. + mockRegionSlides = { + REGION01: [{ executionId: "EXE-3", title: "Slide 3" }], + }; + rerender(); + + // The region should recover and display the new slide. + expect(within(container).getByTestId("slide-EXE-3")).toBeInTheDocument(); + }); + it("renders with correct grid area style", () => { const { container } = render(); const el = container.querySelector(".region"); diff --git a/broken1.md b/broken1.md new file mode 100644 index 000000000..174c732d0 --- /dev/null +++ b/broken1.md @@ -0,0 +1,17 @@ +# 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 From 222b2955efcb66ad09b41f138b93ec791674ed97 Mon Sep 17 00:00:00 2001 From: Troels Ugilt Jensen <6103205+tuj@users.noreply.github.com> Date: Mon, 20 Apr 2026 19:24:43 +0200 Subject: [PATCH 58/73] 7228: Fixed bugs --- assets/client/components/region.jsx | 1 + assets/client/service/schedule-service.js | 2 +- broken2.md | 24 +++++++++++++++++++++++ 3 files changed, 26 insertions(+), 1 deletion(-) create mode 100644 broken2.md diff --git a/assets/client/components/region.jsx b/assets/client/components/region.jsx index 216a980f2..aef84d154 100644 --- a/assets/client/components/region.jsx +++ b/assets/client/components/region.jsx @@ -130,6 +130,7 @@ function Region({ region }) { useEffect(() => { if (newSlides !== null && !currentSlide) { setSlides(newSlides); + setNewSlides(null); } }, [newSlides, currentSlide]); diff --git a/assets/client/service/schedule-service.js b/assets/client/service/schedule-service.js index 17972d042..f0004d8e4 100644 --- a/assets/client/service/schedule-service.js +++ b/assets/client/service/schedule-service.js @@ -211,7 +211,7 @@ class ScheduleService { } // Execution id is the product of region, playlist and slide id, to ensure uniqueness in the client. - const executionId = Md5(regionId + playlist["@id"] + slide["@id"]); + const executionId = Md5(regionId + playlist["@id"] + slide["@id"]).toString(); slides.push({ ...slide, executionId: `EXE-ID-${executionId}` }); }); } else { diff --git a/broken2.md b/broken2.md new file mode 100644 index 000000000..c69b2e07d --- /dev/null +++ b/broken2.md @@ -0,0 +1,24 @@ +# 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 From 0d79d37c0b66091b24b5cdad7d41e90970baac22 Mon Sep 17 00:00:00 2001 From: Troels Ugilt Jensen <6103205+tuj@users.noreply.github.com> Date: Mon, 20 Apr 2026 19:32:03 +0200 Subject: [PATCH 59/73] 7228: Fixed bugs --- assets/client/app.jsx | 14 +++++++++++--- assets/client/components/error-boundary.jsx | 6 ++++++ assets/client/components/region.jsx | 2 +- assets/client/components/touch-region.jsx | 2 +- 4 files changed, 19 insertions(+), 5 deletions(-) diff --git a/assets/client/app.jsx b/assets/client/app.jsx index 733e3ce56..ac72de203 100644 --- a/assets/client/app.jsx +++ b/assets/client/app.jsx @@ -35,12 +35,14 @@ function App({ preview, previewId }) { const loginTimeoutGenRef = useRef(0); const contentServiceRef = useRef(null); const runningRef = useRef(false); + const reauthenticatingRef = useRef(false); const fallbackImageUrl = appStorage.getFallbackImageUrl(); + const safeFallbackUrl = ( + fallbackImageUrl !== null ? fallbackImageUrl : fallback + ).replace(/"/g, ""); const fallbackStyle = { - backgroundImage: `url("${ - fallbackImageUrl !== null ? fallbackImageUrl : fallback - }")`, + backgroundImage: `url("${safeFallbackUrl}")`, }; const appStyle = {}; @@ -126,6 +128,9 @@ function App({ preview, previewId }) { }; const reauthenticateHandler = () => { + if (reauthenticatingRef.current) return; + reauthenticatingRef.current = true; + logger.info("Reauthenticate handler invoked. Trying to use refresh token."); tokenService @@ -157,6 +162,9 @@ function App({ preview, previewId }) { tokenService.stopRefreshing(); checkLogin(); + }) + .finally(() => { + reauthenticatingRef.current = false; }); }; diff --git a/assets/client/components/error-boundary.jsx b/assets/client/components/error-boundary.jsx index 98aeb0ac0..68a3e5b54 100644 --- a/assets/client/components/error-boundary.jsx +++ b/assets/client/components/error-boundary.jsx @@ -14,6 +14,12 @@ class ErrorBoundary extends Component { return { hasError: true }; } + componentDidUpdate(prevProps) { + if (this.props.resetKey !== prevProps.resetKey && this.state.hasError) { + this.setState({ hasError: false, errorMessage: null, errorStackTrace: null }); + } + } + componentDidCatch(error, errorInfo) { logger.error(`ErrorBoundary caught error: ${error}`, errorInfo); diff --git a/assets/client/components/region.jsx b/assets/client/components/region.jsx index aef84d154..44fc284b4 100644 --- a/assets/client/components/region.jsx +++ b/assets/client/components/region.jsx @@ -157,7 +157,7 @@ function Region({ region }) { return (
- + <> {currentSlide && ( diff --git a/assets/client/components/touch-region.jsx b/assets/client/components/touch-region.jsx index 67c4c4ec5..9b72e760b 100644 --- a/assets/client/components/touch-region.jsx +++ b/assets/client/components/touch-region.jsx @@ -98,7 +98,7 @@ function TouchRegion({ region }) { return (
- + <> {currentSlide !== null && (
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