618 lines
29 KiB
Lua
618 lines
29 KiB
Lua
-- Unit and initialization tests for collector.luau using a minimal Noctalia
|
|
-- host mock. This runs with stock Lua after lowering Luau compound assignment.
|
|
|
|
local mode = arg[1] or "ready"
|
|
local state = {}
|
|
local launchedCommand = nil
|
|
local launchedCommands = {}
|
|
local logs = {}
|
|
local notifications = {}
|
|
local files = {}
|
|
local directories = {}
|
|
local watchers = {}
|
|
local pendingProbeCallback = nil
|
|
local probeCalls = 0
|
|
local probeAction = nil
|
|
local collectorEnabled = mode == "raw-cache" or mode == "outdated-raw-cache"
|
|
|
|
local available = {
|
|
lsblk = mode ~= "missing-lsblk",
|
|
smartctl = mode ~= "missing-smartctl",
|
|
pacman = true,
|
|
sudo = true,
|
|
pkexec = true,
|
|
systemctl = true,
|
|
}
|
|
|
|
local rawFixture = {
|
|
schema = 2,
|
|
collector_version = mode == "outdated-raw-cache" and "0.6.0" or "2.0.1",
|
|
collection_id = "fixture-collection-id",
|
|
generated_at_epoch = 1700000000,
|
|
lsblk = { blockdevices = {} },
|
|
smart = {},
|
|
}
|
|
|
|
if mode == "raw-cache" or mode == "outdated-raw-cache" then
|
|
files["/usr/local/libexec/noctalia-drive-health/manage-collector.sh"] = "installed"
|
|
end
|
|
|
|
local function translate(key, substitutions)
|
|
local value = key
|
|
for name, replacement in pairs(substitutions or {}) do
|
|
value = value:gsub("{" .. name .. "}", tostring(replacement))
|
|
end
|
|
return value
|
|
end
|
|
|
|
noctalia = {
|
|
commandExists = function(command) return available[command] == true end,
|
|
getConfig = function(key)
|
|
if key == "system_collector_enabled" then
|
|
return collectorEnabled
|
|
end
|
|
if key == "full_smart_refresh_minutes" then return 15 end
|
|
return nil
|
|
end,
|
|
pluginDir = function() return "/mock/plugin" end,
|
|
pluginDataDir = function() return "/mock/plugin-data" end,
|
|
fileInfo = function(path)
|
|
if (mode == "raw-cache" or mode == "outdated-raw-cache" or mode == "collector-disabled")
|
|
and path:match("raw%.json$") then
|
|
return { isDir = false, mtime = os.time() }
|
|
end
|
|
return nil
|
|
end,
|
|
fileExists = function(path) return files[path] ~= nil end,
|
|
listDir = function(path) return directories[path] or {} end,
|
|
readFile = function(path)
|
|
if (mode == "raw-cache" or mode == "outdated-raw-cache" or mode == "collector-disabled")
|
|
and path:match("raw%.json$") then
|
|
return "raw-cache"
|
|
end
|
|
return files[path]
|
|
end,
|
|
writeFile = function(path, contents) files[path] = contents return true end,
|
|
log = function(message) table.insert(logs, message) end,
|
|
notify = function(title, body) table.insert(notifications, { title = title, body = body }) end,
|
|
tr = translate,
|
|
formatTime = function(_pattern, _epoch) return "22:13:20" end,
|
|
setUpdateInterval = function(_milliseconds) end,
|
|
state = {
|
|
get = function(key) return state[key] end,
|
|
set = function(key, value)
|
|
state[key] = value
|
|
if watchers[key] ~= nil then watchers[key](value) end
|
|
end,
|
|
watch = function(key, callback) watchers[key] = callback end,
|
|
},
|
|
json = {
|
|
decode = function(_raw) return rawFixture end,
|
|
encode = function(_value, _pretty) return "{}" end,
|
|
},
|
|
string = {
|
|
trim = function(value) return tostring(value):match("^%s*(.-)%s*$") end,
|
|
},
|
|
runAsync = function(command, callback, _timeout)
|
|
launchedCommand = command
|
|
table.insert(launchedCommands, command)
|
|
if command:match("lsblk=ok") then
|
|
probeCalls = probeCalls + 1
|
|
if probeAction == "pending" then
|
|
assert(pendingProbeCallback == nil, "dependency probes overlapped")
|
|
pendingProbeCallback = callback
|
|
return true
|
|
end
|
|
if probeAction == "launch-failure" then
|
|
probeAction = nil
|
|
return false
|
|
end
|
|
if type(probeAction) == "table" then
|
|
local response = probeAction
|
|
probeAction = nil
|
|
callback(response)
|
|
return true
|
|
end
|
|
if mode == "async-incompatible-lsblk" or mode == "probe-completes-during-collection" then
|
|
pendingProbeCallback = callback
|
|
return true
|
|
end
|
|
if mode == "probe-timeout" then
|
|
callback({ exitCode = 124, stdout = "", stderr = "", timedOut = true })
|
|
return true
|
|
end
|
|
local lsblk = mode == "incompatible-lsblk" and "bad" or "ok"
|
|
local smartctl = mode == "incompatible-smartctl" and "bad" or "ok"
|
|
callback({ exitCode = 0, stdout = "lsblk=" .. lsblk .. "\nsmartctl=" .. smartctl .. "\n",
|
|
stderr = "", timedOut = false })
|
|
else
|
|
if mode == "probe-completes-during-collection" and pendingProbeCallback ~= nil then
|
|
local probe = pendingProbeCallback
|
|
pendingProbeCallback = nil
|
|
probe({ exitCode = 0, stdout = "lsblk=bad\nsmartctl=ok\n", stderr = "", timedOut = false })
|
|
end
|
|
callback({ exitCode = 0, stdout = "{}", stderr = "", timedOut = false })
|
|
end
|
|
return true
|
|
end,
|
|
}
|
|
|
|
local handle = assert(io.open("collector.luau", "rb"))
|
|
local source = handle:read("*a")
|
|
handle:close()
|
|
source = source:gsub("local function mountedUsage", "function mountedUsage")
|
|
source = source:gsub("local function normalizeSmart", "function normalizeSmart")
|
|
source = source:gsub("local function normalizeRaw", "function normalizeRaw")
|
|
source = source:gsub("local function publishError", "function publishError")
|
|
source = source:gsub("([%a_][%w_]*) %+%= ([^\n]+)", "%1 = %1 + %2")
|
|
assert(load(source, "@collector.luau"))()
|
|
|
|
local snapshot = assert(state.collector_snapshot, "collector did not publish an initialization snapshot")
|
|
local dependencies = assert(snapshot.dependencies, "snapshot has no dependency state")
|
|
|
|
if mode == "probe-completes-during-collection" then
|
|
assert(dependencies.ready == false and dependencies.blocking == true,
|
|
"collector published stale dependency state when the probe completed during collection")
|
|
print("collector initialization test passed: " .. mode)
|
|
return
|
|
elseif mode == "async-incompatible-lsblk" then
|
|
assert(dependencies.ready == true and pendingProbeCallback ~= nil,
|
|
"asynchronous capability probe was not pending")
|
|
pendingProbeCallback({ exitCode = 0, stdout = "lsblk=bad\nsmartctl=ok\n", stderr = "", timedOut = false })
|
|
dependencies = assert(state.collector_snapshot.dependencies)
|
|
assert(dependencies.ready == false and dependencies.blocking == true,
|
|
"completed capability probe did not immediately refresh dependency state")
|
|
print("collector initialization test passed: " .. mode)
|
|
return
|
|
elseif mode == "probe-timeout" then
|
|
assert(dependencies.ready == false and dependencies.blocking == true,
|
|
"failed capability probe incorrectly reported dependencies ready")
|
|
print("collector initialization test passed: " .. mode)
|
|
return
|
|
elseif mode == "missing-lsblk" then
|
|
assert(dependencies.ready == false and dependencies.blocking == true, "missing lsblk was not blocking")
|
|
assert(dependencies.install_command == "pkexec pacman -S --needed --noconfirm util-linux", "wrong lsblk install command")
|
|
assert(launchedCommand == nil, "collector launched without lsblk")
|
|
assert(snapshot.collector_error ~= nil, "blocking dependency did not publish an error")
|
|
print("collector initialization test passed: " .. mode)
|
|
return
|
|
elseif mode == "missing-smartctl" then
|
|
assert(dependencies.ready == false and dependencies.blocking == false, "missing smartctl blocked inventory")
|
|
assert(dependencies.install_command == "pkexec pacman -S --needed --noconfirm smartmontools", "wrong smartctl install command")
|
|
assert(launchedCommand and launchedCommand:match("collect_raw%.sh"), "fallback collector did not launch")
|
|
print("collector initialization test passed: " .. mode)
|
|
return
|
|
elseif mode == "raw-cache" then
|
|
assert(snapshot.source == "system-cache", "raw cache was not normalized")
|
|
assert(snapshot.collection_id == "fixture-collection-id", "raw cache lost its collection ID")
|
|
assert(snapshot.system_collector.status == "healthy"
|
|
and snapshot.system_collector.version == "2.0.1"
|
|
and snapshot.system_collector.expected_version == "2.0.1"
|
|
and snapshot.system_collector.smart_refresh_minutes == 15,
|
|
"current system collector was not reported healthy")
|
|
assert(not (launchedCommand or ""):match("collect_raw%.sh"), "collector launched despite a fresh raw cache")
|
|
print("collector initialization test passed: " .. mode)
|
|
return
|
|
elseif mode == "outdated-raw-cache" then
|
|
assert(snapshot.source == "system-cache", "outdated raw cache was not normalized")
|
|
assert(snapshot.system_collector.status == "upgrade-required"
|
|
and snapshot.system_collector.version == "0.6.0"
|
|
and snapshot.system_collector.expected_version == "2.0.1",
|
|
"older system collector did not request an upgrade")
|
|
assert(not (launchedCommand or ""):match("collect_raw%.sh"), "collector launched despite a fresh raw cache")
|
|
assert(#notifications == 1 and notifications[1].title == "collector.update_title"
|
|
and notifications[1].body == "collector.update_body",
|
|
"enabled outdated collector did not produce one coordinated update notice")
|
|
print("collector initialization test passed: " .. mode)
|
|
return
|
|
elseif mode == "collector-disabled" then
|
|
assert(snapshot.source == "direct", "disabled collector still consumed the privileged cache")
|
|
assert(snapshot.system_collector.enabled == false and snapshot.system_collector.status == "disabled",
|
|
"disabled collector did not publish Basic-mode state")
|
|
assert((launchedCommand or ""):match("collect_raw%.sh"),
|
|
"disabled collector did not fall back to direct Basic collection")
|
|
assert(#notifications == 0, "disabled collector produced an update or installation notification")
|
|
print("collector initialization test passed: " .. mode)
|
|
return
|
|
elseif mode == "incompatible-lsblk" then
|
|
assert(dependencies.ready == false and dependencies.blocking == true, "incompatible lsblk was not blocking")
|
|
assert(dependencies.missing_text:match("incompatible"), "incompatible lsblk was not explained")
|
|
assert(not (launchedCommand or ""):match("collect_raw%.sh"), "collector launched with incompatible lsblk")
|
|
print("collector initialization test passed: " .. mode)
|
|
return
|
|
elseif mode == "incompatible-smartctl" then
|
|
assert(dependencies.ready == false and dependencies.blocking == false, "incompatible smartctl blocked inventory")
|
|
assert(dependencies.missing_text:match("incompatible"), "incompatible smartctl was not explained")
|
|
assert((launchedCommand or ""):match("collect_raw%.sh"), "fallback inventory did not launch")
|
|
print("collector initialization test passed: " .. mode)
|
|
return
|
|
end
|
|
|
|
assert(dependencies.ready == true, "available dependencies were reported missing")
|
|
assert(launchedCommand and launchedCommand:match("collect_raw%.sh"), "raw collector was not launched")
|
|
assert(snapshot.system_collector.authorization_available == true,
|
|
"available Polkit authorization was not exposed to the panel")
|
|
|
|
local nvme = normalizeSmart({
|
|
smart_status = { passed = true },
|
|
temperature = { current = 55 },
|
|
nvme_smart_health_information_log = {
|
|
available_spare = 100, available_spare_threshold = 25, percentage_used = 3,
|
|
temperature_sensors = { 74, 59, 55 },
|
|
data_units_read = 10, data_units_written = 20,
|
|
power_cycles = 7, power_on_hours = 100, unsafe_shutdowns = 2,
|
|
media_errors = 0, num_err_log_entries = 0, critical_warning = 0,
|
|
warning_temp_time = 4, critical_comp_time = 1,
|
|
},
|
|
nvme_self_test_log = {
|
|
current_self_test_operation = { value = 0, string = "No self-test in progress" },
|
|
table = { { self_test_code = { value = 1, string = "Short" },
|
|
self_test_result = { value = 0, string = "Completed without error" }, power_on_hours = 99 } },
|
|
},
|
|
})
|
|
assert(nvme.health == "passed" and nvme.temperature_c == 55, "NVMe health normalization failed")
|
|
assert(nvme.hotspot_temperature_c == 74 and #nvme.temperature_sensors_c == 3, "NVMe hotspot normalization failed")
|
|
assert(nvme.remaining_life_percent == 97, "NVMe endurance normalization failed")
|
|
assert(nvme.data_written_bytes == 20 * 512000, "NVMe data-unit conversion failed")
|
|
assert(nvme.available_spare_threshold_percent == 25, "NVMe spare threshold normalization failed")
|
|
assert(nvme.self_test_state == "passed" and nvme.self_test_supported, "NVMe self-test normalization failed")
|
|
|
|
local runningNvme = normalizeSmart({
|
|
smart_status = { passed = true },
|
|
nvme_smart_health_information_log = { percentage_used = 1 },
|
|
nvme_self_test_log = {
|
|
current_self_test_operation = { value = 1, string = "Short self-test in progress" },
|
|
current_self_test_completion_percent = 37,
|
|
},
|
|
})
|
|
assert(runningNvme.self_test_state == "running" and runningNvme.self_test_completion_percent == 37,
|
|
"NVMe self-test completion was not normalized")
|
|
|
|
local runningAta = normalizeSmart({
|
|
smart_status = { passed = true },
|
|
ata_smart_self_test_log = { standard = { table = { {
|
|
status = { value = 249, string = "Self-test routine in progress", remaining_percent = 80 },
|
|
} } } },
|
|
})
|
|
assert(runningAta.self_test_state == "running" and runningAta.self_test_completion_percent == 20,
|
|
"ATA remaining self-test percentage was not converted to completion")
|
|
|
|
local samsung = normalizeSmart({
|
|
smart_status = { passed = true },
|
|
power_on_time = { hours = 83062 },
|
|
ata_smart_error_log = { summary = { count = 5 } },
|
|
ata_smart_attributes = { table = {
|
|
{ name = "Wear_Leveling_Count", value = 90, raw = { value = 194 } },
|
|
{ name = "Total_LBAs_Written", value = 99, raw = { value = 210090409618 } },
|
|
{ name = "Airflow_Temperature_Cel", value = 61, raw = { value = 39 } },
|
|
{ name = "Reallocated_Sector_Ct", value = 100, raw = { value = 0 } },
|
|
} },
|
|
})
|
|
assert(samsung.remaining_life_percent == 90, "Samsung wear normalization failed")
|
|
assert(samsung.remaining_life_estimated == true, "vendor ATA life was not marked estimated")
|
|
assert(samsung.temperature_c == 39, "Samsung temperature normalization failed")
|
|
assert(samsung.data_written_bytes == 210090409618 * 512, "Samsung LBA conversion failed")
|
|
assert(samsung.reallocated_sectors == 0, "Samsung integrity counter normalization failed")
|
|
assert(samsung.media_errors == nil and samsung.error_log_entries == 5, "ATA error log was misclassified as media errors")
|
|
|
|
local hdd = normalizeSmart({
|
|
smart_status = { passed = true },
|
|
power_on_time = { hours = 113397 },
|
|
ata_smart_attributes = { table = {
|
|
{ name = "Start_Stop_Count", value = 98, raw = { value = 1423 } },
|
|
{ name = "Load_Cycle_Count", value = 92, raw = { value = 18421 } },
|
|
{ name = "Spin_Retry_Count", value = 100, raw = { value = 0 } },
|
|
{ name = "Command_Timeout", value = 100, raw = { value = 3 } },
|
|
{ name = "UDMA_CRC_Error_Count", value = 200, raw = { value = 2 } },
|
|
{ name = "Offline_Uncorrectable", value = 100, raw = { value = 0 } },
|
|
{ name = "Reported_Uncorrect", value = 100, raw = { value = 4 } },
|
|
} },
|
|
})
|
|
assert(hdd.start_stop_count == 1423 and hdd.load_cycle_count == 18421,
|
|
"HDD mechanical cycle counters were not normalized")
|
|
assert(hdd.spin_retry_count == 0 and hdd.command_timeout_count == 3 and hdd.interface_crc_errors == 2,
|
|
"HDD transport and spindle counters were not normalized")
|
|
assert(hdd.uncorrectable_errors == 4,
|
|
"HDD uncorrectable normalization ignored a nonzero counter after a zero counter")
|
|
|
|
local partial = normalizeSmart({
|
|
smart_status = { passed = true },
|
|
smartctl = { exit_status = 4, messages = { { severity = "error", string = "Optional log unavailable" } } },
|
|
nvme_smart_health_information_log = { percentage_used = 1 },
|
|
})
|
|
assert(partial.health == "passed" and partial.smart_completeness == "partial", "partial SMART result was not preserved")
|
|
assert(partial.smart_messages[1].message == "Optional log unavailable", "SMART diagnostic message was lost")
|
|
|
|
local currentPrefail = normalizeSmart({
|
|
smart_status = { passed = true }, smartctl = { exit_status = 16 },
|
|
})
|
|
assert(currentPrefail.health == "failed" and currentPrefail.smart_prefail_attribute_now == true,
|
|
"current pre-failure threshold bit did not fail health")
|
|
local historicalThreshold = normalizeSmart({
|
|
smart_status = { passed = true }, smartctl = { exit_status = 32 },
|
|
})
|
|
assert(historicalThreshold.health == "passed" and historicalThreshold.smart_past_threshold == true,
|
|
"historical threshold bit incorrectly failed current health")
|
|
local failedSelfTestLog = normalizeSmart({
|
|
smart_status = { passed = true }, smartctl = { exit_status = 128 },
|
|
})
|
|
assert(failedSelfTestLog.self_test_state == "failed" and failedSelfTestLog.smart_self_test_log_error == true,
|
|
"failed self-test log bit was ignored")
|
|
|
|
local sandisk = normalizeSmart({
|
|
smart_status = { passed = true },
|
|
ata_smart_attributes = { table = {
|
|
{ name = "Lifetime_Remaining%", value = 99, raw = { value = 99 } },
|
|
{ name = "Total_Writes_GiB", value = 253, raw = { value = 43210 } },
|
|
{ name = "Total_Reads_GiB", value = 253, raw = { value = 8765 } },
|
|
{ name = "Unexpect_Power_Loss_Ct", value = 100, raw = { value = 12 } },
|
|
} },
|
|
})
|
|
assert(sandisk.remaining_life_percent == 99, "SanDisk endurance normalization failed")
|
|
assert(sandisk.data_written_bytes == 43210 * 1024 ^ 3, "SanDisk write conversion failed")
|
|
assert(sandisk.data_read_bytes == 8765 * 1024 ^ 3, "SanDisk read conversion failed")
|
|
assert(sandisk.unsafe_shutdowns == 12, "SanDisk unsafe shutdown normalization failed")
|
|
|
|
local ambiguousHostWrites = normalizeSmart({
|
|
smart_status = { passed = true },
|
|
ata_smart_attributes = { table = {
|
|
{ name = "Host_Writes", value = 99, raw = { value = 123456 } },
|
|
{ name = "Host_Reads", value = 99, raw = { value = 654321 } },
|
|
} },
|
|
})
|
|
assert(ambiguousHostWrites.data_written_bytes == nil and ambiguousHostWrites.data_read_bytes == nil,
|
|
"unitless ATA host counters were incorrectly treated as LBAs")
|
|
|
|
local estimated = normalizeSmart({
|
|
smart_status = { passed = true },
|
|
ata_smart_attributes = { table = {
|
|
{ name = "Perc_Write/Erase_Count", value = 83, raw = { value = 590 } },
|
|
} },
|
|
})
|
|
assert(estimated.remaining_life_percent == 83 and estimated.remaining_life_estimated, "vendor life fallback failed")
|
|
assert(estimated.percentage_used == 17, "vendor life usage calculation failed")
|
|
|
|
local reserveOnly = normalizeSmart({
|
|
smart_status = { passed = true },
|
|
ata_smart_attributes = { table = {
|
|
{ name = "Perc_Avail_Resrvd_Space", value = 97, raw = { value = 97 } },
|
|
} },
|
|
})
|
|
assert(reserveOnly.remaining_life_percent == nil, "reserve space must not be treated as remaining life")
|
|
assert(reserveOnly.percentage_used == nil, "reserve space must not produce a used-life percentage")
|
|
assert(not reserveOnly.remaining_life_estimated, "reserve space must not be marked as estimated life")
|
|
assert(reserveOnly.available_spare_percent == 97, "vendor spare normalization failed")
|
|
|
|
local used, availableBytes, usage, mountPoints = mountedUsage({ children = {
|
|
{ kname = "nvme0n1p1", mountpoints = { "/home", "/", "/home" }, fsused = 25, fsavail = 75 },
|
|
} })
|
|
assert(used == 25 and availableBytes == 75 and usage == 25, "mounted usage aggregation failed")
|
|
assert(#mountPoints == 2 and mountPoints[1] == "/" and mountPoints[2] == "/home",
|
|
"mount points were not deduplicated and normalized")
|
|
|
|
files["/sys/class/nvme/nvme9/device/hwmon/hwmon9/temp1_input"] = "47000\n"
|
|
directories["/sys/class/nvme/nvme9/device/hwmon"] = { "hwmon9" }
|
|
local normalized, normalizeError = normalizeRaw({
|
|
schema = 2,
|
|
collection_id = "fixture-normalized-id",
|
|
generated_at_epoch = 1700000000,
|
|
lsblk = { blockdevices = {
|
|
{
|
|
name = "/dev/nvme9n1", kname = "/dev/nvme9n1", path = "/dev/nvme9n1", type = "disk",
|
|
tran = "nvme", rota = false, size = 2000000000, model = "Fixture NVMe", serial = "FIXTURE1",
|
|
mountpoints = {}, children = {
|
|
{ name = "/dev/nvme9n1p1", kname = "/dev/nvme9n1p1", path = "/dev/nvme9n1p1", type = "part",
|
|
mountpoints = { "/mnt/work" }, fsused = 250, fsavail = 750 },
|
|
},
|
|
},
|
|
} },
|
|
smart = {
|
|
{
|
|
requested_device = "/dev/nvme9",
|
|
payload = { smartctl = { messages = { { string = "Permission denied" } } } },
|
|
},
|
|
},
|
|
}, "test")
|
|
assert(normalized ~= nil and normalizeError == nil, "raw normalization failed")
|
|
assert(normalized.collection_id == "fixture-normalized-id", "raw normalization lost its collection ID")
|
|
assert(normalized.summary.ssd_count == 1 and normalized.disks[1].id == "FIXTURE1:n1", "drive discovery failed")
|
|
assert(normalized.disks[1].temperature_c == 47, "sysfs temperature fallback failed")
|
|
assert(normalized.disks[1].mount_points[1] == "/mnt/work",
|
|
"normalized drive omitted its mounted folder")
|
|
assert(normalized.disks[1].smart_available == false, "permission failure incorrectly marked SMART available")
|
|
assert(normalized.disks[1].smart_error:match("Permission denied"), "permission error was not preserved")
|
|
|
|
local mixed = assert(normalizeRaw({
|
|
schema = 2,
|
|
generated_at_epoch = 1700000000,
|
|
lsblk = { blockdevices = {
|
|
{ name = "sda", kname = "sda", path = "/dev/sda", type = "disk", tran = "sata",
|
|
rota = false, size = 1000000000, model = "Fixture SSD", serial = "SSD1", children = {} },
|
|
{ name = "sdb", kname = "sdb", path = "/dev/sdb", type = "disk", tran = "sata",
|
|
rota = true, size = 2000000000, model = "Fixture HDD", serial = "HDD1", children = {} },
|
|
} },
|
|
smart = {
|
|
{ requested_device = "/dev/sda", payload = {
|
|
smart_status = { passed = true }, temperature = { current = 42 },
|
|
nvme_smart_health_information_log = { percentage_used = 12 },
|
|
ata_smart_attributes = { table = {} },
|
|
} },
|
|
{ requested_device = "/dev/sdb", payload = {
|
|
smart_status = { passed = true }, temperature = { current = 36 },
|
|
ata_smart_attributes = { table = {} },
|
|
} },
|
|
},
|
|
}, "test"))
|
|
assert(mixed.summary.disk_count == 2 and mixed.summary.ssd_count == 1 and mixed.summary.hdd_count == 1,
|
|
"mixed SSD/HDD summary counts were incorrect")
|
|
assert(mixed.summary.smart_available_count == 2 and mixed.summary.hottest_drive_temperature_c == 42,
|
|
"mixed-drive SMART or temperature summary was incorrect")
|
|
assert(mixed.summary.hottest_drive_id == "SSD1" and mixed.summary.hottest_drive_name == "Fixture SSD"
|
|
and mixed.summary.hottest_ssd_drive_id == "SSD1",
|
|
"temperature summary omitted the responsible drive")
|
|
assert(mixed.summary.worst_ssd_remaining_life_percent == 88
|
|
and mixed.summary.worst_ssd_life_drive_id == "SSD1"
|
|
and mixed.summary.worst_ssd_life_drive_name == "Fixture SSD",
|
|
"SSD-life summary omitted the responsible drive")
|
|
|
|
local healthyRaw = assert(normalizeRaw({
|
|
schema = 2,
|
|
generated_at_epoch = 1700000000,
|
|
lsblk = { blockdevices = { {
|
|
name = "sda", kname = "sda", path = "/dev/sda", type = "disk", tran = "sata",
|
|
rota = false, size = 1000000000, model = "Healthy SSD", serial = "HEALTHY1",
|
|
mountpoints = {}, children = {},
|
|
} } },
|
|
smart = { {
|
|
requested_device = "/dev/sda",
|
|
payload = { smart_status = { passed = true }, ata_smart_attributes = { table = {} } },
|
|
} },
|
|
}, "test"))
|
|
assert(healthyRaw.disks[1].smart_available == true and healthyRaw.disks[1].smart_error == nil,
|
|
"healthy SMART data retained a contradictory error message")
|
|
|
|
directories["/sys/class/nvme/nvme0"] = { "hwmon0" }
|
|
files["/sys/class/nvme/nvme0/hwmon0/temp1_input"] = "51000\n"
|
|
local liveTemperatureRaw = assert(normalizeRaw({
|
|
schema = 2, generated_at_epoch = 1700000000,
|
|
lsblk = { blockdevices = { {
|
|
name = "nvme0n1", kname = "nvme0n1", path = "/dev/nvme0n1", type = "disk", tran = "nvme",
|
|
rota = false, size = 1000000000, model = "Live Temperature NVMe", serial = "LIVE1",
|
|
mountpoints = {}, children = {},
|
|
} } },
|
|
smart = { { requested_device = "/dev/nvme0", payload = {
|
|
smart_status = { passed = true }, temperature = { current = 42 },
|
|
} } },
|
|
}, "test"))
|
|
assert(liveTemperatureRaw.disks[1].temperature_c == 51
|
|
and liveTemperatureRaw.disks[1].hotspot_temperature_c == 51
|
|
and liveTemperatureRaw.disks[1].temperature_source == "sysfs",
|
|
"fresh sysfs temperature did not override the stale SMART-cache value")
|
|
|
|
local sleeping = assert(normalizeRaw({
|
|
schema = 2, generated_at_epoch = 1700000000,
|
|
lsblk = { blockdevices = { {
|
|
name = "sdb", kname = "sdb", path = "/dev/sdb", type = "disk", tran = "sata",
|
|
rota = true, size = 1000000000, model = "Sleeping HDD", serial = "SLEEP1",
|
|
mountpoints = {}, children = {},
|
|
} } },
|
|
smart = { { requested_device = "/dev/sdb", payload = {
|
|
power_mode = { value = 128, string = "STANDBY" },
|
|
smartctl = { exit_status = 2 },
|
|
} } },
|
|
}, "test"))
|
|
assert(sleeping.disks[1].smart_sleeping == true and sleeping.disks[1].smart_error == nil,
|
|
"sleeping HDD was reported as a SMART access failure")
|
|
assert(sleeping.summary.sleeping_count == 1 and sleeping.summary.smart_unavailable_count == 0,
|
|
"sleeping HDD was counted as unavailable")
|
|
|
|
local namespaces = assert(normalizeRaw({
|
|
schema = 2, generated_at_epoch = 1700000000,
|
|
lsblk = { blockdevices = {
|
|
{ name = "/dev/nvme0n1", kname = "/dev/nvme0n1", path = "/dev/nvme0n1", type = "disk",
|
|
tran = "nvme", rota = false, serial = "SHARED", children = {} },
|
|
{ name = "/dev/nvme0n2", kname = "/dev/nvme0n2", path = "/dev/nvme0n2", type = "disk",
|
|
tran = "nvme", rota = false, serial = "SHARED", children = {} },
|
|
{ name = "/dev/zram0", kname = "/dev/zram0", path = "/dev/zram0", type = "disk",
|
|
rota = false, serial = "VIRTUAL", children = {} },
|
|
} }, smart = {},
|
|
}, "test"))
|
|
assert(#namespaces.disks == 2, "absolute zram name was not excluded from physical drive inventory")
|
|
assert(namespaces.disks[1].id == "SHARED:n1" and namespaces.disks[2].id == "SHARED:n2",
|
|
"absolute NVMe names did not receive stable namespace-qualified IDs")
|
|
|
|
local empty = assert(normalizeRaw({
|
|
schema = 2, collection_id = " ", generated_at_epoch = 1700000000,
|
|
lsblk = { blockdevices = {} }, smart = {},
|
|
}, "test"))
|
|
assert(empty.access == "unavailable", "an empty drive inventory incorrectly reported full SMART access")
|
|
assert(empty.collection_id == nil, "invalid collection ID was preserved")
|
|
|
|
local oversizedId = assert(normalizeRaw({
|
|
schema = 2, collection_id = string.rep("x", 129), generated_at_epoch = 1700000000,
|
|
lsblk = { blockdevices = {} }, smart = {},
|
|
}, "test"))
|
|
assert(oversizedId.collection_id == nil, "oversized collection ID was preserved")
|
|
|
|
files["/usr/local/libexec/noctalia-drive-health/collect_raw.sh"] = "installed"
|
|
files["/usr/local/libexec/noctalia-drive-health/manage-collector.sh"] = "installed"
|
|
collectorEnabled = true
|
|
state.collector_snapshot = { summary = {}, system_collector = { status = "healthy" } }
|
|
publishError("fixture failure", { ready = true, blocking = false })
|
|
assert(state.collector_snapshot.system_collector.status == "stale",
|
|
"collector failure retained a stale healthy lifecycle status")
|
|
collectorEnabled = false
|
|
|
|
local compatibleProbe = {
|
|
exitCode = 0, stdout = "lsblk=ok\nsmartctl=ok\n", stderr = "", timedOut = false,
|
|
}
|
|
local incompatibleProbe = {
|
|
exitCode = 0, stdout = "lsblk=bad\nsmartctl=ok\n", stderr = "", timedOut = false,
|
|
}
|
|
|
|
probeAction = incompatibleProbe
|
|
watchers.refresh_nonce(1)
|
|
assert(state.collector_snapshot.dependencies.ready == false,
|
|
"manual dependency probe did not cache an incompatible result")
|
|
probeAction = "pending"
|
|
watchers.refresh_nonce(2)
|
|
assert(pendingProbeCallback ~= nil, "manual recheck did not launch a fresh dependency probe")
|
|
local manualRecheck = pendingProbeCallback
|
|
pendingProbeCallback = nil
|
|
manualRecheck(compatibleProbe)
|
|
assert(state.collector_snapshot.dependencies.ready == true,
|
|
"manual recheck did not recover a cached incompatible dependency")
|
|
local completedProbeCalls = probeCalls
|
|
update()
|
|
onIpc("refresh")
|
|
assert(probeCalls == completedProbeCalls,
|
|
"routine collection reran a completed dependency probe")
|
|
|
|
probeAction = incompatibleProbe
|
|
watchers.refresh_nonce(3)
|
|
assert(state.collector_snapshot.dependencies.ready == false,
|
|
"IPC recheck fixture did not cache an incompatible result")
|
|
probeAction = "pending"
|
|
onIpc("check-dependencies")
|
|
assert(pendingProbeCallback ~= nil, "dependency-check IPC did not launch a fresh probe")
|
|
local ipcRecheck = pendingProbeCallback
|
|
pendingProbeCallback = nil
|
|
ipcRecheck(compatibleProbe)
|
|
assert(state.collector_snapshot.dependencies.ready == true,
|
|
"dependency-check IPC did not recover a cached incompatible dependency")
|
|
|
|
probeAction = "pending"
|
|
local callsBeforeQueuedRecheck = probeCalls
|
|
watchers.refresh_nonce(4)
|
|
onIpc("check-dependencies")
|
|
assert(probeCalls == callsBeforeQueuedRecheck + 1 and pendingProbeCallback ~= nil,
|
|
"recheck during a running probe launched an overlapping probe")
|
|
local runningRecheck = pendingProbeCallback
|
|
pendingProbeCallback = nil
|
|
runningRecheck(compatibleProbe)
|
|
assert(probeCalls == callsBeforeQueuedRecheck + 2 and pendingProbeCallback ~= nil,
|
|
"pending rechecks were not coalesced into one follow-up probe")
|
|
local queuedRecheck = pendingProbeCallback
|
|
pendingProbeCallback = nil
|
|
probeAction = nil
|
|
queuedRecheck(compatibleProbe)
|
|
assert(probeCalls == callsBeforeQueuedRecheck + 2,
|
|
"queued recheck launched more than one follow-up probe")
|
|
|
|
probeAction = "launch-failure"
|
|
local callsBeforeLaunchFailure = probeCalls
|
|
watchers.refresh_nonce(5)
|
|
assert(probeCalls == callsBeforeLaunchFailure + 1
|
|
and state.collector_snapshot.dependencies.ready == false,
|
|
"probe launch failure did not publish an incompatible state")
|
|
update()
|
|
assert(probeCalls == callsBeforeLaunchFailure + 1,
|
|
"routine collection retried a failed probe launch")
|
|
probeAction = compatibleProbe
|
|
watchers.refresh_nonce(6)
|
|
assert(probeCalls == callsBeforeLaunchFailure + 2
|
|
and state.collector_snapshot.dependencies.ready == true,
|
|
"manual recheck did not recover after a probe launch failure")
|
|
|
|
print("collector normalization tests passed")
|