* Stop closed network panels from leaving Wi-Fi scanning enabled refresh() defaults scanWifi to false and its no-scan branch enabled the scanner unconditionally. Five paths reach it with no panel on screen — Component.onCompleted, clearNetworkAction(), failNetworkAction(), the band-change actionProc exit, and the 30s actionTimeout — so the scanner stayed on and Quickshell kept re-arming RequestScan behind a closed panel. scanRestart had the mirror gap: it enabled the scanner 100ms after refresh(true) without re-checking that the panel was still open. Every sweep takes the radio off the operating channel, so this degraded the link it was scanning from: one sweep every 17s, gateway RTT rising from ~2ms to repeated 150ms+ spikes on an otherwise idle connection. Gate the scanner block on the panel being open, cancel a pending restart on close and re-check the panel when it fires, and track the WifiDevice this instance enabled so close, device replacement and destruction release the right object. Destruction matters on its own: a bar reload with the panel open would otherwise die with opened still true and never write scannerEnabled = false. * Cover the scanner ownership helper's own invariants The previous assertion only pinned that no write bypasses setScannerEnabled(); it said nothing about what the helper does. Dropping either the opened gate or the release-before-adopt from the helper still passed, while a closed instance could reclaim scanning and a device swap could leave the previous interface scanning. Run the helper's actual JavaScript against stand-in devices instead, following the extract-and-eval pattern the agents panel tests already use. Removing either invariant now fails its own assertion.
228 lines
13 KiB
Bash
228 lines
13 KiB
Bash
#!/bin/bash
|
||
|
||
set -euo pipefail
|
||
|
||
source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/base-test.sh"
|
||
|
||
run_node_test <<'JS'
|
||
const fs = require('fs')
|
||
const network = requireFromRoot('shell/plugins/panels/network/Model.js')
|
||
const panelSource = fs.readFileSync(root + '/shell/plugins/panels/network/Panel.qml', 'utf8')
|
||
|
||
assert(/IpcHandler[\s\S]*?function toggleNetwork\(\) \{ root\.toggleNetwork\(\) \}/.test(panelSource), 'network exposes the Wi-Fi radio toggle over IPC')
|
||
assert(/manageIpc: false/.test(panelSource), 'network owns its IPC handler so it can extend the target methods')
|
||
|
||
// Opening from the bar must call open() and nothing else. open() runs
|
||
// refresh(true), which defers the PHY scan; a second bare refresh() defaults
|
||
// scanWifi to false, sets scannerEnabled synchronously, and stalls the open on
|
||
// NetworkManager's access-point flood.
|
||
const barPress = panelSource.match(/onPressed: function\(b\) \{[\s\S]*?\n {4}\}/)
|
||
assert(barPress, 'network bar button has an onPressed handler')
|
||
const barPressCode = barPress[0].replace(/\/\/.*$/gm, '')
|
||
assert(!/refresh\(/.test(barPressCode), 'network bar click opens the panel without a second refresh that would undo the deferred scan')
|
||
|
||
// A closed panel has no nearby-network list to fill. Quickshell's scanner
|
||
// re-arms RequestScan on its own timer, and every sweep takes the radio off
|
||
// the operating channel, so a scanner left enabled behind a closed panel keeps
|
||
// degrading the connection it is scanning from.
|
||
const refreshFn = panelSource.match(/function refresh\(scanWifi\)[\s\S]*?\n {2}\}/)
|
||
assert(refreshFn, 'network has a refresh() function')
|
||
assert(/if \(opened && wifiDevice\)/.test(refreshFn[0]), 'network only touches the scanner from refresh() while its panel is open')
|
||
|
||
// The 100ms deferral can outlive the panel: closing inside the window would
|
||
// otherwise re-enable scanning from a timer nobody is watching.
|
||
const scanRestart = panelSource.match(/id: scanRestart[\s\S]*?onTriggered: \{[\s\S]*?\n {4}\}/)
|
||
assert(scanRestart, 'network has the deferred scan restart timer')
|
||
assert(/root\.opened/.test(scanRestart[0]), 'network re-checks the panel before the deferred restart re-enables scanning')
|
||
assert(/scanRestart\.stop\(\)/.test(panelSource), 'network cancels a pending scan restart when the panel closes')
|
||
|
||
// scannerEnabled lives on a shared WifiDevice with no reference counting, so
|
||
// the panel has to own what it enabled. Run the helper's own JavaScript against
|
||
// stand-in devices: the two invariants it carries are that a closed panel never
|
||
// takes a device, and that adopting a new one releases the previous.
|
||
const scannerHelper = panelSource.match(/function setScannerEnabled\(enabled\) \{[\s\S]*?\n {2}\}/)
|
||
assert(scannerHelper, 'network has a scanner ownership helper')
|
||
|
||
var opened = false
|
||
var wifiDevice = { scannerEnabled: false }
|
||
var scannerDevice = null
|
||
eval(scannerHelper[0])
|
||
|
||
setScannerEnabled(true)
|
||
assert(
|
||
scannerDevice === null && wifiDevice.scannerEnabled === false,
|
||
'network does not let a closed panel claim or enable a scanner device'
|
||
)
|
||
|
||
var previousScannerDevice = { scannerEnabled: true }
|
||
var replacementScannerDevice = { scannerEnabled: false }
|
||
opened = true
|
||
scannerDevice = previousScannerDevice
|
||
wifiDevice = replacementScannerDevice
|
||
setScannerEnabled(true)
|
||
assert(
|
||
previousScannerDevice.scannerEnabled === false &&
|
||
scannerDevice === replacementScannerDevice &&
|
||
replacementScannerDevice.scannerEnabled === true,
|
||
'network releases the previous scanner device before enabling its replacement'
|
||
)
|
||
|
||
// Destruction is the case a guard-only fix misses: the widget dies with the
|
||
// panel still open, as a bar reload does, and nothing else would release it.
|
||
assert(
|
||
/Component\.onDestruction[\s\S]{0,140}scannerDevice\.scannerEnabled = false/.test(panelSource),
|
||
'network releases the scanner it owns when the widget is destroyed'
|
||
)
|
||
assert(!/wifiDevice\.scannerEnabled\s*=/.test(panelSource), 'network writes scanner state through its owned device reference rather than the moving wifiDevice reference')
|
||
|
||
// A row is a primitive snapshot that can outlive its WifiNetwork, and
|
||
// disconnect() falls back to the live connection when handed null, so row
|
||
// activation must go through the guarded disconnectRow().
|
||
assert(
|
||
/function disconnectRow\(ssid\) \{\s*var network = networkForSsid\(ssid\)\s*if \(network\) disconnect\(network\)/.test(panelSource),
|
||
'network guards row disconnects so a stale row cannot drop an unrelated connection'
|
||
)
|
||
assert(!/disconnect\(\s*(root\.)?networkForSsid\(/.test(panelSource), 'network never passes an unguarded networkForSsid() lookup to disconnect()')
|
||
|
||
assertDeepEqual(
|
||
network.parseNetworkStatus('wifi\tCafe WiFi\t78\t5200\n'),
|
||
{ kind: 'wifi', label: 'Cafe WiFi', signalStrength: 78, frequency: '5200' },
|
||
'network parses bar status'
|
||
)
|
||
assertEqual(network.connectionIcon('wifi', 80), network.wifiIconFor(80), 'network maps wifi icon from signal')
|
||
assertEqual(network.formatHeaderSpeed('1000'), '1gbit', 'network formats gigabit speed')
|
||
assertEqual(network.formatHeaderSpeed('2500'), '2.5gbit', 'network formats fractional gigabit speed')
|
||
assertEqual(network.formatHeaderFreq('2462'), '2.4ghz', 'network formats 2.4GHz wifi band')
|
||
assertEqual(network.formatHeaderFreq('5200'), '5ghz', 'network formats 5GHz wifi band')
|
||
assertEqual(network.formatHeaderFreq('6455.0'), '6ghz', 'network formats 6GHz wifi band')
|
||
assertEqual(network.formatHeaderFreq('18300'), '18.3ghz', 'network falls back to exact GHz for unknown bands')
|
||
assertEqual(network.headerDetail({ type: 'ethernet', speed: '100' }), '100mbit', 'network header uses ethernet speed')
|
||
|
||
assertDeepEqual(
|
||
network.parseKeyValue('iface\twlan0\nrx_bytes\t100\ntx_bytes\t50\n'),
|
||
{ iface: 'wlan0', rx_bytes: '100', tx_bytes: '50' },
|
||
'network parses detail key values'
|
||
)
|
||
assertEqual(network.decodeIwSsid('Cafe\\xe2\\x80\\x99'), 'Cafe’', 'network decodes UTF-8 SSID bytes')
|
||
assertEqual(network.decodeIwSsid('Smile \\xf0\\x9f\\x98\\x80'), 'Smile 😀', 'network decodes emoji SSID bytes')
|
||
assertEqual(network.decodeIwSsid('\\x20Cafe\\x20'), ' Cafe ', 'network preserves edge spaces in SSIDs')
|
||
assertEqual(network.decodeIwSsid('slash\\x5cname'), 'slash\\name', 'network decodes SSID backslashes once')
|
||
assertEqual(network.decodeIwSsid('invalid\\xff'), 'invalid\\xff', 'network preserves invalid UTF-8 escapes')
|
||
assertEqual(network.decodeIwSsid('already 😀'), 'already 😀', 'network safely preserves unexpected non-BMP input')
|
||
assertDeepEqual(
|
||
network.parseKeyValue('ssid\tline\\x0abreak\\x09tab\\x00nul\nsignal_dbm\t-40\n'),
|
||
{ ssid: 'line\\x0abreak\\x09tab\\x00nul', signal_dbm: '-40' },
|
||
'network leaves control-byte escapes safe for single-line display'
|
||
)
|
||
assertDeepEqual(
|
||
network.throughputState({ prevIface: '', prevSampleTime: 0 }, { iface: 'wlan0', rx_bytes: '100', tx_bytes: '50' }, 10),
|
||
{ prevIface: 'wlan0', prevRxBytes: 100, prevTxBytes: 50, prevSampleTime: 10, downloadRate: 0, uploadRate: 0 },
|
||
'network seeds throughput state on first sample'
|
||
)
|
||
assertDeepEqual(
|
||
network.throughputState({ prevIface: 'wlan0', prevRxBytes: 100, prevTxBytes: 50, prevSampleTime: 10 }, { iface: 'wlan0', rx_bytes: '300', tx_bytes: '90' }, 12),
|
||
{ prevIface: 'wlan0', prevRxBytes: 300, prevTxBytes: 90, prevSampleTime: 12, downloadRate: 100, uploadRate: 20 },
|
||
'network computes throughput deltas'
|
||
)
|
||
|
||
let ping = network.pingLatencyState(
|
||
{ pingIface: '', routerPingSamples: [], internetPingSamples: [] },
|
||
{ iface: 'wlan0', router_ping_ms: '2.0', internet_ping_ms: '20.0' },
|
||
4
|
||
)
|
||
assertDeepEqual(
|
||
ping,
|
||
{ pingIface: 'wlan0', routerPingSamples: [2], internetPingSamples: [20], routerPingLatency: 2, internetPingLatency: 20, internetPingPacketLoss: 0 },
|
||
'network seeds ping latency samples'
|
||
)
|
||
|
||
ping = network.pingLatencyState(ping, { iface: 'wlan0', router_ping_ms: '4.0', internet_ping_ms: '' }, 4)
|
||
assertDeepEqual(
|
||
ping,
|
||
{ pingIface: 'wlan0', routerPingSamples: [2, 4], internetPingSamples: [20, null], routerPingLatency: 3, internetPingLatency: 20, internetPingPacketLoss: 50 },
|
||
'network averages recent successful ping samples'
|
||
)
|
||
|
||
assertDeepEqual(
|
||
network.pingLatencyState(ping, { iface: 'eth0', router_ping_ms: '1.5', internet_ping_ms: '10.0' }, 4),
|
||
{ pingIface: 'eth0', routerPingSamples: [1.5], internetPingSamples: [10], routerPingLatency: 1.5, internetPingLatency: 10, internetPingPacketLoss: 0 },
|
||
'network resets ping samples when interface changes'
|
||
)
|
||
|
||
assertDeepEqual(
|
||
network.pingLatencyState(ping, { iface: 'wlan0', internet_ping_ms: '22.0' }, 4),
|
||
{ pingIface: 'wlan0', routerPingSamples: [], internetPingSamples: [20, null, 22], routerPingLatency: -1, internetPingLatency: 21, internetPingPacketLoss: 33 },
|
||
'network clears ping samples when a target is unavailable'
|
||
)
|
||
|
||
assertEqual(network.formatBytes(1536), '1.5 KB', 'network formats bytes')
|
||
assertEqual(network.formatRate(1536), '1.5 KB/s', 'network formats rates')
|
||
assertEqual(network.formatPingLatency('2.54'), '2.5 ms', 'network formats low ping with precision')
|
||
assertEqual(network.formatPingLatency('25.4'), '25 ms', 'network formats ping')
|
||
assertEqual(network.formatPingLatency(''), 'Timeout', 'network formats missing ping as timeout')
|
||
assertEqual(network.formatPingLatency(-1, false), '--', 'network holds the ping row before the first sample')
|
||
assertEqual(network.formatPingLatency('25.4', true), '25 ms', 'network formats ping once samples exist')
|
||
assertEqual(network.formatPingLatency('', true), 'Timeout', 'network still reports a timeout among real samples')
|
||
assertEqual(network.formatPacketLoss(2), '2%', 'network formats packet loss')
|
||
assertEqual(network.formatPacketLoss(0), '0%', 'network formats zero packet loss')
|
||
assertEqual(network.formatPacketLoss(0, false), '--', 'network holds the packet loss row before the first sample')
|
||
assertEqual(network.formatPacketLoss(0, true), '0%', 'network reports zero loss once samples exist')
|
||
|
||
const rows = network.sortWifiRows([
|
||
{ ssid: 'Open', connected: false, known: false, signal: 95 },
|
||
{ ssid: 'Known', connected: false, known: true, signal: 10 },
|
||
{ ssid: 'Connected', connected: true, known: true, signal: 20 }
|
||
])
|
||
assertDeepEqual(rows.map(row => row.ssid), ['Connected', 'Known', 'Open'], 'network sorts wifi rows by connection and known state')
|
||
assertEqual(network.wifiSectionTitle(rows, 0), 'KNOWN NETWORKS', 'network labels known wifi section')
|
||
assertEqual(network.wifiSectionTitle(rows, 2), 'OTHER NETWORKS', 'network labels other wifi section')
|
||
|
||
const wifiRow = network.wifiRow({ connected: true, known: true, name: 'Home', signalStrength: 0.8, security: 1 })
|
||
assertDeepEqual(
|
||
wifiRow,
|
||
{ connected: true, known: true, ssid: 'Home', signal: 80, security: 1 },
|
||
'network projects wifi rows with primitives so delegates never hold the live WifiNetwork object'
|
||
)
|
||
assertDeepEqual(
|
||
Object.keys(wifiRow).sort(),
|
||
['connected', 'known', 'security', 'signal', 'ssid'],
|
||
'network wifi rows project exactly the primitive fields, so each delegate stores no live QObject'
|
||
)
|
||
|
||
const reasons = { NoSecrets: 1, WifiAuthTimeout: 2, WifiNetworkLost: 3, WifiClientDisconnected: 4, WifiClientFailed: 5 }
|
||
assertEqual(network.networkFailureReason(1, reasons), 'Passphrase required', 'network maps missing passphrase failures')
|
||
assertEqual(network.networkFailureReason(2, reasons), 'Wrong password', 'network maps auth timeout failures')
|
||
assertEqual(network.networkFailureReason(99, reasons), 'Failed to connect', 'network maps unknown failures')
|
||
|
||
assertEqual(network.shouldRepromptPassphrase(reasons.NoSecrets, false, reasons), true, 'network reprompts when secrets are missing')
|
||
assertEqual(network.shouldRepromptPassphrase(reasons.WifiAuthTimeout, true, reasons), true, 'network reprompts a protected network after a wrong password')
|
||
assertEqual(network.shouldRepromptPassphrase(reasons.WifiAuthTimeout, false, reasons), false, 'network does not reprompt an open network on auth timeout')
|
||
assertEqual(network.shouldRepromptPassphrase(reasons.WifiClientFailed, true, reasons), false, 'network does not reprompt on generic connection failures')
|
||
|
||
|
||
assertEqual(network.bandLabel('2.4'), '2.4ghz', 'network labels the 2.4GHz band')
|
||
assertEqual(network.bandLabel('6'), '6ghz', 'network labels the 6GHz band')
|
||
assertEqual(network.bandLabel('auto'), 'Auto', 'network labels the automatic band choice')
|
||
|
||
assertEqual(network.bandSectionTitle('auto', '2.4'), 'WI-FI BAND: 2.4GHZ', 'network names the live band in the header under automatic')
|
||
assertEqual(network.bandSectionTitle('auto', ''), 'WI-FI BAND', 'network omits an unknown band from the header')
|
||
assertEqual(network.bandSectionTitle('5', '5'), 'WI-FI BAND', 'network drops the header band once the pills are showing')
|
||
assertEqual(network.bandSectionTitle('5', '2.4'), 'WI-FI BAND', 'network keeps a plain header while a pin is settling')
|
||
|
||
assertDeepEqual(
|
||
network.parseBandStatus('band\t5\navailable\t2.4 5 6\nselected\tauto\n'),
|
||
{ band: '5', selected: 'auto', available: ['2.4', '5', '6'] },
|
||
'network parses band status'
|
||
)
|
||
assertDeepEqual(
|
||
network.parseBandStatus(''),
|
||
{ band: '', selected: 'auto', available: [] },
|
||
'network parses empty band status without a wifi connection'
|
||
)
|
||
|
||
|
||
|
||
assertEqual(network.headerDetail({ type: 'wifi', freq: '5745' }), '', 'network keeps wifi band state out of the hero')
|
||
assertEqual(network.headerDetail({ type: 'ethernet', speed: '100' }), '100mbit', 'network keeps ethernet speed in the hero')
|
||
JS
|