Files
omarchycn/test/shell.d/bluetooth-test.sh
T
96bbe53634 Fix panel delegate segfault and the network panel's open stall (#6605)
* fix(network): drop the redundant rescan on the bar click

Opening from the bar ran open() and then a bare refresh(). open() already
triggers onOpenedChanged -> refresh(true), which defers the PHY scan by
disabling the scanner and re-enabling it from scanRestart. The bare
refresh() that followed defaults scanWifi to false, so it took the other
branch and set wifiDevice.scannerEnabled synchronously on the click frame,
undoing the deferral and stalling the open on NetworkManager's access-point
flood. It also double-started the DNS and band probes.

Co-Authored-By: shrijit <shrijitsrivastav@gmail.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(network): keep wifi rows QObject-free to prevent a delegate crash

wifiRow() embedded the WifiNetwork QObject in the row it returns, and those
rows are list-model data, so every delegate held a live QObject wrapper in a
var property. When NetworkManager churns the list -- a scan's access-point
flood, an AP disappearing -- the object can be destroyed while a delegate is
still incubating, and quickshell segfaults in QObjectWrapper::wrap_slowPath
on the dangling wrapper.

Project primitives only and resolve the backend object at action time via
the existing networkForSsid(). Both failNetworkAction() and
checkActionCompletion() already no-op on a null network, so a row whose
network has since vanished is handled the same way it was before.

Co-Authored-By: shrijit <shrijitsrivastav@gmail.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(bluetooth): keep device rows QObject-free to prevent a delegate crash

Same crash class as the wifi rows: scrollRows embedded the BlueZ Device
QObject in list-model data, so every delegate held a live wrapper in a var
property. Discovery churn -- a scan timeout dropping a device, an unpair --
can destroy the object while a delegate is still incubating, and quickshell
segfaults on the dangling wrapper.

Project primitives for both the scroll rows and the connected rows, and
resolve the backend object by address in deviceFor() for the click actions.
The keyboard flow already went through deviceAt(), which reads the live
device arrays directly rather than model data, so it is untouched.

Co-Authored-By: shrijit <shrijitsrivastav@gmail.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(network): guard row disconnects against a vanished network

Row activation resolved the WifiNetwork with networkForSsid() and passed the
result straight to disconnect(), which falls back to connectedWifiNetwork
when handed null. A row is a primitive snapshot, so scan churn can remove its
backing object while the row is still on screen -- activating it then tore
down whatever happened to be connected at that moment rather than doing
nothing.

Route both row paths through disconnectRow(), which resolves first and only
acts when the row still maps to a live network. disconnect() keeps its
fallback for callers that mean "drop the current connection".

Also covers the bar-click open path, which had no regression: the suite
already asserts against Panel.qml source, so assert the closed branch calls
open() alone and never a second refresh().

Co-Authored-By: shrijit <shrijitsrivastav@gmail.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: shrijit <shrijitsrivastav@gmail.com>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-07 17:32:36 +02:00

152 lines
6.4 KiB
Bash

