diff --git a/front/js/app-init.js b/front/js/app-init.js index fe294043..7ec62d7c 100644 --- a/front/js/app-init.js +++ b/front/js/app-init.js @@ -16,7 +16,7 @@ // ----------------------------------------------------------------------------- var completedCalls = [] -var completedCalls_final = ['cacheApiConfig', 'cacheSettings', 'cacheStrings_v2', 'cacheDevices']; +var completedCalls_final = ['cacheApiConfig', 'cacheSettings', 'cacheStrings_v2', 'cachePluginStrings_v1', 'cacheDevices']; var lang_completedCalls = 0; @@ -185,10 +185,11 @@ async function executeOnce() { try { await waitForGraphQLServer(); // Wait for the server to start - await retryStep('cacheApiConfig', cacheApiConfig); // Bootstrap: API_TOKEN + GRAPHQL_PORT from app.conf - await retryStep('cacheDevices', cacheDevices); - await retryStep('cacheSettings', cacheSettings); - await retryStep('cacheStrings', cacheStrings); + await retryStep('cacheApiConfig', cacheApiConfig); // Bootstrap: API_TOKEN + GRAPHQL_PORT from app.conf + await retryStep('cacheDevices', cacheDevices); + await retryStep('cacheSettings', cacheSettings); + await retryStep('cacheStrings', cacheStrings); + await retryStep('cachePluginStrings', cachePluginStrings); console.log("All AJAX callbacks have completed"); onAllCallsComplete(); @@ -254,9 +255,13 @@ const onAllCallsComplete = () => { // Function to check if all necessary strings are initialized const areAllStringsInitialized = () => { - // Implement logic to check if all necessary strings are initialized - // Return true if all strings are initialized, false otherwise - return getString('UI_LANG_name') != "" + // Core string check — comes from en_us.json + if (getString('UI_LANG_name') === '') return false; + // Plugin string check — NEWDEV is a necessary plugin (always loaded) and always + // generates the 'NEWDEV_display_name' key. If plugins are loaded but this key + // is missing, plugin strings have not been registered yet. + if (Array.isArray(pluginsData) && pluginsData.length > 0 && getString('NEWDEV_display_name') === '') return false; + return true; }; // Call the function to execute the code diff --git a/front/js/cache.js b/front/js/cache.js index 575a40d4..4412a622 100644 --- a/front/js/cache.js +++ b/front/js/cache.js @@ -176,11 +176,31 @@ function cacheApiConfig() { }); } +// Module-level declaration so cachePluginStrings() and other callers can +// safely reference pluginsData even before cacheSettings() has fetched it. +// Populated (or repopulated) by cacheSettings() on every full fetch. +var pluginsData = []; + function cacheSettings() { return new Promise((resolve, reject) => { if(getCache(CACHE_KEYS.initFlag('cacheSettings')) === "true") { + // Re-populate pluginsData only when cachePluginStrings hasn't completed + // yet (first load after a deploy / migration). Once cachePluginStrings_v1 + // is set it early-returns immediately and never reads pluginsData again, + // so the extra fetch would be pointless on every subsequent warm load. + if (getCache(CACHE_KEYS.initFlag('cachePluginStrings_v1')) !== 'true') { + fetchJson('plugins.json') + .catch(() => []) + .then((pluginsArr) => { + if (Array.isArray(pluginsArr) && pluginsArr.length > 0) { + pluginsData = pluginsArr; + } + resolve(); + }); + return; + } resolve(); return; } @@ -292,22 +312,9 @@ function cacheStrings() { return new Promise((resolve, reject) => { if(getCache(CACHE_KEYS.initFlag('cacheStrings_v2')) === "true") { - // Core strings are cached, but plugin strings may have failed silently on - // the first load (non-fatal fetch). Always re-fetch them so that plugin - // keys like "CSVBCKP_overwrite_description" are available without needing - // a full clearCache(). - fetchJson('table_plugins_language_strings.json') - .catch((pluginError) => { - console.warn('[cacheStrings early-return] Plugin language strings unavailable (non-fatal):', pluginError); - return []; - }) - .then((data) => { - if (!Array.isArray(data)) { data = []; } - data.forEach((langString) => { - setCache(CACHE_KEYS.langString(langString.stringKey, langString.languageCode), langString.stringValue); - }); - resolve(); - }); + // Core strings are already cached. Plugin strings are now handled by + // the dedicated cachePluginStrings() step — nothing to do here. + resolve(); return; } @@ -321,42 +328,21 @@ function cacheStrings() { languagesToLoad.push(additionalLanguage) } - console.log(languagesToLoad); + console.log('[cacheStrings] Languages to load:', languagesToLoad); const languagePromises = languagesToLoad.map((language_code) => { return new Promise((resolveLang, rejectLang) => { - // Fetch core strings and translations - $.get(`php/templates/language/${language_code}.json?nocache=${Date.now()}`) .done((res) => { - // Iterate over each key-value pair and store the translations - Object.entries(res).forEach(([key, value]) => { + const entries = Object.entries(res); + entries.forEach(([key, value]) => { setCache(CACHE_KEYS.langString(key, language_code), value); }); - - // Fetch strings and translations from plugins (non-fatal — file may - // not exist on first boot or immediately after a cache clear) - fetchJson('table_plugins_language_strings.json') - .catch((pluginError) => { - console.warn('[cacheStrings] Plugin language strings unavailable (non-fatal):', pluginError); - return []; // treat as empty list - }) - .then((data) => { - // Defensive: ensure data is an array (fetchJson may return - // an object, undefined, or empty string on edge cases) - if (!Array.isArray(data)) { data = []; } - // Store plugin translations - data.forEach((langString) => { - setCache(CACHE_KEYS.langString(langString.stringKey, langString.languageCode), langString.stringValue); - }); - - // Handle successful completion of language processing - handleSuccess('cacheStrings_v2'); - resolveLang(); - }); + console.log(`[cacheStrings] Loaded ${entries.length} core strings for language '${language_code}'.`); + handleSuccess('cacheStrings_v2'); + resolveLang(); }) .fail((error) => { - // Handle failure in core strings fetching rejectLang(error); }); }); @@ -365,11 +351,9 @@ function cacheStrings() { // Wait for all language promises to complete Promise.all(languagePromises) .then(() => { - // All languages processed successfully resolve(); }) .catch((error) => { - // Handle failure in any of the language processing handleFailure('cacheStrings_v2'); reject(error); }); @@ -377,6 +361,53 @@ function cacheStrings() { }); } +// ----------------------------------------------------------------------------- +// Fetch and cache plugin-specific language strings from table_plugins_language_strings.json. +// Runs as a separate init step so that a missing or empty file triggers a retry +// via retryStep(), instead of silently marking initialization complete. +// Resolves immediately if no plugins are loaded (pluginsData is empty). +// ----------------------------------------------------------------------------- +function cachePluginStrings() { + return new Promise((resolve, reject) => { + if (getCache(CACHE_KEYS.initFlag('cachePluginStrings_v1')) === 'true') { + resolve(); + return; + } + + fetchJson('table_plugins_language_strings.json') + .catch((err) => { + console.warn('[cachePluginStrings] Plugin language strings fetch failed:', err); + return []; + }) + .then((data) => { + if (!Array.isArray(data)) { data = []; } + + if (data.length === 0) { + // No plugin strings returned. If plugins are loaded the file may not + // be ready yet — reject so retryStep() retries. If no plugins are + // loaded at all, empty strings are expected: succeed. + const hasPlugins = Array.isArray(pluginsData) && pluginsData.length > 0; + if (hasPlugins) { + console.warn('[cachePluginStrings] 0 entries returned but plugins are loaded — file may not be ready yet. Will retry.'); + reject(new Error('Plugin language strings empty')); + } else { + console.log('[cachePluginStrings] No plugins loaded — skipping plugin string cache.'); + handleSuccess('cachePluginStrings_v1'); + resolve(); + } + return; + } + + data.forEach((langString) => { + setCache(CACHE_KEYS.langString(langString.stringKey, langString.languageCode), langString.stringValue); + }); + console.log(`[cachePluginStrings] Loaded ${data.length} plugin language string entries.`); + handleSuccess('cachePluginStrings_v1'); + resolve(); + }); + }); +} + // ----------------------------------------------------------------------------- // Get translated language string function getString(key) { diff --git a/front/multiEditCore.php b/front/multiEditCore.php index 2e447431..b7933eb7 100755 --- a/front/multiEditCore.php +++ b/front/multiEditCore.php @@ -537,7 +537,9 @@ function deleteSelectedDevices() } -getData(); +// Wait for full app init (strings + plugin strings) before rendering. +// Without this guard getString() returns undefined during cold init. +callAfterAppInitialized(getData); diff --git a/front/pluginsCore.php b/front/pluginsCore.php index 43fe13f0..70ca8322 100755 --- a/front/pluginsCore.php +++ b/front/pluginsCore.php @@ -80,13 +80,16 @@ function initFields() { // Update the lastMac so we don't reload unnecessarily lastMac = currentVal; - // Trigger data loading based on new MAC - getData(); + // Wait for full app init (strings + plugin strings) before rendering tabs. + // Without this guard getString() returns undefined during cold init. + callAfterAppInitialized(getData); } else if((currentVal === "--" || currentVal == null ) && keepUpdating) { $("#txtMacFilter").val("--"); // need to set this as filters are using this later on keepUpdating = false; // stop updates - getData(); + // Wait for full app init (strings + plugin strings) before rendering tabs. + // Without this guard getString() returns undefined during cold init. + callAfterAppInitialized(getData); } } @@ -289,7 +292,7 @@ async function getData() { showSpinner(); console.log("Plugins getData called"); - const plugins = await fetchJson('plugins.json'); + const plugins = await fetchPluginJson('plugins.json'); pluginDefinitions = plugins.data; // Fetch counts BEFORE rendering tabs so we can skip empty plugins (no flicker). @@ -305,7 +308,10 @@ async function getData() { } } -async function fetchJson(filename) { +// NOTE: Named fetchPluginJson (not fetchJson) to avoid colliding with the +// module-level fetchJson defined in cache.js, which returns res['data'] and +// is used by cachePluginStrings() during app initialization. +async function fetchPluginJson(filename) { const response = await fetch(`php/server/query_json.php?file=${filename}`); if (!response.ok) throw new Error(`Failed to load ${filename}`); return await response.json(); @@ -381,7 +387,7 @@ async function fetchPluginCounts(prefixes) { if (!foreignKey) { // ---- FAST PATH: pre-computed static JSON ---- - const stats = await fetchJson('table_plugins_stats.json'); + const stats = await fetchPluginJson('table_plugins_stats.json'); rows = stats.data; } else { // ---- MAC-FILTERED PATH: single SQL via REST endpoint ---- diff --git a/front/workflowsCore.php b/front/workflowsCore.php index 12b9c0ec..b5a4516b 100755 --- a/front/workflowsCore.php +++ b/front/workflowsCore.php @@ -1374,7 +1374,9 @@ $(document).ready(function() { // -------------------------------------- // Initialize $(document).ready(function () { - getData(); + // Wait for full app init (strings + plugin strings) before rendering. + // Without this guard getString() returns undefined during cold init. + callAfterAppInitialized(getData); }); function hideWorkflowsSkeleton() { diff --git a/test/ui/test_ui_translations.py b/test/ui/test_ui_translations.py new file mode 100644 index 00000000..7bbca4cf --- /dev/null +++ b/test/ui/test_ui_translations.py @@ -0,0 +1,176 @@ +#!/usr/bin/env python3 +""" +Translation Loading UI Tests + +Validates that both core and plugin translation strings are available after +page load and after a browser cache clear, as described in: +PRD: Reliable Loading of Plugin Translation Strings +""" + +import sys +import os +import time + +from selenium.webdriver.common.by import By +from selenium.webdriver.support.ui import WebDriverWait + +sys.path.insert(0, os.path.dirname(__file__)) + +from .test_helpers import BASE_URL, wait_for_page_load # noqa: E402 + +# JS helper: poll isAppInitialized() until true (or timeout) +_WAIT_FOR_INIT_JS = "return typeof isAppInitialized === 'function' && isAppInitialized();" + +# Timeout (seconds) for app init — plugins + core strings must all be loaded +INIT_TIMEOUT = 30 + + +def _wait_for_app_init(driver, timeout=INIT_TIMEOUT): + """Block until isAppInitialized() returns true in the browser.""" + WebDriverWait(driver, timeout).until( + lambda d: d.execute_script(_WAIT_FOR_INIT_JS) is True + ) + + +def _get_string(driver, key): + """Call getString(key) in the browser and return the result.""" + return driver.execute_script( + "return (typeof getString === 'function') ? getString(arguments[0]) : null;", + key + ) + + +def _get_local_storage(driver, key): + """Return the raw localStorage value for a given key.""" + return driver.execute_script("return localStorage.getItem(arguments[0]);", key) + + +def _clear_local_storage(driver): + """Clear the browser's localStorage (simulates a cache clear).""" + driver.execute_script("localStorage.clear(); sessionStorage.clear();") + + +# --------------------------------------------------------------------------- +# Tests +# --------------------------------------------------------------------------- + +def test_plugin_strings_loaded_on_cold_init(driver): + """After a localStorage clear + reload, plugin strings must be available. + + Covers PRD Scenario 2 (Cache Clear) and the core bug: + cachePluginStrings() must not silently succeed with empty data. + """ + driver.get(f"{BASE_URL}/index.php") + wait_for_page_load(driver, timeout=10) + + # Simulate a cache clear + _clear_local_storage(driver) + + # Reload so executeOnce() runs fresh with empty localStorage + driver.refresh() + wait_for_page_load(driver, timeout=10) + + # Wait for full app initialization including cachePluginStrings_v1 + _wait_for_app_init(driver) + + # NEWDEV is a necessary plugin — its display_name key is always generated + plugin_string = _get_string(driver, "NEWDEV_display_name") + assert plugin_string, ( + "Plugin string 'NEWDEV_display_name' should be non-empty after cold init. " + "cachePluginStrings() may have resolved with empty data." + ) + + +def test_core_strings_loaded_on_cold_init(driver): + """After a localStorage clear + reload, core strings must be available. + + Ensures cacheStrings() still works correctly after its simplification. + """ + driver.get(f"{BASE_URL}/index.php") + wait_for_page_load(driver, timeout=10) + + _clear_local_storage(driver) + driver.refresh() + wait_for_page_load(driver, timeout=10) + + _wait_for_app_init(driver) + + core_string = _get_string(driver, "UI_LANG_name") + assert core_string, ( + "Core string 'UI_LANG_name' should be non-empty after cold init. " + "cacheStrings() may have regressed." + ) + + +def test_plugin_strings_init_flag_set(driver): + """The cachePluginStrings_v1_completed flag must be set in localStorage after init. + + Ensures handleSuccess('cachePluginStrings_v1') was reached, confirming + plugin strings actually loaded rather than failing silently. + """ + driver.get(f"{BASE_URL}/index.php") + wait_for_page_load(driver, timeout=10) + + _clear_local_storage(driver) + driver.refresh() + wait_for_page_load(driver, timeout=10) + + _wait_for_app_init(driver) + + flag = _get_local_storage(driver, "cachePluginStrings_v1_completed") + assert flag == "true", ( + "localStorage key 'cachePluginStrings_v1_completed' should be 'true' after init. " + f"Got: {flag!r}" + ) + + +def test_plugin_strings_available_on_warm_reload(driver): + """On a normal page refresh (warm cache), plugin strings remain available. + + Covers PRD Scenario 1 (Normal Refresh) and Scenario 3 (Repeated Refreshes). + """ + driver.get(f"{BASE_URL}/index.php") + wait_for_page_load(driver, timeout=10) + _wait_for_app_init(driver) + + # Warm reload — localStorage is intact + driver.refresh() + wait_for_page_load(driver, timeout=10) + _wait_for_app_init(driver) + + plugin_string = _get_string(driver, "NEWDEV_display_name") + assert plugin_string, ( + "Plugin string 'NEWDEV_display_name' should be non-empty after a warm reload." + ) + + +def test_plugins_page_no_undefined_strings_after_cache_clear(driver): + """pluginsCore.php must not render 'undefined' tab labels after a cache clear. + + Previously initFields() called getData() before isAppInitialized() was + true, so getString() returned undefined and tab headers showed 'undefined'. + This regression test loads plugins.php after a cold init and asserts that + no visible text on the page is the literal string 'undefined'. + """ + driver.get(f"{BASE_URL}/index.php") + wait_for_page_load(driver, timeout=10) + + _clear_local_storage(driver) + driver.get(f"{BASE_URL}/plugins.php") + wait_for_page_load(driver, timeout=15) + + _wait_for_app_init(driver) + + # Give the tabs a moment to render after init completes + time.sleep(2) + + # Allow the word 'undefined' inside code/technical blocks but not as a + # standalone visible label replacing a translation string. + # We check that it does not appear as a trimmed standalone word in headings/tabs. + tab_labels = driver.find_elements(By.CSS_SELECTOR, "#tabs-location a, .tab-label, h5") + for el in tab_labels: + label = el.text.strip() + assert label != "undefined", ( + f"Tab/heading label is literally 'undefined' — plugin strings were not " + f"loaded before getData() rendered the tabs on plugins.php." + )