Files
omarchy/test/shell.d/audio-test.sh
T
OmabotandCodex XHigh c9faea7c55 Keep PwNode objects out of the audio panel's Repeater models
The panel handed its three Repeaters snapshots of live PwNode objects, and Quickshell deletes a node the moment PipeWire drops it. Qt then dereferences that row without a null check in VDMListDelegateDataType::createMissingProperties (qqmldmlistaccessordata_p.h:222, unchanged in 6.11.2), so any teardown that emptied the graph with the panel open segfaulted the shell: a USB DAC unplugged, a Bluetooth profile switch, omarchy-restart-audio, a WirePlumber restart, or a laptop suspend.

Rows now carry the node's id and name rather than the node, and each delegate resolves its own PwNode through nodeFor(), so labels, glyphs and stream volumes stay live. The name is what pins a row to the node it came from, since PipeWire reuses object ids; it is CONSTANT on PwNode for as long as the node exists. This is the projection the bluetooth panel already uses against the same crash class.

Filtering the snapshot instead does not work, which is worth recording because it is the obvious thing to reach for: the node is usually alive when the snapshot is taken and dies while Qt is incubating the delegate, so nothing visible at snapshot time identifies the row that will dangle.

Co-Authored-By: Codex XHigh <codex@openai.com>
2026-08-22 08:10:51 -07:00

60 lines
3.5 KiB
Bash

#!/bin/bash
set -euo pipefail
source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/base-test.sh"
run_node_test <<'JS'
const audio = requireFromRoot('shell/plugins/panels/audio/Model.js')
assert(audio.isPlaybackStream({ isStream: true, isSink: true }), 'audio detects sink-backed playback streams')
assert(audio.isPlaybackStream({ isStream: true, type: 'Stream/Output/Audio' }), 'audio detects typed playback streams')
assert(!audio.isPlaybackStream({ isStream: false, isSink: true }), 'audio rejects non-stream playback nodes')
assert(audio.isAudioSource({ audio: {} }), 'audio detects nodes with audio as sources')
assert(audio.isAudioSource({ type: 'Audio/Source' }), 'audio detects typed source nodes')
// A destroyed PwNode stays truthy but reads back no id, which is the shape the
// third entry stands in for: it must not reach a Repeater row.
assertDeepEqual(
audio.rowSnapshot([{ id: 0, name: 'alsa_output' }, { id: 42 }, {}, null]),
[{ id: 0, name: 'alsa_output' }, { id: 42, name: '' }],
'audio projects nodes to primitive rows and drops nodes without an id'
)
assertDeepEqual(audio.rowSnapshot(undefined), [], 'audio projects a missing list to no rows')
assert(audio.nodeRow({ id: 7, name: 'bluez_output' }).name === 'bluez_output', 'audio rows carry the node name that identifies them')
assertEqual(audio.outputVolumeName(0, false), 'Silenced', 'audio labels silent output')
assertEqual(audio.outputVolumeName(0.9, false), 'Party mode', 'audio labels loud output')
assertEqual(audio.outputVolumeName(0.5, true), 'Muted', 'audio labels muted output')
assertDeepEqual(audio.parseSinkAvailability('alsa_output\t1\nhdmi_output\t0\n'), { alsa_output: true, hdmi_output: false }, 'audio parses sink availability')
assertEqual(audio.friendlyDeviceLabel('Built-in Audio Speakers Output'), 'Speakers', 'audio cleans device labels')
assertEqual(
audio.nodeLabel({ ready: true, properties: { 'node.nick': 'Built-in Audio Microphones Input' }, name: 'alsa_input' }),
'Microphone',
'audio chooses friendly node labels'
)
const headphones = { ready: true, name: 'bluez_output.airpods', properties: { 'device.product.name': 'AirPods Headphones' } }
assert(audio.isHeadphones(headphones), 'audio detects headphone devices')
assertEqual(audio.sinkGlyph(headphones), '󰋋', 'audio uses headphone sink glyph')
assert(audio.sourceGlyph({ ready: true, properties: { 'device.icon-name': 'camera-webcam' } }).length > 0, 'audio maps webcam source glyph')
assertEqual(audio.friendlyStreamLabel('spotify'), 'Spotify', 'audio normalizes known stream labels')
assert(audio.streamRepresentsMprisPlayer('Chromium', 'Chromium Browser'), 'audio matches related stream and MPRIS labels')
const players = [
{ identity: 'Spotify', canPlay: true, isPlaying: true, dbusName: 'org.mpris.MediaPlayer2.spotify' },
{ identity: 'Chromium', canPlay: true, isPlaying: false, dbusName: 'org.mpris.MediaPlayer2.chromium' }
]
const streams = [
{ ready: true, properties: { 'application.name': 'Chromium' } },
{ ready: true, properties: { 'application.name': 'audio-src' } }
]
assertEqual(audio.matchingMprisStreamLabel('Chromium', players), 'Chromium', 'audio finds matching MPRIS labels')
assertEqual(audio.unmatchedMprisStreamLabel('audio-src', players, streams), 'Spotify', 'audio uses unmatched MPRIS player for generic streams')
assertEqual(audio.streamLabel(streams[1], players, streams), 'Spotify', 'audio labels generic streams from MPRIS')
assert(audio.streamRepresentsPlayer(streams[1], players[0], players, streams), 'audio links generic streams to active player')
JS