#!/bin/bash
set -euo pipefail
source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/base-test.sh"
grep -q '^ConditionPathIsDirectory=/sys/class/bluetooth$' "$ROOT/default/systemd/user/bt-agent.service" || \
fail "bt-agent is skipped on machines without Bluetooth hardware"
pass "bt-agent is skipped on machines without Bluetooth hardware"
run_node_test <<'JS'
const fs = require('fs')
const bluetooth = requireFromRoot('shell/plugins/panels/bluetooth/Model.js')
const panelSource = fs.readFileSync(root + '/shell/plugins/panels/bluetooth/Panel.qml', 'utf8')
assert(/IpcHandler[\s\S]*?function toggleBluetooth\(\) \{ root\.toggleBluetooth\(\) \}/.test(panelSource), 'bluetooth exposes the radio toggle over IPC')
assert(/manageIpc: false/.test(panelSource), 'bluetooth owns its IPC handler so it can extend the target methods')
assert(bluetooth.isUuidLike('0000110b-0000-1000-8000-00805f9b34fb'), 'bluetooth detects UUID-like names')
assert(bluetooth.isAddressLike('AA:BB:CC:DD:EE:FF'), 'bluetooth detects address-like names')
assertEqual(bluetooth.normalizedAddress('AA:BB_CC-dd-ee-ff'), 'aabbccddeeff', 'bluetooth normalizes BlueZ and PipeWire address formats')
assert(!bluetooth.hasHumanName({ name: 'AA:BB:CC:DD:EE:FF' }), 'bluetooth rejects address-only device labels')
assert(bluetooth.hasHumanName({ deviceName: 'MX Master 3S' }), 'bluetooth accepts human device labels')
const devices = [
{ name: 'Speaker', connected: false, paired: true, address: '2' },
{ name: 'Headphones', connected: true, address: '1' },
{ name: 'Keyboard', connected: false, address: '3' },
{ name: 'AA:BB:CC:DD:EE:FF', connected: true, address: '4' },
{ name: 'Mouse', connected: false, trusted: true, address: '5' }
]
const arrayLikeDevices = {
0: devices[0],
1: devices[1],
length: 2
}
assertDeepEqual(
bluetooth.toArray(arrayLikeDevices).map(bluetooth.deviceLabel),
['Speaker', 'Headphones'],
'bluetooth converts Quickshell QObjectList-style values into arrays'
)
const lists = bluetooth.deviceLists(devices)
assertDeepEqual(lists.connected.map(bluetooth.deviceLabel), ['Headphones'], 'bluetooth groups connected devices')
assertDeepEqual(lists.known.map(bluetooth.deviceLabel), ['Mouse', 'Speaker'], 'bluetooth groups known devices by label')
assertDeepEqual(lists.discovered.map(bluetooth.deviceLabel), ['Keyboard'], 'bluetooth groups discovered devices')
assertDeepEqual(bluetooth.visibleSections(lists, true), ['connected', 'known', 'discovered'], 'bluetooth shows discovered section while scanning')
assertDeepEqual(bluetooth.visibleSections(lists, false), ['connected', 'known'], 'bluetooth hides discovered section when not scanning')
const arrayLikeLists = bluetooth.deviceLists({
0: { name: 'Earbuds', connected: true, address: '6' },
1: { name: 'Trackpad', paired: true, address: '7' },
2: { name: 'Gamepad', address: '8' },
length: 3
})
assertDeepEqual(arrayLikeLists.connected.map(bluetooth.deviceLabel), ['Earbuds'], 'bluetooth groups connected devices from array-like values')
assertDeepEqual(arrayLikeLists.known.map(bluetooth.deviceLabel), ['Trackpad'], 'bluetooth groups known devices from array-like values')
assertDeepEqual(arrayLikeLists.discovered.map(bluetooth.deviceLabel), ['Gamepad'], 'bluetooth groups discovered devices from array-like values')
assertDeepEqual(
bluetooth.deviceRow({ name: 'Deadbeef', address: '1', connected: false }),
{ address: '1', name: 'Deadbeef', deviceName: '', connected: false, state: -1, batteryAvailable: false, battery: 0, pairing: false },
'bluetooth projects device rows with primitives only'
)
assertEqual(
bluetooth.deviceLabel(bluetooth.deviceRow({ name: 'Generic', deviceName: 'MX Master 3S', address: '2', connected: true })),
'MX Master 3S',
'bluetooth keeps deviceName in row projections so labels survive QObject-free rows'
)
assertDeepEqual(
bluetooth.withPendingAction({ a: 'connecting' }, 'b', 'forgetting'),
{ a: 'connecting', b: 'forgetting' },
'bluetooth adds pending actions immutably'
)
assertDeepEqual(bluetooth.withPendingAction({ a: 'connecting' }, 'a', ''), {}, 'bluetooth clears pending actions immutably')
const bluetoothSink = {
isSink: true,
isStream: false,
ready: true,
name: 'bluez_output.AA_BB_CC_DD_EE_FF.1',
properties: {
'device.product.name': 'JBL Go 3'
}
}
assert(
bluetooth.bluetoothSinkMatchesDevice(bluetoothSink, { address: 'AA:BB:CC:DD:EE:FF', name: 'JBL Go 3' }),
'bluetooth matches audio sinks by device address'
)
assert(
bluetooth.bluetoothSinkMatchesDevice(
{
isSink: true,
isStream: false,
ready: true,
name: 'alsa_output.usb-speaker',
properties: { 'device.product.name': 'JBL Go 3' }
},
{ address: '11:22:33:44:55:66', name: 'JBL Go 3' }
),
'bluetooth matches audio sinks by human device label when address is unavailable'
)
assert(
!bluetooth.bluetoothSinkMatchesDevice({ isSink: false, isStream: false, ready: true, name: 'bluez_output.AA_BB_CC_DD_EE_FF.1', properties: {} }, { address: 'AA:BB:CC:DD:EE:FF', name: 'JBL Go 3' }),
'bluetooth ignores non-sink nodes when matching audio outputs'
)
JS
# The power-on shortcut is the whole point of skipping the stabilization sleep:
# pair/connect from the panel run against an adapter that is already powered.
device_tmp=$(mktemp -d)
trap 'rm -rf "$device_tmp"' EXIT
mock_bin="$device_tmp/bin"
mkdir -p "$mock_bin"
cat >"$mock_bin/bluetoothctl" <<'SH'
#!/bin/bash
printf '%s\n' "$*" >>"$BLUETOOTHCTL_LOG"
[[ $1 == "show" ]] && printf '\tPowered: %s\n' "$BLUETOOTHCTL_POWERED"
exit 0
SH
chmod +x "$mock_bin/bluetoothctl"
bluetooth_device_log() {
local powered="$1"
local log="$device_tmp/$powered.log"
: >"$log"
PATH="$mock_bin:$PATH" BLUETOOTHCTL_LOG="$log" BLUETOOTHCTL_POWERED="$powered" \
"$ROOT/bin/omarchy-bluetooth-device" connect AA:BB:CC:DD:EE:FF ||
fail "omarchy-bluetooth-device exits cleanly with Powered: $powered"
printf '%s' "$log"
}
powered_log=$(bluetooth_device_log yes)
grep -qx "power on" "$powered_log" &&
fail "bluetooth skips the power-on delay when the adapter is already powered"
pass "bluetooth skips the power-on delay when the adapter is already powered"
grep -qx "connect AA:BB:CC:DD:EE:FF" "$powered_log" ||
fail "bluetooth still connects when the adapter is already powered"
pass "bluetooth still connects when the adapter is already powered"
unpowered_log=$(bluetooth_device_log no)
grep -qx "power on" "$unpowered_log" ||
fail "bluetooth powers the adapter on when it is off"
pass "bluetooth powers the adapter on when it is off"