mirror of
https://github.com/jokob-sk/NetAlertX.git
synced 2026-07-25 15:08:10 -04:00
10
README.md
10
README.md
@@ -1,6 +1,6 @@
|
||||
[](https://hub.docker.com/r/jokobsk/netalertx)
|
||||
[](https://hub.docker.com/r/jokobsk/netalertx)
|
||||
[](https://github.com/netalertx/NetAlertX/releases)
|
||||
[](https://github.com/netalertx/NetAlertX/releases)
|
||||
[](https://discord.gg/NczTUTWyRr)
|
||||
[](https://my.home-assistant.io/redirect/supervisor_add_addon_repository/?repository_url=https%3A%2F%2Fgithub.com%2Falexbelgium%2Fhassio-addons)
|
||||
|
||||
@@ -21,13 +21,11 @@
|
||||
</details>
|
||||
|
||||
|
||||
Centralized network visibility and continuous asset discovery for homelabs, IT teams, MSPs, and distributed environments.
|
||||
NetAlertX is a network visibility and asset intelligence platform for homelabs, IT teams, MSPs, and distributed environments. It provides centralized network visibility and continuous asset discovery across remote sites, VLANs, branch offices, and segmented networks from a single interface.
|
||||
|
||||
Monitor devices, detect change, and maintain visibility across remote sites, VLANs, branch offices, and segmented networks from a single interface.
|
||||
NetAlertX gives you a real-time source of truth for connected devices, helps identify shadow IT and unauthorized hardware, supports compliance initiatives, and automates operational workflows across distributed customer environments.
|
||||
|
||||
NetAlertX provides a centralized "Source of Truth" (NSoT) for network infrastructure. Maintain a real-time inventory of connected devices, identify Shadow IT and unauthorized hardware, support compliance initiatives, and automate operational workflows across distributed customer environments.
|
||||
|
||||
Designed to bridge the gap between simple network scanners and complex SIEM platforms, NetAlertX delivers actionable network intelligence and centralized monitoring without the operational overhead.
|
||||
Use NetAlertX to spot shadow IT, unauthorized hardware, IPAM drift, and other changes that matter to service teams. With multi-site sync, reporting, workflows, and webhooks, it helps MSPs stay ahead of problems without the overhead of a full NMS or SIEM.
|
||||
|
||||
|
||||
## Table of Contents
|
||||
|
||||
@@ -247,7 +247,8 @@ a[target="_blank"] {
|
||||
|
||||
.main-footer {
|
||||
padding: 5px;
|
||||
color: gray;
|
||||
z-index: 10000;
|
||||
position: inherit;
|
||||
}
|
||||
|
||||
.header-server-time
|
||||
@@ -317,6 +318,10 @@ body
|
||||
width: 150px;
|
||||
}
|
||||
|
||||
.fixed .main-header
|
||||
{
|
||||
z-index: 10001;
|
||||
}
|
||||
|
||||
.navbar-nav > .user-menu .user-image
|
||||
{
|
||||
@@ -330,6 +335,7 @@ body
|
||||
.main-sidebar,
|
||||
.left-side {
|
||||
width: 150px;
|
||||
z-index: 10000;
|
||||
}
|
||||
|
||||
.content-wrapper,
|
||||
@@ -338,7 +344,6 @@ body
|
||||
margin-left: 150px;
|
||||
}
|
||||
|
||||
|
||||
@media (max-width: 767px) {
|
||||
.main-header .logo {
|
||||
width: 100%;
|
||||
@@ -727,6 +732,11 @@ hr
|
||||
margin-right: auto;
|
||||
}
|
||||
|
||||
.modal
|
||||
{
|
||||
z-index: 12000;
|
||||
}
|
||||
|
||||
.modal_green
|
||||
{
|
||||
color: #258744;
|
||||
@@ -1846,7 +1856,7 @@ textarea[readonly],
|
||||
|
||||
#modal-ok
|
||||
{
|
||||
z-index: 1051; /*highest priority*/
|
||||
z-index: 11051; /*highest priority*/
|
||||
}
|
||||
|
||||
#modal-form-plc
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -50,6 +50,7 @@ const CACHE_KEYS = {
|
||||
// --- Internal init tracking ---
|
||||
GRAPHQL_STARTED: 'graphQLServerStarted', // set when GraphQL server responds
|
||||
STRINGS_COUNT: 'cacheStringsCountCompleted', // count of language packs loaded
|
||||
STRINGS_LANG: 'cacheStrings_v2_language', // active non-English locale at last load
|
||||
COMPLETED_CALLS: 'completedCalls', // comma-joined list of completed init calls
|
||||
INIT_TIMESTAMP: 'nax_init_timestamp', // ms timestamp of last successful cache init
|
||||
CACHE_VERSION: 'nax_cache_version', // version stamp for auto-bust on deploy
|
||||
@@ -176,11 +177,39 @@ function cacheApiConfig() {
|
||||
});
|
||||
}
|
||||
|
||||
// Module-level declaration so cachePluginStrings() and other callers can
|
||||
// safely reference pluginsData even before cacheSettings() has fetched it.
|
||||
// null = fetch not yet attempted or fetch failed (unknown state).
|
||||
// [] = fetch succeeded and confirmed no plugins are configured.
|
||||
// [...] = fetch succeeded and plugins are present.
|
||||
var pluginsData = null;
|
||||
|
||||
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')
|
||||
.then((pluginsArr) => {
|
||||
// Only update pluginsData on a successful fetch so that a network
|
||||
// failure is not misread as "no plugins configured" (null sentinel).
|
||||
if (Array.isArray(pluginsArr)) {
|
||||
pluginsData = pluginsArr;
|
||||
}
|
||||
resolve();
|
||||
})
|
||||
.catch(() => {
|
||||
// Fetch failed — leave pluginsData as null (unknown state) so
|
||||
// cachePluginStrings() retries rather than treating it as no-op.
|
||||
resolve();
|
||||
});
|
||||
return;
|
||||
}
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
@@ -292,23 +321,23 @@ 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();
|
||||
});
|
||||
return;
|
||||
// Verify STRINGS_COUNT and the stored language both match the current
|
||||
// configuration. A count-only check allows a locale switch between two
|
||||
// non-English languages (both count=2) to pass undetected, leaving the
|
||||
// UI with stale strings for the old language.
|
||||
const activeLang = getLangCode();
|
||||
const expectedCount = activeLang === 'en_us' ? 1 : 2;
|
||||
const currentCount = parseInt(getCache(CACHE_KEYS.STRINGS_COUNT)) || 0;
|
||||
const storedLang = getCache(CACHE_KEYS.STRINGS_LANG) || 'en_us';
|
||||
if (currentCount === expectedCount && storedLang === activeLang) {
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
// Mismatch — reset count and fall through to reload strings for the
|
||||
// current language set. The flag stays set; handleSuccess re-increments.
|
||||
console.log(`[cacheStrings] stale cache (count: ${currentCount}/${expectedCount}, lang: '${storedLang}' → '${activeLang}') — reloading strings.`);
|
||||
setCache(CACHE_KEYS.STRINGS_COUNT, 0);
|
||||
// Do NOT return — fall through to full load path below.
|
||||
}
|
||||
|
||||
// Create a promise for each language (include en_us by default as fallback)
|
||||
@@ -321,42 +350,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 +373,12 @@ function cacheStrings() {
|
||||
// Wait for all language promises to complete
|
||||
Promise.all(languagePromises)
|
||||
.then(() => {
|
||||
// All languages processed successfully
|
||||
// Record the active language so the early-return check can detect
|
||||
// a locale switch between two non-English languages (both count=2).
|
||||
setCache(CACHE_KEYS.STRINGS_LANG, getLangCode());
|
||||
resolve();
|
||||
})
|
||||
.catch((error) => {
|
||||
// Handle failure in any of the language processing
|
||||
handleFailure('cacheStrings_v2');
|
||||
reject(error);
|
||||
});
|
||||
@@ -377,6 +386,56 @@ 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.
|
||||
// null = plugins.json fetch failed (unknown) — retry to be safe.
|
||||
// [] = confirmed no plugins — accept empty strings as valid.
|
||||
// [...] = plugins present — retry, file may not be ready yet.
|
||||
const hasPlugins = pluginsData === null || (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>
|
||||
|
||||
@@ -23,6 +23,7 @@ from messaging.in_app import write_notification # noqa: E402 [flake8 lint suppr
|
||||
import conf # noqa: E402 [flake8 lint suppression]
|
||||
from pytz import timezone # noqa: E402 [flake8 lint suppression]
|
||||
from database import get_temp_db_connection # noqa: E402 [flake8 lint suppression]
|
||||
from config_paths import API_PATH # noqa: E402 [flake8 lint suppression]
|
||||
|
||||
# Make sure the TIMEZONE for logging is correct
|
||||
conf.tz = timezone(get_setting_value('TIMEZONE'))
|
||||
@@ -105,8 +106,7 @@ def main():
|
||||
|
||||
# PUSHING/SENDING devices
|
||||
if send_devices:
|
||||
|
||||
file_path = f"{INSTALL_PATH}/api/table_devices.json"
|
||||
file_path = f"{API_PATH}/table_devices.json"
|
||||
pref = 'SYNC'
|
||||
|
||||
if os.path.exists(file_path):
|
||||
@@ -116,6 +116,8 @@ def main():
|
||||
|
||||
mylog('verbose', [f'[{pluginName}] Sending file_content: "{file_content}"'])
|
||||
send_data(api_token, file_content, encryption_key, file_path, node_name, pref, hub_url)
|
||||
else:
|
||||
mylog('none', [f'[{pluginName}] ERROR Could not open: "{file_path}"'])
|
||||
else:
|
||||
mylog('verbose', [f'[{pluginName}] SYNC_hub_url not defined, skipping posting "Devices" data'])
|
||||
else:
|
||||
|
||||
@@ -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,8 +308,11 @@ async function getData() {
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchJson(filename) {
|
||||
const response = await fetch(`php/server/query_json.php?file=${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=${encodeURIComponent(filename)}&nocache=${Date.now()}`);
|
||||
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() {
|
||||
|
||||
@@ -159,7 +159,7 @@ def get_setting(key):
|
||||
try:
|
||||
fileModifiedTime = os.path.getmtime(settingsFile)
|
||||
except FileNotFoundError:
|
||||
mylog("none", [f"[Settings] ⚠ File not found: {settingsFile}"])
|
||||
mylog("none", [f"[Settings] ⚠ Settings file not found: {settingsFile} (backend may still be initializing)"])
|
||||
return None
|
||||
|
||||
mylog("trace", f"[Import table_settings.json] checking table_settings.json file SETTINGS_LASTCACHEDATE: {SETTINGS_LASTCACHEDATE} fileModifiedTime: {fileModifiedTime}")
|
||||
|
||||
@@ -510,7 +510,7 @@ def update_vendors_from_mac(db):
|
||||
|
||||
|
||||
# -------------------------------------------------------------------------------
|
||||
def save_scanned_devices(db):
|
||||
def save_own_device(db):
|
||||
sql = db.sql # TO-DO
|
||||
|
||||
# Add Local MAC of default local interface
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
from scan.device_handling import (
|
||||
create_new_devices,
|
||||
print_scan_stats,
|
||||
save_scanned_devices,
|
||||
save_own_device,
|
||||
exclude_ignored_devices,
|
||||
update_devices_data_from_scan,
|
||||
update_sync_hub_node,
|
||||
@@ -35,14 +35,15 @@ Logger(get_setting_value("LOG_LEVEL"))
|
||||
|
||||
|
||||
def process_scan(db):
|
||||
|
||||
# Save own device data into CurrentScan TODO:move potentially into a separate plugin
|
||||
mylog("verbose", "[Process Scan] Processing scan results")
|
||||
save_own_device(db)
|
||||
|
||||
# Apply exclusions
|
||||
mylog("verbose", "[Process Scan] Exclude ignored devices")
|
||||
exclude_ignored_devices(db)
|
||||
|
||||
# Load current scan data
|
||||
mylog("verbose", "[Process Scan] Processing scan results")
|
||||
save_scanned_devices(db)
|
||||
|
||||
db.commitDB()
|
||||
|
||||
# Print stats
|
||||
|
||||
178
test/ui/test_ui_translations.py
Normal file
178
test/ui/test_ui_translations.py
Normal file
@@ -0,0 +1,178 @@
|
||||
#!/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
|
||||
|
||||
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)
|
||||
|
||||
# Wait for getData() to complete: either tab links or the empty-state
|
||||
# paragraph will appear once generateTabs() has run.
|
||||
WebDriverWait(driver, 10).until(
|
||||
lambda d: len(d.find_elements(
|
||||
By.CSS_SELECTOR,
|
||||
"#tabs-location a, #tabs-content-location p.text-muted"
|
||||
)) > 0
|
||||
)
|
||||
|
||||
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", (
|
||||
"Tab/heading label is literally 'undefined' — plugin strings were not "
|
||||
"loaded before getData() rendered the tabs on plugins.php."
|
||||
)
|
||||
Reference in New Issue
Block a user