Add Drive Health (gustav0ar/drive-health) (#58)
* add Drive Health * declare optional package manager commands * use live Drive Health screenshot * hide redundant dependency subtitle * fix drive error counter alerts * stabilize transient SMART availability * address collector packaging review * refactor: use generic collector service name * test: harden collector packaging checks
This commit is contained in:
@@ -0,0 +1,504 @@
|
||||
-- Behavioral tests for the isolated alert service.
|
||||
|
||||
local state = {}
|
||||
local watchers = {}
|
||||
local notifications = {}
|
||||
local stateWrites = {}
|
||||
local stateRenames = {}
|
||||
local successfulStateCommits = 0
|
||||
local failNextStateWrite = false
|
||||
local failNextStateRename = false
|
||||
local fullSmartEnabled = false
|
||||
|
||||
local function translate(key, substitutions)
|
||||
local value = key
|
||||
for name, replacement in pairs(substitutions or {}) do
|
||||
value = value:gsub("{" .. name .. "}", tostring(replacement))
|
||||
end
|
||||
if substitutions ~= nil and substitutions.count ~= nil then
|
||||
value = value .. ":count=" .. tostring(substitutions.count)
|
||||
end
|
||||
return value
|
||||
end
|
||||
|
||||
noctalia = {
|
||||
getConfig = function(key)
|
||||
local values = {
|
||||
alerts_enabled = true,
|
||||
notify_recovery = true,
|
||||
warning_temperature = 65,
|
||||
critical_temperature = 80,
|
||||
life_warning_percent = 20,
|
||||
show_hdd = false,
|
||||
alert_hdd = true,
|
||||
drive_missing_alerts = true,
|
||||
missing_grace_scans = 3,
|
||||
use_hotspot_temperature = true,
|
||||
system_collector_enabled = fullSmartEnabled,
|
||||
}
|
||||
return values[key]
|
||||
end,
|
||||
pluginDataDir = function() return "/mock/plugin-data" end,
|
||||
readFile = function(path)
|
||||
return path:match("alert%-state%.json$") and "{}" or nil
|
||||
end,
|
||||
writeFile = function(path, _contents)
|
||||
table.insert(stateWrites, path)
|
||||
if failNextStateWrite then
|
||||
failNextStateWrite = false
|
||||
return false, "fixture write failure"
|
||||
end
|
||||
return true
|
||||
end,
|
||||
renameFile = function(from, to)
|
||||
table.insert(stateRenames, { from = from, to = to })
|
||||
if failNextStateRename then
|
||||
failNextStateRename = false
|
||||
return false, "fixture rename failure"
|
||||
end
|
||||
successfulStateCommits = successfulStateCommits + 1
|
||||
return true
|
||||
end,
|
||||
log = function(_message) end,
|
||||
notify = function(title, body)
|
||||
table.insert(notifications, { severity = "warning", title = title, body = body })
|
||||
end,
|
||||
notifyError = function(title, body)
|
||||
table.insert(notifications, { severity = "critical", title = title, body = body })
|
||||
end,
|
||||
tr = translate,
|
||||
state = {
|
||||
get = function(key) return state[key] end,
|
||||
set = function(key, value) state[key] = value end,
|
||||
watch = function(key, callback) watchers[key] = callback end,
|
||||
},
|
||||
json = {
|
||||
decode = function(_raw)
|
||||
return {
|
||||
active = {
|
||||
["SERIAL1:interface-crc"] = {
|
||||
id = "SERIAL1:interface-crc", kind = "interface-crc",
|
||||
drive = "Fixture SSD", message = "historical CRC total", severity = "warning",
|
||||
},
|
||||
},
|
||||
counters = {}, inventory = {}, availability = {}, dismissed = {},
|
||||
}
|
||||
end,
|
||||
encode = function(_value, _pretty) return "{}" end,
|
||||
},
|
||||
}
|
||||
|
||||
local handle = assert(io.open("service.luau", "rb"))
|
||||
local source = handle:read("*a")
|
||||
handle:close()
|
||||
source = source:gsub("([%a_][%w_]*) %+%= ([^\n]+)", "%1 = %1 + %2")
|
||||
assert(load(source, "@service.luau"))()
|
||||
|
||||
local publishSnapshot = assert(watchers.collector_snapshot, "alert service did not watch collector snapshots")
|
||||
local function publish(value)
|
||||
state.collector_snapshot = value
|
||||
publishSnapshot(value)
|
||||
end
|
||||
local dismiss = assert(watchers.dismiss_alert_request, "alert service did not watch dismissal requests")
|
||||
local function drive(temperature)
|
||||
return {
|
||||
id = "SERIAL1", device = "/dev/nvme0n1", model = "Fixture SSD",
|
||||
kind = "ssd", health = "passed", smart_available = true,
|
||||
temperature_c = temperature, hotspot_temperature_c = temperature, remaining_life_percent = 95,
|
||||
available_spare_percent = 100, critical_warning = 0,
|
||||
media_errors = 0, reallocated_sectors = 0, pending_sectors = 0,
|
||||
uncorrectable_errors = 0, spin_retry_count = 0, command_timeout_count = 0,
|
||||
interface_crc_errors = 0, unsafe_shutdowns = 12, error_log_entries = 5893,
|
||||
smart_completeness = "full", alerts_enabled = true, presence_alert_enabled = true,
|
||||
self_test_state = "passed",
|
||||
}
|
||||
end
|
||||
|
||||
local function snapshot(disk, collectionId)
|
||||
return {
|
||||
collection_id = collectionId,
|
||||
disks = disk ~= nil and { disk } or {},
|
||||
dependencies = { ready = true, missing = {}, missing_text = "", signature = "" },
|
||||
summary = { ssd_count = disk ~= nil and 1 or 0 },
|
||||
}
|
||||
end
|
||||
|
||||
local function findIssue(kind)
|
||||
for _, issue in ipairs(state.snapshot.issues or {}) do
|
||||
if issue.kind == kind then
|
||||
return issue
|
||||
end
|
||||
end
|
||||
return nil
|
||||
end
|
||||
|
||||
publish(snapshot(drive(45)))
|
||||
assert(#state.snapshot.issues == 0, "healthy drive produced an alert")
|
||||
assert(#notifications == 0, "healthy baseline or legacy counter cleanup produced a notification")
|
||||
assert(#stateWrites == 1 and #stateRenames == 1 and successfulStateCommits == 1,
|
||||
"first healthy baseline was not persisted as one atomic commit")
|
||||
|
||||
publish(snapshot(drive(45)))
|
||||
onConfigChanged()
|
||||
assert(#stateWrites == 1 and #stateRenames == 1,
|
||||
"identical or config-only processing rewrote alert state")
|
||||
|
||||
local writesBeforeNewAlert = #stateWrites
|
||||
publish(snapshot(drive(70)))
|
||||
assert(#state.snapshot.issues == 1 and state.snapshot.issues[1].kind == "temperature", "warning temperature was missed")
|
||||
assert(#notifications == 1 and notifications[1].severity == "warning", "warning notification was not sent")
|
||||
assert(#stateWrites == writesBeforeNewAlert + 1 and #stateRenames == writesBeforeNewAlert + 1,
|
||||
"new alert was not persisted as exactly one atomic commit")
|
||||
|
||||
publish(snapshot(drive(70)))
|
||||
assert(#notifications == 1, "unchanged warning notification was duplicated")
|
||||
assert(#stateWrites == writesBeforeNewAlert + 1,
|
||||
"unchanged active alert rewrote alert state")
|
||||
|
||||
publish(snapshot(drive(63)))
|
||||
assert(#state.snapshot.issues == 1 and state.snapshot.issues[1].message == "alerts.temperature_cooling",
|
||||
"temperature hysteresis displayed a contradictory threshold message")
|
||||
assert(#notifications == 1, "cooling hysteresis duplicated its warning notification")
|
||||
|
||||
publish(snapshot(drive(85)))
|
||||
assert(state.snapshot.issues[1].severity == "critical", "critical escalation was missed")
|
||||
assert(#notifications == 2 and notifications[2].severity == "critical", "critical escalation did not notify")
|
||||
|
||||
local writesBeforeRecovery = #stateWrites
|
||||
publish(snapshot(drive(60)))
|
||||
assert(#state.snapshot.issues == 0, "temperature recovery did not clear")
|
||||
assert(#notifications == 3 and notifications[3].severity == "warning", "recovery did not notify")
|
||||
assert(#stateWrites == writesBeforeRecovery + 1 and #stateRenames == writesBeforeRecovery + 1,
|
||||
"alert recovery was not persisted as exactly one atomic commit")
|
||||
|
||||
publish(snapshot(drive(70)))
|
||||
assert(#notifications == 4, "recurring temperature issue did not notify")
|
||||
publish(snapshot(drive(45)))
|
||||
assert(#notifications == 5, "temperature recovery did not notify")
|
||||
|
||||
local retiredFixture = drive(45)
|
||||
retiredFixture.presence_alert_enabled = false
|
||||
publish(snapshot(retiredFixture))
|
||||
|
||||
local hddIssue = drive(35)
|
||||
hddIssue.id = "HDD-SERIAL"
|
||||
hddIssue.kind = "hdd"
|
||||
hddIssue.remaining_life_percent = nil
|
||||
hddIssue.presence_alert_enabled = false
|
||||
hddIssue.interface_crc_errors = 2
|
||||
local notificationsBeforeHddBaseline = #notifications
|
||||
publish(snapshot(hddIssue))
|
||||
assert(#state.snapshot.issues == 0 and #notifications == notificationsBeforeHddBaseline,
|
||||
"historical HDD interface CRC errors produced a false alert")
|
||||
hddIssue.interface_crc_errors = 3
|
||||
publish(snapshot(hddIssue))
|
||||
assert(#state.snapshot.issues == 0, "new HDD interface CRC errors produced a persistent issue")
|
||||
assert(#notifications == notificationsBeforeHddBaseline + 1
|
||||
and notifications[#notifications].severity == "warning"
|
||||
and notifications[#notifications].body:match("count=1"),
|
||||
"new HDD interface CRC error did not produce one delta notification")
|
||||
publish(snapshot(hddIssue))
|
||||
assert(#notifications == notificationsBeforeHddBaseline + 1,
|
||||
"unchanged HDD interface CRC total duplicated its notification")
|
||||
hddIssue.interface_crc_errors = 0
|
||||
publish(snapshot(hddIssue))
|
||||
assert(#notifications == notificationsBeforeHddBaseline + 1,
|
||||
"decreased HDD interface CRC total produced a notification")
|
||||
|
||||
local historicalErrors = drive(45)
|
||||
historicalErrors.id = "HISTORICAL-SERIAL"
|
||||
historicalErrors.presence_alert_enabled = false
|
||||
historicalErrors.media_errors = 8
|
||||
historicalErrors.reallocated_sectors = 4
|
||||
historicalErrors.pending_sectors = 2
|
||||
historicalErrors.uncorrectable_errors = 3
|
||||
historicalErrors.spin_retry_count = 1
|
||||
historicalErrors.command_timeout_count = 6
|
||||
historicalErrors.interface_crc_errors = 9
|
||||
historicalErrors.error_log_entries = 20
|
||||
local notificationsBeforeHistoricalBaseline = #notifications
|
||||
publish(snapshot(historicalErrors))
|
||||
assert(#state.snapshot.issues == 0 and #notifications == notificationsBeforeHistoricalBaseline,
|
||||
"first observation of historical SMART counters produced false alerts")
|
||||
|
||||
historicalErrors.uncorrectable_errors = 5
|
||||
historicalErrors.error_log_entries = 21
|
||||
historicalErrors.interface_crc_errors = 11
|
||||
publish(snapshot(historicalErrors))
|
||||
assert(#state.snapshot.issues == 0, "counter deltas produced persistent drive issues")
|
||||
assert(#notifications == notificationsBeforeHistoricalBaseline + 3,
|
||||
"new SMART counter values did not produce exactly one notification per increase")
|
||||
assert(notifications[notificationsBeforeHistoricalBaseline + 1].severity == "critical"
|
||||
and notifications[notificationsBeforeHistoricalBaseline + 1].body:match("count=2"),
|
||||
"uncorrectable-error delta notification was incorrect")
|
||||
assert(notifications[notificationsBeforeHistoricalBaseline + 2].severity == "warning"
|
||||
and notifications[notificationsBeforeHistoricalBaseline + 2].body:match("count=2"),
|
||||
"interface CRC delta notification was incorrect")
|
||||
assert(notifications[notificationsBeforeHistoricalBaseline + 3].severity == "warning"
|
||||
and notifications[notificationsBeforeHistoricalBaseline + 3].body:match("count=1"),
|
||||
"error-log delta notification was incorrect")
|
||||
publish(snapshot(historicalErrors))
|
||||
local unchangedCounterNotifications = {}
|
||||
for index = notificationsBeforeHistoricalBaseline + 1, #notifications do
|
||||
table.insert(unchangedCounterNotifications, notifications[index].body)
|
||||
end
|
||||
assert(#notifications == notificationsBeforeHistoricalBaseline + 3,
|
||||
"unchanged SMART counter values duplicated notifications: " .. table.concat(unchangedCounterNotifications, ", "))
|
||||
publish(snapshot(drive(45)))
|
||||
|
||||
local hotspot = drive(45)
|
||||
hotspot.hotspot_temperature_c = 70
|
||||
publish(snapshot(hotspot))
|
||||
assert(state.snapshot.issues[1].kind == "temperature", "NVMe hotspot warning was missed")
|
||||
publish(snapshot(drive(45)))
|
||||
|
||||
local customThreshold = drive(55)
|
||||
customThreshold.warning_temperature = 50
|
||||
customThreshold.critical_temperature = 70
|
||||
publish(snapshot(customThreshold))
|
||||
assert(state.snapshot.issues[1].kind == "temperature" and state.snapshot.issues[1].severity == "warning",
|
||||
"per-drive temperature threshold was ignored")
|
||||
publish(snapshot(drive(45)))
|
||||
|
||||
local partial = drive(45)
|
||||
partial.smart_completeness = "partial"
|
||||
publish(snapshot(partial))
|
||||
assert(#state.snapshot.issues == 0, "healthy partial SMART data produced an alert")
|
||||
publish(snapshot(drive(45)))
|
||||
|
||||
local unavailable = drive(45)
|
||||
unavailable.smart_available = false
|
||||
publish(snapshot(unavailable))
|
||||
assert(#state.snapshot.issues == 0,
|
||||
"Basic mode produced a false SMART-unavailable warning")
|
||||
fullSmartEnabled = true
|
||||
local notificationsBeforeUnavailable = #notifications
|
||||
local writesBeforeUnavailable = #stateWrites
|
||||
publish(snapshot(unavailable, "smart-unavailable-transient"))
|
||||
assert(#state.snapshot.issues == 0 and #notifications == notificationsBeforeUnavailable,
|
||||
"a single transient SMART read failure produced an alert")
|
||||
assert(#stateWrites == writesBeforeUnavailable + 1,
|
||||
"the first unavailable SMART scan did not persist its pending state")
|
||||
publish(snapshot(unavailable, "smart-unavailable-transient"))
|
||||
onConfigChanged()
|
||||
assert(#state.snapshot.issues == 0 and #notifications == notificationsBeforeUnavailable,
|
||||
"reprocessing one unavailable SMART snapshot advanced its grace period")
|
||||
assert(#stateWrites == writesBeforeUnavailable + 1,
|
||||
"reprocessing one unavailable SMART snapshot rewrote pending state")
|
||||
publish(snapshot(drive(45), "smart-available-reset"))
|
||||
assert(#state.snapshot.issues == 0 and #notifications == notificationsBeforeUnavailable,
|
||||
"transient SMART availability recovery produced a notification")
|
||||
|
||||
publish(snapshot(unavailable, "smart-unavailable-1"))
|
||||
publish(snapshot(unavailable, "smart-unavailable-2"))
|
||||
assert(#state.snapshot.issues == 0 and #notifications == notificationsBeforeUnavailable,
|
||||
"SMART unavailability alerted before three completed scans")
|
||||
publish(snapshot(unavailable, "smart-unavailable-3"))
|
||||
assert(#state.snapshot.issues == 1 and state.snapshot.issues[1].kind == "smart-unavailable",
|
||||
"sustained SMART unavailability did not alert after three completed scans")
|
||||
assert(#notifications == notificationsBeforeUnavailable + 1,
|
||||
"sustained SMART unavailability did not produce exactly one notification")
|
||||
local writesAfterConfirmedUnavailable = #stateWrites
|
||||
publish(snapshot(unavailable, "smart-unavailable-4"))
|
||||
assert(#notifications == notificationsBeforeUnavailable + 1,
|
||||
"continued SMART unavailability duplicated its notification")
|
||||
assert(#stateWrites == writesAfterConfirmedUnavailable,
|
||||
"confirmed SMART unavailability rewrote stable alert state")
|
||||
local sleeping = drive(45)
|
||||
sleeping.smart_available = false
|
||||
sleeping.smart_sleeping = true
|
||||
publish(snapshot(sleeping, "smart-sleeping"))
|
||||
assert(#state.snapshot.issues == 0, "sleeping drive produced a SMART-unavailable warning")
|
||||
assert(#notifications == notificationsBeforeUnavailable + 2,
|
||||
"confirmed SMART-unavailable recovery did not notify exactly once")
|
||||
fullSmartEnabled = false
|
||||
publish(snapshot(drive(45)))
|
||||
|
||||
local selfTestFailure = drive(45)
|
||||
selfTestFailure.self_test_state = "failed"
|
||||
selfTestFailure.self_test_status = "Completed with read failure"
|
||||
publish(snapshot(selfTestFailure))
|
||||
assert(state.snapshot.issues[1].kind == "self-test" and state.snapshot.issues[1].severity == "critical",
|
||||
"self-test failure was missed")
|
||||
publish(snapshot(drive(45)))
|
||||
|
||||
local counterIncrease = drive(45)
|
||||
counterIncrease.unsafe_shutdowns = 13
|
||||
local writesBeforeCounterIncrease = #stateWrites
|
||||
local notificationsBeforeCounterIncrease = #notifications
|
||||
publish(snapshot(counterIncrease))
|
||||
assert(#stateWrites == writesBeforeCounterIncrease + 1
|
||||
and #stateRenames == writesBeforeCounterIncrease + 1,
|
||||
"counter increase was not persisted as exactly one atomic commit")
|
||||
assert(#notifications == notificationsBeforeCounterIncrease + 1,
|
||||
"counter increase notification behavior changed")
|
||||
publish(snapshot(counterIncrease))
|
||||
assert(#stateWrites == writesBeforeCounterIncrease + 1,
|
||||
"unchanged diagnostic counter rewrote alert state")
|
||||
publish(snapshot(drive(45)))
|
||||
|
||||
publish(snapshot(drive(45), "scan-100"))
|
||||
|
||||
local firstMissingScan = snapshot(nil, "scan-101")
|
||||
local writesBeforeMissingScan = #stateWrites
|
||||
publish(firstMissingScan)
|
||||
assert(findIssue("drive-missing") == nil, "missing drive alerted before the grace period")
|
||||
assert(#stateWrites == writesBeforeMissingScan + 1
|
||||
and #stateRenames == writesBeforeMissingScan + 1,
|
||||
"missing-drive inventory change was not persisted as exactly one atomic commit")
|
||||
publish(firstMissingScan)
|
||||
assert(findIssue("drive-missing") == nil, "reprocessing one snapshot advanced the grace period")
|
||||
assert(#stateWrites == writesBeforeMissingScan + 1,
|
||||
"reprocessing one collection rewrote unchanged inventory state")
|
||||
publish(snapshot(nil, "scan-101"))
|
||||
assert(findIssue("drive-missing") == nil, "a repeated collection ID advanced the grace period")
|
||||
assert(#stateWrites == writesBeforeMissingScan + 1,
|
||||
"a repeated collection ID rewrote unchanged inventory state")
|
||||
|
||||
onConfigChanged()
|
||||
assert(findIssue("drive-missing") == nil, "a config refresh advanced the grace period")
|
||||
assert(#stateWrites == writesBeforeMissingScan + 1,
|
||||
"config refresh rewrote unchanged missing-drive state")
|
||||
local collectingSnapshot = snapshot(nil, "scan-collecting")
|
||||
collectingSnapshot.collecting = true
|
||||
publish(collectingSnapshot)
|
||||
assert(findIssue("drive-missing") == nil, "an in-progress collection advanced the grace period")
|
||||
|
||||
local failedMissingScan = snapshot(nil, "scan-failed")
|
||||
failedMissingScan.collector_error = "fixture failure"
|
||||
publish(failedMissingScan)
|
||||
assert(findIssue("drive-missing") == nil, "a failed collection advanced the grace period")
|
||||
|
||||
local blockedMissingScan = snapshot(nil, "scan-blocked")
|
||||
blockedMissingScan.dependencies = {
|
||||
ready = false, blocking = true,
|
||||
missing = { "lsblk (lsblk)" }, missing_text = "lsblk (lsblk)", signature = "lsblk",
|
||||
}
|
||||
publish(blockedMissingScan)
|
||||
assert(findIssue("drive-missing") == nil, "a blocked collection advanced the grace period")
|
||||
|
||||
publish(snapshot(nil, "scan-102"))
|
||||
assert(findIssue("drive-missing") == nil, "missing drive alerted after only two completed scans")
|
||||
publish(snapshot(nil, "scan-103"))
|
||||
assert(findIssue("drive-missing") ~= nil, "three unique completed scans did not trigger a missing-drive alert")
|
||||
publish(snapshot(nil, "scan-103"))
|
||||
assert(findIssue("drive-missing") ~= nil, "reprocessing a snapshot removed an active missing-drive alert")
|
||||
|
||||
local writesBeforeReappearance = #stateWrites
|
||||
publish(snapshot(drive(45), "scan-104"))
|
||||
assert(findIssue("drive-missing") == nil, "drive reappearance did not clear its missing alert")
|
||||
assert(#stateWrites == writesBeforeReappearance + 1
|
||||
and #stateRenames == writesBeforeReappearance + 1,
|
||||
"drive reappearance was not persisted as exactly one atomic commit")
|
||||
publish(snapshot(nil, "scan-105"))
|
||||
publish(snapshot(nil, "scan-106"))
|
||||
assert(findIssue("drive-missing") == nil, "reappearance did not reset the missing-drive grace period")
|
||||
publish(snapshot(nil, "scan-107"))
|
||||
assert(findIssue("drive-missing") ~= nil, "three new scans after reappearance did not trigger an alert")
|
||||
|
||||
publish(snapshot(drive(45), "scan-108"))
|
||||
local legacyMissingScan = snapshot(nil)
|
||||
publish(legacyMissingScan)
|
||||
publish(legacyMissingScan)
|
||||
assert(findIssue("drive-missing") == nil, "a repeated legacy snapshot advanced the grace period")
|
||||
publish(snapshot(nil))
|
||||
assert(findIssue("drive-missing") == nil, "two legacy snapshot objects triggered an early alert")
|
||||
publish(snapshot(nil))
|
||||
assert(findIssue("drive-missing") ~= nil, "distinct legacy snapshot objects did not advance the grace period")
|
||||
|
||||
local missing = snapshot(nil)
|
||||
missing.dependencies = {
|
||||
ready = false, blocking = true,
|
||||
missing = { "lsblk (lsblk)" }, missing_text = "lsblk (lsblk)", signature = "lsblk",
|
||||
}
|
||||
local notificationCountBeforeDependency = #notifications
|
||||
publish(missing)
|
||||
assert(findIssue("missing-dependencies") ~= nil, "dependency issue was missed")
|
||||
assert(findIssue("drive-missing") ~= nil, "a blocking dependency removed an active missing-drive alert")
|
||||
local dependencyCritical = false
|
||||
for index = notificationCountBeforeDependency + 1, #notifications do
|
||||
if notifications[index].severity == "critical" then dependencyCritical = true end
|
||||
end
|
||||
assert(dependencyCritical, "blocking dependency was not critical")
|
||||
|
||||
local failed = snapshot(nil)
|
||||
failed.collector_error = "fixture failure"
|
||||
local notificationCountBeforeFailure = #notifications
|
||||
publish(failed)
|
||||
assert(findIssue("collector-error") ~= nil, "collector issue was missed")
|
||||
assert(findIssue("drive-missing") ~= nil, "a collector failure removed an active missing-drive alert")
|
||||
local collectorCritical = false
|
||||
for index = notificationCountBeforeFailure + 1, #notifications do
|
||||
if notifications[index].severity == "critical" then
|
||||
collectorCritical = true
|
||||
end
|
||||
end
|
||||
assert(collectorCritical, "collector failure did not notify critically")
|
||||
|
||||
local writesBeforeWriteFailure = #stateWrites
|
||||
local renamesBeforeWriteFailure = #stateRenames
|
||||
local commitsBeforeWriteFailure = successfulStateCommits
|
||||
failNextStateWrite = true
|
||||
dismiss({ id = "collector:error", nonce = 8 })
|
||||
assert(#stateWrites == writesBeforeWriteFailure + 1
|
||||
and #stateRenames == renamesBeforeWriteFailure
|
||||
and successfulStateCommits == commitsBeforeWriteFailure,
|
||||
"failed temporary write attempted a rename or lost the pending state")
|
||||
publish(failed)
|
||||
assert(#stateWrites == writesBeforeWriteFailure + 2
|
||||
and #stateRenames == renamesBeforeWriteFailure + 1
|
||||
and successfulStateCommits == commitsBeforeWriteFailure + 1,
|
||||
"unchanged snapshot did not retry a failed alert-state write")
|
||||
publish(failed)
|
||||
assert(#stateWrites == writesBeforeWriteFailure + 2,
|
||||
"successful write retry did not clear dirty alert state")
|
||||
|
||||
local writesBeforeRenameFailure = #stateWrites
|
||||
local renamesBeforeRenameFailure = #stateRenames
|
||||
local commitsBeforeRenameFailure = successfulStateCommits
|
||||
failNextStateRename = true
|
||||
dismiss({ all = true, nonce = 9 })
|
||||
assert(#state.snapshot.issues == 0, "dismiss all did not hide every active issue")
|
||||
assert(#stateWrites == writesBeforeRenameFailure + 1
|
||||
and #stateRenames == renamesBeforeRenameFailure + 1
|
||||
and successfulStateCommits == commitsBeforeRenameFailure,
|
||||
"failed atomic rename was treated as a successful commit")
|
||||
onConfigChanged()
|
||||
assert(#stateWrites == writesBeforeRenameFailure + 2
|
||||
and #stateRenames == renamesBeforeRenameFailure + 2
|
||||
and successfulStateCommits == commitsBeforeRenameFailure + 1,
|
||||
"unchanged processing did not retry a failed atomic rename")
|
||||
onConfigChanged()
|
||||
assert(#stateWrites == writesBeforeRenameFailure + 2,
|
||||
"successful rename retry did not clear dirty alert state")
|
||||
|
||||
publish(snapshot(drive(45), "scan-dismissal-baseline"))
|
||||
publish(snapshot(drive(70), "scan-dismissal-warning"))
|
||||
assert(findIssue("temperature") ~= nil, "permanent-dismissal fixture did not create an alert")
|
||||
local notificationsBeforeDismissal = #notifications
|
||||
local writesBeforeDismissal = #stateWrites
|
||||
local renamesBeforeDismissal = #stateRenames
|
||||
dismiss({ id = "SERIAL1:temperature", nonce = 10 })
|
||||
assert(findIssue("temperature") == nil, "individual dismissal did not hide the active issue")
|
||||
assert(#notifications == notificationsBeforeDismissal, "dismissing an issue emitted a notification")
|
||||
assert(#stateWrites == writesBeforeDismissal + 1 and #stateRenames == renamesBeforeDismissal + 1,
|
||||
"dismissal was not persisted as exactly one atomic commit")
|
||||
publish(snapshot(drive(85), "scan-dismissal-critical"))
|
||||
assert(findIssue("temperature") == nil and #notifications == notificationsBeforeDismissal,
|
||||
"a dismissed alert returned or notified after escalating")
|
||||
publish(snapshot(drive(45), "scan-dismissal-recovery"))
|
||||
publish(snapshot(drive(70), "scan-dismissal-recurrence"))
|
||||
assert(findIssue("temperature") == nil and #notifications == notificationsBeforeDismissal,
|
||||
"a dismissed alert returned or notified after recurring")
|
||||
|
||||
for _, path in ipairs(stateWrites) do
|
||||
assert(path:match("alert%-state%.json%.tmp$"),
|
||||
"alert state bypassed its temporary file")
|
||||
end
|
||||
for _, rename in ipairs(stateRenames) do
|
||||
assert(rename.from:match("alert%-state%.json%.tmp$")
|
||||
and rename.to:match("alert%-state%.json$"),
|
||||
"alert state was not committed with an atomic rename")
|
||||
end
|
||||
|
||||
print("alert behavior tests passed")
|
||||
@@ -0,0 +1,590 @@
|
||||
-- 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.0",
|
||||
collection_id = "fixture-collection-id",
|
||||
generated_at_epoch = 1700000000,
|
||||
lsblk = { blockdevices = {} },
|
||||
smart = {},
|
||||
}
|
||||
|
||||
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
|
||||
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 == "sudo pacman -S --needed 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 == "sudo pacman -S --needed 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.0"
|
||||
and snapshot.system_collector.expected_version == "2.0.0",
|
||||
"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.0",
|
||||
"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 = "nvme9n1", kname = "nvme9n1", path = "/dev/nvme9n1", type = "disk",
|
||||
tran = "nvme", rota = false, size = 2000000000, model = "Fixture NVMe", serial = "FIXTURE1",
|
||||
mountpoints = {}, children = {
|
||||
{ name = "nvme9n1p1", kname = "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")
|
||||
|
||||
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 = "nvme0n1", kname = "nvme0n1", path = "/dev/nvme0n1", type = "disk",
|
||||
tran = "nvme", rota = false, serial = "SHARED", children = {} },
|
||||
{ name = "nvme0n2", kname = "nvme0n2", path = "/dev/nvme0n2", type = "disk",
|
||||
tran = "nvme", rota = false, serial = "SHARED", children = {} },
|
||||
} }, smart = {},
|
||||
}, "test"))
|
||||
assert(namespaces.disks[1].id ~= namespaces.disks[2].id
|
||||
and namespaces.disks[1].id:match(":n%d+$") and namespaces.disks[2].id:match(":n%d+$"),
|
||||
"NVMe namespaces sharing a controller serial did not receive unique 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"
|
||||
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")
|
||||
+18
@@ -0,0 +1,18 @@
|
||||
#!/bin/sh
|
||||
set -eu
|
||||
|
||||
case " $* " in
|
||||
*" --nodeps "*)
|
||||
printf '%s\n' "/dev/sda disk 1" "/dev/nvme0n1 disk 0"
|
||||
;;
|
||||
*" --json "*)
|
||||
printf '%s\n' '{"blockdevices":[' \
|
||||
'{"name":"sda","kname":"sda","path":"/dev/sda","type":"disk","tran":"sata","rota":true,"size":1000000000,"model":"Fixture SATA","serial":"SATA1","mountpoints":[]},' \
|
||||
'{"name":"nvme0n1","kname":"nvme0n1","path":"/dev/nvme0n1","type":"disk","tran":"nvme","rota":false,"size":2000000000,"model":"Fixture NVMe","serial":"NVME1","mountpoints":[]}' \
|
||||
']}'
|
||||
;;
|
||||
*)
|
||||
echo "unexpected lsblk arguments: $*" >&2
|
||||
exit 2
|
||||
;;
|
||||
esac
|
||||
+27
@@ -0,0 +1,27 @@
|
||||
#!/bin/sh
|
||||
set -eu
|
||||
|
||||
device=""
|
||||
standby=false
|
||||
for argument in "$@"; do
|
||||
device=$argument
|
||||
if [ "$argument" = "--nocheck=standby,0" ]; then
|
||||
standby=true
|
||||
fi
|
||||
done
|
||||
|
||||
case "$device" in
|
||||
/dev/sda)
|
||||
if [ "${SMARTCTL_EMPTY:-0}" = "1" ]; then
|
||||
exit 2
|
||||
fi
|
||||
printf '%s\n' "{\"json_format_version\":[1,0],\"device\":{\"name\":\"/dev/sda\"},\"smart_status\":{\"passed\":true},\"temperature\":{\"current\":35},\"test_standby\":$standby}"
|
||||
;;
|
||||
/dev/nvme0)
|
||||
printf '%s\n' "{\"json_format_version\":[1,0],\"device\":{\"name\":\"/dev/nvme0\"},\"smart_status\":{\"passed\":true},\"temperature\":{\"current\":42},\"nvme_smart_health_information_log\":{\"percentage_used\":2,\"available_spare\":100,\"critical_warning\":0},\"test_standby\":$standby}"
|
||||
;;
|
||||
*)
|
||||
echo "unexpected smartctl device: $device" >&2
|
||||
exit 2
|
||||
;;
|
||||
esac
|
||||
@@ -0,0 +1,69 @@
|
||||
-- Behavioral tests for bounded trend persistence.
|
||||
|
||||
local state = {}
|
||||
local watchers = {}
|
||||
local writes = 0
|
||||
local renames = 0
|
||||
local writesSucceed = true
|
||||
|
||||
noctalia = {
|
||||
getConfig = function(key)
|
||||
if key == "history_interval_minutes" then return 15 end
|
||||
if key == "history_retention_days" then return 1 end
|
||||
return nil
|
||||
end,
|
||||
pluginDataDir = function() return "/mock/plugin-data" end,
|
||||
readFile = function(_path) return nil end,
|
||||
writeFile = function(_path, _contents) writes = writes + 1 return writesSucceed end,
|
||||
renameFile = function(_from, _to) renames = renames + 1 return true end,
|
||||
log = function(_message) end,
|
||||
state = {
|
||||
get = function(key) return state[key] end,
|
||||
set = function(key, value) state[key] = value end,
|
||||
watch = function(key, callback) watchers[key] = callback end,
|
||||
},
|
||||
json = {
|
||||
decode = function(_raw) return nil end,
|
||||
encode = function(_value, _pretty) return "{}" end,
|
||||
},
|
||||
}
|
||||
|
||||
local handle = assert(io.open("history.luau", "rb"))
|
||||
local source = handle:read("*a")
|
||||
handle:close()
|
||||
assert(load(source, "@history.luau"))()
|
||||
|
||||
local publish = assert(watchers.snapshot, "history service did not watch snapshots")
|
||||
local function snapshot(epoch, hotspot, life)
|
||||
return {
|
||||
generated_at_epoch = epoch,
|
||||
disks = { {
|
||||
id = "SERIAL1", model = "Fixture SSD", display_name = "Fixture SSD", kind = "ssd",
|
||||
temperature_c = hotspot - 5, hotspot_temperature_c = hotspot,
|
||||
remaining_life_percent = life, storage_usage_percent = 25, data_written_bytes = 1000,
|
||||
} },
|
||||
}
|
||||
end
|
||||
|
||||
publish(snapshot(100000, 60, 99))
|
||||
local samples = state.drive_history.drives.SERIAL1.samples
|
||||
assert(#samples == 1 and samples[1].hotspot_temperature_c == 60, "first history sample was not recorded")
|
||||
assert(writes == 1 and renames == 1, "history was not committed atomically")
|
||||
|
||||
publish(snapshot(100100, 61, 99))
|
||||
assert(#state.drive_history.drives.SERIAL1.samples == 1, "history ignored its sample interval")
|
||||
assert(writes == 1, "history rewrote the file without a new sample")
|
||||
|
||||
publish(snapshot(100901, 62, 98))
|
||||
samples = state.drive_history.drives.SERIAL1.samples
|
||||
assert(#samples == 2 and samples[2].remaining_life_percent == 98, "scheduled history sample was missed")
|
||||
assert(writes == 2 and renames == 2, "second history sample was not committed")
|
||||
|
||||
writesSucceed = false
|
||||
publish(snapshot(101802, 63, 97))
|
||||
assert(writes == 3 and renames == 2, "failed history write was not exercised")
|
||||
writesSucceed = true
|
||||
publish(snapshot(101900, 63, 97))
|
||||
assert(writes == 4 and renames == 3, "dirty history was not retried after a transient write failure")
|
||||
|
||||
print("history behavior tests passed")
|
||||
@@ -0,0 +1,404 @@
|
||||
-- Declarative panel smoke tests with a minimal Noctalia/UI host.
|
||||
|
||||
local state = {}
|
||||
local rendered = nil
|
||||
local terminalCommand = nil
|
||||
local asyncCommand = nil
|
||||
local asyncCallback = nil
|
||||
local notifications = {}
|
||||
local errors = {}
|
||||
local writesSucceed = true
|
||||
local configValues = {
|
||||
warning_temperature = 65,
|
||||
critical_temperature = 80,
|
||||
show_hdd = true,
|
||||
use_hotspot_temperature = true,
|
||||
system_collector_enabled = true,
|
||||
}
|
||||
|
||||
local function translate(key, substitutions)
|
||||
if key == "metrics.mounted_at" then
|
||||
return key .. " " .. tostring(substitutions and substitutions.paths or "")
|
||||
elseif key == "metrics.serial" then
|
||||
return key .. " " .. tostring(substitutions and substitutions.value or "")
|
||||
end
|
||||
local value = key
|
||||
for name, replacement in pairs(substitutions or {}) do
|
||||
value = value:gsub("{" .. name .. "}", tostring(replacement))
|
||||
end
|
||||
return value
|
||||
end
|
||||
|
||||
local function node(kind, props, children)
|
||||
return { kind = kind, props = props or {}, children = children or {} }
|
||||
end
|
||||
|
||||
ui = setmetatable({}, {
|
||||
__index = function(_table, kind)
|
||||
return function(props, children) return node(kind, props, children) end
|
||||
end,
|
||||
})
|
||||
|
||||
panel = {
|
||||
render = function(tree) rendered = tree end,
|
||||
close = function() end,
|
||||
}
|
||||
|
||||
local watchers = {}
|
||||
noctalia = {
|
||||
getConfig = function(key)
|
||||
return configValues[key]
|
||||
end,
|
||||
tr = translate,
|
||||
pluginDataDir = function() return "/mock/plugin-data" end,
|
||||
writeFile = function(_path, _contents) return writesSucceed end,
|
||||
renameFile = function(_from, _to) return true end,
|
||||
notify = function(title, body) table.insert(notifications, { title = title, body = body }) end,
|
||||
notifyError = function(title, body) table.insert(errors, { title = title, body = body }) end,
|
||||
runInTerminal = function(command) terminalCommand = command return true end,
|
||||
runAsync = function(command, callback, _timeout)
|
||||
asyncCommand = command
|
||||
asyncCallback = callback
|
||||
return true
|
||||
end,
|
||||
copyToClipboard = function(_text, _mime) return true end,
|
||||
string = { trim = function(value) return tostring(value):match("^%s*(.-)%s*$") end },
|
||||
json = { encode = function(_value, _pretty) return "{}" end },
|
||||
state = {
|
||||
get = function(key) return state[key] end,
|
||||
set = function(key, value) state[key] = value end,
|
||||
watch = function(key, callback) watchers[key] = callback end,
|
||||
},
|
||||
}
|
||||
|
||||
state.snapshot = {
|
||||
generated_at_local = "12:00:00",
|
||||
collector_error = nil,
|
||||
dependencies = { ready = true },
|
||||
system_collector = { enabled = true, installed = true, status = "healthy", version = "1.0.0",
|
||||
expected_version = "1.0.0", helper_available = true, authorization_available = true,
|
||||
enable_command = "sudo systemctl enable --now noctalia-drive-health.timer",
|
||||
disable_command = "sudo systemctl disable --now noctalia-drive-health.timer",
|
||||
install_command = "sudo '/mock/plugin/packaging/install-system-collector.sh'",
|
||||
uninstall_command = "sudo '/mock/plugin/packaging/uninstall-system-collector.sh'" },
|
||||
summary = { disk_count = 2, ssd_count = 1, hdd_count = 1, smart_available_count = 2,
|
||||
ssd_smart_available_count = 1, hottest_drive_temperature_c = 70,
|
||||
hottest_drive_name = "Fixture SSD", hottest_ssd_temperature_c = 70,
|
||||
hottest_ssd_drive_name = "Fixture SSD", worst_ssd_remaining_life_percent = 95,
|
||||
worst_ssd_life_drive_name = "Fixture SSD" },
|
||||
issues = {},
|
||||
disks = { {
|
||||
id = "SERIAL1", serial = "SERIAL1", model = "Fixture SSD", display_name = "Fixture SSD", device = "/dev/nvme0n1",
|
||||
smart_device = "/dev/nvme0", kind = "ssd", transport = "nvme", capacity_bytes = 2000000000,
|
||||
health = "passed", smart_available = true, smart_completeness = "full",
|
||||
temperature_c = 45, hotspot_temperature_c = 70, temperature_sensors_c = { 70, 45 },
|
||||
remaining_life_percent = 95, percentage_used = 5, available_spare_percent = 100,
|
||||
power_on_hours = 100, data_written_bytes = 1000, self_test_supported = true,
|
||||
self_test_state = "running", self_test_status = "Short self-test in progress",
|
||||
self_test_completion_percent = 37,
|
||||
mount_points = { "/", "/home/example" },
|
||||
alerts_enabled = true, presence_alert_enabled = true,
|
||||
}, {
|
||||
id = "HDD1", model = "Fixture HDD", display_name = "Fixture HDD", device = "/dev/sdb",
|
||||
smart_device = "/dev/sdb", kind = "hdd", transport = "sata", capacity_bytes = 2000000000000,
|
||||
health = "passed", smart_available = true, smart_completeness = "full",
|
||||
temperature_c = 36, hotspot_temperature_c = 36, power_on_hours = 113397, power_cycles = 2200,
|
||||
start_stop_count = 1423, load_cycle_count = 18421, reallocated_sectors = 0,
|
||||
pending_sectors = 0, uncorrectable_errors = 0, spin_retry_count = 0,
|
||||
command_timeout_count = 0, interface_crc_errors = 0, self_test_supported = true,
|
||||
self_test_state = "passed", self_test_status = "Completed without error",
|
||||
alerts_enabled = true, presence_alert_enabled = true,
|
||||
} },
|
||||
}
|
||||
state.drive_history = { schema = 1, drives = { SERIAL1 = { samples = {
|
||||
{ epoch = 1, hotspot_temperature_c = 65, remaining_life_percent = 96 },
|
||||
{ epoch = 2, hotspot_temperature_c = 67, remaining_life_percent = 96 },
|
||||
{ epoch = 3, hotspot_temperature_c = 69, remaining_life_percent = 95 },
|
||||
{ epoch = 4, hotspot_temperature_c = 70, remaining_life_percent = 95 },
|
||||
} } } }
|
||||
state.drive_preferences = { schema = 1, order = {}, drives = {} }
|
||||
|
||||
local handle = assert(io.open("panel.luau", "rb"))
|
||||
local source = handle:read("*a")
|
||||
handle:close()
|
||||
source = source:gsub("([%a_][%w_]*) %+%= ([^\n]+)", "%1 = %1 + %2")
|
||||
source = source:gsub("([%a_][%w_]*) /%= ([^\n]+)", "%1 = %1 / %2")
|
||||
assert(load(source, "@panel.luau"))()
|
||||
|
||||
local function containsText(value, target)
|
||||
if type(value) ~= "table" then return false end
|
||||
if type(value.props) == "table" and value.props.text == target then return true end
|
||||
for _, child in pairs(value.children or {}) do
|
||||
if containsText(child, target) then return true end
|
||||
end
|
||||
return false
|
||||
end
|
||||
|
||||
local function countText(value, target)
|
||||
if type(value) ~= "table" then return 0 end
|
||||
local count = type(value.props) == "table" and value.props.text == target and 1 or 0
|
||||
for _, child in pairs(value.children or {}) do
|
||||
count = count + countText(child, target)
|
||||
end
|
||||
return count
|
||||
end
|
||||
|
||||
local function findNode(value, kind)
|
||||
if type(value) ~= "table" then return nil end
|
||||
if value.kind == kind then return value end
|
||||
for _, child in pairs(value.children or {}) do
|
||||
local found = findNode(child, kind)
|
||||
if found ~= nil then return found end
|
||||
end
|
||||
return nil
|
||||
end
|
||||
|
||||
local function findNodeWithProp(value, kind, property, expected)
|
||||
if type(value) ~= "table" then return nil end
|
||||
if value.kind == kind and type(value.props) == "table" and value.props[property] == expected then
|
||||
return value
|
||||
end
|
||||
for _, child in pairs(value.children or {}) do
|
||||
local found = findNodeWithProp(child, kind, property, expected)
|
||||
if found ~= nil then return found end
|
||||
end
|
||||
return nil
|
||||
end
|
||||
|
||||
onOpen({})
|
||||
assert(rendered ~= nil and not containsText(rendered, "collector.title"),
|
||||
"healthy collector consumed panel space")
|
||||
assert(findNodeWithProp(rendered, "glyph", "name", "server-2") ~= nil,
|
||||
"panel header did not use the physical-storage icon")
|
||||
assert(countText(rendered, "Fixture SSD") >= 3,
|
||||
"summary cards did not identify the hottest and lowest-life drives")
|
||||
assert(not containsText(rendered, "metrics.mounted_at / · /home/example"),
|
||||
"collapsed drive card exposed mount paths")
|
||||
onToggleCollectorSettingsClicked()
|
||||
assert(containsText(rendered, "collector.settings_title")
|
||||
and containsText(rendered, "collector.basic_features")
|
||||
and containsText(rendered, "collector.full_features"),
|
||||
"collector settings did not explain Basic and Full SMART capabilities")
|
||||
onPauseCollectorClicked()
|
||||
assert(terminalCommand == "sudo systemctl disable --now noctalia-drive-health.timer",
|
||||
"collector settings did not expose the explicit service pause command")
|
||||
terminalCommand = nil
|
||||
onOpenPluginSettingsClicked()
|
||||
assert(asyncCommand == "noctalia msg settings-open plugins",
|
||||
"collector settings did not open Noctalia's Plugins section")
|
||||
onToggleCollectorSettingsClicked()
|
||||
state.snapshot.system_collector.status = "upgrade-required"
|
||||
state.snapshot.system_collector.version = "0.6.0"
|
||||
watchers.snapshot(state.snapshot)
|
||||
assert(containsText(rendered, "collector.title"), "actionable collector state did not render")
|
||||
onToggleCollectorSettingsClicked()
|
||||
assert(not containsText(rendered, "collector.title") and containsText(rendered, "collector.settings_title"),
|
||||
"collector settings duplicated the actionable lifecycle card")
|
||||
onToggleCollectorSettingsClicked()
|
||||
state.snapshot.system_collector.status = "healthy"
|
||||
state.snapshot.system_collector.version = "1.0.0"
|
||||
watchers.snapshot(state.snapshot)
|
||||
configValues.system_collector_enabled = false
|
||||
state.snapshot.system_collector.enabled = false
|
||||
state.snapshot.system_collector.status = "disabled"
|
||||
state.snapshot.system_collector.helper_available = false
|
||||
watchers.snapshot(state.snapshot)
|
||||
assert(not containsText(rendered, "collector.title"),
|
||||
"disabled optional collector created a persistent main-panel warning")
|
||||
onToggleCollectorSettingsClicked()
|
||||
assert(containsText(rendered, "collector.status_disabled")
|
||||
and containsText(rendered, "collector.open_settings"),
|
||||
"disabled collector status or re-enable route was missing from collector settings")
|
||||
onToggleCollectorSettingsClicked()
|
||||
configValues.system_collector_enabled = true
|
||||
state.snapshot.system_collector.enabled = true
|
||||
state.snapshot.system_collector.status = "healthy"
|
||||
state.snapshot.system_collector.helper_available = true
|
||||
watchers.snapshot(state.snapshot)
|
||||
onDrive1Clicked()
|
||||
assert(containsText(rendered, "self_test.title"), "expanded self-test card did not render")
|
||||
assert(containsText(rendered, "metrics.mounted_at / · /home/example"),
|
||||
"expanded drive card omitted its mounted folders")
|
||||
assert(containsText(rendered, "metrics.serial SERIAL1"),
|
||||
"expanded drive card omitted its serial")
|
||||
assert(containsText(rendered, "self_test.progress"), "running self-test progress did not render")
|
||||
state.snapshot.disks[1].smart_completeness = "partial"
|
||||
watchers.snapshot(state.snapshot)
|
||||
assert(containsText(rendered, "smart.partial_details"), "partial SMART status lost its inline explanation")
|
||||
state.snapshot.disks[1].smart_completeness = "full"
|
||||
watchers.snapshot(state.snapshot)
|
||||
local testProgress = assert(findNodeWithProp(rendered, "progress", "progress", 0.37),
|
||||
"running self-test progress bar did not render")
|
||||
assert(testProgress.props.progress == 0.37 and testProgress.props.value == nil,
|
||||
"running self-test used an invalid progress property")
|
||||
assert(containsText(rendered, "history.title"), "expanded history graph did not render")
|
||||
assert(containsText(rendered, "preferences.edit"), "drive preference action did not render")
|
||||
onDrive1Clicked()
|
||||
assert(not containsText(rendered, "self_test.title"), "drive details did not collapse")
|
||||
assert(not containsText(rendered, "history.title"), "drive history remained visible after collapse")
|
||||
onDrive1Clicked()
|
||||
state.snapshot.disks[1].self_test_state = "passed"
|
||||
state.snapshot.disks[1].self_test_status = "Previous test passed"
|
||||
state.snapshot.disks[1].self_test_completion_percent = nil
|
||||
state.snapshot.generated_at_epoch = 100
|
||||
watchers.snapshot(state.snapshot)
|
||||
onStartShortSelfTestClicked()
|
||||
assert(containsText(rendered, "self_test.confirm_action"), "self-test confirmation did not render")
|
||||
assert(terminalCommand == nil, "self-test started before confirmation")
|
||||
onConfirmSelfTestClicked()
|
||||
assert(asyncCommand:match("^pkexec /usr/local/libexec/noctalia%-drive%-health/smart%-action%.sh 'short' '/dev/nvme0'$"),
|
||||
"self-test did not use Polkit, the fixed helper, and normalized controller")
|
||||
assert(terminalCommand == nil, "background self-test opened a terminal")
|
||||
assert(containsText(rendered, "self_test.authorizing"), "authorization state did not render")
|
||||
assert(asyncCallback ~= nil, "background self-test callback was not registered")
|
||||
-- Bit 3 is an existing SMART health finding; it must not hide an accepted test request.
|
||||
asyncCallback({ exitCode = 8, stdout = "accepted", stderr = "", timedOut = false })
|
||||
assert(containsText(rendered, "self_test.starting"), "accepted self-test did not render startup state")
|
||||
assert(state.refresh_nonce == 1, "accepted self-test did not request an immediate SMART refresh")
|
||||
assert(notifications[#notifications].body == "self_test.started_background",
|
||||
"accepted self-test did not notify that it is running in the background")
|
||||
|
||||
state.snapshot.generated_at_epoch = 101
|
||||
state.snapshot.disks[1].self_test_state = "running"
|
||||
state.snapshot.disks[1].self_test_status = "Short self-test in progress"
|
||||
state.snapshot.disks[1].self_test_completion_percent = 52
|
||||
watchers.snapshot(state.snapshot)
|
||||
assert(containsText(rendered, "Short self-test in progress"), "firmware self-test state did not replace startup state")
|
||||
assert(findNodeWithProp(rendered, "progress", "progress", 0.52) ~= nil,
|
||||
"background self-test progress did not update")
|
||||
|
||||
state.snapshot.generated_at_epoch = 102
|
||||
state.snapshot.disks[1].self_test_state = "passed"
|
||||
state.snapshot.disks[1].self_test_status = "Completed without error"
|
||||
state.snapshot.disks[1].self_test_completion_percent = nil
|
||||
watchers.snapshot(state.snapshot)
|
||||
assert(containsText(rendered, "Completed without error"), "completed background self-test result did not render")
|
||||
|
||||
asyncCommand = nil
|
||||
asyncCallback = nil
|
||||
onStartLongSelfTestClicked()
|
||||
onConfirmSelfTestClicked()
|
||||
assert(asyncCommand:match("smart%-action%.sh 'long' '/dev/nvme0'"),
|
||||
"extended self-test did not use the long action")
|
||||
asyncCallback({ exitCode = 126, stdout = "", stderr = "", timedOut = false })
|
||||
assert(containsText(rendered, "self_test.authorization_cancelled"),
|
||||
"cancelled authorization did not render a useful inline result")
|
||||
assert(errors[#errors].body == "self_test.authorization_cancelled",
|
||||
"cancelled authorization did not produce an error notification")
|
||||
|
||||
state.snapshot.system_collector.authorization_available = false
|
||||
watchers.snapshot(state.snapshot)
|
||||
assert(containsText(rendered, "self_test.authorization_required"),
|
||||
"missing Polkit dependency was not explained")
|
||||
state.snapshot.system_collector.authorization_available = true
|
||||
watchers.snapshot(state.snapshot)
|
||||
onEditExpandedDriveClicked()
|
||||
assert(containsText(rendered, "preferences.title"), "drive preference editor did not render")
|
||||
onDriveAlertsChanged(false)
|
||||
onPresenceAlertsChanged(false)
|
||||
onCancelDrivePreferencesClicked()
|
||||
local cancelled = state.drive_preferences.drives.SERIAL1
|
||||
assert(cancelled.alerts_enabled == nil and cancelled.presence_alert_enabled == nil,
|
||||
"cancelled alert preference changes leaked into shared state")
|
||||
onEditExpandedDriveClicked()
|
||||
onAliasChanged("Workspace")
|
||||
onWarningThresholdChanged("68")
|
||||
onCriticalThresholdChanged("82")
|
||||
onLifeThresholdChanged("15")
|
||||
onSaveDrivePreferencesClicked()
|
||||
local saved = state.drive_preferences.drives.SERIAL1
|
||||
assert(saved.alias == "Workspace" and saved.warning_temperature == 68
|
||||
and saved.critical_temperature == 82 and saved.life_warning_percent == 15,
|
||||
"drive preferences were not persisted to shared state")
|
||||
|
||||
onEditExpandedDriveClicked()
|
||||
onAliasChanged("Should not persist")
|
||||
writesSucceed = false
|
||||
onSaveDrivePreferencesClicked()
|
||||
writesSucceed = true
|
||||
assert(saved.alias == "Workspace", "failed preference write leaked changes into shared state")
|
||||
onCancelDrivePreferencesClicked()
|
||||
|
||||
onEditExpandedDriveClicked()
|
||||
writesSucceed = false
|
||||
onHideDriveClicked()
|
||||
writesSucceed = true
|
||||
assert(saved.hidden == nil and containsText(rendered, "preferences.title"),
|
||||
"failed hide write changed visibility or closed the editor")
|
||||
onCancelDrivePreferencesClicked()
|
||||
|
||||
state.drive_history.drives.SERIAL1.samples = {
|
||||
{ epoch = 1, hotspot_temperature_c = 65 },
|
||||
{ epoch = 2, hotspot_temperature_c = 67 },
|
||||
{ epoch = 3, hotspot_temperature_c = 69 },
|
||||
}
|
||||
watchers.drive_history(state.drive_history)
|
||||
assert(findNode(rendered, "graph") == nil and not containsText(rendered, "history.title"),
|
||||
"trend section rendered before a graph-compatible series had four samples")
|
||||
state.drive_history.drives.SERIAL1.samples = {
|
||||
{ epoch = 1, hotspot_temperature_c = 65 },
|
||||
{ epoch = 2, hotspot_temperature_c = 67 },
|
||||
{ epoch = 3, hotspot_temperature_c = 69 },
|
||||
{ epoch = 4, hotspot_temperature_c = 70 },
|
||||
}
|
||||
watchers.drive_history(state.drive_history)
|
||||
local graph = assert(findNode(rendered, "graph"), "temperature-only history graph did not render")
|
||||
assert(graph.props.values2 == nil, "missing endurance history was rendered as a zero-percent series")
|
||||
assert(graph.props.height == 44, "rendered trend graph did not use the compact height")
|
||||
assert(not containsText(rendered, "● history.life"), "missing endurance history kept a misleading legend")
|
||||
|
||||
state.snapshot.issues = {
|
||||
{ id = "SERIAL1:temperature", severity = "warning", message = "Fixture temperature warning" },
|
||||
{ id = "SERIAL1:interface-crc", severity = "warning", message = "Fixture interface CRC warning" },
|
||||
}
|
||||
state.snapshot.summary.active_alert_count = 2
|
||||
watchers.snapshot(state.snapshot)
|
||||
assert(containsText(rendered, "alerts.dismiss_all"), "dismiss-all alert action did not render")
|
||||
assert(findNodeWithProp(rendered, "button", "tooltip", "alerts.dismiss") ~= nil,
|
||||
"per-alert dismiss action did not render")
|
||||
onDismissAlert1Clicked()
|
||||
assert(state.dismiss_alert_request.id == "SERIAL1:temperature",
|
||||
"per-alert dismiss action targeted the wrong issue")
|
||||
onDismissAllAlertsClicked()
|
||||
assert(state.dismiss_alert_request.all == true, "dismiss-all action did not request all active issues")
|
||||
|
||||
state.snapshot.issues = {}
|
||||
state.snapshot.summary.active_alert_count = 0
|
||||
watchers.snapshot(state.snapshot)
|
||||
assert(not containsText(rendered, "alerts.active_title")
|
||||
and findNodeWithProp(rendered, "button", "tooltip", "alerts.dismiss") == nil,
|
||||
"empty alert state kept an alert card or dismiss controls")
|
||||
|
||||
onDrive2Clicked()
|
||||
assert(containsText(rendered, "metrics.start_stop_count")
|
||||
and containsText(rendered, "metrics.load_cycle_count")
|
||||
and containsText(rendered, "metrics.interface_crc_errors"),
|
||||
"expanded HDD card omitted mechanical or interface health details")
|
||||
assert(countText(rendered, "metrics.life_remaining") == 1,
|
||||
"HDD card rendered a meaningless SSD endurance metric")
|
||||
onEditExpandedDriveClicked()
|
||||
assert(not containsText(rendered, "preferences.life_warning"),
|
||||
"HDD preference editor exposed an SSD-only endurance threshold")
|
||||
onCancelDrivePreferencesClicked()
|
||||
|
||||
state.snapshot.dependencies = {
|
||||
ready = false, blocking = true, missing_text = "lsblk (util-linux)",
|
||||
install_command = "sudo pacman -S --needed util-linux", package_manager = "pacman", can_install = true,
|
||||
}
|
||||
watchers.snapshot(state.snapshot)
|
||||
assert(containsText(rendered, "dependencies.title"), "missing dependency card did not render")
|
||||
|
||||
state.snapshot.dependencies = { ready = true }
|
||||
state.snapshot.disks = { state.snapshot.disks[2] }
|
||||
state.snapshot.summary = {
|
||||
disk_count = 1, ssd_count = 0, hdd_count = 1, smart_available_count = 1,
|
||||
hdd_smart_available_count = 1, hottest_drive_temperature_c = 36,
|
||||
hottest_drive_name = "Fixture HDD",
|
||||
}
|
||||
watchers.snapshot(state.snapshot)
|
||||
assert(not containsText(rendered, "summary.lowest_ssd_life"),
|
||||
"HDD-only system rendered the SSD-life summary card")
|
||||
assert(countText(rendered, "Fixture HDD") >= 2,
|
||||
"HDD-only temperature summary did not identify its drive")
|
||||
|
||||
print("panel rendering tests passed")
|
||||
Executable
+47
@@ -0,0 +1,47 @@
|
||||
#!/bin/sh
|
||||
set -eu
|
||||
|
||||
project_dir=$(CDPATH='' cd -- "$(dirname -- "$0")/.." && pwd)
|
||||
fixture_bin="$project_dir/tests/fixtures/bin"
|
||||
|
||||
payload=$(PATH="$fixture_bin:$PATH" sh "$project_dir/scripts/collect_raw.sh")
|
||||
printf '%s\n' "$payload" | jq -e '
|
||||
.schema == 2
|
||||
and .collector_version == "2.0.0"
|
||||
and (.collection_id | type == "string" and length > 0)
|
||||
and (.lsblk.blockdevices | length) == 2
|
||||
and (.smart | length) == 2
|
||||
and ([.smart[].requested_device] | sort) == ["/dev/nvme0", "/dev/sda"]
|
||||
and (.smart[] | select(.requested_device == "/dev/sda") | .payload.test_standby) == true
|
||||
and (.smart[] | select(.requested_device == "/dev/nvme0") | .payload.test_standby) == false
|
||||
and ([.smart[].exit_code] | all(. == 0))
|
||||
' >/dev/null
|
||||
|
||||
first_collection_id=$(printf '%s\n' "$payload" | jq -er '.collection_id')
|
||||
second_payload=$(PATH="$fixture_bin:$PATH" sh "$project_dir/scripts/collect_raw.sh")
|
||||
second_collection_id=$(printf '%s\n' "$second_payload" | jq -er '.collection_id')
|
||||
if [ "$first_collection_id" = "$second_collection_id" ]; then
|
||||
echo "raw collector reused a collection ID" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
empty_payload=$(SMARTCTL_EMPTY=1 PATH="$fixture_bin:$PATH" sh "$project_dir/scripts/collect_raw.sh")
|
||||
printf '%s\n' "$empty_payload" | jq -e '
|
||||
(.smart | length) == 2
|
||||
and (.smart[] | select(.requested_device == "/dev/sda") | .exit_code) == 2
|
||||
and (.smart[] | select(.requested_device == "/dev/sda")
|
||||
| .payload.smartctl.messages[0].string) == "smartctl produced no JSON output"
|
||||
' >/dev/null
|
||||
|
||||
output=$(mktemp "${TMPDIR:-/tmp}/noctalia-smart-raw-test.XXXXXX")
|
||||
PATH="$fixture_bin:$PATH" sh "$project_dir/scripts/collect_raw.sh" --output "$output"
|
||||
jq -e '.schema == 2 and (.collection_id | type == "string" and length > 0)
|
||||
and (.smart | length) == 2' "$output" >/dev/null
|
||||
mode=$(stat -c '%a' "$output")
|
||||
if [ "$mode" != "640" ]; then
|
||||
echo "raw collector output mode is $mode, expected 640" >&2
|
||||
exit 1
|
||||
fi
|
||||
rm -f -- "$output"
|
||||
|
||||
echo "raw collector tests passed"
|
||||
@@ -0,0 +1,58 @@
|
||||
#!/bin/sh
|
||||
set -eu
|
||||
|
||||
project_dir=$(CDPATH='' cd -- "$(dirname -- "$0")/.." && pwd)
|
||||
service_template="$project_dir/packaging/noctalia-drive-health.service.in"
|
||||
timer="$project_dir/packaging/noctalia-drive-health.timer"
|
||||
fixture=$(mktemp -d "${TMPDIR:-/tmp}/drive-health-packaging.XXXXXX")
|
||||
trap 'rm -rf -- "$fixture"' EXIT HUP INT TERM
|
||||
|
||||
sed 's/@TARGET_GID@/1000/g' "$service_template" >"$fixture/noctalia-drive-health.service"
|
||||
cp "$timer" "$fixture/noctalia-drive-health.timer"
|
||||
|
||||
grep -q '^Group=1000$' "$fixture/noctalia-drive-health.service"
|
||||
grep -q '^RuntimeDirectoryMode=0750$' "$fixture/noctalia-drive-health.service"
|
||||
grep -q '^UMask=0027$' "$fixture/noctalia-drive-health.service"
|
||||
grep -q '^Unit=noctalia-drive-health.service$' "$fixture/noctalia-drive-health.timer"
|
||||
|
||||
if grep -R -q 'noctalia-smart-monito[r]' "$project_dir"; then
|
||||
echo "generic legacy collector namespace must not be read, modified, or removed" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if grep -R -q 'noctalia-gustav0ar-drive-healt[h]' "$project_dir"; then
|
||||
echo "publisher-specific collector namespace must not be packaged" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
declared_dependencies=$(sed -n 's/^dependencies = \[\(.*\)\]$/\1/p' "$project_dir/plugin.toml")
|
||||
for dependency in \
|
||||
lsblk smartctl sh date dirname mkdir mktemp rm sed cat chmod mv sudo env bash \
|
||||
install systemctl pkexec id tr pacman apt-get dnf zypper apk xbps-install emerge; do
|
||||
case "$declared_dependencies" in
|
||||
*\"$dependency\"*) ;;
|
||||
*)
|
||||
echo "runtime command is missing from plugin.toml dependencies: $dependency" >&2
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
grep -q "\`$dependency\`" "$project_dir/README.md" || {
|
||||
echo "runtime command is missing from README requirements: $dependency" >&2
|
||||
exit 1
|
||||
}
|
||||
done
|
||||
|
||||
if command -v systemd-analyze >/dev/null 2>&1; then
|
||||
if ! systemd-analyze verify \
|
||||
"$fixture/noctalia-drive-health.service" \
|
||||
"$fixture/noctalia-drive-health.timer" >"$fixture/verify.log" 2>&1; then
|
||||
if grep -q 'Operation not permitted' "$fixture/verify.log"; then
|
||||
echo "systemd unit verification unavailable in this sandbox; structural checks passed"
|
||||
else
|
||||
cat "$fixture/verify.log" >&2
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
|
||||
echo "collector packaging tests passed"
|
||||
@@ -0,0 +1,109 @@
|
||||
-- Declarative bar-widget smoke tests with a minimal Noctalia host.
|
||||
|
||||
local state = {}
|
||||
local watchers = {}
|
||||
local rendered = nil
|
||||
local tooltip = nil
|
||||
local toggledPanel = nil
|
||||
|
||||
local function node(kind, props, children)
|
||||
return { kind = kind, props = props or {}, children = children or {} }
|
||||
end
|
||||
|
||||
ui = setmetatable({}, {
|
||||
__index = function(_table, kind)
|
||||
return function(props, children) return node(kind, props, children) end
|
||||
end,
|
||||
})
|
||||
|
||||
barWidget = {
|
||||
render = function(tree) rendered = tree end,
|
||||
setTooltip = function(value) tooltip = value end,
|
||||
isVertical = function() return false end,
|
||||
}
|
||||
|
||||
noctalia = {
|
||||
getConfig = function(key)
|
||||
local values = { warning_temperature = 65, critical_temperature = 50 }
|
||||
return values[key]
|
||||
end,
|
||||
tr = function(key, substitutions)
|
||||
local value = key
|
||||
for name, replacement in pairs(substitutions or {}) do
|
||||
value = value:gsub("{" .. name .. "}", tostring(replacement))
|
||||
end
|
||||
return value
|
||||
end,
|
||||
togglePanel = function(id) toggledPanel = id end,
|
||||
state = {
|
||||
get = function(key) return state[key] end,
|
||||
watch = function(key, callback) watchers[key] = callback end,
|
||||
},
|
||||
}
|
||||
|
||||
state.snapshot = {
|
||||
summary = {
|
||||
disk_count = 3,
|
||||
ssd_count = 2,
|
||||
hdd_count = 1,
|
||||
hottest_drive_temperature_c = 60,
|
||||
hottest_ssd_temperature_c = 60,
|
||||
worst_ssd_remaining_life_percent = 90,
|
||||
ssd_smart_unavailable_count = 0,
|
||||
ssd_unhealthy_count = 0,
|
||||
smart_unavailable_count = 0,
|
||||
unhealthy_count = 0,
|
||||
active_alert_count = 0,
|
||||
critical_alert_count = 0,
|
||||
},
|
||||
issues = {},
|
||||
}
|
||||
|
||||
local handle = assert(io.open("widget.luau", "rb"))
|
||||
local source = handle:read("*a")
|
||||
handle:close()
|
||||
source = source:gsub("([%a_][%w_]*) %.%.= ([^\n]+)", "%1 = %1 .. %2")
|
||||
assert(load(source, "@widget.luau"))()
|
||||
|
||||
local function findNodeWithProp(value, kind, property, expected)
|
||||
if type(value) ~= "table" then return nil end
|
||||
if value.kind == kind and type(value.props) == "table" and value.props[property] == expected then
|
||||
return value
|
||||
end
|
||||
for _, child in pairs(value.children or {}) do
|
||||
local found = findNodeWithProp(child, kind, property, expected)
|
||||
if found ~= nil then return found end
|
||||
end
|
||||
return nil
|
||||
end
|
||||
|
||||
update()
|
||||
assert(rendered ~= nil and tooltip:find("widget.no_alerts", 1, true), "healthy widget did not render")
|
||||
assert(findNodeWithProp(rendered, "glyph", "name", "server-2") ~= nil,
|
||||
"healthy widget did not use the physical-storage icon")
|
||||
assert(findNodeWithProp(rendered, "glyph", "color", "primary") ~= nil,
|
||||
"invalid cross-setting temperature thresholds produced a false critical state")
|
||||
|
||||
state.snapshot.summary.hottest_drive_temperature_c = 70
|
||||
watchers.snapshot(state.snapshot)
|
||||
assert(findNodeWithProp(rendered, "glyph", "color", "error") ~= nil,
|
||||
"HDD temperature was excluded from the mixed-drive widget state")
|
||||
state.snapshot.summary.hottest_drive_temperature_c = 60
|
||||
state.snapshot.summary.unhealthy_count = 1
|
||||
watchers.snapshot(state.snapshot)
|
||||
assert(findNodeWithProp(rendered, "glyph", "color", "error") ~= nil,
|
||||
"unhealthy HDD was excluded from the mixed-drive widget state")
|
||||
state.snapshot.summary.unhealthy_count = 0
|
||||
|
||||
state.snapshot.summary.active_alert_count = 1
|
||||
state.snapshot.summary.critical_alert_count = 1
|
||||
state.snapshot.issues = { { message = "Fixture failure", severity = "critical" } }
|
||||
watchers.snapshot(state.snapshot)
|
||||
assert(findNodeWithProp(rendered, "glyph", "color", "error") ~= nil,
|
||||
"critical widget alert did not render")
|
||||
assert(tooltip:find("Fixture failure", 1, true), "widget tooltip omitted active alert details")
|
||||
|
||||
onClick()
|
||||
assert(toggledPanel == "gustav0ar/drive-health:drives", "widget click did not toggle its panel")
|
||||
|
||||
print("widget rendering tests passed")
|
||||
Reference in New Issue
Block a user