Enhance plugin string handling and initialization; add UI tests for translation loading

This commit is contained in:
Jokob @NetAlertX
2026-07-02 04:15:00 +00:00
parent b29d279de8
commit 6c593d8879
6 changed files with 283 additions and 61 deletions

View File

@@ -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

View File

@@ -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) {

View File

@@ -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);
</script>

View File

@@ -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 ----

View File

@@ -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() {

View File

@@ -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."
)