mirror of
https://github.com/jokob-sk/NetAlertX.git
synced 2026-07-25 15:08:10 -04:00
Enhance plugin string handling and initialization; add UI tests for translation loading
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 ----
|
||||
|
||||
@@ -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() {
|
||||
|
||||
176
test/ui/test_ui_translations.py
Normal file
176
test/ui/test_ui_translations.py
Normal 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."
|
||||
)
|
||||
Reference in New Issue
Block a user