Files
community-plugins/drive-health/tests/collector_harness.lua
T

683 lines
31 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 failNextLaunch = false
local nextAsyncResult = 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,
notifyError = function(title, body) table.insert(notifications, { title = title, body = body, error = true }) 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 failNextLaunch then
failNextLaunch = false
return false
end
if callback == nil then
return true
end
if nextAsyncResult ~= nil then
local result = nextAsyncResult
nextAsyncResult = nil
callback(result)
return true
end
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"))()
if mode == "lifecycle" then
collectorEnabled = true
files["/usr/local/libexec/noctalia-drive-health/collect_raw.sh"] = "installed"
files["/usr/local/libexec/noctalia-drive-health/manage-collector.sh"] = "installed"
files["/usr/local/libexec/noctalia-drive-health/uninstall-collector.sh"] = "installed"
launchedCommands = {}
onEnable()
assert(launchedCommands[1]
== "pkexec '/usr/local/libexec/noctalia-drive-health/manage-collector.sh' start",
"plugin enable did not start the installed collector")
onExit(0, "disable")
assert(launchedCommands[2]
== "pkexec '/usr/local/libexec/noctalia-drive-health/manage-collector.sh' pause",
"plugin disable did not pause the installed collector")
onExit(0, "uninstall")
assert(launchedCommands[3]
== "pkexec '/usr/local/libexec/noctalia-drive-health/uninstall-collector.sh'",
"plugin uninstall did not remove the installed collector")
files["/usr/local/libexec/noctalia-drive-health/uninstall-collector.sh"] = nil
onExit(0, "uninstall")
assert(launchedCommands[4]:match("^pkexec /bin/sh %-c ")
and launchedCommands[4]:match("/usr/local/libexec/noctalia%-drive%-health")
and not launchedCommands[4]:match("/mock/plugin"),
"legacy uninstall fallback depends on files removed with the plugin")
local notificationCount = #notifications
failNextLaunch = true
onExit(0, "disable")
assert(#notifications == notificationCount + 1 and notifications[#notifications].error == true,
"detached lifecycle launch failure was not reported")
nextAsyncResult = { exitCode = 126, stdout = "", stderr = "", timedOut = false }
onEnable()
assert(#notifications == notificationCount + 2
and logs[#logs]:match("privileged_action%.cancelled"),
"onEnable authorization cancellation was not reported")
onExit(0, "reload")
onExit(15, "shutdown")
assert(#launchedCommands == 6, "reload or shutdown changed the collector service")
print("collector lifecycle test passed")
return
end
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")