Add shell plugin model tests
This commit is contained in:
@@ -0,0 +1,49 @@
|
||||
#!/bin/bash
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/base-test.sh"
|
||||
|
||||
run_node_test <<'JS'
|
||||
const audio = requireFromRoot('shell/plugins/audio/AudioModel.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')
|
||||
|
||||
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
|
||||
@@ -0,0 +1,36 @@
|
||||
#!/bin/bash
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/base-test.sh"
|
||||
|
||||
run_node_test <<'JS'
|
||||
const bar = requireFromRoot('shell/plugins/bar/BarModel.js')
|
||||
|
||||
assertEqual(bar.normalizePosition('left'), 'left', 'bar accepts valid positions')
|
||||
assertEqual(bar.normalizePosition('sideways'), 'top', 'bar defaults invalid positions')
|
||||
assertDeepEqual(bar.entrySettings({ id: 'omarchy.clock', format: 'HH:mm' }), { format: 'HH:mm' }, 'bar extracts entry settings')
|
||||
assertEqual(bar.entryId({ id: 'omarchy.clock' }), 'omarchy.clock', 'bar extracts object entry ids')
|
||||
assertEqual(bar.entryId('omarchy.clock'), 'omarchy.clock', 'bar extracts string entry ids')
|
||||
|
||||
const entries = [{ id: 'a' }, { id: 'omarchy.tray' }, { id: 'b' }]
|
||||
assertDeepEqual(bar.pinTrayToInner(entries, 'left').map(bar.entryId), ['a', 'b', 'omarchy.tray'], 'bar pins tray to left inner edge')
|
||||
assertDeepEqual(bar.pinTrayToInner(entries, 'right').map(bar.entryId), ['omarchy.tray', 'a', 'b'], 'bar pins tray to right inner edge')
|
||||
|
||||
assertEqual(bar.moduleString({ id: 'custom', label: 42 }, 'label', 'fallback'), '42', 'bar stringifies module settings')
|
||||
assertEqual(bar.entryIndex(entries, 'b'), 2, 'bar finds entry indexes')
|
||||
assertDeepEqual(bar.entriesBefore(entries, 'b').map(bar.entryId), ['a', 'omarchy.tray'], 'bar returns entries before target')
|
||||
assertDeepEqual(bar.entriesAfter(entries, 'a').map(bar.entryId), ['omarchy.tray', 'b'], 'bar returns entries after target')
|
||||
|
||||
assertEqual(bar.expandPath('~/module.qml', '/home/dhh'), '/home/dhh/module.qml', 'bar expands tilde paths')
|
||||
assertEqual(bar.expandPath('$HOME/module.qml', '/home/dhh'), '/home/dhh/module.qml', 'bar expands HOME paths')
|
||||
assert(bar.customModuleSafeName('local.weather'), 'bar accepts safe custom module names')
|
||||
assert(!bar.customModuleSafeName('../escape'), 'bar rejects path traversal custom module names')
|
||||
assertEqual(bar.customModuleType({ id: 'custom', exec: 'date' }), 'command', 'bar infers command custom modules')
|
||||
assertEqual(bar.customModuleType({ id: 'custom', source: '~/Custom.qml' }), 'qml', 'bar infers qml custom modules')
|
||||
assertEqual(
|
||||
bar.customModulePath({ id: 'local.weather' }, '/home/dhh', '/home/dhh/.config/omarchy'),
|
||||
'/home/dhh/.config/omarchy/bar/modules/local.weather.qml',
|
||||
'bar builds default custom module paths'
|
||||
)
|
||||
JS
|
||||
@@ -0,0 +1,80 @@
|
||||
#!/bin/bash
|
||||
|
||||
if [[ ${BASH_SOURCE[0]} == "$0" ]]; then
|
||||
echo "source test/shell.d/base-test.sh from a shell test; do not run it directly" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
ROOT=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/../.." && pwd)
|
||||
SHELL_TEST_DIR="$ROOT/test/shell.d"
|
||||
|
||||
export ROOT
|
||||
|
||||
pass() {
|
||||
printf 'ok - %s\n' "$1"
|
||||
}
|
||||
|
||||
fail() {
|
||||
local description="$1"
|
||||
local detail="${2:-}"
|
||||
|
||||
[[ -n $detail ]] && printf '%s\n' "$detail" >&2
|
||||
printf 'not ok - %s\n' "$description" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
require_command() {
|
||||
local command="$1"
|
||||
|
||||
command -v "$command" >/dev/null || fail "required command is available: $command"
|
||||
}
|
||||
|
||||
run_node_test() {
|
||||
require_command node
|
||||
|
||||
{
|
||||
cat <<'JS_PRELUDE'
|
||||
const path = require('path')
|
||||
const root = process.env.ROOT
|
||||
|
||||
function fail(description, detail) {
|
||||
if (detail) console.error(detail)
|
||||
console.error(`not ok - ${description}`)
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
function pass(description) {
|
||||
console.log(`ok - ${description}`)
|
||||
}
|
||||
|
||||
function assert(condition, description, detail) {
|
||||
if (!condition) fail(description, detail)
|
||||
pass(description)
|
||||
}
|
||||
|
||||
function assertEqual(actual, expected, description) {
|
||||
assert(
|
||||
actual === expected,
|
||||
description,
|
||||
`expected: ${expected}\nactual: ${actual}`
|
||||
)
|
||||
}
|
||||
|
||||
function assertDeepEqual(actual, expected, description) {
|
||||
const actualJson = JSON.stringify(actual)
|
||||
const expectedJson = JSON.stringify(expected)
|
||||
assert(
|
||||
actualJson === expectedJson,
|
||||
description,
|
||||
`expected: ${expectedJson}\nactual: ${actualJson}`
|
||||
)
|
||||
}
|
||||
|
||||
function requireFromRoot(relativePath) {
|
||||
return require(path.join(root, relativePath))
|
||||
}
|
||||
|
||||
JS_PRELUDE
|
||||
cat
|
||||
} | node
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
#!/bin/bash
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/base-test.sh"
|
||||
|
||||
run_node_test <<'JS'
|
||||
const battery = requireFromRoot('shell/plugins/services/battery/BatteryModel.js')
|
||||
const discharging = 1
|
||||
|
||||
assertEqual(battery.batteryPercentage({ isPresent: true, percentage: 0.126 }), 13, 'battery rounds display percentage')
|
||||
assertEqual(battery.batteryPercentage({ isPresent: false, percentage: 0.5 }), -1, 'battery reports missing battery')
|
||||
assert(battery.isDischarging({ isPresent: true, state: discharging }, true, discharging), 'battery detects discharging state')
|
||||
assert(!battery.isDischarging({ isPresent: true, state: discharging }, false, discharging), 'battery requires on-battery state')
|
||||
|
||||
assertDeepEqual(
|
||||
battery.shouldWarnLowBattery({ isPresent: true, percentage: 0.08, state: discharging }, true, discharging, 10, false),
|
||||
{ level: 8, notify: true, notifiedLowBattery: true },
|
||||
'battery warns once under threshold'
|
||||
)
|
||||
assertDeepEqual(
|
||||
battery.shouldWarnLowBattery({ isPresent: true, percentage: 0.08, state: discharging }, true, discharging, 10, true),
|
||||
{ level: 8, notify: false, notifiedLowBattery: true },
|
||||
'battery keeps low-battery notified state'
|
||||
)
|
||||
assertDeepEqual(
|
||||
battery.shouldWarnLowBattery({ isPresent: true, percentage: 0.4, state: discharging }, true, discharging, 10, true),
|
||||
{ level: 40, notify: false, notifiedLowBattery: false },
|
||||
'battery clears notified state after recovery'
|
||||
)
|
||||
JS
|
||||
@@ -0,0 +1,36 @@
|
||||
#!/bin/bash
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/base-test.sh"
|
||||
|
||||
run_node_test <<'JS'
|
||||
const bluetooth = requireFromRoot('shell/plugins/bluetooth/BluetoothModel.js')
|
||||
|
||||
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')
|
||||
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 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')
|
||||
|
||||
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')
|
||||
JS
|
||||
@@ -0,0 +1,62 @@
|
||||
#!/bin/bash
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/base-test.sh"
|
||||
|
||||
run_node_test <<'JS'
|
||||
const clipboard = requireFromRoot('shell/plugins/clipboard/ClipboardHistory.js')
|
||||
|
||||
assertDeepEqual(
|
||||
clipboard.normalizeEntry('hello'),
|
||||
{ type: 'text', text: 'hello' },
|
||||
'clipboard normalizes string entries'
|
||||
)
|
||||
|
||||
assertDeepEqual(
|
||||
clipboard.normalizeEntry({ kind: 'image', path: '/tmp/a.png' }),
|
||||
{ type: 'image', path: '/tmp/a.png', mime: 'image/png' },
|
||||
'clipboard normalizes image entries with default mime'
|
||||
)
|
||||
|
||||
assertDeepEqual(
|
||||
clipboard.parseHistory(JSON.stringify(['one', '', { type: 'text', text: 'two' }, { type: 'image', path: '/tmp/a.jpg', mime: 'image/jpeg' }])),
|
||||
[
|
||||
{ type: 'text', text: 'one' },
|
||||
{ type: 'text', text: 'two' },
|
||||
{ type: 'image', path: '/tmp/a.jpg', mime: 'image/jpeg' }
|
||||
],
|
||||
'clipboard history parser drops invalid entries'
|
||||
)
|
||||
|
||||
const history = [
|
||||
{ type: 'text', text: 'old' },
|
||||
{ type: 'text', text: 'new' },
|
||||
{ type: 'image', path: '/tmp/a.png', mime: 'image/png' }
|
||||
]
|
||||
|
||||
assertDeepEqual(
|
||||
clipboard.addEntry(history, { type: 'text', text: 'new' }, 100),
|
||||
[
|
||||
{ type: 'text', text: 'new' },
|
||||
{ type: 'text', text: 'old' },
|
||||
{ type: 'image', path: '/tmp/a.png', mime: 'image/png' }
|
||||
],
|
||||
'clipboard addEntry moves duplicate text to front'
|
||||
)
|
||||
|
||||
assertDeepEqual(
|
||||
clipboard.displayRows(history, 'image', 50).map(row => ({ type: row.entryType, preview: row.previewText, mime: row.mime })),
|
||||
[{ type: 'image', preview: 'Image', mime: 'image/png' }],
|
||||
'clipboard display rows search image metadata'
|
||||
)
|
||||
|
||||
assertDeepEqual(
|
||||
clipboard.displayRows([{ type: 'text', text: 'line one\nline two' }], '', 50)[0].previewText,
|
||||
'line one line two',
|
||||
'clipboard display rows collapse text whitespace'
|
||||
)
|
||||
|
||||
assertDeepEqual(clipboard.displayRows(history, '', 0), [], 'clipboard display rows supports zero result limit')
|
||||
assertDeepEqual(clipboard.addEntry(history, 'next', 0), [], 'clipboard addEntry supports zero history limit')
|
||||
JS
|
||||
@@ -0,0 +1,47 @@
|
||||
#!/bin/bash
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/base-test.sh"
|
||||
|
||||
run_node_test <<'JS'
|
||||
const fs = require('fs')
|
||||
const emojis = requireFromRoot('shell/plugins/emojis/EmojiSearch.js')
|
||||
|
||||
const raw = fs.readFileSync(path.join(root, 'shell/plugins/emojis/emojis.json'), 'utf8')
|
||||
const data = emojis.parseEmojis(raw)
|
||||
|
||||
assert(data.length > 1000, 'emoji dataset parses')
|
||||
assertDeepEqual(emojis.parseEmojis('{'), [], 'invalid emoji JSON parses as empty list')
|
||||
assertDeepEqual(emojis.parseEmojis('{"e":"nope"}'), [], 'non-array emoji JSON parses as empty list')
|
||||
|
||||
const fixture = [
|
||||
{ e: 'a', k: 'grinning face smile happy' },
|
||||
{ e: 'b', k: 'face with tears of joy joy tears' },
|
||||
{ e: 'c', k: 'flag: united states us america' }
|
||||
]
|
||||
|
||||
assertDeepEqual(
|
||||
emojis.filterEmojis(fixture, ' JOY ').map(item => item.e),
|
||||
['b'],
|
||||
'emoji filtering trims and lowercases query'
|
||||
)
|
||||
|
||||
assertDeepEqual(
|
||||
emojis.filterEmojis(fixture, '', 2).map(item => item.e),
|
||||
['a', 'b'],
|
||||
'emoji filtering honors result limit'
|
||||
)
|
||||
|
||||
assertDeepEqual(
|
||||
emojis.filterEmojis(fixture, '', 0),
|
||||
[],
|
||||
'emoji filtering supports zero result limit'
|
||||
)
|
||||
|
||||
assertEqual(
|
||||
emojis.filterEmojis(data, 'face with tears')[0].e,
|
||||
'\u{1F602}',
|
||||
'emoji filtering finds face with tears of joy'
|
||||
)
|
||||
JS
|
||||
@@ -0,0 +1,36 @@
|
||||
#!/bin/bash
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/base-test.sh"
|
||||
|
||||
run_node_test <<'JS'
|
||||
const idle = requireFromRoot('shell/plugins/services/idle/IdleModel.js')
|
||||
|
||||
assertEqual(idle.secondsFromConfig('42.9', 10), 42, 'idle floors configured seconds')
|
||||
assertEqual(idle.secondsFromConfig('-1', 10), 10, 'idle rejects negative seconds')
|
||||
assertEqual(idle.secondsFromConfig('nope', 10), 10, 'idle rejects invalid seconds')
|
||||
|
||||
assertDeepEqual(idle.eventParts({ data: 'a,b,c' }, 2), ['a', 'b', 'c'], 'idle parses raw event data')
|
||||
assertDeepEqual(
|
||||
idle.eventParts({ parse: function(count) { return ['parsed', count] } }, 4),
|
||||
['parsed', 4],
|
||||
'idle prefers event parser when available'
|
||||
)
|
||||
|
||||
assertDeepEqual(
|
||||
idle.screensaverWindowsAfter({ a: true }, 'b', true),
|
||||
{ windows: { a: true, b: true }, count: 2 },
|
||||
'idle adds visible screensaver windows'
|
||||
)
|
||||
assertDeepEqual(
|
||||
idle.screensaverWindowsAfter({ a: true, b: true }, 'a', false),
|
||||
{ windows: { b: true }, count: 1 },
|
||||
'idle removes closed screensaver windows'
|
||||
)
|
||||
assertDeepEqual(
|
||||
idle.screensaverWindowsAfter({ a: true }, '', false),
|
||||
{ windows: { a: true }, count: 1 },
|
||||
'idle leaves screensaver windows unchanged without an address'
|
||||
)
|
||||
JS
|
||||
@@ -0,0 +1,43 @@
|
||||
#!/bin/bash
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/base-test.sh"
|
||||
|
||||
run_node_test <<'JS'
|
||||
const picker = requireFromRoot('shell/plugins/image-picker/ImagePickerModel.js')
|
||||
|
||||
assertEqual(picker.nameForPath('/themes/nord-river.png'), 'nord-river', 'image picker strips directory and extension')
|
||||
assertEqual(picker.labelForPath('/themes/nord_river.png'), 'Nord River', 'image picker builds display labels')
|
||||
|
||||
const rows = [
|
||||
'/themes/a/nord-river.png\t/cache/nord-river.jpg',
|
||||
'/themes/b/nord-river.png\t/cache/duplicate.jpg',
|
||||
'/themes/a/gruvbox-dark.jpeg',
|
||||
'',
|
||||
'\t/cache/no-path.jpg',
|
||||
'/themes/a/plain'
|
||||
].join('\n')
|
||||
|
||||
const images = picker.loadRows(rows)
|
||||
assertDeepEqual(
|
||||
images,
|
||||
[
|
||||
{ filePath: '/themes/a/nord-river.png', fileName: 'nord-river.png', thumbnailPath: '/cache/nord-river.jpg' },
|
||||
{ filePath: '/themes/a/gruvbox-dark.jpeg', fileName: 'gruvbox-dark.jpeg', thumbnailPath: '/themes/a/gruvbox-dark.jpeg' },
|
||||
{ filePath: '/themes/a/plain', fileName: 'plain', thumbnailPath: '/themes/a/plain' }
|
||||
],
|
||||
'image picker parses rows and dedupes by file name'
|
||||
)
|
||||
|
||||
assert(picker.itemMatches(images, 0, 'river'), 'image picker matches file names')
|
||||
assert(picker.itemMatches(images, 1, 'Gruvbox Dark'), 'image picker matches labels case-insensitively')
|
||||
assert(!picker.itemMatches(images, 2, 'river'), 'image picker rejects non-matching filters')
|
||||
assertEqual(picker.firstMatchingIndex(images, 'plain'), 2, 'image picker finds first matching index')
|
||||
assertEqual(picker.indexForSelectedImage(images, '/themes/a/gruvbox-dark.jpeg'), 1, 'image picker finds selected image')
|
||||
assertEqual(picker.indexForSelectedImage(images, '/missing.png'), 0, 'image picker defaults selected image to first row')
|
||||
|
||||
assertEqual(picker.filteredPosition(images, 2, 'dark'), 1, 'image picker computes filtered position')
|
||||
assertEqual(picker.selectedFilteredPosition(images, 2, 'dark'), 0, 'image picker selected filtered position falls back when selected is hidden')
|
||||
assertEqual(picker.nextSelectedIndexForFilter(images, 0, 'dark'), 1, 'image picker moves selection to first match when filter hides current item')
|
||||
JS
|
||||
@@ -0,0 +1,68 @@
|
||||
#!/bin/bash
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/base-test.sh"
|
||||
|
||||
run_node_test <<'JS'
|
||||
const search = requireFromRoot('shell/plugins/launcher/LauncherSearch.js')
|
||||
|
||||
const entries = [
|
||||
{
|
||||
name: 'Google Contacts',
|
||||
genericName: 'Address Book',
|
||||
comment: 'Manage contacts',
|
||||
keywords: ['contacts', 'address book', 'people'],
|
||||
id: 'google-contacts.desktop'
|
||||
},
|
||||
{
|
||||
name: 'Calculator',
|
||||
genericName: 'Calculator',
|
||||
comment: 'Perform arithmetic, scientific or financial calculations',
|
||||
keywords: ['calculation', 'arithmetic', 'scientific', 'financial'],
|
||||
id: 'org.gnome.Calculator.desktop'
|
||||
},
|
||||
{
|
||||
name: 'OBS Studio',
|
||||
genericName: 'Streaming/Recording Software',
|
||||
comment: 'Free and Open Source Streaming/Recording Software',
|
||||
keywords: ['streaming', 'recording', 'capture'],
|
||||
id: 'com.obsproject.Studio.desktop'
|
||||
},
|
||||
{
|
||||
name: 'Aether',
|
||||
genericName: '',
|
||||
comment: 'Minimal internet radio player',
|
||||
keywords: ['audio', 'music', 'radio'],
|
||||
id: 'io.github.taqi.aether.desktop'
|
||||
},
|
||||
{
|
||||
name: 'Xournal++',
|
||||
genericName: 'Notetaking',
|
||||
comment: 'Take handwritten notes',
|
||||
keywords: ['notes', 'pdf', 'annotation'],
|
||||
id: 'com.github.xournalpp.xournalpp.desktop'
|
||||
},
|
||||
{
|
||||
name: 'RustDesk',
|
||||
genericName: 'Remote Desktop',
|
||||
comment: 'Remote desktop control',
|
||||
keywords: ['remote', 'desktop', 'control'],
|
||||
id: 'com.rustdesk.RustDesk.desktop'
|
||||
}
|
||||
]
|
||||
|
||||
const contactMatches = search.sortedEntries(entries, 'contact').map(row => search.entryName(row.entry))
|
||||
assertDeepEqual(contactMatches, ['Google Contacts'], 'contact search only returns direct contact matches')
|
||||
|
||||
assert(
|
||||
search.fuzzyScore(entries[1], 'contact') < 0,
|
||||
'calculator does not match contact as a loose subsequence'
|
||||
)
|
||||
|
||||
const acronymMatches = search.sortedEntries(entries, 'gc').map(row => search.entryName(row.entry))
|
||||
assertEqual(acronymMatches[0], 'Google Contacts', 'short acronym matching still works')
|
||||
|
||||
const directMatches = search.sortedEntries(entries, 'obs').map(row => search.entryName(row.entry))
|
||||
assertEqual(directMatches[0], 'OBS Studio', 'direct app-name matching still works')
|
||||
JS
|
||||
@@ -0,0 +1,39 @@
|
||||
#!/bin/bash
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/base-test.sh"
|
||||
|
||||
run_node_test <<'JS'
|
||||
const media = requireFromRoot('shell/plugins/services/media/MediaModel.js')
|
||||
|
||||
assert(media.isProxyPlayer({ dbusName: 'org.mpris.MediaPlayer2.playerctld' }), 'media detects playerctld proxy by DBus name')
|
||||
assert(media.isProxyPlayer({ desktopEntry: 'playerctld' }), 'media detects playerctld proxy by desktop entry')
|
||||
assert(media.hasMetadata({ identity: 'Spotify' }), 'media detects identity metadata')
|
||||
assert(media.hasTrackMetadata({ trackTitle: 'Track' }), 'media detects track metadata')
|
||||
assert(media.playerCanControl({ canGoNext: true }), 'media detects controllable players')
|
||||
assert(media.canHandleAction({ canTogglePlaying: true }, 'playPause'), 'media maps playPause capability')
|
||||
assert(media.canCycleSource({ identity: 'Spotify', canPlay: true }), 'media detects cycleable sources')
|
||||
|
||||
assert(media.isPlaybackStream({ isStream: true, type: 'Stream/Output/Audio' }), 'media detects playback streams')
|
||||
assertEqual(media.streamLabelKey('PipeWire ALSA [Chromium]'), 'chromium', 'media normalizes stream labels')
|
||||
assertEqual(
|
||||
media.rawStreamLabel({ ready: true, properties: { 'application.name': 'Chromium' }, name: 'fallback' }),
|
||||
'Chromium',
|
||||
'media extracts raw stream labels'
|
||||
)
|
||||
assertEqual(
|
||||
media.playerAppLabel({ dbusName: 'org.mpris.MediaPlayer2.spotify.instance42' }),
|
||||
'spotify',
|
||||
'media derives player app labels from DBus names'
|
||||
)
|
||||
assert(media.playerHasPlaybackStream(
|
||||
{ desktopEntry: 'chromium' },
|
||||
[{ ready: true, properties: { 'application.name': 'Chromium' } }]
|
||||
), 'media matches players to playback streams')
|
||||
|
||||
assertEqual(media.playerKey({ dbusName: 'org.mpris.MediaPlayer2.spotify' }), 'org.mpris.MediaPlayer2.spotify', 'media derives stable player keys')
|
||||
assertEqual(media.labelFor({ trackTitle: 'Song', identity: 'Spotify' }), 'Song', 'media labels players by track first')
|
||||
assertEqual(media.osdMessage({ trackTitle: 'Song', trackArtist: 'Artist' }, 'Fallback'), 'Song - Artist', 'media builds OSD messages')
|
||||
assertEqual(media.osdMessage(null, 'Fallback'), 'Fallback', 'media falls back OSD messages')
|
||||
JS
|
||||
@@ -0,0 +1,88 @@
|
||||
#!/bin/bash
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/base-test.sh"
|
||||
|
||||
run_node_test <<'JS'
|
||||
const menu = requireFromRoot('shell/plugins/menu/MenuModel.js')
|
||||
|
||||
const parsed = menu.parseMenuJsonc(`
|
||||
{
|
||||
// comment
|
||||
"items": {
|
||||
"root": { "label": "Go" },
|
||||
"style": { "label": "Style" },
|
||||
"style.theme": {
|
||||
"label": "Themes",
|
||||
"aliases": "theme",
|
||||
"keywords": "appearance appearance colors",
|
||||
"action": "omarchy-theme-set"
|
||||
},
|
||||
},
|
||||
}
|
||||
`)
|
||||
|
||||
assertEqual(parsed.length, 3, 'menu parses JSONC with comments and trailing commas')
|
||||
assertDeepEqual(
|
||||
parsed.find(item => item.id === 'style.theme'),
|
||||
{
|
||||
id: 'style.theme',
|
||||
parent: 'style',
|
||||
kind: 'action',
|
||||
icon: '',
|
||||
label: 'Themes',
|
||||
target: '',
|
||||
keywords: 'appearance colors',
|
||||
description: '',
|
||||
action: 'omarchy-theme-set',
|
||||
provider: '',
|
||||
aliases: ['theme'],
|
||||
when: '',
|
||||
checked: ''
|
||||
},
|
||||
'menu normalizes parsed items'
|
||||
)
|
||||
|
||||
const user = [
|
||||
menu.normalizeItem('style.theme', { label: 'Theme picker', aliases: ['theme', 'colors'], action: 'custom-theme' }),
|
||||
menu.normalizeItem('tools', { label: 'Tools' })
|
||||
]
|
||||
const merged = menu.mergeMenuSources(parsed, user)
|
||||
assertEqual(merged.items['style.theme'].label, 'Theme picker', 'menu user entries override default entries')
|
||||
assertEqual(merged.items['style.theme'].order, 2, 'menu preserves original order on override')
|
||||
assert(merged.items.root, 'menu injects root when merging sources')
|
||||
|
||||
assertEqual(menu.slugify('Power Saver!'), 'power-saver', 'menu slugifies provider rows')
|
||||
assertEqual(menu.pathFor(merged.items, 'style.theme'), 'Style › Theme picker', 'menu builds item paths')
|
||||
assertEqual(menu.parentPathFor(merged.items, 'style.theme'), 'Style', 'menu builds parent paths')
|
||||
assert(menu.isDescendantOf(merged.items, 'style.theme', 'style'), 'menu detects descendants')
|
||||
assertEqual(menu.childCount(merged.items, merged.itemOrder, 'style'), 1, 'menu counts children')
|
||||
assertEqual(menu.labelFor({ id: 'style.theme', label: 'Theme', checked: 'cmd' }, { 'style.theme': true }), 'Theme ✓', 'menu appends checked marker')
|
||||
|
||||
const entry = merged.items['style.theme']
|
||||
assert(menu.matchesQuery(entry, 'theme', true), 'menu matches labels and aliases')
|
||||
assert(menu.matchesQuery(entry, 'colors', true), 'menu matches aliases')
|
||||
assert(!menu.matchesQuery(entry, 'missing', true), 'menu rejects missing terms')
|
||||
assert(!menu.matchesQuery(entry, 'theme', false), 'menu hides invisible matches')
|
||||
assert(menu.searchScore(merged.items, entry, 'theme') < menu.searchScore(merged.items, entry, 'appearance'), 'menu scores name matches above keyword matches')
|
||||
|
||||
assertDeepEqual(
|
||||
menu.displayRow(merged.items, merged.itemOrder, {}, entry, 'Style', 12, 'search'),
|
||||
{
|
||||
itemId: 'style.theme',
|
||||
kind: 'action',
|
||||
icon: '',
|
||||
label: 'Theme picker',
|
||||
target: 'style.theme',
|
||||
detail: 'Style',
|
||||
path: 'Style › Theme picker',
|
||||
childCount: 0,
|
||||
action: 'custom-theme',
|
||||
provider: '',
|
||||
score: 12,
|
||||
section: 'search'
|
||||
},
|
||||
'menu builds display rows'
|
||||
)
|
||||
JS
|
||||
@@ -0,0 +1,39 @@
|
||||
#!/bin/bash
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/base-test.sh"
|
||||
|
||||
run_node_test <<'JS'
|
||||
const monitor = requireFromRoot('shell/plugins/monitor/MonitorModel.js')
|
||||
|
||||
assertEqual(monitor.clampBrightness(0), 1, 'monitor clamps minimum brightness')
|
||||
assertEqual(monitor.clampBrightness(101), 100, 'monitor clamps maximum brightness')
|
||||
assertEqual(monitor.clampBrightness(42.4), 42, 'monitor rounds brightness')
|
||||
assertEqual(monitor.clampBrightness('nope'), 1, 'monitor rejects invalid brightness')
|
||||
|
||||
assertEqual(monitor.normalizeScale('1.250'), '1.25', 'monitor normalizes fractional scale')
|
||||
assertEqual(monitor.normalizeScale('nope'), '', 'monitor rejects invalid scale')
|
||||
|
||||
assertEqual(monitor.brightnessName(96), 'Sun blast', 'monitor names very bright displays')
|
||||
assertEqual(monitor.brightnessName(12), 'Candlelit', 'monitor names dim displays')
|
||||
|
||||
assertDeepEqual(
|
||||
monitor.parseDisplays(JSON.stringify([
|
||||
{ name: 'eDP-1', enabled: true },
|
||||
{ name: 'HDMI-A-1', enabled: false },
|
||||
{ name: 'DP-1', enabled: true }
|
||||
])),
|
||||
{
|
||||
displays: [
|
||||
{ name: 'eDP-1', enabled: true },
|
||||
{ name: 'HDMI-A-1', enabled: false },
|
||||
{ name: 'DP-1', enabled: true }
|
||||
],
|
||||
enabledDisplayCount: 2
|
||||
},
|
||||
'monitor parses display state'
|
||||
)
|
||||
|
||||
assertDeepEqual(monitor.parseDisplays('{'), { displays: [], enabledDisplayCount: 0 }, 'monitor handles invalid display JSON')
|
||||
JS
|
||||
@@ -0,0 +1,53 @@
|
||||
#!/bin/bash
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/base-test.sh"
|
||||
|
||||
run_node_test <<'JS'
|
||||
const network = requireFromRoot('shell/plugins/network/NetworkModel.js')
|
||||
|
||||
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('5200'), '5.2ghz', 'network formats wifi frequency')
|
||||
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'
|
||||
)
|
||||
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'
|
||||
)
|
||||
|
||||
assertEqual(network.formatBytes(1536), '1.5 KB', 'network formats bytes')
|
||||
assertEqual(network.formatRate(1536), '1.5 KB/s', 'network formats rates')
|
||||
|
||||
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 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')
|
||||
JS
|
||||
@@ -0,0 +1,125 @@
|
||||
#!/bin/bash
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/base-test.sh"
|
||||
|
||||
run_node_test <<'JS'
|
||||
const notifications = requireFromRoot('shell/plugins/notifications/NotificationLogic.js')
|
||||
|
||||
assert(notifications.isChromiumDerived('Brave Browser', ''), 'notifications detect chromium-derived apps by name')
|
||||
assert(notifications.isChromiumDerived('', 'microsoft-edge'), 'notifications detect chromium-derived apps by icon')
|
||||
assert(!notifications.isChromiumDerived('Slack', ''), 'notifications do not treat unrelated apps as chromium-derived')
|
||||
|
||||
assertEqual(
|
||||
notifications.sanitizeBody('<img src="x">Hello', 'Slack', ''),
|
||||
'Hello',
|
||||
'notifications strip inline image tags'
|
||||
)
|
||||
|
||||
assertEqual(
|
||||
notifications.sanitizeBody('<a href="https://example.com">example.com</a> Message body', 'Chromium', ''),
|
||||
'Message body',
|
||||
'notifications strip chromium leading origin links'
|
||||
)
|
||||
|
||||
assertEqual(
|
||||
notifications.sanitizeBody('https://example.com/path Message body', 'Chromium', ''),
|
||||
'Message body',
|
||||
'notifications strip chromium leading origin text'
|
||||
)
|
||||
|
||||
assertEqual(
|
||||
notifications.sanitizeBody('https://example.com/path Message body', 'Slack', ''),
|
||||
'https://example.com/path Message body',
|
||||
'notifications keep non-browser leading origin text'
|
||||
)
|
||||
|
||||
assert(notifications.summaryStartsWithGlyph(' Silenced'), 'notifications detect glyph-prefixed summaries')
|
||||
assert(!notifications.summaryStartsWithGlyph('Normal summary'), 'notifications ignore normal summaries as glyph-prefixed')
|
||||
|
||||
assert(notifications.shouldBypassDnd({ appName: 'omarchy-action', urgency: 1 }, 2), 'omarchy action toasts bypass DND')
|
||||
assert(notifications.shouldBypassDnd({ appName: 'notify-send', urgency: 2 }, 2), 'critical notify-send bypasses DND')
|
||||
assert(!notifications.shouldBypassDnd({ appName: 'notify-send', urgency: 1 }, 2), 'normal notify-send does not bypass DND')
|
||||
assert(!notifications.shouldBypassDnd({ appName: 'Slack', urgency: 2 }, 2), 'critical app notifications do not bypass DND')
|
||||
|
||||
const notification = {
|
||||
id: 12,
|
||||
appName: 'Mail',
|
||||
appIcon: 'mail',
|
||||
summary: 42,
|
||||
body: 'Body',
|
||||
image: 'file:///tmp/mail.png',
|
||||
hints: { 'omarchy-glyph': '!' },
|
||||
urgency: 1
|
||||
}
|
||||
const snapshot = notifications.snapshotOf(notification, 12345)
|
||||
assertDeepEqual(
|
||||
{
|
||||
id: snapshot.id,
|
||||
originalId: snapshot.originalId,
|
||||
app: snapshot.app,
|
||||
appIcon: snapshot.appIcon,
|
||||
summary: snapshot.summary,
|
||||
body: snapshot.body,
|
||||
image: snapshot.image,
|
||||
glyph: snapshot.glyph,
|
||||
urgency: snapshot.urgency,
|
||||
timestamp: snapshot.timestamp
|
||||
},
|
||||
{
|
||||
id: 12,
|
||||
originalId: 12,
|
||||
app: 'Mail',
|
||||
appIcon: 'mail',
|
||||
summary: '42',
|
||||
body: 'Body',
|
||||
image: 'file:///tmp/mail.png',
|
||||
glyph: '!',
|
||||
urgency: 1,
|
||||
timestamp: 12345
|
||||
},
|
||||
'notifications create stable snapshots'
|
||||
)
|
||||
|
||||
const history = notifications.parseHistory(JSON.stringify({
|
||||
dnd: true,
|
||||
pending: [
|
||||
{ id: 1, originalId: 10, summary: 'old', timestamp: 100 },
|
||||
{ id: 2, originalId: 10, summary: 'new', timestamp: 200 },
|
||||
{ id: 3, originalId: 11, summary: 'other', timestamp: 150 }
|
||||
],
|
||||
past: [
|
||||
{ id: 4, summary: 'past', timestamp: 50 }
|
||||
],
|
||||
entries: [
|
||||
{ id: 5, summary: 'legacy', timestamp: 75 }
|
||||
]
|
||||
}), 1, 100)
|
||||
|
||||
assertEqual(history.dnd, true, 'notifications parse persisted DND state')
|
||||
assertEqual(history.hadDuplicates, true, 'notifications report duplicate history rows')
|
||||
assertDeepEqual(
|
||||
history.pending.map(row => ({ id: row.id, originalId: row.originalId, summary: row.summary, urgency: row.urgency, timestamp: row.timestamp })),
|
||||
[
|
||||
{ id: 2, originalId: 10, summary: 'new', urgency: 1, timestamp: 200 },
|
||||
{ id: 3, originalId: 11, summary: 'other', urgency: 1, timestamp: 150 }
|
||||
],
|
||||
'notifications dedupe pending history by original id'
|
||||
)
|
||||
assertDeepEqual(
|
||||
history.past.map(row => row.summary),
|
||||
['legacy', 'past'],
|
||||
'notifications merge legacy entries into past history'
|
||||
)
|
||||
assertDeepEqual(
|
||||
notifications.parseHistory(JSON.stringify({ pending: [{ id: 1, timestamp: 1 }] }), 1, 0).pending,
|
||||
[],
|
||||
'notifications history parser supports zero result cap'
|
||||
)
|
||||
assert(notifications.parseHistory('{', 1, 100).error, 'notifications flag invalid history JSON')
|
||||
|
||||
assertEqual(notifications.imageExtension('/tmp/screenshot.PNG'), 'png', 'notifications normalize image extensions')
|
||||
assertEqual(notifications.imageExtension('/tmp/no-extension'), 'png', 'notifications default missing image extension')
|
||||
assertEqual(notifications.imageExtension('/tmp/archive.reallylong'), 'png', 'notifications reject suspicious image extensions')
|
||||
JS
|
||||
@@ -0,0 +1,41 @@
|
||||
#!/bin/bash
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/base-test.sh"
|
||||
|
||||
run_node_test <<'JS'
|
||||
const osd = requireFromRoot('shell/plugins/osd/OsdModel.js')
|
||||
|
||||
assertEqual(osd.iconFor('', 0), osd.iconFor('muted', 50), 'osd falls back to muted icon at zero percent')
|
||||
assertEqual(osd.iconFor('volume-high', 1), osd.iconFor('', 100), 'osd maps high volume aliases')
|
||||
assertEqual(osd.iconFor('custom-symbol', 50), 'custom-symbol', 'osd preserves unknown explicit icons')
|
||||
|
||||
assertDeepEqual(
|
||||
osd.stateForShow('volume', '', '75', '100', '', '800'),
|
||||
{
|
||||
iconKey: 'volume',
|
||||
maxValue: 100,
|
||||
hasProgress: true,
|
||||
value: 75,
|
||||
message: '75%',
|
||||
icon: osd.iconFor('volume', 75),
|
||||
duration: 800
|
||||
},
|
||||
'osd builds progress state'
|
||||
)
|
||||
|
||||
assertDeepEqual(
|
||||
osd.stateForShow('media-pause', 'Paused', '', '100', '', 'nope'),
|
||||
{
|
||||
iconKey: 'media-pause',
|
||||
maxValue: 100,
|
||||
hasProgress: false,
|
||||
value: 0,
|
||||
message: 'Paused',
|
||||
icon: osd.iconFor('media-pause', -1),
|
||||
duration: 1200
|
||||
},
|
||||
'osd builds message state'
|
||||
)
|
||||
JS
|
||||
@@ -0,0 +1,151 @@
|
||||
#!/bin/bash
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/base-test.sh"
|
||||
|
||||
run_node_test <<'JS'
|
||||
const fs = require('fs')
|
||||
|
||||
const pluginsDir = path.join(root, 'shell/plugins')
|
||||
const kindEntryPoints = {
|
||||
'bar': 'bar',
|
||||
'bar-widget': 'barWidget',
|
||||
'menu': 'menu',
|
||||
'overlay': 'overlay',
|
||||
'panel': 'panel',
|
||||
'service': 'service'
|
||||
}
|
||||
|
||||
function isPlainObject(value) {
|
||||
return !!value && typeof value === 'object' && !Array.isArray(value)
|
||||
}
|
||||
|
||||
function walk(dir) {
|
||||
const rows = []
|
||||
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
||||
const fullPath = path.join(dir, entry.name)
|
||||
if (entry.isDirectory()) {
|
||||
rows.push(...walk(fullPath))
|
||||
} else if (entry.isFile() && (entry.name === 'manifest.json' || entry.name.endsWith('.manifest.json'))) {
|
||||
rows.push(fullPath)
|
||||
}
|
||||
}
|
||||
return rows.sort()
|
||||
}
|
||||
|
||||
function relativeFromPlugins(filePath) {
|
||||
return path.relative(pluginsDir, filePath).split(path.sep).join('/')
|
||||
}
|
||||
|
||||
function sourceDirForManifest(manifestPath) {
|
||||
return path.dirname(manifestPath)
|
||||
}
|
||||
|
||||
const errors = []
|
||||
|
||||
function check(condition, detail) {
|
||||
if (!condition) errors.push(detail)
|
||||
}
|
||||
|
||||
function assertSafeEntryPoint(manifest, manifestPath, key, value) {
|
||||
const label = `${manifest.id} ${key} entry point`
|
||||
check(typeof value === 'string' && value.length > 0, `${label} must be a non-empty string`)
|
||||
check(!path.isAbsolute(value), `${label} must be relative`)
|
||||
check(!String(value).split(/[\\/]+/).includes('..'), `${label} must stay inside plugin source`)
|
||||
check(fs.existsSync(path.join(sourceDirForManifest(manifestPath), String(value))), `${label} file must exist`)
|
||||
}
|
||||
|
||||
const manifests = walk(pluginsDir)
|
||||
const manifestPaths = manifests.map(relativeFromPlugins)
|
||||
const manifestSet = new Set(manifestPaths)
|
||||
|
||||
assert(manifests.length > 0, 'plugin manifests are present')
|
||||
|
||||
for (const entry of fs.readdirSync(pluginsDir, { withFileTypes: true })) {
|
||||
if (!entry.isDirectory() || entry.name === 'services') continue
|
||||
check(
|
||||
manifestSet.has(`${entry.name}/manifest.json`),
|
||||
`top-level plugin ${entry.name} must have a manifest`
|
||||
)
|
||||
}
|
||||
|
||||
const serviceRoot = path.join(pluginsDir, 'services')
|
||||
for (const entry of fs.readdirSync(serviceRoot, { withFileTypes: true })) {
|
||||
if (!entry.isDirectory()) continue
|
||||
check(
|
||||
manifestSet.has(`services/${entry.name}/manifest.json`),
|
||||
`service plugin ${entry.name} must have a manifest`
|
||||
)
|
||||
}
|
||||
|
||||
for (const manifestPath of manifestPaths) {
|
||||
const depth = manifestPath.split('/').length
|
||||
check(depth >= 2 && depth <= 3, `${manifestPath} must be discoverable by PluginRegistry`)
|
||||
}
|
||||
|
||||
const ids = new Set()
|
||||
for (const manifestPath of manifests) {
|
||||
const relativePath = relativeFromPlugins(manifestPath)
|
||||
let manifest = null
|
||||
try {
|
||||
manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8'))
|
||||
} catch (error) {
|
||||
errors.push(`${relativePath} must parse as JSON: ${error.message}`)
|
||||
continue
|
||||
}
|
||||
|
||||
check(isPlainObject(manifest), `${relativePath} must parse to an object`)
|
||||
if (!isPlainObject(manifest)) continue
|
||||
|
||||
check(manifest.schemaVersion === 1, `${relativePath} must use schema version 1`)
|
||||
|
||||
for (const field of ['id', 'name', 'version', 'description']) {
|
||||
check(typeof manifest[field] === 'string' && manifest[field].length > 0, `${manifest.id || relativePath} must have ${field}`)
|
||||
}
|
||||
|
||||
check(String(manifest.id).startsWith('omarchy.'), `${manifest.id} must use the first-party namespace`)
|
||||
check(!String(manifest.id).includes('/') && !String(manifest.id).includes('..'), `${manifest.id} must be safe as a plugin id`)
|
||||
check(!ids.has(manifest.id), `${manifest.id} must be unique`)
|
||||
ids.add(manifest.id)
|
||||
|
||||
check(Array.isArray(manifest.kinds) && manifest.kinds.length > 0, `${manifest.id} must declare plugin kinds`)
|
||||
check(
|
||||
JSON.stringify([...new Set(manifest.kinds || [])]) === JSON.stringify(manifest.kinds || []),
|
||||
`${manifest.id} must not duplicate plugin kinds`
|
||||
)
|
||||
check(isPlainObject(manifest.entryPoints), `${manifest.id} must have an entryPoints object`)
|
||||
|
||||
for (const kind of manifest.kinds || []) {
|
||||
check(kindEntryPoints[kind], `${manifest.id} must use supported plugin kind ${kind}`)
|
||||
const entryPointKey = kindEntryPoints[kind]
|
||||
check(manifest.entryPoints && manifest.entryPoints[entryPointKey], `${manifest.id} must declare ${entryPointKey} entry point`)
|
||||
}
|
||||
|
||||
for (const key of Object.keys(manifest.entryPoints || {})) {
|
||||
check(Object.values(kindEntryPoints).includes(key), `${manifest.id} entry point ${key} must be a supported key`)
|
||||
assertSafeEntryPoint(manifest, manifestPath, key, manifest.entryPoints[key])
|
||||
}
|
||||
|
||||
if (manifest.keepLoaded !== undefined) {
|
||||
check(typeof manifest.keepLoaded === 'boolean', `${manifest.id} keepLoaded must be boolean when present`)
|
||||
}
|
||||
|
||||
if ((manifest.kinds || []).includes('bar-widget')) {
|
||||
check(isPlainObject(manifest.barWidget), `${manifest.id} must have barWidget metadata`)
|
||||
for (const field of ['displayName', 'description', 'category']) {
|
||||
check(
|
||||
manifest.barWidget && typeof manifest.barWidget[field] === 'string' && manifest.barWidget[field].length > 0,
|
||||
`${manifest.id} barWidget metadata must have ${field}`
|
||||
)
|
||||
}
|
||||
check(manifest.barWidget && typeof manifest.barWidget.allowMultiple === 'boolean', `${manifest.id} barWidget allowMultiple must be boolean`)
|
||||
}
|
||||
|
||||
if (relativePath.endsWith('.manifest.json')) {
|
||||
check(JSON.stringify(manifest.kinds) === JSON.stringify(['bar-widget']), `${manifest.id} sibling manifest must be a bar widget`)
|
||||
}
|
||||
}
|
||||
|
||||
assert(errors.length === 0, 'plugin manifests match shell registry contract', errors.join('\n'))
|
||||
JS
|
||||
@@ -0,0 +1,30 @@
|
||||
#!/bin/bash
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/base-test.sh"
|
||||
|
||||
run_node_test <<'JS'
|
||||
const polkit = requireFromRoot('shell/plugins/polkit/PolkitModel.js')
|
||||
|
||||
assert(polkit.promptLooksFingerprint('Swipe your finger'), 'polkit detects fingerprint prompts')
|
||||
assert(polkit.promptLooksFingerprint('fprintd verification'), 'polkit detects fprint prompts')
|
||||
assert(!polkit.promptLooksFingerprint('Password:'), 'polkit ignores password prompts')
|
||||
|
||||
assert(
|
||||
polkit.fingerprintFirstFromPamConfig(`
|
||||
# comment
|
||||
auth sufficient pam_fprintd.so
|
||||
auth include system-auth
|
||||
`),
|
||||
'polkit detects fingerprint-first PAM config'
|
||||
)
|
||||
assert(
|
||||
!polkit.fingerprintFirstFromPamConfig(`
|
||||
account include system-auth
|
||||
auth include system-auth
|
||||
auth sufficient pam_fprintd.so
|
||||
`),
|
||||
'polkit detects password-first PAM config'
|
||||
)
|
||||
JS
|
||||
@@ -0,0 +1,30 @@
|
||||
#!/bin/bash
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/base-test.sh"
|
||||
|
||||
run_node_test <<'JS'
|
||||
const power = requireFromRoot('shell/plugins/power/PowerModel.js')
|
||||
const states = { Charging: 1, Discharging: 2, FullyCharged: 3, PendingCharge: 4 }
|
||||
|
||||
assertEqual(power.selectProfileIndex(0, 1, ['balanced', 'performance']), 1, 'power advances profile selection')
|
||||
assertEqual(power.selectProfileIndex(1, 1, ['balanced', 'performance']), 1, 'power clamps profile selection')
|
||||
|
||||
assertDeepEqual(power.parseKeyValue('time\t2:00\nenergy\t42\n'), { time: '2:00', energy: '42' }, 'power parses key-value output')
|
||||
assertDeepEqual(
|
||||
power.parseProfiles('power-saver\t0\nbalanced\t1\nperformance\t0\n', 5),
|
||||
{ profiles: ['power-saver', 'balanced', 'performance'], activeProfile: 'balanced', profileIndex: 2 },
|
||||
'power parses profile output and clamps selection'
|
||||
)
|
||||
|
||||
assert(power.profileIcon('performance').length > 0, 'power maps profile icons')
|
||||
assertEqual(power.batteryFraction({ isPresent: true, percentage: 1.5 }), 1, 'power clamps battery fraction')
|
||||
|
||||
assert(power.chargeThresholdActive({ isPresent: true, percentage: 0.8, state: states.PendingCharge }, false, states), 'power detects threshold by pending charge state')
|
||||
assert(power.chargeThresholdActive({ isPresent: true, percentage: 0.8, state: states.Charging, changeRate: 0.1, timeToFull: 120 }, false, states), 'power detects threshold by stalled charging')
|
||||
assert(!power.chargeThresholdActive({ isPresent: true, percentage: 0.8, state: states.Charging, changeRate: 1.0, timeToFull: 120 }, false, states), 'power does not flag active charging as threshold')
|
||||
assertEqual(power.modeLabel({ isPresent: true, percentage: 1, state: states.FullyCharged }, false, states), 'Fully charged', 'power labels full battery')
|
||||
assertEqual(power.modeLabel({ isPresent: true, percentage: 0.5, state: states.Discharging }, true, states), 'On battery', 'power labels battery mode')
|
||||
assert(power.batteryIcon({ isPresent: true, percentage: 0.4, state: states.Charging }, false, states).length > 0, 'power maps battery icons')
|
||||
JS
|
||||
@@ -0,0 +1,34 @@
|
||||
#!/bin/bash
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/base-test.sh"
|
||||
|
||||
run_node_test <<'JS'
|
||||
const reminders = requireFromRoot('shell/plugins/reminders/ReminderFlowModel.js')
|
||||
|
||||
assertEqual(reminders.validMinutes('15'), '15', 'reminders accepts positive integer minutes')
|
||||
assertEqual(reminders.validMinutes(' 5 '), '5', 'reminders trims minute input')
|
||||
assertEqual(reminders.validMinutes('0'), '', 'reminders rejects zero minutes')
|
||||
assertEqual(reminders.validMinutes('-5'), '', 'reminders rejects negative minutes')
|
||||
assertEqual(reminders.validMinutes('1.5'), '', 'reminders rejects fractional minutes')
|
||||
assertEqual(reminders.validMinutes('soon'), '', 'reminders rejects non-numeric minutes')
|
||||
|
||||
assertDeepEqual(
|
||||
reminders.reminderArgs('10', 'Check the oven'),
|
||||
['10', 'Check the oven'],
|
||||
'reminders builds command args with message'
|
||||
)
|
||||
|
||||
assertDeepEqual(
|
||||
reminders.reminderArgs('10', ''),
|
||||
['10'],
|
||||
'reminders omits empty message arg'
|
||||
)
|
||||
|
||||
assertDeepEqual(
|
||||
reminders.reminderArgs('0', 'ignored'),
|
||||
[],
|
||||
'reminders command args are empty for invalid minutes'
|
||||
)
|
||||
JS
|
||||
@@ -0,0 +1,63 @@
|
||||
#!/bin/bash
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/base-test.sh"
|
||||
|
||||
run_node_test <<'JS'
|
||||
const weather = requireFromRoot('shell/plugins/weather/WeatherModel.js')
|
||||
|
||||
assertDeepEqual(
|
||||
weather.parseWeatherStatus('{"text":"☀","class":"sunny"}'),
|
||||
{ label: '☀', klass: 'sunny' },
|
||||
'weather parses pill status JSON'
|
||||
)
|
||||
assertDeepEqual(weather.parseWeatherStatus('{'), { label: '', klass: '' }, 'weather handles invalid pill status JSON')
|
||||
|
||||
assertEqual(weather.roundedTemp('21.6'), '22', 'weather rounds temperatures')
|
||||
assertEqual(weather.roundedTemp('nope'), '', 'weather ignores invalid temperatures')
|
||||
assertEqual(weather.formatTemp(72, true), '72°F', 'weather formats imperial temperatures')
|
||||
assertEqual(weather.formatTemp(22, false), '22°C', 'weather formats metric temperatures')
|
||||
assertEqual(weather.dayName('2026-05-25'), 'Monday', 'weather derives day names')
|
||||
|
||||
const openMeteo = {
|
||||
daily: {
|
||||
time: ['2026-05-25', '2026-05-26', '2026-05-27', '2026-05-28', '2026-05-29'],
|
||||
temperature_2m_max: [20.1, 21.6, 18.2, 17.9, 22.4],
|
||||
temperature_2m_min: [12.2, 13.1, 10.8, 9.2, 11.5],
|
||||
weather_code: [0, 63, 95, 3, 1]
|
||||
}
|
||||
}
|
||||
|
||||
assertDeepEqual(
|
||||
weather.openMeteoForecastDays(openMeteo, '2026-05-25').map(day => ({
|
||||
date: day.date,
|
||||
maxtempC: day.maxtempC,
|
||||
mintempF: day.mintempF,
|
||||
code: day.openMeteoWeatherCode
|
||||
})),
|
||||
[
|
||||
{ date: '2026-05-26', maxtempC: '22', mintempF: '56', code: 63 },
|
||||
{ date: '2026-05-27', maxtempC: '18', mintempF: '51', code: 95 },
|
||||
{ date: '2026-05-28', maxtempC: '18', mintempF: '49', code: 3 }
|
||||
],
|
||||
'weather builds future Open-Meteo forecast days'
|
||||
)
|
||||
|
||||
const wttr = {
|
||||
weather: [
|
||||
{ date: '2026-05-25', maxtempC: '20', mintempC: '12' },
|
||||
{ date: '2026-05-26', maxtempC: '22', mintempC: '13' }
|
||||
]
|
||||
}
|
||||
assertEqual(weather.buildForecastDays(wttr, {}, '2026-05-25')[0].date, '2026-05-26', 'weather falls back to wttr forecast')
|
||||
assertEqual(weather.bareTempForDay({ maxtempC: '22', mintempC: '13', maxtempF: '72', mintempF: '55' }, 'max', false), '22°', 'weather formats forecast metric highs')
|
||||
assertEqual(weather.bareTempForDay({ maxtempC: '22', mintempC: '13', maxtempF: '72', mintempF: '55' }, 'min', true), '55°', 'weather formats forecast imperial lows')
|
||||
|
||||
assert(weather.dayIcon({ openMeteoWeatherCode: 95 }).length > 0, 'weather maps Open-Meteo weather icons')
|
||||
assertEqual(
|
||||
weather.dayIcon({ hourly: [{ time: '900', weatherCode: 113 }, { time: '1200', weatherCode: 389 }, { time: '1800', weatherCode: 116 }] }),
|
||||
weather.iconForCode(389, false),
|
||||
'weather picks hourly forecast icon nearest noon'
|
||||
)
|
||||
JS
|
||||
Reference in New Issue
Block a user